Vibrez au rythme du code

Accueil : Cliquer ici

logo Elixiriste

Streams : ne stockez plus, faites circuler !

Imaginez devoir repeindre dix milles figurines en bois.

L'approche classique, celle du module Enum, consiste à travailler par entrepôts. Vous prenez vos dix milles figurines, vous les déballez toutes et vous les stockez dans un premier entrepôt. Ensuite, vous les déplacez toutes 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 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 seule figurine est déballée, poncée, puis peinte, avant même que la seconde ne quitte son carton. Vous n'avez 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" va transformer votre manière de coder en Elixir. Nous verrons comment manipuler des flux de données massifs avec une empreinte mémoire dérisoire.

Le péché de gourmandise

En Elixir, le module Enum est votre outil de tous les jours. Il est efficace, prévisible et simple. Mais il a un défaut caché : il est gourmand. Chaque fois que vous utilisez une fonction Enum, Elixir s'arrête, traite l'intégralité de la collection, et génère une nouvelle liste en mémoire.

Imaginez les transformations suivantes :

require Integer
1..10_000_000
|> Enum.map(&(&1 * 3))
|> Enum.filter(&Integer.is_even/1)
|> Enum.take(5)

Le problème est flagrant : pour obtenir seulement 5 malheureux résultats à la fin, votre processeur a dû mouliner 10 millions d'opérations deux fois, et votre mémoire a dû stocker des listes gigantesques qui ont été jetées à la poubelle l'instant d'après.

La vertue de la paresse

Le module Stream propose une philosophie radicalement différente : la paresse. Au lieu d'exécuter chaque étape immédiatement, il se contente d'enregistrer la liste des tâches à accomplir.

Quand vous écrivez un Stream.map, Elixir ne transforme rien. Il construit simplement une recette de transformation : un flux.

Reprenons notre exemple précédent, mais en version convoyeur :

require Integer
stream = 1..10_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&Integer.is_even/1)

À ce stade, la console vous affichera quelque chose comme :

#Stream<[
enum: 1..10000000,
funs: [#Function<...>, #Function<...>]
]>

À ce moment précis du code, aucune opération n'a eu lieu. Votre ordinateur n'a consommé pratiquement aucune mémoire, car le flux attend un signal pour démarrer.

Le calcul ne commence que lorsqu'une fonction du module Enum (ou toute fonction qui consomme de la donnée, comme Enum.to_list/1 ou Enum.take/5) appelle le flux.

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 il prend le nombre 2, applique les opérations et continue ainsi de suite. Dès que le convoyeur a livré 5 nombres, tout s'arrête.

L'avantage est majeur : le reste des quasi 10 millions d'éléments ne sera jamais traité. Elixir n'a fait que le strict nécessaire pour satisfaire votre demande de 5 éléments.

Dompter les fichiers gigantesques

C’est ici que la théorie rencontre la réalité du terrain. Imaginez que vous deviez analyser un fichier de logs serveur de 15 Go pour extraire les 10 premières erreurs fatales.

Si vous utilisez File.read!(path) |> String.split("\n"), votre application va tenter d'allouer 15 Go de RAM d'un coup. Sur la plupart des serveurs, c'est le crash instantané.

Voici comment Elixir traite ce problème avec élégance à l'aide des flux :

defmodule LogAnalyzer do
def get_last_errors(file_path, count) do
file_path
|> File.stream!()
|> Stream.map(&String.trim/1)
|> Stream.filter(&String.contains?(&1, "[ERROR]"))
|> Enum.take(count)
end
end

Les avantages d'utiliser un flux :

Traiter des fichiers avec File.stream! est probablement l'usage le plus courant et le plus vital 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, tenter de créer une liste infinie est le meilleur moyen de faire chauffer votre processeur jusqu'à l'explosion. En Elixir, grâce à la paresse des flux, l'infini n'est qu'une structure de données comme une autre.

Pourquoi voudriez-vous une liste infinie ? Pour modéliser des cycles qui n'ont pas de fin théorique : des dates, des suites mathématiques, ou même un système de retry.

Imaginons que vous deviez trouver les 5 prochains lundis à partir d'aujourd'hui pour planifier des tâches de maintenance.

Générons une suite de dates infinie, jour après jour avec Stream.iterate/2.

Stream.iterate(Date.utc_today(), fn date -> Date.add(date, 1) end)
|> Stream.filter(fn date -> Date.day_of_week(date) == 1 end)
|> Enum.take(5)

Stream.iterate(start_value, next_fun) prend une valeur de départ et une fonction pour générer la valeur suivante. Le flux ne calcule jamais la date du lendemain tant que vous ne lui demandez pas. Il ne calculera jamais le 6ème lundi, car Enum.take(5) envoie un signal d'arrêt au flux dès que la condition est remplie.

Le cas complexe : La suite de Fibonacci

Pour les amateurs de complexité élégante, on peut même utiliser Stream.unfold/2. C'est le grand frère de Stream.iterate/2, capable de transporter un état interne.

fib_stream = Stream.unfold({0, 1}, fn {a, b} ->
{a, {b, a + b}}
end)
fib_stream |> Enum.take(10)
# Résultat : [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Le mot de la fin

Nous avons commencé avec des entrepôts de stockage encombrants (Enum) pour finir avec un convoyeur fluide et infini (Stream).

Ce qu'il faut retenir pour votre prochain projet Elixir :