Back to flin
flin

L'opérateur pipeline : composition fonctionnelle dans FLIN

Comment nous avons implémenté l'opérateur pipeline dans FLIN -- la syntaxe |> pour la composition fonctionnelle, sa transformation en appels de fonctions par le parser, et l'expérience développeur qu'il débloque.

Thales & Claude | March 30, 2026 11 min flin
EN/ FR/ ES
flinpipelinefunctionalcomposition

La Session 150 a ajouté une fonctionnalité techniquement simple -- environ 30 lignes de code réparties sur trois fichiers -- mais d'un impact disproportionné sur la façon dont les programmes FLIN se lisent. L'opérateur pipeline.

L'idée est empruntée à Elixir, F#, et la proposition (éternelle) d'opérateur pipeline pour JavaScript : prendre une valeur à gauche, la passer comme premier argument à la fonction à droite. Enchaîner autant que l'on veut. Lire de gauche à droite, comme de la prose.

flin5 |> double |> add_one |> square

Cela s'évalue à square(add_one(double(5))). Même résultat. Expérience de lecture entièrement différente. La version imbriquée se lit de l'intérieur vers l'extérieur. La version pipeline se lit de gauche à droite : commencer avec 5, le doubler, ajouter un, le mettre au carré.

Pour un langage qui valorise la lisibilité par-dessus tout, l'opérateur pipeline n'était pas un luxe. C'était une nécessité.

Pourquoi les pipelines comptent

Considérons une chaîne de transformation de données sans pipelines :

flinresult = format(
    sort(
        filter(
            users,
            u => u.active
        ),
        u => u.name
    ),
    "table"
)

Maintenant avec les pipelines :

flinresult = users
    |> filter(u => u.active)
    |> sort(u => u.name)
    |> format("table")

La version pipeline raconte une histoire : prendre les utilisateurs, filtrer les actifs, trier par nom, formater en tableau. Chaque étape est une ligne. Les données coulent vers le bas. Les transformations se lisent dans l'ordre.

La version imbriquée raconte la même histoire, mais à l'envers et de l'intérieur vers l'extérieur. Il faut commencer par l'appel de fonction le plus interne et remonter vers l'extérieur pour comprendre l'ordre d'exécution. C'est une charge cognitive bien connue en programmation fonctionnelle, et l'opérateur pipeline l'élimine.

La syntaxe

L'opérateur pipeline de FLIN utilise le token |>, suivant la convention de F#, Elixir et OCaml :

flin// Basique : value |> function
5 |> double              // devient double(5)

// Enchaîné : a |> f |> g |> h
5 |> add_one |> double |> square
// devient square(double(add_one(5)))

// Avec arguments : value |> function(args)
10 |> add(5)             // devient add(10, 5)

// Pipeline multi-arguments
"hello world" |> split(" ") |> join("-")
// devient join(split("hello world", " "), "-")

La sémantique clé : value |> function(args) devient function(value, args). La valeur de gauche est ajoutée en tête de la liste d'arguments. Si la fonction ne prend pas d'arguments explicites, value |> function devient function(value).

Lexer : le token PipeArrow

La première étape était d'apprendre au lexer à reconnaître |> comme un seul token :

rust// In src/lexer/scanner.rs
fn scan_pipe(&mut self) -> Token {
    if self.peek() == '>' {
        self.advance(); // consume '>'
        Token::PipeArrow
    } else {
        Token::Pipe // existing | token for union types
    }
}

C'est un lookahead de deux caractères. Quand le scanner voit |, il vérifie si le caractère suivant est >. Si oui, il produit un token PipeArrow. Sinon, il produit le token Pipe existant (utilisé pour les types union comme int | text).

Le token a été ajouté à l'enum de tokens :

rustpub enum Token {
    // ... existing tokens ...
    PipeArrow,  // |>
}

Trois lignes dans le scanner, une ligne dans l'enum de tokens. Les modifications du lexer étaient triviales.

Parser : désucrage en appels de fonction

Le parser est là où le vrai travail se passe. L'opérateur pipeline est du sucre syntaxique -- il ne crée pas de nouveau nœud AST. Au lieu de cela, le parser transforme a |> f(b, c) en f(a, b, c) pendant l'analyse.

Cette approche de désucrage a un avantage significatif : chaque passe du compilateur après le parser (vérificateur de types, générateur de code, optimiseur) ne voit jamais un opérateur pipeline. Elle voit un appel de fonction. Aucune modification nécessaire en aval.

L'opérateur pipeline est analysé comme un opérateur infixe associatif à gauche avec une faible précédence (juste au-dessus de l'affectation) :

rustfn parse_pipeline(&mut self) -> Result<Expr, ParseError> {
    let mut expr = self.parse_logical_or()?;

    while self.match_token(&Token::PipeArrow) {
        let right = self.parse_postfix()?;

        expr = match right {
            // value |> function(args) -> function(value, args)
            Expr::Call { callee, mut args, span } => {
                args.insert(0, expr);
                Expr::Call { callee, args, span }
            }
            // value |> function -> function(value)
            Expr::Identifier { name, span } => {
                Expr::Call {
                    callee: Box::new(Expr::Identifier { name, span }),
                    args: vec![expr],
                    span,
                }
            }
            _ => {
                return Err(ParseError::new(
                    "pipeline right-hand side must be a function or function call",
                    self.current_span(),
                ));
            }
        };
    }

    Ok(expr)
}

Le parser gère deux cas :

  1. value |> function(args) -- Le côté droit est déjà une expression Call. Insérer la valeur de gauche comme premier argument.
  2. value |> function -- Le côté droit est un identifiant. Créer une nouvelle expression Call avec la valeur de gauche comme seul argument.

Tout autre côté droit est une erreur. On ne peut pas écrire 5 |> 42 ou 5 |> "hello".

Pipelines enchaînés

Parce que le parser utilise une boucle while, les pipelines enchaînés fonctionnent naturellement :

flin5 |> add_one |> double |> square

Première itération : 5 |> add_one devient add_one(5). Deuxième itération : add_one(5) |> double devient double(add_one(5)). Troisième itération : double(add_one(5)) |> square devient square(double(add_one(5))).

L'associativité à gauche de la boucle produit l'imbrication correcte. Chaque étape du pipeline enveloppe le résultat précédent.

Pipelines multi-arguments

L'opération args.insert(0, expr) gère les fonctions multi-arguments :

flin10 |> add(5)
// Analysé comme : add(10, 5)

"hello" |> replace("l", "r")
// Analysé comme : replace("hello", "l", "r")

data |> transform(config, options)
// Analysé comme : transform(data, config, options)

La valeur pipelinée devient toujours le premier argument. Cette convention est partagée par Elixir et suit un motif naturel : la plupart des fonctions de transformation de données prennent les données comme premier argument et la configuration/les options comme arguments suivants.

Vérification de types

Parce que les pipelines se désucraient en appels de fonction, le vérificateur de types les gère automatiquement. Quand 5 |> double devient double(5), le vérificateur de types :

  1. Résout double comme une fonction fn double(x: int) -> int
  2. Vérifie que l'argument 5 (un int) correspond au type de paramètre int
  3. Retourne le type résultat int

Pas de logique pipeline spéciale dans le vérificateur de types. Pas de nouvelles règles de type. La vérification d'appel de fonction existante gère tout.

C'est la puissance du désucrage. En transformant la syntaxe au niveau du parser, l'ensemble du pipeline en aval (vérificateur de types, générateur de code, optimiseur, formateur) fonctionne sans modification.

Motifs de pipeline réels

Traitement de données

flinreport = transactions
    |> filter(t => t.date > last_month)
    |> group_by(t => t.category)
    |> map_values(sum)
    |> sort_by_value("desc")
    |> take(10)
    |> format_table()

Sept transformations, chacune sur sa propre ligne, lisant de haut en bas. Comparez cela à la version imbriquée et la différence de lisibilité est frappante.

Manipulation de chaînes

flinslug = title
    |> trim()
    |> lower()
    |> replace(" ", "-")
    |> replace("--", "-")
    |> truncate(50)

Chaque étape transforme la chaîne. Le pipeline se lit comme une recette : le couper, le mettre en minuscules, remplacer les espaces par des tirets, nettoyer les doubles tirets, tronquer à 50 caractères.

Traitement d'entités

flindashboard_data = User.all
    |> filter(u => u.active)
    |> sort(u => u.last_login, "desc")
    |> take(20)
    |> map(u => { name: u.name, last_seen: u.last_login.format("YYYY-MM-DD") })

En partant de tous les utilisateurs, on filtre, trie, limite et transforme -- chaque étape clairement visible.

Session 150 : le tableau complet

L'opérateur pipeline était l'une des quatre fonctionnalités implémentées dans la Session 150. Les autres étaient la validation des contraintes de clause where, une correction de l'opérateur de coalescence nulle, et la vérification des paramètres rest. La session démontre un motif courant dans le développement de FLIN : combiner une nouvelle fonctionnalité significative avec des corrections de bogues ciblées et des vérifications de fonctionnalités.

Validation des clauses where

Avant la Session 150, les clauses where étaient analysées mais pas validées. La syntaxe fonctionnait :

flinfn max<T>(a: T, b: T) -> T where T: Comparable {
    if a > b { return a }
    return b
}

Mais la contrainte T: Comparable n'était pas vérifiée lors de l'appel de max. La Session 150 a corrigé cela en extrayant et fusionnant les contraintes à la fois de la syntaxe inline (<T: Comparable>) et de la syntaxe de clause where (where T: Comparable) :

rustfn extract_constraints(
    &self,
    type_params: &[TypeParam],
    where_clauses: &[(String, Vec<String>)],
) -> HashMap<String, Vec<String>> {
    let mut constraints: HashMap<String, Vec<String>> = HashMap::new();

    // Collect inline constraints
    for param in type_params {
        if !param.constraints.is_empty() {
            constraints.insert(param.name.clone(), param.constraints.clone());
        }
    }

    // Merge where clause constraints
    for (name, bounds) in where_clauses {
        constraints.entry(name.clone())
            .or_insert_with(Vec::new)
            .extend(bounds.clone());
    }

    constraints
}

Cette fonction fusionne les contraintes des deux sources. Un paramètre de type peut avoir des contraintes inline et des contraintes de clause where, et les deux sont validées aux sites d'appel.

Correction de la coalescence nulle

Un bogue subtil dans l'opérateur ?? : l'instruction JumpIfNone retirait la valeur de la pile, causant un dépassement de pile quand la valeur n'était pas none. La correction a ajouté une instruction Dup avant la vérification :

rustfn emit_nullish_coalesce(&mut self, left: &Expr, right: &Expr) {
    self.emit_expr(left);
    self.emit_op(OpCode::Dup);           // Keep a copy
    let jump = self.emit_jump_if_not_none();
    self.emit_op(OpCode::Pop);           // Discard the none
    self.emit_expr(right);               // Evaluate right side
    self.patch_jump(jump);
    // If not none: original value remains on stack
    // If none: right side value is on stack
}

C'est un bogue classique de VM -- gestion incorrecte de la pile dans une instruction de branchement. La correction fait deux lignes (Dup et Pop), mais trouver le bogue a nécessité de comprendre l'état exact de la pile à chaque point dans la séquence d'instructions.

Résultats des tests

La Session 150 a ajouté 16 nouveaux tests à travers les quatre fonctionnalités :

  • 4 tests de contraintes de clause where
  • 4 tests d'opérateur pipeline
  • 4 tests de correction de coalescence nulle
  • 4 tests de vérification de paramètres rest

Le nombre total de tests a atteint 1 879 (1 430 bibliothèque + 449 intégration). Tous passants.

Décisions de conception pour les pipelines

De gauche à droite, valeur d'abord. La valeur pipelinée devient le premier argument, pas le dernier. Cela suit la convention d'Elixir et fonctionne bien avec les signatures de fonctions de FLIN où l'argument de données vient typiquement en premier.

Désucrage au moment de l'analyse. L'opérateur pipeline n'existe pas dans l'AST. Il est transformé en appels de fonction pendant l'analyse. Cela élimine la complexité dans chaque passe en aval.

Pas de raccourci lambda. Certaines propositions de pipeline permettent value |> .method() ou value |> x => x.field. Nous avons choisi de ne pas les supporter initialement. La forme d'appel de fonction est claire et suffisante. Les raccourcis lambda pourront être ajoutés plus tard sans casser le code existant.

Pas d'interaction avec await. Dans les langages avec async/await, l'opérateur pipeline interagit parfois avec les promesses (value |> await asyncFn). FLIN reporte cette interaction, gardant le pipeline purement synchrone pour l'instant.

Le tableau d'ensemble

L'opérateur pipeline a complété une suite de fonctionnalités d'expérience développeur qui ont transformé l'apparence et le ressenti des programmes FLIN. La déstructuration (Session 097-098) a rendu l'extraction de données concise. L'opérateur Elvis (Session 097) a rendu les valeurs par défaut propres. L'opérateur pipeline (Session 150) a rendu les chaînes de transformation de données lisibles.

Ensemble, ces fonctionnalités permettent aux développeurs d'écrire du code qui se lit comme une description de ce qu'il fait, plutôt qu'une prescription de comment l'ordinateur devrait le faire. C'est l'essence de la philosophie de conception de FLIN : exprimer l'intention, pas le mécanisme.

Le prochain article couvre les tuples, enums et structs -- les primitives de structures de données sur lesquelles ces fonctionnalités ergonomiques opèrent.


Ceci est la partie 38 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et implémenté un langage de programmation à partir de zéro.

Navigation de la série : - [36] Types union étiquetés et types de données algébriques - [37] La déstructuration partout - [38] L'opérateur pipeline : composition fonctionnelle dans FLIN (vous êtes ici) - [39] Tuples, enums et structs - [40] Gardes de type et rétrécissement de type à l'exécution

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles