Vibrez au rythme du code
Accueil : Cliquer ici
Elixiriste
Streams : ne stockez plus, faites circuler !
Imaginez devoir repeindre dix mille figurines en bois.
L'approche classique, celle du module Enum, consiste à travailler par entrepôts. Vous prenez vos dix mille figurines, vous les déballez toutes et vous les stockez dans un premier entrepôt. Ensuite, vous les déplacez dans un deuxième entrepôt pour les poncer. Puis dans un troisième pour les peindre. Le problème ? Il vous faut des entrepôts gigantesques pour stocker toute la production à chaque étape. Si votre collection s'agrandit, vous finirez par manquer d'espace (en mémoire).
C'est ce qu'on appelle l'évaluation immédiate. Chaque opération crée une nouvelle liste complète en mémoire avant de passer à la suivante.
Le module Stream, lui, préfère le convoyeur.
Au lieu d'entrepôts, vous installez une ligne de production. Une figurine est déballée, poncée, puis peinte, avant même que la suivante ne quitte son carton. Plus besoin d'entrepôts : un simple établi suffit, quelle que soit la quantité totale à traiter.
Dans la suite, nous allons explorer pourquoi passer du « stockage » au « flux » transformera votre manière de coder en Elixir. Nous verrons comment manipuler des données massives avec une empreinte mémoire dérisoire.
Le péché de gourmandise
En Elixir, le module Enum est votre outil du quotidien. Efficace, prévisible, simple. Mais il a un défaut caché : il est gourmand. Chaque fonction Enum s'arrête, traite l'intégralité de la collection, et génère une nouvelle liste en mémoire.
Observez ces transformations :
1..10_000_000|>Enum.map(&(&1*3))|>Enum.filter(&(rem(&1,2)==0))|>Enum.take(5)Le problème est flagrant : pour obtenir 5 malheureux résultats, votre processeur a dû mouliner 10 millions d'opérations, par deux fois. Et votre mémoire a dû stocker des listes intermédiaires gigantesques, jetées à la poubelle l'instant d'après.
La vertu de la paresse
Le module Stream propose une philosophie radicalement différente : l'évaluation paresseuse. Au lieu d'exécuter chaque étape immédiatement, il se contente d'enregistrer la liste des tâches à accomplir.
Quand vous écrivez Stream.map, Elixir ne transforme rien. Il construit simplement une recette de transformation.
Reprenons notre exemple, version convoyeur :
stream=1..10_000_000|>Stream.map(&(&1*3))|>Stream.filter(&(rem(&1,2)==0))À ce stade, la console affiche quelque chose comme :
Stream<[enum: 1..10_000_000,funs: [#Function<...>, #Function<...>]]>Aucune opération n'a eu lieu. Votre ordinateur n'a consommé pratiquement aucune mémoire : le flux attend un signal pour démarrer.
Le calcul ne commence que lorsqu'une fonction consommatrice réclame des données :
result=Enum.take(stream,5)Le convoyeur démarre. Il prend le nombre 1, le multiplie par 3, vérifie s'il est pair. Puis le nombre 2. Et ainsi de suite. Dès que 5 résultats sont livrés, tout s'arrête.
L'avantage est majeur : les quasi 10 millions d'éléments restants ne seront jamais traités. Elixir n'a fait que le strict nécessaire.
Dompter les fichiers gigantesques
C'est ici que la théorie rencontre le terrain. Imaginez analyser un fichier de logs de 15 Go pour extraire les 10 premières erreurs critiques.
Avec File.read!(path) |> String.split("\n"), votre application tente d'allouer 15 Go de RAM d'un coup. Sur la plupart des serveurs, c'est le crash immédiat.
Voici comment les flux résolvent ce problème :
defmoduleLogAnalyzerdodeffirst_errors(file_path,count\\10)dofile_path|>File.stream!()|>Stream.map(&String.trim/1)|>Stream.filter(&String.contains?(&1,"[ERROR]"))|>Enum.take(count)endendLes avantages :
- Empreinte mémoire constante : que le fichier fasse 10 Mo ou 100 Go, ce code consomme la même quantité de mémoire.
- Arrêt prématuré : si les 10 erreurs se trouvent dans les 50 premières lignes, Elixir ne lira jamais les millions de lignes restantes.
Enum.take/2envoie un signal d'arrêt au flux, qui ferme proprement le descripteur de fichier. - Réponse quasi instantanée : vous obtenez vos résultats sans attendre le parcours complet.
Le traitement de fichiers avec File.stream! est probablement l'usage le plus courant des flux. Mais saviez-vous que vous pouvez aussi créer vos propres sources de données infinies ?
L'infini à portée de main
Dans un langage classique, créer une liste infinie est le meilleur moyen de faire chauffer votre processeur jusqu'à l'explosion. En Elixir, grâce à l'évaluation paresseuse, l'infini devient une structure de données comme une autre.
Pourquoi voudriez-vous une liste infinie ? Pour modéliser des cycles sans fin théorique : des séquences de dates, des suites mathématiques, un système de retry avec backoff exponentiel.
Imaginons trouver les 5 prochains lundis à partir d'aujourd'hui :
Date.utc_today()|>Stream.iterate(&Date.add(&1,1))|>Stream.filter(&(Date.day_of_week(&1)==1))|>Enum.take(5)Stream.iterate/2 prend une valeur de départ et une fonction pour générer la suivante. Le flux ne calcule jamais la date du lendemain tant que personne ne la demande. Et il ne calculera jamais le 6ᵉ lundi : Enum.take(5) envoie un signal d'arrêt dès que la condition est remplie.
Le cas complexe : la suite de Fibonacci
Pour les amateurs d'élégance, Stream.unfold/2 permet de transporter un état interne entre chaque itération :
Stream.unfold({0,1},fn{a,b}->{a,{b,a+b}}end)|>Enum.take(10)# => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]La fonction reçoit l'état courant {a, b} et retourne un tuple : la valeur à émettre a et le nouvel état {b, a + b}. Simple, élégant et infini.
Le mot de la fin
Nous avons commencé avec des entrepôts encombrants pour finir avec un convoyeur fluide et potentiellement infini.
Ce qu'il faut retenir :
- Utilisez
Enumpour les collections que vous avez déjà en main et dont la taille est raisonnable. - Utilisez
Streamdès que vous manipulez des fichiers, des appels API paginés, ou des chaînes de transformations où seule une partie du résultat vous intéresse. - Avec les flux, c'est le consommateur qui décide quand le travail s'arrête, pas la source.