Version without JavaScript, read-only: workshops, quizzes and running code are not available here. Full version · Catalogue · Sign in
Lecture notes · chapter 4
Data structures
Structures and typedef, abstract types, and the classics of algorithmics written in C - linked lists, queues, binary trees, vectors. Passing a pointer by value or by address, and reading it in the signature.
Structures
Grouping fields
A structure groups several values, possibly of different types, under a single name. Each one is a field.
#include <stdio.h>
struct point
{
int x;
int y;
};
typedef struct point t_point;
void translater(t_point *p, int dx, int dy)
{
p->x += dx;
p->y += dy;
}
int main(void)
{
t_point a = { 1, 2 };
t_point b = a; /* field-by-field copy */
translater(&a, 10, 10);
printf("a = (%d, %d), b = (%d, %d)\n", a.x, a.y, b.x, b.y);
printf("sizeof (t_point) = %zu\n", sizeof (t_point));
return 0;
}
a = (11, 12), b = (1, 2)
sizeof (t_point) = 8
struct pointis the name of the type. It can be given a shorter name withtypedef:typedef struct point t_point;makest_pointa synonym forstruct point. Thet_prefix is only a convention, to recognise a type name at a glance.a.xdesignates the fieldxof the structurea.- When you have a pointer
pto a structure,p->xdesignates the fieldxof the structure pointed to. It is an abbreviation of(*p).x; the parentheses are necessary, because.takes precedence over*. - The assignment
b = acopies the whole structure. Modifyingaafterwards does not changeb. The same goes when a structure is passed to a function: it receives a copy. Fortranslaterto modify the caller's point, it must be given its address (chapter 2).
Recursive structures
A linked list is made of links; each link contains a value and the address of the next link. A structure cannot contain itself (it would be of infinite size), but it can contain a pointer to a structure of its own type:
typedef struct s_liste *t_liste;
struct s_liste
{
int val;
t_liste suivant;
};
The typedef can come before the definition of the structure: to declare a pointer to struct s_liste, the compiler does not need to know its fields, since all pointers have the same size.
Here, the typedef does not name the structure, but a pointer to it. A list is the address of its first link, and the empty list is the pointer NULL. This is exactly Caml's list: NULL plays the role of [], and a link that of x :: reste.
Abstract types
Hiding the representation
Each data structure is stored in its own module, as in chapter 1: a header that announces the operations, a source file that defines them. The important point is what goes in the header.
liste.h:
#ifndef LISTE_H
#define LISTE_H
#include <stddef.h>
typedef struct s_liste *t_liste;
#define liste_vide() (NULL)
#define est_vide(l) ((l) == NULL)
t_liste ajouter(int x, t_liste l);
int tete(t_liste l);
t_liste queue(t_liste l);
size_t longueur(t_liste l);
t_liste chercher(int x, t_liste l);
void inserer(int x, t_liste *l);
void detruire(t_liste l);
#endif
The header says that t_liste is a pointer to a struct s_liste, but not what that structure contains. Its definition is in liste.c, and nowhere else. A file that includes liste.h can declare lists, pass them to the module's functions, but cannot write l->val: the compiler refuses, it does not know the fields.
The benefit is the same as with Caml's modules: you can change the representation (add a field, switch from a list to an array) without touching the code that uses it. And no one can break the structure's invariants by going and modifying a field by hand.
liste.c:
#include <stdio.h>
#include <stdlib.h>
#include "liste.h"
struct s_liste
{
int val;
t_liste suivant;
};
t_liste ajouter(int x, t_liste l)
{
t_liste t = malloc(sizeof (struct s_liste));
if (t == NULL)
{
perror("ajouter");
exit(1);
}
t->val = x;
t->suivant = l;
return t;
}
int tete(t_liste l)
{
return l->val;
}
t_liste queue(t_liste l)
{
return l->suivant;
}
size_t longueur(t_liste l)
{
size_t n = 0;
for (; l != NULL; l = l->suivant)
n++;
return n;
}
t_liste chercher(int x, t_liste l)
{
while (l != NULL && l->val != x)
l = l->suivant;
return l;
}
void detruire(t_liste l)
{
while (l != NULL)
{
t_liste t = l;
l = l->suivant;
free(t);
}
}
ajouter makes a new link on the heap with malloc (chapter 3), and checks that it got it. chercher returns the link that contains x, or NULL. detruire frees the links one by one; you must keep the address of the next one before freeing the current link, because after free(t), reading t->suivant is forbidden.
tete and queue do not check that the list is non-empty: it is up to the caller to do so, with est_vide. This is written in the header's comment, or a test is added that stops the program.
A program that uses it
essai_liste.c:
#include <stdio.h>
#include "liste.h"
void afficher(t_liste l)
{
for (; !est_vide(l); l = queue(l))
printf("%d ", tete(l));
printf("\n");
}
int main(void)
{
t_liste l = liste_vide();
int valeurs[] = { 5, 1, 4, 1, 9, 2, 6 };
for (size_t i = 0; i < sizeof valeurs / sizeof valeurs[0]; i++)
inserer(valeurs[i], &l);
afficher(l);
printf("longueur : %zu\n", longueur(l));
printf("9 %s\n", chercher(9, l) != NULL ? "trouvé" : "absent");
printf("3 %s\n", chercher(3, l) != NULL ? "trouvé" : "absent");
detruire(l);
return 0;
}
afficher only uses the operations of the header. sizeof valeurs / sizeof valeurs[0] is the number of cells in the array: its total size divided by that of one cell (this only works on a real array, not on a pointer, chapter 2).
The Makefile can rely on make's implicit rules, since it already knows how to build a .o from a .c: you only need to give the dependencies. $@ is the target, $^ the list of its prerequisites.
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -Werror -O2
essai_liste: essai_liste.o liste.o
$(CC) -o $@ $^
essai_liste.o: essai_liste.c liste.h
liste.o: liste.c liste.h
clean:
rm -f essai_liste *.o
$ make
gcc -std=c11 -Wall -Wextra -Werror -O2 -c -o essai_liste.o essai_liste.c
gcc -std=c11 -Wall -Wextra -Werror -O2 -c -o liste.o liste.c
gcc -o essai_liste essai_liste.o liste.o
$ ./essai_liste
1 1 2 4 5 6 9
longueur : 7
9 trouvé
3 absent
Passing a list to a function
By value, or by address
In C, all parameters are passed by value: the function receives a copy. For a pointer, it is a copy of the pointer, which designates the same links. So the function can modify the links; it cannot modify the caller's variable that contains the pointer.
void vider_rate(t_liste l)
{
detruire(l);
l = liste_vide(); /* only changes the copy */
}
void vider(t_liste *l)
{
detruire(*l);
*l = liste_vide(); /* changes the caller's variable */
}
After vider_rate(l), the links are freed, but the caller's variable l still contains the address of the old first link: it is a dangling pointer, which must never be followed again. After vider(&l), l is NULL.
Algorithmic languages often distinguish input parameters (passed by value) from input-output parameters (passed by reference, which the procedure may modify). C has no passing by reference: it is simulated by passing the address of the variable, as for echange in chapter 2. A function that must change which link is at the head of the list therefore receives a t_liste *.
There are two ways of returning a new start of list:
- return it as the result:
t_liste ajouter(int x, t_liste l), called asl = ajouter(x, l);; - receive the list by address:
void inserer(int x, t_liste *l), called asinserer(x, &l);. This is the only solution when the result is already used for something else, or so that the caller cannot forget the assignment.
Inserting into a sorted list
inserer adds x at its place in a list sorted in increasing order. The first idea distinguishes two cases: insertion at the head, which changes the caller's variable, and insertion further on, which changes the suivant field of the previous link.
void inserer(int x, t_liste *l)
{
if (*l == NULL || (*l)->val >= x)
*l = ajouter(x, *l);
else
{
t_liste prec = *l;
while (prec->suivant != NULL && prec->suivant->val < x)
prec = prec->suivant;
prec->suivant = ajouter(x, prec->suivant);
}
}
We stop on the previous link (the one whose next is too large, or does not exist), because it is its suivant field that must be modified.
But both cases do the same thing: replace a pointer (the caller's variable, or a suivant field) with the address of a new link. If we keep the address of that pointer rather than the link, there is only one case left:
void inserer(int x, t_liste *l)
{
while (*l != NULL && (*l)->val < x)
l = &(*l)->suivant;
*l = ajouter(x, *l);
}
At the start, l points to the caller's variable. At each turn, it moves on to the suivant field of the current link. When we stop, *l is the pointer to replace, whichever it is. &(*l)->suivant reads &((*l)->suivant): the address of the suivant field of link *l.
Reading a signature
C uses the star for many things: passing by address, an array, a string, a linked structure, a generic pointer. Looking at f(int *p), you cannot tell which. Hence a few conventions, so that a function's signature says by itself how to use it:
- Data structures: the pointer is hidden by a
typedef(t_liste,t_arbre). No star. - Arrays: in a parameter,
int t[]means exactly the same thing asint *t. Write the brackets for an array. - Strings: they are arrays of characters; write
char s[], orconst char *swhen the function does not modify it. - What is left is the explicit star: it signals passing by address, a variable of the caller that the function will modify, or more rarely a generic pointer
void *.
A few signatures, and what they say:
/* t_liste is abstract: we do not need to know it is a pointer. */
t_liste ajouter(int x, t_liste l);
/* The head of the list may change: the list is passed by address. */
void inserer(int x, t_liste *l);
/* v is an array; *somme will receive a second result. The function
* probably returns the number of elements, and stores their sum in *somme.
* But how does it know the size of v? A parameter is missing. */
size_t compter_sommer(int v[], int *somme);
One ambiguity remains: is char *t[] an array of strings, or the address of a string that will be modified? The syntax does not say. It is the former (argv, in int main(int argc, char *argv[]), is an array of strings), but only a comment, or a typedef, can tell the reader.
A generic swap
echange(int *a, int *b) swaps two integers. To swap two values of any type, we go through void *, and must then give the size of the values, since void * does not know it. The copy is done byte by byte, with memcpy:
#include <stdlib.h>
#include <string.h>
void echange_gen(void *a, void *b, size_t taille)
{
void *tmp = malloc(taille);
if (tmp == NULL)
abort();
memcpy(tmp, a, taille);
memcpy(a, b, taille);
memcpy(b, tmp, taille);
free(tmp);
}
double u = 1.5, v = 2.5;
echange_gen(&u, &v, sizeof u); /* u = 2.5, v = 1.5 */
char *s = "bonjour", *t = "au revoir";
echange_gen(&s, &t, sizeof s); /* swaps the two pointers */
This is how the generic functions of the standard library, such as qsort, are written. Type checking is lost: nothing prevents passing a wrong size. And for two integers, it is much slower than echange.
A queue
A queue is a list where you add at one end and remove at the other: first in, first out. For both operations to take constant time, you need access to both ends. A trick allows it with a single pointer: the list is circular, and we keep the address of the last link. Its next is the first.
flowchart LR
F([f]) --> C
A[first] --> B[second] --> C[last] --> AWe store void * in it, so as to be able to put any data in it: trees, strings…
file.h:
#ifndef FILE_H
#define FILE_H
typedef struct s_file *t_file;
#define file_vide() (NULL)
#define file_est_vide(f) ((f) == NULL)
t_file enfiler(void *x, t_file f);
void *defiler(t_file *f);
#endif
file.c:
#include <stdio.h>
#include <stdlib.h>
#include "file.h"
struct s_file
{
void *val;
t_file suivant;
};
/* f points to the last link; the last one points to the first. */
t_file enfiler(void *x, t_file f)
{
t_file t = malloc(sizeof (struct s_file));
if (t == NULL)
{
perror("enfiler");
exit(1);
}
t->val = x;
if (f == NULL)
t->suivant = t;
else
{
t->suivant = f->suivant;
f->suivant = t;
}
return t;
}
void *defiler(t_file *f)
{
t_file premier = (*f)->suivant;
void *x = premier->val;
if (premier == *f)
*f = NULL;
else
(*f)->suivant = premier->suivant;
free(premier);
return x;
}
enfiler(enqueue) inserts the new link after the last one, so between the last and the first, and the new link becomes the last: we return it. A queue of a single link points to itself.defiler(dequeue) removes the first link (the next of the last one). It already returns the value, so it receives the queue by address in order to be able to modify it. If the first was also the last, the queue becomes empty.
char *mots[] = { "un", "deux", "trois" };
t_file f = file_vide();
for (int i = 0; i < 3; i++)
f = enfiler(mots[i], f);
while (!file_est_vide(f))
printf("%s\n", (char *)defiler(&f));
un
deux
trois
Binary trees
The module
A binary tree is either empty, or made of a node that carries a key and two subtrees. Same principle as the list: the empty tree is NULL.
arbre.h:
#ifndef ARBRE_H
#define ARBRE_H
#include <stddef.h>
typedef struct s_arbre *t_arbre;
t_arbre noeud(int cle, t_arbre g, t_arbre d);
int hauteur(t_arbre a);
size_t taille(t_arbre a);
void afficher_profondeur(t_arbre a);
void afficher_largeur(t_arbre a);
void detruire_arbre(t_arbre a);
#endif
The beginning of arbre.c:
#include <stdio.h>
#include <stdlib.h>
#include "file.h"
#include "arbre.h"
struct s_arbre
{
int cle;
t_arbre g, d;
};
t_arbre noeud(int cle, t_arbre g, t_arbre d)
{
t_arbre a = malloc(sizeof (struct s_arbre));
if (a == NULL)
{
perror("noeud");
exit(1);
}
a->cle = cle;
a->g = g;
a->d = d;
return a;
}
size_t taille(t_arbre a)
{
return a == NULL ? 0 : 1 + taille(a->g) + taille(a->d);
}
void detruire_arbre(t_arbre a)
{
if (a == NULL)
return;
detruire_arbre(a->g);
detruire_arbre(a->d);
free(a);
}
Recursive functions are written as in Caml: one case for the empty tree, one case for the node. detruire_arbre frees both subtrees before the node, since it is in the node that their addresses are stored.
The height, and a trap with integers
By convention, a leaf (a node without children) has height 0, and the empty tree -1. So the height is a signed integer:
int hauteur(t_arbre a)
{
if (a == NULL)
return -1;
int hg = hauteur(a->g);
int hd = hauteur(a->d);
return 1 + (hg > hd ? hg : hd);
}
Two traversals
The depth-first traversal visits a node, then its whole left subtree, then its whole right subtree. We print each key indented according to its depth:
static void profondeur(t_arbre a, int decalage)
{
if (a == NULL)
return;
printf("%*s%d\n", decalage, "", a->cle);
profondeur(a->g, decalage + 2);
profondeur(a->d, decalage + 2);
}
void afficher_profondeur(t_arbre a)
{
profondeur(a, 0);
}
%*s prints a string over a width given as an argument: here the empty string, padded with decalage spaces. static makes profondeur invisible outside arbre.c: it is an auxiliary function, which has no place in the header.
The breadth-first traversal visits the nodes level by level: the root, then its children, then its grandchildren. We use the queue: remove a node, print it, add its children at the end of the queue. To start a new line at the end of each level, we put a marker in the queue, NULL: when it is removed, the whole level has been printed, and the whole next level is in the queue, behind it.
void afficher_largeur(t_arbre a)
{
if (a == NULL)
return;
t_file f = file_vide();
f = enfiler(a, f);
f = enfiler(NULL, f);
while (!file_est_vide(f))
{
t_arbre t = defiler(&f);
if (t != NULL)
{
printf("%d ", t->cle);
if (t->g != NULL)
f = enfiler(t->g, f);
if (t->d != NULL)
f = enfiler(t->d, f);
}
else
{
printf("\n");
if (!file_est_vide(f))
f = enfiler(NULL, f);
}
}
}
t_arbre a = noeud(1,
noeud(2, noeud(4, NULL, NULL), NULL),
noeud(3, noeud(5, NULL, NULL), noeud(6, NULL, NULL)));
printf("taille %zu, hauteur %d\n", taille(a), hauteur(a));
afficher_profondeur(a);
afficher_largeur(a);
detruire_arbre(a);
taille 6, hauteur 2
1
2
4
3
5
6
1
2 3
4 5 6
The program is compiled with both modules: gcc -std=c11 -Wall -Wextra -Werror essai_arbre.c arbre.c file.c.
A variable-size vector
Chapter 3 introduced the dynamic vector: an array allocated on the heap, enlarged with realloc when it is full, by doubling its capacity. Here is the complete version, as an abstract type.
vecteur.h:
#ifndef VECTEUR_H
#define VECTEUR_H
#include <stddef.h>
typedef struct s_vecteur *t_vecteur;
t_vecteur vecteur_nouveau(void);
void vecteur_ajouter(t_vecteur v, int x);
int vecteur_lire(t_vecteur v, size_t i);
size_t vecteur_longueur(t_vecteur v);
void vecteur_detruire(t_vecteur v);
#endif
vecteur.c:
#include <stdio.h>
#include <stdlib.h>
#include "vecteur.h"
struct s_vecteur
{
size_t longueur; /* cells in use */
size_t capacite; /* cells allocated */
int *cases;
};
static void *allouer(void *p, size_t n)
{
p = realloc(p, n);
if (p == NULL)
{
perror("vecteur");
exit(1);
}
return p;
}
t_vecteur vecteur_nouveau(void)
{
t_vecteur v = allouer(NULL, sizeof (struct s_vecteur));
v->longueur = 0;
v->capacite = 1;
v->cases = allouer(NULL, sizeof (int));
return v;
}
void vecteur_ajouter(t_vecteur v, int x)
{
if (v->longueur == v->capacite)
{
v->capacite *= 2;
v->cases = allouer(v->cases, v->capacite * sizeof (int));
}
v->cases[v->longueur++] = x;
}
int vecteur_lire(t_vecteur v, size_t i)
{
if (i >= v->longueur)
{
fprintf(stderr, "vecteur_lire : indice %zu hors limites\n", i);
exit(1);
}
return v->cases[i];
}
size_t vecteur_longueur(t_vecteur v)
{
return v->longueur;
}
void vecteur_detruire(t_vecteur v)
{
free(v->cases);
free(v);
}
realloc(NULL, n)behaves likemalloc(n): a single functionallouerserves both purposes, and checks the result.- Because the type is abstract,
vecteur_lirecan check the index, which C never does on an array. No one can get round the check by readingv->cases[i]directly. vecteur_detruirefrees the array then the structure: in the other order, we would readv->casesin an area already freed.
t_vecteur v = vecteur_nouveau();
for (int i = 0; i < 1000; i++)
vecteur_ajouter(v, i * i);
printf("%zu cases, la dernière vaut %d\n",
vecteur_longueur(v), vecteur_lire(v, vecteur_longueur(v) - 1));
vecteur_lire(v, 1000);
1000 cases, la dernière vaut 998001
vecteur_lire : indice 1000 hors limites