Pourquoi Écrire des Nouveaux Langages de Programmation

de Dominic Burkart
25 Novembre 2022

une crabe peint avec des grandes touches de jaune, orange, et des traces de rouge se répose sur un champs des fleurs violettes et encore jaunes sous un ciel bleu clair et blanc

« a painting of a crab made out of marigold flowers in the style of claude monet » fait avec DALL·E

Quand j'ai dit à mes amis en informatique que j'ai commencé à écrire Marigold, un langage spécifique aux flux des données, quelques-uns étaient enthousiastes, d'autres perplexes. Écrire un nouveau langage de programmation pourrait sembler loin des intérêts des programmeurs : des technologies existantes partagées et maintenues par des communautés de développeurs sont déjà plus riches en fonctionnalités, mieux documentées et testées, et déjà connues par d’autres ingénieurs. Du coup, quel serait l'intérêt d'en écrire un nouveau ?

Des langages plus ou moins spécifiques sont créés pour simplifier moult domaines : n'importe quel langage de programmation pourrait être cité comme exemple.1 Si un langage existait qui faisait déjà le travail de SQL, ou Go, ou Rust, ces langages n'auraient jamais été écrits ni adoptés : les nouveaux langages sont écrits par besoin, car n'importe quel langage existant n'est approprié. La question est alors si Marigold répond à un besoin important.

Souvent, les besoins sont commerciaux. Dans le domaine des logs et de l’observabilité, chez Datadog, le langage de recherche des evenements (version archivée) facilite des requêtes qui seraient sinon complexes à écrire, avec divers homologues dans l'industrie, dont LogQL de Grafana et SPL de Splunk. Bien que Datadog et ses concurrents auraient pu demander aux clients de faire des requêtes en JSON ou similaire, ils ont estimé qu'il était approprié d'écrire un langage adapté à leurs produits et leurs infrastructures.

Mais l'élan de Marigold n'était pas commercial. C'était technique. J'aime écrire en Rust, mais je trouve que l'écosystème async est toujours jeune. Les packages ne sont pas forcement stable ni facile à composer, et c'est difficile même pour des ingénieurs experimentés en Rust de raisonner comment de gérer les complexités des lifetimes et des types en async.

Par exemple, voici un bout de code qui vient de la documentation de csv-async, un bon package pour opérer sur les fichiers CSV :

#[cfg(not(feature = "tokio"))]
use async_std::fs::File;
#[cfg(not(feature = "tokio"))]
use futures::stream::StreamExt;
#[cfg(feature = "with_serde")]
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::process;
#[cfg(feature = "tokio")]
use tokio::fs::File;
#[cfg(feature = "tokio")]
use tokio1 as tokio;
#[cfg(feature = "tokio")]
use tokio_stream::StreamExt;

#[cfg(feature = "with_serde")]
#[derive(Deserialize, Serialize)]
struct Row {
    city: String,
    region: String,
    country: String,
    population: u64,
}

#[cfg(feature = "with_serde")]
async fn filter_by_region_serde(
    region: &str,
    file_in: &str,
    file_out: &str,
) -> Result<(), Box<dyn Error>> {
    let mut rdr = csv_async::AsyncDeserializer::from_reader(
        File::open(file_in).await?,
    );
    let mut wri = csv_async::AsyncSerializer::from_writer(
        File::create(file_out).await?,
    );
    let mut records = rdr.deserialize::<Row>();
    while let Some(record) = records.next().await {
        let record = record?;
        if record.region == region {
            wri.serialize(&record).await?;
        }
    }
    Ok(())
}

#[cfg(feature = "with_serde")]
#[cfg(not(feature = "tokio"))]
fn main() {
    async_std::task::block_on(async {
        if let Err(err) = filter_by_region_serde(
            "MA",
            "/tmp/all_regions.csv",
            "/tmp/MA_only.csv",
        )
        .await
        {
            eprintln!(
                "error running filter_by_region_serde: {}",
                err
            );
            process::exit(1);
        }
    });
}

#[cfg(feature = "with_serde")]
#[cfg(feature = "tokio")]
fn main() {
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        if let Err(err) = filter_by_region_serde(
            "MA",
            "/tmp/all_regions.csv",
            "/tmp/MA_only.csv",
        )
        .await
        {
            eprintln!(
                "error running filter_by_region_serde: {}",
                err
            );
            process::exit(1);
        }
    });
}

#[cfg(not(feature = "with_serde"))]
fn main() {}

Le code fonctionne bien, mais il est long. Plusieurs implémentations doit être écrites pour soutenir differents packages (tokio, serde, etc.).

Souvent en Rust, le coût pour écrire un projet qui lit et transforme des données en async ne vaut pas la peine. Cependant, les packages pour faire des serveurs en Rust async sont bien stables et performants, ce qui facilite la création des applications plus évolutives et rapides. À temps, l'écosysteme et le système de types de Rust async les rattraperont.

Mais pour l'instant, ils ne sont pas si mûr. Les grands projets en Rust ont la tendance d'éviter async si possible. Autres langages comme Go sont plus souvent selectionnés pour des projets très asynchrones. L'objectif de Marigold est de montrer qu'on peut utiliser l'efficacité de Rust async dans les pipelines des données sans la complexité qui fait hésiter autant d'ingénieurs.

Voici l'équivalent du bout de code de csv-async écrit en Marigold :

enum Region {
  Massachusetts = "MA",
  default Other
}

enum Country {
  UnitedStates = "United States",
  default Other
}

struct City {
  name: string_25,
  region: Region,
  country: Country,
  population: u32
}

fn in_massachusetts(city: &City) -> bool {
  match city.region {
    Region::Massachusetts => true,
    _ => false,
  }
}

read_file(
  "/tmp/all_regions.csv",
  csv,
  struct=City
)
  .ok_or_panic()
  .filter(in_massachusetts)
  .write_file("/tmp/MA_only.csv", csv)

Bien plus est internalisé dans la langue. En Marigold, Serde est requis et compilé dès qu'on peut lire et écrire des fichiers (et pas sinon). Les implémentations spécifiques aux tokio et async-std, deux packages « runtime » qui gèrent le fonctionnement des programmes async Rust, ne sont pas réécrites dans chaque programme. D'ajouter la compression nécessite la modification de qu'une seule ligne de code. Marigold gère aussi le modèle de parallélisation (ou simultanéité, parmi le runtime disponible) qui évite une classe d'erreur qui existe en Rust async. Marigold peut être utilisé pour faire une application, ou peut être intégré dans une application Rust existante.

Marigold est censé minimiser la complexité des opérations async en limitant ce qui est possible : Rust est un langage de programmation général et du coup très polyvalent. Marigold, en tant que langage spécifique aux flux des données, rend facile des opérations sur des flux (et uniquement ces opérations). La grammaire est dédiée aux transformations des flux, la dé•sérialisation est intégrée dans la déclaration des structs et enums, et les types sont adaptés aux particularités des programmes async.

Écrire un langage de programmation pourrait rendre facile un pattern de programmation qui serait sinon complexe. Cela :

C'est loin d'être la seule façon de faciliter le travail d'un programmeur, mais cela mérite sa place dans la boîte à outils. ☄︎

1 : Sauf les langages exotiques (les langues qui n'étaient pas conçues d'être pratiques).