Structures et Traits
Sommaire
- Caractéristiques
- Valeurs par défaut
- Références cycliques
- Méthodes
- Résolution de méthodes
- Surcharge d'opérateurs
- Égalité et comparaison
- Appartenance
- Opérateurs unaires
- Dispatch inverse (reverse operators)
- Hashabilité
- Méthodes statiques
- Méthodes abstraites
- Constructeur init
- Héritage
- Accès au parent (super)
- Héritage multiple
- Traits
- Définition d'un trait
- Implémentation de traits
- Héritage de traits
- Méthodes abstraites dans les traits
- Méthodes statiques dans les traits
- Composition multiple et conflits
- Diamonds
- Combiner héritage et traits
Le mot-clé struct déclare un type avec des champs nommés. Le type s'instancie comme une fonction :
struct Point { x; y; }
p1 = Point(10, 20)
p2 = Point(x=5, y=15)
p1.x
# ⇒ 10
p2.y
# ⇒ 15
Caractéristiques
Les structures sont des types natifs Rust avec accès aux champs en O(1). Propriétés :
- Attributs mutables : les champs peuvent être modifiés après création. Un champ annoté garde son contrat à l'écriture comme au constructeur — même refus, même conversion — de sorte que sa déclaration décrit ce qu'il contient et pas seulement la façon dont il a été rempli (annotations de type)
- Représentation automatique :
str()etrepr()affichent la structure avec ses valeurs (Point(x=1, y=2)) - Introspection :
dir()retourne les champs, méthodes et méthodes statiques (utilisé par la complétion REPL) - Égalité structurelle : deux instances du même type et de mêmes valeurs sont égales. « Même type » se lit par
identité de forme (nom et forme : champs, méthodes, hiérarchie), pas par le nom seul — deux
struct P { x }etstruct P { y }ne sont pas le même type, et deux déclarations identiques le sont - Un nom lié à une structure désigne une forme : après
A = P, redéclarerPsous une autre forme ne change pas ce queAconstruit — c'est un autre type. RedéclarerPà la même forme, en revanche, désigne le même type, etA(...)suit alors la déclaration courante : corps des méthodes, valeurs par défaut etiniten viennent, puisque ce sont précisément les choses que l'identité de forme ne compare pas. Une structure venue d'un module importé garde la sienne dans tous les cas — même nom et même forme ne font pas le même type quand ce n'est pas le même module - Validation des arguments : erreurs claires si arguments manquants ou en trop
- Un champ ou une méthode ne peut pas porter un nom d'introspection (
__class__,__dict__, …) : le membre serait construit sans jamais pouvoir être relu, la lecture étant refusée. La liste et la raison sont dans Expressions ; la même règle vaut pour les traits, les énumérations et les unions - La déclaration n'est pas locale au corps qui la porte : un
structdéclaré dans un bloc, une lambda ou une boucle reste joignable après, et redéfinit son nom pour la suite du programme. Les liaisons de valeurs suivent la règle inverse — détail et exemples vérifiés dans SCOPES_AND_VARIABLES
struct Color { r; g; b; }
# Mutation
c = Color(255, 0, 0)
c.g = 128
print(c) # → Color(r=255, g=128, b=0)
# Égalité
c1 = Color(100, 100, 100)
c2 = Color(100, 100, 100)
print(c1 == c2) # → True
Valeurs par défaut
Les champs de structure supportent des valeurs par défaut, avec la même syntaxe que les paramètres de fonctions :
struct Point { x; y = 0; }
Point(5) # → Point(x=5, y=0)
Point(1, 2) # → Point(x=1, y=2)
Point(x=3) # → Point(x=3, y=0)
Un argument nommé qui vise un champ déjà rempli par un argument positionnel est une erreur (même règle qu'en Python) :
struct Point { x; y = 0; }
Point(5, x=3) # TypeError: Point() got multiple values for argument 'x'
Les champs sans défaut doivent précéder ceux avec défaut :
struct Config { host; port = 8080; debug = False; }
Config("localhost") # Config(host="localhost", port=8080, debug=False)
Config("0.0.0.0", 3000, True) # Config(host="0.0.0.0", port=3000, debug=True)
Si tous les champs ont un défaut, l'instanciation sans argument est possible :
struct Opts { verbose = False; retries = 3; }
Opts() # → Opts(verbose=False, retries=3)
Si un champ requis arrive après un champ optionnel, le parseur refuse. Même dans le futur, l'ordre des paramètres reste une loi locale.
Les champs peuvent contenir toute valeur, y compris une autre structure ou une collection :
struct Vector2D { x; y; }
struct Particle { position; velocity; mass; }
v = Vector2D(10, 20)
p = Particle(
Vector2D(0, 0),
Vector2D(5, 10),
1.5
)
p.velocity.x
# ⇒ 5
Références cycliques
Un champ peut pointer sur l'instance qui le porte, directement ou par une chaîne d'instances. Listes chaînées, arbres avec lien vers le parent et graphes se déclarent sans construction dédiée :
struct Node { value; next; }
a = Node(1, None)
b = Node(2, None)
a.next = b
b.next = a
print(a.next.value) # → 2
print(a.next.next.value) # → 1 (le cycle est refermé)
Le cycle est préservé jusqu'à Python : la valeur rendue par un champ qui boucle est l'instance elle-même, pas une copie. Une instance ne produit qu'un objet, quel que soit le nombre de chemins qui y mènent. Une structure confiée à une fonction Python reste elle aussi la même instance : ce que cette fonction y écrit se lit ensuite côté Catnip.
La mémoire d'un cycle devenu inatteignable est rendue : le compte de références seul ne peut pas défaire une boucle, un
balayage s'en charge. Il ne suit que les champs, donc un cycle refermé par une liste, un dictionnaire ou une closure —
a.enfants = [b] avec b.parent = a — est conservé jusqu'à la fin du programme.
Une structure qui se contient elle-même ne contient rien de plus : la boucle n'ajoute pas de matière, seulement un chemin de retour.
Méthodes
Les structures peuvent définir des méthodes inline avec un paramètre self explicite :
struct Point {
x; y;
distance(self, other) => {
sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
}
translate(self, dx, dy) => {
Point(self.x + dx, self.y + dy)
}
}
a = Point(0, 0)
b = Point(3, 4)
print(a.distance(b)) # 5.0
print(a.translate(1, 2)) # Point(x=1, y=2)
Les méthodes sont déclarées après les champs, avec la syntaxe nom(self, ...) => { corps }. Le premier paramètre
(self) est lié automatiquement à l'instance lors de l'appel, via le protocole descripteur Python (__get__).
Le point-virgule (;) après chaque champ est optionnel :
struct Point {
x; y;
sum(self) => { self.x + self.y }
}
Point(3, 4).sum() # → 7
Les méthodes respectent le lexical scoping : elles peuvent capturer des variables locales du scope englobant.
make_point_type = () => {
offset = 10
struct Point {
x
shifted(self) => { self.x + offset }
}
Point
}
P = make_point_type()
P(3).shifted() # → 13
Une méthode est une fonction attachée à la
struct.selfdésigne l'instance courante: protagoniste local, budget infini en parenthèses.
Résolution de méthodes
Quand instance.nom(...) est appelé, le dispatch suit cette chaîne :
Si nom désigne un champ dont la valeur est appelable (une lambda stockée à la construction), il est appelé
directement, sans liaison de self : un champ appelable a priorité sur une méthode de même nom. Sinon le dispatch
cherche une méthode. Les méthodes propres de la structure ont priorité sur l'héritage, qui suit l'ordre C3 (MRO). Les
traits sont intégrés après les parents directs. En cas de conflit entre traits, un override explicite est requis.
struct Handler {
on_click # champ tenant une fonction
label(self) => { "handler" }
}
h = Handler((event) => { event + 1 })
h.on_click(41) # 42 — le champ est appelé, sans self
h.label() # "handler" — la méthode reçoit self
Un champ qui tient une fonction n'est pas une méthode déguisée : personne ne lui passe l'instance, il ne sait rien d'elle.
Surcharge d'opérateurs
La syntaxe op <symbole> définit le comportement d'un opérateur pour une structure. Quand l'opérateur est appliqué à
une instance, le dispatch cherche la méthode correspondante et l'appelle.
La signature dépend de la famille et du nombre de paramètres :
| Famille | Syntaxes | Signature |
|---|---|---|
| Arithmétique | +, -, *, /, //, %, ** |
(self, rhs) |
| Comparaison | ==, !=, <, <=, >, >= |
(self, rhs) |
| Bitwise | &, \|, ^, <<, >> |
(self, rhs) |
| Appartenance | in, not in |
(self, item) |
| Unaire | -, +, ~ |
(self) |
struct Vec2 {
x; y;
op +(self, rhs) => { Vec2(self.x + rhs.x, self.y + rhs.y) }
op *(self, rhs) => { Vec2(self.x * rhs, self.y * rhs) }
}
a = Vec2(1, 2)
b = Vec2(3, 4)
a + b
# ⇒ Vec2(x=4, y=6)
a * 3
# ⇒ Vec2(x=3, y=6)
a + b + a # Vec2(x=5, y=8) - chaînage par fold left
Si la méthode n'est pas définie, l'opérateur lève une erreur de type.
Égalité et comparaison
Sans op == défini, l'égalité structurelle s'applique : même type (par identité de forme, pas par nom) et mêmes valeurs
de champs. Sans op </>/etc., TypeError.
op == gouverne tout ce qui est défini par l'égalité : in, not in, index(), count() et remove(), y compris
sur des éléments imbriqués (list(a) == list(b) consulte l'égalité des éléments). Deux exceptions, les mêmes qu'en
Python : une valeur comparée à elle-même dans un conteneur est égale sans consulter op == (a in list(a) est
toujours vrai), et les clés de dict et de set se comparent par leur contenu.
Appartenance
op in définit le comportement de item in instance. op not in définit le comportement de item not in instance.
Si seul op in est défini, not in utilise sa négation automatiquement (protocole Python __contains__).
struct Bag {
items
op in(self, item) => { item in self.items }
}
b = Bag(list(1, 2, 3))
2 in b
# ⇒ True
5 not in b
# ⇒ True
Sans op in défini, in lève TypeError.
Opérateurs unaires
Désambiguïsation : 1 paramètre = unaire, 2 paramètres = binaire.
struct Vec2 {
x; y;
op -(self) => { Vec2(-self.x, -self.y) }
}
-Vec2(3, -5)
# ⇒ Vec2(x=-3, y=5)
Les opérateurs sont hérités via extends, comme les autres méthodes. L'implémentation héritée garde le type construit
explicitement par son corps ; elle ne remplace pas automatiquement ce type par celui du sous-struct.
Un struct sans
op +face à+: erreur de type. Un struct avec : dispatch silencieux, une seule forme de code pour tous les cas.
Dispatch inverse (reverse operators)
Quand un scalaire est à gauche et un struct à droite (5 + S(10)), le dispatch inverse se déclenche automatiquement :
l'opérateur cherche la méthode op_X sur l'opérande droit.
struct S {
val
op +(self, rhs) => { S(self.val + rhs) }
op *(self, rhs) => { S(self.val * rhs) }
}
S(10) + 5 # S(val=15) - dispatch forward classique
5 + S(10) # S(val=15) - dispatch inverse, self = S(10), rhs = 5
3 * S(7) # S(val=21) - idem
Le struct reste toujours self (premier paramètre). Pour les opérateurs commutatifs (+, *, &, |, ^), le
résultat suit généralement le forward. Pour les non-commutatifs (-, /, //, %, **, <<, >>), self reste
aussi le struct : 3 - S(10) appelle donc l'opérateur de S(10) avec rhs = 3, sans inverser le calcul du corps.
Priorité : le forward (opérande gauche) gagne toujours. Le reverse ne se déclenche que si l'opérande gauche ne gère pas l'opération.
Hashabilité
Les instances de struct sont utilisables comme clés de dict ou membres de set. Trois règles encadrent le contrat
a == b ⇒ hash(a) == hash(b) :
-
Hash structural par défaut : sans
op_hashniop ==, le hash combine la signature de forme du type (nom + forme, ce que compare l'égalité) et le hash de chaque champ. Cohérent avec l'égalité structurelle, donc deux types de même nom mais de formes différentes ne se télescopent plus dans undict. -
op ==sansop_hash→ unhashable : définir une égalité personnalisée sans définir aussi le hash provoqueTypeError: unhashableau moment duhash(). Sinon deux instances égales pourraient avoir des hash différents et casser silencieusementdict/set. -
Freeze-on-hash : dès qu'une instance est hashée (insérée dans un
dictouset, ou passée àhash()), ses champs deviennent immuables. Toute affectation ultérieure (p.x = ...) lèveTypeError. Cela garantit la stabilité du hash pendant la vie de la clé.
struct Point { x; y; }
p = Point(1, 2)
d = dict()
d[p] = "first" # p est hashé puis figé
d[Point(1, 2)] # → "first" (hash structural)
# p.x = 99 # TypeError: cannot mutate 'Point' after it has been hashed
struct Box {
v
op ==(self, rhs) => { self.v == rhs.v }
op_hash(self) => { self.v }
}
d = dict()
d[Box(42)] = "answer" # op_hash retourne 42
Si tu définis
op_hash, tu es responsable de la cohérence avecop ==:a == bdoit impliquerhash(a) == hash(b). Le runtime ne le vérifie pas.
La règle s'applique à toutes les variantes payload des union (qui sont des structs derrière) : Status.Ok(200) est
hashable, et figé une fois hashé.
Les deux runtimes (CLI Python et runtime pur MCP/LSP) appliquent le même contrat : hash structural par défaut,
interdiction op == sans hash, freeze-on-hash, et op_hash honoré quand il est défini -- à tout niveau d'imbrication
(le hash d'un champ struct passe par son propre op_hash).
Méthodes statiques
Le décorateur @static déclare une méthode sans self, appelable directement sur le type :
struct Point {
x; y;
length_sq(self) => { self.x * self.x + self.y * self.y }
@static
from_scalar(n) => {
Point(n, n)
}
}
Point.from_scalar(7).length_sq()
# ⇒ 98
Une méthode @static ne déclare pas de paramètre self ; elle peut prendre d'autres paramètres et coexister avec les
méthodes d'instance. Elle reste appelable depuis le type comme depuis une instance.
struct Base {
x
@static
make() => { Base(0) }
}
struct Child extends(Base) {
y
@static
make() => { Child(0, 1) }
}
Child.make()
# ⇒ Child(x=0, y=1)
Les méthodes statiques sont héritées et peuvent être remplacées comme les autres méthodes.
Les traits peuvent déclarer des méthodes @static, y compris @abstract @static (voir
section Traits).
Méthodes abstraites
Le décorateur @abstract déclare une méthode sans corps. Une structure contenant des méthodes abstraites ne peut pas
être instanciée directement - une sous-structure doit fournir l'implémentation :
struct Shape {
@abstract area(self)
@abstract perimeter(self)
describe(self) => {
f"area={self.area()}, perimeter={self.perimeter()}"
}
}
# Shape() # Erreur : cannot instantiate abstract struct 'Shape' (unimplemented: 'area', 'perimeter')
struct Circle extends(Shape) {
radius
area(self) => { 3.14159 * self.radius ** 2 }
perimeter(self) => { 2 * 3.14159 * self.radius }
}
Circle(5).describe() # → "area=78.53975, perimeter=31.4159"
Les traits peuvent aussi déclarer des méthodes abstraites (voir
section Traits). init ne peut pas être abstrait.
Un contrat abstrait se signe sans corps. L'implémentation est laissée en exercice au sous-type.
Constructeur init
Une méthode init(self) est appelée automatiquement après l'assignation des champs. Elle sert de post-constructeur pour
valider ou transformer les valeurs initiales :
struct Counter {
value = 0
init(self) => { self.value = self.value + 1; 999 }
}
Counter(10).value
# ⇒ 11
La valeur de retour de init est ignorée : le constructeur renvoie toujours l'instance. init fonctionne avec les
valeurs par défaut et les arguments nommés, mais ne peut pas être déclaré abstrait.
Héritage
Les structures supportent l'héritage via extends(Base) (simple) ou extends(Base1, Base2, ...)
(multiple). L'enfant hérite des champs et méthodes du parent :
struct Point {
x; y;
sum(self) => { self.x + self.y }
}
struct Point3D extends(Point) {
z
volume(self) => { self.x * self.y * self.z }
}
p = Point3D(1, 2, 3)
p.x # → 1 (hérité de Point)
p.z # → 3 (défini dans Point3D)
p.sum() # → 3 (méthode héritée de Point)
p.volume() # → 6 (méthode de Point3D)
Règles d'héritage :
- Les champs de l'enfant sont ajoutés après ceux du parent
- Redéfinir un champ hérité provoque une erreur
- Les méthodes de l'enfant peuvent remplacer (override) celles du parent
- L'ordre des paramètres au constructeur suit l'ordre des champs : parent puis enfant
struct Base {
x
value(self) => { self.x }
}
struct Child extends(Base) {
value(self) => { self.x * 10 } # override
}
Base(5).value() # → 5
Child(5).value() # → 50
Les valeurs par défaut des champs parents sont conservées. Une base inconnue provoque une erreur à la définition. Les méthodes abstraites sont héritées et doivent toutes être implémentées avant qu'un sous-struct devienne instanciable.
L'héritage reprend les champs du parent, permet d'en ajouter, et autorise l'override des méthodes. Même logique, nouvelle couche de peinture.
Accès au parent (super)
Dans une méthode redéfinie, super donne accès aux méthodes du parent :
struct Base {
x
value(self) => { self.x }
}
struct Child extends(Base) {
value(self) => { super.value() + 10 }
}
Child(5).value() # → 15
super fonctionne sur toute la chaîne d'héritage. Chaque niveau résout vers son propre parent :
struct A {
x
value(self) => { self.x }
}
struct B extends(A) {
value(self) => { super.value() + 10 }
}
struct C extends(B) {
value(self) => { super.value() + 100 }
}
C(1).value() # → 111
super.init() appelle le constructeur du parent :
struct Base {
x
init(self) => { self.x = self.x + 1 }
}
struct Child extends(Base) {
init(self) => {
super.init()
self.x = self.x * 10
}
}
Child(5).x # → 60 (5+1=6, 6*10=60)
Accéder à super sans héritage provoque une erreur :
struct S {
x
value(self) => { super.value() } # Erreur : super has no method 'value'
}
superappelle le parent, puis la méthode enfant reprend le clavier.
Héritage multiple
Les structures supportent l'héritage multiple via extends(Base1, Base2, ...). La résolution de l'ordre de méthodes
(MRO) suit la linéarisation C3, identique à Python :
struct A { x }
struct B extends(A) { y }
struct C extends(A) { z }
struct D extends(B, C) { w }
d = D(1, 2, 3, 4)
d.x # → 1 (hérité de A, via B)
d.w # → 4 (propre à D)
Fusion de champs : les champs de chaque parent sont hérités dans l'ordre du MRO. Un champ partagé (diamant) n'apparaît qu'une fois (first-seen wins) :
struct A { x }
struct B extends(A) { y }
struct C extends(A) { z }
struct D extends(B, C) { w }
# Ordre des champs de D : x, y, z, w
# 'x' vient de A via B (premier dans le MRO), pas dupliqué via C
Résolution de méthodes : le premier parent dans le MRO qui définit la méthode gagne (left priority) :
struct A {
value(self) => { "A" }
}
struct B extends(A) {
value(self) => { "B" }
}
struct C extends(A) {
value(self) => { "C" }
}
struct D extends(B, C) {}
D().value() # → "B" (B est avant C dans le MRO)
super coopératif : super résout vers le parent suivant dans le MRO, pas seulement le parent direct. Cela permet
le pattern d'appel coopératif :
struct A {
x
init(self) => { self.x = self.x + 1 }
}
struct B extends(A) {
init(self) => {
super.init()
self.x = self.x * 10
}
}
struct C extends(A) {
init(self) => {
super.init()
self.x = self.x + 100
}
}
struct D extends(B, C) {}
D(0).x # 1010 (A.init: 0+1=1, C.init: 1+100=101, B.init: 101*10=1010)
Hiérarchie incohérente : si aucun ordre C3 valide n'existe, une erreur est levée :
struct A {}
struct B extends(A) {}
struct C extends(A) {}
# extends(B, C) et extends(C, B) simultanément dans la même hiérarchie
# provoque une erreur C3 si l'ordre est contradictoire
L'héritage multiple avec C3 : toutes les contradictions se trouvent au moment de la définition, pas de l'exécution.
Traits
Les traits définissent des contrats comportementaux (méthodes) qu'une structure peut implémenter. Ils permettent la composition de comportements sans héritage simple.
Définition d'un trait
trait Printable {
repr(self) => { "printable" }
}
Un trait peut contenir une ou plusieurs méthodes avec self explicite.
Un trait ne porte que des méthodes : déclarer un champ dedans est une erreur de parsing. L'état appartient à la
structure qui l'expose, et se transmet par extends. Les deux mécanismes restent donc séparés — extends porte l'état,
implements porte le comportement — ce qui évite d'avoir à définir un ordre de résolution des champs entre les
plusieurs traits d'un même implements.
trait Tagged {
tag = "x" # erreur : trait 'Tagged': field 'tag' not allowed
}
Un contrat qui possède ce qu'il décrit n'est plus un contrat, c'est un propriétaire.
Implémentation de traits
Une structure implémente un ou plusieurs traits via implements(T1, T2, ...) :
trait Printable {
repr(self) => { f"({self.x}, {self.y})" }
}
struct Point implements(Printable) {
x; y;
}
Point(3, 4).repr() # → "(3, 4)"
Les méthodes du trait sont ajoutées à la structure. La structure peut les remplacer (override) :
trait Greetable {
greet(self) => { "hello" }
}
struct Bot implements(Greetable) {
name
greet(self) => { f"I am {self.name}" }
}
Bot("R2").greet() # → "I am R2"
Héritage de traits
Un trait peut étendre un ou plusieurs autres traits via extends(T1, T2, ...) :
trait Named {
name(self) => { "anonymous" }
}
trait Greeter extends(Named) {
greet(self) => { f"hello, {self.name()}" }
}
struct User implements(Greeter) {
label
name(self) => { self.label }
}
User("Alice").greet() # → "hello, Alice"
La structure qui implémente Greeter hérite aussi des méthodes de Named.
Méthodes abstraites dans les traits
Les traits peuvent déclarer des méthodes abstraites avec @abstract. Le trait fournit le contrat, la structure qui
l'implémente fournit le corps :
trait Serializable {
@abstract serialize(self)
to_json(self) => { "{" + self.serialize() + "}" }
}
struct Config implements(Serializable) {
key; value;
serialize(self) => { f"{self.key}: {self.value}" }
}
Config("port", "8080").to_json() # → "{port: 8080}"
Une structure qui implémente un trait sans fournir toutes les méthodes abstraites ne peut pas être instanciée.
Méthodes statiques dans les traits
Les traits peuvent définir une méthode @static, accessible sur le type qui les implémente. Avec @abstract @static,
la structure doit fournir l'implémentation :
trait Buildable {
@abstract
@static
build()
}
struct Thing implements(Buildable) {
x
@static
build() => { Thing(99) }
}
Thing.build().x
# ⇒ 99
Les règles de conflit s'appliquent aux méthodes statiques comme aux méthodes d'instance : si deux traits non reliés définissent la même méthode statique, c'est une erreur.
Composition multiple et conflits
Quand une structure implémente plusieurs traits, les méthodes sont fusionnées. Si deux traits définissent la même méthode, c'est une erreur - sauf si la structure fournit un override :
trait X { f(self) => { 1 } }
trait Y { f(self) => { 2 } }
# struct S implements(X, Y) { } # Erreur : f en conflit entre X et Y
struct S implements(X, Y) {
f(self) => { 3 } # Override qui résout le conflit
}
S().f() # → 3
Diamonds
Quand deux traits héritent d'un même trait ancêtre (diamond), l'ancêtre n'est compté qu'une seule fois (première occurrence). Pas d'erreur tant qu'il n'y a pas de conflit de méthodes :
trait Base { m(self) => { 0 } }
trait Left extends(Base) { }
trait Right extends(Base) { }
struct S implements(Left, Right) { }
S().m() # → 0 (Base.m hérité une seule fois)
Combiner héritage et traits
Une structure peut combiner extends et implements dans n'importe quel ordre. Les champs viennent de la hiérarchie
extends; les méthodes du trait rejoignent la résolution de méthodes :
trait Loggable { log(self) => { "logged" } }
struct Base { x }
struct Child extends(Base) implements(Loggable) { y }
c = Child(1, 2)
c.x
# ⇒ 1
c.log()
# ⇒ "logged"