Vibrez au rythme du code

Accueil : Cliquer ici

logo 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 :

defmoduleLogAnalyzerdo
deffirst_errors(file_path,count\\10)do
file_path
|>File.stream!()
|>Stream.map(&String.trim/1)
|>Stream.filter(&String.contains?(&1,"[ERROR]"))
|>Enum.take(count)
end
end

Les avantages :

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 :