L'État en tant que Types, un Modèle de ConceptionDraft

La programmation tourne autour de l’état.

Les informaticiens vous diront que les langages de programmation ne sont qu’une manière de définir l’automate fini déterministe (ou machine à états) d’une machine de Turing abstraite. D’un autre côté, les ingénieurs en matériel vous diront que le CPU, l’objet physique qui exécute le programme, est littéralement une machine à états encodée avec des transistors.

La programmation tourne autour de l’état.

On pourrait argumenter que, en ignorant la syntaxe, ce qui différencie un langage de programmation d’un autre est la manière dont il gère l’état. Une façon de classer la gestion de l’état par les langages est de considérer combien de celui-ci est résolu au moment de la compilation par rapport à l’exécution.

Spectre des langages de programmation
Langages de programmation courants placés sur le spectre exécution/compilation

À l’extrême gauche se trouvent les langages entièrement interprétés 1, comme Python et Lua, qui ne savent même pas quelles instructions exécuter avant l’exécution. De l’autre côté, nous avons des langages fortement typés et compilés comme Rust et C++, qui nécessitent des informations détaillées sur le programme avant qu’il ne puisse être compilé.

Il y a des compromis, peu importe où se situe le langage sur le spectre. Les langages très stricts, qui nécessitent des informations détaillées sur les types, la mémoire et les durées de vie, sont souvent difficiles à construire et à itérer, mais sont très efficaces à l’exécution. Mais les langages de l’autre côté sont l’opposé : faciles à écrire rapidement, mais avec un coût d’exécution significatif.

Il y a un domaine où les langages fortement typés brillent sans ambiguïté : la gestion des erreurs. Bien souvent, la philosophie des langages interprétés est de déboguer après l’exécution pour voir si des exceptions inattendues sont levées.

Algorithme Y : Débogage des Langages Interprétés (simplifié)

  1. Écrire du code
  2. Exécuter le code
  3. si il n’y a pas d’erreur, aller à l’étape 6
  4. gérer l’erreur
  5. aller à l’étape 2
  6. Déployer

Que vous valorisiez le temps de développement par rapport aux performances d’exécution, ou vice versa, je pense que nous pouvons tous convenir que les programmes doivent être corrects et robustes face aux erreurs.

Nous allons examiner un Modèle de Conception qui aide à catégoriser et à déplacer la logique d’exécution vers des informations de compilation (ou de rédaction de code, si vous utilisez un langage interprété). En effet, il créera un pipeline d’état qui exploite le système de types du langage pour empêcher les états d’objet illégaux. Et ne vous inquiétez pas, le typage statique n’est pas nécessaire.

Examinons un exemple de refactorisation qui utilise ce modèle.

La Tâche

Le patron nous dit qu’il est impératif que nous écrivions une bibliothèque qui compte le nombre d’occurrences d’un caractère donné dans n’importe quel fichier pointé par n’importe quelle URL. Il veut également que le fichier puisse être téléchargé à tout moment par l’utilisateur de notre code.

Ça semble bien. Créons une classe qui fait exactement cela.

class FileDownloadCharCounter:
    def __init__(self, url):
		# Sauvegarder l'argument comme variable d'instance
        self.url = url

    def download(self):
		# Télécharger le contenu et le sauvegarder pour une utilisation ultérieure
        self.downloaded_content = requests.get(self.url).text

    def create_index(self):
		# Créer un dictionnaire qui stocke les comptes de caractères
        self.index = {}
        for char in self.downloaded_content:
            self.index[char] = self.index.get(char, 0) + 1

    def get_count(self, target_char):
		# Obtenir le compte à partir du dictionnaire
        return self.index.get(target_char, 0)

Pour cet exercice, supposons que requests.get ne échouera jamais. Testons la classe.

counter = FileDownloadCharCounter(
	"https://world.hey.com/dhh/programming-types-and-mindsets-5b8490bc"
)
counter.download()
counter.create_index()
target = "a"
n = counter.get_count("a")
print(f"{target} apparaît {n} fois")
a apparaît 477 fois

Super ! Temps de déployer ? Pas si vite… Si vous regardez de près, il y a pas mal d’exceptions non capturées que nous avons introduites. Que se passe-t-il si l’utilisateur fait ceci ?

counter = FileDownloadCharCounter(
	"https://world.hey.com/dhh/programming-types-and-mindsets-5b8490bc"
)
counter.download()
target = "a"
n = counter.get_count("a")
AttributeError: 'FileDownloadCharCounter' object has no attribute 'index'

Oh oh. Si l’utilisateur oublie de créer l’index, il obtient une AttributeError, ce qui n’est pas très utile. Cela nécessiterait qu’il lise le code source de la bibliothèque pour comprendre ce qui s’est mal passé, ce qui n’est pas une excellente expérience utilisateur. Créons une exception auto-explicative que l’utilisateur pourra capturer et résoudre :

class IndexNotCreatedException(Exception):
	pass

class FileDownloadCharCounter:
    def __init__(self, url):
		# autre code ...
        self.index = {}

# autres méthodes ...

    def get_count(self, target_char):
        if len(self.index) == 0:
            raise IndexNotCreatedException
        return self.index[target_char]

Maintenant, nous obtenons une belle

File "example.py", line 31, in get_count
    raise IndexNotCreatedException
__main__.IndexNotCreatedException

que nous pouvons gérer avec

try:
	count = counter.get_count('a')
except IndexNotCreatedException:
	# récupérer
	pass

Y a-t-il une autre manière dont cela peut échouer ? Oui :

counter.create_index()
target = "a"
n = counter.get_count("a")
  File "example.py", line 13, in create_index
    for char in self.downloaded_content:
AttributeError: 'FileDownloadCharCounter' object has no attribute 'downloaded_content'

Si l’utilisateur de la bibliothèque oublie de télécharger le fichier, cela lance une autre AttributeError, ce qui n’est pas très utile. Gérons cela.

class FileNotDownloadedException(Exception):
    pass

class FileDownloadCharCounter:
    def __init__(self, url):
		# ...
        self.downloaded = False

    def download(self):
		# ...
        self.downloaded = True

    def create_index(self):
        if not self.downloaded:
            raise FileNotDownloadedException
		# ...

Maintenant, l’utilisateur peut gérer cette exception comme nous l’avons fait ci-dessus. Il y a encore un autre bug ; pouvez-vous le trouver ? Disons que l’utilisateur fait accidentellement ceci :

counter = FileDownloadCharCounter(
	"https://world.hey.com/dhh/programming-types-and-mindsets-5b8490bc"
)
counter.download()
counter.create_index()
# d'autres choses importantes...
counter.create_index()
# encore plus de choses importantes...
target = "a"
n = counter.get_count("a")
print(f"{target} apparaît {n} fois")

Maintenant, nous obtenons le double du compte réel !

a apparaît 954 fois

Et aucune exception. Cela signifie que ce bug n’a pas été détecté par l’algorithme Y. Encore une fois, gérons cela.

class IndexNotCreatedException(Exception):
    pass

class FileDownloadCharCounter:
	# méthodes...

    def create_index(self):
        if len(self.index) > 0:
            raise IndexAlreadyCreatedException
		# plus de code...

Ouf ! Cela semble être toutes les erreurs liées à l’état. Voici notre code final :

class IndexNotCreatedException(Exception):
    pass

class IndexAlreadyCreatedException(Exception):
    pass

class FileNotDownloadedException(Exception):
    pass

class FileDownloadCharCounter:
    def __init__(self, url):
        self.url = url
        self.index = {}
        self.downloaded = False

    def download(self):
        self.downloaded_content = requests.get(self.url).text
        self.downloaded = True

    def create_index(self):
        if not self.downloaded:
            raise FileNotDownloadedException

        if len(self.index) == 0:
            raise IndexAlreadyCreatedException

        for char in self.downloaded_content:
            self.index[char] = self.index.get(char, 0) + 1

    def get_count(self, target_char):
        if len(self.index) == 0:
            raise IndexNotCreatedException

        return self.index.get(target_char, 0)

Et voilà ! Notre code orienté objet est… terrible. Pour ce qui devrait être juste quelques lignes simples de Python, nous avons 3 exceptions personnalisées et une logique qui s’assure simplement que rien n’a mal tourné.

Et vous pouvez imaginer que si, au lieu de 3 variables d’instance qui suivent l’état, nous en avions 30, il pourrait même ne pas être possible de connaître ou d’énumérer tous les états illégaux pour lancer des exceptions.

Malheureusement, beaucoup de langages interprétés ont tendance à rendre l’écriture de code robuste fastidieuse. Pour aider à gérer cela, introduisons un nouveau modèle de conception, que je vais appeler le Type Pipeline, ou Typeline si vous préférez.

Existence État

Avec cette astuce, dans les langages statiquement typés, le code avec un état illégal ne compilera pas. Dans les langages dynamiquement typés, si vous n’obtenez pas une TypeError, vous aurez un état légal garanti.

Voici comment cela fonctionne :

  • Associez un type à un état
  • Garantissez que l’existence d’un objet instance de implique que nous sommes dans l’état

C’est aussi simple que cela. Essayons maintenant de refactoriser notre code précédent. Considérons les états valides de notre programme :

État URL connue Fichier Téléchargé Fichier Indexé
1
2
3

Il est clair qu’il s’agit d’un pipeline linéaire simple. L’état 1 nécessite une URL en entrée, l’état 2 nécessite l’état 1, et l’état 3 nécessite l’état 2.

Si est le type correspondant à l’état , la construction d’une instance de ne devrait être accessible qu’à travers une instance de . Commençons par l’état 1, qui nécessite simplement une URL valide.

class FileURL: # c'est-à-dire T_1
	def __init__(self, url):
		# optionnellement valider l'URL
		self.url = url

Puisque nous voulons que ne soit construit qu’à partir d’une instance de , ajoutons une méthode sur qui construit un avec un état valide (le fichier a été téléchargé).

class FileURL:
	# autres méthodes ...
	def download_file(self, file_url: FileURL) -> DownloadedFile:
		file_contents = requests.get(file_url).text
		return DownloadedFile(file_contents)

class DownloadedFile:
	def __init__(self, file_contents: str):
		self.contents = file_contents

Et nous répétons pour , qui représente un fichier indexé.

class DownloadedFile:
	# autres méthodes ...
	def index_file(self) -> IndexedFile:
		index = {}
		for char in content:
            index[char] = index.get(char, 0) + 1
		return IndexedFile(index)

class IndexedFile:
	def __init__(self, index: dict[str, int]):
		self.index = index

	def get_count(self, target_char):
		return self.index.get(target_char, 0)

Maintenant, toutes ces classes sont juste pour démontrer l’idée. En réalité, nous n’avons pas vraiment besoin de donner à l’utilisateur l’accès à un fichier téléchargé mais non indexé ni à une classe URL qui enveloppe simplement une chaîne de caractères. Ils ont juste besoin de pouvoir télécharger le fichier à la demande et obtenir des comptages de caractères.

Ainsi, nous pouvons supprimer les types associés à ces états et intégrer la logique dans une méthode de transition ou un constructeur.

class FileURL:
	def __init__(self, url):
		self.url = url

	# État 1 -> 3
	def fetch_index(self) -> CharIndex:
		# État 1 -> 2
		content = requests.get(url).text
		# État 2 -> 3
		return CharIndex(content)

class CharIndex:
	# État 2 -> 3
	def __init__(self, content: str):
		index = {}
		for char in content:
            index[char] = index.get(char, 0) + 1
		self.index = index

	def get_count(self, target_char):
		return self.index.get(target_char, 0)

Utilisation :

file = FileURL("https://world.hey.com/dhh/programming-types-and-mindsets-5b8490bc")
index = file.fetch_index()
count = index.get_count('a')

Vous pouvez voir comment l’état illégal n’est plus un problème avec les garanties de transition/construction.

Cependant, il y a quelques mises en garde. Ce modèle ne fonctionnera que si :

  1. Les états sont connus au moment de la compilation/écriture du code (c’est-à-dire que l’objet à la ligne devrait avoir l’état )
  2. Il n’y a qu’un nombre limité de tels états, car vous pourriez avoir à créer une nouvelle classe pour chacun d’eux.

Quelques avantages

Dans les langages à ramasse-miettes, un énorme avantage est l’efficacité mémoire. Revenons à notre exemple : dans la classe originale, le fichier téléchargé a la même durée de vie que l’objet entier. Cela signifie que tant qu’il existe une référence à l’objet, la chaîne de caractères du site entier est stockée en mémoire.

counter = FileDownloadCharCounter(...)
counter.download_file()  # espace pour la chaîne alloué
counter.create_index()   # dictionnaire d'index alloué
counter.get_count('a')

# Tout libéré à la fin de la portée

Mais nous savons, d’après la tâche, qu’après la création de l’index, nous n’avons plus besoin du contenu du fichier. Parce que Typeline est conçu autour de la transformation de données, les durées de vie sont explicitement définies. Si une donnée n’est plus nécessaire, elle peut être détruite en toute sécurité par le ramasse-miettes.

file = FileURL(...)
index = file.fetch_index() # chaîne allouée et immédiatement libérée
count = index.get_count('a') # index alloué
# Index libéré à la fin de la portée

Cela est particulièrement utile si le fichier téléchargé fait plusieurs Go, ou si nous scrapons simultanément des milliers de sites web.

Conclusion

Cet article décrit un modèle de conception simple que j’ai trouvé utile lors de l’écriture de streamrip v2, qui a corrigé un nombre extrêmement élevé d’erreurs d’état possibles dans la version 1 et a considérablement simplifié la base de code. Bien que je n’aie pas rencontré cette idée exacte auparavant, elle n’est en aucun cas originale. La Typeline est simplement une façon orientée objet d’encoder un automate fini déterministe (DFA) avec une gestion d’effets secondaires. Ainsi, tous les travaux théoriques réalisés sur les DFA sont également applicables ici.

Faites-moi savoir si vous avez repéré ce modèle quelque part !


  1. Les langages modernes que nous appelons interprétés sont en réalité compilés avec une compilation JIT (ou juste-à-temps). Nous utilisons le terme “interprété” pour signifier qu’un programme mal typé peut tout de même être exécuté. ↩︎

✦ No LLMs were used in the ideation, research, writing, or editing of this article.