Dans la partie 2, on a rangé la cave par couleur et démonté le piège de la map nil.
On s’était quitté sur une promesse : SortByPrice, trier une Cellar sans jamais perturber l’originale.
C’est le troisième test du Winery exercice. Anodin en apparence, il cache deux pièges Go que tout le monde a déjà croisés sans forcément prendre le temps de les nommer. 🍷

💡 Tout le code est sur GitHub : pcavezzan/2026-july-technical-interview-sample, branche result/winery.

🎯 Ce que le test attend

SortByPrice prend un booléen desc et renvoie une nouvelle Cellar triée par prix, croissant ou décroissant :

func TestSortPrice(t *testing.T) {
    cellar := openWineCatalog(t)
    assert.Equal(t, 6, cellar.Length())

    sortAsc := cellar.SortByPrice(false)
    assert.Equal(t, "5,3,1,2,0,4", sortAsc.dump())
    assert.Equal(t, "0,1,2,3,4,5", cellar.dump())

    sortDesc := cellar.SortByPrice(true)
    assert.Equal(t, "4,0,1,2,3,5", sortDesc.dump())
    assert.Equal(t, "0,1,2,3,4,5", cellar.dump())
}

Reprenons les prix du catalogue, c’est la clé pour lire les assertions :

IdNomPrix
0Chateau Petrus4900
1Grand Cru Montrachet2350
2Chateau Margaux2350
3Chateau Cheval Blanc1350
4La tâche grand cru6350
5Fourchaume - Chablis 1er Cru40

Le test cache deux exigences, et pas seulement le tri :

  • Ne pas toucher à l’original. Après chaque appel, cellar.dump() doit toujours valoir "0,1,2,3,4,5", l’ordre d’insertion. SortByPrice doit rendre une copie triée, pas trier la cave en place. C’est l’exigence centrale de cette manche. 🎯
  • Les ex æquo gardent leur ordre. Les vins 1 et 2 valent tous les deux 2350. En croissant comme en décroissant, le test attend ...1,2... : l’ordre d’insertion préservé entre égaux. C’est une exigence de stabilité, et on va voir qu’elle est plus fragile qu’elle n’en a l’air. 🪤

🧩 Le point de départ (code fourni)

func (c Cellar) SortByPrice(desc bool) Cellar {
    var res Cellar

    // TODO: Candidate Codes

    return res
}

Rien de piégeux dans le squelette cette fois, juste une Cellar vide à remplir. Le piège, il est dans le premier réflexe qu’on a tous. 👇

🛠️ Le réflexe naturel… et l’original saccagé

La fonction de tri de la lib standard, on la connaît. On range les vins, on renvoie le résultat :

func (c Cellar) SortByPrice(desc bool) Cellar {
    sort.Slice(c, func(i, j int) bool {
        if desc {
            return c[i].Price > c[j].Price
        }
        return c[i].Price < c[j].Price
    })
    return c
}

Ça compile. Le premier dump() du tri croissant est même correct : "5,3,1,2,0,4". Et pourtant le test casse à la ligne suivante :

Error:  Not equal:
        expected: "0,1,2,3,4,5"
        actual  : "5,3,1,2,0,4"
Test:   TestSortPrice
        assert.Equal(t, "0,1,2,3,4,5", cellar.dump())

On a trié c… mais c, c’est la cave de l’appelant. On vient de la réordonner sous ses pieds. 🙈

Pourquoi trier c mute l'original

Une Cellar est un []Wine, et un slice n'est qu'un descripteur (pointeur vers un tableau, longueur, capacité). Passer c à la méthode copie ce descripteur, pas le tableau sous-jacent. La copie et l'original pointent vers le même stockage.

sort.Slice réordonne les éléments en place, directement dans ce tableau partagé. Résultat : le slice de l'appelant voit ses éléments bouger, alors qu'on n'a jamais touché à sa variable. C'est le pendant « données » du piège de la map nil de la partie 2 : là c'était l'écriture qui paniquait, ici c'est le partage silencieux du backing array qui trahit.

🛠️ Le fix : cloner avant de trier

Il suffit de trier une copie et de laisser l’original tranquille :

func (c Cellar) SortByPrice(desc bool) Cellar {
    res := slices.Clone(c)
    if desc {
        sort.Slice(res, func(i, j int) bool {
            return res[i].Price > res[j].Price
        })
    } else {
        sort.Slice(res, func(i, j int) bool {
            return res[i].Price < res[j].Price
        })
    }
    return res
}

slices.Clone(c) alloue un nouveau tableau et y recopie les éléments. On trie res, c reste dans son ordre d’origine, et les deux dump() de la cave tombent juste. 🧼

🔎 slices.Clone est une copie superficielle

slices.Clone ne copie qu'un niveau : le nouveau tableau contient les mêmes Wine, champ pour champ. Ici c'est exactement ce qu'on veut, parce que Wine n'a que des champs valeur (int64, string, float64) : rien à partager, rien à aliaser.

Si Wine contenait un champ référence (un slice, une map, un pointeur), le clone partagerait ces sous-objets avec l'original. Le tri ne s'en soucie pas, il ne fait que déplacer des éléments, mais c'est un réflexe à garder : « clone » ne veut pas dire « deep copy ».

TestWineCreation ✓ TestClassifyByColor ✓ TestSortPrice ✓ TestSearchWines

🥊 L’aparté du jour : ce tri passe-t-il par chance ?

Le test attend que les vins 1 et 2 (ex æquo à 2350) restent dans l’ordre 1,2. On a utilisé sort.Slice. Or sort.Slice n’est pas stable : sa doc le dit noir sur blanc, « The sort is not guaranteed to be stable ». Deux éléments égaux peuvent se retrouver échangés.

Alors pourquoi le test passe-t-il ? 🤔

Le tri passe par un heureux hasard d’implémentation

Depuis Go 1.19, sort.Slice s’appuie sur pdqsort (pattern-defeating quicksort). Pour les petites tranches (moins de 12 éléments), pdqsort bascule sur un tri par insertion, qui, lui, se trouve être stable. Notre cave fait 6 vins : on tombe pile dans ce cas, et l’ordre 1,2 est préservé.

Autrement dit : le test ne passe pas parce que le contrat le garantit, il passe parce que 6 < 12. Ajoute assez de vins ex æquo pour dépasser le seuil, et la garantie s’évapore : rien n’empêcherait alors sort.Slice de rendre 2,1. Le test resterait vert sur 6 vins et pourrait virer au rouge sur 6 000. Le genre de bug qui dort tranquillement jusqu’au jour où le volume grandit. 😴

📐 Stable vs instable : ce que ça change vraiment

Un tri stable garantit que deux éléments comparés « égaux » conservent leur ordre relatif d'entrée. Un tri instable ne promet rien à ce sujet, il est juste libre d'être plus rapide.

Tant que la clé de tri capture tout ce qui compte (un identifiant unique, par exemple), la stabilité est indifférente. Mais dès qu'on trie sur une clé partielle — ici le prix, alors que des vins distincts partagent le même prix — la stabilité devient le seul garde-fou d'un ordre reproductible entre les ex æquo.

Le correctif honnête : dire ce qu’on veut

Si le contrat exige que les ex æquo gardent leur ordre, il faut le demander explicitement, pas l’espérer d’un détail d’implémentation. Deux formes, selon l’âge du code :

// explicite sur l'intention : sort STABLE
sort.SliceStable(res, func(i, j int) bool {
    return res[i].Price < res[j].Price
})
// Go moderne (1.21+) : slices.SortStableFunc + cmp.Compare
slices.SortStableFunc(res, func(a, b Wine) int {
    if desc {
        return cmp.Compare(b.Price, a.Price)
    }
    return cmp.Compare(a.Price, b.Price)
})

La version moderne a trois qualités qui se tiennent :

  • elle est stable, et le nom le dit.
  • elle troque le prédicat « moins-que » contre un cmp.Compare à trois états (-1, 0, +1), autrement plus dur à écrire de travers.
  • elle inverse l’ordre en échangeant a et b, là où sort.Slice m’obligeait à dupliquer tout le bloc. Un seul appel, fini le if desc { ... } else { ... }.

⚖️ Le vrai verdict

Sur cet exercice, sort.Slice passe et c'est défendable : 6 vins, on ne dépassera jamais le seuil d'insertion. Mais c'est un test qui repose sur un invariant non garanti. Le jour où la cave grossit, la seule version qui tient sa promesse, c'est le tri stable. Coût : zéro ligne de plus, juste le bon nom de fonction. Autant prendre l'habitude.

🧪 Deux détails à corriger côté test, tant qu’on y est

Comme dans les parties précédentes, le test de tri traîne les mêmes petits défauts.

assert là où require s’impose

cellar := openWineCatalog(t)
assert.Equal(t, 6, cellar.Length())

Rebelote : cellar.Length() == 6 est un prérequis. Si le catalogue n’a pas 6 vins, tous les dump() attendus ("5,3,1,2,0,4", "4,0,1,2,3,5") décrivent des indices qui n’ont plus de sens. Un require.Equal coupe net au lieu de dérouler une cascade d’échecs illisibles.

func TestSortPrice(t *testing.T) {
    cellar := openWineCatalog(t)
    require.Equal(t, 6, cellar.Length())

    // ... le reste du test peut rester en assert
}

L’assertion qui compte vraiment : la non-mutation

Le assert.Equal(t, "0,1,2,3,4,5", cellar.dump()) répété après chaque tri est le cœur du test, pas une formalité. C’est lui qui vérifie que l’original n’a pas bougé. En table testing, on aurait envie de l’ériger en invariant vérifié systématiquement pour chaque cas, plutôt qu’en ligne copiée-collée :

func TestSortPrice(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name string
        desc bool
        want string
    }{
        {name: "ascending", desc: false, want: "5,3,1,2,0,4"},
        {name: "descending", desc: true, want: "4,0,1,2,3,5"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            cellar := openWineCatalog(t)
            require.Equal(t, 6, cellar.Length())

            got := cellar.SortByPrice(tt.desc)

            assert.Equal(t, tt.want, got.dump())
            // l'invariant clé, vérifié pour chaque cas
            assert.Equal(t, "0,1,2,3,4,5", cellar.dump(), "l'originale ne doit jamais être mutée")
        })
    }
}

Chaque cas recharge sa propre cave

Avec t.Parallel(), ascending et descending tournent en même temps. S'ils partageaient la même cellar, un test qui la muterait par erreur corromprait l'autre, de façon non déterministe. Recharger le catalogue dans chaque sous-test isole les cas : c'est justement l'assertion de non-mutation qui rend ce parallélisme sûr… et ironiquement, c'est aussi elle qui l'exigerait si la fonction était buguée. 🔁

Comme dans la partie 1, retrouver cet exemple de migration en table testing sur github.

✅ Résultat

Une ligne de fix (slices.Clone avant de trier), et le troisième test passe. La leçon ne porte pas sur le tri lui-même, la lib standard fait ça très bien. Elle porte sur ce qu’on trie : jamais l’original, toujours une copie, parce qu’un slice partage son tableau sous-jacent.
Et en filigrane, ce rappel : « ça passe » ne vaut pas « c’est garanti ». Le tri stable dit tout haut ce que sort.Slice se contentait de tolérer.

Dans la partie 4, on clôt le Winery exercice avec Search : filtrer la cave sur le nom, la région et l’année, sans se laisser piéger par la casse ni par la recherche sur un entier. 🍇

À bientôt ! 🍷