Broadcasting

Cette page définit la syntaxe et la sémantique du broadcast. La motivation, le guide progressif et le runtime couvrent respectivement le choix de conception, les recettes et l'implémentation.

Formes

Syntaxe Mode Résultat
target.[op operand] map binaire une valeur transformée par feuille
target.[op] ou target.[f] map unaire une valeur transformée par feuille
target.[if op operand] filtre éléments du premier niveau qui satisfont le prédicat
target.[if f] filtre par fonction éléments du premier niveau pour lesquels f est vrai
target.[mask] masque booléen éléments sélectionnés au premier niveau
target.[~> f] ND-map f appliquée à chaque feuille
target.[~~ callback] ND-récursion callback sur chaque élément du premier niveau, avec recur
data = list(1, 2, 3, 4)

data.[* 2]          # → [2, 4, 6, 8]
data.[> 2]          # → [False, False, True, True]
data.[if > 2]       # → [3, 4]
data.[list(True, False, True, False)]  # → [1, 3]
data.[(x) => { x ** 2 }]               # → [1, 4, 9, 16]

La priorité est celle d'un accès membre : 2 + data.[* 3] signifie 2 + (data.[* 3]). Les broadcasts peuvent être chaînés de gauche à droite.

L'objet reste à gauche et le parcours reste dans les crochets. Deux dimensions grammaticales, un seul sens de lecture.

Map et descente

Le map descend récursivement dans les cibles itérables jusqu'aux feuilles. Les types suivants sont toujours des feuilles, même s'ils sont itérables côté Python : str, bytes et struct. Les nombres, booléens, None et objets non-itérables sont également des feuilles.

Cible Parcours Type du résultat
list éléments, avec descente récursive list
tuple éléments, avec descente récursive tuple
set éléments, ordre non garanti, avec descente list
dict clés, avec descente list
autre itérable, dont range éléments, avec descente list
scalaire ou struct application directe résultat de l'opération

Seuls list et tuple préservent leur type à chaque niveau. Les autres itérables sont normalisés en list ; un dict fournit ses clés comme une boucle Python.

list(1, list(2, 3)).[+ 10]  # → [11, [12, 13]]
tuple(1, tuple(2, 3)).[* 2] # résultat imbriqué de type tuple
set(10, 20).[* 2]          # résultat de type list, ordre non garanti
dict(a=1).[* 3]            # → ["aaa"]
range(3).[+ 10]            # → [10, 11, 12]
"hi".[* 2]                 # → "hihi"

Opérande vectoriel

Pour un map binaire dont l'opérande est une list ou un tuple :

  • une cible plate est zippée élément par élément ;
  • une cible dont le premier élément est une list ou un tuple propage l'opérande dans chaque sous-collection ;
  • les tailles comparées doivent être égales, sinon l'opération lève ValueError.
list(1, 2, 3).[+ list(10, 20, 30)]  # → [11, 22, 33]

matrix = list(list(1, 2), list(3, 4))
matrix.[* list(10, 100)]             # → [[10, 200], [30, 400]]

Cette règle vectorielle ne s'applique pas au filtre. a.[if > b] compare chaque élément de a à la collection b entière et propage l'erreur de l'opérateur.

Filtre

Le filtre teste chaque élément du premier niveau et conserve l'élément original lorsque le prédicat est vrai. Il ne descend pas dans les sous-collections : supprimer des feuilles rendrait la forme du résultat ambiguë.

data = list(3, 8, 2, 9, 5)

data.[> 5]      # → [False, True, False, True, False]
data.[if > 5]   # → [8, 9]

matrix = list(list(1, 2), list(3, 4))
matrix.[if > 2] # TypeError : le prédicat reçoit la première sous-liste

Un filtre sur tuple retourne un tuple; les autres itérables retournent une list. Sur un scalaire, le résultat est une liste vide ou une liste contenant le scalaire :

5.[if > 0]  # → [5]
5.[if < 0]  # → []

Pour filtrer chaque sous-collection, exprimer le niveau explicitement avec une boucle ou fold.

Le map descend parce qu'il remplace une feuille par une feuille. Le filtre reste au niveau demandé parce qu'une absence n'a pas de coordonnées.

Masque booléen

Un masque est une list ou un tuple composé uniquement de booléens. Sa longueur doit égaler celle de la cible :

data = list(10, 20, 30, 40)
mask = list(True, False, True, False)
data.[mask]  # → [10, 30]

Une longueur différente lève ValueError. Une collection contenant autre chose que des booléens n'est pas un masque et ne reçoit pas cette sémantique. Le type tuple de la cible est préservé ; les autres itérables retournent une list.

ND-map et ND-récursion

~> suit la même descente implicite que le map et appelle la fonction sur chaque feuille :

matrix = list(list(-1, 2), list(-3, 4))
matrix.[~> abs]  # → [[1, 2], [3, 4]]

~~ ne descend pas avant l'appel. Le callback reçoit chaque élément du premier niveau ainsi que recur, et choisit la récursion :

nums = list(3, 5)
nums.[~~(n, recur) => {
    if n <= 1 { 1 }
    else { n * recur(n - 1) }
}]
# → [6, 120]

Sur une cible plate les deux formes peuvent produire le même résultat. Sur une cible imbriquée, ~> voit les feuilles alors que ~~ voit les sous-collections.

La forme .[.[...]] reste valide pour forcer une descente niveau par niveau, mais le map ordinaire et ~> n'en ont pas besoin.

Opérations

Les maps binaires acceptent les opérateurs arithmétiques, de comparaison, logiques et bitwise disponibles sur les opérandes. Les fonctions et lambdas servent de maps unaires. Les filtres acceptent les comparaisons ou une fonction retournant une valeur truthy :

values.[+ 10]
values.[** 2]
values.[== 0]
values.[if != 0]
values.[if (x) => { x > 0 and x < 5 }]

Les erreurs de l'opération sont propagées au premier élément concerné. Le broadcast n'insère pas de conversion de type ni de valeur de remplacement.

Structs et frontière d'exécution

Une struct est une feuille. Un callback exécuté à travers la frontière Python reçoit une copie privée de l'instance : muter cette copie ne modifie pas la collection source; retourner la copie mutée l'insère dans le résultat.

struct Point { x }
points = list(Point(1), Point(2))

points.[(p) => {
    p.x = p.x * 10
    p
}]
# points reste inchangé ; le résultat contient Point(10), Point(20)

La copie descend dans les champs struct imbriqués et conserve les identités partagées ou cycles entre structs. Les champs non-struct, par exemple listes ou dictionnaires, restent partagés par référence. Les appels directs entièrement internes à Catnip conservent l'instance partagée ; la copie protège la frontière vers Python et les workers de broadcast.

Cette règle rend le résultat indépendant de l'ordre des workers en modes thread et process.

Règles formelles

  1. Descente : map et ~> parcourent récursivement toute cible itérable jusqu'aux feuilles ; filtre et masque opèrent sur un seul niveau ; ~~ délègue la descente au callback.
  2. Conteneurs : list et tuple préservent leur type ; les autres itérables produisent une list ; un dict fournit ses clés.
  3. Taille : un map conserve la cardinalité de chaque conteneur ; un filtre ou masque peut la réduire.
  4. Composition pure : A.[f].[g] équivaut à A.[(x) => { g(f(x)) }] lorsque f et g sont pures.
  5. Déterminisme : pour des fonctions pures, le résultat ne dépend pas de l'ordre interne de traitement. L'ordre d'un set n'est toutefois pas garanti.

Traiter une collection comme une feuille

Un tuple, un dict ou un set placé dans une liste reste traversable. Si la fonction doit recevoir cet objet entier, utiliser une struct ou une boucle explicite :

struct Metric { label; value }
metrics = list(Metric("A", 1), Metric("B", 2))
metrics.[(m) => { f"{m.label}: {m.value}" }]

Le broadcast est un opérateur dimensionnel. Une collection opaque n'est pas une collection mieux cachée ; c'est une donnée avec une frontière explicite.