C · lesson 13 of 25
Structs in C
C structs with real output: designated initializers, typedef, pointers and the arrow operator, padding measured with offsetof, and bit fields.
By SkillByExample · Updated
Defining and Using Structs
A struct groups related values of different types under one name. Structs can nest, and designated initializers set members by name:
#include <stdio.h>
struct Point {
double x;
double y;
};
struct Rectangle {
struct Point top_left;
struct Point bottom_right;
};
double rect_area(struct Rectangle r) {
double width = r.bottom_right.x - r.top_left.x;
double height = r.top_left.y - r.bottom_right.y;
return width * height;
}
int main(void) {
struct Point p = {3.0, 4.0}; /* positional */
struct Rectangle rect = { /* designated (C99) */
.top_left = {.x = 0.0, .y = 10.0},
.bottom_right = {.x = 5.0, .y = 0.0}
};
struct Point q = {.y = -2.0}; /* x is zeroed */
printf("p = (%.1f, %.1f), q = (%.1f, %.1f)\n", p.x, p.y, q.x, q.y);
printf("area = %.1f\n", rect_area(rect));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o basic basic.c
$ ./basic
p = (3.0, 4.0), q = (0.0, -2.0)
area = 50.0
q named only .y, and the unnamed .x was zeroed. As with arrays, once an initializer is present, every member it doesn’t mention is zero. The height is top_left.y - bottom_right.y because y grows upward here. Subtract the other way round and the area comes out as -50.0, a sign bug that compiles cleanly.
Structs Copy by Value
Assigning a struct copies every member, and passing one to a function copies it too:
#include <stdio.h>
struct Point { int x, y; };
void move_copy(struct Point p) { p.x += 100; }
void move_ptr(struct Point *p) { p->x += 100; }
int main(void) {
struct Point a = {1, 2};
struct Point b = a; /* whole-struct copy */
b.x = 50;
printf("a = (%d, %d), b = (%d, %d)\n", a.x, a.y, b.x, b.y);
move_copy(a);
printf("after move_copy: a.x = %d\n", a.x);
move_ptr(&a);
printf("after move_ptr: a.x = %d\n", a.x);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o copy copy.c
$ ./copy
a = (1, 2), b = (50, 2)
after move_copy: a.x = 1
after move_ptr: a.x = 101
Changing b left a alone, and move_copy changed only its private copy. Unlike arrays, structs never decay to pointers, so passing a struct always copies it. That’s also the only way to pass an array by value: wrap it in a struct. What you can’t do is compare structs directly:
$ gcc -Wall -Wextra -std=c17 -o compare compare.c
compare.c: In function 'main':
compare.c:7:11: error: invalid operands to binary == (have 'struct Point' and 'struct Point')
7 | if (a == b) printf("equal\n");
| ^~
Write a comparison function that checks each member. memcmp isn’t a safe shortcut, because the padding bytes covered below can differ between two structs whose members are equal.
typedef with Structs
typedef names the type so you can drop the struct keyword. A struct that points to its own type still needs a tag, because the typedef name doesn’t exist yet inside the braces:
#include <stdio.h>
typedef struct {
char name[32];
int age;
double salary;
} Employee;
typedef struct Node { /* tag needed: the struct refers to itself */
int value;
struct Node *next;
} Node;
void print_employee(const Employee *e) {
printf("%-14s %3d $%9.2f\n", e->name, e->age, e->salary);
}
int main(void) {
Employee staff[] = {
{"Alice Johnson", 32, 95000.0},
{"Bob Smith", 28, 72000.0},
{"Carol Davis", 45, 120000.0},
};
for (size_t i = 0; i < sizeof staff / sizeof staff[0]; i++) {
print_employee(&staff[i]);
}
Node second = {2, NULL};
Node first = {1, &second};
printf("list: %d -> %d\n", first.value, first.next->value);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o employee employee.c
$ ./employee
Alice Johnson 32 $ 95000.00
Bob Smith 28 $ 72000.00
Carol Davis 45 $120000.00
list: 1 -> 2
first.next->value mixes both member operators. first is a struct, so it uses ., and first.next is a pointer, so it uses ->. Two nodes linked by hand are already a linked list, and the linked-lists lesson builds on exactly this Node. print_employee takes a const Employee * rather than an Employee, which avoids copying 48 bytes per call and promises not to modify the record.
Struct Pointers and the Arrow Operator
Large structs are usually heap-allocated and passed around by pointer:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
char title[128];
char author[64];
int year;
float rating;
} Book;
void update_rating(Book *b, float new_rating) {
b->rating = new_rating; /* same as (*b).rating = new_rating */
}
Book *create_book(const char *title, const char *author, int year) {
Book *b = malloc(sizeof *b);
if (!b) return NULL;
snprintf(b->title, sizeof b->title, "%s", title);
snprintf(b->author, sizeof b->author, "%s", author);
b->year = year;
b->rating = 0.0f;
return b;
}
int main(void) {
printf("sizeof(Book) = %zu, sizeof(Book *) = %zu\n", sizeof(Book), sizeof(Book *));
Book *b = create_book("The C Programming Language", "Kernighan & Ritchie", 1978);
if (!b) return 1;
update_rating(b, 4.9f);
printf("'%s' by %s (%d), rating %.1f\n", b->title, b->author, b->year, b->rating);
free(b);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o book book.c
$ ./book
sizeof(Book) = 200, sizeof(Book *) = 8
'The C Programming Language' by Kernighan & Ritchie (1978), rating 4.9
Passing b hands over 8 bytes. Passing a Book by value would copy all 200. malloc(sizeof *b) sizes the allocation from the variable rather than repeating the type name, so it stays correct if b’s type ever changes. snprintf(dst, size, "%s", src) is a compact, always-terminated string copy.
Nested Structs and Arrays of Structs
Structs compose. Chain . to reach members of members:
#include <stdio.h>
typedef struct { int day, month, year; } Date;
typedef struct { char first[16]; char last[16]; } Name;
typedef struct {
Name name;
Date birthday;
char email[32];
} Person;
void print_person(const Person *p) {
printf("%s %s | born %04d-%02d-%02d | %s\n",
p->name.first, p->name.last,
p->birthday.year, p->birthday.month, p->birthday.day, p->email);
}
int main(void) {
Person people[] = {
{{"Alice", "Smith"}, {15, 3, 1990}, "alice@example.com"},
{{"Bob", "Johnson"}, {22, 7, 1985}, "bob@example.com"},
};
for (size_t i = 0; i < sizeof people / sizeof people[0]; i++) {
print_person(&people[i]);
}
people[1].birthday.year++; /* chain . through nested members */
printf("Bob's birth year is now %d\n", people[1].birthday.year);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o person person.c
$ ./person
Alice Smith | born 1990-03-15 | alice@example.com
Bob Johnson | born 1985-07-22 | bob@example.com
Bob's birth year is now 1986
The nested braces in the initializer mirror the nesting of the types: {"Alice", "Smith"} fills the Name, and {15, 3, 1990} fills the Date. p->name.first starts with -> because p is a pointer, then uses . because name is a struct held directly, not a pointer.
Struct Padding and Size
The compiler inserts padding so each member lands on an address suited to its type. The same four members take different amounts of space depending on their order:
#include <stdio.h>
#include <stddef.h>
struct Padded {
char a;
int b;
char c;
double d;
};
struct Reordered {
double d;
int b;
char a;
char c;
};
int main(void) {
printf("Padded: %zu bytes (members total %zu)\n", sizeof(struct Padded),
sizeof(char) + sizeof(int) + sizeof(char) + sizeof(double));
printf(" offsets: a=%zu b=%zu c=%zu d=%zu\n",
offsetof(struct Padded, a), offsetof(struct Padded, b),
offsetof(struct Padded, c), offsetof(struct Padded, d));
printf("Reordered: %zu bytes\n", sizeof(struct Reordered));
printf(" offsets: d=%zu b=%zu a=%zu c=%zu\n",
offsetof(struct Reordered, d), offsetof(struct Reordered, b),
offsetof(struct Reordered, a), offsetof(struct Reordered, c));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o padding padding.c
$ ./padding
Padded: 24 bytes (members total 14)
offsets: a=0 b=4 c=8 d=16
Reordered: 16 bytes
offsets: d=0 b=8 a=12 c=13
In Padded, the offsets show the gaps: b starts at 4, not 1 (3 bytes of padding), and d starts at 16, not 9 (7 more). That’s 10 wasted bytes out of 24. Ordering largest first leaves only 2 bytes of padding at the end. Those 2 bytes are needed so that in an array of Reordered, the next element’s double is still 8-byte aligned. Across a million records, that’s 8 MB saved.
Bit Fields
Bit fields give a member an exact width in bits, packing several small values into one integer:
#include <stdio.h>
#include <stdint.h>
typedef struct {
unsigned ready : 1;
unsigned busy : 1;
unsigned error : 1;
unsigned timeout : 1;
unsigned mode : 2; /* values 0-3 */
unsigned : 2; /* unnamed: reserved bits */
} StatusReg;
typedef struct {
uint8_t ready : 1, busy : 1, error : 1, timeout : 1, mode : 2;
} StatusReg8;
int main(void) {
StatusReg reg = {0};
reg.ready = 1;
reg.mode = 3;
printf("ready=%u busy=%u mode=%u\n", reg.ready, reg.busy, reg.mode);
reg.mode = 5; /* doesn't fit in 2 bits */
printf("after mode = 5: mode=%u\n", reg.mode);
printf("sizeof(StatusReg) = %zu\n", sizeof(StatusReg));
printf("sizeof(StatusReg8) = %zu\n", sizeof(StatusReg8));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o bitfields bitfields.c
bitfields.c: In function 'main':
bitfields.c:23:16: warning: unsigned conversion from 'int' to 'unsigned char:2' changes value from '5' to '1' [-Woverflow]
23 | reg.mode = 5; /* doesn't fit in 2 bits */
| ^
$ ./bitfields
ready=1 busy=0 mode=3
after mode = 5: mode=1
sizeof(StatusReg) = 4
sizeof(StatusReg8) = 1
Assigning 5 (binary 101) to a 2-bit field kept only the low two bits, 01. GCC warned, but it would stay silent if the value came from a variable. The field layout needs only 8 bits, yet StatusReg is 4 bytes: its fields are declared unsigned, and GCC gives the struct the size and alignment of that type. Declaring them uint8_t brings it down to 1 byte. Bit-field types other than int, unsigned, signed and _Bool are implementation-defined in C17, but GCC and Clang accept them. Bit order, padding, and whether a field can straddle a boundary are all compiler-specific. To match an exact hardware register or wire format, prefer masks and shifts on a fixed-width integer.
Practice
- Write
double distance(Point a, Point b)fortypedef struct { double x, y; } Point;and test it on (0,0)→(3,4) and (3,4)→(−1,1).
Solution
#include <stdio.h>
#include <math.h>
typedef struct { double x, y; } Point;
double distance(Point a, Point b) {
double dx = b.x - a.x, dy = b.y - a.y;
return sqrt(dx * dx + dy * dy);
}
int main(void) {
Point origin = {0, 0}, p = {3, 4}, q = {-1, 1};
printf("origin -> p: %.2f\n", distance(origin, p));
printf("p -> q: %.2f\n", distance(p, q));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c -lm
$ ./p1
origin -> p: 5.00
p -> q: 5.00
Both are 3-4-5 triangles, so both distances are exactly 5. Passing a two-double struct by value is cheap (16 bytes), so a pointer would add nothing here.
- Sort an array of
Player { char name[16]; int score; }by score, highest first, withqsort.
Solution
#include <stdio.h>
#include <stdlib.h>
typedef struct {
char name[16];
int score;
} Player;
int by_score_desc(const void *a, const void *b) {
const Player *pa = a, *pb = b;
return (pb->score > pa->score) - (pb->score < pa->score);
}
int main(void) {
Player team[] = { {"Ana", 72}, {"Ben", 95}, {"Cy", 88}, {"Di", 95} };
size_t n = sizeof team / sizeof team[0];
qsort(team, n, sizeof team[0], by_score_desc);
for (size_t i = 0; i < n; i++) printf("%-4s %d\n", team[i].name, team[i].score);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
Ben 95
Di 95
Cy 88
Ana 72
qsort moves whole structs around, sizeof team[0] bytes at a time. Ben and Di tie at 95. qsort is not guaranteed to be stable, so their relative order could differ on another C library. Add a tie-breaker on name if the order matters.
- Predict
sizeofforstruct { char a; double b; char c; int d; char e; }, then reorder the members to shrink it.
Solution
#include <stdio.h>
#include <stddef.h>
struct Wasteful { char a; double b; char c; int d; char e; };
struct Tight { double b; int d; char a; char c; char e; };
int main(void) {
printf("Wasteful: %zu bytes\n", sizeof(struct Wasteful));
printf("Tight: %zu bytes\n", sizeof(struct Tight));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
Wasteful: 32 bytes
Tight: 16 bytes
In Wasteful, a is followed by 7 padding bytes before b at offset 8. c sits at 16, d at 20 and e at 24, and then the struct is padded to 32, a multiple of 8. Tight holds the same 15 bytes of data in half the space.
- Pack a date into bit fields: 5 bits for the day, 4 for the month, 12 for the year. How big is it?
Solution
#include <stdio.h>
typedef struct {
unsigned day : 5; /* 1-31 */
unsigned month : 4; /* 1-12 */
unsigned year : 12; /* 0-4095 */
} PackedDate;
int main(void) {
PackedDate d = {.day = 23, .month = 9, .year = 2026};
printf("%04u-%02u-%02u in %zu bytes\n", d.year, d.month, d.day, sizeof d);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
2026-09-23 in 4 bytes
5 + 4 + 12 = 21 bits fit in one 32-bit unsigned, so the date takes 4 bytes instead of the 12 an int day, month, year struct needs. Each field’s width is chosen from its range: 31 needs 5 bits, 12 needs 4, and 4095 needs 12.