Dans le monde craft, il est fréquent d’entendre que le typage fort permet de réduire le nombre de tests. Petit expérience avec le kata tennis en rust.

Je gros avantage de ce kata c’est qu’il est relativement simple, riche en vocabulaire métier et suffisamment complexe pour nécessiter une documentation.

Au tennis, les points sont comptés de la façon suivante, en français, ça part de « 0 - 0 » pour aller jusqu’à « Jeu XX » en passant par « Égalité » ou « Avantage XX ». Un système complexe qui trouverait ses origines dans les pénalités du jeu de paume, obligeant le joueur à reculer à quinze, trente et quarante pas, au fur et à mesure de l’avancement du jeu.

De 0 à 40

En début de jeu, on ne compte que les points du joueur. En rust il est possible de décrire ça comme ça :

#[derive(Clone, PartialEq, Eq)]
pub enum PlayerScore {
    Love,
    Fifteen,
    Thirty,
    Forty,
}

avec l’affichage en français suivant :

impl PlayerScore {
    fn display_fr(&self) -> &str {
        match self {
            Love => "0",
            Fifteen => "15",
            Thirty => "30",
            Forty => "40",
        }
    }
}

Le score du jeu peu alors se décrire de la façon suivante :

pub enum GameScore {
    Versus { player_a: PlayerScore, player_b: PlayerScore },
}

avec l’affichage suivant :

impl GameScore {
    fn display_fr(&self) -> String {
        match self {
            Versus { player_a: Love, player_b: Love } => "0 - 0".parse().unwrap(),
            Versus { ref player_a, ref player_b } if player_a == player_b => 
                format!("{} a", player_a.display_fr()),
            Versus { ref player_a, ref player_b } => 
                format!("{} - {}", player_a.display_fr(), player_b.display_fr()),
        }
    }
}

Il nous manque alors la fonction principale, je vous propose la fonction récursive suivante :

fn next_score(game_score: GameScore, winers: &[Player]) -> (GameScore, &[Player]) {
    if winers.len() > 0 {
        return next_score(game_score.next(winers[0]), &winers[1..]);
    }
    return (game_score, &[]);
}

pub fn score(game_score: GameScore, winners: &[Player]) -> String {
    let (local_score, _) = next_score(game_score, winners);
    return local_score.display_fr();
}

Pour que cette fonction principale fonctionne, il faut implémenter les méthodes next sur chaque type de score :

impl PlayerScore {
    fn next(&self) -> PlayerScore {
        match self {
            Love => Fifteen,
            Fifteen => Thirty,
            Thirty => Forty,
            _ => panic!()
        }
    }
}

impl GameScore {
    fn next(&self, player: Player) -> GameScore {
        match (player, self) {
            (B, Versus { player_a: Forty, ref player_b }) => 
                Versus { player_a: Forty, player_b: player_b.next() },
            (A, Versus { ref player_a, player_b: Forty }) => 
                Versus { player_a: player_a.next(), player_b: Forty },
            (B, Versus { ref player_a, ref player_b }) => 
                Versus { player_a: player_a.clone(), player_b: player_b.next() },
            (A, Versus { ref player_a, ref player_b }) => 
                Versus { player_a: player_a.next(), player_b: player_b.clone() },
        }
    }
}

Notez au passage l’expressivité et la concision du langage. Enfin au détail près qu’il n’est pas possible d’initialiser un enum avec une chaine de caractères et que l’on est obligé de faire des parse().unwrap() sur les str pour obtenir des Strings.

Les cas Avantage, Égalité et Jeu

Maintenant que l’on a les points pour les Versus regardons les points d’après.

On va enrichir l’enum GameScore en ajoutant les 3 nouvelles possibilités :

pub enum GameScore {
    Versus { player_a: PlayerScore, player_b: PlayerScore },
    Advantage { player: Player },
    Deuce,
    Game { player: Player },
}

ce qui se traduit comme ceci dans les méthodes display_fr et next :

impl GameScore {
    fn display_fr(&self) -> String {
        match self {
            Versus { player_a: Love, player_b: Love } => "0 - 0".parse().unwrap(),
            Versus { ref player_a, ref player_b } if player_a == player_b =>
                format!("{} a", player_a.display_fr()),
            Versus { ref player_a, ref player_b } =>
                format!("{} - {}", player_a.display_fr(), player_b.display_fr()),
            Advantage { ref player } => format!("Avantage {}", player.dispplay()),
            Deuce => "Égalité".parse().unwrap(),
            Game { ref player } => format!("Jeu {} !", player.dispplay()),
        }
    }
    fn next(&self, player: Player) -> GameScore {
        match (player, self) {
            (A, Advantage { player: A }) => Game { player: A },
            (B, Advantage { player: A }) => Deuce,
            (A, Advantage { player: B }) => Deuce,
            (B, Advantage { player: B }) => Game { player: B },
            (ref p, Deuce) => Advantage { player: *p },
            (ref p, Versus { player_a: Forty, player_b: Forty }) =>
                Advantage { player: *p },
            (A, Versus { player_a: Forty, player_b: _ }) => Game { player: A },
            (B, Versus { player_a: Forty, ref player_b }) =>
                Versus { player_a: Forty, player_b: player_b.next() },
            (A, Versus { ref player_a, player_b: Forty }) =>
                Versus { player_a: player_a.next(), player_b: Forty },
            (B, Versus { player_a: _, player_b: Forty }) => Game { player: B },
            (A, Versus { ref player_a, ref player_b }) =>
                Versus { player_a: player_a.next(), player_b: player_b.clone() },
            (B, Versus { ref player_a, ref player_b }) =>
                Versus { player_a: player_a.clone(), player_b: player_b.next() },
            (_, Game { ref player }) => Game { player: player.clone() },
        }
    }
}

On vient donc de créer en quelques dizaines de minutes et à peine une centaine de lignes, avec 2 espaces dénombrables et une opération interne sur ces 2 espaces un système de comptage des points au tennis. Le tout étant auto documenté. Les 2 cas un peut particulier à gérer sont :

  • le cas de fin de partie : que faire si un joueur marque un point alors qu’il y a déjà un gagnant.
  • le cas de la sortie de l’espace PlayerScore, cas qui fonctionnellement ne correspond à rien, j’ai donc décidé de lui mettre un panic!()

De mon côté, impossible de créer ce code autrement qu’en TDD, mais j’arrive à comprendre les personnes qui disent qu’un code fortement typé demande beaucoup moins de tests. En effet dans le match (Player, GameScore) qui calcul le score suivant, il m’a été difficile de ne faire que des petits pas, en effet le compilateur cherchait systématiquement l’exhaustivité des cas.

Vous pouvez retrouver l’intégralité de ce code accompagné de tous les tests sur le dépôt git suivant : https://gitlab.com/tclavier/kata-tennis-rust/