By Jelle Hellings.
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