Additional Struct Features and Best Practices
Use nested structs, struct size/alignment, and other advanced struct features.
What Is Left to Know About Structs?
By now you can define a struct, initialize it, give its members defaults, and pass it around. Three practical questions remain, and each one tends to surprise people the first time it comes up:
- Why does
sizeofreport a bigger number than the members add up to? - Which member types keep the data inside a struct valid?
- How do you store one struct inside another and reach the members of the inner one?
This lesson answers them in that order, then finishes with one struct that applies all three answers at once.
Why Is a Struct Bigger Than Its Members?
It is tempting to assume a struct occupies exactly as much memory as its members require. Add the sizes up and compare against the real thing.
#include <iostream>
struct Sample
{
short sensorId {};
int elapsedMs {};
double celsius {};
};
int main()
{
std::cout << "sizeof(short) = " << sizeof(short) << '\n';
std::cout << "sizeof(int) = " << sizeof(int) << '\n';
std::cout << "sizeof(double) = " << sizeof(double) << '\n';
std::cout << "members added up = " << sizeof(short) + sizeof(int) + sizeof(double) << '\n';
std::cout << "sizeof(Sample) = " << sizeof(Sample) << '\n';
return 0;
}
sizeof(short) = 2
sizeof(int) = 4
sizeof(double) = 8
members added up = 14
sizeof(Sample) = 16
Fourteen bytes of members, sixteen bytes of struct. The two missing bytes are padding: invisible gaps the compiler inserts between members for performance reasons. A struct is guaranteed to be at least as large as the sum of its members, never smaller, but it is free to be larger.
You can see exactly where the gap landed by asking for each member's byte offset from the start of the object.
#include <cstddef>
#include <iostream>
struct Sample
{
short sensorId {};
int elapsedMs {};
double celsius {};
};
int main()
{
std::cout << "sensorId starts at byte " << offsetof(Sample, sensorId) << '\n';
std::cout << "elapsedMs starts at byte " << offsetof(Sample, elapsedMs) << '\n';
std::cout << "celsius starts at byte " << offsetof(Sample, celsius) << '\n';
return 0;
}
sensorId starts at byte 0
elapsedMs starts at byte 4
celsius starts at byte 8
The short occupies bytes 0 and 1, yet the int does not begin until byte 4. Bytes 2 and 3 are the padding. The compiler placed the 4-byte int at an offset that is a multiple of 4 because processors read memory fastest when a value of size N sits at an address that is a multiple of N. That requirement is called data structure alignment, and satisfying it is the reason padding exists.
The exact alignment requirements come from the hardware and the platform ABI, not from your code, so the same struct can have different sizes on different platforms. You never need to compute padding yourself, but you should know that
sizeof is the only trustworthy answer to how big a struct is.
Never assume a struct's size equals the sum of its members' sizes, and never rely on members sitting immediately next to each other in memory. Use
sizeof when you need the real size.
Does Member Order Change the Size?
Almost nothing else in C++ makes declaration order matter this much. Because the compiler is not permitted to rearrange your members, and because each member has to land on a suitably aligned offset, two structs holding exactly the same data can differ in size purely from how the declarations were ordered.
#include <iostream>
struct Loose
{
char channel {};
double celsius {};
char status {};
int elapsedMs {};
};
struct Packed
{
double celsius {};
int elapsedMs {};
char channel {};
char status {};
};
int main()
{
std::cout << "sizeof(Loose) = " << sizeof(Loose) << '\n';
std::cout << "sizeof(Packed) = " << sizeof(Packed) << '\n';
return 0;
}
sizeof(Loose) = 24
sizeof(Packed) = 16
Same four members, same types, eight extra bytes. In Loose, the single-byte channel is followed by a double that has to start at a multiple of 8, so seven bytes are thrown away immediately, and status costs another three before elapsedMs. In Packed, the large members come first and the two char members share the tail of the object, so the only padding is a couple of bytes at the very end.
When a struct will exist in large numbers, declare its members in decreasing order of size. The compiler cannot reorder members for you, so this is a manual optimization, and it costs nothing to get right while you are writing the struct.
Do not let this override readability for a struct you will only ever create a handful of. Grouping related members together is usually worth more than eight bytes. It matters when you have an array of a million of them.
Which Member Types Are Safe to Store?
Types divide into owners, which manage their own data and decide when it is destroyed, and viewers, which look at data belonging to something else. std::string is an owner. std::string_view, along with pointers and references, is a viewer.
A struct is an owner exactly when all of its members are owners, and an owning struct gives you two guarantees worth having: its data stays valid for as long as the struct itself does, and its values cannot be changed behind your back by whoever else holds the data.
The trouble with a viewer member is that the struct has no say in how long the viewed object lives. This program stores the result of a function that returns a temporary std::string.
#include <iostream>
#include <string>
std::string describeChannel(int channel)
{
return "channel " + std::to_string(channel);
}
struct SafeLabel
{
std::string caption {};
};
int main()
{
SafeLabel keeper { describeChannel(3) };
std::cout << "caption is " << keeper.caption << '\n';
return 0;
}
caption is channel 3
describeChannel() hands back a temporary std::string that is destroyed at the end of the full expression that created it, which here is the initialization of keeper. That is fine, because caption is a std::string and made its own copy before the temporary died.
Swap that one member type and the program is wrong, even though it still compiles without complaint:
#include <iostream>
#include <string>
#include <string_view>
std::string describeChannel(int channel)
{
return "channel " + std::to_string(channel);
}
struct BrokenLabel
{
std::string_view caption {};
};
int main()
{
BrokenLabel peeker { describeChannel(3) };
std::cout << "caption is " << peeker.caption << '\n'; // undefined behavior
return 0;
}
Here caption copies nothing. It records where the temporary's characters live, the temporary is destroyed one statement later, and peeker is left holding a dangling member. Reading it is undefined behavior. The nastiest part is that the abandoned bytes are often still sitting there untouched, so this version may well print the right text on your machine and something else entirely after an unrelated change elsewhere in the program.
Give every data member an owning type. Preferring
std::string over std::string_view for string members is the most common case, and it is why struct members are rarely references or pointers unless the struct is deliberately designed to view something with a longer lifetime.
How Do You Put a Struct Inside a Struct?
A struct member can have any type, including another program-defined type. Define the inner type in the global scope and use it as a member type:
#include <iostream>
struct Coordinates
{
double latitude {};
double longitude {};
};
struct Station
{
int stationId {};
Coordinates position {};
};
int main()
{
Station ridgeTop { 42, { 47.61, -122.33 } };
std::cout << "station " << ridgeTop.stationId << '\n';
std::cout << "latitude " << ridgeTop.position.latitude << '\n';
std::cout << "longitude " << ridgeTop.position.longitude << '\n';
return 0;
}
station 42
latitude 47.61
longitude -122.33
Two things are worth naming here. The inner braces in { 42, { 47.61, -122.33 } } are a nested initialization list: the outer list initializes Station, and the inner one initializes the Coordinates member inside it. And reaching a member of the inner struct takes the member selection operator twice, once per level: ridgeTop.position.latitude selects position out of the station, then latitude out of those coordinates. Nest three types deep and you write three dots.
If the inner type is only meaningful as part of the outer one, you can define it inside the outer struct instead, which keeps its name out of the global scope:
#include <iostream>
struct Station
{
struct Coordinates
{
double latitude {};
double longitude {};
};
int stationId {};
Coordinates position {};
};
int main()
{
Station ridgeTop { 42, { 47.61, -122.33 } };
Station::Coordinates spare { 46.87, -121.76 };
std::cout << "station latitude " << ridgeTop.position.latitude << '\n';
std::cout << "spare latitude " << spare.latitude << '\n';
return 0;
}
station latitude 47.61
spare latitude 46.87
Inside Station the type is just Coordinates; everywhere else it is Station::Coordinates. Initialization and member access work exactly as before, since only the type's name has changed, not its behavior. Nesting a type like this is far more common with classes, so it gets a fuller treatment when member types come up later.
Combining the Three Ideas
Here is the payoff: a two-level nest, every member an owning type, and members declared largest first.
#include <iostream>
#include <string>
struct Coordinates
{
double latitude {};
double longitude {};
};
struct Station
{
Coordinates position {};
std::string label {};
int stationId {};
};
struct Network
{
Station hub {};
std::string operatorName {};
int stationCount {};
};
int main()
{
Network coastal {
{ { 47.61, -122.33 }, "Ridge Top", 42 },
"Coastal Watch",
6
};
std::cout << coastal.operatorName << " runs " << coastal.stationCount << " stations\n";
std::cout << "hub " << coastal.hub.label << " reports from latitude "
<< coastal.hub.position.latitude << '\n';
return 0;
}
Coastal Watch runs 6 stations
hub Ridge Top reports from latitude 47.61
The initializer mirrors the nesting: a brace level for the Network, one for its Station, and one for that station's Coordinates. Every string is a std::string, so a Network copied out of a function keeps working no matter what happened to the text it was built from. And reaching the deepest value chains the member selection operator once per level, which is coastal.hub.position.latitude.
Summary
Padding makes structs bigger: A struct is at least as large as the sum of its members, and often larger, because the compiler inserts invisible gaps between members. sizeof is the only reliable way to ask how big a struct really is.
Alignment is the reason: Each member must start at an offset suited to its type, which is why a 2-byte member can be followed by 2 bytes of nothing before a 4-byte member begins. offsetof shows where the gaps landed.
Declaration order changes the size: The compiler may not reorder your members, so two structs with identical members can differ in size by 50 percent. Declaring members in decreasing order of size minimizes padding, and it matters most when the struct will exist in large numbers.
Owning members keep a struct safe: Owners manage their own data; viewers only look at data belonging to something else. A struct whose members are all owners is itself an owner, and its data stays valid for exactly as long as the struct does.
Viewer members can dangle: A std::string_view member initialized from a temporary is left pointing at destroyed data, and reading it is undefined behavior that may look like it works. Prefer std::string for string members.
Structs can contain structs: Use a program-defined type as a member type directly, or define the type inside the outer struct and refer to it as Outer::Inner from elsewhere. Initialize nested members with nested brace lists.
Member selection chains: Reaching a member of a nested struct applies the member selection operator once per level, as in coastal.hub.position.latitude.
None of these details change what a struct is. They change how well a struct behaves once you have a lot of them, or once the data they hold comes from somewhere with a shorter lifetime than the struct itself.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Additional Struct Features and Best Practices - Quiz
Test your understanding of the lesson.
Practice Exercises
Nested Hardware Components
Create a computer system struct that contains nested component structs. Demonstrate accessing nested struct members using the member selection operator.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!