Layout of structs¶
The Basics¶
You’ve already come across built in C++ data types like float
, int
, and double
. These data types are really just the beginning. C++ (and basically every other language) lets you create more abstractions on top of these or even create your own data types. The means through which one can accomplish this is through struct
s, so lets dive into what these are.
When we talk about struct
s in C++, we are simply referring to some user defined collection of zero or more objects. struct
is a keyword in C++, so thankfully, it is be pretty obvious when you see one!
#include <iostream>
struct MyEmptyCollection
{
// This can totally be empty and that's okay!
};
struct MyCollection
{
// We can not only have multiple things, but also things of different types in here
float x;
float y;
float z;
int count;
};
struct MyBigCollection
{
// Just like how we can include build in types, we can include types we create as well
int ref_id;
MyCollection collection;
};
// We can even wrap structs with other structs
struct MyBigCollectionWrapper : MyBigCollection
{
int extra_field;
};
// Last but not least, structs can even have functions inside them!
struct Parrot
{
unsigned int age;
void print_age() { std::cout << age << std::endl; }
};
So it seems like we can do a bunch of stuff with structs which is awesome!
Note
You may have heard or come across these things called class
es in C++ and may have even noticed that they look awfully similar to structs. The only real difference between the two is that struct
s default to public access whereas class
es default to private. That’s it. That’s the only difference. You can do pretty much anything with structs as you can with classes and vice versa. It’s just what the default access approach is.
struct MyStruct
{
float value; // Anyone can access this!
};
class MyClass
{
float value; // This can only be accessed from within the class!
};
For more information, feel free to check out: cppreference::struct
Data and Memory Layout¶
Simple structures¶
// We recommend being explicit about the data size types when possible.
// So if you want a 64 bit integer, use int64_t or uint64_t.
// If you want 8 bit integer, then use int8_t or uint8_t.
// The reason for this is that int and unsigned int are system and archtecture dependent.
// This will cause portability issues if the int on one machine has 32 bits but 64 bits on another.
// In order to accomplish this, we need to import the <cstdint> header
// - this is where the fixed width integer types hang out
#include <cstdint>
struct ZeroElements
{
};
struct OneElement
{
uint64_t value;
};
struct TwoElements
{
uint64_t first;
uint64_t second;
};
struct ThreeElements
{
uint64_t first;
uint64_t second;
uint64_t third;
};
So above we have 4 struct
s of different sizes.
Note
Quick refresher on bits: 8 bits = 1 byte 32 bits = 4 bytes 64 bits = 8 bytes We will generally talk about things in terms of bytes (and so will your programs) so it’s good to get used to thinking in those terms.
You may see us use the term word
sometimes. On a 64 bit system, a word will be 8 bytes and on a 32 bit system, a word will be 4 bytes.
If we were to take a look at this above, we’d probably expect the struct ZeroElement
to take up 0 space on our computer since it doesn’t actually need to take up any storage. Similarly, we’d expect the struct OneElement
to take up 8 bytes (64 bits), TwoElements
to take up 16 bytes (128 bits), and ThreeElements
to take up 24 bytes (192 bits).
But never blindly trust us on it. Let’s make sure it is true! In C++, in order for us to figure out the size of a struct, we can use the handy tool sizeof
which tells us the size of a struct in bytes.
#include <iostream>
std::cout << "Size of ZeroElements: " << sizeof(ZeroElements) << " bytes" << std::endl;
std::cout << "Size of OneElement: " << sizeof(OneElement) << " bytes" << std::endl;
std::cout << "Size of TwoElements: " << sizeof(TwoElements) << " bytes" << std::endl;
std::cout << "Size of ThreeElements: " << sizeof(ThreeElements) << " bytes" << std::endl;
Size of ZeroElements: 1 bytes
Size of OneElement: 8 bytes
Size of TwoElements: 16 bytes
Size of ThreeElements: 24 bytes
Wait a second… why does the ZeroElements
struct have size 1? Why does it need to take up a byte of space when it literally has no data?
This is a little C++ quirk to ensure that two different objects will have two different addresses.
Note
If you want to get up to speed on what we mean by an address, or perhaps how the computer memory is laid out, check out Memory Layout.
Lets go over a quick example so it’s more obvious what we mean by that and why it matters.
Lets say we create two instances of our ZeroElements
struct:
ZeroElements empty_first{};
ZeroElements empty_second{};
Now let’s go ahead and take the address of the two objects - aka, where is it object located in memory?
ZeroElements* pointer_to_first = &empty_first;
ZeroElements* pointer_to_second = &empty_second;
Let’s say we had a function that looks something like:
template <typename T> // The template bit just lets us be lazy about the type we pass in so
constexpr bool is_same(const T* lhs, const T* rhs) { return lhs == rhs; }
What we would want to be able to do is:
assert(is_same(pointer_to_first, pointer_to_first) == true);
assert(is_same(pointer_to_second, pointer_to_second) == true);
assert(is_same(pointer_to_first, pointer_to_second) == false);
If the size of ZeroElements
was not 1 byte, then we wouldn’t have any physical place in memory for it to reside. If there is no unique place for it to reside, then there is nothing unique about one instance over another. Which means you wouldn’t be able to assert the above!
If you still don’t trust us, (and you never should), lets just see exactly where they hang out in memory:
std::cout << "Memory address of empty_first: " << static_cast<void*>(pointer_to_first) << std::endl;
std::cout << "Memory address of empty_second: " << static_cast<void*>(pointer_to_second) << std::endl;
Memory address of empty_first: 0x7f4af9da5038
Memory address of empty_second: 0x7f4af9da5039
Note
If you want to learn more about casting operations like static_cast
, check out: Casting
Ay, neat! They do indeed have different memory addresses. Just to show you that it is not just printing different memory addresses on each run:
std::cout << "Addr of empty_first: " << static_cast<void*>(pointer_to_first) << std::endl;
std::cout << "Addr of empty_first: " << static_cast<void*>(pointer_to_first) << std::endl;
std::cout << "Addr of empty_second: " << static_cast<void*>(pointer_to_second) << std::endl;
std::cout << "Addr of empty_first: " << static_cast<void*>(pointer_to_first) << std::endl;
std::cout << "Addr of empty_second: " << static_cast<void*>(pointer_to_second) << std::endl;
std::cout << "Addr of empty_second: " << static_cast<void*>(pointer_to_second) << std::endl;
Addr of empty_first: 0x7f4af9da5038
Addr of empty_first: 0x7f4af9da5038
Addr of empty_second: 0x7f4af9da5039
Addr of empty_first: 0x7f4af9da5038
Addr of empty_second: 0x7f4af9da5039
Addr of empty_second: 0x7f4af9da5039
So it seems we can’t have empty objects. But what happens when one empty object inherits from another empty object? Will the size of the inherited empty object by 2 bytes? One for each empty object layer? Well, lets try it out!
struct ZeroElementsWrapper : ZeroElements
{
// Also an empty object!
};
std::cout << "Size of ZeroElementsWrapper: " << sizeof(ZeroElementsWrapper) << " bytes" << std::endl;
Size of ZeroElementsWrapper: 1 bytes
Oh, neat! It’s only 1 byte, not 2! Inheritance really isn’t anything unique or different. It just lets us compose our data types nicely in case we wanted to seperate by abstraction. So it turns out we can indeed inherit from an empty object and pay no extra space penalty for it. Like all concepts, this too has a fancy name: Empty base class optimization
.
Note
It’s important to keep in mind that even though this is completely safe, not all compilers do this for you.
For completeness, lets take a look at the size of different built in data types in C++:
std::cout << "Size of:" << std::endl;
std::cout << "\tbool: " << sizeof(bool) << std::endl;
std::cout << "\tshort: " << sizeof(short) << std::endl;
std::cout << "\tchar: " << sizeof(char) << std::endl;
std::cout << "\tint: " << sizeof(int) << std::endl;
std::cout << "\tunsigned int: " << sizeof(unsigned int) << std::endl;
std::cout << "\tlong: " << sizeof(long) << std::endl;
std::cout << "\tunsigned long: " << sizeof(unsigned long) << std::endl;
std::cout << "\tlong long: " << sizeof(long long) << std::endl;
std::cout << "\tunsigned long long: " << sizeof(unsigned long long) << std::endl;
std::cout << "\tfloat: " << sizeof(float) << std::endl;
std::cout << "\tdouble: " << sizeof(double) << std::endl;
std::cout << "\tint8_t: " << sizeof(int8_t) << std::endl;
std::cout << "\tuint8_t: " << sizeof(uint8_t) << std::endl;
std::cout << "\tint16_t: " << sizeof(int16_t) << std::endl;
std::cout << "\tuint16_t: " << sizeof(uint16_t) << std::endl;
std::cout << "\tint32_t: " << sizeof(int32_t) << std::endl;
std::cout << "\tuint32_t: " << sizeof(uint32_t) << std::endl;
std::cout << "\tint64_t: " << sizeof(int64_t) << std::endl;
std::cout << "\tuint64_t: " << sizeof(uint64_t) << std::endl;
std::cout << "\twchar_t: " << sizeof(wchar_t) << std::endl;
std::cout << "\tlong double: " << sizeof(long double) << std::endl;
uint32_t random = 0;
uint32_t* pointer_to_random = &random;
std::cout << "\tpointer: " << sizeof(pointer_to_random) << std::endl;
Size of:
bool: 1
short: 2
char: 1
int: 4
unsigned int: 4
long: 8
unsigned long: 8
long long: 8
unsigned long long: 8
float: 4
double: 8
int8_t: 1
uint8_t: 1
int16_t: 2
uint16_t: 2
int32_t: 4
uint32_t: 4
int64_t: 8
uint64_t: 8
wchar_t: 4
long double: 16
pointer: 8
Note
For completeness, it’s important to remember that some of the values for types like int
are system dependent. We are running this off of a 64 bit system and the above results reflect that.
Structures with different data types¶
So we saw how the size of structs works for pretty simple data structures.
Packing and Organizing Structures Efficiently¶
So we went over what simple structures with uniform types look like - nothing too suprising there except for the size of an empty struct. But what happens when we start mixing different types of varying sizes in?
Lets create a struct A
that has a few different types in there (we will use the fixed width integers going forward for consistency).
struct A
{
uint8_t first;
uint8_t second;
uint16_t third;
uint32_t fourth;
};
How many bytes (aka storage requirement) does this struct
take up? Well, we see an 8 bit, 8 bit, 16 bit, 32 bit so seems like we ought to just be able to blindly count them up:
8 + 8 + 16 + 32 = 64 (8 bytes)
Lets ask the oracle if that is indeed true!
std::cout << "Size of A: " << sizeof(A) << " bytes" << std::endl;
Size of A: 8 bytes
Ay! Just as expected. Great. So we should also logically expect it to be 8 bytes
regardless of the ordering of the fields. Lets confirm that as well:
struct B
{
uint32_t fourth;
uint16_t third;
uint8_t second;
uint8_t first;
};
std::cout << "Size of B (rev A): " << sizeof(B) << " bytes" << std::endl;
Size of B (rev A): 8 bytes
So reverse ordering of the members seems fine for this case. Lets keep going.
struct C
{
uint8_t first;
uint16_t third;
uint8_t second;
uint32_t fourth;
};
std::cout << "Size of C (rand A): " << sizeof(C) << " bytes" << std::endl;
Size of C (rand A): 12 bytes
Woah, wait a second. We only needed 8 bytes (64 bits from 8 + 8 + 16 + 32) of actual space, so why does changing the order here require 12 bytes (96 bits) of storage here?
The mischief of data alignment¶
Lets take a few steps back and see what the structs A and B look like in memory visually:
The reason we display the reference grouping above (1 8 bytes, 2 4 bytes, 8 8 bytes) is because the smallest size we can work with is 1 byte. On 64 bit systems, the word size is 8 bytes (64 bits). This means that if something is larger than 64 bits, it has to cross word boundaries to be stored in memory. That isn’t to imply that if something is 8 bytes or under, it won’t span a word boundary. We’ll show more of that below. A word has a special place in a computers archtecture. It also happens to be the size of a pointer (8 bytes as shown earlier).
So what does the memory layout of C end up looking like?
It’s very reasonable to, at this point, think - “what”. What’s with the random red regions with “—-” inside them? The random red regions are what we will call “padding” going forward. This is the space that the program is just tacking on (as effectively unused) in order to match the alignment rules. So before we go any further in explaining what is going on here, we want to show you how you can figure out the following information yourself. Again, never take our word for anything!
// Using {} to create an annoymous scope
{
// Lets create an instance of our struct C
C element{};
// Lets print the memory addresses of the individual members (in hexadecimal format)
std::cout << "Addr of: " << std::endl;
std::cout << "\tfirst: " << static_cast<void*>(&element.first) << std::endl;
std::cout << "\tsecond: " << static_cast<void*>(&element.second) << std::endl;
std::cout << "\tthird: " << static_cast<void*>(&element.third) << std::endl;
std::cout << "\tfourth: " << static_cast<void*>(&element.fourth) << std::endl;
// Lets just take the address as integers so we can take the difference of the values:
uint64_t addr_of_first = reinterpret_cast<uint64_t>(&element.first);
uint64_t addr_of_third = reinterpret_cast<uint64_t>(&element.third);
uint64_t addr_of_second = reinterpret_cast<uint64_t>(&element.second);
uint64_t addr_of_fourth = reinterpret_cast<uint64_t>(&element.fourth);
std::cout << std::endl;
// Lets take the diff between consecutive members (Remember, the ordering is: First, Third, Second, and Fourth)
std::cout << "\nDiff between: " << std::endl;
std::cout << "\tFirst and Third: " << (addr_of_third - addr_of_first) << " bytes" << std::endl;
std::cout << "\tThird and Second: " << (addr_of_second - addr_of_third) << " bytes" << std::endl;
std::cout << "\tSecond and Fourth: " << (addr_of_fourth - addr_of_second) << " bytes" << std::endl;
}
Addr of:
first: 0x7ffd1495ed38
second: 0x7ffd1495ed3c
third: 0x7ffd1495ed3a
fourth: 0x7ffd1495ed40
Diff between:
First and Third: 2 bytes
Third and Second: 2 bytes
Second and Fourth: 4 bytes
The differences tell us that we’d expect 2 bytes of space between the start of first
to the start of second
. So something like:
It then tells us that the difference between the memory address of third
and second
is 2 bytes as well. So something like:
Furthermore, the difference between the memory address of second
and fourth
is 4 bytes. So something like:
And lastly, we know that the total struct takes up 12 bytes of memory. Which means from the start of the first
to the end of fourth
:
So far, we’ve shown you what it looks like, but not why. So lets talk about that now.
Warning
It’s important to note that the following may be different for different system archtectures. We are assuming a 64 bit system here where a fetch instruction will retrieve 128 bits of memory (2 words).
In order for a computer to get access to the data it needs, it needs to fetch the data from some storage unit. We mentioned earlier that 1 byte is the smallest unit of space anything can take up. It’s our atomic unit. If computers were designed to fetch 1 byte at a time, they would have to issue 4 memory read cycles in order to fetch an 64 bit value. Unfortunately, there is some overhead in the fetch action itself, so the archtects of these systems decided that we should read multiple bytes per memory cycle instead. For our 64 bit system, this turns out to be 128 bits per cycle (some systems might be 64 bits per memory cycle). In order for these fetches to be efficient (aka, avoid crossing these fetch boundaries, certain alignment rules were established).
For example:
uint8_t
can be slotted on any byte boundaryuint16_t
has to be slotted at any 2 byte boundaryuint32_t
has to be slotted at any 4 byte boundary
In case you were curious what these N byte boundaries look like:
What this means is that if something has an alignment requirement of 2 (bytes), it can really only fall in one of the 2 byte slots shown.
There are many more types with different alignment requirements. Instead of listing them all, we will just show you how to check those yourself. C++ nicely provides a utility called alignof
.
std::cout << "Alignment requirement for: " << std::endl;
std::cout << "\tuint8_t: " << alignof(uint8_t) << std::endl;
std::cout << "\tuint16_t: " << alignof(uint16_t) << std::endl;
std::cout << "\tuint32_t: " << alignof(uint32_t) << std::endl;
Alignment requirement for:
uint8_t: 1
uint16_t: 2
uint32_t: 4
You might be wondering how we figured out our system fetches 128 bits (16 bytes) per memory cycle. Well, there is a handy structure that we can just check the alignment of called max_align_t.
#include <stddef.h> // header for max_align_t
std::cout << "\tmax_align_t: " << alignof(max_align_t) << std::endl;
max_align_t: 16
A couple of other interesting types with alignments that might be worth noting:
std::cout << "Alignment requirement for: " << std::endl;
std::cout << "\tchar arr of size 1: " << alignof(char[1]) << " | Size of: " << sizeof(char[1]) << std::endl;
std::cout << "\tchar arr of size 2: " << alignof(char[2]) << " | Size of: " << sizeof(char[2]) << std::endl;
std::cout << "\tchar arr of size 3: " << alignof(char[3]) << " | Size of: " << sizeof(char[3]) << std::endl;
std::cout << "\tchar arr of size 4: " << alignof(char[4]) << " | Size of: " << sizeof(char[4]) << std::endl;
std::cout << "\tchar arr of size 5: " << alignof(char[5]) << " | Size of: " << sizeof(char[5]) << std::endl;
Alignment requirement for:
char arr of size 1: 1 | Size of: 1
char arr of size 2: 1 | Size of: 2
char arr of size 3: 1 | Size of: 3
char arr of size 4: 1 | Size of: 4
char arr of size 5: 1 | Size of: 5
char
actually fit nicely on the byte boundaries. You might see them sometimes being used to explicitly pad something like:
struct MyPaddedStruct
{
uint8_t x;
char pad[1]; // explicit padding
uint16_t y;
};
// The above is the exact same as:
struct MyNonPaddedStruct
{
uint8_t x;
// has implicit padding of 1 byte here
uint16_t y;
};
std::cout << sizeof(MyPaddedStruct) << std::endl;
std::cout << sizeof(MyNonPaddedStruct) << std::endl;
static_assert(sizeof(MyPaddedStruct) == sizeof(MyNonPaddedStruct));
struct MyOtherPaddedStruct
{
uint16_t x;
char pad1;
char pad2;
uint32_t y;
};
std::cout << sizeof(MyOtherPaddedStruct) << std::endl;
4
4
8
Lets look at some more types:
std::cout << "Alignment requirement for: " << std::endl;
std::cout << "\tbool: " << alignof(bool) << std::endl;
std::cout << "\tfloat: " << alignof(float) << std::endl;
std::cout << "\tdouble: " << alignof(double) << std::endl;
Alignment requirement for:
bool: 1
float: 4
double: 8
Setting your own alignment for custom objects¶
What is extra neat is that you can actually tell the compiler what alignment you wish your custom struct to have with alignas!
struct MyUnalignedEmptyStruct
{
};
struct alignas(8) MyAlignedEmptyStruct
{
};
std::cout << "Alignment requirement for: " << std::endl;
std::cout << "\tMyUnalignedEmptyStruct: " << alignof(MyUnalignedEmptyStruct) << std::endl;
std::cout << "\tMyAlignedEmptyStruct: " << alignof(MyAlignedEmptyStruct) << std::endl;
std::cout << "\tMyAlignedEmptyStruct: " << alignof(MyAlignedEmptyStruct) << std::endl;
Alignment requirement for:
MyUnalignedEmptyStruct: 1
MyAlignedEmptyStruct: 8
MyAlignedEmptyStruct: 8
Note
It is possible to have invalid alignemnt. We showed you earlier that you can really only align on the 1 byte, 2 byte, 4 byte, 8 byte, or 16 byte boundary. If for some reason, you decide to do something like: struct alignas(3) MyIncorrectlyAlginedStruct {}; It will be ill-formed and you should see a compiler error along the lines of: error: requested alignment is not a power of 2
Packing a structure¶
Ordering¶
So we saw how ordering matters quite a bit if you care about efficiently utilizing space. The easiest way to solve the issue of efficiently packing some structure is then to figure out how to order the struct so as to minimize the padding that sneaks in. One of the
You can actually ask the compiler to warn you when padding is being implicitly added to a struct
with -Wpadded
. There is an example via Compiler Explorer you can try out.
Note
The easiest rule of thumb if you don’t really want to think too much about this is to order things by size. Largest to smallest.
Using compiler directives to force pack¶
Most compilers offer you some way of packing your structure (the syntax might different compiler to compiler) so that you can just force the alignment. In GCC, you can do:
struct __attribute__ ((packed)) { uint8_t a; uint32_t b; }
In clang, we can do (also works with GCC):
struct MyNormalStruct { uint8_t a; uint32_t b; };
#pragma pack(push, 1)
struct MyPackedStruct { uint8_t a; uint32_t b; };
#pragma pack(pop)
std::cout << "Size of: " << std::endl;
std::cout << "\tMyNormalStruct: " << sizeof(MyNormalStruct) << std::endl;
std::cout << "\tMyPackedStruct: " << sizeof(MyPackedStruct) << std::endl;
Size of:
MyNormalStruct: 8
MyPackedStruct: 5
By forcing the struct packing, we managed to save 3 bytes overall (8 bytes vs 5 bytes). This is sometimes seen when doing serialization to tightly pack your data and avoid excess data passing.
While you can save space here, it is very important to know that this doesn’t come for free. The reason compilers align objects the way we have seen so far and add implicit padding is that word-aligned memory addresses have the fastest access.
Also, it can be rather tedious and hard to get right when doing it an explicit packing. So be careful when doing it and always benchmark your decisions when in doubt.
The special case of bools¶
We saw earlier that bools take up 1 byte even though we only need 1 bit to represent a bool. You might be hoping that we can pack 8 bools into 8 bytes (1 bit per bool instead of 1 byte). Alas, it isn’t quite that easy:
#pragma pack(push, 1)
struct MyEightBools
{
bool a;
bool b;
bool c;
bool d;
bool e;
bool f;
bool g;
bool h;
};
#pragma pack(pop)
std::cout << "Size of: " << std::endl;
std::cout << "\tMyEightBools: " << sizeof(MyEightBools) << " bytes"<< std::endl;
Size of:
MyEightBools: 8 bytes
But nothing prevents us from creating a bool accessor that lets you pack a bool per bit.
Sharing address of member elements¶
So earlier, we spoke about how an empty struct takes up 1 byte of memory even though it doesn’t need any storage. We mentioned the minor exception that if you inherit from a empty struct that it does not add the extra byte of storage requirement to the inherited class. There is one more exception we haven’t spoke about. What happens if we have a struct with empty structs as members?
struct Empty {};
std::cout << "Size of Empty: " << sizeof(Empty) << std::endl;
struct EmptyCollection
{
uint8_t first;
Empty second;
};
std::cout << "Size of Empty Collection: " << sizeof(EmptyCollection) << std::endl;
Size of Empty: 1
Size of Empty Collection: 2
This is a bit unfortunate. We have a collection of empty objects, all of which require 0 storage space, but because they are composed instead of inherited, we take up 4 bytes of memory. Is there a way to avoid that? Of course there is!
struct Empty {};
struct EfficientEmptyCollection
{
uint8_t first;
[[no_unique_address]] Empty second;
};
If you want to see this in action: Compiler Explorer
Note
The following only works starting C++20: no_unique_address
TODO:¶
alignments for enums and unions
reference information on struct size effects when it comes to virtuals (probably best in the chapter on virtuals)
Key takeaways¶
Empty structs take up 1 byte of memory (in order to have unique addresses)
1 byte is the minimum any object or type can take up
Use the smallest data type to hold your data
When in doubt, order member elements from largest to smallest
You can use
alignof
to check the alignment requirementsWhen in need, you can just pack the struct explicitly via compiler directives such as
#pragma pack(push, 1)
or__attribute__ ((packed))
References:¶
If you want to continue learning more on this subject or are curious about other resources that might help in the learning process: