Lesson 13 of 25

Structures

The Problem Structs Solve

Suppose you are storing a class of students. Without any way to group data, you end up with parallel containers: a vector of names, a vector of roll numbers, a vector of marks, all of them relying on index 3 meaning the same student in every one. Sort one and the correspondence is destroyed. Insert into one and forget the others, and they silently fall out of step. Every function that does anything with a student needs three parameters instead of one.

A struct fixes this by letting you define a new type that bundles those fields into a single value. Once Student exists as a type, you have one vector instead of three, sorting keeps everything together automatically, and functions take one parameter. You have also given the concept a name, which means the compiler can now catch you passing a roll number where a mark was expected.

Defining one is straightforward: the keyword struct, a name, and the fields in braces — and do not forget the semicolon after the closing brace, which is a leftover from C and produces a bewildering error message when omitted. By convention type names are capitalised.

Creating a value uses aggregate initialisation: braces with the values in declaration order. It is worth noticing what happens if you supply fewer values than there are fields — the remaining ones are value-initialised, meaning numbers become 0 and strings become empty. What you should not do is write Student s; for a struct with no default values, because then the numeric members hold garbage, exactly as an uninitialised local int does.

Example
#include <iostream>
#include <string>
#include <vector>

struct Student {
    std::string name;
    int roll;
    double marks;
};                       // <- the semicolon is required

int main() {
    // Aggregate initialisation, in declaration order
    Student a{"Ananya", 118, 91.5};
    Student b{"Rahul", 119, 65.0};

    std::cout << a.name << " scored " << a.marks << '\n';
    b.marks = 68.0;                     // fields are just variables

    // Fewer values: the rest are value-initialised (0 / empty)
    Student c{"Meera"};                 // roll 0, marks 0.0
    std::cout << c.roll << ' ' << c.marks << '\n';

    // One container instead of three parallel ones
    std::vector<Student> classList = {a, b, c};
    for (const auto& s : classList) {
        std::cout << s.roll << " " << s.name << " " << s.marks << '\n';
    }

    // Student d;      // legal, but roll and marks hold garbage. Avoid.
}
Notes
  • The missing semicolon after a struct definition produces errors that point at the next few lines and mention things you did not write. If a struct compiles fine on its own but the code after it explodes, check that semicolon first.

Default Values and Named Initialisers

Fields can be given a default directly in the definition: int width = 800;. This is called a default member initialiser, and it means any object created without an explicit value for that field starts with the default rather than with garbage. For a configuration struct — window size, retry count, timeout — this is exactly what you want, and it removes an entire class of "I forgot to set that one" bugs.

Once defaults exist, Config cfg; becomes a safe and readable way to say "give me the standard settings", and you can override just the ones you care about. This pattern is extremely common in real code, and it is worth reaching for whenever a type has more than three or four fields whose values are usually the same.

There is one readability problem with brace initialisation, and it gets worse the more fields a struct has: Config c{1920, 1080, true, false, 60}; tells the reader nothing about what any of those values mean, and swapping two bools by mistake compiles perfectly. C++20 added designated initialisers to fix this — you name each field as you set it, and the compiler checks the names. Fields you omit keep their defaults.

The rule attached to designated initialisers in C++ is that the fields must appear in the same order as they were declared. You may skip fields, but you may not reorder them, which is a stricter rule than the same syntax has in C. If your compiler is set to C++17, this syntax will not be available and the positional form is the fallback — in which case a short comment naming each argument is a reasonable substitute.

Example
#include <iostream>
#include <string>

struct Config {
    int width = 800;              // defaults, used unless overridden
    int height = 600;
    bool fullscreen = false;
    std::string title = "My App";
};

int main() {
    Config standard;                       // all defaults
    std::cout << standard.width << 'x' << standard.height << '\n';   // 800x600

    Config game{1920, 1080, true, "Cricket Manager"};   // positional

    // C++20 designated initialisers: named, checked, order preserved
    // Config tv{.width = 3840, .height = 2160, .fullscreen = true};
    // `title` keeps its default of "My App"

    std::cout << game.title << ' ' << game.fullscreen << '\n';

    // Overriding just one setting on top of the defaults
    Config custom;
    custom.fullscreen = true;
    std::cout << custom.width << ' ' << custom.fullscreen << '\n';   // 800 1
}
Notes
  • Beware of structs with several adjacent parameters of the same type. Config{1920, 1080, true, false} compiles happily with the two booleans swapped, and the mistake is invisible in review. Named initialisers, or separate small types instead of raw bools, both remove the risk.

Structs Can Have Functions Too

In C, a struct was purely data. In C++ a struct can also contain functions, called member functions or methods, and they can use the fields directly by name because they are already inside the object.

A member function that does not change the object should be marked const, written after the parameter list: double area() const { ... }. This is not decoration. A const object, or an object reached through a const reference — which is how most objects get passed around — can only have its const member functions called. Forget the keyword on a getter and every function taking const Rectangle& becomes unable to call it, and the error message will not obviously point at the missing const. Mark every non-modifying method const as you write it; retrofitting is much more annoying.

This raises the obvious question: if a struct can have methods, what is the difference between struct and class? Technically, exactly one thing — default access. Members of a struct are public unless you say otherwise; members of a class are private. That is the entire language-level difference; anything you can do with one you can do with the other.

The difference that matters is conventional, and C++ programmers follow it fairly consistently. Use struct when the type is a transparent bundle of data whose fields can be read and written freely, with no rules connecting them — a point, a colour, a configuration, a row of a file. Use class when the type has an invariant to protect: a rule that must always hold, such as "balance is never negative" or "the buffer pointer is either null or valid". Protecting an invariant means controlling every write, which means private data and public methods.

Example
#include <cmath>
#include <iostream>

struct Point {
    double x = 0.0;
    double y = 0.0;

    // const: promises not to modify this object
    double distanceTo(const Point& other) const {
        double dx = x - other.x;
        double dy = y - other.y;
        return std::sqrt(dx * dx + dy * dy);
    }

    // not const: it changes the object
    void moveBy(double dx, double dy) {
        x += dx;
        y += dy;
    }
};

void report(const Point& p) {
    // Only const member functions are callable here
    std::cout << p.distanceTo({0.0, 0.0}) << '\n';
    // p.moveBy(1, 1);      // error: p is const
}

int main() {
    Point p1{3.0, 4.0};
    Point origin;                       // {0.0, 0.0} from the defaults

    std::cout << p1.distanceTo(origin) << '\n';   // 5
    p1.moveBy(1.0, 1.0);
    report(p1);                                   // 6.40312
}
Notes
  • The rule of thumb people actually use: if you would be comfortable making every field public, write struct. The moment one field needs to be validated or kept in step with another, you want class — which is the subject of the next two lessons.

Working with Structs in Practice

Structs are values, and they behave like values everywhere. Assigning one copies every field. Passing one to a function by value copies it too, which is why the same rule as always applies: pass by const reference unless the struct is tiny, because a struct holding a std::string is at least as expensive to copy as that string.

Returning a struct by value is the natural way for a function to hand back several related results at once, and it is the modern alternative to output parameters. Copy elision means it costs no more than returning one value would.

Sorting a container of structs is the operation you will perform most often, and the standard library needs to be told what "less than" means for your type. There are two ways. Pass a comparison lambda to std::sort, which is ideal when the ordering is specific to that one call — sort by marks here, by name there. Or define operator< as a member function, which gives the type a single natural ordering and then works with std::sort, std::set and std::map without further effort.

C++17 structured bindings also work on structs whose members are all public: auto [name, roll, marks] = student; unpacks the fields into named variables in one line. It reads well when you want to work with the parts individually, and it is the same feature that makes iterating a std::map so much nicer.

One last detail that occasionally surprises people: sizeof a struct can be larger than the sum of its fields. Processors read certain types faster at certain addresses, so the compiler inserts invisible padding between members to keep them aligned. Ordering the fields from largest type to smallest usually reduces it. This matters when you are storing millions of them or writing them to a file, and not at all otherwise.

Example
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

struct Student {
    std::string name;
    int roll = 0;
    double marks = 0.0;

    // A single natural ordering for this type
    bool operator<(const Student& other) const { return roll < other.roll; }
};

// Return several related values as one struct
struct Stats { double best; double worst; double average; };

Stats summarise(const std::vector<Student>& list) {
    Stats s{0.0, 100.0, 0.0};
    double total = 0.0;
    for (const auto& st : list) {
        s.best  = std::max(s.best, st.marks);
        s.worst = std::min(s.worst, st.marks);
        total  += st.marks;
    }
    s.average = list.empty() ? 0.0 : total / list.size();
    return s;
}

int main() {
    std::vector<Student> cls = {
        {"Ananya", 118, 91.5}, {"Rahul", 119, 65.0}, {"Meera", 117, 78.0}
    };

    std::sort(cls.begin(), cls.end());          // uses operator< : by roll

    std::sort(cls.begin(), cls.end(),           // a one-off rule: by marks
              [](const Student& a, const Student& b) { return a.marks > b.marks; });

    // C++17 structured bindings
    auto [best, worst, average] = summarise(cls);
    std::cout << best << ' ' << worst << ' ' << average << '\n';

    for (const auto& s : cls) std::cout << s.name << ' ' << s.marks << '\n';
}
Notes
  • To use your struct as a key in std::map or an element of std::set, it needs operator<. To use it in std::unordered_map or std::unordered_set it needs a hash function instead, which is more work — so for keys, a plain int or std::string is usually the simpler design.
Ask AI