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
listou untuplepropage 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
- 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. - Conteneurs :
listettuplepréservent leur type ; les autres itérables produisent unelist; undictfournit ses clés. - Taille : un map conserve la cardinalité de chaque conteneur ; un filtre ou masque peut la réduire.
- Composition pure :
A.[f].[g]équivaut àA.[(x) => { g(f(x)) }]lorsquefetgsont pures. - Déterminisme : pour des fonctions pures, le résultat ne dépend pas de l'ordre interne de traitement. L'ordre d'un
setn'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.