1Expliquez comment fonctionnent les valeurs zéro (zero values) de Go pour les types intégrés et les types de type référence, et pourquoi elles sont importantes lorsqu'on déclare des variables sans initialisation explicite.
En Go, une variable déclarée sans initialiseur explicite est automatiquement initialisée à la valeur zéro de son type. Les types numériques deviennent 0, bool devient false, string devient "", et les tableaux ou structs sont mis à zéro élément par élément ou champ par champ. Les types de type pointeur ou référence comme les pointeurs, slices, maps, channels, fonctions et interfaces ont nil comme valeur zéro. Cela compte car les variables Go et les champs de struct omis démarrent dans un état déterministe plutôt que de contenir des données indéterminées, et de nombreuses API sont conçues pour que la valeur zéro soit un défaut utile, même si certaines valeurs nil nécessitent encore une initialisation avant certaines opérations.
package main
import "fmt"
type Config struct {
Port int
Debug bool
Name string
Tags []string
Options map[string]string
}
func main() {
var c Config
fmt.Printf("port=%d debug=%v name=%q tags==nil:%v options==nil:%v\n",
c.Port, c.Debug, c.Name, c.Tags == nil, c.Options == nil)
}
2Comment Go gère-t-il l'égalité des structs et que se passe-t-il lorsqu'une struct contient des champs non comparables ?
Les valeurs de struct en Go peuvent être comparées avec `==` et `!=` uniquement lorsque chaque champ de la struct est comparable. L'égalité compare les champs correspondants selon la règle d'égalité propre à chaque champ. Si une struct contient un champ non comparable tel qu'une slice, une map ou une fonction, le type struct n'est pas comparable, et comparer deux valeurs de ce type struct avec `==` est une erreur à la compilation. Pour de telles structs, utilisez une logique de comparaison personnalisée ou un helper d'égalité en profondeur approprié, surtout dans les tests.
package main
import "fmt"
type UserID struct {
Name string
Age int
}
type UserProfile struct {
Name string
Tags []string
}
func main() {
a := UserID{"Ann", 30}
b := UserID{"Ann", 30}
fmt.Println(a == b)
x := UserProfile{Name: "Ann", Tags: []string{"go"}}
y := UserProfile{Name: "Ann", Tags: []string{"go"}}
_, _ = x, y
// fmt.Println(x == y) // compile error: struct containing []string cannot be compared
}
3Expliquez l'immuabilité des strings en Go et la relation entre string, []byte, bytes.Buffer et strings.Builder.
Une `string` Go est une séquence immuable d'octets, souvent du texte UTF-8 mais pas nécessairement de l'UTF-8 valide. On ne peut pas modifier une string en place ; pour changer le contenu, on convertit généralement en `[]byte` pour des éditions au niveau octet ou en `[]rune` pour des éditions au niveau point de code, puis on reconvertit. Les conversions normales entre `string` et `[]byte` copient les données et peuvent allouer, donc des conversions répétées ou des concatenations répétées dans des boucles peuvent être coûteuses. `strings.Builder` est optimisé pour construire efficacement des strings, tandis que `bytes.Buffer` est un buffer d'octets mutable utile pour les données orientées octets et les E/S, et peut aussi produire une string.
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteByte(' ')
sb.WriteString("world")
fmt.Println(sb.String())
var buf bytes.Buffer
buf.Write([]byte{0x48, 0x69})
buf.WriteByte('!')
fmt.Println(buf.String())
s := "cat"
b := []byte(s) // normally copies
b[0] = 'b'
fmt.Println(s, string(b))
}
4Expliquez en quoi nil fonctionne différemment pour les pointeurs, slices, maps, channels, fonctions et interfaces en Go.
En Go, nil est la valeur zéro des pointeurs, slices, maps, channels, fonctions et interfaces, mais les opérations sur ces valeurs nil diffèrent selon le type. Un pointeur nil peut être comparé à nil, mais le déréférencer provoque un panic. Une slice nil a une longueur et une capacité de 0 et peut être parcourue avec range et étendue avec append. Une map nil peut être lue et parcourue avec range, mais une affectation y provoque un panic. Envoyer ou recevoir sur un channel nil bloque indéfiniment, et fermer un channel nil provoque un panic. Appeler une fonction nil provoque un panic. Une interface n'est nil que lorsqu'elle n'a ni type dynamique ni valeur dynamique ; une interface contenant une valeur nil typée, comme un pointeur nil, n'est pas elle-même nil.
package main
import "fmt"
type MyErr struct{}
func (*MyErr) Error() string { return "my error" }
func returnsTypedNil() error {
var e *MyErr = nil
return e
}
func main() {
var p *int = nil
var s []int = nil
var m map[string]int = nil
var err error = returnsTypedNil()
fmt.Println(p == nil)
fmt.Println(len(s), cap(s), s == nil)
fmt.Println(m["missing"])
fmt.Println(err == nil)
}
5Quels sont les types comparables en Go, et comment les règles de comparabilité affectent-elles les clés de map, l'égalité et les contraintes des generics ?
Les types comparables en Go sont les types dont les valeurs peuvent être comparées avec `==` et `!=`. Les types de base, les pointeurs, les channels, les interfaces, ainsi que les structs/arrays dont les champs ou éléments sont comparables, sont comparables ; les slices, maps et fonctions ne sont pas comparables sauf avec `nil`. Les clés de map doivent être comparables. L'égalité suit les règles de comparaison du type, et la comparaison d'interfaces dépend des valeurs concrètes dynamiques ; si une interface comparée contient une valeur dynamique non comparable, la comparaison provoque un panic. En generics, la contrainte prédéclarée `comparable` permet aux paramètres de type d'être comparés avec `==`/`!=` et d'être utilisés comme clés de map.
package main
import "fmt"
type Point struct{ X, Y int } // comparable
type Bag struct{ Items []string } // not comparable because of slice field
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
func main() {
p1, p2 := Point{1, 2}, Point{1, 2}
fmt.Println(p1 == p2)
m := map[Point]string{p1: "value"}
fmt.Println(m[p2])
fmt.Println(Contains([]string{"a", "b"}, "b"))
var a any = []int{1}
var b any = []int{1}
_, _ = a, b
// fmt.Println(a == b) // panic: comparing uncomparable type []int
}
6Comment Go représente-t-il les bytes, les runes et le texte encodé en UTF-8, et pourquoi len(s) peut-il différer du nombre de caractères visibles par l'utilisateur ?
En Go, `byte` est un alias de `uint8` et représente un octet brut, tandis que `rune` est un alias de `int32` et représente un point de code Unicode. Une `string` est une séquence d'octets en lecture seule, couramment du texte encodé en UTF-8 mais pouvant contenir des octets arbitraires. `len(s)` renvoie le nombre d'octets, pas de runes ni de caractères visibles par l'utilisateur. L'indexation d'une string renvoie un octet ; parcourir une string avec range décode l'UTF-8 et produit des index d'octets plus des runes. `len(s)` peut différer du nombre de caractères visibles car l'UTF-8 peut utiliser plusieurs octets par point de code et car un caractère visible par l'utilisateur peut être composé de plusieurs points de code, comme des marques combinantes ou des séquences d'emoji.
package main
import "fmt"
func main() {
s := "é🙂"
fmt.Println(len(s)) // bytes: é is 2 bytes, 🙂 is 4 bytes
fmt.Println(len([]rune(s))) // Unicode code points
fmt.Printf("first byte: %x\n", s[0])
for i, r := range s {
fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
}
}
7Décrivez la différence entre les tableaux (arrays) et les slices en Go, y compris le comportement de la longueur, de la capacité et du stockage sous-jacent.
Un tableau en Go a une longueur fixe qui fait partie de son type, comme [3]int ; il stocke ses éléments directement, et assigner ou passer un tableau copie toute la valeur du tableau. Une slice, comme []int, est un petit descripteur sur un tableau sous-jacent : conceptuellement elle contient un pointeur vers les éléments, une longueur et une capacité. La longueur d'une slice est le nombre d'éléments visibles ; sa capacité est le nombre d'éléments utilisables depuis le début de la slice jusqu'à la fin du tableau de support. Les slices sont flexibles : le reslicing modifie le descripteur, et append peut réutiliser le même tableau sous-jacent si la capacité le permet ou en allouer un nouveau sinon.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a, b)
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println(s, t)
fmt.Println(len(s), cap(s))
}
8Comment se comporte le type map de Go concernant les types de clés, les clés absentes, les maps nil et l'ordre d'itération ?
Les types de clés d'une map Go doivent être comparables ; les slices, maps et fonctions ne peuvent pas être utilisées directement comme clés. La recherche d'une clé absente renvoie la valeur zéro du type des éléments, c'est pourquoi la forme comma-ok (`v, ok := m[k]`) sert à distinguer l'absence d'une valeur zéro présente. Une map nil peut être lue et parcourue avec range, mais une affectation provoque un panic ; il faut l'initialiser avant d'écrire. L'ordre d'itération d'une map n'est pas spécifié et le code ne doit pas en dépendre.
package main
import "fmt"
func main() {
counts := map[string]int{"a": 0, "b": 2}
fmt.Println(counts["missing"]) // zero value for int
v, ok := counts["a"]
fmt.Println(v, ok) // present even though value is zero
var m map[string]int
fmt.Println(m["x"]) // read from nil map is OK
// m["x"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1
for k, v := range counts {
fmt.Println(k, v) // order is not guaranteed
}
}
9Décrivez comment le reslicing et l'affectation de slices peuvent faire partager le même tableau sous-jacent à plusieurs slices, et quels bugs cela peut créer.
Une valeur de slice est un en-tête pointant dans un tableau sous-jacent. Assigner une slice ou la passer à une fonction ne copie que cet en-tête, pas les éléments. Le reslicing crée un autre en-tête pointant vers une plage du même tableau de support. Par conséquent plusieurs slices peuvent aliaser le même stockage : modifier un élément via une slice peut être visible via une autre, et faire un append sur une slice peut écraser des données visibles par une autre si elle a encore de la capacité disponible. Les bugs incluent des mutations surprenantes, des résultats corrompus, la rétention de grands tableaux de support via de petites sous-slices, et des data races lorsque des alias sont utilisés concurrentiellement. Pour éviter le partage non intentionnel, faites une copie défensive avec copy ou append([]T(nil), s...), ou limitez la capacité avec une expression de slice complète avant append.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[:2] // len 2, cap 4
b := base[2:] // len 2, cap 2
a[0] = 99
fmt.Println(base, a, b)
a = append(a, 77) // reuses base's backing array, overwrites base[2]
fmt.Println(base, a, b)
c := append([]int(nil), base[:2]...) // defensive copy
c[0] = 42
fmt.Println(base, c)
}
10Expliquez la croissance des slices lors d'append à un niveau conceptuel et les implications de performance des réallocations répétées.
Lorsque append ajoute des éléments à une slice, il écrit dans le tableau de support existant si la slice a assez de capacité. Si la capacité est insuffisante, Go alloue un plus grand tableau de support, copie les éléments existants, écrit les nouveaux éléments, et renvoie un en-tête de slice pointant vers le nouveau stockage. La politique exacte de croissance dépend de l'implémentation, mais conceptuellement la capacité croît suffisamment pour rendre les append répétés efficaces en amorti. Les réallocations répétées coûtent tout de même du CPU pour la copie, créent des allocations, augmentent la pression sur le GC, et peuvent rompre le partage avec d'anciens alias de slice. Si vous connaissez la taille attendue, préallouez avec make([]T, 0, n) lors d'une construction par append ou make([]T, n) lors d'un remplissage par index pour réduire les réallocations.
package main
func collectNoPrealloc(input []int) []int {
var out []int
for _, v := range input {
out = append(out, v*2)
}
return out
}
func collectPrealloc(input []int) []int {
out := make([]int, 0, len(input))
for _, v := range input {
out = append(out, v*2)
}
return out
}
11Comment fonctionne l'identifiant blanc en Go pour les valeurs non utilisées, les imports et les vérifications d'interface à la compilation ?
L'identifiant blanc `_` est un placeholder en écriture seule. Lui assigner une valeur la jette et ne crée pas de variable utilisable. On l'utilise pour ignorer des valeurs de retour ou des variables de boucle non nécessaires, pour importer un package uniquement pour ses effets de bord avec `import _ "pkg"`, et pour faire des vérifications d'implémentation d'interface à la compilation telles que `var _ io.Reader = (*MyReader)(nil)`. Un blank import exécute tout de même l'initialisation du package importé. Une assignation de vérification d'interface échoue à la compilation si le method set du type concret ne satisfait pas l'interface.
package main
import "fmt"
func lookup() (string, bool) {
return "gopher", true
}
func main() {
name, _ := lookup() // ignore the bool
fmt.Println(name)
for i, _ := range []int{10, 20} {
fmt.Println(i)
}
}
12Comment fonctionne l'ordre d'initialisation des packages en Go, y compris les fonctions init et les dépendances importées ?
Go initialise les packages dans l'ordre des dépendances. Les dépendances importées d'un package sont initialisées avant le package importateur. Au sein d'un package, les variables de niveau package sont initialisées avant toute fonction `init`, l'initialisation des variables étant ordonnée par dépendance et ordre de déclaration tels que définis par le langage. Ensuite, les fonctions `init` du package s'exécutent automatiquement ; un package peut avoir plusieurs fonctions `init`, et elles ne peuvent pas être appelées directement. Chaque package n'est initialisé qu'une fois. Pour un exécutable, le graphe d'imports est d'abord initialisé, puis le package `main` est initialisé, et enfin `main.main` est appelé.
// a/a.go
package a
import "fmt"
var V = func() int {
fmt.Println("a var")
return 1
}()
func init() { fmt.Println("a init") }
// main.go
package main
import (
"fmt"
"example/a"
)
var M = func() int {
fmt.Println("main var", a.V)
return 2
}()
func init() { fmt.Println("main init") }
func main() { fmt.Println("main") }
13Expliquez les règles de visibilité des packages en Go, y compris les identifiants exportés et la convention du répertoire internal/.
En Go, la visibilité des packages est contrôlée par le nommage des identifiants, et non par des mots-clés d'accès. Un identifiant dont le nom commence par une lettre Unicode majuscule est exporté et peut être référencé depuis d'autres packages ; les autres identifiants sont non exportés et utilisables uniquement au sein du même package. Cela s'applique aux fonctions, types, méthodes, variables, constantes et champs de structures. Les packages utilisent les identifiants exportés pour définir leur API publique et garder les détails d'implémentation non exportés. Séparément, un package situé sous un répertoire `internal/` ne peut être importé que par du code dont le chemin d'import se trouve dans l'arbre parent de ce répertoire `internal` ; cela est appliqué par la toolchain Go.
// module example.com/app
// internal/store/store.go
package store
type Client struct { // exported type
dsn string // unexported field
}
func New(dsn string) *Client { return &Client{dsn: dsn} } // exported
func parseDSN(s string) string { return s } // unexported
// cmd/api/main.go -- allowed: inside example.com/app tree
package main
import "example.com/app/internal/store"
func main() {
c := store.New("db")
_ = c
// c.dsn is not accessible here: field is unexported.
}
// Code outside example.com/app cannot import example.com/app/internal/store.
14Comment fonctionne defer en Go, y compris l'ordre d'exécution, le moment d'évaluation des arguments et l'interaction avec les valeurs de retour ?
`defer` planifie l'exécution d'un appel de fonction lorsque la fonction englobante se termine, qu'elle sorte par un return normal ou par le déroulement d'un panic. Plusieurs appels différés s'exécutent dans l'ordre dernier entré, premier sorti (LIFO). La valeur de fonction différée et ses arguments sont évalués immédiatement lorsque l'instruction `defer` s'exécute, mais l'appel lui-même a lieu plus tard. Avec des valeurs de retour nommées, une instruction return assigne d'abord les valeurs de retour, puis les fonctions différées s'exécutent, de sorte qu'une closure différée peut observer ou modifier les variables de résultat nommées avant que l'appelant ne les reçoive. Cela rend `defer` utile pour le nettoyage, comme fermer des fichiers, déverrouiller des mutex et libérer des ressources.
package main
import "fmt"
func f() (result int) {
x := 1
defer fmt.Println("arg evaluated now:", x)
defer func() {
result++
fmt.Println("deferred closure sees x later:", x)
}()
x = 2
return 10
}
func main() {
fmt.Println("return:", f())
}
15Expliquez le modèle de gestion des erreurs en Go et les façons conventionnelles de créer, retourner et vérifier les erreurs.
Go traite les erreurs comme des valeurs ordinaires, pas comme des exceptions. L'interface intégrée `error` est satisfaite par tout type disposant d'une méthode `Error() string`. Les fonctions retournent conventionnellement une `error` comme dernier résultat, où `nil` signifie succès et une erreur non nulle signifie que l'appelant doit gérer ou propager l'échec. Les erreurs simples sont couramment créées avec `errors.New`, les erreurs formatées avec `fmt.Errorf`, et les appelants vérifient habituellement `if err != nil { ... }`.
package main
import (
"errors"
"fmt"
)
func findUser(id int) (string, error) {
if id <= 0 {
return "", errors.New("invalid user id")
}
if id == 42 {
return "", fmt.Errorf("user %d not found", id)
}
return "alice", nil
}
func handler(id int) error {
name, err := findUser(id)
if err != nil {
return fmt.Errorf("find user: %w", err)
}
fmt.Println(name)
return nil
}
16Comment la récupération après panique (panic recovery) doit-elle être gérée dans les services backend en Go, notamment ce qui se produit lorsqu'une goroutine panique et dans quels cas un processus doit récupérer ou au contraire s'arrêter brusquement ?
Un `panic` déroule la pile de la goroutine courante en exécutant ses fonctions différées (`defer`). `recover` ne fonctionne que lorsqu'il est appelé depuis une fonction différée dans cette même goroutine ; une goroutine ne peut pas intercepter le `panic` d'une autre goroutine. Si un `panic` n'est pas intercepté, le processus plante. Dans les services backend, la récupération doit généralement être placée aux frontières d'isolation, telles que les gestionnaires de requêtes, les middlewares RPC (Remote Procedure Call) ou les points d'entrée des goroutines de traitement (workers), afin qu'une requête ou une tâche en échec ne fasse pas planter tout le service. En revanche, si un `panic` risque d'avoir corrompu l'état partagé ou compromis l'intégrité du processus, il est plus sûr de laisser le processus planter et redémarrer plutôt que de récupérer l'erreur et de continuer aveuglément.
17Que sont les canaux nil en Go, et comment peuvent-ils casser accidentellement du code ou désactiver intentionnellement des cas de select ?
Un canal nil est une variable de type channel dont la valeur est nil, souvent parce qu’elle n’a pas été initialisée avec make ou qu’elle a été explicitement mise à nil. Envoyer vers ou recevoir depuis un canal nil bloque indéfiniment. Dans un select, un cas impliquant un canal nil n’est jamais prêt, donc assigner nil à une variable de canal peut désactiver intentionnellement ce cas. Utiliser accidentellement un canal nil peut faire pendre des goroutines ou empêcher la logique de select de traiter les événements attendus.
var in <-chan int = source
for in != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // disables this receive case
continue
}
fmt.Println(v)
case <-ctx.Done():
return
}
}
18En quoi les opérations atomiques de sync/atomic diffèrent-elles de la synchronisation basée sur des mutex, et quand sont-elles appropriées ?
sync/atomic fournit des opérations indivisibles sur des emplacements mémoire individuels, telles que load, store, add, swap et compare-and-swap, avec des garanties de synchronisation/ordonnancement mémoire. Un mutex protège une section critique, donc il peut garder du code arbitraire et des invariants impliquant plusieurs lectures, écritures ou champs. Les atomics conviennent à un état simple et indépendant tel que des compteurs, des drapeaux, des numéros de séquence, ou des structures lock-free soigneusement conçues. Préférez un mutex lorsque les opérations sont composées, que plusieurs valeurs doivent rester cohérentes, ou que la version atomique serait difficile à raisonner ou à prouver correcte.
package main
import (
"sync"
"sync/atomic"
)
var requests atomic.Int64
func recordRequest() {
requests.Add(1) // one independent counter update
}
type Account struct {
mu sync.Mutex
balance int
limit int
}
func (a *Account) Withdraw(n int) bool {
a.mu.Lock()
defer a.mu.Unlock()
// The check and update must be one protected invariant.
if a.balance-n < -a.limit {
return false
}
a.balance -= n
return true
}
19Comment faut-il concevoir la propriété des canaux et le cycle de vie des goroutines pour éviter les fuites de goroutines ?
Concevez les goroutines avec un propriétaire explicite, un signal d’arrêt clair et un chemin de sortie garanti. Le côté producteur possède en général la fermeture d’un canal, surtout d’un canal de sortie ; les récepteurs ne doivent pas fermer un canal tant que des expéditeurs peuvent encore être actifs. Tout envoi, réception, boucle, timer ou appel externe bloquant doit soit être garanti de se terminer, soit pouvoir se débloquer sur annulation, généralement via context.Context ou un canal done. Utilisez WaitGroup, errgroup ou une coordination similaire afin d’attendre les workers et de ne fermer les canaux qu’après la sortie des expéditeurs.
package worker
import (
"context"
"sync"
)
type Job int
type Result int
func StartWorkers(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
r := Result(j * 2)
select {
case results <- r:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
wg.Wait()
close(results) // close after all senders are done
}()
return results
}
20Quelles sont les causes courantes de fuites de goroutines dans les services Go, et comment les détecter et les corriger en production ?
Les fuites de goroutines courantes dans les services Go proviennent de goroutines bloquées indéfiniment sur des émissions ou des réceptions de canaux, en attente d'autres opérations bloquantes sans mécanisme d'annulation, d'E/S (I/O) bloquées sans délai d'expiration (deadline), de boucles en arrière-plan ou de tickers qui ne s'arrêtent jamais, et de goroutines liées à une requête qui survivent à celle-ci. En production, on surveille une croissance continue du nombre de goroutines ainsi que les symptômes associés, puis on inspecte les dumps de goroutines ou les profils de goroutines `pprof` pour identifier où elles sont bloquées. Corriger la fuite consiste à modifier le code pour permettre à ces goroutines de se terminer : ajouter des annulations et des délais d'expiration, arrêter les tickers, fermer correctement les canaux, éviter les goroutines détachées de la requête et limiter la concurrence si nécessaire.
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
result := <-slowCh // may block forever
_ = result
}()
}