Using std::size from non-const context

Viewed 77

I'm wondering why constexpr functions (std::size in particular) do not work from some non-const context when only type matters.

Let's have a look at two array_size implementations:

  1. The old good c++98
template <typename T, size_t N>
char (&array_size_helper(const T (&)[N]))[N];
#define array_size(a)  sizeof(array_size_helper(a))
  1. Since C++11 you can use constexpr (the following is std::size implementation from GCC-8)
constexpr size_t size(const _Tp (&/*__array*/)[_Nm]) noexcept { return _Nm; }

The second version is nice and perfect except it does not work the same way as the first one does. Since first macro is related on sizeof it only cares about types, while constexpr function is much complicated matter.

Consider an example:

struct A
{
    int a[10];
};

template <typename T, size_t N>
char (&array_size_helper(const T (&)[N]))[N];

# define array_size(a)  sizeof(array_size_helper(a))

int main()
{
    A a;
    A* new_A = reinterpret_cast<A*>(&a);
    static_assert(array_size(a.a) == 10) // OK;
    static_assert(array_size(new_A->a) == 10); //OK
    static_assert(std::size(a.a) == 10); //OK
    static_assert(std::size(new_A->a) == 10); //error: the value of ‘new_A’ is not usable in a constant expression
}

Why is that? Why does std::size care about anything but type? Shouldn't it be re-implemented?

1 Answers

I wrote a whole blog post about this. No, std::size shouldn't be reimplemented.

There's an important difference between the two implementations: with array_size(), everything is in an unevaluated context. Only the types matter, not any of the specific values. array_size() works for any C array type and for no other types.

std::size() on the other hand works on all ranges. But it has to evaluate it's arguments. And when we're doing constant evaluation, there's a strict set of rules we have to follow. One such is that undefined behavior is ill-formed - the compiler must track each and every such access. So when you read through a pointer or reference, the compiler must verify that that read is valid. It's facially weird that std::size(a.a) works but std::size(new_A->a) does not, but consider the differing operations that have to happen in these two cases:

  • for std::size(a.a), we don't ever have to look at a. The member access is just some offset. We bind a reference to that (the parameter of std::size) but the implementation of size() never actually reads that reference. So even though a itself isn't readable in a constant expression, we're not actually doing any reading of it - so this just works.
  • for std::size(new_A->a), the very first thing we have to do is read the value of new_A in order to perform that dereference. But new_A isn't a constant, so we can't read its value during constant evaluation, so we're already done. It doesn't matter that we don't even need the value that we're reading at, we just care about its type in this situation.

This is a fundamental limitation at the moment - in that statically sized ranges you need a type trait (or macro) to get their size as constant expressions, while dynamically sized ranges you need to rely on std::size().

Related