Constant expressions, templates, and incomplete types: A bug in my code?

By .
Posted at 16 September 2024, 9:36.

Recently, I was working on a custom memory allocator that used offsetof in some internal components. Internally, this allocator allocates space for some internal header information, followed by the memory for one-or-more values of type Type, followed by some internal footer information. The header and footer information are both used when memory is freed (deallocated) and to detect improper usage of the allocator or of allocated memory (e.g., double-free, out-of-bound writes, and so on).

To set up the header and the first value of type Type, the allocator internally uses a structure

template<class Type>
struct allocation_header
{
    /* Header information used internally (simplified). */
    int header_fields;

    /* The first allocated value. */
    Type value;
};

When allocating memory for n values of type Type, the allocator reserves memory for a header h of type allocation_header<Type>, followed by memory for the remaining n - 1 values, followed by memory for the footer. Next, the header and footer are initialized, after which a pointer ptr to h.value is returned to the user.

When freeing memory, the user must provide (a copy of) the pointer ptr it received during allocation and is expected to specify the size n of the original allocation. The first step during freeing is to locate the header structure of type allocation_header<Type> (and to locate the footer structure) using only the pointer ptr. To do so, we have to move ptr back by the number of bytes m used to store the header fields of allocation_header<Type>. Unfortunately, this number of bytes m depends on low-level details such as the compiler, the target platform, and the type Type, values to which one does not have easy access without help of the compiler. For this specific low-level purpose, both C and C++ provide the construct offsetof: the value provided by offsetof(allocation_header<Type>, value) is exactly the number of bytes m we are looking for. For this purpose, we use the following function:

allocation_header<Type>* get_header_pointer(Type* ptr)
{
    auto bptr = reinterpret_cast<std::byte*>(ptr);
    auto header_bptr = bptr - offsetof(allocation_header<Type>, value);
    return reinterpret_cast<allocation_header<Type>*>(header_bptr);
}

Likewise, to find the footer based on pointer ptr, we can use offsetof in combination with sizeof and the value n.

The usage of offsetof is very rare. Hence, it is likely that most people working with the code base are unfamiliar with offsetof, what it does, or why it is used in the code base. Hence, some clear documentation was required! As offsetof was used in a few places in the allocator, I decided to centralize its usage into a constexpr constant:

#include <cstddef>

template<class Type>
struct allocation_header
{
    /* Header information used internally (simplified). */
    int header_fields;

    /* The first allocated value. */
    Type value;

    /* Hopefully clear documentation of our usage of @{offsetof}. */
    constexpr static std::size_t value_offset = offsetof(allocation_header, value);
};

All fair and good: the code was documented, everything compiled in GCC g++, my allocator had excellent performance in the intended use cases, and my allocator reliably detected common types of errors (for example, the allocator will detect out of bound writes to an std::vector that uses the allocator to obtain memory).

I typically aim to have my code base work with any modern standard compliant C++ compilers (as far as reasonably possible). Hence, I tested my code base with two other compilers: the Microsoft (R) C/C++ compiler and the Clang compiler. To my surprise, both compilers complained heavily about the constexpr definition of value_offset with error messages indicating the usage of an incomplete or undefined type allocation_header.

As I am not too familiar with offsetof myself, I was unsure whether this was a bug in my code (in which case GCC g++ would be too permissive), whether the other compilers are wrong, or whether something else was going on. Hence, before I looked too much into the issue, I answered two quick sanity check questions. First, can offsetof be used as a constant expression according to the standard? Second, do all three compilers support offsetof as a constant expression? To answer the second question, we can use the following test program:

#include <cstddef>

struct test_struct
{
    int first_field;
    int second_field;
};

/* @{m} must be initialized by a constant expression. */
inline constexpr auto m = offsetof(test_struct, second_field);

int main(int argc, char* argv[])
{
    /* @{m}, when used as an array bound, must be a constant expression. */
    int an_array[m] = {};
}

The above code compiles and works perfectly in all three compilers I am testing with. Likewise, Section [expr.const] of the C++ standard does not exclude the usage of offsetof as a constant expression. Hence, the two sanity check questions have a positive answer.

Second, we can look at whether the usage of offsetof itself is the issue (e.g., do compilers not yet fully support offsetof as a constant expression due to offsetof being rarely used), or whether a similar situation exist with the much-more common sizeof. We test:

#include <cstddef>

template<class Type>
struct sizeof_test
{
    Type value;

    /* Test: replaced @{offsetof} by @{sizeof}. */
    constexpr static std::size_t struct_size = sizeof(sizeof_test);
};


int main(int argc, char* argv[])
{
    sizeof_test<int> x;
}

Same issues: compiles in GCC G++, but not in the Microsoft (R) C/C++ compiler and the Clang compiler. As sizeof as a constant expression is used in many places throughout my code base (and many other code bases) with zero issues, something weird must be going on. We can quickly check whether the fact that the structure is a template plays a role via the following test program:

#include <cstddef>

struct sizeof_test_ntpl
{
    int value;
    constexpr static std::size_t struct_size = sizeof(sizeof_test_ntpl);
};


int main(int argc, char* argv[])
{
    sizeof_test_ntpl x;
}

Interestingly, this does not compile in any compiler for a clear reason: when we apply sizeof(sizeof_test_ntpl), the size of the structure sizeof_test_ntpl is not yet known, as the type sizeof_test_ntpl is not yet complete. This is in line with the C++ standard, which requires in Section [expr.sizeof] that sizeof shall not be applied to an incomplete type. As such, the error messages are very clear: use of an incomplete or undefined type sizeof_test_ntpl (which is the same error message we got for allocation_header). Likewise, the C++ standard refers to the C standard for the definition of offsetof, and my best interpretation of the C standard seems to also require that the type argument of offsetof is a complete type.

To see that the restriction to a complete type is a reasonable restriction, we can consider the following invalid code:

#include <cstddef>

struct sizeof_invalid
{
    int value;
    constexpr static std::size_t invalid_size = sizeof(sizeof_invalid);
    int extra_values[invalid_size];
};


int main(int argc, char* argv[])
{
    sizeof_invalid x;
}

Note that the type sizeof_invalid can never be valid, as there is a circular dependency between the definition of invalid_size and extra_values: the value of invalid_size depends on the size of extra_values, which itself depends on invalid_size. Note that upon the definition of any constexpr constant, any future code (including within the same structure) can depend on that constant. Hence, it is reasonable to require that sizeof requires a complete type. As such, it is reasonable that sizeof_invalid and sizeof_test_ntpl do not compile

So what about the templates sizeof_test and allocation_header? Clearly, the following templated version of sizeof_invalid should not compile:

#include <cstddef>

template<class Type>
struct sizeof_invalid_tpl
{
    Type value;
    constexpr static std::size_t invalid_size = sizeof(sizeof_invalid_tpl);
    int extra_values[invalid_size];
};


int main(int argc, char* argv[])
{
    sizeof_invalid_tpl<int> x;
}

During instantiation of the template sizeof_invalid_tpl<int>, the member variable extra_values is instantiated, which requires instantiation of the constant invalid_size, which requires a complete type sizeof_invalid_tpl<int>, which does not exist at this point. As such, sizeof_invalid_tpl<int> does not compile in any of the three compilers.

Instantiation of a template does not require instantiation of all members, however: only those parts that are used need to have valid definitions (Section [temp.inst]). In specific, the initialization of a static member does not occur unless the static data member is itself used in a way that requires definition. The instantiation of the types allocation_header<Type> and sizeof_test<int> do not require instantiation of the constants value_offset and struct_size, respectively. Hence, any usage of the constants value_offset and struct_size after the instantiation of these templates is well-formed: in these cases, the static member can be defined as they are used after allocation_header<Type> and sizeof_test<int> are defined.

Hence, we can only conclude that the Microsoft (R) C/C++ compiler and the Clang compiler are wrong in this case. We note that this issue has been fixed in recent versions of the Clang compiler (as version 16 and newer of the Clang compiler accepts the two valid type template).

Conclusion: my original code was correct. To ensure my allocator can compile using all three compilers, I have put the logic involving offsetof into a static member function together with sufficient documentation to explain each step of this function, which was sufficient to work around the limitations of the Microsoft (R) C/C++ compiler and older Clang compilers (e.g., the ones available in XCode).

Join the discussion