std::span should have a converting constructor from initializer_list
std::span should have a converting constructor from initializer_listUPDATE, 2023-12-28: This blog post describes a feature that was missing from C++20 and C++23; but P2447 has been adopted for C++26, so if you try these examples in C++26 you’ll find that they all work fine, and that’s a good thing.
C++17 introduced std::string_view as a “parameter-only” drop-in replacement for const std::string&. This allows us to make clean refactorings such as:
// C++14 void f(const std::string&); void test() { std::string s = "hello"; f(s); f("world"); } // C++17 void f(std::string_view); void test() { std::string s = "hello"; f(s); // OK f("world"); // OK } C++20 introduces std::span<[const] T> as a “parameter-only” drop-in replacement for [const] std::vector<T>&. This allows us to make clean refactorings such as:
// C++17 void f(const std::vector<int>&); void test() { std::vector<int> v = {1, 2, 3}; f(v); f({1, 2, 3}); } // C++20 void f(std::span<const int>); void test() { std::vector<int> v = {1, 2, 3}; f(v); // OK f({1, 2, 3}); // ...error?? } You read that right: f({1, 2, 3}) compiles when f takes const vector<int>&, but it fails to compile when f takes span<const int>. The problem isn’t that span can’t be constructed from initializer_list: it can.
static_assert(std::is_convertible_v<std::initializer_list<int>, std::span<const int>>); The problem is that span’s templated constructor can’t deduce initializer_list. The rules of C++ are such that a function taking initializer_list will happily match a braced-initializer-list like {1, 2, 3}, but a function taking simply T&& will never deduce [with T=initializer_list<int>]. If you want to be constructible from a braced-initializer-list, you must provide a converting constructor specifically from initializer_list<T>. And span (as of C++20) fails to do so.
Notice that we can write any of
void f(std::span<const int>); f(std::vector{1, 2, 3}); // conforming f(std::array{1, 2, 3}); // conforming f((int[]){1, 2, 3}); // invalid: GCC and Clang only f(std::initializer_list{1, 2, 3}); // invalid: GCC and MSVC only f(std::initializer_list<int>{1, 2, 3}); // conforming But if we’re looking for something we can drop in as a replacement for const vector<int>& function parameters — the way string_view drops in for const string& — well, span<const int> doesn’t quite fit the bill… yet.
Federico Kircheis has drafted a proposal for C++23 to fix this problem. It should appear in an upcoming mailing as paper number P2447. I’ve done a quick reference implementation for libc++, which you can find here, and you can play with it on Godbolt Compiler Explorer here.
But… dangling?
When string_view was adopted as the parameter-only replacement for const string&, we suffered through a few years of people worrying about the proverbial newbie writing dangling-reference bugs like
std::string getString() { return "hello"; } std::string_view sv = getString(); std::cout << sv; // UB, dangling pointer, possible segfault But this was not a problem in practice, because we simply teach that string_view is a parameter-only type. You use it in function parameter lists for the express purpose of avoiding unnecessary string constructions, in a function that would ordinarily take const string&.
span is the same way. During its standardization, we suffered a little bit from people worrying about things like (Godbolt)
std::vector<int> getVector() { return {1, 2, 3}; } std::span<const int> sp = getVector(); for (int i : sp) std::cout << i; // UB, dangling pointer, possible segfault But this was not a problem in practice (and was less of a problem in the Committee this time around, as far as I know), because people were already familiar with the notion of “parameter-only types” and how they are meant to be used. Obviously any reference-semantic type can dangle if it’s misused; but string_view and span have clearly delineated use-cases, and “put an rvalue into a local variable” is not one of them.
“Put an rvalue into a function parameter,” on the other hand, is explicitly the use-case for both string_view and span. So it’s important that we preserve their ability to bind to rvalues. See “Value category is not lifetime” (2019-03-11).
Incidentally, notice that function-parameter-passing is not one of the use-cases for
std::reference_wrapper; therefore it does not provide construction from rvalues:void f(std::reference_wrapper<const int>); int i = 42; f(i); // OK f(42); // errorThere’s never any reason to take a function parameter of type
reference_wrapper<const T>, because you could just take a nativeconst T&instead. Soreference_wrapperhas no vested interest in binding to rvalues.
So, if you’re worried about code like this…
std::string_view sv = "hello"s; std::cout << sv; // UB, possible segfault std::span<const int> sp = {1, 2, 3}; // C++20: invalid; proposed: OK for (int i : sp) std::cout << i; // UB, possible segfault …don’t be worried. People know that local variables of parameter-only types are in danger of dangling; and if they don’t know yet, well, we’ll teach them!
Also, perhaps I should point out that with double braces, this already compiles! The following code simply calls span’s converting constructor from int (&)[N], deducing N as 3 in this case.
std::span<const int> sp = {{1, 2, 3}}; // OK for (int i : sp) std::cout << i; // UB, possible segfault This constructor does change some code’s behavior (for the better)
As with any change to the fragile monolith that is C++, adding this converting constructor would break some (arguably pathological) code. Consider the following C++17 program:
struct Sink { Sink() = default; Sink(Sink*) {} }; void countElements(const std::vector<Sink>& v) { return v.size(); } Sink a[10]; int main() { std::cout << countElements({a, a+10}) << '\n'; } You might think that the implicit constructor Sink(Sink*) is extremely contrived; but in fact two real-world types resembling Sink are void* (implicitly convertible from void**) and std::any (implicitly convertible from std::any*).
Obviously, the program above prints 2, because the vector passed to countElements is of size two. Now we upgrade it to C++20 in the “obvious” way:
void countElements(std::span<const Sink> v) { return v.size(); } Sink a[10]; int main() { std::cout << countElements({a, a+10}) << '\n'; } And suddenly it prints 10! Although {a, a+10} prefers to be interpreted as an initializer_list, it can (when no initializer_list constructor is present) be interpreted as an implicit conversion from two arbitrary arguments: in this case, an iterator pair.
In C++20 today, span<const Sink>{a, a+10} is a span of length 10. If we add this proposed constructor, though, then {a, a+10} will get its preferred interpretation as an initializer_list, and so both programs above will have the same behavior: {a, a+10} will uniformly be treated as a range of length 2, regardless of whether it’s taken by const vector& or by span. In a sense, C++20 “broke” this code, and the proposed new constructor “restores” the expected behavior.
Dangling array case is slightly altered
std::span<const int> sp1 = {{1, 2, 3}}; // OK, dangles std::span<const int, 3> sp2 = {{1, 2, 3}}; // C++20: OK, dangles. Proposed: error In C++20, these initializations pick up the constructor from int(&)[3], which is non-explicit in both cases. After the proposal, these initializations pick up the constructor from initializer_list<int>, which is non-explicit in the first case (so nothing changes) but explicit in the second case (so it will no longer compile).
Implementation notes
The proposed constructor is of the form
template<class E> struct Span { using V = remove_cv_t<E>; Span(initializer_list<V>) requires is_const_v<E>; }; Prior to C++20, we would have had to implement this constructor with awkward metaprogramming. Because it is not a constructor template, enable_if_t cannot be used to disable this declaration. So probably we’d put it into a base class, like this:
template<class E, bool IsConst = is_const_v<E>> struct SpanBase {}; template<class E> struct SpanBase<E, true> { SpanBase(initializer_list<remove_cv_t<E>>); }; template<class E> struct Span : SpanBase<E> { using V = remove_cv_t<E>; using SpanBase<E>::SpanBase; }; In C++20, though, we can just use a simple requires-clause to express the constraint. This is nice.
Here’s another thing I noticed while doing my implementation. You might think that the member-initializers would be simply
Span(initializer_list<V> il) requires is_const_v<E> : data_(il.data()), size_(il.size()) {} (mirroring the converting constructor from std::array). However, you’d be in for a nasty surprise:
initializer_listhas no member functiondata()!
Instead, you must use one of il.begin(), std::begin(il), or std::data(il) to get the pointer to its contiguous data.
This discrepancy was noticed and proposed-to-be-fixed in Daniil Goncharov and Antony Polukhin’s P1990R1 “Add operator[] and data() to std::initializer_list” (May 2020). However, since the authors were more interested in the “operator[]” part of the paper than the “data()” part (in fact data() wasn’t even part of P1990R0), and LEWG (rightly) wasn’t interested in giving initializer_list an operator[], the paper was abandoned without ever getting il.data() working.
See also Federico’s own blog post:
- “
std::span, the missing constructor” (Federico Kircheis, June 2021)
