Cours 2 — Array/slices/maps et fonctions
Array
Comme dans tous les langages de programmation, le tableau permet de stocker un ensemble d'éléments de type identique à l'intérieur.
var t [3]int
t[0] = 4
t[1] = 5
ou plus simplement
t := [3]int{4,5}
Les arrays sont définis avec leur taille, donc ils sont fixes (ne peuvent donc pas être redimensionnés).
Quand l'utiliser
Rarement en pratique — seulement quand la taille est connue d'avance et ne changera jamais, typiquement parce qu'elle est imposée par un protocole. Par exemple, une adresse IPv4 tient toujours sur 4 octets :
var ip [4]byte = [4]byte{192, 168, 0, 1}
Quand l'éviter
Dès que le nombre d'éléments dépend de l'exécution (nombre de clients connectés, taille d'un message reçu sur le réseau, ...). C'est le cas de figure le plus courant dans ce cours, et c'est pour ça qu'on utilise presque toujours un slice plutôt qu'un array.
Slice
Un slice est un sous-ensemble d'un array, par contre, il est dynamique dans le sens où on n'a pas besoin de spécifier sa taille lors de la création:
t := [6]int{4,5,2,54,12,34}
var s []int = t[1:3]
C'est un range à demi ouvert, il inclut le premier, mais n'inclut pas le dernier. Donc dans ce cas-ci, on serait porté à croire 1 à 3, mais en réalité, ce n'est que 1 à 2:
s = [5 2]
Les slices ne contiennent aucune donnée, ce ne sont en fait que des références vers les données d'un tableau. Il s'agit presque d'un résumé.
Quand l'utiliser
C'est le type de collection par défaut en Go, et vous l'utiliserez constamment dans ce cours. L'exemple le plus courant en programmation réseau : lire des octets depuis une connexion TCP dans un buffer.
buf := make([]byte, 4096) // buffer de lecture
n, err := conn.Read(buf)
if err != nil {
log.Println("erreur de lecture:", err)
return
}
message := buf[:n] // seuls les n premiers octets sont valides, le reste du buffer est "vieux"
fmt.Println(string(message))
Ici, n (le nombre d'octets réellement lus) est presque toujours plus petit que len(buf) — un message réseau n'arrive pas forcément d'un seul coup, et il ne remplit pas nécessairement tout le buffer. C'est pour ça qu'on retranche systématiquement buf[:n] plutôt que d'utiliser buf en entier.
Longueur et capacité
Un slice a une longueur (len, le nombre d'éléments qu'il contient actuellement) et une capacité (cap, la taille maximale qu'il peut atteindre sans devoir réallouer un nouveau tableau sous-jacent) :
t := make([]int, 3, 5) // longueur 3, capacité 5
fmt.Println(len(t), cap(t)) // 3 5
append
append est l'opération la plus utilisée sur les slices : elle ajoute un ou plusieurs éléments à la fin, et retourne le slice (potentiellement réalloué si la capacité ne suffisait plus) :
var t []int // slice vide (nil), longueur et capacité à 0
t = append(t, 1)
t = append(t, 2, 3) // on peut ajouter plusieurs éléments d'un coup
fmt.Println(t) // [1 2 3]
// append peut aussi vider un autre slice dedans avec les "..."
autre := []int{4, 5}
t = append(t, autre...)
fmt.Println(t) // [1 2 3 4 5]
Attention : tant que la capacité suffit, append réutilise le tableau sous-jacent — deux slices peuvent donc partager de la mémoire et se modifier l'un l'autre par surprise. Quand la capacité est dépassée, Go alloue un nouveau tableau plus grand (environ le double pour un petit slice, un facteur de croissance plus modeste au-delà de quelques centaines d'éléments) et y copie les données ; le slice retourné pointe alors ailleurs. C'est pour cette raison qu'on doit toujours réassigner le résultat : t = append(t, x), jamais juste append(t, x).
Piège fréquent en programmation réseau : réutiliser le même buffer pour chaque lecture dans une boucle, et conserver directement le slice retourné pour un traitement plus tard (ex.: le mettre dans une file ou un autre slice). Comme buf[:n] pointe toujours vers le même tableau sous-jacent, la lecture suivante écrase silencieusement les données du message précédent :
buf := make([]byte, 4096)
var messages [][]byte
for {
n, err := conn.Read(buf)
if err != nil {
break
}
messages = append(messages, buf[:n]) // BUG : tous les éléments de "messages"
// pointent vers le même tableau "buf"
}
// À la fin, tous les messages de "messages" affichent le contenu... du dernier message lu.
La correction consiste à copier les données avant de les conserver :
messages = append(messages, append([]byte(nil), buf[:n]...)) // copie explicite
Donc, comme vous vous en doutez, en modifiant les données d'un slice, vous allez modifier les données du tableau:
noms:= [4]string{
"Michel",
"Philippe",
"Charles",
"France",
}
fmt.Println(noms)
a := noms[0:2] // Contient Michel et Philippe
b := noms[1:3] // Contient Philippe et Charles
b[0] = "XXX" // Remplace Philippe par XXX
fmt.Println(a, b)
fmt.Println(noms)
// a = Michel XXX
// b = XXX Charles
// noms = Michel XXX Charles France
https://go.dev/play/p/ipd5vYeLXbH
Map
Dernier type de base du langage : il s'agit d'une liste de clé -> valeur non ordonnée (aussi appelée tableau associatif, hash table ou dictionnaire dans d'autres langages). Le but d'une map est de rechercher un objet par sa clé.
var x map[string]int
Comme les slices, une map doit être instanciée (avec make) avant d'être utilisée, sinon vous obtenez une erreur d'exécution :
panic: assignment to entry in nil map
var x = make(map[string]int)
x["clé"] = 10
fmt.Println(x["clé"]) // 10
Pour supprimer un élément :
delete(x, "clé")
Rechercher une clé absente retourne la valeur zéro du type — pour savoir si la clé existe réellement, on utilise l'idiome à deux valeurs (« comma, ok ») :
name, ok := elements["Un"]
if ok {
fmt.Println(name, ok)
}
Il est aussi possible de créer une map directement par littéral, sans make :
elements := map[string]string{
"H": "Hydrogène",
"He": "Hélium",
"Li": "Lithium",
}
Une map peut contenir n'importe quel type de valeur, y compris une autre map (map[string]map[string]string), ce qui est utile pour représenter des données imbriquées.
Quand l'utiliser
Dès qu'on doit retrouver rapidement une information à partir d'une clé. En programmation réseau, c'est le cas typique d'un serveur qui garde une trace de ses clients connectés :
// map de l'adresse du client vers sa connexion active
clients := make(map[string]net.Conn)
clients[conn.RemoteAddr().String()] = conn
D'autres exemples courants : un cache pour éviter de refaire une résolution DNS déjà faite récemment, ou un compteur de requêtes par adresse IP pour détecter un abus (rate limiting) — requetesParIP[ip]++.
Quand l'éviter
- si l'ordre dans lequel les éléments ont été ajoutés (ou un tri quelconque) est important : Go parcourt volontairement une map dans un ordre aléatoire à chaque exécution (
for k, v := range maMap), justement pour empêcher qu'on se fie par erreur à un ordre qui n'est pas garanti. Utilisez un slice (ou un slice de clés triées séparément) si l'ordre compte ; - si la map est partagée entre plusieurs threads d'exécution concurrents : lire et écrire une map en même temps depuis deux d'entre eux sans synchronisation cause un crash immédiat (
fatal error: concurrent map read and map write), pas juste un résultat incorrect.
Package strings
Le package standard strings regroupe les fonctions les plus courantes pour manipuler des chaînes de caractères. En voici quelques-unes, utiles dès que des données textuelles arrivent d'une source externe (entrée utilisateur, fichier, réseau) et doivent être nettoyées ou découpées avant traitement :
strings.ToUpper/strings.ToLower: changer la casse (utile pour comparer sans tenir compte de majuscules/minuscules) ;strings.TrimSpace: retirer les espaces (et retours de ligne) en début et fin de chaîne ;strings.Split: découper une chaîne selon un séparateur exact, en un slice de chaînes ;strings.Fields: découper selon une séquence d'espaces ou d'autres caractères d'espacement (tabulations, retours de ligne) — contrairement àSplit(s, " "), ignore les répétitions ;strings.Join: recoller un slice de chaînes avec un séparateur (l'inverse deSplit) ;strings.ReplaceAll: remplacer toutes les occurrences d'une sous-chaîne ;strings.Contains/strings.HasPrefix/strings.Count: rechercher une sous-chaîne, vérifier un préfixe, compter des occurrences.
adresse := " Serveur:8080 "
adresse = strings.TrimSpace(adresse)
morceaux := strings.Split(adresse, ":")
fmt.Println(morceaux[0], morceaux[1]) // Serveur 8080
Fonctions
Les fonctions acceptent zéro, un ou plusieurs arguments et elles peuvent retourner zéro, un ou plusieurs éléments.
Voici la syntaxe pour la fonction addition:
func add(x int, y int) int {
return x + y
}
Elles peuvent également retourner plusieurs arguments, remarquez la syntaxe:
func swap(x, y string) (string, string) {
return y, x
}
func main() {
a, b := swap("hello", "world")
fmt.Println(a, b)
}
Nombre d'arguments variable
Les fonctions variables sont un type de fonctions spéciales ayant un type particulier comme dernier élément: ce dernier peut être de taille variable (remarquez les ...) dans la méthode suivante:
func add(args ...int) int {
total := 0
for _, v := range args {
total += v
}
return total
}
func main() {
fmt.Println(add(1,2,3))
}
Les ... indiquent que c'est un nombre variable d'arguments, on peut y passer un slice et il va l'accepter et retourner la donnée (total).
Quand l'utiliser
Quand on ne connaît pas d'avance le nombre d'arguments à traiter. Exemple réseau : diffuser un même message à un nombre variable de connexions clientes (broadcast) :
func broadcast(msg string, clients ...net.Conn) {
for _, c := range clients {
fmt.Fprintln(c, msg)
}
}
func main() {
broadcast("Serveur en ligne", conn1, conn2, conn3)
// ou directement avec un slice existant, grâce aux "..."
listeClients := []net.Conn{conn1, conn2, conn3}
broadcast("Serveur en ligne", listeClients...)
}
C'est d'ailleurs le même principe que fmt.Println(args ...any), que vous utilisez depuis le premier cours.
Quand l'éviter
Si le nombre d'arguments attendu est fixe et connu (ex.: une fonction qui prend toujours une adresse IP et un port) — préférez des paramètres nommés explicites, plus clairs à l'appel et vérifiés par le compilateur.
Fonctions en ligne (inline)
Il est possible de créer des fonctions inline, c.-à-d. définies à même une fonction:
func main(){
add := func(x, y int) int {
return x + y
}
fmt.Println(add(1,2))
}
ou
func main() {
x := 0
increment := func() int {
x++
return x
}
fmt.Println(increment())
fmt.Println(increment())
}
Quand l'utiliser
Pour un traitement ponctuel qui n'a de sens qu'à un seul endroit du programme, sans avoir besoin de lui donner un nom séparé ni de le réutiliser ailleurs — comme dans les deux exemples ci-dessus.
Quand l'éviter
Si le traitement est réutilisé à plusieurs endroits dans le programme — à ce moment-là, une fonction nommée séparée est plus lisible et évite la duplication.
Gestion des erreurs avec error
Go n'a pas d'exceptions. La convention idiomatique est de retourner une valeur d'erreur en dernière position, que l'appelant doit vérifier explicitement :
func diviser(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division par zéro")
}
return a / b, nil
}
func main() {
resultat, err := diviser(10, 0)
if err != nil {
fmt.Println("Erreur:", err)
return
}
fmt.Println(resultat)
}
error est une interface (une seule méthode : Error() string) — nil signifie « pas d'erreur ». On l'utilise avec errors.New("...") pour un message simple, ou fmt.Errorf("contexte: %w", err) pour ajouter du contexte tout en conservant l'erreur d'origine (utile pour savoir où une erreur s'est produite dans une chaîne d'appels).
C'est le pattern qui revient dans tout le reste du cours : db.Query(...), json.Unmarshal(...), http.Get(...) retournent tous (résultat, error). Le réflexe if err != nil { ... } immédiatement après chaque appel est la norme en Go — ignorer une erreur (resultat, _ := diviser(10, 0)) est possible mais à éviter sauf cas justifié.
Quand l'utiliser
Dès qu'une opération peut échouer pour une raison qui dépend de l'environnement d'exécution et non d'un bug — c'est presque toujours le cas en réseau : le serveur distant est injoignable, la connexion se coupe en plein milieu, les données reçues sont mal formées, etc. Contrairement à une exception qui peut être « oubliée » silencieusement, une valeur error retournée doit être explicitement traitée (ou explicitement ignorée avec _, ce qui se voit clairement à la lecture du code).
Retour nommé
On peut également spécifier plusieurs variables de retour en go. Par contre, nous devons être précis quant au nom qu'on attribue à ces éléments de retour. Finalement, il faut utiliser un return sans argument pour retourner toutes les valeurs assignées de ces éléments.
package main
import "fmt"
func split(nbr int) (x, y int) {
x = nbr * 4 / 9
y = nbr - x
return
}
func main() {
fmt.Println(split(17))
}
Quand l'utiliser
Surtout quand la fonction retourne plusieurs valeurs du même type et que leur rôle n'est pas évident à la simple lecture de la signature. Exemple : séparer une adresse "host:port" reçue en paramètre :
func parseAdresse(adresse string) (host string, port string, err error) {
parts := strings.Split(adresse, ":")
if len(parts) != 2 {
err = errors.New("format attendu: host:port")
return
}
host, port = parts[0], parts[1]
return
}
Ici, (host string, port string, err error) documente directement, dans la signature, ce que chaque valeur de retour représente — bien plus clair que (string, string, error).
Quand l'éviter
Pour des fonctions courtes à une seule valeur de retour, où nommer le retour n'ajoute rien et alourdit la signature inutilement.
defer
Une déclaration defer s'effectue seulement à la fin d'exécution de la fonction en cours:
func main() {
defer fmt.Println("world")
fmt.Println("hello")
}
L'évaluation se fait immédiatement, mais s'exécute ultérieurement. L'exemple précédent va afficher hello puis world.
Les defer s'empilent de façon last in, first out. (une pile)
func main() {
fmt.Println("counting")
for i := 0; i < 10; i++ {
defer fmt.Println(i)
}
fmt.Println("done")
}
Ceci affiche 9 8 7 6 ... 0 (en sautant une ligne à chaque fois).
Quand l'utiliser
Pour garantir qu'une ressource est libérée peu importe le chemin de sortie de la fonction (return normal, erreur, panic). C'est l'usage le plus fréquent en programmation réseau : fermer une connexion ou un fichier juste après l'avoir ouvert, pour ne jamais oublier de le faire même si une erreur survient plus loin dans la fonction.
func traiterClient(conn net.Conn) {
defer conn.Close() // s'exécute peu importe comment la fonction se termine
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return // conn.Close() s'exécute quand même grâce au defer
}
fmt.Println(string(buf[:n]))
}
Quand l'éviter
Dans une boucle qui ouvre plusieurs ressources. Un defer ne s'exécute qu'à la fin de la fonction, pas à la fin de l'itération de boucle en cours — donc defer à l'intérieur d'une boucle accumule les fermetures jusqu'à la toute fin, ce qui peut épuiser les descripteurs de fichiers ou les connexions disponibles si la fonction tourne longtemps :
func mauvaisPatron(adresses []string) {
for _, addr := range adresses {
conn, err := net.Dial("tcp", addr)
if err != nil {
continue
}
defer conn.Close() // BUG : toutes les connexions restent ouvertes
// jusqu'à la fin de mauvaisPatron, pas de l'itération
// ... utiliser conn ...
}
}
La correction consiste à isoler chaque itération dans sa propre fonction (ex.: une fonction inline appelée immédiatement), pour que le defer s'exécute à chaque tour de boucle plutôt qu'à la toute fin.
panic et recover
Go n'utilise pas les exceptions pour les erreurs normales (voir error plus haut), mais dispose tout de même d'un mécanisme pour les situations réellement exceptionnelles. panic interrompt immédiatement l'exécution normale et déroule la pile d'appels (en exécutant les defer rencontrés au passage), jusqu'à faire planter le programme entier — à moins qu'un recover() ne l'intercepte en chemin :
func diviserPanique(a, b int) int {
if b == 0 {
panic("division par zéro")
}
return a / b
}
recover() ne fait quelque chose que s'il est appelé directement à l'intérieur d'une fonction passée à defer :
func securise() {
defer func() {
if r := recover(); r != nil {
fmt.Println("récupéré d'une panique:", r)
}
}()
panic("boom")
}
Quand l'utiliser
Pour empêcher qu'une panique sur UN SEUL élément traité dans une boucle n'arrête tout le programme avant même d'avoir traité les éléments suivants. Exemple : traiter une liste de messages reçus un par un, où un seul message mal formé ne devrait pas empêcher de traiter les messages valides qui le suivent dans la liste.
func traiterMessage(donnees []string, index int) {
defer func() {
if r := recover(); r != nil {
fmt.Println("message invalide ignoré:", r)
}
}()
fmt.Println(donnees[index])
}
func main() {
donnees := []string{"a", "b", "c"}
indexRecus := []int{0, 1, 10, 2} // 10 est hors limites : provoquera une panique
for _, index := range indexRecus {
traiterMessage(donnees, index)
}
}
Sans le recover, la panique sur l'index 10 arrêterait tout le programme immédiatement, empêchant même le traitement de l'index 2 qui suit dans la boucle. Avec le recover, seul le traitement de cet index échoue (avec un message clair), et la boucle continue normalement jusqu'au bout.
Quand l'éviter
Ne pas utiliser panic/recover comme substitut à error pour un échec attendu et normal (fichier de configuration absent, entrée utilisateur invalide, division par un nombre fourni par l'utilisateur) — ces cas doivent retourner une error que l'appelant vérifie explicitement, comme vu plus haut. panic est réservé aux situations qui indiquent un bug ou un état interne vraiment incohérent, pas un contrôle de flux régulier.
Piège classique : ajouter un niveau d'indirection de trop entre le defer et l'appel à recover(). La règle exacte est que recover() doit être appelé directement dans le corps de la fonction qui est elle-même nommée dans le defer — peu importe que cette fonction soit une closure inline ou une fonction séparée. Ceci fonctionne très bien, par exemple :
func recupererPanique() {
if r := recover(); r != nil { // OK : recupererPanique est la fonction deferred,
fmt.Println("récupéré:", r) // recover() est appelé directement dans son corps
}
}
func traiterMessage(donnees []string, index int) {
defer recupererPanique() // recupererPanique est directement la fonction deferred
fmt.Println(donnees[index])
}
Le piège apparaît si on enveloppe cet appel dans une closure supplémentaire — la fonction directement passée à defer devient alors la closure, et non recupererPanique : recover() se retrouve appelé un niveau plus loin que la fonction deferred, donc il n'a plus d'effet et retourne toujours nil, la panique continue donc de se propager :
func gererConnexion(conn net.Conn) {
defer func() { // BUG : la fonction deferred est cette closure, pas recupererPanique
recupererPanique() // recover() (dans recupererPanique) n'est donc plus appelé
}() // DIRECTEMENT par la fonction deferred
panic("boom")
}
Corrigé : soit appeler recupererPanique() directement dans le defer (comme dans traiterMessage plus haut), soit écrire l'appel à recover() directement dans le corps de la closure passée à defer, sans indirection :
func gererConnexion(conn net.Conn) {
defer func() {
if r := recover(); r != nil {
fmt.Println("récupéré:", r)
}
}()
panic("boom")
}