Structures et Traits
Le mot-clé struct permet de déclarer une structure nommée avec des champs :
struct Point { x, y }
Les structures créent des types de données personnalisés avec des champs nommés. Une fois déclarées, elles peuvent être instanciées comme des fonctions :
# Déclaration
struct Point { x, y }
# Instanciation avec arguments positionnels
p1 = Point(10, 20)
# Instanciation avec arguments nommés
p2 = Point(x=5, y=15)
# Accès aux attributs
print(p1.x) # 10
print(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
- Représentation automatique :
str()etrepr()affichent la structure avec ses valeurs - Égalité structurelle : deux instances avec les mêmes valeurs sont considérées égales
- Validation des arguments : erreurs claires si arguments manquants ou en trop
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)
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.
Structures complexes
Les champs peuvent contenir n'importe quel type de valeur :
struct Container { data, metadata }
c = Container(
list(1, 2, 3),
dict(name="test", version=1)
)
print(c.data[0]) # 1
print(c.metadata["name"]) # "test"
Structures multiples
On peut définir plusieurs structures dans le même programme :
struct Vector2D { x, y }
struct Particle { position, velocity, mass }
v = Vector2D(10, 20)
p = Particle(
Vector2D(0, 0),
Vector2D(5, 10),
1.5
)
print(p.velocity.x) # 5
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__).
Un point-virgule peut séparer visuellement les champs des méthodes :
struct Point {
x, y;
sum(self) => { self.x + self.y }
}
Point(3, 4).sum() # 7
La virgule traîlante (x, y,) est aussi acceptée.
Les méthodes respectent la portée lexicale: 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.
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.
Opérateurs binaires (arithmétique)
| Syntaxe | Signature |
|---|---|
op + |
(self, rhs) |
op - |
(self, rhs) |
op * |
(self, rhs) |
op / |
(self, rhs) |
op // |
(self, rhs) |
op % |
(self, rhs) |
op ** |
(self, rhs) |
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.
Opérateurs de comparaison
| Syntaxe | Signature |
|---|---|
op == |
(self, rhs) |
op != |
(self, rhs) |
op < |
(self, rhs) |
op <= |
(self, rhs) |
op > |
(self, rhs) |
op >= |
(self, rhs) |
Sans op == défini, l'égalité structurelle s'applique (même type + mêmes champs). Sans op </>/etc., TypeError.
Opérateurs bitwise
| Syntaxe | Signature |
|---|---|
op & |
(self, rhs) |
op \| |
(self, rhs) |
op ^ |
(self, rhs) |
op << |
(self, rhs) |
op >> |
(self, rhs) |
Opérateurs unaires
| Syntaxe | Signature |
|---|---|
op - |
(self) |
op + |
(self) |
op ~ |
(self) |
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)
Héritage
Les opérateurs sont hérités via extends, comme toute autre méthode :
struct Base {
x, y
op +(self, rhs) => { Base(self.x + rhs.x, self.y + rhs.y) }
}
struct Child extends(Base) { }
Child(1, 2) + Child(3, 4) # Child(x=4, y=6) -- op + hérité de Base
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 est identique au forward. Pour les non-commutatifs (-, /, //, %, **, <<, >>), self est le
struct :
struct S {
val
op -(self, rhs) => { S(self.val - rhs) }
}
S(10) - 3 # S(val=7) -- forward: S(10).val - 3
3 - S(10) # S(val=-7) -- reverse: S(10).val - 3 (self = S(10))
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.
Méthodes statiques
Le décorateur @static déclare une méthode sans self, appelable directement sur le type :
struct Counter {
value
@static
zero() => {
Counter(0)
}
}
Counter.zero() # Counter(value=0)
Counter(5).zero() # Counter(value=0) -- aussi callable sur une instance
Une méthode @static n'a pas de paramètre self -- déclarer self comme premier paramètre est une erreur. Elle peut
prendre d'autres paramètres :
struct Point {
x, y
@static
from_scalar(n) => {
Point(n, n)
}
}
Point.from_scalar(7) # Point(x=7, y=7)
Les méthodes statiques et d'instance coexistent librement dans une même structure :
struct Vec2 {
x, y
length_sq(self) => { self.x * self.x + self.y * self.y }
@static
zero() => { Vec2(0, 0) }
}
Vec2.zero().length_sq() # 0
Les méthodes statiques sont héritées via extends et peuvent être overridées :
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 traits peuvent déclarer des méthodes @static, y compris @abstract @static (voir
section Traits).
Une méthode statique n'a pas besoin d'instance pour exister. Elle est accessible partout, tout le temps, sans condition d'identité.
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 {
x
init(self) => { self.x = self.x + 1 }
}
Counter(10).x # 11
La valeur de retour de init est ignorée -- l'instance est toujours renvoyée :
struct S {
x
init(self) => { self.x = self.x * 2; 999 }
}
S(5).x # 10 (pas 999)
init fonctionne avec les valeurs par défaut et les arguments nommés :
struct Config {
host, port = 8080
init(self) => { self.host = self.host + ":auto" }
}
Config("localhost").host # "localhost:auto"
Config("localhost").port # 8080
inits'exécute automatiquement après l'initialisation des champs. Elle commence avant même que vous pensiez à l'appeler.
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
L'héritage fonctionne avec les valeurs par défaut. Les champs avec défaut du parent sont conservés :
struct Config {
host, port = 8080
}
struct SecureConfig extends(Config) {
ssl = True
}
SecureConfig("localhost") # host="localhost", port=8080, ssl=True
Tenter d'hériter d'une structure inexistante provoque une erreur à l'exécution :
struct Child extends(Unknown) { x } # RuntimeError: unknown base struct 'Unknown'
Les méthodes abstraites sont héritées par les sous-structures. Une sous-structure qui n'implémente pas toutes les méthodes abstraites provoque une erreur à la définition :
struct Shape {
@abstract area(self)
}
# struct ColoredShape extends(Shape) { color }
# Erreur : struct 'ColoredShape' must implement abstract method(s): 'area'
struct Square extends(Shape) {
side
area(self) => { self.side ** 2 }
}
Square(4).area() # 16
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.
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 des méthodes @static, accessibles sur le type qui les implémente :
trait Factory {
@static
create() => { 42 }
}
struct Widget implements(Factory) { v }
Widget.create() # 42
Un trait peut déclarer une méthode @abstract @static -- la structure doit alors 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)
En diamond, l'ancêtre commun n'est intégré qu'une seule fois. Pas de doublon, pas de boucle temporelle.
Combiner héritage et traits
Une structure peut combiner extends et implements dans n'importe quel ordre :
trait Loggable { log(self) => { "logged" } }
struct Base { x }
struct Child extends(Base) implements(Loggable) { y }
# ou
struct Child2 implements(Loggable) extends(Base) { y }
c = Child(1, 2)
c.x # 1 (hérité de Base)
c.y # 2 (propre à Child)
c.log() # "logged" (de Loggable)