Menu

La capacité World en Zero : des E/S explicites sans globales

Zero n'a pas de stdout global, pas de système de fichiers ambiant, pas de réseau implicite. Tout ce qui touche au monde extérieur passe par une capacité World transmise à main. Voici pourquoi et comment.

Cette page contient des éditeurs exécutables — modifiez, exécutez et voyez la sortie instantanément.

L'idée en une phrase

Les programmes Zero font des E/S parce qu'on leur en donne la permission, pas parce qu'ils tendent la main vers une globale ambiante.

Cette permission est une valeur de type World. Le runtime en construit une avant d'appeler main, et votre programme la fait circuler (ou des morceaux d'elle) partout où il a besoin d'interagir avec le monde extérieur.

Pourquoi pas de globales ?

La plupart des langages laissent n'importe quelle fonction, n'importe où, écrire sur stdout ou ouvrir un fichier. JavaScript a console.log. Python a print. C a printf. Le confort est réel, mais le coût aussi : vous ne pouvez pas dire depuis la signature d'une fonction si elle pourrait faire des E/S. Pour le savoir, il faut lire le corps — récursivement.

Zero adopte une position différente. Il n'y a pas de print global. Il n'y a pas d'os.Stdout ambiant. Si votre fonction fait des E/S, ce fait doit apparaître dans sa signature, parce que la seule façon de faire des E/S, c'est qu'on vous ait remis une capacité.

Les bénéfices se voient à trois endroits :

  1. Lire une signature vous dit ce qu'une fonction peut faire. Une fonction qui ne mentionne pas World ne peut pas écrire sur stdout, ne peut pas ouvrir un socket, ne peut pas lire un fichier. Le système de types fait de ça une garantie dure.
  2. Tester du code pur est trivial. Les fonctions pures n'ont pas besoin de stubs print ou de systèmes de fichiers fictifs — elles n'y ont tout simplement pas accès.
  3. Les agents peuvent raisonner localement. Un agent IA qui génère ou répare du code Zero peut savoir — sans lire toute la base de code — si une fonction qu'il regarde a des effets.

L'usage canonique

Vous avez déjà vu la forme de base depuis hello-world :

Trois choses se passent :

  • main déclare son paramètre world: World. Le runtime remet au programme une valeur World et la lie ici.
  • world.out est le flux de sortie standard, exposé comme un champ de la capacité World.
  • world.out.write(...) écrit une chaîne. Elle retourne une valeur faillible (l'écriture pourrait échouer), que check propage.

Vous pouvez aussi renommer le paramètre — w: World ou io: World — mais world est la convention et vaut le coup d'être gardé pour cohérence avec l'écosystème Zero plus large.

Ce qui vit sur World

La capacité World expose les surfaces dont un programme pourrait avoir besoin. La forme exacte dépend de ce que le runtime supporte, mais vous pouvez vous attendre à des entrées pour :

  • world.out — sortie standard.
  • world.err — erreur standard.
  • world.in — entrée standard.
  • Un moyen d'ouvrir des fichiers, de lire des variables d'environnement et de se connecter au réseau.

Référez-vous à la doc actuelle de la bibliothèque standard Zero pour la liste faisant autorité des champs. Certaines surfaces (réseau, système de fichiers) peuvent vivre derrière des types de capacités plus restreints accessibles via World plutôt qu'au niveau supérieur.

Faire circuler des capacités dans le code

Le piège — le prix à payer pour des effets explicites — c'est que toute fonction qui doit faire des E/S doit recevoir la capacité. Vous ne pouvez pas la récupérer implicitement.

fun log(world: World, message: String) -> Void raises {
    check world.out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world, "starting\n")
    log(world, "done\n")
}

log prend world pour pouvoir écrire à travers. Si log ne prenait pas world, le corps ne pourrait pas appeler world.out.write — la liaison n'existerait pas.

C'est plus de plomberie de paramètres que dans un langage avec des E/S ambiantes. Le compromis, c'est que tout le graphe d'appels pour main est maintenant visible depuis les signatures seules :

  • main prend world, donc il pourrait faire des E/S.
  • log prend world, donc il pourrait faire des E/S.
  • Toute fonction sans world dans sa signature ne peut pas.

Capacités plus restreintes

Passer tout le World à chaque fonction est une approche brutale — c'est comme distribuer les privilèges root. Le motif que Zero encourage, c'est de ne prendre que la tranche de World dont vous avez réellement besoin :

fun log(out: Stream, message: String) -> Void raises {
    check out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world.out, "starting\n")
    log(world.out, "done\n")
}

Maintenant, log n'obtient qu'un accès à un Stream (le même type que world.out expose). Il peut écrire à travers, mais il ne peut pas ouvrir un fichier ni lire depuis le réseau. L'appelant a choisi ce que log a le droit de faire.

Les noms de types exacts que vous verrez dans du vrai code Zero (Stream, Writer, tranches de capacités) suivront le vocabulaire de la bibliothèque standard dans votre version de chaîne d'outils. Le motif — passer le minimum, pas le maximum — est universel.

Fonctions pures

Les fonctions qui n'ont pas besoin de World ne devraient pas prendre World. C'est en partie une préférence stylistique et en partie une contrainte du système de types : il n'y a pas moyen de faire des E/S sans capacité.

fun sum(point: Point) -> i32 {
    return point.x + point.y
}

sum est pure vis-à-vis du monde extérieur. Un appelant qui regarde la signature sait avec certitude que cette fonction n'imprimera rien, n'ouvrira pas de fichier, ne pinguera aucun serveur. C'est une propriété sur laquelle un analyseur statique (ou un agent) peut s'appuyer sans lire le corps.

Capacités et raises

Presque toute opération sur une capacité est faillible. world.out.write peut échouer parce que le flux est fermé. L'ouverture d'un fichier peut échouer parce que le fichier n'existe pas. La surface d'API de capacités est appariée avec raises et check — les opérations faillibles déclarent leurs modes d'échec dans leurs signatures, et les appelants les reconnaissent avec check.

La combinaison est le cœur de l'histoire des effets en Zero :

  • Ce qui peut arriver → raises { ... }.
  • À travers quoiWorld (ou une tranche).
  • → partout où la capacité et raises sont visibles.

C'est assez d'information pour raisonner précisément sur les effets d'une fonction depuis sa signature seule.

Une note sur le test

Les E/S à base de capacités rendent le test simple par construction. Vous voulez capturer la sortie d'une fonction sous test ? Passez-lui une fausse capacité out qui enregistre ce qu'on lui demande d'écrire. Vous voulez tester une fonction censée être pure ? Ne lui passez aucune capacité — sa signature ne lui permettra pas de toucher au monde extérieur.

La bibliothèque standard peut fournir des harnais de test qui construisent des capacités fictives ou en mémoire précisément à cet effet. L'API exacte évoluera avec le langage ; le principe (les capacités sont des valeurs qu'on peut substituer) est le levier.

La suite : raises et check

World couvre la moitié de l'histoire des effets — les surfaces qu'une fonction peut toucher. L'autre moitié, c'est l'échec : quand quelque chose tourne mal, comment se propage-t-il ? C'est l'objet du prochain document, Raises et Check.

Questions fréquentes

Qu'est-ce que le World en Zero ?

World est l'objet de capacité fourni par le runtime qui donne à un programme Zero accès au monde extérieur : stdout, stdin, fichiers, réseau, variables d'environnement, etc. Le runtime construit une valeur World et la passe à main. Les fonctions qui doivent faire des E/S doivent recevoir le World (ou une tranche plus restreinte) — il n'y a pas d'échappatoire globale.

Pourquoi main prend-il un paramètre World ?

Zero n'a pas de globales ambiantes. Il n'y a pas d'équivalent de printf, console.log ou os.Stdout que n'importe quelle fonction pourrait appeler sans permission. Le runtime remet à main une capacité World et main (ainsi que toute fonction qu'il appelle) ne peut faire des E/S qu'à travers cette valeur. Ça rend chaque effet visible dans la signature d'une fonction.

En quoi les E/S à base de capacités diffèrent-elles des E/S classiques ?

Dans la plupart des langages, les E/S sont implicites — n'importe quelle fonction peut écrire sur stdout ou lire le système de fichiers à tout moment. Les E/S à base de capacités font de la permission de faire des E/S une valeur : il faut qu'on vous donne un World (ou une tranche de celui-ci) pour vous en servir. Les fonctions de calcul pures ne prennent pas World et ne peuvent donc littéralement pas faire d'E/S, ce que le système de types impose.

Peut-on récupérer le World implicitement quelque part au fond de la pile d'appels ?

Non, par conception. Si un utilitaire au fond de la pile d'appels doit écrire sur stdout, vous devez lui passer le World — ou une capacité plus restreinte — explicitement. C'est plus de plomberie de paramètres que dans un langage avec des E/S ambiantes, mais c'est le prix à payer pour pouvoir lire la signature d'une fonction et savoir si elle pourrait faire des E/S.

Que fait world.out.write ?

world.out.write("text\n") écrit la chaîne donnée dans le flux de sortie standard du programme à travers la capacité fournie par le runtime. Elle renvoie une valeur faillible — l'écriture pourrait échouer — donc vous emballez l'appel avec check pour propager l'erreur vers le haut de la pile d'appels.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER