Style Go : lisible et rapide
L'objectif est d'écrire du code qu'un mainteneur peut lire de haut en bas, avec des performances qui viennent de la forme de l'API et des données plutôt que d'astuces disséminées dans le code. Lisibilité et rapidité sont rarement en conflit une fois la conception bien faite ; lorsqu'elles le sont, n'abandonnez la lisibilité que sur un chemin chaud mesuré, et mentionnez-le dans un commentaire.
Ceci suit la philosophie de Dave Cheney "API Design as a Performance Primitive" : au-delà d'un certain point, la performance ne peut pas être ajoutée après coup, alors concevez-la dès le départ, puis gardez tout le reste simple.
Sources : talk de Cheney (https://dave.cheney.net/paste/gophercon-uk-2026.html), avec le code dans github.com/pkg/json et github.com/davecheney/bytereader ; John Ousterhout, "Define errors out of existence".
Ordre des priorités
- Correction. Ne la sacrifiez jamais pour la vitesse ou la concision.
- Forme de l'API et complexité algorithmique. Qui possède la mémoire, ce qui est retourné, combien de travail chaque appel effectue. Cela décide de la performance plus que tout réglage ultérieur.
- Allocations sur le chemin chaud. Chaque allocation coûte deux fois : une fois pour allouer, une fois pour le ramasse-miettes. Le ramasse-miettes peut être rapide, "mais ne pas allouer sera toujours plus rapide."
- Travail par unité d'entrée. Touchez chaque octet une seule fois et effectuez des appels par jeton ou enregistrement, pas par octet.
- Micro-optimisations (inlining manuel, astuces binaires, éviter les vérifications de bornes), uniquement sur un chemin qu'un profil montre comme chaud, et seulement avec un benchmark prouvant le gain.
Si un changement n'est pas justifié au niveau supérieur, ne le faites pas.
Séparer les chemins chauds et froids
Décidez quel code s'exécute par événement ou par requête (chaud) et lequel s'exécute lors de la configuration, de la construction, de la mise à jour ou du traitement par lots (froid).
- Chemin froid : écrivez le code le plus simple. Clonez plutôt que partagez, utilisez des maps et
fmt, allouez librement. N'utilisez pas d'idiomes du chemin chaud ici ; ils coûtent en lisibilité et n'apportent rien. - Chemin chaud : peut utiliser des tampons réutilisables, des compteurs de génération, des tableaux plats et des techniques similaires. Donnez à chaque technique un nom (un petit type ou une fonction d'aide) et un commentaire expliquant pourquoi elle existe, afin qu'un lecteur n'ait pas à la rétro-ingénierer.
Concevoir l'API pour la performance
L'API décide combien un appelant peut économiser. "Vous faites un benchmark du ramasse-miettes" lorsque l'API force chaque résultat sur le tas. Façonnez-la pour que le chemin rapide soit le naturel ; un gain de 2 à 10x est souvent disponible "si vous êtes prêt à accepter une API différente".
- Ne retournez pas de valeurs qui doivent forcément s'échapper. Retourner un type primitif via un
interface{}/any, une nouvelle chaîne, une map ou un pointeur vers une nouvelle structure par élément force une allocation par appel (encoding/json.Decoder.Tokenest l'exemple classique d'"usine à déchets"). Retournez un type concret, ou une vue comme[]byte, et laissez l'appelant ne convertir que ce dont il a besoin. - Retournez des vues, pas des copies, lorsque l'appelant a seulement besoin de lire. Documentez combien de temps la vue est valide ("valide jusqu'au prochain appel", comme
bufio.Scanner.Bytes). Une vue de l'entrée brute (les octets du jeton, dont le premier octet donne son type) est moins coûteuse qu'une valeur décodée que l'appelant pourrait ignorer. - Laissez l'appelant posséder la mémoire réutilisable. Acceptez un argument de travail ou de tampon, ou conservez-le dans une structure que l'appelant garde, au lieu d'allouer à chaque appel. Laissez les appelants fournir le tampon initial, afin qu'il puisse vivre sur la pile ou provenir d'un pool :
var buf [8 << 10]byte; d := NewDecoderBuffer(r, buf[:]). - Cachez la gestion des tampons derrière quelques méthodes.
io.Readercopie dans un tampon géré par l'appelant et laisse le re-remplissage à chaque appelant. Une petite API fenêtrée conserve les données en place :Window() []bytepour regarder,Release(n)pour consommer,Extend() intpour lire plus, avec "la fenêtre est invalidée par Release ou Extend" dans le commentaire de documentation. - Définissez les erreurs en dehors de la boucle chaude. Utilisez une itération qui retourne un booléen ou un résultat vide et signale l'erreur après coup :
for s.Next() { ... }puiss.Err(). Utilisez des résultats sentinelles sur le chemin lent (Extendretourne 0, puis l'appelant demande l'erreur) afin que la boucle interne n'ait pas de branches d'erreur. Traitezio.EOFcomme toute autre condition terminale. - Comptez le travail par unité d'entrée. Traitez chaque octet une seule fois, effectuez des appels de fonction par jeton ou enregistrement plutôt que par octet, et supprimez les copies intermédiaires. Passer d'appels par octet à des appels par jeton est souvent le deuxième plus grand gain après la suppression des allocations.
- Superposez l'API. Proposez un bloc de construction de bas niveau, sans allocation (jetons bruts, vues, itérateurs propriétaires de leur tampon) et construisez l'API pratique par-dessus. Ne faites pas de l'API pratique et allouante la seule disponible.
- Écrivez la propriété et la durée de vie dans les commentaires de documentation : ce qui est emprunté, ce qui est conservé, ce qui est sûr pour une utilisation concurrente, ce que signifie la valeur zéro.
- Rendez la valeur zéro utile là où c'est naturel.
- Préférez les valeurs partagées immuables et publiez de nouvelles instantanés, plutôt que des verrous autour d'un état mutable partagé, lorsque les lecteurs dominent.
Mesurez l'API, pas seulement l'implémentation : comparez allocs/op et débit par rapport à l'API que vous remplacez. references/examples.md montre ces formes côte à côte.
Accorder une boucle chaude, en dernier
Seulement après que l'API et la complexité algorithmique soient correctes, et qu'un profil montre que la boucle est chaude :
- Un
switchsur un état se compile en une chaîne de comparaisons. De petites méthodes par état, choisies via un champfunc(d.state = (*Decoder).stateValue), gardent chaque état lisible et peuvent supprimer cette répartition. - Déplacez une vérification dans la fonction qui utilise la valeur afin que le compilateur puisse prouver que les vérifications de bornes sont inutiles, et gardez le point d'entrée sous le budget d'inlining (80 ; un appel coûte environ 52). Vérifiez avec
go build -gcflags=-m=2. - Fusionner deux fonctions pour éviter un appel peut gagner 5 à 15 % sur un chemin par jeton, au prix d'un imbrication plus profonde. Ne le faites qu'avec un benchmark, et expliquez pourquoi dans un commentaire.
Comme le dit Cheney : ne recourez pas à ces astuces "sans avoir traité les effets de complexité algorithmique dans votre API. Cela ne fera pas bouger l'aiguille."
Garder les fonctions petites et plates
Visez à ce que chaque fonction fasse une seule chose, avec au plus une ou deux branches. Comme guide, gardez la complexité cyclomatique à 10 ou moins et la complexité cognitive à 10 ou moins (gocyclo, gocognit). Un switch de type plat qui confie chaque cas à un appel est acceptable au-dessus ; l'imbrication ne l'est pas.
- Nommez les conditions à plusieurs termes. Remplacez
if len(a)*len(b) > maxN || size(a)*len(b)+size(b)*len(a) > maxMparif !fitsProduct(a, b). Le nom documente l'intention et la fonction peut porter l'explication. - Répartissez, puis déléguez. Un
switchsur les types de nœuds ou de messages doit appeler une méthode par cas, et non contenir la logique de chaque cas. - Retournez tôt. Les clauses de garde d'abord, le chemin principal non indenté.
- Ne répétez pas un cas. Si deux branches ne diffèrent que par l'ordre des opérandes, écrivez une seule fonction d'aide et appelez-la deux fois.
- Séparez les préoccupations mélangées. "Trouver", "construire la nouvelle valeur" et "nettoyer" sont trois fonctions, pas une.
- Évitez les paramètres booléens. Si l'un est l'option la plus claire, donnez-lui un nom significatif et expliquez-le au site d'appel.
- Utilisez des tables de recherche (
map[string]Kind,[256]bool) au lieu de chaînes de comparaisons de chaînes ou d'octets.
Façonner les données pour le lecteur et le processeur
- Regroupez les tableaux parallèles en un tableau de structures. Quatre tableaux indexés par le même identifiant sont une seule structure. Cela se lit mieux et améliore généralement la localité.
- Donnez à un mécanisme répété son propre petit type. Si la même logique de compteur, de bitmap ou de tampon apparaît deux fois, c'est un type avec deux ou trois méthodes.
- Préférez les champs explicites au bit-packing sauf si le packing est mesuré comme important.
[]segment{name, each}bat unuint64de drapeaux avec des décalages de bits. - Gardez les représentations peu nombreuses. Moins d'états ou de types signifient moins de
switchen double. Fusionnez un cas spécial dans le cas général lorsque cela ne coûte rien de mesurable.
Prouver les affirmations de performance
Ne prétendez jamais qu'un changement est plus rapide, plus lent ou sans allocation sans le mesurer. Avant de modifier du code sensible à la performance :
- Enregistrez une référence
benchstatdes benchmarks pertinents. - Effectuez le changement, puis comparez. Rapportez les différences significatives avec des chiffres.
- Diffusez la sortie de
go build -gcflags=-mpar rapport à la base pour détecter de nouvelles échappées de tas. - Conservez ou ajoutez des tests
testing.AllocsPerRunpour les chemins qui promettent zéro allocation. - Profilez avant de deviner la cause d'une régression.
Si la simplification coûte du temps mesurable sur le chemin chaud et que vous ne pouvez pas le récupérer proprement, arrêtez-vous et signalez le compromis plutôt que d'ajouter silencieusement de la complexité ou d'accepter silencieusement la régression.
Lisez references/verify.md pour les commandes exactes, y compris comment mesurer la complexité et les pièges qui causent couramment des régressions. Lisez references/examples.md pour des refactorisations avant-après qui montrent le style.
Suivre les conventions Go
gofmt,go vetet le linter du projet doivent passer. Corrigez le code, n'affaiblissez ni n'excluez les règles du linter ; ne supprimez une seule ligne qu'avec une règle nommée et une raison.- Noms de récepteur courts, cohérents sur les méthodes d'un type ; ne mélangez pas les récepteurs pointeur et valeur sur un même type sans raison.
- Les commentaires de documentation commencent par le nom de l'identifiant et énoncent le contrat.
- Commentez le pourquoi, pas le quoi. Adaptez-vous à la densité de commentaires environnante.
- Bibliothèque standard d'abord (
slices,maps,strings,math/bits) avant les fonctions d'aide écrites à la main. - Ne modifiez jamais manuellement du code généré.
Lors de la révision ou de la refactorisation
- Identifiez le chemin chaud et ses benchmarks avant de toucher à quoi que ce soit.
- Mesurez la complexité pour trouver les pires fonctions ; corrigez-les d'abord.
- Refactorisez par petites étapes, en exécutant des tests ciblés après chacune.
- Gardez le comportement identique : mêmes résultats, mêmes budgets et solutions de repli, mêmes garanties d'allocation. Ne modifiez le comportement qu'en tant que décision distincte et déclarée.
- Signalez ce qui a changé, les chiffres de complexité avant/après et les résultats des benchmarks, et tout ce qui n'a pas été vérifié.