0

I'm writing a C++ library that has to externally expose a C API. While doing it, I find myself questioning what is the better way to wrap the C idiomatic pair of (pointer, size). I find myself having a hard time choosing between std::ranges::contiguous_range and std::span.

Where I am right now is that, while contiguous_range is more generic, span seems more natural for this use case. The answer here says that span erases the type of the container, or maybe makes it irrelevant. Which is good for me, as this is embedded and code size matters. Also, I can not use anything that would not compile under GCC 10 (so most, if not all, of C++20 is okay).

Are there any pros or cons to using one over the other in my use case?

A small sample of the two options - both working.

Option 1 - the one I initially went with - is to use std::ranges::contiguous_range, like this:

template<typename T>
concept contiguous_uint8_range =
    std::ranges::contiguous_range<T> && std::is_same_v<uint8_t, std::ranges::range_value_t<T>>;

[[nodiscard]] static inline int read(
    const struct i2c_controller* controller, uint8_t addr_7b, contiguous_uint8_range auto output_buffer
) {
    if (controller->read == nullptr) return I2C_ERROR_NOT_SUPPORTED;
    return controller->read(
        controller->context, addr_7b, std::ranges::size(output_buffer), std::to_address(output_buffer.begin())
    );
}

Option 2 is to use std::span, like this:

template<std::size_t Extent = std::dynamic_extent>
using u8_span = std::span<uint8_t, Extent>;

template<std::size_t Extent = std::dynamic_extent>
[[nodiscard]] static inline int read(
    const struct i2c_controller* controller, uint8_t addr_7b, u8_span<Extent> output_buffer
) {
    if (controller->read == nullptr) return I2C_ERROR_NOT_SUPPORTED;
    return controller->read(controller->context, addr_7b, output_buffer.size(), output_buffer.data());
}
Craig Estey
  • 30,627
  • 4
  • 24
  • 48
jaskij
  • 222
  • 1
  • 7

3 Answers3

2

I find myself having a hard time choosing between std::ranges::contiguous_range and std::span.

That's not a choice. All you're doing is deciding where the template is. span has a template constructor conditioned on contiguous_range (and sized_range, which your version also needs to use). So you're either writing a template function, or you're requiring that the user invoke a template function through span's constructor.

A template is being specialized either way.

I'd pick span because someone else already wrote the code for you. As I noted, your version isn't as good as it doesn't consider that the range may not be a sized_range. Since it's easy to get wrong, it's best to let someone else do it.

Nicol Bolas
  • 449,505
  • 63
  • 781
  • 982
2

Since you're in an embedded context, the std::span version has an advantage for you: std::span<uint8_t> is a concrete type, not a template type (or concept), which means that there will only be one copy of your read function in the code memory of the MCU (unless it gets inlined somewhere). The ranges version may result in a copy of the function being generated whenever you call it with a different range type.

In fact, since any std::span<uint8_t, N> is convertible to a std::span<uint8_t> (with dynamic extent), you could make read a plain function instead of a function template:

[[nodiscard]] static inline int read(
    const struct i2c_controller* controller, uint8_t addr_7b, std::span<uint8_t> output_buffer
) {
    if (controller->read == nullptr) return I2C_ERROR_NOT_SUPPORTED;
    return controller->read(controller->context, addr_7b, output_buffer.size(), output_buffer.data());
}

Since a dynamic-extent std::span contains just a pointer and a size, this is exactly equivalent to the C version of passing separate pointer and size parameters.

Additionally, any contiguous range of uint8_t can be converted to a std::span<uint8_t> as well, which means that the version using ranges has no advantage at all.

Jonathan S.
  • 1,796
  • 5
  • 14
2

You should definitely use std::span, but not the way you're doing.

You just want:

[[nodiscard]] static inline int read(
    const struct i2c_controller* controller, uint8_t addr_7b, std::span<u8> output_buffer);

In this context, you do not care about the actual source type - you just want a view onto it. The original approach using contiguous_range requires a new instantiation for every source type (which you don't need) but more importantly is actively wrong in most use-cases.

u8 c_array[100];
std::array<u8, 100> cpp_array;
std::vector<u8> v;

// this one doesn't compile
read(..., c_array);

// these compile, but you're copying the container
// into the function, and writing into that copy
// the actual containers I'm passing in are unchanged
read(..., cpp_array);
read(..., v);

The advantage of using std::span<u8> (not a template) is that all of these work and do the right thing. If you make it a template, none of these compile and have to instead be explicitly converted into the function - which is utterly pointless syntactic noise, as you basically have no reason of supporting fixed extent.

If you ever find yourself asking the question "contiguous_range or span?" the answer is always span, since the former almost never makes sense in the context where the latter is a viable option.

Barry
  • 286,269
  • 29
  • 621
  • 977