46 Structs
What to know first
Looking back
Chapter 23 said “a type is a set of values plus an agreement about operations”, and every type used so far has been one that already existed (int, double, char, pointers). Then what does it mean for a programmer to make a new type?
A. It is settling a new shape of memory and giving it a name. Declare “I shall call a lump of two integers side by side a point”, and from that moment point is a fully-fledged type from which variables can be made, which can be passed to functions and laid out as an array. If chapter 38′s array was a repetition of the same type, a struct is a bundle of different types — and the moment that bundle gets a name, the program’s vocabulary grows.
The need for this chapter, and its context
-> nor an array member can be explained. Part 8 following part 7 is no accident.By the end of this chapter
. and ->), and how a struct travels as a value.The questions this chapter answers
- Should the biggest member always come first, then?
- Is “filled with zero” not the same thing as “made null” for a pointer?
- Writing
struct pointwithstructevery time is a nuisance — can it not be shortened? - Can a struct hold itself as a member — it seems necessary for making something like a list.
(In chapter 26′s families a struct was both an aggregate type and a derived type. This chapter is the inside of that cell.)
46.1 Declaration, initialisation, access
examples-en/ch46/point.c
#include <stdio.h>
struct point {
int x;
int y;
};
struct point moved(struct point p, int dx, int dy)
{
return (struct point){ .x = p.x + dx, .y = p.y + dy }; /* a compound literal */
}
int main(void)
{
struct point a = { .x = 3, .y = 4 }; /* designated initialisation */
struct point b = moved(a, 10, -1);
printf("a = (%d, %d)\n", a.x, a.y); /* access through a value: . */
printf("b = (%d, %d)\n", b.x, b.y);
struct point *p = &b;
printf("p->x = %d\n", p->x); /* access through a pointer: -> */
printf("sizeof(struct point) = %zu\n", sizeof(struct point));
return 0;
}
Output
a = (3, 4)
b = (13, 3)
p->x = 13
sizeof(struct point) = 8
Declaration is struct point { int x; int y; }; — each item inside the braces is called a member. The declaration itself takes no memory. It is only a definition saying “a type of this shape exists”; a variable appears when you write struct point a;.
For initialisation we recommend, as in the demonstration, writing the member names — the designated initializer (C99): { .x = 3, .y = 4 }. It reads better than the order-dependent {3, 4} and stays safe if members are added or reordered. Members not written are filled with 0.
Access has two notations — a dot for a value (a.x), an arrow for a pointer (p->x). The arrow is in fact an abbreviation of (*p).x (chapter 35′s dereference plus dot). Handling structs through pointers is overwhelmingly common, which is why it got its own notation.
Compound literal — the demonstration’s (struct point){ .x = ..., .y = ... } is the notation for “making one unnamed struct value on the spot” (C99). It is useful for handing a struct over immediately as a return value or an argument.
46.2 The first surprise of sizeof — not the sum of the members
Make a struct, ask for its size, and the guess is usually wrong.
examples-en/ch46/sizeof_first.c
/* Why the sum of member sizes is not the struct's size — print the layout. */
#include <stddef.h>
#include <stdio.h>
/* the same members, only the order differs */
struct loose { char a; int b; char c; }; /* a big one between small ones */
struct tight { int b; char a; char c; }; /* biggest first */
int main(void)
{
printf("sum of member sizes: %zu + %zu + %zu = %zu bytes\n",
sizeof(char), sizeof(int), sizeof(char),
sizeof(char) * 2 + sizeof(int));
printf("\nstruct loose { char a; int b; char c; }\n");
printf(" sizeof = %zu, _Alignof = %zu\n",
sizeof(struct loose), alignof(struct loose));
printf(" offsetof(a) = %zu, offsetof(b) = %zu, offsetof(c) = %zu\n",
offsetof(struct loose, a), offsetof(struct loose, b),
offsetof(struct loose, c));
printf("\nstruct tight { int b; char a; char c; }\n");
printf(" sizeof = %zu, _Alignof = %zu\n",
sizeof(struct tight), alignof(struct tight));
printf(" offsetof(b) = %zu, offsetof(a) = %zu, offsetof(c) = %zu\n",
offsetof(struct tight, b), offsetof(struct tight, a),
offsetof(struct tight, c));
/* draw the layout: named cells for members, dots for the gaps */
puts("\ncell by cell (numbers are offsets, dots are padding):");
for (size_t i = 0; i < sizeof(struct loose); i++) {
char mark = '.';
if (i == offsetof(struct loose, a)) mark = 'a';
else if (i >= offsetof(struct loose, b)
&& i < offsetof(struct loose, b) + sizeof(int)) mark = 'b';
else if (i == offsetof(struct loose, c)) mark = 'c';
printf("%c", mark);
}
printf(" <- loose (%zu bytes)\n", sizeof(struct loose));
for (size_t i = 0; i < sizeof(struct tight); i++) {
char mark = '.';
if (i < sizeof(int)) mark = 'b';
else if (i == offsetof(struct tight, a)) mark = 'a';
else if (i == offsetof(struct tight, c)) mark = 'c';
printf("%c", mark);
}
printf(" <- tight (%zu bytes)\n", sizeof(struct tight));
/* laid out as an array, the difference multiplies */
printf("\nwith a million elements: loose %zu MiB, tight %zu MiB\n",
sizeof(struct loose) * 1000000u / (1024 * 1024),
sizeof(struct tight) * 1000000u / (1024 * 1024));
return 0;
}
Output
sum of member sizes: 1 + 4 + 1 = 6 bytes
struct loose { char a; int b; char c; }
sizeof = 12, _Alignof = 4
offsetof(a) = 0, offsetof(b) = 4, offsetof(c) = 8
struct tight { int b; char a; char c; }
sizeof = 8, _Alignof = 4
offsetof(b) = 0, offsetof(a) = 4, offsetof(c) = 5
cell by cell (numbers are offsets, dots are padding):
a...bbbbc... <- loose (12 bytes)
bbbbac.. <- tight (8 bytes)
with a million elements: loose 11 MiB, tight 7 MiB
char + int + char looks like six bytes; it is twelve. The extra six are padding — empty space between the members and at the end.
Figure 47.1 — The hatched cells are padding. Each member sits at a multiple of its alignment, and space is added at the end too.
The reason is chapter 6′s alignment. An int must sit at an address that is a multiple of four, so three bytes go empty after the first char, and three more after the last one — because when this struct is laid out as an array, the next element’s int must be aligned too.
There are only three rules.
- Each member sits at an offset that is a multiple of its own alignment.
- The struct’s alignment is the maximum of its members’ alignments.
- The struct’s size is rounded up to a multiple of that alignment (tail padding).
So changing only the order can shrink it. The demonstration’s tight puts the big one first and turns twelve bytes into eight. With a million elements that is 11 MiB against 7 MiB — and not only memory: the number of elements that fit in cache changes with it (chapter 11).
The tool for seeing the layout is offsetof from <stddef.h>; the demonstration uses it to print where each member starts. “If it differs from what you thought, ask” is the knack here.
Q. Should the biggest member always come first, then?
A. It makes a fine default but a poor rule. A readable order often matters more (keeping related members together), and where only one struct is ever made, a few bytes are nothing.
The places to think about order are clear — when very many of the same struct are laid out (arrays, pools, nodes), and where memory is tight (embedded). Elsewhere it is enough to print the size once and not be surprised.
The devices for removing padding (#pragma pack, packed) and for raising alignment (alignas) are in chapter 47. What to know first is that they are either non-standard or have a price.
46.3 Zeroing the whole thing — { 0 } and { }
The previous section passed over “members you leave out are filled with zero” in a single clause. That clause is the foundation of an idiom used every day, so it is worth a section of its own.
struct config c = {0}; /* the old idiom */
struct config c = {}; /* C23 onwards — the empty initializer */examples-en/ch46/zeroinit.c
/* Zeroing a whole struct — { 0 } and C23's { } null out pointer members too. */
#include <stdio.h>
#include <string.h>
struct inner { int k; char *note; };
struct config {
int retries;
char *path; /* a pointer member */
double ratio;
struct inner in; /* which contains another pointer */
char name[4];
};
static void dump(const char *tag, const struct config *c)
{
printf("%s retries=%d path=%s ratio=%g in.k=%d in.note=%s name[0]=%d\n",
tag, c->retries,
c->path == NULL ? "null" : "not null",
c->ratio, c->in.k,
c->in.note == NULL ? "null" : "not null",
c->name[0]);
}
static void bytes(const char *tag, const void *p, size_t n)
{
const unsigned char *b = p;
size_t zero = 0;
for (size_t i = 0; i < n; i++)
zero += (b[i] == 0);
printf("%s %zu of %zu bytes are zero\n", tag, zero, n);
}
int main(void)
{
struct config a = {0}; /* only the first member is spelled out */
struct config b = {}; /* C23: empty initializer — whole object */
dump("{0} :", &a);
dump("{ } :", &b);
/* Designated initializers behave the same: what you leave out is
default-initialized. */
struct config c = { .retries = 3 };
dump("{.retries=3}:", &c);
/* memset writes all-bits-zero, which is not the same promise as "null" —
the same here, but the standard does not guarantee it. */
struct config m;
memset(&m, 0, sizeof m);
printf("after memset, is path null? %s (on this implementation)\n",
m.path == NULL ? "yes" : "no");
printf("\nsizeof(struct config) = %zu, sum of member sizes = %zu"
" — the difference is padding\n",
sizeof(struct config),
sizeof(int) + sizeof(char *) + sizeof(double)
+ sizeof(struct inner) + 4);
bytes("{0} :", &a, sizeof a);
bytes("{ } :", &b, sizeof b);
return 0;
}
Output
{0} : retries=0 path=null ratio=0 in.k=0 in.note=null name[0]=0
{ } : retries=0 path=null ratio=0 in.k=0 in.note=null name[0]=0
{.retries=3}: retries=3 path=null ratio=0 in.k=0 in.note=null name[0]=0
after memset, is path null? yes (on this implementation)
sizeof(struct config) = 48, sum of member sizes = 40 — the difference is padding
{0} : 48 of 48 bytes are zero
{ } : 48 of 48 bytes are zero
46.3.1 What is actually guaranteed
The standard (C23 §6.7.11) gives this a name: default initialization. Anything not initialised explicitly is filled in as follows.
| Type of the member | What it is filled with |
|---|---|
| Pointer | A null pointer |
| Arithmetic type (integer, floating) | (positive or unsigned) zero |
| Decimal floating type | Positive zero; the quantum exponent is implementation-defined |
| Aggregate (struct, array, union) | The same rules again, recursively |
Table 47.1
That answers this section’s central question: pointer members are initialised to null — recursively, including pointers inside nested structs. In the demonstration both path and in.note come out null.
Q. Is “filled with zero” not the same thing as “made null” for a pointer?
A. On the overwhelming majority of implementations the result is the same, but the promise is a different promise.
What the standard guarantees is “becomes a null pointer value”, not “becomes all-bits-zero” (chapter 36, on what null really is). Implementations where the representation of null is not all-bits-zero have existed, and the standard still leaves room for them. So {0} and {} give you null everywhere, while memset(&c, 0, sizeof c) only ever gives you all-bits-zero. On an implementation where those two promises come apart, the latter is not null.
Chapter 36′s demonstration empties this very struct both ways and prints the bytes side by side — on this machine the results agree, and the promises do not. The same goes for floating point: {0} promises the value 0.0, memset promises a bit pattern. The working rule is simple — use an initializer to empty a struct, and keep memset for other purposes (such as the padding question below).
46.3.2 The fine difference between {0} and {} — padding
They are nearly the same, and they part company in one place. For an aggregate subject to default initialization, C23 states that any padding is initialized to zero bits. With {} the whole object is subject to default initialization, so the gaps between members are zero too. With {0} the first member is initialised explicitly, so what gets default initialization is the remaining members — the struct’s own padding bytes are not covered, and their values are unspecified.
In the demonstration all 48 bytes come out zero, but that is this implementation’s behaviour, not a promise.
The distinction is usually irrelevant, and then suddenly matters when you compare whole structs with memcmp or write them out byte-wise to a file or a socket. The rule for those cases:
- You only need the values to be right →
{0}or{}. - The padding must be zero too (comparison, serialisation) →
{}in C23; otherwisememsetfirst and then assign the members you need.
A common misconception. “{0} only zeroes the first member”
It does not. {0} spells out one member, but everything left out is default-initialised (§6.7.11). A struct with a hundred members is fully zeroed and nulled by that one {0}.
The inverted misconception is just as common: “if I write only { .retries = 3 }, the rest is garbage.” Also false. Designated or positional, if there is any initializer at all, the members you leave out are default-initialised — the third line of the demonstration is the check. Garbage is what you get when there is no initializer whatsoever (struct config c;).
Platform note. Can you use `{}`?
The empty initializer{} became standard in C23. GCC and Clang accepted it as an extension before that, but such code was not portable. If you must also support C17 and earlier, use {0} — bearing in mind that when the first member is itself a struct or an array, some compilers warn and you end up writing { {0} }. Not having that annoyance is another point in favour of {}.46.4 A struct is a value
In C a struct is treated like a value — assign it and it is copied whole, pass it to a function and it crosses over copied, exactly by chapter 33′s rule, and it can be returned whole with return. The demonstration’s moved(a, 10, -1) is the check: a is unchanged and a new value b came out.
This copying is a shallow copy that transcribes the members as they are. If all the members are numbers there is no problem, but if a member is a pointer the address is duplicated as it is, so original and copy point at the same place — this fact becomes a decisive trap later when handling data that points at itself.
In practice, though, rather than passing large structs by value it is common to pass a pointer — to save the cost of copying (recall chapter 11′s ladder of memory and it is clear that copying a large lump is not free). When only reading, the practice is to receive it as a const pointer, as in const struct point *p — chapter 23′s const working as a contract mark saying “this function does not touch the original.”
Q. Writing struct point with struct every time is a nuisance — can it not be shortened?
A. Traditionally an alias has been made with typedef — typedef struct point point_t; and the like. But this is a point where taste and schools divide (there is the counter-argument that an alias hides the information “this is a struct”), so this book writes struct so the identity is visible on the page. Either way, consistency is what matters.
Q. Can a struct hold itself as a member — it seems necessary for making something like a list.
A. It cannot hold itself by value (the size would be infinite). But it can hold a pointer to itself, and that is precisely the seed of linked data structures: struct node { int value; struct node *next; };. Let chapter 35′s pointers and chapter 45′s dynamic memory meet in that one line and structures such as linked lists and trees open up — a world of data structures beyond this book’s scope, but worth knowing that the key that opens the door is here.
46.4.1 Why assignment works but comparison does not
A struct is a value, so b = a; copies the whole thing in one line. Yet a == b does not exist — it is a compile error. Why does assignment work and comparison not?
Because of padding. Assignment can be defined as “move the members’ values”, but comparison has to answer “are they equal”, and the value of the padding is not specified. Two structs holding the same values may hold different rubbish in their padding, and comparing bit by bit then says “different”.
A common misconception. “Then compare them with memcmp”
The commonest substitute, and quietly wrong. memcmp compares representations — it looks at the padding as well as the members.
Chapter 47′s demonstration shows this in the flesh: two structs whose members are all equal, and memcmp reports “different”. The opposite accident exists too — if the padding happens to match, it says “equal”, but that is luck, not a contract.
For the same reason a struct must not be hashed whole (equal values give different hashes) and must not be written whole to a file or a socket (chapter 47 goes into it).
There is one prescription — write a function that compares member by member.
bool point_eq(struct point a, struct point b)
{ return a.x == b.x && a.y == b.y; }46.5 Header and data in one block — the flexible array member
A struct followed by data of no fixed length is a very common shape — messages, packets, nodes holding a string. C99 made the pattern official.
A flexible array member is the last member of a struct: an array with its size left empty.
examples-en/ch46/flexible.c
/* The flexible array member — C99's way to take header and data in one block. */
#include <stdckdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Leave the last member's size empty and it is a flexible array member.
It is not counted in sizeof — the size is decided when allocating. */
struct msg {
unsigned kind;
size_t len;
char data[]; /* <- the flexible array member */
};
/* size = header + data. Skip the overflow check and a big array lands in a small vessel. */
static struct msg *msg_new(unsigned kind, const char *text)
{
size_t len = strlen(text);
size_t need;
/* offsetof(struct msg, data) is more exact than sizeof(struct msg) —
it does not count the tail padding twice. */
if (ckd_add(&need, offsetof(struct msg, data), len)) return nullptr;
struct msg *m = malloc(need);
if (!m) return nullptr;
m->kind = kind;
m->len = len;
memcpy(m->data, text, len);
return m;
}
int main(void)
{
printf("sizeof(struct msg) = %zu <- data is not counted\n",
sizeof(struct msg));
printf("offsetof(struct msg, data) = %zu\n", offsetof(struct msg, data));
printf("alignof(struct msg) = %zu\n", alignof(struct msg));
const char *text = "hello, world!";
struct msg *m = msg_new(7, text);
if (!m) { perror("malloc"); return 1; }
printf("\nallocated = offsetof(data) + %zu = %zu bytes\n",
m->len, offsetof(struct msg, data) + m->len);
printf("kind = %u, len = %zu, data = \"%.*s\"\n",
m->kind, m->len, (int)m->len, m->data);
/* header and data are one block, so one free */
free(m);
puts("\nHow the old practice differed:");
puts(" char data[1]; <- the 'struct hack'. The size arithmetic was off by one,");
puts(" and it accessed past the array — outside the contract.");
puts(" char data[]; <- what C99 made official. Inside the contract.");
return 0;
}
Output
sizeof(struct msg) = 16 <- data is not counted
offsetof(struct msg, data) = 16
alignof(struct msg) = 8
allocated = offsetof(data) + 13 = 29 bytes
kind = 7, len = 13, data = "hello, world!"
How the old practice differed:
char data[1]; <- the 'struct hack'. The size arithmetic was off by one,
and it accessed past the array — outside the contract.
char data[]; <- what C99 made official. Inside the contract.
Three things are the contract.
- It must be last, and at least one other member must precede it.
- It is not included in
sizeof. Thatsizeof(struct msg)andoffsetof(struct msg, data)printed the same value says exactly that. - The size is decided when allocating.
malloc(offsetof(…, data) + length)is the standard form.
There is a reason for using offsetof rather than sizeof: sizeof includes the tail padding, so it is counted twice — not wrong, merely a little more than needed. And the length arithmetic must be checked for overflow (chapter 75) — a large length that wraps around means writing large data into a small vessel, which is precisely a heap overflow.
In practice. From “the struct hack” to official syntax
Before C99, people who wanted this wrote the last member as char data[1] and balanced the arithmetic when allocating, as in malloc(sizeof(struct msg) + len - 1). This practice was known as the struct hack.
It worked, but it was outside the contract — it touched the second element of an array with only one. A compiler optimising on that fact could break it.
C99 removed the grey area by making char data[] official. Read [1] in old code as a trace of that era, and write [] in new code.
We have both a way of binding values together and the shape those values take in memory. The next chapter is how to use them — the temporary struct made and handed over on the spot, order-free named arguments, the devices for dealing with padding, and why a struct must not be stored or sent whole.