Fil d'Ariane
Mis à jour le jeudi 10 janvier 2013
  • Facile
Connectez-vous ou inscrivez-vous pour bénéficier de toutes les fonctionnalités de ce cours !

Introduction du cours

Bonjour à tous !

Il n'est pas rare que l'on souhaite faire dialoguer OCaml avec le C. C'est particulièrement vrai quand :

  • On veut utiliser une bibliothèque écrite en C (par exemple GTK+) ou dialoguer avec un langage tiers par l'intermédiaire du C.

  • On a identifié les goulots d'étranglement (bottleneck) d'un code OCaml et on voudrait gagner en performance en les réécrivant en C.

  • On a plus d'imagination que moi pour trouver des exemples d'application. :lol:

Bien entendu, le dialogue entre deux langages n'est pas quelque chose d'anodin. Je vous propose ici une introduction orientée vers la pratique et destinée avant tout à des utilisateurs expérimentés d'OCaml. En d'autres termes, si vous ne connaissez pas OCaml, il n'est peut-être pas judicieux de commencer la lecture ici.

Comme de coutume, je vous invite à me faire part de vos suggestions et critiques à propos de ce tutoriel. J'adresse en particulier ce message aux programmeurs C expérimentés qui pourront probablement corriger certaines fonctions maladroites.

Dernière chose, chaque partie du tutoriel se termine par un tableau récapitulatif des macros et fonctions qui y ont été abordées (ceci pour faciliter la recherche des notions dans le texte).

Bonne lecture,
Cacophrène

Des macros et de la discipline

Des macros

Une macro est un fragment de code auquel a été donné un nom. Un programme, le préprocesseur, remplace les macros par leur contenu dans le code source avant de le donner au compilateur. Notons au passage que le préprocesseur du langage C est très différent du préprocesseur camlp4 : alors que le premier effectue essentiellement des substitutions de texte, le second agit sur un arbre syntaxique abstrait (AST).

Pour que le dialogue entre OCaml et le C soit facile à mettre en œuvre et sûr, les développeurs d'OCaml ont défini de nombreuses macros. Elles sont toutes définies dans les fichiers d'en-tête du sous-répertoire caml de votre installation :

$ ls $(ocamlc -where)/caml
alloc.h     compatibility.h  fail.h    misc.h    
bigarray.h  config.h         intext.h  mlvalues.h
callback.h  custom.h         memory.h  printexc.h

Nous n'allons pas utiliser tous ces fichiers dans ce tutoriel, c'est pourquoi je ne vous donne la description que de quelques-uns d'entre eux :

Fichier d'en-tête

Contenu

mlvalues.h

Macros et fonctions usuelles, type value

fail.h

Lever des exceptions

alloc.h

Allouer de la mémoire

memory.h

Dialoguer avec le GC

De la discipline

Tous les types OCaml sont exportés dans le monde C avec le type unique value. On convertit ensuite les valeurs de ce type en données manipulables par le C, et on renvoie une valeur qui, elle aussi, doit être de type value.

Le code C et le code OCaml sont placés dans des fichiers séparés qui diffèrent par leur nom. En effet, si vous choisissez foo.c et foo.ml, les fichiers ne diffèrent que par leur extension... or, à la compilation, vous allez créer deux fichiers foo.o : un pour OCaml et un pour le C. Vous l'aurez compris, cela ne marchera pas. Sachez également que la coutume veut que l'on utilise les noms foo_stub.c et foo.ml. Je vais donc garder cette convention tout au long de ce tutoriel.

Hello world!

Pour débuter, faisons simple. Nous allons tenter d'écrire une fonction hello en C que nous appellerons depuis OCaml. Voici le code, que nous détaillerons après :

#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <stdio.h>

CAMLprim
value caml_hello(value unit) {
  CAMLparam1(unit);
  printf("Hello world!\n");
  CAMLreturn(Val_unit);
}
external hello : unit -> unit = "caml_hello"

let _ = hello ()

Du côté d'OCaml, les choses sont assez simples. Les fonctions écrites en C (fonctions externes) sont définies avec le mot-clef external. Elles sont suivies de leur signature, puis d'une chaîne de caractères qui n'est autre que le nom correspondant dans le code C. En résumé :

external nom_ocaml : signature = "nom_c"

La macro CAMLprim

Les choses se compliquent un peu du côté du code C. On trouve d'abord la macro CAMLprim, qui doit toujours précéder les fonctions C accessibles depuis OCaml. On trouve ensuite une définition assez classique de fonction en C, avec cette particularité que les arguments reçus en entrée ont tous le même type value, quel que soit leur type d'origine dans le monde OCaml.

La macro CAMLparamN

Le corps de la fonction diffère également d'un code C standard. Les arguments reçus en entrée sont tous protégés avec la macro CAMLparamN (N est à remplacer par le nombre d'arguments) pour s'assurer qu'aucune interaction malheureuse avec le ramasse-miettes (GC) d'OCaml ne viendra perturber le bon déroulement du programme.

La macro CAMLreturn

Dernière chose, et non des moindres : on doit renvoyer vers OCaml une valeur de type value en utilisant la macro CAMLreturn en lieu et place du mot-clef return habituel. Ce premier code me permet d'ailleurs d'introduire la macro Val_unit, qui est l'équivalent C de la valeur () (type unit).

Compilation

C'est tout ! ^^ Il ne reste plus qu'à compiler. Dans tous les exemples que nous allons présenter ici, c'est une étape facile puisqu'il suffit de passer les deux fichiers au compilateur OCaml (si vous voulez savoir ce qui se passe, utilisez l'option -verbose) :

ocamlopt hello_stub.c hello.ml -o hello

et ça marche ! (enfin ça devrait :p )

Commande/Macro

Fonction

CAMLprim

Introduit une fonction C accessible depuis OCaml

CAMLparamN

Protéger les N arguments reçus en entrée

CAMLreturn

Renvoyer un résultat (de type value) dans le monde OCaml

Val_unit

value (unit OCaml)

Les types de base

Voyons maintenant comment manipuler les types de base, c'est-à-dire les entiers et les booléens. Vous l'aurez compris, par « types de base », il faut en fait entendre les types qui ne font pas l'objet d'une allocation.

Les booléens

Commençons par les booléens. Par exemple, nous pouvons essayer de réécrire les tests logiques et (&&) et ou (||).

#include <caml/mlvalues.h>
#include <caml/memory.h>

CAMLprim
value caml_and(value x, value y) {
  CAMLparam2(x, y);
  int res = Bool_val(x) && Bool_val(y);
  CAMLreturn(Val_bool(res));
}

CAMLprim
value caml_or(value x, value y) {
  CAMLparam2(x, y);
  int res = Bool_val(x) || Bool_val(y);
  CAMLreturn(Val_bool(res));
}
external my_and : bool -> bool -> bool = "caml_and"
external my_or : bool -> bool -> bool = "caml_or"

let _ = 
  let print = Printf.printf "Résultat : %B\n%!" in
  print (my_and true false);
  print (my_or false true)

Ce code permet d'introduire deux nouvelles macros. La première, Bool_val, renvoie un entier C (0 ou 1) à partir d'une valeur de type value qui correspond à un booléen en OCaml. La seconde, Val_bool, assure la fonction inverse : elle renvoie une valeur de type value (correspondant au type bool d'OCaml) à partir d'un entier C.

Bon, ça suffit ! :p Testons sans tarder notre code :

ocamlopt bool_stub.c bool.ml -o bool

Les entiers

Il existe des macros Int_val et Val_int, qui étendent aux entiers ce que nous venons de voir avec les booléens. Comme vous pouvez le voir, elles obéissent aussi à la règle générale de type To_from que nous avons exposée précédemment.

#include <caml/mlvalues.h>
#include <caml/memory.h>

CAMLprim
value caml_succ(value x) {
  CAMLparam1(x);
  int res = Int_val(x) + 1;
  CAMLreturn(Val_int(res));
}

CAMLprim
value caml_prev(value x) {
  CAMLparam1(x);
  int res = Int_val(x) - 1;
  CAMLreturn(Val_int(res));
}
external succ : int -> int = "caml_succ"
external prev : int -> int = "caml_prev"

let _ = 
  let n = 25 in
  Printf.printf "Résultat : %d < %d < %d\n%!" (pred n) n (succ n)

Ce code peut être compilé avec ocamlopt int_stub.c int.ml -o int.

Commande/Macro

Fonction

Int_val

value vers entier C

Val_int

Entier C vers value (entier OCaml)

Bool_val

value vers entier C (0 ou 1)

Val_bool

Entier C vers value (booléen OCaml)

Val_true

value (booléen OCaml true)

Val_false

value (booléen OCaml false)

Au pays des allocations

Maintenant que nous avons vu les cas les plus simples, nous pouvons aborder le cas des nombres à virgule flottante et des chaînes de caractères. Ces types diffèrent des précédents dans la mesure où les valeurs correspondantes font l'objet d'une allocation.

Les nombres à virgule flottante

Je vous propose d'écrire des fonctions de calcul de l'exponentielle naturelle et du logarithme népérien d'un flottant :

#include <math.h>
#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>

CAMLprim
value caml_exp(value x) {
  CAMLparam1(x);
  double res = exp(Double_val(x));
  CAMLreturn(caml_copy_double(res));
}

CAMLprim
value caml_exp(value x) {
  CAMLparam1(x);
  double res = log(Double_val(x));
  CAMLreturn(caml_copy_double(res));
}
external my_exp : float -> float = "caml_exp"
external my_log : float -> float = "caml_log"

let _ =
  let resultat = my_exp 1.0 in
  Printf.printf "Résultat : %.4f %.4f\n%!" (log resultat) (my_log resultat)

Macros d'allocation

Notez l'utilisation du fichier alloc.h dans lequel sont définies les fonctions d'allocation. Parmi les nouveautés, on trouve la fonction caml_copy_double qui permet de convertir un flottant C (qu'il soit de type float ou double) en valeur de type value, et Double_val, qui renvoie un flottant C de type double à partir d'une valeur de type value.

Quelques remarques

Remarque 1 : le type float d'OCaml correspond au type double du C. En d'autres termes, OCaml n'a pas de nombres à virgule flottante en précision simple.
Remarque 2 : en C comme en OCaml, le logarithme népérien est noté log et le logarithme décimal log10. Il n'y a donc pas de confusion !

Comme précédemment, on compile avec :

ocamlopt math_stub.c math.ml -o math

Les chaînes de caractères

Qu'en est-il des chaînes de caractères ? Eh bien, c'est un peu la même chose. Il existe une macro String_val qui renvoie un char* à partir d'une valeur de type value, et une fonction caml_copy_string pour l'opération inverse. Voyez par exemple :

#include <ctype.h>
#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>

CAMLprim
value caml_uppercase(value s) {
  CAMLparam1(s);
  int i;
  char *str = String_val(s);
  for(i = 0; i < caml_string_length(s); i++)
    str[i] = toupper(str[i]);
  CAMLreturn(s);
}
external uppercase : string -> string = "caml_uppercase"

let _ = print_endline (uppercase "Hello world!")

Ce code peut être compilé comme précédemment :

ocamlopt str_stub.c str.ml -o str

Commande/Macro

Fonction

Double_val

value vers flottant C

caml_copy_double

Flottant C vers value (flottant OCaml)

String_val

value vers char*

caml_string_length

Renvoie la longueur d'une chaîne OCaml (type value) sous forme d'entier C

caml_copy_string

char* C vers value (chaîne OCaml)

Vivre en harmonie avec le ramasse-miettes

Les exemples que nous venons de voir jusqu'à présent sont en fait très simples et masquent le problème principal posé par les allocations. Quel est-il ?

Vous savez sans doute qu'OCaml possède un ramasse-miettes (GC). Or, si vous souhaitez définir une valeur de type value à l'intérieur d'une fonction C (valeur locale), mais que vous voulez continuer à utiliser les valeurs reçues en argument, sachez qu'il existe un risque qu'elles soient récupérées par le GC au moment de l'allocation. Aïe ! o_O

Interactions avec le ramasse-miettes

Il faut donc préciser au GC que les valeurs reçues par la fonction C doivent être conservées. C'est pourquoi on utilise depuis le débutla macro CAMLparamN (en remplaçant N par le nombre de paramètres). C'est aussi pour cette raison que l'on utilise CAMLreturn à la place du mot-clef return.

Variables locales de type value

De la même façon, la définition de variables locales de type value se fera grâce à la macro CAMLlocalN (en remplaçant N par le nombre de paramètres). Pour mémoire, cette macro, comme les précédentes, nécessite le fichier d'en-tête memory.h.

Subtilités

Si vous décidez d'approfondir votre connaissance du dialogue entre le C et OCaml, vous apprendrez qu'il existe des cas où l'on peut se passer des macros CAMLparamN et CAMLreturn. Mais attention : il est fortement recommandé de les utiliser systématiquement quand on débute, comme nous le faisons dans ce tutoriel. En effet, il vaut mieux les utiliser dans des situations où elles sont superflues (y perd-on vraiment grand-chose ?) que les oublier là où elles sont utiles !

Que j'aime à faire apprendre ce nombre aux sages...

Pour illustrer l'utilisation de la macro CAMLlocalN, écrivons une fonction C qui renvoie une valeur approchée de \pi en utilisant la formule \pi = \arccos(-1).

#include <math.h>
#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>

CAMLprim
value caml_approx_pi(value unit) {
  CAMLparam1(unit);
  CAMLlocal1(approx_pi);
  approx_pi = caml_copy_double(acos(-1));
  CAMLreturn(approx_pi);
}
external approx_pi : unit -> float = "caml_approx_pi"

let _ = Printf.printf "pi = %.12f\n%!" (approx_pi ())

On compile comme d'habitude (vous devez avoir pris l'habitude depuis que je l'écris ;) ) :

ocamlopt pi_stub.c pi.ml -o pi

Commande/Macro

Fonction

CAMLparamN

Préserve les valeurs reçues en entrée

CAMLlocalN

Définit des valeurs locales de type value

CAMLreturn

Renvoie une valeur de type value

N-uplets et listes

Nous allons maintenant nous intéresser au parcours et à la construction de types plus complexes tels que les n-uplets et les listes.

Les n-uplets

Inspection

Les n-uplets (tuples en anglais) sont constitués d'un nombre variable de champs de types hétérogènes. On accède à un champ donné avec la commande Field(tuple, i) où tuple est le n-uplet (de type value) et i l'index du champ auquel on veut accéder. Les champs sont numérotés à partir de zéro. La valeur renvoyée par Field est elle-même de type value.

Nous pouvons donc écrire une fonction très générale qui reçoit en entrée un triplet et un entier et renvoie le champ correspondant. Lorsque l'entier reçu en argument est incorrect, l'exception Invalid_argument est levée (nous n'avons pas parlé des exceptions dans ce tutoriel, mais leur utilisation dans le cas présent est assez intuitive) :

#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/fail.h>

CAMLprim
value caml_triplet_nth(value triplet, value n) {
  CAMLparam2(triplet, n);
  int i = Int_val(n);
  if (i < 0 || i > 2) caml_invalid_argument("triplet_nth");
  CAMLreturn(Field(triplet, i));
}
external triplet_nth : float * float * float -> int -> float = "caml_triplet_nth"

let _ = 
  let x = (1.6, 3.2, 7.5) in
  Printf.printf "x = (%.1f, %.1f, %.1f)\n%!" (triplet_nth x 0) (triplet_nth x 1) 
    (triplet_nth x 2)

Cet exemple se compile comme tous les autres :

ocamlopt tuple_stub.c tuple.ml -o tuple

Création

La création d'un n-uplet fait appel à la commande caml_alloc_tuple(n) où n désigne le nombre de champs du n-uplet. Des valeurs peuvent ensuite être stockées dans les champs à l'aide de la commande Store_field(tuple, i, value). Voici un exemple :

#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>

CAMLprim
value caml_triplet(value unit) {
  CAMLparam1(unit);
  CAMLlocal1(triplet);
  triplet = caml_alloc_tuple(3);
  Store_field(triplet, 0, Val_true);
  Store_field(triplet, 1, caml_copy_double(3.14));
  Store_field(triplet, 2, Val_int(65));
  CAMLreturn(triplet);
}
external triplet : unit -> bool * float * char = "caml_triplet"

let _ =
  let x, y, z = triplet () in
  Printf.printf "(%b, %.2f, %C)\n%!" x y z

Je vous laisse le soin de compiler comme des grands. :lol:

Les listes

Parcours

Les listes d'OCaml sont représentées à l'aide de couples composés d'une tête h (un élément de la liste) et d'une queue t (la sous-liste restante). On accède au contenu de ces couples avec Field, comme précédemment.

#include <stdio.h>
#include <caml/mlvalues.h>
#include <caml/memory.h>

CAMLprim
value caml_inspect_list(value list) {
  CAMLparam1(list);
  CAMLlocal1(head);
  while (list != Val_emptylist) {
    head = Field(list, 0);
    printf("%s\n", String_val(head));
    list = Field(list, 1);
  }
  CAMLreturn(Val_unit);
}
external inspect_list : string list -> unit = "caml_inspect_list"

let _ = inspect_list ["Hello"; "world"; "!"]

Compilez avec ocamlopt list_stub.c list.ml -o list.

Construction

Pour construire une liste, on procède exactement de la même façon. On crée un couple pour chaque élément de la liste. Le premier élément du couple contient la valeur (de type value) et le second la queue de la liste. Pour créer un couple, on utilise la commande caml_alloc(n, tag). Le paramètre n indique la taille du bloc à allouer (2 pour le couple tête/queue). Le paramètre tag vaut toujours 0 dans le cas d'une liste. Il s'agit d'une valeur (étiquette) qui renseigne sur la nature des données (par exemple, il existe un tag pour les fermetures, les objets, les chaînes de caractères, etc.). On utilise ensuite Store_field(list, 0, x) pour stocker la tête de liste (ici x) et Store_field(list, 1, y) pour stocker la queue (ici y).

#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>

CAMLprim
value caml_explode(value s) {
  CAMLparam1(s);
  CAMLlocal2(list, cons);
  list = Val_emptylist;
  int i;
  char* str = String_val(s);
  for(i = caml_string_length(s); i; i--) {
    cons = caml_alloc(2, 0);
    Store_field(cons, 0, Val_int(str[i - 1]));
    Store_field(cons, 1, list);
    list = cons;
  }
  CAMLreturn(list);
}
external explode : string -> char list = "caml_explode"

let _ = 
  let str = read_line () in
  Printf.printf "[ ";
  List.iter (Printf.printf "%C ") (explode str);
  Printf.printf "]\n%!"

Compilez avec ocamlopt explode_stub.c explode.ml -o explode.

Commande/Macro

Fonction

Field(v, n)

Renvoie le champ n (entier C) de la valeur v (type value)

Store_field(v, n, x)

Enregistre la valeur x (type value) dans le champ n (entier C) de la valeur v (type value)

caml_alloc(n, tag)

Alloue un bloc de taille n (entier C) contenant l'étiquette tag (entier C)

caml_alloc_tuple(i)

Alloue de la mémoire pour un n-uplet contenant i champs

caml_alloc_string(n)

Alloue de la mémoire pour une chaîne de caractères de longueur n. La chaîne est initialisée avec des données quelconques.

Où l'on déshabille un chameau

Des avantages et des inconvénients

Terminons cette présentation du dialogue entre C et OCaml par quelques considérations générales sur ce que nous venons de voir. D'abord, j'espère vous avoir convaincu que l'ajout de code C dans un code OCaml n'est pas une chose anodine : il y a des règles à respecter et des risques potentiels à prendre en considération.

OCaml vu de l'intérieur

Cette présentation nous a également permis de mettre à jour une partie de la représentation interne des types d'OCaml. C'est cette même représentation interne qu'il vous est possible de manipuler avec le module de magie noire Obj.

Le fantasme de la fonction print polymorphe

Je crois que vous êtes maintenant en mesure de comprendre pourquoi il n'est pas possible d'écrire une fonction print polymorphe en OCaml. Puisque les informations de type sont perdues à l'exécution, on ne peut afficher que la représentation interne d'une valeur. Ceci a pour conséquence que la fonction print polymorphe affichera la même chose pour plusieurs entrées différentes, comme (), [], None, 0 et false.

Erreurs fréquentes

Dans cette partie, nous allons voir quelles sont les principales erreurs auxquelles vous pouvez être confronté lorsque vous écrivez des fonctions de dialogue entre OCaml et le C.

Des calculs erronés

Voyons d'abord ce qui se passe quand on oublie de convertir un résultat de calcul en value. Pour cela, considérons la fonction erronée suivante :

#include <caml/mlvalues.h>
#include <caml/memory.h>

CAMLprim
value caml_succ(value n) {
  CAMLparam1(n);
  int res = Int_val(n) + 1;
  CAMLreturn(res);
}
external succ : int -> int = "caml_succ"

let _ = 
  let n = 3 in
  Printf.printf "succ %d = %d\n%!" n (succ n)

À l'exécution, on obtient quelque chose d'assez inattendu :

$ ocamlopt int_stub.c int.ml -o int
$ ./int
succ 4 = 2
$

Si vous avez bien suivi le tutoriel, vous avez dû remarquer que l'on a oublié de convertir la variable res en valeur de type value avant de la renvoyer à OCaml (regardez la ligne surlignée dans le code C). Sans plus attendre, compilons ce code (ocamlopt int_stub.c int.ml -o int) et exécutons-le : nous obtenons le texte "succ 3 = 2". Voilà qui est surprenant !

Pour bien comprendre ce qui ne va pas, il faut se souvenir que les entiers d'OCaml sont codés sur 31 bits (63 bits sur les architectures en 64 bits). Le dernier bit, c'est-à-dire le bit de poids faible en représentation binaire conventionnelle, est utilisé par le ramasse-miettes : il permet de savoir si l'on a affaire à un bloc (bit égal à 0) ou à un nombre (bit égal à 1).

Que se passe-t-il si l'on renvoie res sans conversion ? La variable res contient la valeur 4, qui se note 0b100 en binaire. Pour OCaml, ce résultat est valide mais correspond au chiffre 2 (partie en gras). De la même façon, si vous demandez le successeur de 4, res contiendra 0b101, qui correspond toujours à 2 pour OCaml !

Mise en échec du typage

Nous allons maitenant voir comment obtenir une erreur de segmentation (segfault). J'ai volontairement choisi un code correct pour montrer que l'erreur ne vient pas toujours d'une mauvaise programmation.

#include <caml/mlvalues.h>
#include <caml/memory.h>

CAMLprim
value caml_nth_tuple(value tuple, value n) {
  CAMLparam2(tuple, n);
  CAMLreturn(Field(tuple, Int_val(n) - 1));
}
external nth_tuple : 'a -> int -> 'b = "caml_nth_tuple"

let _ =
  let tuple = (1, "oui", true, None) in
  Printf.printf "Résultat : %s\n%!" (nth_tuple tuple 3)

Voici ce que l'on obtient à l'exécution (le message d'erreur exact peut varier selon le système et la configuration locale) :

$ ocamlopt crash_stub.c crash.ml -o crash
$ ./crash
Erreur de segmentation
$

La ligne surlignée devrait vous mettre sur la piste : l'appel de nth_tuple tuple 3 renvoie un booléen alors que la fonction Printf.printf attend une chaîne de caractères. Pourquoi l'erreur de typage n'est-elle pas signalée à la compilation comme c'est toujours le cas en OCaml ? Eh bien, la réponse est simple : la fonction nth_tuple est polymorphe; il est donc impossible de voir le problème.

Autres erreurs possibles

Hélas, la liste est longue et ne s'arrête sûrement pas là ! Il y a plein d'autres manières de provoquer une erreur de segmentation sans le vouloir. Comme il n'est pas possible de présenter tous les cas, je vous invite à suivre une règle simple : testez vos codes le plus souvent possible, bien avant d'avoir écrit plusieurs pages. Vous gagnerez un temps considérable !

Vous savez désormais comment fonctionne le dialogue entre OCaml et le C, au moins dans ses grandes lignes. Je crois que c'est suffisant pour vous permettre de faire appel au C dans des situations simples. Mais, n'en doutez pas, l'histoire n'est pas terminée, loin s'en faut !

Je vous parlerai dans un autre tutoriel des tableaux, des variants polymorphes, des types personnalisés, et peut-être aussi du passage en argument de fonctions OCaml (fermetures) à un code C. J'ai choisi de passer ces notions sous silence pour le moment car j'en ai déjà bien assez dit pour une introduction !

Bonne programmation et à bientôt,
Cacophrène

Remerciements

Je tiens à remercier bluestorm pour sa relecture critique et Thunderseb pour l'intérêt qu'il a porté à la validation de ce tutoriel.

Références bibliographiques

Exemple de certificat de réussite
Exemple de certificat de réussite