C Part I · Module 10 · Structures, Unions, Enums and User-Defined Types
Pointers to Structures and the Arrow Operator
So far a structure has always been reached by name, as an object sitting in the function that declared it — and that is the situation real C code is least often in. Structures are big enough that programs pass their addresses rather than their contents, and a function that is supposed to change one has no choice in the matter. So most structure code you will ever read reaches its members through a pointer, and C gives that case an operator of its own. This chapter covers what that operator abbreviates, why the longer form it replaces needs parentheses you will get wrong at least once, the three increment expressions that all compile and all mean different things, and the one structure shape that cannot be built at all without a pointer.
Intermediate19 min readCPart IFundamentalsTypesPointers
Part I · Module 10 · Chapter 10.3 · Pointers to Structures and the Arrow Operator
1. A Pointer to a Structure Is an Ordinary Pointer
Nothing new is needed to make one. Chapter 7.2's address-of operator works on a structure object exactly as it works on an int:
struct counter k = { .hits = 0, .tag = 'k' };
struct counter *p = &k;p has type struct counter *, it holds the address of k, and every rule from Module 7 applies unchanged — it can be null (7.4), it can be const-qualified (7.11), it can be passed to a function (7.5), and arithmetic on it moves by whole structures (7.8). Nothing about structures changes the pointer model.
What does need attention is reaching a member through it, and that is the whole of this chapter.
2. Why This Form Dominates
Two measurable reasons, and one that is not about measurement at all.
#include <stdio.h>
struct big { int a[16]; double d; char tag; };
int main(void)
{
struct big v = { .tag = 'z' };
struct big *p = &v;
printf("sizeof(struct big) = %zu\n", sizeof(struct big));
printf("sizeof p = %zu (a pointer)\n", sizeof p);
printf("sizeof *p = %zu (what it points at)\n", sizeof *p);
printf("(*p).tag = %c p->tag = %c same ? %d\n",
(*p).tag, p->tag, (*p).tag == p->tag);
return 0;
}sizeof(struct big) = 80
sizeof p = 8 (a pointer)
sizeof *p = 80 (what it points at)
(*p).tag = z p->tag = z same ? 1Size. Eighty bytes against eight. Passing this structure by value copies ten times as much as passing its address.
Mutation. A function that receives a structure by value receives a copy — Chapter 5.3's pass-by-value, which structures do not escape. Changing the copy changes nothing the caller can see. A function that is supposed to modify the caller's structure must receive a pointer, exactly as Chapter 7.5 established for int.
And the reason that is not about either: many structures are not copies of anything. A structure allocated with malloc is reached only through the pointer that malloc returned (Chapter 9.6), and a structure that refers to its own type — §7 below — cannot be built any other way.
Note sizeof p versus sizeof *p: the first is the pointer's size, the second the structure's. Chapter 7.3 made this distinction for int *; it matters far more here, because the two numbers differ by a factor of ten rather than a factor of two.
This chapter establishes what the pointer form can do. Choosing between by-value and by-pointer — on cost, on intent, on what the interface should promise — is Chapter 10.4's subject.
3. The Two Forms, and What -> Abbreviates
Given struct counter *p, there are two ways to reach a member:
(*p).hits /* dereference, then select */
p->hits /* identical meaning */
p->memberis defined to mean exactly(*p).member. It is not an approximation and there is no case where they differ. The->operator exists because the parenthesised form is tedious and, as §4 shows, easy to write wrongly.
The §2 output confirms the equivalence by comparison: (*p).tag and p->tag both gave z, and the test printed 1.
-> chains like . does, and the two mix freely:
p->origin.x /* member of a member */
p->next->id /* through two pointers */
(&k)->hits /* legal, and silly: write k.hits */4. Why the Parenthesised Form Needs Its Parentheses
The parentheses in (*p).hits are not optional. . has higher precedence than unary *, so without them:
*p.hits /* means *(p.hits) — not what you want */This one the compiler catches, and helpfully:
error: member reference type 'struct s *' is a pointer; did you mean to use '->'?
error: indirection requires pointer operand ('int' invalid)Two complaints from one mistake: p is a pointer so . is wrong on it, and even if that worked, the result is an int which cannot be dereferenced. The first message names the fix.
So *p.member is a nuisance rather than a hazard. The real precedence hazard is the increment forms, because all of them compile.
5. Three Expressions That All Compile and All Differ
#include <stdio.h>
struct s { int x; };
int main(void)
{
struct s a[3] = { {10}, {20}, {30} };
struct s *p = a;
int r1 = ++p->x; /* ++(p->x) : increments the MEMBER */
printf("++p->x -> value %d, a[0].x=%d, p now at index %ld\n",
r1, a[0].x, (long)(p - a));
struct s *q = a;
int r2 = (++q)->x; /* moves the POINTER, then reads */
printf("(++q)->x -> value %d, a[0].x=%d, q now at index %ld\n",
r2, a[0].x, (long)(q - a));
struct s *t = a;
int r3 = t->x++; /* (t->x)++ : post-increment the member */
printf("t->x++ -> value %d, a[0].x=%d\n", r3, a[0].x);
return 0;
}Output:
++p->x -> value 11, a[0].x=11, p now at index 0
(++q)->x -> value 20, a[0].x=11, q now at index 1
t->x++ -> value 11, a[0].x=12Read it row by row. (a[0].x carries forward between rows — it is 11 on the second line because the first line incremented it, and 12 on the third for the same reason.)
++p->x incremented the member. a[0].x went from 10 to 11 and p is still at index 0. The expression parsed as ++(p->x).
(++q)->x moved the pointer. q is now at index 1 and the value read is 20 — a[1].x. Nothing was incremented except the pointer, and a[0].x is untouched by this line.
t->x++ post-incremented the member, so it yielded 11 (the old value) and left a[0].x at 12.
All three compiled with no warnings at -Wall -Wextra -Wpedantic. One fact explains every row:
->binds tighter than unary++,--,*,&and!. So++p->xis++(p->x)and*p->qis*(p->q). If you want the operator to apply to the pointer, you must say so with parentheses:(++p)->x.
The practical advice is not to memorise the table. It is this: when an increment and an arrow appear in one expression, parenthesise the one you mean — even when the default happens to be right — because the reader cannot tell a deliberate ++p->x from a mistyped (++p)->x.
6. Modifying Through a Pointer
This is the mechanical payoff, and it is Chapter 7.5's lesson with a structure in place of an int.
#include <stdio.h>
struct counter { int hits; char tag; };
static void bump_by_value(struct counter c) { c.hits = c.hits + 1; }
static void bump_by_pointer(struct counter *c) { c->hits = c->hits + 1; }
/* reads only, and says so — Chapter 7.11 */
static void report(const struct counter *c)
{
printf(" tag %c hits %d\n", c->tag, c->hits);
}
int main(void)
{
struct counter k = { .hits = 0, .tag = 'k' };
bump_by_value(k);
printf("after bump_by_value:"); report(&k);
bump_by_pointer(&k);
printf("after bump_by_pointer:"); report(&k);
bump_by_pointer(&k);
bump_by_pointer(&k);
printf("after two more:"); report(&k);
return 0;
}Output:
after bump_by_value: tag k hits 0
after bump_by_pointer: tag k hits 1
after two more: tag k hits 3bump_by_value incremented a member of its own copy, which then ceased to exist when the function returned — Chapter 9.2's automatic storage duration, doing exactly what it is supposed to. The caller's k never moved off zero. And nothing warned: the function is correct C that does nothing useful.
bump_by_pointer received an address and wrote through it, so the caller's object changed.
report takes a const struct counter *, which does two jobs at once. It is Chapter 7.11's promise not to modify, enforced:
static void f(const struct c *p) { p->h = 1; }error: cannot assign to variable 'p' with const-qualified type 'const struct c *'And it is Chapter 9.8 §7's ownership signal — a const T * parameter reads as "this function borrows and does not own". With structures the habit matters more than it did for scalars, because a structure parameter is nearly always a pointer, so const is the only thing in the signature that tells a reader whether the function is going to change their object.
Mark every structure pointer parameter
constunless the function modifies through it. The signature then says what the function does, which is the only documentation that cannot drift.
7. The Shape That Needs a Pointer
Chapter 10.2 §8 said a structure cannot contain an object of its own type. Here is what that refusal looks like:
struct bad { int id; struct bad inner; };error: field has incomplete type 'struct bad'The reason is size. To know how big struct bad is, the compiler would need to know how big inner is, which is how big struct bad is. There is no answer.
A pointer to the type has a known size — eight bytes here, whatever the structure turns out to be — so this is legal and is how C builds structures that refer to each other:
#include <stdio.h>
struct stage {
int id;
struct stage *next; /* legal: a pointer has a known size */
};
int main(void)
{
struct stage third = { .id = 3, .next = NULL };
struct stage second = { .id = 2, .next = &third };
struct stage first = { .id = 1, .next = &second };
for (struct stage *p = &first; p != NULL; p = p->next) {
printf("stage %d", p->id);
printf(p->next != NULL ? " -> " : "\n");
}
printf("first.next->next->id = %d\n", first.next->next->id);
printf("sizeof(struct stage) = %zu\n", sizeof(struct stage));
return 0;
}Output:
stage 1 -> stage 2 -> stage 3
first.next->next->id = 3
sizeof(struct stage) = 16Notice that struct stage *next; appears inside the declaration of struct stage, at a point where the type is still incomplete. That is specifically allowed: a pointer to an incomplete structure type is fine, because its size does not depend on the structure's.
Two idioms in that program are worth naming, because you will see them constantly.
for (struct stage *p = &first; p != NULL; p = p->next) is the traversal. The advance step is p = p->next rather than p++ — the objects are not in an array and are not adjacent, so pointer arithmetic would be meaningless. This is the one traversal in C that is not an array walk.
first.next->next->id mixes both operators in one expression, correctly: first is an object so .; first.next is a pointer so ->; and again. Read left to right and the rule is simply dot for an object, arrow for a pointer.
8. The Rules That Still Apply
Nothing in this chapter suspends Module 7 or Module 9. Three reminders, because each has a structure-shaped version that looks new and is not.
A null structure pointer must not be dereferenced. p->hits where p is NULL is Chapter 7.4 §3's undefined behaviour. Check at the boundary, once.
A pointer to a structure whose lifetime ended is invalid. Returning &local_struct is Chapter 9.2's bug and Chapter 9.11's source 2 — the fact that the object is a structure changes nothing, and the compiler's -Wreturn-stack-address catches only some shapes of it, as Chapter 9.11 §7 measured.
An allocated structure follows Module 9 exactly. struct big *p = malloc(sizeof *p); is Chapter 9.6's idiom with no changes at all — note sizeof *p rather than sizeof(struct big), for the reason Chapter 9.6 §4 gave. It must be checked for NULL, freed exactly once, and have one documented owner.
9. What You Should Be Able to Say Now
- A pointer to a structure is an ordinary pointer; every rule from Module 7 applies unchanged.
p->membermeans exactly(*p).member— verified identical.- The pointer form dominates for two measurable reasons: size (80 bytes against 8) and mutation (by value changes a copy).
sizeof pis the pointer's size;sizeof *pis the structure's..binds tighter than unary*, so*p.mis wrong — and the compiler catches it with "did you mean to use '->'?".->binds tighter than++: measured,++p->xincremented the member,(++q)->xmoved the pointer,t->x++post-incremented the member. All three compiled with no warnings.- Parenthesise whenever an increment meets an arrow, even when the default is right.
- A function must take a pointer to modify the caller's structure — verified, by-value left it at zero.
const struct T *is enforced ("cannot assign to variable ... with const-qualified type") and is also Chapter 9.8's borrow signal. Mark every read-only structure pointer parameterconst.- A structure cannot contain its own type ("field has incomplete type") but may point to it — the one shape that requires a pointer.
- Traversal is
p = p->next, notp++: the objects are not adjacent. - Dot for an object, arrow for a pointer — that is the whole rule for reading a chain.
10. Exercises
Exercise 1 — rewrite both ways. Given struct window *w, write each expression in the -> form and in the (*...) form.
- the origin's y
- the title's first character
- the x of the window that
w->parentpoints at
Exercise 2 — fix the parentheses. Each line is wrong or ambiguous. Say what it currently means and what it was probably meant to mean.
*p.hits
++p->x
*p->next
&p->hits + 1Exercise 3 — predict the three. Given struct s a[3] = { {5}, {6}, {7} }; and struct s *p = a;, give the value of each expression and the state of p and a afterwards. Treat each line as starting fresh.
++p->x
(++p)->x
p++->x
(*p).x++Exercise 4 — explain the no-op. bump_by_value in §6 is correct C and does nothing observable. Explain why using Chapter 5.3 and Chapter 9.2, and say why no warning flag reports it.
Exercise 5 — add the const. Mark each parameter const if it should be, and say what the signature then promises.
void print_box(struct box *b);
void resize_box(struct box *b, int w);
int area_of(struct box *b);
void copy_box(struct box *dst, struct box *src);Exercise 6 — why it cannot work. Explain, in terms of size, why struct bad { int id; struct bad inner; }; is rejected but struct stage { int id; struct stage *next; }; is accepted — and say why the pointer member is legal even though the type is incomplete at that point.
Exercise 7 — write the traversal. Using §7's struct stage, write a function that counts the stages in a chain given a pointer to the first. State what it must do if handed NULL, and say which chapter's rule decides that.
Exercise 8 — allocate one. Write the three lines that allocate a struct stage, check the result, and set both members, using the idiom from Chapter 9.6 §4. Then say who owns it and which chapter made that your responsibility to answer.
11. Summary
- A structure pointer is an ordinary pointer — Module 7 applies unchanged.
p->mis exactly(*p).m.- The pointer form dominates on size (80 against 8) and mutation (by value copies).
*p.mis caught by the compiler; the increment forms are not.- Measured, all with zero warnings:
++p->x→ the member,(++q)->x→ the pointer,t->x++→ post-increment the member. ->binds tighter than++,--,*,&. Parenthesise what you mean.- Only a pointer parameter lets a function modify the caller's structure.
const struct T *is enforced and signals a borrow (9.8).- A struct may point to its own type but not contain it — "field has incomplete type".
- Traverse with
p = p->next. Dot for an object, arrow for a pointer.
So the pointer form is smaller to pass and is the only form that can modify the caller's object — which reads like a verdict, and is not one. Passing by value has real advantages that this chapter never mentioned: the callee cannot damage anything, the caller needs no address, and the function's signature promises independence without needing a qualifier to say so. There is also a cost question with a genuinely surprising answer, because a small structure passed by value may never touch memory at all while the pointer version has to be followed. The next chapter is the decision rather than the mechanics: when a copy is right, when an address is right, what each one actually costs, and the one case where returning a structure by value is the clearest code you can write.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
