defer, panic et recover en Go : quand les utiliser
1. Vue d’ensemble
Go n’a pas de try/catch. À la place, le langage propose trois outils : defer pour nettoyer les ressources, panic pour signaler une erreur irrécupérable, et recover pour intercepter un panic à une certaine limite.
Cet article explique le mécanisme de chaque outil, les pièges courants, et surtout quand les utiliser et quand ne pas les utiliser. Après lecture, vous saurez utiliser defer correctement, connaître les situations où panic est approprié, et savoir utiliser recover pour empêcher le serveur de s’écraser à cause d’une requête défectueuse.
2. defer
defer repousse l’appel d’une fonction jusqu’à ce que la fonction englobante se termine. La fonction peut se terminer par un return normal ou par un panic. Dans les deux cas, tous les appels defer s’exécutent.
2.1. Ordre d’exécution LIFO
package main
import "fmt"
func main() {
for i := 1; i <= 3; i++ {
defer fmt.Println("defer", i)
}
fmt.Println("done")
}
À chaque fois que Go rencontre defer, il empile l’appel dans une pile propre à la fonction. Quand main se termine, les appels sont retirés dans l’ordre inverse :
done
defer 3
defer 2
defer 1
Cet ordre LIFO a une raison. Les ressources ouvertes ensuite dépendent généralement des ressources ouvertes avant. Par exemple, vous ouvrez une connexion puis une transaction, donc la transaction doit se fermer avant la connexion.
2.2. Les arguments sont évalués immédiatement
C’est le premier piège. Les arguments de la fonction defer sont évalués au moment où on rencontre defer, pas au moment où l’appel s’exécute vraiment.
package main
import "fmt"
func main() {
total := 10
defer fmt.Println("deferred total:", total)
defer func() {
fmt.Println("closure total:", total)
}()
total = 20
fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10
Au premier appel, la valeur 10 est capturée dès qu’on rencontre defer. La closure du deuxième appel est différente. Elle référence la variable total, donc à l’exécution elle lira la dernière valeur qui est 20.
Quand vous avez besoin de la valeur finale, par exemple pour mesurer le temps ou enregistrer l’état, utilisez une closure.
2.3. Modifier la valeur retournée avec named result
Une closure defer peut lire et écrire les named results de la fonction. Cette technique est très utile quand Close() peut retourner une erreur, par exemple lors de l’écriture dans un fichier où les données ne sont effectivement vidées que lors de la fermeture.
package main
import (
"errors"
"fmt"
"os"
)
func writeReport(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, f.Close())
}()
_, err = f.Write(data)
return err
}
func main() {
err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
fmt.Println("error:", err)
}
error: <nil>
L’instruction return err assigne une valeur à la variable err, puis la closure defer s’exécute. La closure fusionne l’erreur de f.Close() dans err avec errors.Join, disponible depuis Go 1.20. errors.Join retourne nil si les deux erreurs sont nil.
Si vous écrivez simplement defer f.Close(), l’erreur lors de la fermeture du fichier sera avalée sans que personne le sache.
Note : Avec un fichier en lecture seule,
defer f.Close()suffit. L’erreur lors de la fermeture d’un fichier read-only n’affecte presque pas les données.
2.4. Piège : defer dans une boucle
func processAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // MAUVAIS : ferme seulement quand processAll retourne
// ... lire f ...
}
return nil
}
defer s’attache à la fonction, pas au bloc ou à la boucle. Avec 10 000 fichiers, ce code garde 10 000 descripteurs de fichiers ouverts en même temps et vous rencontrez facilement l’erreur too many open files.
La solution est de séparer le corps de la boucle dans sa propre fonction :
func processAll(paths []string) error {
for _, p := range paths {
if err := processFile(p); err != nil {
return err
}
}
return nil
}
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // ferme dès que processFile retourne
// ... lire f ...
return nil
}
3. panic
panic arrête le flux d’exécution normal de la goroutine actuelle. Go commence à dérouler la pile : chaque fonction retourne en arrière, et les defer de chaque fonction s’exécutent toujours. Quand on atteint le sommet de la pile de la goroutine sans que personne ne fasse recover, le programme affiche une trace de pile et sort avec le code 2.
3.1. Observer le processus de déroulement
package main
import "fmt"
func main() {
defer fmt.Println("main: deferred")
processOrder()
fmt.Println("never printed")
}
func processOrder() {
defer fmt.Println("processOrder: deferred")
panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state
goroutine 1 [running]:
main.processOrder()
/tmp/main.go:13 +0x...
exit status 2
Vous voyez que les deux defer s’exécutent avant que le programme meure. C’est pour cela que defer mu.Unlock() ou defer tx.Rollback() restent sûrs en cas de panic.
Une panique peut aussi venir du runtime et pas seulement d’un appel panic() explicite. Les cas courants sont l’accès à un index hors limites, la déréférence d’un pointeur nil, l’écriture dans une map nil, ou la division entière par zéro.
3.2. Quand faut-il utiliser panic
La règle générale en Go : si une erreur peut être prédite, retournez une error. Par exemple, un fichier qui n’existe pas, une entrée mal formattée ou un délai d’attente réseau. N’utilisez panic que dans les rares cas suivants.
1. Erreur de programmation, c’est-à-dire violation d’invariant. C’est un état « impossible » si le code est correct, et continuer à s’exécuter ne ferait qu’endommager les données.
func (s OrderStatus) String() string {
switch s {
case Pending:
return "PENDING"
case Paid:
return "PAID"
case Shipped:
return "SHIPPED"
}
panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}
2. Fonction MustXxx utilisée lors de l’initialisation. La stdlib fournit regexp.MustCompile et template.Must. Vous pouvez écrire une fonction similaire pour la configuration :
package config
import (
"fmt"
"strconv"
)
func MustParsePort(s string) int {
port, err := strconv.Atoi(s)
if err != nil || port < 1 || port > 65535 {
panic(fmt.Sprintf("invalid port %q", s))
}
return port
}
var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)
Si la configuration est incorrecte, l’application ne devrait pas démarrer. Faire paniquer au démarrage vous aide à détecter les erreurs tôt, plutôt que de laisser le serveur s’exécuter dans un état cassé.
Note : N’utilisez pas
MustXxxavec des données utilisateur ou une requête. Une mauvaise entrée ne doit pas faire s’écrouler l’intégralité du processus.
4. recover
recover() arrête le processus de déroulement et retourne la valeur passée à panic. Cette fonction ne fonctionne que si elle est appelée directement à l’intérieur d’une fonction defer. Appelée ailleurs, elle retourne nil et ne fait rien.
4.1. Convertir panic en erreur
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
}
}()
return a / b, nil
}
func main() {
fmt.Println(safeDivide(10, 2))
fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero
Quand b == 0, le runtime fait panic. La closure defer intercepte la panique avec recover() puis l’assigne au named result err. La fonction retourne normalement avec result ayant la valeur zéro qui est 0.
Cet exemple est juste pour l’illustration. En pratique, vous devriez vérifier b == 0 et retourner une error directement.
4.2. Piège : recover doit être appelé directement
func logPanic() {
if r := recover(); r != nil {
log.Println("recovered:", r)
}
}
func handlerA() {
defer logPanic() // OK : logPanic est la fonction defer
panic("boom A")
}
func handlerB() {
defer func() {
logPanic() // NE FONCTIONNE PAS : recover est plus profond
}()
panic("boom B")
}
handlerA recover avec succès. handlerB fait toujours planter le programme, parce que recover est appelé à l’intérieur de logPanic, mais logPanic est appelée par une fonction defer — elle n’est pas elle-même une fonction defer.
4.3. Piège : recover ne capture pas les panics d’autres goroutines
Chaque goroutine a sa propre pile. Une panique dans une goroutine enfant écrase l’intégralité du processus, peu importe si main a recover ou non.
func main() {
defer func() { recover() }() // inutile avec la goroutine ci-dessous
go func() {
panic("worker crashed") // le process meurt quand même
}()
time.Sleep(time.Second)
}
Si vous lancez une tâche de fond dont vous ne maîtrisez pas complètement le code, enrobez-la :
func SafeGo(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
}
}()
fn()
}()
}
debug.Stack() se trouve dans le package runtime/debug. Cela aide à enregistrer la trace de pile complète pour que vous puissiez déboguer plus tard.
4.4. Recover aux limites HTTP
Un endroit logique pour mettre recover est à la limite de la requête. Une requête défectueuse ne devrait pas affecter les autres requêtes.
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec) // laisser net/http gérer l'abandon comme prévu
}
log.Printf("panique sur %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
http.Error(w, `{"error":"erreur serveur interne"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
net/http fait déjà recover les panics pour chaque connexion, mais il ne fait que logger puis fermer la connexion, donc le client ne reçoit pas une réponse correcte. Le middleware ci-dessus retourne explicitement un code 500, tout en enregistrant la méthode et le chemin pour un dépannage facile.
5. Tester le panic et le recover
Go utilise le package testing standard, pas besoin de dépendances externes.
package config
import (
"strings"
"testing"
)
func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
result, err := safeDivide(10, 0)
if err == nil {
t.Fatal("expected error, got nil")
}
if result != 0 {
t.Errorf("expected result 0, got %d", result)
}
if !strings.Contains(err.Error(), "divide by zero") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic, got none")
}
if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
t.Errorf("unexpected panic value: %v", r)
}
}()
MustParsePort("abc")
}
func TestMustParsePort_ValidInput(t *testing.T) {
if got := MustParsePort("8080"); got != 8080 {
t.Errorf("expected 8080, got %d", got)
}
}
go test ./... -v
=== RUN TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS
Le deuxième test utilise defer et recover eux-mêmes pour affirmer que la fonction panique et panique avec le bon message. Si vous utilisez testify, assert.PanicsWithValue donne un résultat similaire avec moins de code.
6. Quand NE PAS utiliser
- N’utilisez pas panic/recover comme des exceptions. Écrire
panic(ErrNotFound)puis recover plus haut rend le flux de contrôle difficile à lire et va à l’encontre de la convention Go. Retournez uneerrorà la place. - Ne faites pas panic dans une bibliothèque publique quand l’erreur provient de l’entrée de l’appelant. Les utilisateurs de votre bibliothèque n’attendent pas que leur processus meure.
- Ne faites pas recover puis ignorer silencieusement. Enregistrez au moins la valeur de panique et la trace de pile. Si l’état est compromis, par exemple les données partagées ont été écrites partiellement, laissez le processus mourir et laissez l’orchestrateur redémarrer.
- N’utilisez pas
deferdans les longues boucles (voir section 2.4). - Ne vous inquiétez pas trop de la performance du
defer. Depuis Go 1.14, le open-coded defer a presque aucun coût dans la plupart des cas. N’optimisez que si le profiler montre que c’est un goulot d’étranglement.
Note : Depuis Go 1.21,
panic(nil)ne fait plus retournernilàrecover(). À la place,recover()retourne*runtime.PanicNilError. L’ancien code utilisantrecover() == nilpour vérifier « pas de panic » est maintenant plus précis.
7. Conclusion
Ces trois outils ont des rôles très différents :
deferest un outil quotidien pour nettoyer les ressources. Rappelez-vous deux choses : les arguments sont évalués dès qu’on rencontredefer, etdefers’attache à la fonction et non à la boucle. Combiné avec named result,defervous aide à ne pas oublier les erreurs deClose().panicest destiné aux erreurs de programmation et aux erreurs d’initialisation irrécupérables. Pour les autres erreurs, retournez uneerror.recoverne devrait apparaître qu’aux limites : middleware HTTP, goroutine de travail, ou appel dans du code que vous ne maîtrisez pas. Il doit être appelé directement dans une fonctiondeferet ne capture les panics que de cette goroutine.
Maîtriser ces limites, vous écrirez du code Go à la fois sûr et conforme à l’esprit du langage : l’erreur est une valeur, et panic est une véritable exception.