Mathieu Agopian : Objets ou fonctions

Objets ou fonctions ? Voilà une question que je ne me pose pas assez souvent.

De part mon utilisation d'un langage objet (Python), et d'un framework web (Django) basé sur des objets (utilisation d'un ORM, de vues génériques sous forme de classes), je ne me pose quasiment jamais la question : j'utilise par défaut des objets.

Seulement, depuis mes explorations du côté de langages fonctionnels (Clojure, Erlang et Elixir), je me rends compte que la question mérite vraiment d'être posée.

Un exemple concret

Pour le reste de cet article, je vais me baser sur l'exemple concret du jeu de la vie de Conway qui était le sujet de la journée de code retreat à Marseille à laquelle j'ai assisté ce samedi 14 Décembre.

Voici deux implémentations, avec des objets puis avec des fonctions, pour étayer le discours. Je ne prétends pas fournir le code parfait, mais uniquement un support de discussion.

J'ai essayé de reprendre le même code (parcours de la grille, recherche des cellules voisines, calcul de la vie ou mort de la cellule) lorsque c'était possible pour avoir le plus de points de comparaison possibles.

Avec des objets

J'ai opté pour un découpage « raisonnable » (on a fait une session avec un CellContext qui s'occupait de gérer les cellules voisines, au lieu de grouper ça directement dans l'objet Cell, mais je trouvais ça un brin trop over-engineered).

La logique est d'initialiser une fois pour toutes les cellules voisines pour chaque cellule, ce qui simplifie le comptage des voisines en vie.

WORLD = [  # False = dead cell, True = live cell
    [False, False, False, False, False],
    [False, True, False, True, False],
    [False, True, True, False, False],
    [False, False, True, True, False],
    [False, False, False, False, False],
]


class Cell:

    def __init__(self, alive=False):
        self.neighbours = []
        self.alive = alive

    def add_neighbour(self, cell):
        self.neighbours.append(cell)

    def get_num_live_neighbours(self):
        return len([cell for cell in self.neighbours if cell.alive])

    def mutate(self):
        live_neighbours = self.get_num_live_neighbours()
        survives = self.alive and live_neighbours in [2, 3]
        born = not self.alive and live_neighbours == 3
        self.next_state = born or survives
        return self.next_state

    def step(self):
        self.alive = self.next_state

    def __str__(self):
        return 'O' if self.alive else ' '


class World:

    neighbours_rel_pos = [  # relative position of all possible neighbours
        (-1, -1), (-1, 0), (-1, 1),  # upper row
        (0, -1), (0, 1),  # same row
        (1, -1), (1, 0), (1, 1)]  # lower row

    def __init__(self, board):
        self.board = [[Cell(alive) for alive in line]
                      for line in board]

        # initialize neighbours for all cells
        for x, line in enumerate(self.board):
            for y, cell in enumerate(line):
                for neigh_pos_x, neigh_pos_y in self.neighbours_rel_pos:
                    pos_x = x + neigh_pos_x
                    pos_y = y + neigh_pos_y
                    if (pos_x >= 0 and pos_x < len(self.board) and
                            pos_y >= 0 and pos_y < len(self.board[0])):
                        cell.add_neighbour(self.board[pos_x][pos_y])

    def step(self):
        for line in self.board:  # compute next state
            for cell in line:
                cell.mutate()
        for line in self.board:  # apply
            for cell in line:
                cell.step()

    def __str__(self):
        return "\n".join("".join(str(cell) for cell in line)
                         for line in self.board)

67 lignes, 2 objets, 9 méthodes.

Avec des fonctions

WORLD = [  # False = dead cell, True = live cell
    [False, False, False, False, False],
    [False, True, False, True, False],
    [False, True, True, False, False],
    [False, False, True, True, False],
    [False, False, False, False, False],
]


def evolve_world(world):
    new_world = [line[:] for line in world]  # copy the current world
    for x, line in enumerate(new_world):
        for y, cell in enumerate(line):
            new_world[x][y] = evolve_cell(new_world[x][y],
                                          num_alive_neighbours(world, x, y))
    return new_world


def evolve_cell(cell_alive, num_neighbours):
    return ((cell_alive and num_neighbours in [2, 3]) or
            (not cell_alive and num_neighbours == 3))


def num_alive_neighbours(world, x, y):
    neighbours_rel_pos = [  # relative position of all possible neighbours
        (-1, -1), (-1, 0), (-1, 1),  # upper row
        (0, -1), (0, 1),  # same row
        (1, -1), (1, 0), (1, 1)]  # lower row

    count = 0
    for neigh_pos_x, neigh_pos_y in neighbours_rel_pos:
        pos_x = x + neigh_pos_x
        pos_y = y + neigh_pos_y
        if (pos_x >= 0 and pos_x < len(world) and
                pos_y >= 0 and pos_y < len(world[0]) and
                world[pos_x][pos_y]):
            count += 1
    return count


def world_tostring(world):
    return "\n".join("".join("O" if alive else " " for alive in line)
                     for line in world)

43 lignes, 4 fonctions.

Le rôle des données

Avec des objets

En POO, on scinde la donnée d'entrée (base de données, fichiers, flux de données...) pour la répartir dans différents objets. Dans notre exemple, un objet World qui stocke l'ensemble des cellules, et un objet Cell qui stocke son état (en vie ou morte) et l'ensemble de ses voisines.

  • + Représentation mentale aisée des différentes entitées
  • + Répartition des responsabilités
  • - Verbosité
  • - Difficulté pour les tests : il faut gérer les fixtures

Avec des fonctions

En fonctionnel, on traite directement la donnée d'entrée par des étapes successives et différentes fonctions que l'on compose.

  • + Concision
  • + Moins de code à maintenir, moins de code à lire et comprendre
  • + Facilité pour les tests
  • - Duplication de la donnée (nouveau monde à chaque itération)
  • - Recalcul des voisins à chaque itération

La réutilisation du code

Dans notre exemple très basique, pas de réutilisation du code. Pas d'héritage pour les objets, pas de composition de fonctions.

Avec des objets

La réutilisation du code dans la POO se fait principalement par l'héritage d'objets.

Imaginons que nous ayons demain un monde différent, qui au lieu d'être représenté par un tableau de cellules carrées, soit un amas de cellules hexagonales. On pourrait alors avoir un objet HexagonalWorld qui hériterait de World et redéfinirait les méthodes __init__ et __str__.

Le reste du code resterait le même, et serait donc réutilisé.

On peut encore imaginer des cellules plus ou moins résistantes qui, en redéfinissant mutate auraient des règles différentes de vie ou de mort.

Avec des fonctions

La réutilisation du code dans la programmation fonctionnelle se fait par la composition de fonctions.

On aurait pu imaginer partir d'un format différent pour le monde, sous la forme d'une suite de 0 et de 1.

On aurait alors tout d'abord transformé chaque 0 ou 1 en booléen puis découpé cette suite en lignes d'une longueur donnée, composant deux fonctions :

data = "001001100"
to_bool(data) == [False, False, True, False, False, True, True, False, False]
to_grid(to_bool(data)) == [
    [False, False, True],
    [False, False, True],
    [True, False, False]]

Si ce n'est pas d'une série de 0 et de 1 qu'on part, mais de X et de O, on change la fonction to_bool, la fonction to_grid reste identique.

La gestion de l'état

Voilà le plus gros point d'achoppement à mon avis, la plus grosse différence entre les langages fonctionnels (plus ou moins purs) et les langages objets : la gestion et le stockage d'un état changeant et les effets de bord.

En programmation fonctionnelle, il n'y a pas de stockage d'un état changeant dans les fonctions. Une fonction retournera toujours le même résultat pour la même donnée en entrée.

Une méthode d'un objet par contre pourra retourner un résultat différent selon l'état stocké dans l'objet. Une méthode is_alive sur un objet Cell retournera True ou False selon l'état de la cellule.

L'avantage d'avoir un état changeant est de pouvoir justement cantonner des morceaux de données dans différents objets, chacun avec ses responsabilités, son domaine d'application. Avec un objet donné, on a toutes les informations nécessaires à la gestion de cet objet, et on peut connaître à tout instant son état actuel.

Le stockage de l'état va souvent de pair avec les effets de bord. Une méthode set_alive sur un objet Cell va par exemple passer cette cellule vivante, mais aussi incrémenter son âge, ou encore incrémenter le compteur du nombre de cellules vivantes de l'objet World.

Les inconvénients sont nombreux :

Est-ce qu'on s'est trompés ?

Il est communément admis (en tout cas dans mon entourage) que l'utilisation d'objets est plus intuitive, plus facile, plus claire et explicite. Seulement, lors de la code retreat et des différentes sessions, j'ai été confronté à des visions très différentes de mes collègues de pair-programming, par exemple sur le découpage des objets, ou sur leur responsabilité :

L'impression que ça m'a laissé est qu'avoir utilisé des objets nous mettait une contrainte supplémentaire, un frein dont on aurait pu se passer.

Alors oui, il y a de meilleures manières d'aboutir au même résultat. Oui, cet exemple trivial n'est que peu représentatif de notre métier de développeur qui est de se frotter à des problèmes beaucoup plus complexes.

Oui, si il y a autant de monde qui fait de la POO, c'est vraisemblablement que le concept n'est pas aberrant. Mais attention à la loi des nombres, ce n'est pas parce que Java et PHP sont les langages les plus courants que je vais me mettre à en (re)faire.

Mais plus j'y pense, et plus je me dis qu'on s'est peut-être trompés. Pour les curieux (et je vous recommande très fortement d'être curieux !), voici quelques liens à voir absolument :