Lesson 2: User-Defined Types

Lesson 0002 — A Tour of C++, Chapter 2 (§2.1–§2.6)

Chapter 1 gave you the procedural floor — types, functions, and the machine mapping. But built-in types (int, double, pointers) are deliberately low-level: they reflect the hardware, not the problem. Real programs need higher-level abstractions — types that name the concepts of the domain and carry their own invariants. This chapter introduces the four mechanisms C++ provides for that: structs (data grouping), classes (data + operations with access control), enumerations (named constant sets), and unions (shared-memory packing). Every lesson in this course builds on the last: here you learn the vocabulary (“class”, “constructor”, “access specifier”) you will use throughout.

Why user-defined types matter now: the standard library (vector, string, map) is built from exactly these mechanisms. Understanding how struct and class work at the language level makes the library's design decisions legible instead of magical.

2.1 Introduction — Built-in vs. User-Defined Types

C++'s built-in types (int, double, pointers) and their operations are rich but deliberately low-level. They directly and efficiently reflect the capabilities of conventional computer hardware. They do not provide high-level facilities to write advanced applications conveniently.

C++ augments the built-in types with abstraction mechanisms — facilities for building user-defined types. These are the backbone of the standard library and the primary subject of this course. A user-defined type is often preferred over a built-in type because it is easier to use, less error-prone, and typically as efficient as direct use of the underlying built-in representation.

Two names for user-defined types in C++: class and enumeration. A struct is a class with its members public by default; an enum is a type whose values are a finite set of named constants. The rest of this chapter walks through all four.

built-in types → low-level, hardware-native
abstraction mechanisms → user-defined types → struct, class, enum, union
C++ augments built-in types with abstraction mechanisms so programmers can build domain-level types.

2.2 Structures — Grouping Related Data

The first step in building a new type is organizing the elements it needs into a data structure. The C++ keyword for that is struct.

Defining a struct

Consider a simple vector-of-doubles with a pointer and a size:

struct Vector {
    double* elem;  // pointer to elements
    int sz;        // number of elements
};

A variable of type Vector is declared like any other type:

Vector v;

But that is not useful yet. v.elem is an uninitialized pointer — it doesn't point anywhere. To make the vector functional we must give it memory to hold its elements.

Initializing a struct: allocation

void vector_init(Vector& v, int s)
// initialize a Vector
{
    v.elem = new double[s];  // allocate s doubles on the free store
    v.sz = s;
}

The & in Vector& means we pass v by non-const reference — vector_init() can modify the caller's vector. The new operator allocates memory from an area called the free store (also known as dynamic memory or the heap). Objects allocated on the free store live until they are explicitly destroyed with delete.

Using the struct

double read_and_sum(int s)    // read s doubles from cin and return their sum
{
    Vector v;
    vector_init(v, s);       // allocate s elements for v

    for (int i = 0; i != s; ++i)
        std::cin >> v.elem[i];  // read into elements

    double sum = 0;
    for (int i = 0; i != s; ++i)
        sum += v.elem[i];     // compute the sum of the elements

    return sum;
}

Access to struct members through a name (or a reference) uses the dot operator: .. Access through a pointer uses the arrow operator: ->.

void f(Vector v, Vector& rv, Vector* pv)
{
    int i1 = v.sz;       // access through name
    int i2 = rv.sz;      // access through reference
    int i3 = pv->sz;    // access through pointer
}

The free-store pattern. The struct itself is a fixed-size “handle” — a pointer (to the heap) plus a size. The data it refers to can grow or shrink; the handle stays the same size. This is the foundational technique for handling variable amounts of data in C++. The standard library's vector works exactly this way (Ch. 12).

2.3 Classes — Encapsulating Data and Operations

Keeping data separate from the operations on it has an obvious advantage: you can use the data in arbitrary ways. But it also means users can see and corrupt the representation directly, bypassing every invariant the designer intended. A tighter connection between representation and operations is needed for a type to behave like a “real” type.

The language mechanism is the class. A class has a set of members — data, functions, or type definitions — grouped under access specifiers. The interface is the set of public members; users interact with the class only through it. The implementation (private members) is invisible and inaccessible; we can change it later without breaking user code.

The Vector class (first-class version)

class Vector {
public:
    Vector(int s) : elem{new double[s]}, sz{s} { }
    // construct a Vector of s elements

    double& operator[](int i) { return elem[i]; }
    // element access: subscripting

    int size() { return sz; }
    // number of elements

private:
    double* elem;  // pointer to the elements
    int sz;        // the number of elements
};

What changed from the struct version.

Using the class

Vector v(6);                // a Vector with 6 elements
v[0] = 1.0;                 // write through subscript
double x = v[3];            // read through subscript

for (int i = 0; i != v.size(); ++i)
    std::cin >> v[i];        // cleaner than v.elem[i]

The read_and_sum() example from §2.2 simplifies: users no longer need to know that the representation is a pointer to heap memory and a size. They just call v.size() and v[i]. The representation is entirely encapsulated.

Constructor details

The constructor Vector(int s) : elem{new double[s]}, sz{s} { } says:

  1. Allocate s doubles on the free store and make elem point at that memory.
  2. Set sz to s.

The member initializer list (: elem{...}, sz{...}) is the preferred way to initialize members, especially for const members, references, and members of classes without default constructors. It is more efficient than assigning inside the constructor body because member initialization happens once (in-place) rather than twice (default-construct then assign).

A struct is simply a class with its members public by default. You can define constructors and other member functions for structs just as you would for classes. The choice between struct and class is a convention — use class when you want encapsulation; use struct for simple data aggregates with public access.

2.4 Enumerations — Named Constants with Type Safety

Enumerations are user-defined types whose values are a finite, named set. They make code more readable and less error-prone than raw integer constants.

Strongly-typed enum class (C++11)

enum class Color { red, blue, green };
enum class Traffic_light { green, yellow, red };

Color col = Color::red;
Traffic_light light = Traffic_light::red;

Scoping matters: Color::red and Traffic_light::red are distinct values that happen to share the name. They cannot be mixed or compared accidentally.

Color x1 = red;            // error: which red?
Color y2 = Traffic_light::red;  // error: Traffic_light::red is not a Color
Color z3 = Color::red;     // OK
auto x4 = Color::red;      // OK: Color::red is a Color

Enum class values also do not implicitly convert to integers — preventing a whole class of accidental integer comparisons:

int i = Color::red;         // error: Color::red is not an int!
Color c = 2;                // error: 2 is not a Color

Color x = Color{5};         // OK but verbose — explicit underlying value
Color y {6};                // also OK
int x = int(Color::red);   // explicit conversion to underlying type (int)

Custom operations on enum class: define a member or free function to make the type practically usable:

Traffic_light& operator++(Traffic_light& t)  // prefix increment: ++
{
    switch (t) {
        case Traffic_light::green:  return t = Traffic_light::yellow;
        case Traffic_light::yellow: return t = Traffic_light::red;
        case Traffic_light::red:    return t = Traffic_light::green;
    }
}

auto signal = Traffic_light::red;
Traffic_light next = ++signal;  // next becomes Traffic_light::green

If qualifying every enumerator is verbose, use a using enum declaration inside a limited scope:

Traffic_light& operator++(Traffic_light& t)
{
    using enum Traffic_light;  // bring enumerators into scope here
    switch (t) {
        case green:  return t = yellow;
        case yellow: return t = red;
        case red:    return t = green;
    }
}

Plain (unscoped) enums

C++ inherits plain enum from C and keeps it for compatibility. The enumerators are in the enclosing scope and implicitly convert to int:

enum Color { red, green, blue };
int col = green;  // OK: green is 1

Plain enumerations have been in C++ since C++98 and are common in existing code. Prefer enum class in new code — the type safety prevents bugs that plain enums make too easy.

2.5 Unions — Shared Memory

A union is a struct in which all members are allocated at the same memory address, so the union occupies only as much space as its largest member. At any given time a union can hold a value for only one member.

Motivating example: a symbol-table entry that holds a name and a value, where the value is either a pointer (Node*) or an integer:

// Tagged union approach (manual tagging)
enum class Type { ptr, num };

struct Entry {
    std::string name;
    Type t;       // type tag
    Node* p;      // use if t == Type::ptr
    int i;        // use if t == Type::num
};

void f(Entry* pe) {
    if (pe->t == Type::num)
        std::cout << pe->i;
}

p and i are never used simultaneously, so memory is wasted unless we pack them into a union:

union Value { Node* p; int i; };
// Value::p and Value::i share the same memory address

The language does not track which member is active — the programmer must do that by pairing the union with a type tag. This tagged union pattern is common and useful, but the correspondence between the tag and the union member it describes is error-prone to maintain by hand.

The modern solution: std::variant

Standard library variant (§15.4.1) eliminates most direct uses of unions. It stores a value of one of a set of alternative types, with the active type tracked automatically:

#include <variant>

struct Entry {
    std::string name;
    std::variant<Node*, int> v;   // v holds either a Node* or an int
};

void f(Entry* pe) {
    if (std::holds_alternative<int>(pe->v))
        std::cout << std::get<int>(pe->v);
}

For many uses variant is simpler and safer than a naked union. Use it unless you have a specific reason to manage the raw union manually.

The takeaway: naked unions are low-level space optimization. Prefer enum class for type-safe sets of constants, and variant over manual tagged unions. The standard library already has the safe versions; building them yourself is error-prone and unnecessary unless you need the absolute control raw memory gives.

What each mechanism gives you

Mechanism When to use Type safety? Overhead
struct Simple data aggregates; public access by convention No stronger than its members None (zero-cost abstraction)
class Encapsulated types with invariants; private + public separation Enforced by compiler (access control) None
enum class Named constants that form a closed set; need scoping and type safety Strong (no implicit int conversion) Typically int-sized; zero overhead
plain enum Existing C code; interoperating with APIs that use unscoped enums Weak (implicit int conversion, enumerators leak) None
union Memory optimization when only one member is active; low-level systems code None (programmer must track active member manually) Saves sizeof(largest member) × count
std::variant Tagged union with automatic type tracking; preferred over raw union Type-safe (visit/handles_alternative/check) Size of largest alternative + discriminator

2.6 Advice

Stroustrup closes the chapter with a subset of the C++ Core Guidelines. All 10 items, with the section where each is introduced:

# Guideline §
1 Prefer well-defined user-defined types over built-in types when the built-in types are too low-level 2.1
2 Organize related data into structures (structs or classes) 2.2
3 Represent the distinction between an interface and an implementation using a class 2.3
4 A struct is simply a class with its members public by default 2.3
5 Define constructors to guarantee and simplify initialization of classes 2.3
6 Use enumerations to represent sets of named constants 2.4
7 Prefer enum class over plain enums to minimize surprises 2.4
8 Define operations on enumerations for safe and simple use 2.4
9 Avoid naked unions; wrap them in a class together with a type field 2.5
10 Prefer std::variant over naked unions 2.5

Retrieval Quiz

Quick check: struct vs. class

Which statement about struct and class in C++ is true?

Trace through enum class scoping

Given enum class Color { red, green, blue }; which code compiles?

Union memory layout

Memory is wasted in a union when:

Constructor initializer list

What does the colon after a constructor parameter list do?

std::variant vs. naked union

Why is std::variant typically preferred over a raw union?


Notes

User-defined types :-

a) built-in types in the language core (eg char, int, double)

b) user-defined struct, class, enum ; no overhead vs built-ins

struct v/s class :-

struct class with public members by default ; data grouping

class private by default ; separates interface from implementation

. member direct access (eg v.sz) ; -> member access via pointer (eg p->sz)

constructor same name as class ; no return type ; guarantees initialization

member initializer list :sz{s}, elem{new double[s]} ; runs before body

operator[] index access ; returns double& writable (eg v[i] = x)

enum class v/s plain enum :-

enum class scoped enumerators ; no implicit int type-safe

plain enum unscoped ; enumerators leak ; implicit int conversion

using enum brings enumerators into scope (eg using enum Color; red)

operator++ advance enumerator (eg Traffic_light green yellow red)

union v/s variant :-

union one active member at a time ; programmer tracks it

tag field records which member is active

naked union × wrap in class with type field

std::variant type-safe tagged union ; tracks active member automatically


Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed., Chapter 2: "User-Defined Types." Addison-Wesley.
Reference: Chapter 1–2 Quick Reference & Glossary — keep it beside you while you study.
Recommended supplement: cppreference on classes, enumerations, and variant.

Questions? Ask your agent — your teacher — about anything unclear: a quiz answer, a code example, or how the struct-to-class progression maps to the abstractions you use in your own code. Follow-ups are expected, not optional.

Chapters

  1. Lesson 1: The Basics (Ch. 1)
  2. Lesson 2: User-Defined Types (Ch. 2 — this lesson)
  3. Lesson 3: Separate Compilation (Ch. 3)
  4. Lesson 4: Error Handling (Ch. 4)
  5. Lesson 5: Classes (Ch. 5)
  6. Lesson 6: Essential Operations (Ch. 6)
  7. Lesson 7: Templates (Ch. 7)
  8. Lesson 8: Concepts and Generic Programming (Ch. 8)
  9. Lesson 9: Library Overview (Ch. 9)
  10. Lesson 10: Strings and Regular Expressions (Ch. 10)
  11. Lesson 11: Input and Output (Ch. 11)
  12. Lesson 12: Containers (Ch. 12)
  13. Lesson 13: Algorithms (Ch. 13)
  14. Lesson 14: Ranges (Ch. 14)
  15. Lesson 15: Pointers and Containers (Ch. 15)
  16. Lesson 16: Utilities (Ch. 16)
  17. Lesson 17: Numerics (Ch. 17)
  18. Lesson 18: Concurrency (Ch. 18)
  19. Lesson 19: History and Compatibility (Ch. 19)
← Course Dashboard ← Ch. 1: The Basics Ch. 3: Separate Compilation → Quick Reference →