The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal

std::span, the missing constructor


7 - 9 minutes read, 1796 words
Categories: c++
Keywords: c++ std::span

std::span is a view over a contiguous sequence of objects, it does not own the memory where the objects are located.

What makes span different from other containers of the standard library, is that it does not own the content, it provides an API to work as if it was in a container.

It is such a simple concept, and yet so useful, it is unbelievable that it was added in the standard library only in C++20. Fortunately, it is easy to backport to any C++ revision (some features might be missing), I recommend you to do so if working with an older C++ standard.

But back to my problem.

I had the following interface

void foo(const std::vector<int>& v);

As the parameter is passed by const-reference, and foo does not need the ownership of the data, using std::vector is suboptimal.

  • What if the caller of foo has its data stored in another container?

  • What if he is using a std::vector with a different allocator?

  • What if the data is on the stack?

In all those situations, with the given interface, the user needs to allocate memory and copy the values we want to read.

This is just as bad as using a smart pointer, where a naked pointer would suffice.

The standard library avoided this issue thanks to the iterator concept, which provided a single API for dealing with containers.

The downside is that every algorithm needs to be templated, and this can lead to code bloat, and also increase compile-times.

It also makes it hard, if not impossible, to "hide" an algorithm and provide precompiled binaries, as the implementation, or at least part of it needs to be written in the header.

But for many use-cases, providing algorithms for common use-cases, or the most used containers is sufficient. It makes little sense, for example, to provide a UTF-8 validator that also works on std::set. Supporting contiguous containers like std::string_view std::vector, an array, std::array or a pointer and a length would cover all conceivable use-cases (whos stores strings in a std::list?).

Using std::span fixes the problem of the ownership, it does not matter where the data lives, just like when we pass a single value (per pointer, value, or reference) to a function, it does not matter if it lives inside a container, on the heap or the stack.

As long as the data is stored sequentially, the user can store the data where it wants without additional copies. It can be in a vector with any type of allocator, on the stack, or in an array.

While it is not as generic as an iterator, it is much more generic than using vector, and it still permits writing a non-templated function.

But there is one drawback.

While all sequential containers (std::string, std::string_view std::vector, an array, std::array, …​) can be converted to a span, the same does ot hold for an rvalue of std::intializer_list.

I noticed this issue as I had code similar to

foo({1,2,3});

and it did not compile after changing the function signature of foo.

While following snippets do not need any adjustments

std::vector<int> vec;
foo(vec);

std::vector<int> g();
foo(g());

int data[] {....}
foo(data);

std::initializer_list<int> data;
foo(data);

Why would the authors of std::span remove the possibility to construct it from std::initalizer_list?

The standardization process is open, and papers are public, but I could not find anything about std::initalizer_list.

Searching here and there I could only find two reasons

The first one is for avoiding dangling references.

Consider the following snippet

std::span<const int> si = {1,2,3};

You have constructed a dangling std::span, accessing its elements is UB. Probably the only sane operation one can do is querying the size, which is not really much.

While it might seem thus a good feature prohibiting such code to compile, it is highly inconsistent with all other constructors.

std::span<const int> si = std::vector<int>{1,2,3};

This also creates a dangling std::span, but the code compiles as one would expect. There are too many valid use-cases for permitting constructions from rvalues. Prohibiting them would make std::span much more cumbersome to use.

This happens to be so because value category is not lifetime, thus removing std::initializer_list does not fix any issue, we have no benefits but all drawbacks.

The second argument I could find is that std::initalizer_list is not a "real" container.

While it might be true, std::initalizer_list is used for initializing all owning containers (except arrays, but that’s another story), and also has an API similar to other containers.

Thus it can be used as a container and satisfies all properties for working with std::span.

Also, notice that initializing from lvalue works, so this argument is just bogus, I should not even have mentioned it:

std::initializer_list<int> il{1,2,3};
std::span<const int> si = il;

Possible solution

Now that I have found one possible (and unsatisfying) reason for the absence of such a constructor, let’s see what are the possible solutions.

dummy array

The first is creating a dummy array (a dummy std::initializer_list would work too). The code would look like

int dummy[]{1,2,3};
foo(dummy);

While it works, it is not as good as the original API. Naming is hard, and the lifetime of the objects passed to foo is much bigger. Of course for an array of int this does not matter, but for a more complex type, it could make some difference.

Unless one introduces also a new scope:

{
    int dummy[]{1,2,3};
    foo(dummy);
}

But then we have quadruplicated the amount of lines of code.

A "better" std::span

Another solution is implementing another class that mimics std::span and adds the missing constructor, but it would make code less interoperable, so I’m not even considering it.

Fix std::span

This means writing a paper. This would be a very long-term solution, but I need something else in the meantime.

Use a temporary array

Creating a temporary array is very counterintuitive, thus difficult to maintain when working with other people.

Long story short, the correct syntax is

template<typename T>
using raw_array = T[];

foo(raw_array<int>{1,2,3});

Generally using std::array would be easier to understand/maintain, but requires to spell out the size

foo(std::array<int,3>{1,2,3});

unless using at least C++17

foo(std::array{1,2,3});

But it is not possible to deduce only the size, and not the type. From this point of view, a naked array seems to be a superior solution.

Note while std::span has been standardized after C++17, it is easy to backport to previous standards. And for the sake of compatibility, adding additional constructors makes it more difficult to replace such custom span with std::span when upgrading to a newer version of the standard.

Factory functions

For types we do not own, it is not possible to add constructors or conversion operators. But we can extend the API of any type with free functions, in this case, a factory function will do the job.

template <class T, int N>
constexpr std::span<const T> as_span(const T (&arr)[N]) noexcept {
    return std::span<const T>(arr, N);
}

void foo(std::span<const int>);


void bar(){
    foo(as_span({1,2,3}));
}

Wait…​ as_span does not use std::initializer_list!

True, it uses an array.

As the syntax of std::initializer_list and an array, in this context, are identical, by using an array we have covered both types.

The advantage is that if we need to add overloads for other containers, in case we have some custom containers and want to unify the situation for generic code, we can write something like

#include <span>

template <class P>
constexpr auto as_span_impl(P* p, std::size_t s) {
    return std::span<P>(p, s);
}

template <class C>
auto as_span(C& c) {
    return as_span_impl(std::data(c), std::size(c));
}
template <class C>
auto as_span(const C& c) {
    return as_span_impl(std::data(c), std::size(c));
}

template <class T, int N>
constexpr auto as_span(T (&arr)[N]) noexcept {
    return as_span_impl(arr, N);
}
template <class T, int N>
constexpr auto as_span(const T (&arr)[N]) noexcept {
    return as_span_impl(arr, N);
}

void foo(std::span<const int>);


foo(as_span({1,2,3}));

There are some subtleties, as both std::initializer_list and naked arrays are special snowflakes in the C++ language.

At first, it seems to be enough to provide

#include <span>

template <class P>
auto as_span_impl(P* p, std::size_t s) {
    return std::span<P>(p, s);
}

template <class C>
auto as_span(C&& c) {
    return as_span(std::data(c), std::size(c));
}

Because it works for containers like std::string, arrays, …​ but it fails miserably for {1,2,3}.

{1,2,3} does not have any type, it’s not std::initializer_list and it’s not an array! With auto it gets deduced as a std::initializer_list, but it’s a special rule for auto.

Now it’s obvious why template type deduction fails, {1,2,3} has no type.

I could not find the rationale, but I hope it is better than the one I could find for std::span constructor. I suspect it has to do with the "uniform initialization movement".

One possible workaround is either writing

foo(std::initializer_list<int>(1,2,3));

which hurts my eyes (at least we do not have to write the size…​), or add a templated overload for std::initializer_list.

At that point, I rather add the overloads for arrays, and all use-cases should be covered.

And finally, I can write

foo(as_span({1,2,3}));

Which does still require me to change all places where foo is called with a temporary, but at least it has the same meaning as before.

Note in case of an empty container, it is possible to write foo({}); instead of foo(as_span({}));. This is because {} would call the default std::span constructor (because of uniform initialization), instead of a span with an empty std::initializer_list or array (if those would exist), which has the same effect. While it is nice, it makes me wonder why we have ended with so many inconsistencies for a such simple abstraction.

Do you want to share your opinion? Or is there an error, same parts that are not clear enough?

You can contact me here.