5
\$\begingroup\$

C++23 is going to add multidimensional array subscript operators to the language, yippee!

Alas, I have not yet come across a way to adapt them to types where the number of dimensions is templated, so I came up with this, using variadic templates, fold expression and concepts:

#include <cstddef>
#include <concepts>

// version with static bounds on the dimensions
template <typename T, std::size_t... DIMS>
struct vec {
    template <std::convertible_to<std::size_t>... Is>
    requires (sizeof...(Is) == sizeof...(DIMS))
    T operator[](Is... indices) { return {}; }
};

// version with only the number of dimensions specified (unbounded)
template <typename T, std::size_t D>
struct nvec {
    template <std::convertible_to<std::size_t>... Is>
    requires (sizeof...(Is) == D)
    T operator[](Is... indices) { return {}; }
};

int main() {
    vec<int, 3, 2, 4> foo;
    vec<int, 1, 1> bar;
    vec<int, 91'000> baz;
    vec<int> bung;
    nvec<int, 4> bogget;
    return foo[2, 3, 1] + bar[0, 1] + baz[120] + bung[] + bogget[1, 2, 3, 4];
}

Note that the actual implementation in both cases is stubbed out (I suppose in practice, I will probably use fold expressions to resolve the indices to the correct location in the 1-D array that actually backs the storage for the things, probably with some bounds-checking for the case of vec<> which has static bounds for all dimensions).

I dunno, this feels slightly unwieldy, but this is the most succinct thing I could come up with that allows template-variadic dimensions. There isn't a more concise way now, is there?

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Usually when there's code review here, there are things in the presented code I have opinions against, but nah, this looks fine. Perhaps static_asserts would be clearer than requires if you don't need those for SFINAE and perhaps it'd be preferable to join the two templates into vec somehow. It makes vecs with only one dimension a little trickier for sure though. \$\endgroup\$
    – Ted Lyngmo
    May 28 at 19:28
  • \$\begingroup\$ @TedLyngmo that's an interesting remark about static_assert vs requires —my understanding was that requires is meant to be more idiomatic than the former for expressing template constraints... RE merging them into one template, yes that may be possible (I've done similar for a Matrix type I've written where there's a static-sized and dynamic-sized version, backed by an array and vector respectively). The variadic template params for DIMS do make it awkward though. The best I can think of for disambiguation is a tag type as an additional template arg, not sure if that's better or worse \$\endgroup\$
    – saxbophone
    May 29 at 13:29
  • 1
    \$\begingroup\$ I think of requires as something to simplify SFINAE in place of enable_if etc. but if SFINAE isn't needed, the clarity given by a static_assert is i.m.o. preferable. Re merging: Yes, I was think in the terms of tagging too. I guess one would have to try it out to get a feeling for how well it would work out. \$\endgroup\$
    – Ted Lyngmo
    May 29 at 15:17

1 Answer 1

3
\$\begingroup\$

Yes, this is a perfectly fine way to do it, and it is very concise. It might be instructive to compare this to std::mdspan: its operator[] has a similar overload that takes a parameter pack of indices. However, your code is not doing anything useful. The real question is: how do you convert from multi-dimensional indices into a single offset into the underlying buffer? This should look something like:

template <typename T, std::size_t... DIMS>
struct vec {
    template <std::convertible_to<std::size_t>... Is>
    requires (sizeof...(Is) == sizeof...(DIMS))
    T operator[](Is... indices) {
        // Cheat to be able to use a regular for-loop.
        static constexpr std::array dims_array{DIMS...};
        std::array index_array{indices...};

        std::size_t offset = 0;

        // The compiler should unroll this.
        for (std::size_t i = 0; i < sizeof...(DIMS); ++i) {
             offset *= dims_array[i];
             offset += index_array[i];
        }

        return data[offset];
    }

private:
    static constexpr std::size_t size = (DIMS * ...);
    std::vector<T> data{size};
};

It becomes even more complex if you allow for different strides and/or layouts, like std::mdspan does. This complexity is alledgedly one of the reasons why they decided to postpone the proposed std::mdarray to C++26.

\$\endgroup\$
6
  • \$\begingroup\$ Great reference to std::mdspan, I'd forgotten that made its way into C++23! I really like your use of unpacking the dims and indices into an array for easier traversal. This could be adapted easily into a bounds-checked version vec.at(...). The only thing I would've done differently is use a std::array for the fixed version and a std::vector for the variable-sized version. \$\endgroup\$
    – saxbophone
    May 30 at 12:25
  • \$\begingroup\$ The variable-sized version still has a fixed number of dimensions, so you can still use std::array to hold the sizes and indices. \$\endgroup\$
    – G. Sliepen
    May 30 at 13:41
  • 1
    \$\begingroup\$ Ah right. That's probably fine, but then consider having the name of the class with fixed size contain the word "array", not "vec" or "vector", to avoid surprises for someone who is only familiar with the standard library's containers. One thing to also keep in mind is that multi-dimensional arrays can quickly get very large, and allocating a large amount of data on the stack might overflow it. Of course, if you make your own mdarray class, then the user of it can always use new mdarray(…) to ensure it is allocated on the heap. \$\endgroup\$
    – G. Sliepen
    May 30 at 15:12
  • 1
    \$\begingroup\$ Yeah, I can see how the name might collide. When I named it vec, I was thinking more like the geometry n-ary coördinate-tuple rather than the list-like std::vector... But I think I've got my coördinates mixed up with my containers, as it is in fact a container, more like array or vector..! I am mindful of the limits of stack-allocated arrays, the way I see it —the const-sized version is for when one wants static semantics, which can be useful, but the user should know to use the vector-backed one or explicitly stack-allocate when it's variably or largely sized. \$\endgroup\$
    – saxbophone
    May 30 at 15:23
  • 2
    \$\begingroup\$ Heads up regarding std::vector<T> data{size}; - If T is int (or something that size can convert to) then you'll get one element with the value size instead of size number of default constructed elements. \$\endgroup\$
    – Ted Lyngmo
    May 30 at 15:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.