DEV Community

Pierre Gradot
Pierre Gradot

Posted on • Edited on

Let's try C++20 | std::span

Eventually, I have decided to try C++20 😀

Let's start with std::span!

What is a Span?

Here is the definition given by cppreference:

The class template span describes an object that can refer to a contiguous sequence of objects with the first element of the sequence at position zero.

Spans are sometimes called "views" because they don't own the sequence of objects.

If you are familiar std::string_view from C++17, then you can easily understand the concept of spans. std::string_views are somehow spans on sequences of characters. The main difference is that spans can modify the objects while string views can't modify the caracters.

In the following, I will try to use std::spans in various situations to understand how they work and how they can be useful for future code.

With Plain Arrays

I personally use a lot of plain arrays because I work with on embedded systems where those C-style arrays are widely used. Why? Because dynamic allocation is forbidden (so std::vectors are not an option) and because there is a lot of C code.

When you pass a plain array to a function, it is decayed to a pointer to its first element and its size is lost.
std::spans sound like a great solution to safely pass around plan arrays to functions.

First, let's write a template function that prints any span:

#include <iostream>
#include <span>

template<typename T, std::size_t length>
void print(std::span<T, length> span) {
    for(auto i : span) {
        std::cout << i << ' ';
    }
    std::cout << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Then, let's create a span from a plain array:

int main() {
    int data[] = {1, 2, 3, 4, 5};
    print(std::span{data});
}

Enter fullscreen mode Exit fullscreen mode

This code outputs:

1 2 3 4 5
Enter fullscreen mode Exit fullscreen mode

The size of the array is deduced from its type and stored in the std::span object.

With a Pointer and a Size

If you are already in a function where the original plain array has been "transformed" to pointer + size, you can create a span from them. Of course, you cannot be sure it maps the original plain array:

void function(int* pointer, unsigned int size) {
    printf("> ");
    print(std::span{pointer, size});
}

int main() {
    int data[] = {1, 2, 3, 4, 5};

    function(data, 0);
    function(data, 2);
    function(data, 5);
    function(data, 10);
}
Enter fullscreen mode Exit fullscreen mode

Output:

> 
> 1 2 
> 1 2 3 4 5 
> 1 2 3 4 5 32766 0 0 4199488 0
Enter fullscreen mode Exit fullscreen mode

The last span is reading outside of the array, which is clearly an undefined behavior. You are responsible for creating the span with a valid sequence.

The form (2) of std::span's constructors is used here:

template< class It >
explicit(extent != std::dynamic_extent)
constexpr span( It first, size_type count );
Enter fullscreen mode Exit fullscreen mode

With std::arrays or std::vectors

You can also easily create spans from std::arrays or std::vectors:

int main() {
    std::array a = {1, 2, 3, 4, 5};
    std::vector v = {6, 7, 8, 9, 10};

    print(std::span{a});
    print(std::span{v});
}
Enter fullscreen mode Exit fullscreen mode

Output:

1 2 3 4 5
6 7 8 9 10
Enter fullscreen mode Exit fullscreen mode

Obviously, constructor (5) is used for the array:

template< class U, std::size_t N >
constexpr span( std::array<U, N>& arr ) noexcept;
Enter fullscreen mode Exit fullscreen mode

But which one is used for the vector?

It took me a while to understand that form (7) is used:

template< class R >
explicit(extent != std::dynamic_extent)
constexpr span( R&& r );
Enter fullscreen mode Exit fullscreen mode

This is because a vector is a range :

#include <iostream>
#include <span>
#include <ranges>
#include <vector>

int main() {
    std::vector v = {1, 2, 3, 4, 5};
    auto r = std::ranges::range<decltype(v)>;
    std::cout << std::boolalpha << r;
}
Enter fullscreen mode Exit fullscreen mode

std::ranges::range is a concept, a new feature of C++20. Concepts are one of the next features I will try.

With a part of an std::array or std::vector

You can't directly create a span over a part of the array or the vector. For instance:

int main() {
    std::array a = {1, 2, 3, 4, 5};
    print(std::span{a, 2});
}
Enter fullscreen mode Exit fullscreen mode

doesn't compile because of the following error:

error: no viable constructor or deduction guide for deduction of template arguments of 'span'

The same error occurs with an std::vector.

The trick is to get an iterator/pointer from the container. For instance:

int main() {
    std::array a = {1, 2, 3, 4, 5};

    print(std::span{a.data(), 2});
    print(std::span{a.cbegin() + 2, a.cbegin() + a.size()});
}}
Enter fullscreen mode Exit fullscreen mode

Output:

1 2
3 4 5
Enter fullscreen mode Exit fullscreen mode

Here, constructors (2) and (3) are used:

// (2)
template< class It >
explicit(extent != std::dynamic_extent)
constexpr span( It first, size_type count );

// (3)  
template< class It, class End >
explicit(extent != std::dynamic_extent)
constexpr span( It first, End last );
Enter fullscreen mode Exit fullscreen mode

Subspans

It is possible to create subspans, which are spans on a part of a span. There are 3 member functions to do that:

  1. first(): obtains a subspan consisting of the first N elements of the sequence
  2. last(): obtains a subspan consisting of the last N elements of the sequence
  3. subspan(): obtains a subspan

Example:

int main() {
    std::array data = {1, 2, 3, 4, 5};

    auto span = std::span{data};
    print(span);

    print(span.first(3));
    print(span.last(3));
    print(span.subspan(1, 3));
}
Enter fullscreen mode Exit fullscreen mode

Output:

1 2 3 4 5 
1 2 3 
3 4 5 
2 3 4 
Enter fullscreen mode Exit fullscreen mode

Modify Objects in the Span

It is possible to modify the objects the span refers to:

template<typename T, std::size_t length>
void change(std::span<T, length> span) {
    std::transform(span.begin(), span.end(), span.begin(), std::negate());
}

int main() {
    std::array data = {1,2,3,4,5};

    print(std::span{data});
    change(std::span{data});
    print(std::span{data});
}
Enter fullscreen mode Exit fullscreen mode

Output:

1 2 3 4 5 
-1 -2 -3 -4 -5 
Enter fullscreen mode Exit fullscreen mode

Note than std::span doesn't have cbegin() and cend() as iterator functions. It only has begin() / end() / rbegin() / rend().

Nevertheless, if we declare data as const std::array data = {1,2,3,4,5};, then the code won't compile. Here is an example where we try to modify a const array using a span:

int main() {
    const std::array data = {1,2,3,4,5};
    auto s = std::span{data};
    s[0] = 42;
}
Enter fullscreen mode Exit fullscreen mode

This doesn't compile:

error: assignment of read-only location 's.std::span<const int, 5>::operator[](0)'

Static vs Dynamic Extent

The size of the sequence of objects can be static or dynamic, as stated in the overview of the class:

A span can either have a static extent, in which case the number of elements in the sequence is known at compile-time and encoded in the type, or a dynamic extent.

If a span has dynamic extent a typical implementation holds two members: a pointer to T and a size. A span with static extent may have only one member: a pointer to T.

Let's create a function to print the extent of spans:

template<typename T, std::size_t length>
void print_extent(std::span<T, length> span) {
    std::cout << std::hex << "0x" << span.extent << '\n';
}

int main() {
    std::array array = {1,2,3,4,5};

    auto span = std::span{array};
    print_extent(span);

    print_extent(span.subspan(1, 3));

    std::vector<int> vector;
    print_extent(std::span{vector});

    int c_array[] = {42};
    print_extent(std::span{c_array});
}
Enter fullscreen mode Exit fullscreen mode

Output:

0x5
0xffffffffffffffff
0xffffffffffffffff
0x1
Enter fullscreen mode Exit fullscreen mode

Those 0xffffffffffffffff do look like std::dynamic_extent.

A dynamic extent really means that the size of the span changes along with the size of the sequence. Here is an example where we push items to a vector and look how the size of the span evolves (we know from the previous example it has dynamic extent):

template<typename T, std::size_t length>
void print(std::span<T, length> span) {
    std::cout << "Size = " << span.size() << " --> ";

    for(auto i : span) {
        std::cout << i << ' ';
    }

    std::cout << '\n';
}

int main() {
    std::vector<int> vector;
    print(std::span{vector});

    vector.push_back(42);
    print(std::span{vector});

    vector.push_back(66);
    print(std::span{vector});
}
Enter fullscreen mode Exit fullscreen mode

Output:

Size = 0 --> 
Size = 1 --> 42 
Size = 2 --> 42 66 
Enter fullscreen mode Exit fullscreen mode

Non-Template Code with Dynamic Extent

A span with a static extent can be converted implicitly to span with a dynamic extent:

#include <iostream>
#include <span>

int main() {
    char buffer[] = "hello, world";

    std::span<char, 13> static_span{buffer};
    std::span<char> dynamic_span{static_span};

    std::cout << std::boolalpha;

    std::cout << (static_span.extent == std::dynamic_extent) <<  '\n';
    std::cout << (dynamic_span.extent == std::dynamic_extent) <<  '\n';
}
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
Enter fullscreen mode Exit fullscreen mode

Why is this really interesting? Because we can rely on this conversion to write non-template functions that take spans of any size as their parameters.
This is something we cannot do with std::arrays.
Compare these 2 functions that prints arrays:

#include <array>
#include <iostream>
#include <span>

void print(std::span<char> span) {
   for(auto c : span) std::cout << c;
   std::cout << '\n';
}

template<std::size_t size>
void print(std::array<char, size> array) {
   for(auto c : array) std::cout << c;
   std::cout << '\n';
}

int main() {
    std::array data{'h', 'e', 'l', 'l', 'o'};
    print(data);
    print(data);
}
Enter fullscreen mode Exit fullscreen mode

Both of course output "hello". Because the first one is non-template, it can be simply declared in a *.h and defined in a *.c.
This is not possible of course for the second one.
But I think you already know that "issue" with templates, right? 😅

sizeof()

And finally, because I am an embedded software developer and I care about footprint, let's take a look at the size of a span:

int main() {
    std::vector<int> vector;
    auto sv = std::span{vector};
    std::cout << sizeof(sv) << '\n';

    std::array<int, 10> array;
    auto sa = std::span{array};
    std::cout << sizeof(sa) << '\n';

    int c_array[10];
    auto sca = std::span{c_array};
    std::cout << sizeof(sca) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Output:

16
8
8
Enter fullscreen mode Exit fullscreen mode

I didn't test the ROM footprint because I don't have a C++20 compatible embedded compiler on my computer yet.

Conclusion

OK, spans look amazing 😍

I believe they will help write me better code, mainly when I deal with plain arrays I get from C code. Spans will provide a good solution (safe, standard, portable) to carry the size of the data along with a pointer to them. They will probably be a nice bridge between the C and the C++ words.

Top comments (0)