De la programmation orientée objet à la programmation fonctionnelle

Guillaume Pierson
Guillaume PiersonJune 20, 2022
#functional-programming#integration

Passer de la programmation orientée objet à la programmation fonctionnelle, ce n'est pas naturel. Mais grâce aux 5 semaines d'intégration que j'ai passées chez Marmelab, je me sens prêt !

De Java à JavaScript

Lors de mon intégration chez Marmelab, j'ai été confronté à quelques difficultés. Plus particulièrement, j'ai du passer de Java à JavaScript.

Hey mais c'est pareil, JavaScript est juste la version web de Java.

Alors, pas du tout. Ce sont deux langages différents. Bon ça vous devez le savoir maintenant. 😉

Je connaissais déjà JavaScript avant, en l'utilisant avec Angular qui a un fonctionnement assez proche de ce qu'on voit dans le monde Java : avec des classes.

Mais son usage chez Marmelab est différent, et ce qui m'a posé le plus de difficultés, c'est ce qui touche à la programmation fonctionnelle.

Au commencement étaient les classes

Pour mon intégration chez Marmelab, j'ai du développer une adaptation numérique d'un jeu de plateau, Aqualin, avec Cindy (qui en a déjà parlé dans un autre article sur le déploiement AWS).

Aqualin

Pour nos premières Pull Requests, on est partis sur un développement qu'on connaissait bien, c'est-à-dire de la programmation orientée objet.

Dans le cas de notre projet, pour modéliser l'état du jeu Aqualin, ça a donné naturellement une classe GameState :

class Token {
    color: number;
    element: number;
}

class GameState {
    board: Token[][];
    river: Token[];
}

L'état du jeu contient un board, qui représente un plateau de 6x6 cases. Chaque case peut contenir une pièce (ou token). Six autres pièces sont tirées au sort et peuvent être jouées au prochain tour, c'est la "rivière" selon le vocabulaire du jeu.

Le reste des pièces constitue la réserve (ou stock dans notre modèle). Pour cette réserve, nous sommes également partis sur une modélisation objet. Il contient les tokens non joués, une méthode pour en tirer un au sort, et une autre pour savoir s'il est vide.

class StockManager {
    stocks: Token[];

    drawToken(): Token;

    isEmpty(): boolean;
}

Pour le moteur du jeu, là aussi nous sommes partis sur une classe. Il contient le game state, le stock, et une méthode pour recueillir les actions des joueurs qui modifie l'état :

class GameEngine {
    stockManager: StockManager;
    GameState: GameState;

    doAction(position: Position);
}

Notre code était rempli de classes. Ceux qui font du Java connaissent ça bien.

Puis vint la programmation fonctionnelle

Conseillés par nos coaches, nous avons modifié notre code en remplaçant les constructions orientées objet par de la programmation fonctionnelle.

Pour le GameState ce n'est pas très visible. On a juste remplacé les classes par des types typescript. Rien de bien différent.

type Token = {
    color: number;
    symbol: number;
};

type GameState = {
    board: Token[][];
    river: Token[];
};

Passons ensuite à la gestion du stock. Au lieu d'une classe avec des méthodes, on a développé une liste de fonctions, sans état partagé. Chaque fonction renvoie un nouvel objet, on ne modifie pas les paramètres.

export const isTokenInState = (token: Token, gameState: GameState) => {
    const cellContainsToken = (cell: Cell) =>
        cell != null &&
        cell.symbol === token.symbol &&
        cell.color === token.color;

    const tokenIsInBoard = gameState.board.some(row =>
        row.some(cellContainsToken),
    );
    if (tokenIsInBoard) {
        return true;
    }
    const tokenIsInRiver = gameState.river.some(cellContainsToken);
    if (tokenIsInRiver) {
        return true;
    }
    return false;
};

export const computeStock = (gameState: GameState): StockState => {
    const state = {
        stock: [],
    };

    for (let row = 0; row < gameState.board.length; row++) {
        if (!state.visualStock[row]) {
            state.stock[row] = [];
        }
        for (let column = 0; column < gameState.board.length; column++) {
            const token = { color: row, symbol: column };
            if (!isTokenInState(token, gameState)) {
                state.stock.push(token);
            }
        }
    }
    return state;
};

export const drawToken = (state: GameState): Token => {
    const stock = computeStock(state);
    if (stock.stock.length === 0) {
        return null;
    }
    const index = Math.floor(Math.random() * stock.stock.length);
    return stock.stock[index];
};

Note: Dans Aqualin, on connait à l'avance toutes les pièces (tokens) du jeu. Ca facilite la gestion du stock.

Il n'y a plus d'attributs privés, donc plus de risque de désynchronisation entre le stock et les tokens en jeu.

Des tests plus faciles

Certaines de ces fonctions sont pures comme isTokenInState. La fonction retournera toujours true si le token est sur le plateau ou la rivière. La fonction n'a pas d'effet de bord, et ne peut pas non plus être impactéé par d'autres états. Seuls les paramètres changent le retour de la fonction.

C'est un gros avantage pour les tests : un appel à la fonction fait dans un test sera forcément identique à celui fait dans l'application si les paramètres sont identiques. On ne risque pas d'avoir d'autres effets de bords.

DrawToken, lui, n'est pas une fonction pure, car il contient à une part d'aléatoire. Par contre, c'est très simple à tester : on lui passe un GameState, et on vérifie que le token sorti n'est pas présent dans le plateau ou la rivière.

Un code plus simple à maintenir

De manière générale, le code est simple à lire : pas de fioritures, pas de setter et de getter partout. La complexité cognitive s'en retrouve réduite.

En suivant ces principes, on passe moins de temps à réfléchir à créer un système complexe. On crée des fonctions simples qui répondent au besoin, rien de plus.

La simplicité du code est quelque chose que j'apprécie. Avoir un code simple facilite sa maintenance, et permet à d'autres personnes de facilement rentrer dans le code.

L'over-engineering pour moi, est un fléau. Rendre du code complexe avec de nombreuses couches à un tel point que personne ne puisse comprendre le code à part la personne qui l'a créée, n'est pas une solution viable au long terme.

D'ailleurs la simplicité peut faire gagner du temps, car plus c'est simple, moins il y a des risques de bug et moins de temps passer à débugguer.

L'immutabilité évite des bugs

Au tour du moteur du jeu :

playTurn(gameState: GameState, action: Position): GameState;

Dans notre jeu, les actions des joueurs passent toutes par cette fonction playTurn.

Comme vous pouvez le voir, la fonction playTurn retourne un gameState. Mais ce n'est pas celui qu'elle a reçu. c'est un nouveau gameState. Ce state est donc dit immutable, car on ne le modifie jamais.

Gros avantage : on peut facilement comparer deux états, et on n'a pas d'état persistant dans un objet, qui est souvent source de bugs (comme le démontre cet article de Fabio Labella). Enfin, lorsqu'il sera nécessaire de paralléliser le code, ce sera beaucoup plus facile sans état partagé. Nous en avons fait l'expérience lorsque nous avons du développer une AI pour Aqualin. cette IA doit explorer de nombreux états de jeu possibles, et c'est beaucoup plus simple de créer des states différents que de modifier toujours le même état.

Conclusion

Passer de la programmation orientée objet à la programmation fonctionnelle a été un exercice très enrichissant.

Le résultat est convaincant: le code que nous avons produit avec Cindy a grandi sans difficultés, a traversé de multiples refactorings sans perdre en maintenabilité. Il est facile à lire et à tester - et le fait de limiter la taille des fonctions amène un bénéfice inattendu : on a moins besoin de commenter le code, puisque les appels de fonctions ajoutent à la lisibilité.

J'ai quand même des doutes pour savoir si cette approche reste valable à très grande échelle. J'ai déjà travaillé sur de très gros projets en Java, et l'approche OOP fonctionnait. Est-ce aussi le cas pour l'approche fonctionnelle ?

Et puis certaines implémentations ne sont encore pas naturelles en fonctionnel pour moi. Je dois encore me tirer les cheveux pour écrire du code que j'aurais fait naturellement en OOP.

Mais, au dire de mes collègues, on s'y fait vite !

Enfin, je n'ai que gratté la surface de la programmation fonctionnelle. Si mon code contient des map et des reduce, je suis encore loin des Monoides et autres Monades. J'ai des articles à lire sur le blog de marmelab sur ce sujet!

Did you like this article? Share it!