Histoire. Au début, Rust exigeait que tous les types aient une taille connue statiquement pour garantir l'allocation sur la pile et des sémantiques de valeur efficaces. Lorsque des types à taille dynamique (DSTs) tels que les tranches [T] et les objets de traits dyn Trait ont été introduits pour soutenir des structures de données flexibles, le langage avait besoin d'un mécanisme pour distinguer les paramètres génériques de taille connue et ceux potentiellement non dimensionnés sans casser le code existant. La syntaxe ?Sized a été adoptée comme une contrainte "relâchée", permettant aux génériques de s'exempter explicitement de l'exigence par défaut Sized tout en préservant le comportement par défaut ergonomique pour la majorité des cas d'utilisation qui n'impliquent pas de données non dimensionnées.
Le Problème. La contrainte implicite T: **Sized** crée une tension fondamentale : elle permet la manipulation de valeurs et les calculs de mémoire à la compilation, mais empêche les fonctions d'accepter directement des types dyn Trait ou des tranches sans indirection. Cette restriction oblige les développeurs à utiliser Box ou des références même lorsque des sémantiques de propriété sont souhaitées, compliquant les API qui visent à prendre en charge à la fois le polymorphisme statique et dynamique. Sans ?Sized, le code générique ne peut pas abstraire à la fois sur des types concrets et des objets polymorphes à l'exécution, conduisant soit à des allocations forcées sur le tas, soit à des interfaces dupliquées pour les variantes dimensionnées et non dimensionnées.
La Solution. Le compilateur résout cela en imposant que les types limités par ?Sized ne peuvent être accessibles que par des pointeurs larges—des valeurs composites contenant un pointeur de données et des métadonnées à l'exécution (longueur pour les tranches, vtable pour les objets de traits). Lorsqu'un générique spécifie T: **?Sized**, le compilateur interdit les opérations exigeant des tailles connues, telles que std::mem::size_of::<T>() ou le déplacement de valeurs par valeur, garantissant que toutes les mises en page de mémoire restent calculables à la compilation. Cette conception permet des abstractions à coût zéro où les types dimensionnés utilisent des pointeurs fins et les types non dimensionnés utilisent des pointeurs larges, le système de types gérant la distinction de manière transparente.
Une bibliothèque de surveillance des systèmes devait enregistrer des erreurs qui pouvaient être soit de petits codes d'erreur alloués sur la pile, soit des messages d'erreur formatés dynamiquement de grande taille implémentant dyn **Display**. La conception initiale de l'API utilisant fn log<T: **Display**>(error: T) rejetait les objets de traits car la contrainte implicite Sized empêchait dyn Display de satisfaire la contrainte, créant un obstacle ergonomique significatif pour la gestion dynamique des erreurs.
La première approche envisagée consistait à exiger Box<dyn **Display**> pour tous les types d'erreur, convertissant même les simples codes d'erreur u32 en allocations sur le tas. Avantages : unification de la surface de l'API et possibilité de posséder des erreurs dynamiques sans génériques complexes. Inconvénients : introduction de dépendances à l'allocateur, inadaptées aux cibles embarquées, et ajout d'une latence mesurable aux chemins critiques traitant des erreurs simples et statiques.
La deuxième option impliquait de maintenir deux méthodes de journalisation séparées : une pour les types dimensionnés génériques T: **Display** et une spécifiquement pour &dyn **Display**. Avantages : évitait l'allocation sur le tas pour les types dimensionnés et supportait correctement le dispatch dynamique pour les erreurs complexes. Inconvénients : nécessitait une duplication de code significative, compliquait la documentation de l'API publique et forçait les appelants à choisir la méthode correcte en fonction de la connaissance préalable de la taille du type.
L'équipe a choisi une troisième approche en utilisant fn log<T: **?Sized** + **Display**>(error: &T), acceptant des références à la fois pour des types dimensionnés et non dimensionnés. Cette solution a été choisie car elle maintenait un point d'entrée API unique et cohérent, soutenait les environnements no-std en évitant le boxing obligatoire, et n'imposait aucun coût d'exécution par rapport à l'approche à méthodes doubles. L'implémentation générique a été compilée en code machine identique pour les types dimensionnés que la version monomorphique originale, tout en gérant correctement les objets de traits par dispatch vtable.
Le crate résultant a été déployé avec succès sur des microcontrôleurs et des serveurs, traitant des millions d'événements d'erreur hétérogènes sans surcharge d'allocation. L'interface unifiée a permis aux développeurs de passer à la fois &ConcreteError et &dyn Error sans effort, démontrant que ?Sized permet un véritable polymorphisme sans coût à travers divers cibles de déploiement.
Pourquoi une fonction ne peut-elle pas renvoyer une valeur de type T où T: **?Sized** ?
Les fonctions renvoyant des valeurs doivent placer ces valeurs dans des registres ou sur la pile, nécessitant une taille connue à la compilation pour générer le bon code de convention d'appel et réserver l'espace de pile approprié. Comme les types ?Sized comme [i32] ou dyn **Debug** ont des tailles déterminées à l'exécution, le compilateur ne peut pas générer les séquences d'instructions de retour de taille fixe nécessaires pour l'ABI. Seuls les types pointeurs (Box<T>, &T) ont des tailles connues statiquement (usize ou largeur du pointeur large), ce qui les rend les seuls types de retour légaux pour des données non dimensionnées, restreignant fondamentalement les génériques ?Sized aux types "vue" plutôt qu'aux types "valeur" qui peuvent être déplacés par valeur.
Comment **?Sized** interagit-il avec les règles de cohérence concernant les implémentations de traits pour les références ?
Lors de l'implémentation de traits pour &T où T: **?Sized**, l'implémentation s'applique automatiquement aux pointeurs larges (comme &[i32] ou &dyn Trait) car ce ne sont que des références à des types ?Sized. Les candidats manquent souvent que impl Trait for &T where T: **?Sized** couvre à la fois les pointeurs fins et larges, tandis que impl Trait for T where T: **Sized** ne le fait pas. Cette distinction est cruciale pour définir des implémentations générales qui fonctionnent à la fois avec des données dimensionnées et des objets de traits, garantissant la cohérence à travers la hiérarchie des types sans chevauchements d'implémentations qui violeraient les règles d'orphelin de Rust.
Qu'est-ce qui distingue la représentation en mémoire de **Box<dyn Trait>** de **&dyn Trait** au-delà des sémantiques de propriété ?
Bien que les deux utilisent des pointeurs larges (pointeur + vtable), **Box<dyn Trait>** possède l'allocation et stocke le pointeur vtable spécifiquement pour les besoins de désallocation, tandis que **&dyn Trait** se contente d'observer les données. Crucialement, Box<T> où T: **?Sized** exige que l'allocateur gère la désallocation de taille dynamique en utilisant la taille stockée dans le vtable, tandis que les références n'ont aucune telle responsabilité. Les débutants négligent souvent que Box permet l'allocation sur le tas de types non dimensionnés qui ne peuvent pas exister sur la pile, tandis que les références empruntent simplement de la mémoire existante, ce qui rend Box essentiel pour retourner des données non dimensionnées possédées à partir de fonctions.