Questions d'entretien Middle pour développeur backend Go
15 questions sélectionnées pour les développeurs Middle Go qui doivent expliquer les compromis, la concurrence et le comportement des services backend.
1Expliquez 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.
2Quels 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.
3Comment 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.
4En quoi les type aliases diffèrent-ils des defined types, et quand utiliser chacun ?
Un defined type, comme `type UserID int64`, crée un nouveau type distinct dont le underlying type est `int64`. Il n’est pas librement assignable à `int64` sans conversion et peut avoir ses propres méthodes. Un type alias, comme `type UserID = int64`, n’est qu’un autre nom pour le même type, donc l’identité de type et l’assignabilité sont préservées. Utilisez les defined types pour la modélisation de domaine, la sûreté de typage et les méthodes ; utilisez les aliases surtout pour le refactoring, la migration ou la compatibilité sans introduire un nouveau type.
5Comment Go traite-t-il les valeurs flottantes spéciales et quels pièges d’égalité comptent dans les systèmes backend ?
Les `float32` et `float64` de Go suivent un comportement de style IEEE-754, y compris des valeurs spéciales comme l’infini positif/négatif et NaN. Pour `float64`, les helpers incluent `math.Inf`, `math.IsInf`, `math.NaN` et `math.IsNaN`. NaN n’est égal à rien, y compris à lui-même, donc `x == x` est faux lorsque `x` est NaN. L’égalité exacte sur des floats calculés est aussi risquée car l’arrondi et la précision peuvent faire différer des valeurs mathématiquement égales ; utilisez des tolérances adaptées au domaine ou évitez les floats pour des valeurs métier exactes comme l’argent. Les floats sont autorisés comme clés de map, mais les clés NaN sont problématiques car la recherche dans une map dépend de l’égalité et NaN ne se compare pas égal, même à lui-même.
6Décrivez l'embedding de structs en Go et le comportement des champs et méthodes promus.
L'embedding de structs en Go consiste à déclarer un champ par son type sans nom de champ explicite, par exemple `type User struct { Person }`. La valeur embarquée reste un vrai champ, accessible via `u.Person`, mais ses champs et méthodes exportés/accessibles peuvent être promus afin que les appelants puissent écrire des sélecteurs comme `u.Name` ou `u.Greet()` comme raccourci pour passer par le champ embarqué. L'embedding est de la composition, pas de l'héritage classique : le type externe n'est pas automatiquement un sous-type du type embarqué. Si des sélecteurs promus entrent en conflit, Go ne choisit pas à votre place ; les noms ambigus doivent être qualifiés ou ne sont pas sélectionnables via la valeur externe.
7Dé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.
8Expliquez 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.
9Que sont les motifs d'enregistrement au moment de l'init en Go, et quels risques accompagnent les effets de bord des imports blank et les registres globaux ?
Un motif d'enregistrement au moment de l'init consiste pour un package à enregistrer une implémentation auprès d'un registre partagé depuis une fonction `init`. Un blank import tel que `_ "example.com/driver"` est souvent utilisé pour importer un package uniquement pour ses effets de bord, ce qui provoque l'exécution de sa fonction `init` même si aucun nom exporté n'est référencé. C'est courant pour les points d'extension de drivers, codecs, plugins, métriques ou sérialiseurs. Les risques sont des dépendances cachées et des effets de bord au démarrage, un état global mutable, des enregistrements en double ou sensibles à l'ordre, une isolation des tests plus difficile, et un câblage de dépendances moins explicite. Il faut l'utiliser délibérément, le documenter clairement, et souvent l'atténuer par un enregistrement explicite, des registres idempotents/sûrs en concurrence, ou des registres injectables/réinitialisables pour les tests.
10Comment defer, panic et les valeurs de retour nommées interagissent-ils lors d’un nettoyage susceptible de modifier les erreurs renvoyées ?
Les fonctions différées s’exécutent après l’affectation des valeurs de retour mais avant que la fonction ne rende la main à son appelant. De ce fait, une closure différée peut lire ou modifier des valeurs de retour nommées telles qu’un `err` nommé. C’est couramment utilisé pour ajouter aux erreurs renvoyées celles du nettoyage provenant de `Close`, `Commit` ou d’opérations similaires, en préservant de préférence l’erreur principale plutôt qu’en l’écrasant. Pendant le déroulement d’un panic, les fonctions différées s’exécutent encore ; une fonction différée peut faire un recover et fixer une valeur de retour nommée, mais cela doit rester limité à des frontières de panic intentionnelles. Attention à ne pas masquer une variable de retour nommée comme `err`, car le defer observerait ou modifierait alors une autre variable que celle prévue.
11Comment fonctionnent errors.Is, errors.As et %w dans les chaînes d'enveloppement d'erreurs en Go ?
`fmt.Errorf` avec `%w` crée une nouvelle erreur qui enveloppe une erreur sous-jacente tout en ajoutant du contexte. Les wrappers exposent les erreurs sous-jacentes via `Unwrap`, formant une chaîne ou un arbre que la bibliothèque standard peut inspecter. `errors.Is(err, target)` vérifie si `err` ou tout ce qu'il enveloppe correspond à une erreur cible. `errors.As(err, &target)` vérifie si `err` ou tout ce qu'il enveloppe est assignable au type cible et stocke la valeur correspondante dans le pointeur fourni.
12Que sont les sentinel errors, et quels sont les compromis par rapport aux erreurs typées personnalisées ou à des modèles d'erreurs de domaine plus riches ?
Une sentinel error est une valeur d'erreur nommée, souvent une variable au niveau du package comme `var ErrNotFound = errors.New("not found")`, utilisée pour représenter une condition spécifique que les appelants peuvent tester, typiquement avec `errors.Is` lorsque l'emballage (wrapping) est possible. Les sentinels sont simples et utiles pour des catégories larges et stables, mais les sentinels exportées font partie de l'API et peuvent coupler les appelants à des valeurs particulières. Les erreurs typées personnalisées peuvent porter des champs structurés et être trouvées avec `errors.As`. Des modèles d'erreurs de domaine plus riches classent les échecs par kind/code et peuvent inclure des messages sûrs ou des métadonnées, ce qui est utile lorsque les appelants ont besoin d'un comportement stable au-delà d'une seule valeur d'erreur fixe.
13Comment un backend Go doit-il mapper les erreurs internes vers des réponses client utiles tout en fournissant aux opérateurs des signaux diagnostiquables ?
Un backend Go doit traduire les erreurs internes à une frontière applicative ou de transport en catégories stables et sûres pour le client, ainsi qu'en réponses correspondantes. Ces catégories doivent se mapper vers des codes de statut HTTP appropriés ou des statuts de transport équivalents, avec des messages sûrs et des codes lisibles par machine plutôt que des erreurs internes brutes. Les opérateurs doivent tout de même obtenir des informations de diagnostic via des logs structurés, des traces, des métriques, des IDs de corrélation/requête, et des causes sous-jacentes préservées. La journalisation se fait généralement une seule fois à une frontière qui dispose du contexte de requête, pour éviter à la fois les échecs manqués et les logs dupliqués bruyants.
14Comment implémenter correctement des types d'erreurs personnalisés en Go, et comment errors.Join affecte-t-il l'inspection et la gestion des erreurs sur le chemin de nettoyage ?
Un type d'erreur Go personnalisé satisfait `error` en implémentant `Error() string`. Il peut aussi porter des champs structurés et implémenter `Unwrap() error` pour exposer une cause sous-jacente. Les receivers pointeur versus valeur comptent : un receiver pointeur signifie que seul `*T` satisfait `error`, tandis qu'un receiver valeur signifie généralement que `T` et `*T` le font, ce qui affecte la copie et le type que les appelants doivent utiliser avec `errors.As`. `errors.Join` combine plusieurs erreurs en une seule ; `errors.Is` et `errors.As` inspectent les enfants joints. C'est utile lorsque à la fois une erreur d'opération principale et une erreur de nettoyage/différée doivent être renvoyées sans perdre l'un ou l'autre échec.
15Comment fonctionne select avec les channels, y compris le choix des cases prêts, les cases default, et les opérations annulables ?
select attend sur plusieurs opérations de channel et exécute un case dont l'envoi ou la réception peut se faire. Si aucun case de channel n'est prêt, il bloque sauf s'il y a un case default ; default s'exécute immédiatement seulement lorsqu'aucune opération de channel ne peut se faire, ce qui est utile pour des tentatives d'envoi/réception non bloquantes. Si plusieurs cases sont prêts, Go en choisit un de façon pseudo-aléatoire plutôt que selon l'ordre source. Les opérations de channel annulables ajoutent couramment un case qui reçoit depuis ctx.Done() afin que la goroutine puisse cesser d'attendre lorsque le contexte est annulé ou expire.