Skip to content

Lecture 7 - Structs: Bundling Related Data

Last week ended on the heap: memory whose lifetime you control, and the promise that we would put it to work building real data structures. We start today. So far every variable has held one value - an int, a char, a pointer. But most real things are made of several values that belong together: a point is an x and a y; a person is a name and an age. A struct lets you bundle those values into a single named type, so they travel together as one unit.

1. Recap and the problem structs solve

  • The heap gives us memory sized at run time and memory that outlives its function. We now need a way to describe what lives in that memory once it is more than a bare array of ints.
  • Suppose you are keeping a small roster: for each person you have a name and an age. Without structs you are forced into parallel arrays - one array per field, held together only by a shared index:
char names[100][32];   /* names[i] is the name of person i */
int  ages[100];        /* ages[i]  is the age  of person i */
  • This works, but it is fragile, and notice the fields are not even the same type, so you cannot fold them into one array. The two arrays can fall out of step; if you sort by age you must remember to swap the matching name; a function that takes "a person" needs two parameters; and adding a third field (a GPA, an ID) means threading a third array through every loop and call.
  • A struct fixes this by making "a person" a single type with named parts - a name and an age bundled together. One variable, one parameter, one array element holds the whole record, name and age travelling as one unit.

In-class exercise: Warm-up (on the computer) - build the roster the hard way first, with parallel arrays: read a count, then read each person's name and age into two arrays, and print them back. Feel how the two arrays must move in lockstep - that friction is exactly what a struct removes. Once we have struct (Section 2), you will rewrite this same roster as a single array of person_t; keep the program handy. Also, consider the various options to input a name if it has a space in it.


2. Defining and using a struct

A structure groups several variables - its members (or fields) - under one type name. You define the shape once, then declare variables of that type. Here is the type the roster wanted, bundling a name and an age into one record:

struct person {
    char name[32];       /* a fixed-size string, stored inside the struct */
    int  age;
};              /* note the semicolon after the closing brace */
  • struct person is now a type, just like int - except its members are of different types, a char array and an int. That is precisely what two parallel arrays could never really capture. The definition allocates no storage by itself; it is a blueprint.
  • The semicolon after the closing brace is required and easy to forget. A struct definition is a statement.

To drill the pure mechanics, we will keep a second, even simpler struct beside it - just two ints, x and y:

struct point {
    int x;
    int y;
};

Declaring and initializing

struct point p;          /* two ints' worth of storage, members not yet set */
p.x = 3;                 /* the dot operator reaches a member */
p.y = 4;

struct point q = {1, 2};             /* initialize members in order: x=1, y=2 */
struct point r = {.x = 5, .y = 6};   /* or name them (designated initializers) */
  • The dot operator . selects a member of a struct: p.x is the x inside p. You read and write p.x exactly like any int.
  • {1, 2} fills the members in declaration order. The named form {.x = 5, .y = 6} is clearer and does not depend on order.
  • A member left out of an initializer is set to zero: struct point z = {0}; zeroes every field.

Using members

struct point p = {3, 4};
int dist_sq = p.x * p.x + p.y * p.y;     /* members are just ints */
printf("(%d, %d)\n", p.x, p.y);          /* (3, 4) */
  • Once you have selected a member with ., it behaves like a normal variable of its type. There is nothing special about p.x other than where it lives.

Dropping the word struct with typedef

Writing struct point and struct person on every declaration is a mouthful. A typedef gives a type a second, shorter name; after it, you use that name wherever the long one worked.

First, let's look at how typedef works with a simple type:

typedef unsigned int uint;   /* "uint" now means "unsigned int" */
uint x = 42;                 /* same as "unsigned int x = 42;" */

It is common to name the the new type with a _t suffix, so you can tell at a glance that it is a derived/defined type:

typedef unsigned int uint_t;   /* "uint_t" now means "unsigned int" */
uint_t y = 99;                 /* same as "unsigned int y = 99;" */

So, now, we can do the same with structs, after the struct is defined:

struct point { int x; int y; };
struct person { char name[32]; int age; };

typedef struct point  point_t;    /* "point_t" now means "struct point" */
typedef struct person person_t;   /* "person_t" now means "struct person" */

point_t  p    = {3, 4};           /* no need to write "struct point" */
person_t pers = {"Ada", 36};
  • typedef struct point point_t; does not create a new type - it makes point_t an alias for the existing struct point. The two names mean the same type and can be mixed freely.
  • Most code combines the struct definition and the typedef into one statement, dropping the tag entirely:
typedef struct {
    char name[32];
    int  age;
} person_t;                        /* define the shape and name it person_t at once */
  • The convention we follow is to suffix a typedef name with _t (point_t, person_t) so a reader can tell at a glance that the word is a type. From here on we use these short names, and typedef each new struct the same way.
  • typedef is pure syntactic sugar: it changes how you write the type, never how the value is laid out or copied. Everything about members, the dot operator, and copy semantics below is exactly the same.

3. A struct is a value: copy semantics

This is the idea that surprises people, so we make it explicit early. A struct is a value, like an int. Assigning one struct to another copies every member, and passing a struct to a function copies the whole struct into the parameter.

point_t a = {1, 2};
point_t b = a;           /* b is a full, independent copy: b.x=1, b.y=2 */
b.x = 99;                /* changes b only; a is untouched */
printf("%d %d\n", a.x, b.x);   /* 1 99 */

Passing a struct to a function copies it

void bump(point_t p) {
    p.x = p.x + 1;       /* changes the COPY inside bump, not the caller's */
}

point_t origin = {0, 0};
bump(origin);
printf("%d\n", origin.x);      /* still 0 - bump modified its own copy */
  • Just like an int, a struct passed by value cannot be changed by the callee. The function gets its own copy. (Next lecture we pass a pointer to a struct when we do want the function to change the caller's struct - the same move as swap.)

Returning a struct

point_t make_point(int x, int y) {
    point_t p = {x, y};
    return p;            /* the whole struct is copied back to the caller */
}

point_t c = make_point(7, 8);   /* c.x = 7, c.y = 8 */
  • Returning a struct copies it out to the caller. Unlike returning the address of a local (which dangles), returning the struct by value is perfectly safe - it is a copy, not a pointer into the dead frame.

In-class exercise: Part A1 (pen and paper) - predict the output of a program that copies a struct and passes one to a function.


4. Nested structs

A struct member can itself be a struct. This is how you build bigger shapes out of smaller ones.

typedef struct {
    point_t top_left;
    point_t bottom_right;
} rect_t;

rect_t box = { {0, 10}, {20, 0} };        /* two points, each {x, y} */

int width  = box.bottom_right.x - box.top_left.x;    /* chain the dots */
int height = box.top_left.y - box.bottom_right.y;
printf("%d x %d\n", width, height);                  /* 20 x 10 */
  • To reach a member of a nested struct you chain the dot operator: box.top_left.x is the x of the top_left point of box. Read it left to right, one dot at a time.
  • The nested initializer mirrors the shape: { {0, 10}, {20, 0} } - an outer brace for the rect, an inner brace for each point.
  • Copy semantics still apply, all the way down: copying a rect copies both of its points, which copies all four ints. A struct copy is deep for its members (though not, as we will see, for anything a member merely points at).

5. Arrays of structs

Because a struct is a type, you can make an array of structs - a table where each row is a full record. This is the warm-up done right: one array of person records instead of two parallel arrays that have to move in lockstep.

person_t roster[3] = {
    {"Ada", 36},
    {"Alan", 41},
    {"Grace", 79},
};

for (int i = 0; i < 3; i++) {
    printf("%s: %d\n", roster[i].name, roster[i].age);
}
  • roster[i] is one whole person_t; roster[i].age reaches into that element. First index the array, then dot into the element.
  • Each element carries its own copy of every field, including the 32-byte name buffer. The array is one contiguous block of records - and there is no second array to keep in step.
  • Finding the oldest person is the same max-scan as always, but the "value" is now a record and the key is one of its fields:
int best = 0;
for (int i = 1; i < 3; i++) {
    if (roster[i].age > roster[best].age) {
        best = i;
    }
}
printf("oldest: %s\n", roster[best].name);   /* oldest: Grace */

In-class exercise: Part B, Exercise B2 (on the computer) - build an array of records and scan it for the largest field.


6. Wrap-up

  • A struct bundles several named members into one type. Define it once (with the trailing semicolon), then declare variables and reach members with the dot operator ..
  • A typedef gives a struct a shorter name: typedef struct person person_t; (or define and name it in one shot) lets you write person_t instead of struct person. It is pure sugar - the layout and copy behaviour are unchanged; we suffix such names with _t.
  • A struct is a value: assignment and function calls copy every member. Passing by value means a function cannot change the caller's struct; returning a struct by value is safe (it is a copy, not a dangling address).
  • Structs nest (chain the dots: box.top_left.x) and go into arrays (index first, then dot: roster[i].age), which is how you build tables of records.
  • Cliffhanger: copying a whole struct into every function is wasteful, and we still cannot change a caller's struct. Next lecture: pointers to structs and the -> operator fix both, which also let a struct own heap memory through a pointer member; then enum and union round out our type toolkit.