Tagged Unions
Le mot-clé union déclare un type somme : un ensemble fini de variantes nommées, chacune pouvant porter des champs
typés.
union Option {
Some(value);
None;
}
Là où enum ne nomme que des constantes (variantes nullaires uniquement), union permet à certaines variantes de
transporter une charge utile (payload) qu'on récupère par destructuration dans match.
Une
unionCatnip est toujours taggée et fermée. Pas d'union mémoire à la C : chaque valeur sait quelle variante elle est.
Construction
Les variantes sont toujours qualifiées par le nom de l'union :
union Option {
Some(value);
None;
}
x = Option.Some(42)
y = Option.None
Option.Some(42)appelle le constructeur de la varianteSomeavec un argumentvalue=42.Option.Noneest un singleton -- pas d'appel, juste la valeur.
Accéder à une variante inexistante lève une erreur :
union Option { Some(value); None }
Option.Maybe # Erreur : union 'Option' has no variant 'Maybe'
Variantes avec et sans payload
Une union peut mélanger les deux dans la même déclaration :
union Event {
Click(x, y);
KeyPress(code);
Quit;
}
e = Event.Click(10, 20)
q = Event.Quit
Quand toutes les variantes sont nullaires, enum reste préférable :
enum Color { red; green; blue }
Critère pratique : enum pour des constantes nommées, union pour une forme de donnée qui peut transporter une charge
utile.
Champs de payload typés
Un champ de payload peut porter une annotation, comme un champ de struct. Le type est un contrat vérifié à la
construction de la variante : un mismatch prouvable est une erreur statique (E300), une valeur non prouvable (issue
d'un appel, d'une frontière Python) échoue en TypeError au moment de la construction.
union Shape {
Circle(radius: int);
Rect(w: int, h: int);
}
Shape.Circle(3) # accepté
Shape.Rect(4, 5) # accepté
Shape.Circle(1.5) # E300 : le champ 'radius' attend 'int', reçoit 'float'
La tour numérique s'applique comme pour les struct : un int fourni à un champ : float est coercé (Circle(3) avec
radius: float stocke 3.0).
Un champ dont le type est un paramètre générique (Some(value: T)) fait exception : T n'est pas fixé à la
construction, donc le contrat s'applique plus loin, au franchissement d'une frontière typée (voir ci-dessous).
Un champ annoté mais non vérifié n'est pas typé, il est décoré.
Paramètres génériques
Les unions peuvent déclarer des paramètres de type entre crochets :
union Option[T] {
Some(value: T);
None;
}
union Result[T, E] {
Ok(value: T);
Err(error: E);
}
Une annotation générique est un contrat vérifié. x: Option[int] exige que la valeur soit une Option et, si c'est
un variant à payload dont le champ est le paramètre T, que ce payload satisfasse int (substitution T := int) :
union Option[T] { Some(value: T); None }
f = (x: Option[int]) => { 1 }
f(Option.Some(42)) # accepté
f(Option.None) # accepté (pas de payload à vérifier)
f(Option.Some("nope")) # TypeError : le payload est str, pas int
La vérification s'applique aux positions annotées : paramètres, champs de struct, champs de payload, type de retour.
L'arité fait partie du contrat -- Option[int, str] (deux arguments pour un seul paramètre) est une erreur statique. Un
champ paramètre (value: T) reste non typé à la construction (Option.Some("x") construit sans erreur : il vaut
Option[str]) ; T n'étant pas fixé, c'est au franchissement d'une frontière typée que son contrat s'applique. Un
champ concret (x: int), lui, est vérifié dès la construction (voir « Champs de payload typés »).
La substitution v1 couvre les champs qui sont un paramètre direct (
value: T). Un paramètre imbriqué dans un composite (items: list[T]) vérifie le conteneur, pas encore l'élément.
Les variantes elles-mêmes ne déclarent pas de paramètres : Some[T](value: T) est refusé. Le paramétrage décrit l'union
complète, pas chaque constructeur.
Pattern matching
Une variante avec payload se matche avec la syntaxe Union.Variant{field, ...}, identique aux struct patterns :
union Option {
Some(value);
None;
}
opt = Option.Some(42)
match opt {
Option.Some{value} => { print("got", value) }
Option.None => { print("nothing") }
}
# → got 42
Une variante nullaire se matche sans accolades (forme Union.Variant), identique aux variantes d'enum :
match Event.Quit {
Event.Click{x, y} => { print("click at", x, y) }
Event.KeyPress{code} => { print("key", code) }
Event.Quit => { print("bye") }
}
Les champs de la variante sont reliés en variables locales du bloc de match :
valuedansOption.Some{value}capture la charge utile dans une variablevalue.
Méthodes
Une union peut déclarer des méthodes, au niveau de l'union -- jamais par variante. self reçoit la variante sur
laquelle la méthode est appelée, quelle qu'elle soit, et le corps discrimine par match :
union Option {
Some(value);
None;
map(self, f) => {
match self {
Option.Some{value} => { Option.Some(f(value)) }
Option.None => { Option.None }
}
}
unwrap_or(self, default) => {
match self {
Option.Some{value} => { value }
Option.None => { default }
}
}
}
Option.Some(21).map((x) => { x * 2 }).unwrap_or(0) # → 42
Option.None.unwrap_or(-1) # → -1
Cette forme donne :
- une seule forme de code pour toutes les variantes, payload ou nullaire ;
- pas de dispatch caché : la logique par variante est lisible dans le corps, au même endroit ;
- l'exhaustivité du linter s'applique aux corps -- dans une méthode, le type de
selfest connu, donc unmatch selfincomplet émetI103avec les variantes manquantes.
Les méthodes ne mutent jamais self : les unions sont immutables, une méthode retourne une valeur. map ci-dessus ne
modifie pas l'Option, elle en construit une nouvelle.
selfest la seule variable du langage qui ne sait pas qui elle est avant de se matcher elle-même.
Différences avec les méthodes de struct :
- Pas d'
init: les constructeurs d'une union sont ses variantes. - Pas d'
@abstract: une union n'a pas d'héritage, le corps est toujours obligatoire. - Pas de décorateurs ni d'
op: une méthode statique ou un opérateur surchargé introduirait un second espace de noms surUnion.Xet un second mécanisme de dispatch ; le MVP garde une seule forme.
Méthodes et variantes partagent l'espace de noms Union.x : un nom de méthode identique à un nom de variante est rejeté
à la compilation, comme un nom dupliqué.
Égalité
L'égalité est structurelle :
- Deux variantes du même nom avec les mêmes champs sont égales.
- Deux variantes différentes ne le sont jamais, même si elles portent les mêmes données.
Option.Some(1) == Option.Some(1)
# → True
Option.Some(1) == Option.Some(2)
# → False
union Box { A(v); B(v) }
Box.A(1) == Box.B(1)
# → False (variantes différentes)
Hash
Toutes les variantes sont hashables, payload incluse. Les nullaires passent par CatnipEnumVariant, les payloads par
CatnipStructProxy (hash structural : nom de variante + hash de chaque champ).
union Event { Click(x, y); Quit; }
s = set()
s.add(Event.Quit) # variante nullaire
s.add(Event.Click(1, 2)) # variante payload
Event.Click(1, 2) in s # → True
Le contrat hash/eq des structs s'applique : voir STRUCTURES.md pour les règles
(op_hash/op ==, freeze-on-hash).
Truthiness
Toutes les variantes -- y compris les nullaires comme None, Quit, Eof -- sont truthy. Aucune n'est falsy par
défaut.
union Option { Some(value); None }
if Option.None { "oui" } else { "non" }
# → "oui"
Pour tester l'absence de valeur, utiliser explicitement match ou la comparaison directe :
opt = Option.None
match opt {
Option.None => { "absent" }
Option.Some{value} => { f"présent: {value}" }
}
Cohabitation avec enum
enum et union cohabitent. Le critère est simple :
| Cas | Outil |
|---|---|
| Constantes nommées | enum |
| Variantes avec payload | union |
| Mix nullaires + avec payload | union |
Une union entièrement nullaire est autorisée par le parseur, mais le linter peut suggérer enum à la place.
Mode d'exécution
Le runtime des union est câblé sur les trois exécuteurs :
- Mode VM (défaut) : opcode
MakeUnionqui matérialise le namespace au démarrage du module. - Mode AST (
catnip -x ast) :op_uniondans le registry, même logique partagée. - PureVM (MCP, runtime pur sans Python) : même opcode
MakeUnion, décodé parhandle_make_union-- variantes à payload en struct types qualifiés, nullaires en symboles internés.
Différences avec enum et struct
| Propriété | enum |
struct |
union |
|---|---|---|---|
| Contenu | Variantes nullaires | Champs typés | Variantes avec/sans payload |
| Instanciation | Color.red |
Point(1, 2) |
Option.Some(42), Option.None |
| Pattern matching | Color.red => {...} |
Point{x, y} => {...} |
Option.Some{value} => {...} |
| Égalité | Même variante = égal | Même champs = égal | Variante + champs structurels |
| Hash | Structurel | Structurel | Structurel |
| Truthiness | Toujours truthy | Toujours truthy | Toujours truthy |
| Mutation | Non (valeur fixe) | Oui (p.x = 5) |
Non (variante figée, payload local) |
| Méthodes | Non | Oui | Oui (niveau union, match self) |
| Génériques | Non | Pas encore | Oui (Option[T], vérifié au boundary) |
Exhaustivité (linter)
Le linter vérifie statiquement qu'un match sur une union couvre toutes les variantes. La règle (I103) s'applique dès
que le type du scrutinee peut être inféré -- assignation directe à une variante (x = Option.Some(...)), accès à
l'union (Option.None), ou variable typée dans le scope courant.
union Option { Some(value); None; }
x = Option.Some(1)
# Manque Option.None -- émet I103
match x {
Option.Some{value} => { value }
}
Les patterns OR comptent normalement : Event.Quit | Event.Reset => { ... } couvre les deux variantes. Un wildcard
final (_ => { ... }) supprime l'avertissement.
Limitations (MVP)
- Champ paramètre non fixé à la construction : un champ dont le type est un paramètre générique
(
Some(value: T)) n'est pas vérifié au constructeur ; son contrat s'applique au franchissement d'une frontière typée (voir « Champs de payload typés » et « Paramètres génériques »). Un champ concret (x: int) est vérifié dès la construction. - Paramètre imbriqué dans un composite :
items: list[T]vérifie le conteneur, pas encore l'élément. - Traits /
implements: non supportés.