jeu de mot entre this is dope “ça déchire” et DOP

Le Data-oriented programming (DOP) n’est pas un concept nouveau. C’est un paradigme applicable par les développeurs dans n’importe quel langage de programmation, qui a pour but de réduire la complexité des systèmes d’information qu’ils sont en train de concevoir.

Yehonathan Sharvit s’en fait l’apôtre dans son livre Data-oriented programming. Ce livre explore les principes qui sous-tendent ce paradigme sous la forme d’un dialogue entre deux personnes.

Le narrateur est un jeune développeur javascript, il conçoit une application de gestion de bibliothèque pour un client. Le besoin initial est simple, l’application est codée en orienté objet. Mais dès que le client demande de rajouter de nouveaux comportements à la dernière minute, tout se complique. Il demande alors conseil à un développeur vétéran, Joe.

Au fil des pages Joe va mettre l’accent sur les difficultés qu’il a et lui donner des clés pour les surmonter. In fine, il va lui montrer une nouvelle manière d’organiser son code source de manière à le rendre plus facile à appréhender et plus évolutif.


Les exemples du livre sont en javascript, aussi je vous propose une version en python sur un petit aspect de ces règles : la séparation des données et du code.

Dans le livre les héros parlent de gestion d’utilisateurs. Le narrateur devait initialement modéliser 2 types d’utilisateurs, le bibliothécaire et le membre :

Une fois cette logique implémentée, le client lui demanda de rajouter des super membres, puis des membres VIP. Ce qui, représenté en UML, donnerait :

Le narrateur débutant ne s’en sort pas, car bien que ce soit à priori logique, cela donne une hiérarchie de classes difficile à manipuler, mélangeant héritages et dépendances.

Joe confirme son ressenti en lui affirmant que « l’encapsulation de données a ses avantages et inconvénients. Pense à la manière dont tu avais initialement conçu ce système. D’après le DO (Data Oriented), les objets sont la principale cause de la complexité des systèmes et de leur manque de souplesse, car code et données sont mélangés ensemble. »

Voici ce que Yehonathan Sharvit entend par complexité tout au long du livre : la difficulté de comprendre simplement quelque chose afin de pouvoir la mettre à jour.

La complexité est une chose qui a été accumulée petit à petit. Si elle n’est pas gardée sous contrôle, l’implémentation de nouvelles fonctionnalités peut prendre des semaines au lieu de quelques jours. Mais le DO a une approche radicale pour contrer cette complexité. Pour cela les données et le code doivent être séparés :

Pour mieux appréhender cette séparation, voici mon implémentation en Python.

J’ai suivi les techniques décrites dans le livre. Je suis parti des spécifications du client, j’ai relevé d’un coté les noms qui semblent représenter les entités de ce système, et de l’autre côté tout ce qui semble correspondre à une fonctionnalité. Puis j’ai hiérarchisé ce que j’ai relevé :

  • Deux type d’utilisateurs : les membres et les bibliothécaires.
  • Les utilisateurs s’authentifient avec leur email et leur mot de passe
  • Les membres peuvent emprunter des livres
  • Les membres et bibliothécaires peuvent chercher des livres par leur titre ou leur auteurs
  • Les bibliothécaires peuvent bloquer et débloquer des membres
  • Les bibliothécaires peuvent lister les livres actuellement empruntés par un membre
  • Il peut avoir plusieurs copies d’un même livre

Les entités hiérarchisées du système :

Les fonctionalités rangées par module :

En partant de ces principes, je vais implémenter l’emprunt de livres.

Pour la partie données catalog :

$schema: "https://json-schema.org/draft/2020-12/schema"
properties:
  lendings:
    additionalProperties:
      type: object
      properties:
        id: { type: string }
        user_id: { type: string, format: uuid }
        book_item_id: { type: string }
      required: [id, user_email, book_item_id]
  propertyNames: { type: string, format: uuid }
required: [lendings]

Pour la partie données user_management :

$schema: "https://json-schema.org/draft/2020-12/schema"
properties:
  members_by_id:
    type: object
    additionalProperties:
      type: object
      properties:
        is_blocked: { type: boolean }
      required: [is_blocked]
    propertyNames: { type: string, format: uuid }
required: [members_by_id]

J’ai utilisé ici JSON Schema, car les données ne sont pas limitées à des structures rigides. Seules les clés réellement utiles ont besoin d’être exprimées. En DO, les données doivent aussi obéir à trois autres règles :

  • tous les types sont génériques
  • tous les types sont immuables
  • la partie représentation et la partie schéma sont séparées

Voici un mock de données qui valident ces deux schémas :

library_data = {
    "catalog": {
        "books_by_isbn": {
            "9781234567897": {
                "title": "Data Oriented Programming",
                "author": "Yehonathan Sharvit",
            }
        },
        "book_items_by_id": {
            "book-item-1": {
                "isbn": "9781617298578",
            },
            "book-item-2": {
                "isbn": "9781617298578",
            }
        },
        "lendings": [
            {
                "id": "...",
                "user_id": "member-1",
                "book_item_id": "book-item-1",
            }
        ],
    },
    "user_management": {
        "members_by_id": {
            "member-1": {
                "id": "member-1",
                "name": "Xavier B.",
                "email": "xavier@deepki.com",
                "password": "aG93IGRhcmUgeW91IQ==",
                "is_blocked": False,
            }
        }
    },
}

Par convention les dict sont traités comme des sortes de Mapping, et je m’interdis de les modifier.

Notez que pour les besoins de l’article, les examples seront écrits sous la forme de classes + méthodes statiques. Dans du code de production, la forme modules + fonctions de modules est à utiliser.

Et maintenant la partie code :

from __future__ import annotations

from typing import Tuple, TypeVar
from uuid import uuid4

T = TypeVar("T")


class Library:
    @staticmethod
    def checkout(library_data: T, user_id, book_item_id) -> tuple[T, dict]:
        user_management_data = library_data["user_management"]
        if not UserManagement.is_member(user_management_data, user_id):
            raise Exception("Only members can borrow books")
        if UserManagement.is_blocked(user_management_data, user_id):
            raise Exception("Member cannot borrow book because he is bloqued")
        catalog_data = library_data["catalog"]
        if not Catalog.is_available(catalog_data, book_item_id):
            raise Exception("Book is already borrowed")
        catalog_data, lending = Catalog.checkout(catalog_data, book_item_id, user_id)
        return (
            library_data | {
                "catalog": catalog_data,
            },
            lending,
        )


class UserManagement:
    @staticmethod
    def is_member(user_management_data: T, user_id) -> bool:
        return user_id in user_management_data["members_by_id"]

    @staticmethod
    def is_blocked(user_management_data: T, user_id) -> bool:
        return user_management_data["members_by_id"][user_id]["is_blocked"] is True


class Catalog:
    @staticmethod
    def is_available(catalog_data: T, book_item_id) -> bool:
        lendings = catalog_data["lendings"]
        return all(lending["book_item_id"] != book_item_id for lending in lendings)

    @staticmethod
    def checkout(catalog_data: T, book_item_id, user_id) -> Tuple[T, dict]:
        lending_id = uuid4().__str__()
        lending = {"id": lending_id, "user_id": user_id, "book_item_id": book_item_id}
        lendings = catalog_data["lendings"]
        return (
            catalog_data | {
                "lendings": lendings + [lending]
            },
            lending
        )

Comme on peut le voir le code quant à lui est une série de fonctions “pures” (stateless dans le livre). Les fonctions qui modifient l’état retournent un nouvel état sans toucher à l’état précédent.

Dans chaque module, les fonctions sont extrêmement simples et faciles à tester. Elles peuvent être réutilisées dans n’importe quel contexte, tel que le module principal. D’une manière générale, elles sont composées des autres fonctions déjà conçues. Les adapter selon les besoins du client devient alors très facile.

Maintenant, quel chemin prennent les données si mon alter égo empruntait un autre exemplaire de ce livre ?

library_data, lending = Library.checkout(
    library_data,
    user_id="member-1",
    book_item_id="book-item-2",
)

Il se passe deux choses :

  1. Les données sont systématiquement transmises à chaque appel de fonction. Cet objet est assez obscur et chaque niveau n’utilise que des fragments qu’il connait sans se soucier du reste :

     # 1. injecte les données dans le module Library.checkout
     library_data, lending = Library.checkout(library_data, ...)
    
     # 2. extrait les données de user_management
     user_management_data = library_data["user_management"]
    
     # 3. utilise ce fragment de data dans le module UserManagement
     if not UserManagement.is_member(user_management_data, ...):
         ...
     if UserManagement.is_blocked(user_management_data, ...):
         ...
    
     # 4. déréférence les données de catalog
     catalog_data = library_data["catalog"]
    
     # 5. utilise ce fragment de data dans le module Catalog
     if not Catalog.is_available(catalog_data, ...):
         ...
     ... = Catalog.checkout(catalog_data, ...)
    
  2. Quand une fonction est en charge de mettre à jour un état, elle retourne alors une nouvelle version des données. Chaque niveau de la pile d’appel doit retourner une nouvelle version des données :

     # 1. Traitement de la demande dans Catalog.checkout
     lending = ...
     lendings = catalog_data["lendings"]
     # 2. Création d'une nouvelle version de catalog_data
     catalog_data = catalog_data | {
         "lendings": lendings + [lending]
     }
    
     # 3. Interception du nouveau catalog_data par Library.checkout
     catalog_data, ... = Catalog.checkout(...)
     # 4. Création d'une nouvelle version de library_data
     library_data = library_data | {
         "catalog": catalog_data,
     }
    

    Cette nouvelle version des données peut alors être exposée au reste du système d’information.

Dans mon exemple je ne traite pas de la consolidation. Je vous invite à consulter le livre pour avoir des informations concernant ces sujets.

Est-ce pythonique ?

D’une manière générale ce paradigme sied bien à Python si on minimise les aspects orienté objet du langage. Les notions de module en DO se superposent naturellement aux modules en Python, ce qui en facilite l’adhésion. Les emprunts fonctionnels tel que map(), filter() ainsi que les fonctions du module standard operator contribuent aussi à rendre ce paradigme assez naturel en Python.

Dans notre exemple le typage standard ne fonctionnera pas. Cependant il est assez facile de faire un typage personnalisé, comme par exemple :

from __future__ import annotations

from typing import Any, Mapping


class M(Mapping[str, Any]):
    def __or__(self, other: dict) -> M:
        return {**self, **other}  # type: ignore

    def __hash__(self) -> int:
        ...

# utilisable ainsi dans le code source

def my_func(members_data: M[str, M], member_id: str):
    member = members_data[member_id]

Pureté du code

D’autre part dans mon implémentation mes fonctions ne sont pas réellement pures car j’ai usé d’exceptions. Cependant cette disgression est acceptable si elle est appliquée sous une certaine condition. En effet les exceptions ne sont utilisées que pour exprimer des opérations illégales, en mode throw early, catch late. Les utiliser ainsi contribue à la lisibilité du code. Les couches plus hautes du système sauront comment les traiter.

Par exemple dans une application Flask:

@app.post("/checkout")
def checkout_view():
    ...
    try:
        ..., lending = Library.checkout(library_data, user_id, book_item_id)
        # la fonction 'checkout' peut lever des exceptions
        ...
        return jsonify(lending), 201
    except Exception as error:
        result = {"error": str(error)}
        return jsonify(result), 400

L’auteur est aussi lucide sur le prix à payer en faisant du DO. Par exemple, le fait que DO est relativement agnostique de tout langage de programmation met à mal les garanties qu’offre la modélisation objet (ou les autres outils tel que l’analyse de code que permettent certains IDE). Néanmoins il propose parfois des alternatives pour cela, tel que JSON Schema utilisé ici.

Ce que je vous ai présenté n’était qu’un aperçu du DOP en pur Python. L’auteur donne énormément de détails sur les tests unitaires, les structures de données, le state management, le structural sharing, l’atomicité, la pipeline de transformation, etc…

Je vous invite donc à lire Data-oriented programming par Yehonathan Sharvit, et suivre sa démarche dans son blog.

Pour terminer

L’auteur est un développeur polyglotte et sans le citer, beaucoup de concepts viennent du language Clojure. Selon ses défenseurs, Clojure est le langage de programmation le plus facile du monde car il a très peu de mots clés et il a été conçu par Rich Hickey de manière à faciliter les modifications de code.

Ce langage peut être inspirant pour d’autres langages. Pour vous en convaincre vous pouvez consulter cette autre présentation Design, Composition, and Performance Short par Rich Hickey.

Cela me met en joie de voir certains de ces principes ré-utilisés dans d’autres langages. En effet, les langages doivent se nourrir les uns les autres.

Et faire du fonctionnel en Python, this is dope.