Custom Data Types with Enums and Structs Summary
Review and test your understanding of all enum and struct concepts covered in this chapter.
What This Chapter Added
Up to this chapter, every type you declared came out of a fixed catalogue: int, double, char, bool, and the standard library types built on top of them. Those are the fundamental types, and the language hands them to you already defined. This chapter gave you the other half of the type system, the half where you write the type yourself.
Everything below is reviewed against one running example: the route board at a climbing gym. A route sits on a wall of some angle, its hardest hold has some shape, someone set it, and it takes some number of moves. Not one of those facts fits an int honestly, which is exactly the situation program-defined types exist for.
Two Families of Program-Defined Type
When the built-in types cannot express what you mean, you define your own. Such a type is a program-defined type, and the standard's broader term for the same idea is user-defined type. They come in two families, and the whole chapter divides along that line.
| Family | Keywords | What one object holds | Reach for it when |
|---|---|---|---|
| Enumerated types | enum, enum class |
exactly one name from a fixed list | the value is a choice from a short, closed set |
| Class types | struct, class, union |
several values at once, one per member | the concept is several facts that travel together |
Both families obey the same two rules. The compiler needs the full definition before you use the type, since a forward declaration does not tell it how large an object is or what is inside one. And the definition itself, called a type definition, is exempt from the one-definition rule: you may repeat it in every translation unit that needs it, which is why type definitions live happily in headers. Repeat it twice inside a single file and you still have an error.
The difference is authorship, not capability. Fundamental types are built into the language and always available. Program-defined types are ones you write, and they must be defined before use. Once defined, yours is a real type: it has a name, the compiler type-checks it, and it appears by name in diagnostics.
Enumerations: The Same Idea Behind Two Keywords
An enumeration (also called an enumerated type or an enum) is a compound type whose every possible value is a symbolic constant that you named. Each of those constants is an enumerator. Both keywords produce a distinct type, meaning the compiler can tell it apart from every other type. This is the line that separates an enumeration from a type alias: an alias is only a second spelling for a type that already exists, so the compiler cannot tell the alias from the original and cannot reject a nonsense value passed through it. An enumeration is a genuinely new type, so it can.
Where the two keywords part company:
| Question | enum (unscoped) |
enum class (scoped) |
|---|---|---|
| Where do the enumerator names land? | in the scope enclosing the definition, alongside the type | only inside the enumeration's own scope |
| Must you qualify an enumerator? | no, though WallAngle::slab also works |
yes, always HoldShape::crimp |
| Does it convert to an integral type on its own? | yes, implicitly | no, never |
| How do you get the number out? | it is already there | static_cast to the underlying type |
| Risk of clashing with a nearby name | real, since the names are out in the open | none, the type name walls them off |
| Distinct type? | yes | yes |
The name is the whole story: an unscoped enumeration does not create a scope region for its enumerators, so they end up next to the type. It does still supply a named scope region you may use for qualification, which is why WallAngle::slab compiles even though the bare slab does too.
#include <iostream>
enum WallAngle // unscoped: the enumerator names land out here, beside the type
{
slab,
vertical,
overhang,
roof,
};
enum class HoldShape // scoped: the enumerator names stay inside HoldShape
{
crimp,
jug,
sloper,
pinch,
};
int main()
{
WallAngle angle{ overhang }; // no qualification needed
HoldShape shape{ HoldShape::pinch }; // qualification is mandatory
std::cout << "angle as a number: " << angle << '\n';
std::cout << "shape as a number: " << static_cast<int>(shape) << '\n';
return 0;
}
Output:
angle as a number: 2
shape as a number: 3
Notice the asymmetry in those two print statements. angle reached std::cout on its own because an unscoped enumeration converts to its integral value without being asked. shape needed a cast, because a scoped enumeration refuses that conversion. The refusal is the feature: it is also what stops a HoldShape from being quietly compared against, added to, or assigned from a plain number.
Reach for
enum class first. Drop to an unscoped enum only when you have a specific reason to want the implicit conversion, such as an enumeration whose values index into an array.
Giving an Enumeration a Readable Face
Because an enumerator is a name attached to a number, printing one prints the number, which is useless on a route board. Two chapter techniques fix that together. A lookup function turns the enumerator into text, and an overloaded operator<< wires that function into the stream so callers never have to invoke it by hand.
#include <iostream>
#include <string_view>
enum class HoldShape
{
crimp,
jug,
sloper,
pinch,
};
constexpr std::string_view describe(HoldShape shape)
{
switch (shape)
{
case HoldShape::crimp: return "crimp";
case HoldShape::jug: return "jug";
case HoldShape::sloper: return "sloper";
case HoldShape::pinch: return "pinch";
}
return "off-list";
}
std::ostream& operator<<(std::ostream& out, HoldShape shape)
{
return out << describe(shape);
}
int main()
{
std::cout << "start hold: " << HoldShape::jug << '\n';
std::cout << "crux hold: " << HoldShape::sloper << '\n';
return 0;
}
Output:
start hold: jug
crux hold: sloper
Three details in that overload are worth carrying forward. The first parameter is std::ostream&, not std::cout, so the same function serves any stream. The return type is that same reference, which is what makes << chain. And the function is an ordinary free function, not a member, because the left operand of << is the stream rather than your type. The matching input operator, operator>>, takes std::istream& and a non-const reference to the object it fills.
Structs: Where a Member's Value Comes From
A struct (short for structure) is a program-defined type that bundles several variables into one. Those variables are its data members (or member variables).
A struct that holds nothing but data members is an aggregate. So is an array. That word matters because aggregates get their own initialization form, aggregate initialization: you supply an initializer list, a brace-enclosed run of comma-separated values, and the compiler performs memberwise initialization, walking the members in declaration order and matching them to your values one for one. A struct stops being an aggregate the moment it gains a user-declared constructor, a private or protected data member, or a virtual function, and then this whole form stops applying to it.
Four different mechanisms can put a value into a member, and they cooperate rather than compete:
| Mechanism | What it looks like | What decides the value |
|---|---|---|
| Positional aggregate initialization | Route r{ "Wren", 14, 5 } |
position in the list, matched against declaration order |
| Designated initializer (C++20) | Route r{ .setter = "Otis" } |
the member you named |
| Default member initializer | int moves{ 8 }; inside the definition |
the value written in the type definition |
| Value initialization | Route r{} |
the default member initializer, or zero-like if there is none |
A default member initializer is a value written beside a member in the type definition itself. The practice of writing them is called non-static member initialization, and their effect is to catch every member you leave out of an initializer list.
#include <iostream>
#include <string>
struct Route
{
std::string setter{}; // no default value, so an empty string
int moves{ 8 }; // default member initializer
int stars{ 3 }; // default member initializer
};
void printRoute(const Route& route)
{
std::cout << route.setter << " | " << route.moves << " moves | " << route.stars << " stars" << '\n';
}
int main()
{
Route positional{ "Wren", 14, 5 }; // memberwise, in declaration order
Route partial{ "Otis" }; // the rest fall back to their defaults
Route designated{ .setter = "Marisol", .stars = 4 }; // C++20, moves keeps its default
Route blank{}; // every member takes its default
printRoute(positional);
printRoute(partial);
printRoute(designated);
printRoute(blank);
return 0;
}
Output:
Wren | 14 moves | 5 stars
Otis | 8 moves | 3 stars
Marisol | 8 moves | 4 stars
| 8 moves | 3 stars
Designated initializers buy readability at a call site full of bare numbers, but they do not buy freedom of order. The designators must still run in declaration order. The following program is broken on purpose to show what happens when they do not:
#include <iostream>
#include <string>
struct Route
{
std::string setter{};
int moves{ 8 };
int stars{ 3 };
};
int main()
{
Route jumbled{ .stars = 4, .setter = "Marisol" }; // designators run backwards
std::cout << jumbled.setter << '\n';
return 0;
}
s.cpp: In function 'int main()':
s.cpp:13:52: error: designator order for field 'Route::setter' does not match declaration order in 'Route'
13 | Route jumbled{ .stars = 4, .setter = "Marisol" }; // designators run backwards
| ^
Reordering the members in a struct definition silently rewrites the meaning of every positional initializer list already written for it, and turns every designated initializer list into the error above. The error is the lucky case, because the compiler tells you. Prefer adding new members at the end.
Reaching a Member Through Whatever You Are Holding
Selecting a member takes one of two operators, and which one you need depends purely on what you are holding.
| You are holding | Operator | Written as |
|---|---|---|
| an object | member selection operator, operator. |
topOut.setter |
| a reference to an object | member selection operator, operator. |
sameRoute.setter |
| a pointer to an object | member selection from pointer operator, operator-> |
boardEntry->setter |
#include <iostream>
#include <string>
struct Route
{
std::string setter{};
int moves{};
};
int main()
{
Route topOut{ "Wren", 14 };
Route& sameRoute{ topOut }; // a reference to it
Route* boardEntry{ &topOut }; // a pointer to it
std::cout << topOut.setter << '\n'; // object: dot
std::cout << sameRoute.moves << '\n'; // reference: still dot
std::cout << boardEntry->setter << '\n'; // pointer: arrow
std::cout << (*boardEntry).moves << '\n'; // what the arrow stands for
return 0;
}
Output:
Wren
14
Wren
14
A reference behaves as the object itself, so it takes the dot. A pointer does not, so boardEntry.setter would not compile. The arrow is exactly the shorthand shown on the last line, dereference then select, and it is worth preferring over the parenthesised form because the parentheses are load-bearing. Drop them and *boardEntry.setter parses as *(boardEntry.setter), which asks a pointer for a member it does not have.
Crossing a Function Boundary
Structs are cheap to pass in the sense that one argument replaces a handful, but the copy is not free, so the chapter's rule of thumb splits by what the type costs to copy.
| Parameter type | Pass it | Reason |
|---|---|---|
int, char, bool, double |
by value | a copy is one register |
| an enumeration object | by value | it holds an integral value, so copying costs the same |
std::string_view |
by value | it owns nothing and is cheap to copy |
std::string |
by const reference | copying it allocates |
| any struct | by const reference | copying every member adds up, and members get added later |
The last row deserves the emphasis. A struct with one int in it today would be fine by value today, but passing it by const reference costs almost nothing and spares you from revisiting every call site the week a second member appears.
Returning is the other direction and follows the opposite habit: return a struct by value. Returning a reference or pointer to a local object hands the caller a dangling one, and the compiler is very good at making the by-value return cheap.
Size, Padding, and Layout
The size of a struct is not reliably the sum of the sizes of its members. Processors read memory fastest when a value sits on an address that is a multiple of its own size, so for performance reasons the compiler inserts gaps between members to keep them aligned. Those gaps are padding.
Padding is a consequence of declaration order, which means the same four members can produce two different sizes:
#include <iostream>
struct Interleaved
{
char panel{};
int moves{};
char grade{};
int holds{};
};
struct Grouped
{
int moves{};
int holds{};
char panel{};
char grade{};
};
int main()
{
std::cout << "sum of the member sizes: " << 2 * sizeof(int) + 2 * sizeof(char) << '\n';
std::cout << "sizeof(Interleaved): " << sizeof(Interleaved) << '\n';
std::cout << "sizeof(Grouped): " << sizeof(Grouped) << '\n';
return 0;
}
Output:
sum of the member sizes: 10
sizeof(Interleaved): 16
sizeof(Grouped): 12
Ten bytes of members, and neither struct is ten bytes. Interleaved pays three bytes of padding after each char so that the int following it starts on a four-byte boundary. Grouped puts both int members first and lets the two char members share the tail, so it wastes two bytes instead of six. Exact numbers vary by platform, so measure with sizeof rather than counting members.
Declaring members largest first is a reasonable default, but it is a micro-optimisation. Group members by what they mean while you are writing ordinary code, and only reorder for size when a profiler or a very large array of the struct says it matters.
One Definition, Many Types
A class template is not a type. It is a definition the compiler uses to manufacture types on demand: write it once with the varying type left as a parameter, and the compiler instantiates a separate class type for each set of arguments you actually use.
Three features work together here:
| Feature | Since | What it does for you |
|---|---|---|
| Class template | C++98 | one definition generates a class type per set of template arguments |
| Class template argument deduction (CTAD) | C++17 | the compiler deduces the template arguments from the initializer, so you can omit them |
| Alias template | C++11 | names a class template with some arguments already filled in |
#include <iostream>
#include <string_view>
template <typename TScore>
struct GradeBand
{
TScore easiest{};
TScore hardest{};
};
template <typename TLabel, typename TScore>
struct Placard
{
TLabel heading{};
GradeBand<TScore> band{};
};
// an alias template: Placard with the label type already pinned down
template <typename TScore>
using WallPlacard = Placard<std::string_view, TScore>;
template <typename TScore>
void printBand(const GradeBand<TScore>& band)
{
std::cout << band.easiest << " up to " << band.hardest << '\n';
}
int main()
{
GradeBand<int> spelledOut{ 3, 7 }; // template argument written by hand
GradeBand deduced{ 4, 9 }; // CTAD deduces GradeBand<int>
GradeBand fractional{ 5.5, 8.25 }; // CTAD deduces GradeBand<double>
printBand(spelledOut);
printBand(deduced);
printBand(fractional);
WallPlacard<int> board{ "roof circuit", { 6, 9 } };
std::cout << board.heading << ": ";
printBand(board.band);
return 0;
}
Output:
3 up to 7
4 up to 9
5.5 up to 8.25
roof circuit: 6 up to 9
GradeBand<int> and GradeBand<double> are two unrelated types that happen to share a definition. CTAD read { 4, 9 } and concluded int, and read { 5.5, 8.25 } and concluded double, which is the same service the standard library gives you when std::pair p{ 1, 2.5 } compiles without angle brackets. WallPlacard is not a new template but a shorter name for an old one with std::string_view already supplied.
CTAD deduces from an initializer, so it needs one.
GradeBand empty{}; and GradeBand empty; give the compiler nothing to work from and will not compile. Name the argument explicitly when there is no initializer to read.
Summary
The chapter is one idea applied twice: the language lets you add types to it, and the two shapes those types come in are a closed list of names and a bundle of related values.
| Need | Tool | Key detail |
|---|---|---|
| a value from a short fixed set | enum class |
no implicit conversion to a number, enumerators are scoped inside the type |
| the same, but you want the number | enum |
converts implicitly, enumerator names sit in the enclosing scope |
| readable output for either | a lookup function plus operator<< |
free function, takes and returns std::ostream& |
| several facts as one object | struct |
data members initialize memberwise, in declaration order |
| sensible values when you omit some | default member initializers | written in the type definition, used by anything you leave out |
| clarity at a busy call site | designated initializers | C++20, and they must follow declaration order |
| a member, given a pointer | operator-> |
given an object or reference, use operator. instead |
| a struct parameter | const reference | return by value, never by reference to a local |
| the same structure over many types | a class template | CTAD infers the arguments, an alias template shortens the name |
The one number never to assume is sizeof. Padding makes a struct at least as large as the sum of its members and often larger, and reordering the members changes the answer.
Key Terminology
- Program-defined type: a type you write yourself rather than one built into the language
- User-defined type: another name for a program-defined type
- Type definition: the definition of a program-defined type, exempt from the one-definition rule
- Enumeration (enumerated type, enum): a compound type whose every value is a named symbolic constant
- Enumerator: one named value belonging to an enumeration
- Distinct type: a type the compiler can tell apart from all others, which a type alias is not
- Unscoped enumeration: an
enumwhose enumerators land in the enclosing scope and convert to integers implicitly - Scoped enumeration: an
enum classwhose enumerators stay inside the type and refuse implicit conversion - Named scope region: the scope an unscoped enumeration supplies for optional qualification of its enumerators
- Struct (structure): a program-defined type bundling several variables into one
- Data member (member variable): one of the variables that makes up a struct or class
- Member selection operator (
.): selects a member of an object or of a reference to one - Member selection from pointer operator (
->): dereferences a pointer and selects a member in one step - Aggregate: a type that can hold several data members, which in C++ means arrays and structs with data members only
- Aggregate initialization: initializing an aggregate directly from a braced list of values
- Initializer list: the comma-separated list of values supplied in that form
- Memberwise initialization: matching those values to members one at a time, in declaration order
- Designated initializer: a C++20 initializer that names the member it applies to, and must follow declaration order
- Non-static member initialization: writing default values for members in the type definition
- Default member initializer: the value so written, used whenever that member is not explicitly initialized
- Padding: gaps the compiler inserts between members to keep them aligned, making a struct larger than its members total
- Class template: a definition from which the compiler instantiates a class type per set of template arguments
- Class template argument deduction (CTAD): the C++17 feature that deduces those arguments from an initializer
- Alias template: a templated name for a class template with some arguments already supplied
Looking Forward
Every struct you wrote in this chapter was a passive container: it held data, and the functions that operated on it lived outside it and took it as a parameter. The next step is to move those functions inside the type, so an object can act on its own data instead of waiting to be handed to something that will.
That step turns a struct into a class, and it brings a set of questions a plain aggregate never had to answer. Which members may outsiders touch, and which are internal bookkeeping? How does an object guarantee it is in a valid state from the moment it is created, rather than trusting whoever writes the initializer list? What happens when one is copied, or destroyed? Access specifiers, constructors, and destructors are the answers, and they build directly on the aggregate, initialization, and member-selection rules you have just reviewed. Nothing here gets replaced. It gets extended.
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.
Custom Data Types with Enums and Structs Summary - Quiz
Test your understanding of the lesson.
Practice Exercises
Card Game Data Types
Build a card game foundation using enums and structs. This exercise demonstrates scoped enumerations, struct initialization, and combining custom types to model real-world concepts.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!