Article original : How to Work with Collections in Go Using the Standard Library Helpers

Dans un article précédent — Arrays, Slices, and Maps in Go: a Quick Guide to Collection Types — nous avons exploré les trois types de collections intégrés de Go et leur fonctionnement interne. Cela nous a donné les bases pour stocker et accéder aux données efficacement.

Mais dans les programmes réels, disposer des données n'est que le début. Vous avez généralement besoin de trier une slice, de rechercher un élément, de cloner ou de comparer des collections, ou même de recourir à des structures de données de plus haut niveau comme des heaps (tas) ou des rings (anneaux). Écrire tout cela à la main est fastidieux et source d'erreurs.

Si les tableaux, les slices et les maps sont les noms des collections de Go, alors les utilitaires de la bibliothèque standard en sont les verbes. Ils vous permettent d'agir sur vos données : trier, rechercher, cloner, filtrer et transformer de manière prévisible et efficace.

Les ajouts récents comme les packages slices et maps (introduits dans Go 1.21 et améliorés dans la version 1.25) vous offrent des opérations génériques et type-safe, tandis que des packages de longue date comme sort et container/heap gèrent les essentiels tels que l'ordonnancement, la recherche et les files de priorité.

Dans cet article, nous allons passer en revue ces utilitaires avec des exemples et des études de cas. À la fin, vous saurez comment manipuler les collections de manière idiomatique en Go, en utilisant toute la puissance de la bibliothèque standard.

Ce que nous allons couvrir :

Prérequis

Pour suivre ce guide, vous devriez :

  • Être à l'aise avec les bases de Go comme les variables, les fonctions et les structs.

  • Avoir lu l'article précédent article sur les tableaux, les slices et les maps, ou comprendre comment ces types de collections fonctionnent.

  • Avoir Go 1.25 ou une version ultérieure installée sur votre système pour essayer les utilitaires modernes de slices et maps ainsi que les récentes améliorations du langage.

Vous n'avez pas besoin de connaissances préalables en algorithmes ou en structures de données — tout ce que nous utilisons de la bibliothèque standard sera expliqué étape par étape.

Tri et recherche dans les collections

Le tri et la recherche font partie des opérations les plus courantes que vous effectuerez sur des slices et des tableaux en Go. La bibliothèque standard fournit des outils robustes pour rendre ces tâches simples et efficaces. Dans cette section, nous explorerons les packages sort et slices, avec des exemples montrant comment les utiliser dans des scénarios pratiques.

Tri avec sort

Le package sort fournit des fonctions pour trier des slices de types basiques (int, string, float64) et une fonction générique sort.Slice pour les types personnalisés.

Trier une slice d'entiers

package main

import (
    "fmt"
    "sort"
)

func main() {
    scores := []int{42, 23, 17, 99, 56}

    // Tri par ordre croissant
    sort.Ints(scores)
    fmt.Println("Scores triés :", scores)
}

Sortie :

Sorted scores: [17 23 42 56 99]

Trier une slice de chaînes de caractères

names := []string{"Alice", "Bob", "Charlie", "Diana"}
sort.Strings(names)
fmt.Println("Noms triés :", names)

Sortie :

Sorted names: [Alice Bob Charlie Diana]

Tri inversé

Pour trier dans l'ordre inverse, vous devez d'abord convertir la slice en un type qui implémente sort.Interface, comme sort.IntSlice ou sort.StringSlice, puis utiliser sort.Reverse :

sort.Sort(sort.Reverse(sort.StringSlice(names)))
fmt.Println("Noms triés en sens inverse :", names)

sort.StringSlice est un type qui enveloppe un []string et implémente sort.Interface, ce qui permet de l'utiliser avec les fonctions du package sort. Ensuite, sort.Reverse prend cela et fournit un ordonnancement inversé. Enfin, sort.Sort effectue le tri réel.

Notez que sort.Reverse ne renverse pas réellement votre slice au préalable, il indique simplement à Go de la trier dans la direction opposée.

Sortie :

Reverse sorted names: [Diana Charlie Bob Alice]

Trier selon un critère personnalisé

Pour les slices de structs ou une logique personnalisée, utilisez sort.Slice et fournissez une fonction de comparaison :

type Player struct {
    Name  string
    Score int
}

players := []Player{
    {"Alice", 42},
    {"Bob", 99},
    {"Charlie", 17},
}

// Tri par Score décroissant
sort.Slice(players, func(i, j int) bool {
    return players[i].Score > players[j].Score
})

fmt.Println("Joueurs triés par score :", players)

Sortie :

Players sorted by score: [{Bob 99} {Alice 42} {Charlie 17}]

Ici, nous passons la slice que nous voulons trier (players) et une fonction de comparaison à sort.Slice. Le tri fonctionne en appelant votre fonction de comparaison avec deux indices (i et j) de manière répétée pendant le processus. Votre fonction renvoie vrai si l'élément à l'index i doit venir avant l'élément à l'index j dans l'ordre final. Dans ce cas, players[i].Score > players[j].Score crée un tri décroissant car les éléments avec les scores les plus élevés sont placés avant ceux avec les scores les plus bas.

Parfois, vous voudrez trier une slice tout en conservant l'ordre original des éléments égaux. Pour cela, utilisez sort.SliceStable :

sort.SliceStable(players, func(i, j int) bool {
    return players[i].Score > players[j].Score
})

Cela garantit que si deux joueurs ont le même score, leur ordre d'origine dans la slice est préservé.

Recherche avec sort.Search

Une fois qu'une slice est triée, sort.Search offre un moyen pratique d'effectuer une recherche binaire pour trouver l'index du premier élément qui satisfait une condition.

La recherche binaire est un algorithme rapide pour trouver une valeur cible dans un tableau trié. Elle fonctionne en divisant répétitivement l'intervalle de recherche en deux, en comparant l'élément du milieu à la cible, puis en continuant la recherche dans la moitié gauche ou droite selon le résultat. Cette approche réduit rapidement l'espace de recherche et trouve la cible en un temps O(log n). Voici un diagramme simple pour illustrer le processus de recherche binaire :

une image montrant les étapes d'une recherche binaire

Cela offre un gain de performance significatif par rapport à la recherche linéaire, surtout pour les grands ensembles de données. Voici quelques cas d'utilisation courants :

  • Trouver un seuil : Quel joueur a atteint le premier un score de 50 ?

  • Insérer tout en maintenant l'ordre : Où devrions-nous insérer un nouveau score pour que le classement reste trié ?

  • Filtrer des plages : Quel est le premier élément au-dessus d'une certaine valeur ?

Exemple : Trouver le premier score au-dessus d'un seuil

scores := []int{17, 23, 42, 56, 99}
threshold := 50

// Trouver l'index du premier score >= threshold
index := sort.Search(len(scores), func(i int) bool {
    return scores[i] >= threshold
})

if index < len(scores) {
    fmt.Printf("Le premier score >= %d est à l'index %d avec la valeur %d\n",
        threshold, index, scores[index])
}
else {
    fmt.Printf("Aucun score >= %d trouvé\n", threshold)
}

Sortie :

First score >= 50 is at index 3 with value 56

L'index est pratique : il nous indique quel joueur franchit le seuil en premier. Nous pouvons l'utiliser pour mettre en évidence ce joueur, insérer de nouveaux scores ou extraire une sous-liste.

Que se passe-t-il ici si le seuil est plus élevé que n'importe quel score de la liste ? sort.Search renverra la longueur de la slice, ce qui est un index hors limites. Vérifiez toujours l'index retourné avant de l'utiliser pour éviter les panics au runtime.

Tri et recherche avec slices

Le package slices fournit des fonctions pratiques qui simplifient les opérations courantes sur les slices, réduisent le code redondant et fonctionnent avec des types génériques.

import (
    "fmt"
    "slices"
)

scores := []int{42, 23, 17, 99, 56}

// Tri sur place (in-place)
slices.Sort(scores)
fmt.Println("Scores triés :", scores)

// Recherche binaire
index, found := slices.BinarySearch(scores, 56)
if found {
    fmt.Println("56 trouvé à l'index :", index)
} else {
    fmt.Println("56 non trouvé")
}

Sortie :

Sorted scores: [17 23 42 56 99]
Found 56 at index: 3

La fonction slices.Sort trie la slice sur place, tandis que slices.BinarySearch effectue une recherche binaire sur la slice triée. Si l'élément est trouvé, elle renvoie son index et true ; sinon, elle renvoie l'index où l'élément pourrait être inséré et false.

Pourquoi slices.Sort est-il plus pratique ? Avec le package slices plus récent (Go 1.18+), vous pouvez trier et rechercher avec un seul import, et cela fonctionne avec n'importe quel type ordonné — grâce aux génériques de Go. L'API est également plus simple pour les types basiques, car vous n'avez pas besoin de fournir une fonction de comparaison.

La fonctionnalité de génériques de Go vous permet d'écrire des fonctions qui fonctionnent avec de nombreux types tout en maintenant la sécurité des types (type safety). Le package slices utilise les génériques pour que vous puissiez trier ou rechercher des slices d' int, float64, string, ou tout autre type ordonné, le tout avec le même appel de fonction.

Trier des types personnalisés

type Player struct {
    Name  string
    Score int
}

players := []Player{
    {"Alice", 42},
    {"Bob", 99},
    {"Charlie", 17},
}

// Tri par Score décroissant
slices.SortFunc(players, func(a, b Player) int {
    return b.Score - a.Score
})

fmt.Println("Joueurs triés par score :", players)

Sortie :

Players sorted by score: [{Bob 99} {Alice 42} {Charlie 17}]

Ici, slices.SortFunc prend une fonction de comparaison qui renvoie une valeur négative si a doit venir avant b, zéro s'ils sont égaux, et une valeur positive si a doit venir après b. Cela permet des critères de tri flexibles.

Exemple pratique : trier un tableau de classement

type Player struct {
    Name  string
    Score int
    Date  string // date d'obtention
}

func main() {
    leaderboard := []Player{
        {"Alice", 42, "2023-01-01"},
        {"Bob", 99, "2023-01-02"},
        {"Charlie", 17, "2023-01-03"},
        {"Diana", 56, "2023-01-04"},
    }

    // Tri décroissant par score
    slices.SortFunc(leaderboard, func(a, b Player) int {
        return b.Score - a.Score
    })

    fmt.Println("Meilleurs joueurs :")
    for i, p := range leaderboard {
        fmt.Printf("%d: %s (%d points) - %s\n", i+1, p.Name, p.Score, p.Date)
    }
}

Sortie :

Top players:
1: Bob (99 points) - 2023-01-02
2: Diana (56 points) - 2023-01-04
3: Alice (42 points) - 2023-01-01
4: Charlie (17 points) - 2023-01-03

Dans cet exemple, nous définissons une struct Player avec un nom, un score et une date. Nous créons une slice de joueurs représentant un classement. En utilisant slices.SortFunc, nous trions les joueurs par ordre décroissant de leurs scores. Enfin, nous affichons le classement trié.

Points clés à retenir

  • Utilisez sort.Ints, sort.Strings ou sort.Slice pour les tâches de tri classiques.

  • Utilisez sort.Search pour les recherches binaires sur des slices triées.

  • Le package slices simplifie le tri et la recherche avec des utilitaires génériques et type-safe.

  • Pour les structs, SortFunc ou slices.SortFunc offre un moyen propre de définir une logique de tri personnalisée.

  • Le tri est souvent la première étape avant d'appliquer d'autres utilitaires comme le filtrage, le mapping ou les files de priorité.

Utilitaires de collection : slices & maps

Une fois que vous savez comment stocker, trier et rechercher des collections, l'étape suivante consiste à les manipuler efficacement. La bibliothèque standard de Go fournit des utilitaires modernes et type-safe dans les packages slices et maps, qui simplifient les opérations courantes comme le clonage, le filtrage, la suppression et l'extraction de clés ou de valeurs.

Utilitaires pour les slices

Le package slices offre une variété de fonctions pour travailler avec les slices de manière plus pratique. Vous avez pu voir slices.Sort et slices.BinarySearch dans le chapitre précédent — en voici d'autres utiles :

Clonage d'une slice

import "slices"

original := []int{1, 2, 3, 4}
copy := slices.Clone(original)
copy[0] = 99

fmt.Println("Original :", original)
fmt.Println("Copie :", copy)

Sortie :

Original: [1 2 3 4]
Copy: [99 2 3 4]

Rappelez-vous que les slices sont des types de référence en Go. Le clonage évite la mutation accidentelle de la slice d'origine lors de la transmission de slices.

Vérification de la présence et de l'égalité

import "slices"

a := []int{1, 2, 3}
b := []int{1, 2, 3}
c := []int{4, 5, 6}

fmt.Println("Contient :", slices.Contains(a, 2))
fmt.Println("Égal :", slices.Equal(a, b))
fmt.Println("Égal :", slices.Equal(a, c))

Sortie :

Contains: true
Equal: true
Equal: false

slices.Contains vérifie si une slice contient un élément spécifique.

slices.Equal vérifie si deux slices sont égales en longueur et en contenu. Notez que a == b renverrait false car ce sont des en-têtes de slice différents, même si leur contenu est identique.

Insertion et suppression d'éléments

names := []string{"Alice", "Bob", "Charlie"}

// Insérer "Diana" à l'index 1
names = slices.Insert(names, 1, "Diana")
fmt.Println(names) // [Alice Diana Bob Charlie]

// Supprimer l'élément à l'index 2
names = slices.Delete(names, 2, 3)
fmt.Println(names) // [Alice Diana Charlie]

Sortie :

[Alice Diana Bob Charlie]
[Alice Diana Charlie]

slices.Insert ajoute un élément à un index spécifié, décalant les éléments suivants vers la droite.

slices.Delete supprime les éléments dans la plage [start, end) (incluant start et excluant end), décalant les éléments suivants vers la gauche.

Trouver le min, le max, trier et utiliser la recherche binaire

Les slices de types ordonnés peuvent être interrogées pour leurs valeurs minimales ou maximales, triées ou recherchées par recherche binaire.

Les types ordonnés en Go sont des types qui supportent les opérateurs de comparaison comme <, <=, >, et >=. Cela inclut les types intégrés tels que les entiers (int, int8, int16, int32, int64), les entiers non signés (uint, uint8, uint16, uint32, uint64), les nombres à virgule flottante (float32, float64) et les chaînes de caractères. Ces types peuvent être comparés directement à l'aide de ces opérateurs.

scores := []int{42, 23, 17, 99, 56}

fmt.Println(slices.Min(scores))
fmt.Println(slices.Max(scores))

Sortie :

17
99

slices.Min et slices.Max renvoient respectivement les valeurs minimales et maximales d'une slice de types ordonnés.

Pour le tri et la recherche binaire, nous avons déjà vu slices.Sort et slices.BinarySearch dans le chapitre précédent.

Exemple pratique : filtrer une slice

Supposons que vous souhaitiez supprimer tous les joueurs ayant un score inférieur à 50 :

type Player struct {
    Name  string
    Score int
    Date  string // date d'obtention
}

players := []Player{
    {"Alice", 42, "2023-01-01"},
    {"Bob", 99, "2023-01-02"},
    {"Charlie", 17, "2023-01-03"},
    {"Diana", 56, "2023-01-04"},
}

// Filtrer les scores faibles
filtered := players[:0] // utilise le même tableau sous-jacent
for _, p := range players {
    if p.Score >= 50 {
        filtered = append(filtered, p)
    }
}

fmt.Println(filtered)

Sortie :

[{Bob 99 2023-01-02} {Diana 56 2023-01-04}]

Ici, nous créons une nouvelle slice filtered de longueur nulle mais partageant le même tableau sous-jacent que players. Cela signifie que vous pouvez construire efficacement la liste filtrée par ajouts successifs, sans allouer un nouveau tableau. Ensuite, nous itérons sur players, en ajoutant seulement ceux dont le score est supérieur ou égal à 50 dans filtered. Cette approche est économe en mémoire puisqu'elle évite l'allocation d'un nouveau tableau.

Utilitaires pour les maps

Le package maps fournit des fonctions génériques pour travailler avec les maps : clonage, comparaison, extraction des clés/valeurs, etc.

Extraction des clés et des valeurs

import "maps"

scores := map[string]int{
    "Alice":   42,
    "Bob":     99,
    "Charlie": 17,
}

// Obtenir toutes les clés
keys := maps.Keys(scores)
fmt.Println(keys) // [Alice Bob Charlie] (ordre non garanti !)

// Obtenir toutes les valeurs
values := maps.Values(scores)
fmt.Println(values) // [42 99 17] (ordre non garanti !)

maps.Keys renvoie une slice de toutes les clés de la map, tandis que maps.Values renvoie une slice de toutes les valeurs.

Important à noter : l'ordre des clés et des valeurs renvoyés par maps.Keys et maps.Values n'est pas garanti, car les maps en Go ne maintiennent aucun ordre spécifique.

Clonage et comparaison de maps

clone := maps.Clone(scores)
fmt.Println(clone)

equal := maps.Equal(scores, clone)
fmt.Println(equal) // true

Sortie :

map[Alice:42 Bob:99 Charlie:17]
true

maps.Clone crée une copie superficielle (shallow copy) de la map, ce qui signifie que la nouvelle map possède son propre ensemble de clés et de valeurs, mais si l'une des valeurs est un type de référence (comme une slice, un pointeur ou une autre map), les deux maps pointeront toujours vers les mêmes données sous-jacentes pour ces valeurs. Seule la structure de la map de haut niveau est dupliquée.

maps.Equal vérifie si deux maps ont les mêmes clés et valeurs. Elle renvoie true si les deux maps contiennent exactement le même ensemble de clés et que, pour chaque clé, la valeur correspondante est identique dans les deux maps. L'ordre des clés n'a pas d'importance.

Suppression sous condition

Disons que nous voulons supprimer d'une map tous les joueurs ayant un score inférieur à 50 :

for name, score := range scores {
    if score < 50 {
        delete(scores, name)
    }
}

Ceci itère sur la map et supprime les entrées qui ne remplissent pas la condition. Notez que la modification d'une map pendant son itération est sûre en Go.

maps.DeleteFunc offre une alternative de style fonctionnel à la boucle ci-dessus :

maps.DeleteFunc(scores, func(name string, score int) bool {
    return score < 50
})

Ici, maps.DeleteFunc prend une fonction prédicat qui renvoie true pour les clés à supprimer. Cela abstrait la boucle et rend l'intention plus claire.

Exemple pratique : combiner des slices et des maps

Imaginez que vous ayez une map de configuration et que vous deviez traiter les clés dans l'ordre alphabétique :

config := map[string]string{
    "host":     "localhost",
    "port":     "8080",
    "protocol": "http",
    "timeout":  "30s",
    "retries":  "3",
    "logLevel": "debug",
}

// Extraire et trier les clés
keys := maps.Keys(config)
slices.Sort(keys)

for _, k := range keys {
    fmt.Printf("%s = %s\n", k, config[k])
}

Sortie :

host = localhost
logLevel = debug
port = 8080
protocol = http
retries = 3
timeout = 30s

C'est un motif courant : maps.Keys + slices.Sort pour traiter les maps de manière déterministe.

Notes sur les performances

Les fonctions des packages slices et maps sont optimisées pour la performance, mais gardez à l'esprit :

  • La plupart des opérations sont en O(n) car elles doivent itérer sur toute la collection.

  • Le clonage crée une copie superficielle, ce qui est rapide mais demande de la prudence avec les types de référence.

  • Les maps ont un temps d'accès moyen de O(1), mais dans le pire des cas de O(n) si de nombreuses clés entrent en collision.

Points clés à retenir

  • Le package slices fournit des opérations type-safe et concises pour cloner, insérer, supprimer et rechercher des slices.

  • Le package maps facilite l'extraction de clés/valeurs, le clonage, la comparaison et la suppression conditionnelle d'entrées de map.

  • La combinaison de ces utilitaires vous permet d'écrire un code Go propre, idiomatique et expressif sans boucles redondantes.

  • Ces utilitaires complètent les routines de tri/recherche et préparent les slices/maps pour des opérations plus avancées, comme la construction de files de priorité ou le filtrage de jeux de données.

Structures de données « classiques » avec container/*

Bien que les slices et les maps couvrent la plupart des besoins quotidiens, vous avez parfois besoin de structures de données spécialisées offrant des performances prévisibles ou des comportements spécifiques. La bibliothèque standard de Go inclut quelques-unes de ces structures sous les packages container/* :

  • container/list – une liste doublement chaînée

  • container/heap – une file de priorité (min-heap par défaut)

  • container/ring – une liste circulaire

Elles ne sont pas aussi couramment utilisées que les slices ou les maps, mais elles sont précieuses lorsque vous avez besoin d'insertions, de suppressions ou d'un comportement de file d'attente efficaces.

Listes doublement chaînées avec container/list

Une liste chaînée est une séquence d'éléments où chaque élément pointe vers le suivant (et dans le cas d'une liste doublement chaînée, également vers le précédent).

  • L'insertion ou la suppression d'éléments est en O(1) une fois que vous avez une référence.

  • L'accès par index est en O(n) (plus lent que les slices).

  • Idéal pour les files d'attente ou lorsque vous avez besoin d'insertions fréquentes au milieu.

Utilisation de base

import (
    "container/list"
    "fmt"
)

func main() {
    l := list.New()

    l.PushBack("Alice")
    l.PushBack("Bob")
    l.PushFront("Eve")

    for e := l.Front(); e != nil; e = e.Next() {
        fmt.Println(e.Value)
    }
}

Sortie :

Eve
Alice
Bob

Ici, nous créons une nouvelle liste doublement chaînée et ajoutons des éléments au début et à la fin. Nous itérons ensuite sur la liste du début à la fin pour afficher la valeur de chaque élément.

Suppression d'éléments

element := l.Front().Next() // Alice
l.Remove(element)           // supprimer Alice

for e := l.Front(); e != nil; e = e.Next() {
    fmt.Println(e.Value)
}

Sortie :

Eve
Bob

Nous supprimons l'élément "Alice" de la liste en obtenant d'abord une référence via l.Front().Next(), puis en appelant l.Remove(element). Après la suppression, nous itérons à nouveau pour afficher les éléments restants.

Quand utiliser list

  • Lorsque vous avez besoin d'insertions/suppressions fréquentes au milieu d'une séquence.

  • Lorsque l'accès direct par index ne vous importe pas.

  • Sinon, les slices sont généralement plus simples et plus rapides.

Files de priorité avec container/heap

Un tas (heap) est une structure de données arborescente spécialisée qui satisfait la propriété de tas : dans un min-heap, pour tout nœud donné, la valeur de ce nœud est inférieure ou égale aux valeurs de ses enfants. Cela rend les tas idéaux pour implémenter des files de priorité, où vous souhaitez récupérer et supprimer efficacement l'élément de priorité la plus haute (ou la plus basse).

Implémenter une file de priorité

Vous définissez votre propre type qui implémente heap.Interface :

import (
    "container/heap"
    "fmt"
)

// Un Item contient une valeur et une priorité
type Item struct {
    Value    string
    Priority int
}

// Une PriorityQueue implémente heap.Interface
type PriorityQueue []*Item // slice de pointeurs vers des Items

func (pq PriorityQueue) Len() int            { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool  { return pq[i].Priority < pq[j].Priority }
func (pq PriorityQueue) Swap(i, j int)       { pq[i], pq[j] = pq[j], pq[i] }
func (pq *PriorityQueue) Push(x any)         { *pq = append(*pq, x.(*Item)) }
func (pq *PriorityQueue) Pop() any {
    old := *pq
    n := len(old)
    item := old[n-1]
    *pq = old[0 : n-1]
    return item
}

Ici, nous définissons une struct Item avec une valeur et une priorité. Le type PriorityQueue est une slice de pointeurs vers Item. Nous implémentons les méthodes requises pour heap.Interface : Len, Less, Swap, Push et Pop.

Utilisation de la file de priorité

func main() {
    pq := &PriorityQueue{}
    heap.Init(pq)

    heap.Push(pq, &Item{"rédiger le rapport", 3})
    heap.Push(pq, &Item{"corriger le bug", 1})
    heap.Push(pq, &Item{"répondre aux emails", 2})

    for pq.Len() > 0 {
        item := heap.Pop(pq).(*Item)
        fmt.Println(item.Priority, item.Value)
    }
}

Sortie :

1 corriger le bug
2 répondre aux emails
3 rédiger le rapport

Dans cet exemple, nous créons une nouvelle PriorityQueue, l'initialisons avec heap.Init et poussons plusieurs éléments avec différentes priorités. Lorsque nous extrayons (pop) les éléments du tas, ils sortent par ordre de priorité (le chiffre le plus bas en premier).

Par défaut, il s'agit d'un min-heap (priorité la plus petite en premier). Vous pouvez inverser la logique de Less pour changer cet ordre.

Buffers circulaires avec container/ring

Un ring est une liste circulaire où la fin se connecte au début. C'est utile pour les buffers de taille fixe, la planification par tour de rôle (round-robin) ou lorsque vous souhaitez parcourir des éléments de manière cyclique.

Utilisation de base

import (
    "container/ring"
    "fmt"
)

func main() {
    r := ring.New(3)
    for i := 0; i < r.Len(); i++ {
        r.Value = i + 1
        r = r.Next()
    }

    // Itérer sur le ring
    r.Do(func(x any) {
        fmt.Println(x)
    })
}

Sortie :

1
2
3

Dans cet exemple, nous créons un ring de taille 3 et le remplissons avec les valeurs 1, 2 et 3. Nous utilisons ensuite la méthode Do pour itérer sur l'anneau et afficher chaque valeur.

Vous pouvez également avancer ou reculer avec r.Move(n) pour naviguer dans l'anneau.

Exemple de cas d'utilisation : Buffer de log à taille fixe

import (
    "container/ring"
    "fmt"
)

// LogBuffer est un buffer circulaire pour les messages de log
type LogBuffer struct {
    ring *ring.Ring
    size int
}

// NewLogBuffer crée un nouveau buffer de log avec la taille donnée
func NewLogBuffer(size int) *LogBuffer {
    return &LogBuffer{
        ring: ring.New(size),
        size: size,
    }
}

// Add ajoute un message de log au buffer
func (lb *LogBuffer) Add(msg string) {
    lb.ring.Value = msg
    lb.ring = lb.ring.Next()
}

// All renvoie tous les messages de log dans l'ordre (du plus ancien au plus récent)
func (lb *LogBuffer) All() []string {
    logs := make([]string, 0, lb.size)
    lb.ring.Do(func(x any) {
        if x != nil {
            logs = append(logs, x.(string))
        }
    })
    return logs
}

func main() {
    lb := NewLogBuffer(3)
    lb.Add("premier")
    lb.Add("second")
    lb.Add("troisième")
    lb.Add("quatrième") // écrase "premier"

    fmt.Println(lb.All())
}

Sortie :

[second troisième quatrième]

Compromis par rapport aux slices/maps

  • Utilisation de la mémoire : Les types du package container peuvent consommer plus de mémoire que les slices/maps en raison de leurs structures internes (par exemple, les pointeurs pour les listes chaînées).

  • Performance : Les schémas d'accès comptent. Par exemple, les slices sont excellentes pour l'accès séquentiel, tandis que les maps excellent pour les recherches. Choisissez en fonction de votre cas d'utilisation.

  • Complexité : L'utilisation de ces types peut ajouter de la complexité. Évaluez les avantages par rapport à la charge cognitive supplémentaire.

Points clés à retenir

  • Une liste (list) vous offre une liste doublement chaînée avec des insertions et suppressions efficaces au milieu.

  • Un tas (heap) fournit une abstraction de file de priorité — puissant pour la planification et la récupération ordonnée.

  • Un anneau (ring) implémente une liste circulaire, parfaite pour les scénarios de type round-robin.

Ces structures ne sont pas des outils de tous les jours, mais elles comblent des niches importantes lorsque les slices et les maps ne suffisent pas.

Utilitaires spécialisés

Au-delà des slices, des maps et des types de conteneurs classiques, Go fournit des utilitaires spécialisés qui rendent le travail avec les collections plus propre, plus sûr et plus expressif. Les chaînes de caractères et les slices d'octets sont deux collections spéciales qui possèdent leur propre ensemble de fonctions utilitaires. De plus, le package reflect offre des outils puissants pour inspecter et manipuler des types arbitraires au runtime.

Chaînes et octets comme collections

Les chaînes de caractères (strings) et les slices d'octets (byte slices) sont des types de collections particuliers en Go. La bibliothèque standard fournit de nombreuses fonctions pour rechercher, diviser, joindre et transformer ces séquences.

Diviser, Joindre et Rechercher des chaînes

import (
    "fmt"
    "strings"
)

func main() {
    csv := "Alice,Bob,Charlie,Diana"

    names := strings.Split(csv, ",")
    fmt.Println(names) // [Alice Bob Charlie Diana]

    joined := strings.Join(names, " & ")
    fmt.Println(joined) // Alice & Bob & Charlie & Diana

    contains := strings.Contains(csv, "Bob")
    fmt.Println("Contient Bob ?", contains) // true
}

Sortie :

[Alice Bob Charlie Diana]
Alice & Bob & Charlie & Diana
Contains Bob? true

Ici, nous utilisons strings.Split pour diviser une chaîne CSV en une slice de noms, strings.Join pour les concaténer avec " & ", et strings.Contains pour vérifier si "Bob" est présent dans la chaîne d'origine.

Transformer des chaînes

strings fournit également des fonctions pour transformer les chaînes, comme changer la casse, supprimer les espaces ou remplacer des sous-chaînes :

upper := strings.ToUpper("hello world")
fmt.Println(upper)
trimmed := strings.TrimSpace("   padded string   ")
fmt.Println(trimmed)
replaced := strings.ReplaceAll("Alice, Bob, Charlie, Diana", "Bob", "Brian")
fmt.Println(replaced)

Sortie :

HELLO WORLD
"padded string"
[Alice Brian Charlie Diana]

Note importante : les fonctions du package strings renvoient de nouvelles chaînes, car les chaînes en Go sont immuables.

Pour la liste complète des fonctions de chaîne, consultez la documentation du package strings.

Travailler avec des slices d'octets

Le package bytes fournit des fonctionnalités similaires pour les slices d'octets ([]byte), qui sont souvent utilisées pour les données binaires ou lorsque la performance est critique.

data := []byte("hello world")

upper := bytes.ToUpper(data)
fmt.Println(string(upper))

index := bytes.Index(data, []byte("world"))
fmt.Println("Index de 'world' :", index)

Sortie :

HELLO WORLD
Index of 'world': 6

Les chaînes et les octets sont interchangeables via les conversions []byte(str) et string(bytes), ce qui facilite l'application d'opérations de type slice au texte.

Pour la liste complète des fonctions de slice d'octets, consultez la documentation du package bytes.

Utilitaires basés sur la réflexion

Le package reflect fournit des outils puissants pour inspecter et manipuler des types arbitraires au moment de l'exécution (runtime). Bien que la réflexion (Reflection) soit un sujet avancé et doive être utilisée avec parcimonie en raison des coûts de performance et de la complexité, elle peut être inestimable pour les tâches de programmation générique.

import (
    "fmt"
    "reflect"
)

func main() {
    slice := []int{1, 2, 3}
    v := reflect.ValueOf(slice)

    fmt.Println("Longueur :", v.Len())
    fmt.Println("Premier élément :", v.Index(0))
}

Sortie :

Length: 3
First element: 1

Ici, nous utilisons reflect.ValueOf pour obtenir un objet de réflexion représentant la slice. Nous pouvons ensuite appeler des méthodes comme Len et Index pour inspecter ses propriétés.

Utilisez la réflexion avec modération : elle est plus lente et moins sûre au niveau des types que les opérations directes sur les slices, mais elle est parfois nécessaire pour des fonctions véritablement génériques.

La réflexion est un sujet vaste. Pour plus de détails, consultez la documentation du package reflect.

Exemple : un afficheur (pretty-printer) générique pour n'importe quelle collection

import (
    "fmt"
    "reflect"
)
func PrettyPrint(col any) {
    v := reflect.ValueOf(col)
    switch v.Kind() {
    case reflect.Slice, reflect.Array:
        fmt.Println("Slice/Array :")
        for i := 0; i < v.Len(); i++ {
            fmt.Printf("  [%d]: %v\n", i, v.Index(i))
        }
    case reflect.Map:
        fmt.Println("Map :")
        for _, key := range v.MapKeys() {
            fmt.Printf("  %v: %v\n", key, v.MapIndex(key))
        }
    default:
        fmt.Println("Type non supporté :", v.Kind())
    }
}
func main() {
    PrettyPrint([]int{1, 2, 3})
    PrettyPrint(map[string]int{"Alice": 42, "Bob": 99})
}

Sortie :

Slice/Array:
  [0]: 1
  [1]: 2
  [2]: 3
Map:
  Alice: 42
  Bob: 99

Dans cet exemple, PrettyPrint utilise la réflexion pour gérer génériquement à la fois les slices/tableaux et les maps. Il inspecte la nature de l'entrée et affiche son contenu en conséquence. C'est une démonstration simple de la manière dont la réflexion peut activer des opérations génériques sur les collections.

Points clés à retenir

  • Les chaînes et les slices d'octets sont des collections spécialisées ; la bibliothèque standard fournit des outils riches pour les manipuler.

  • La réflexion permet une inspection dynamique des slices et des maps, utile pour le code générique.

Étude de cas : un planificateur de tâches simple

Pour mettre en pratique ce que nous avons appris, implémentons un mini-planificateur de tâches — le genre de système que vous pourriez voir dans un pipeline d'intégration continue (CI) ou un exécuteur de tâches.

Ce gestionnaire de tâches devra :

  • Stocker des tâches avec un titre, une date d'échéance et une priorité.

  • Permettre l'ajout et la suppression de tâches.

  • Supporter le listage des tâches triées par date d'échéance ou par priorité.

  • Fournir une opération "tâche suivante" en utilisant une file de priorité.

Définition du type de tâche

Tout d'abord, nous aurons besoin d'une struct Job :

import (
    "time"
)

type Job struct {
    ID       int
    Name     string
    ETA      time.Duration // temps d'achèvement estimé
    Priority int           // nombre plus bas = priorité plus élevée
}

Stockage des tâches dans une map

Nous utiliserons une map pour stocker les tâches par leur ID afin de permettre des recherches et des suppressions rapides :

var jobs = make(map[int]*Job)
var nextID int // ID à incrémentation automatique

func AddJob(name string, eta time.Duration, priority int) int {
    id := nextID
    nextID++
    job := &Job{ID: id, Name: name, ETA: eta, Priority: priority}
    jobs[id] = job
    return id
}

Nous pourrions stocker les jobs dans une slice, mais l'utilisation d'une map présente plusieurs avantages :

  • IDs stables : chaque tâche a un ID unique qui ne change pas, même si d'autres tâches sont supprimées ou réordonnées.

  • Recherche rapide : récupérer, mettre à jour ou supprimer une tâche par ID est en O(1).

  • Extensibilité : les maps s'alignent naturellement avec les IDs de base de données ou le stockage externe si les tâches sont persistées.

  • Collections éparses : les suppressions fréquentes ne nécessitent pas de décaler les éléments comme avec les slices.

Notez également que nous stockons des pointeurs vers Job dans la map pour éviter de copier la struct à chaque accès.

Rapport instantané avec des slices

Pour générer un rapport ordonné par ETA, nous collectons toutes les tâches dans une slice et nous les trions :

import "slices"

func JobsByETA() []*Job {
    all := make([]*Job, 0, len(jobs))
    for _, j := range jobs {
        all = append(all, j)
    }

    slices.SortFunc(all, func(a, b *Job) int {
        return int(a.ETA - b.ETA)
    })
    return all
}

Ici, nous créons une slice de pointeurs de jobs, la remplissons à partir de la map et la trions par ETA à l'aide de slices.SortFunc. Cela nous donne une vue instantanée des tâches ordonnées par leur temps d'achèvement estimé.

File de priorité pour la planification

Implémentons maintenant une file de priorité pour obtenir efficacement la tâche suivante en fonction de la priorité. Nous pourrions trier la liste entière à chaque fois (comme nous l'avons fait pour les échéances), mais ce serait inefficace. À la place, nous utiliserons un min-heap.

type JobQueue []*Job

func (jq JobQueue) Len() int           { return len(jq) }
func (jq JobQueue) Less(i, j int) bool { return jq[i].Priority < jq[j].Priority }
func (jq JobQueue) Swap(i, j int)      { jq[i], jq[j] = jq[j], jq[i] }
func (jq *JobQueue) Push(x any)        { *jq = append(*jq, x.(*Job)) }
func (jq *JobQueue) Pop() any {
    old := *jq
    n := len(old)
    item := old[n-1]
    *jq = old[:n-1]
    return item
}

func NextJob() *Job {
    if jobHeap.Len() == 0 {
        return nil
    }
    return heap.Pop(jobHeap).(*Job)
}

Ici, JobQueue implémente heap.Interface, ce qui nous permet de maintenir une file de priorité des tâches. La fonction NextJob extrait la tâche de priorité la plus élevée du tas.

Nous devons maintenant initialiser et maintenir le tas, et le mettre à jour lorsque des tâches sont ajoutées :

var jobHeap = &JobQueue{}

func AddJob(name string, eta time.Duration, priority int) int {
    id := nextID
    nextID++
    job := &Job{ID: id, Name: name, ETA: eta, Priority: priority}
    jobs[id] = job
    heap.Push(jobHeap, job)
    return id
}

Assemblage final

Voici une fonction main simple pour démontrer le gestionnaire de tâches :

func main() {
    AddJob("Compiler les assets", 5*time.Second, 2)
    AddJob("Lancer les tests", 10*time.Second, 1)
    AddJob("Déployer", 30*time.Second, 3)

    fmt.Println("Tâches par ETA (vue instantanée) :")
    for _, j := range JobsByETA() {
        fmt.Println("-", j.Name, "(ETA", j.ETA, ")")
    }

    fmt.Println("\nExécution des tâches par priorité :")
    for i := 0; i < 2; i++ {
        j := NextJob()
        fmt.Println("-", j.Name, "(priorité", j.Priority, ")")
    }

    fmt.Println("\nAjout d'un hotfix urgent...")
    AddJob("Hotfix", 2*time.Second, 0)

    fmt.Println("\nSuite de l'exécution :")
    for jobHeap.Len() > 0 {
        j := NextJob()
        fmt.Println("-", j.Name, "(priorité", j.Priority, ")")
    }
}

Sortie :

Jobs by ETA (snapshot view):
- Compile assets (ETA 5s)
- Run tests (ETA 10s)
- Deploy (ETA 30s)

Executing jobs by priority:
- Run tests (priority 1)
- Compile assets (priority 2)

Adding urgent hotfix job...

Continuing execution:
- Hotfix (priority 0)
- Deploy (priority 3)

Pour ce planificateur de tâches simple, nous avons combiné des maps pour des recherches rapides, des slices pour les rapports instantanés et une file de priorité pour une planification efficace. Cette conception est flexible, performante et facile à étendre. Notez que ce n'est pas un code prêt pour la production — la gestion des erreurs, la concurrence et d'autres aspects devraient être abordés dans un système réel.

Pour référence, voici le code complet :

package main

import (
        "container/heap"
        "fmt"
        "slices"
    "time"
)

type Job struct {
    ID       int
    Name     string
    ETA      time.Duration // temps d'achèvement estimé
    Priority int           // nombre plus bas = priorité plus élevée
}

var jobs = make(map[int]*Job)
var nextID int // ID à incrémentation automatique

var jobHeap = &JobQueue{}

func AddJob(name string, eta time.Duration, priority int) int {
    id := nextID
    nextID++
    job := &Job{ID: id, Name: name, ETA: eta, Priority: priority}
    jobs[id] = job
    heap.Push(jobHeap, job)
    return id
}

func JobsByETA() []*Job {
    all := make([]*Job, 0, len(jobs))
    for _, j := range jobs {
        all = append(all, j)
    }

    slices.SortFunc(all, func(a, b *Job) int {
        return int(a.ETA - b.ETA)
    })
    return all
}

type JobQueue []*Job

func (jq JobQueue) Len() int           { return len(jq) }
func (jq JobQueue) Less(i, j int) bool { return jq[i].Priority < jq[j].Priority }
func (jq JobQueue) Swap(i, j int)      { jq[i], jq[j] = jq[j], jq[i] }
func (jq *JobQueue) Push(x any)        { *jq = append(*jq, x.(*Job)) }
func (jq *JobQueue) Pop() any {
    old := *jq
    n := len(old)
    item := old[n-1]
    *jq = old[:n-1]
    return item
}

func NextJob() *Job {
    if jobHeap.Len() == 0 {
        return nil
    }
    return heap.Pop(jobHeap).(*Job)
}

func main() {
    AddJob("Compiler les assets", 5*time.Second, 2)
    AddJob("Lancer les tests", 10*time.Second, 1)
    AddJob("Déployer", 30*time.Second, 3)

    fmt.Println("Tâches par ETA (vue instantanée) :")
    for _, j := range JobsByETA() {
        fmt.Println("-", j.Name, "(ETA", j.ETA, ")")
    }

    fmt.Println("\nExécution des tâches par priorité :")
    for i := 0; i < 2; i++ {
        j := NextJob()
        fmt.Println("-", j.Name, "(priorité", j.Priority, ")")
    }

    fmt.Println("\nAjout d'un hotfix urgent...")
    AddJob("Hotfix", 2*time.Second, 0)

    fmt.Println("\nSuite de l'exécution :")
    for jobHeap.Len() > 0 {
        j := NextJob()
        fmt.Println("-", j.Name, "(priorité", j.Priority, ")")
    }
}

Défi pratique

Implémentez une fonction pour supprimer une tâche par son ID. Assurez-vous qu'elle mette à jour correctement à la fois la map et la file de priorité.

Conclusion

Go garde les choses simples — le langage lui-même ne vous donne que trois types de collections de base : les tableaux, les slices et les maps. Mais simplicité ne signifie pas manque de puissance. Comme nous l'avons vu tout au long de cet article, la bibliothèque standard ajoute un ensemble riche d'utilitaires qui vous permettent de faire l'essentiel de ce dont vous aurez besoin au quotidien :

  • Trier et rechercher avec sort et slices.

  • Manipuler commodément avec slices et maps.

  • Utiliser des structures de données spécialisées comme list, heap et ring lorsque les slices et les maps ne suffisent pas.

  • Employer des utilitaires pour les chaînes, les octets et la réflexion pour compléter la boîte à outils.

Ces outils sont conçus pour être composables. Vous pouvez trier avec slices.Sort, puis filtrer avec une boucle, stocker les résultats dans une map et récupérer les clés avec maps.Keys. Ou vous pouvez construire des abstractions de plus haut niveau comme notre planificateur de tâches en combinant des heaps, des maps et des slices en quelques dizaines de lignes de code.

C'est la véritable valeur de l'approche de Go : vous avez rarement besoin de recourir à des bibliothèques tierces pour gérer les collections. Tout ici est stable, éprouvé et cohérent à travers tout l'écosystème.

Notez que nous n'avons fait qu'effleurer la surface. Chacun de ces packages possède bien d'autres fonctions et options à explorer. La meilleure façon d'apprendre est de pratiquer.

L'étape suivante est la pratique. Lancez-vous dans un petit projet personnel, peut-être un tableau de classement, un buffer de log ou une file d'attente de tâches, et voyez jusqu'où vous pouvez aller avec les seuls utilitaires de la bibliothèque standard. Une fois que vous aurez travaillé sur quelques exemples concrets, vous commencerez à penser automatiquement selon ces motifs, en écrivant un code Go propre et idiomatique sans même chercher de dépendances externes.

Solution du défi pratique

func RemoveJob(id int) {
    if job, exists := jobs[id]; exists {
        delete(jobs, id)
        // Supprimer de la file de priorité
        for i := 0; i < jobHeap.Len(); i++ {
            if (*jobHeap)[i].ID == id {
                heap.Remove(jobHeap, i)
                break
            }
        }
    }
}

Comment cela fonctionne :

  • Nous vérifions d'abord si la tâche avec l'ID donné existe dans la map jobs.

  • Si c'est le cas, nous la supprimons de la map.

  • Ensuite, nous itérons sur le jobHeap pour trouver la tâche correspondant à l'ID.

  • Une fois trouvée, nous utilisons heap.Remove pour la retirer de la file de priorité, ce qui maintient la propriété de tas (heap property).