Struct miscellany

Structs with program-defined members

In C++, structs (and classes) can have members that are other program-defined types. There are two ways to accomplish this.

First, we can define one program-defined type (in the global scope) and then use it as a member of another program-defined type:

#include <iostream>

struct Battery
{
    int percentage {};
    int voltage {};
    int temperature {};
};

struct Drone
{
    int flightTime {};
    Battery powerSource {}; // Battery is a struct within the Drone struct
};

int main()
{
    Drone quadcopter { 45, { 95, 12, 28 } }; // Nested initialization list to initialize Battery
    std::cout << quadcopter.powerSource.percentage << '\n'; // print the battery percentage

    return 0;
}

In this case, we've defined a Battery struct, then used it as a member in a Drone struct. When we initialize our Drone, we can also initialize our Battery using a nested initialization list. And if we want to know the battery percentage, we simply use the member selection operator twice: quadcopter.powerSource.percentage;

Second, types can be nested inside other types, so if a Battery only existed as part of a Drone, the Battery type could be nested inside the Drone struct:

#include <iostream>

struct Drone
{
    struct Battery // accessed via Drone::Battery
    {
        int percentage {};
        int voltage {};
        int temperature {};
    };

    int flightTime {};
    Battery powerSource {}; // Battery is a struct within the Drone struct
};

int main()
{
    Drone quadcopter { 45, { 95, 12, 28 } }; // Nested initialization list to initialize Battery
    std::cout << quadcopter.powerSource.percentage << '\n'; // print the battery percentage

    return 0;
}

This is more often done with classes, so we'll discuss this more in a future lesson (15.3).

Structs that are owners should have data members that are owners

In lesson 5.9, we introduced the dual concepts of owners and viewers. Owners manage their own data, and control when it's destroyed. Viewers view someone else's data, and don't control when it's altered or destroyed.

In most cases, we want our structs (and classes) to be owners of the data they contain. This provides useful benefits:

  • The data members will be valid for as long as the struct (or class) is.
  • The value of those data members won't change unexpectedly.

The easiest way to make a struct (or class) an owner is to give each data member a type that is an owner (e.g., not a viewer, pointer, or reference). If a struct or class has data members that are all owners, then the struct or class itself is automatically an owner.

If a struct (or class) has a data member that is a viewer, it's possible that the object being viewed by that member will be destroyed before the data member viewing it. If this happens, the struct will be left with a dangling member, and accessing that member will lead to undefined behavior.

Best Practice
In most cases, we want our structs (and classes) to be owners. The easiest way to enable this is to ensure each data member has an owning type (e.g., not a viewer, pointer, or reference).
For Context
Practice safe structs. Don't let your member dangle.

This is why string data members are almost always of type std::string (which is an owner), and not of type std::string_view (which is a viewer). The following example illustrates why this matters:

#include <iostream>
#include <string>
#include <string_view>

struct DataOwner
{
    std::string content {}; // std::string is an owner
};

struct DataViewer
{
    std::string_view content {}; // std::string_view is a viewer
};

// getInput() returns the user-entered string as a temporary std::string
// This temporary std::string will be destroyed at the end of the full expression
// containing the function call.
std::string getInput()
{
    std::cout << "Enter some text: ";
    std::string text {};
    std::cin >> text;
    return text;
}

int main()
{
    DataOwner owner { getInput() };  // The return value of getInput() is destroyed just after initialization
    std::cout << "The owner's content is " << owner.content << '\n';  // ok

    DataViewer viewer { getInput() }; // The return value of getInput() is destroyed just after initialization
    std::cout << "The viewer's content is " << viewer.content << '\n'; // undefined behavior

    return 0;
}

The getInput() function returns the text the user entered as a temporary std::string. This temporary return value is destroyed at the end of the full expression in which the function is called.

In the case of owner, this temporary std::string is used to initialize owner.content. Since owner.content is a std::string, owner.content makes a copy of the temporary std::string. The temporary std::string then dies, but owner.content is unaffected since it's a copy. When we print owner.content in the subsequent statement, it works as expected.

In the case of viewer, this temporary std::string is used to initialize viewer.content. Since viewer.content is a std::string_view, viewer.content is just a view of the temporary std::string, not a copy. The temporary std::string then dies, leaving viewer.content dangling. When we print viewer.content in the subsequent statement, we get undefined behavior.

Struct size and data structure alignment

Typically, a struct's size is the sum of the sizes of all its members, but not always!

Consider the following program:

#include <iostream>

struct DataPacket
{
    short header {};
    int payload {};
    double checksum {};
};

int main()
{
    std::cout << "The size of short is " << sizeof(short) << " bytes\n";
    std::cout << "The size of int is " << sizeof(int) << " bytes\n";
    std::cout << "The size of double is " << sizeof(double) << " bytes\n";

    std::cout << "The size of DataPacket is " << sizeof(DataPacket) << " bytes\n";

    return 0;
}

When tested, this printed:

The size of short is 2 bytes
The size of int is 4 bytes
The size of double is 8 bytes
The size of DataPacket is 16 bytes

Note that the size of short + int + double is 14 bytes, but the size of DataPacket is 16 bytes!

It turns out, we can only say that a struct's size will be at least as large as the size of all the variables it contains. But it could be larger! For performance reasons, the compiler sometimes adds gaps into structures (this is called padding).

In the DataPacket struct above, the compiler is invisibly adding 2 bytes of padding after member header, making the structure size 16 bytes instead of 14.

Advanced note: The reason compilers may add padding is beyond this tutorial's scope, but readers interested can read about data structure alignment. This can have a pretty significant impact on struct size, as the following program demonstrates:

#include <iostream>

struct Network1
{
    short port {};       // will have 2 bytes of padding after port
    int address {};
    short protocol {};   // will have 2 bytes of padding after protocol
};

struct Network2
{
    int address {};
    short port {};
    short protocol {};
};

int main()
{
    std::cout << sizeof(Network1) << '\n'; // prints 12
    std::cout << sizeof(Network2) << '\n'; // prints 8

    return 0;
}

This program prints:

12 8

Note that Network1 and Network2 have the same members, the only difference being declaration order. Yet Network1 is 50% larger due to added padding.

Tip
You can minimize padding by defining your members in decreasing order of size.

The C++ compiler is not allowed to reorder members, so this must be done manually.

Summary

Nested struct members: Structs can contain members that are themselves program-defined types. Define the inner type globally and use it as a member type, or nest the type definition inside the outer struct. Access nested members using multiple member selection operators (e.g., quadcopter.powerSource.percentage).

Structs should be owners: Make struct members owning types (not viewers, pointers, or references) whenever possible. This ensures data members remain valid for the struct's lifetime and won't become dangling. Use std::string instead of std::string_view for string members to ensure the struct owns its data.

Dangling members: If a struct has viewer members (like std::string_view) that reference temporary objects, those members will dangle when the temporary is destroyed. Always ensure viewed objects outlive the struct viewing them, or use owning types instead.

Struct size and padding: A struct's size is at least as large as the sum of its members' sizes, but may be larger due to padding. Compilers add padding bytes between members for performance reasons related to memory alignment.

Minimizing padding: Define struct members in decreasing order of size to minimize padding. The compiler cannot reorder members automatically, so this must be done manually. This can significantly reduce struct size - sometimes by 50% or more.

Member declaration order matters: Unlike most aspects of C++, the order in which you declare struct members affects the struct's memory layout and size. Consider both logical grouping and size optimization when ordering members.

Understanding these struct characteristics helps you write more efficient and safer code, particularly when working with large numbers of struct objects or memory-constrained environments.