Dans les implémentations C # et Java, les objets ont généralement un seul pointeur vers sa classe. Cela est possible car ce sont des langages à héritage unique. La structure de classe contient alors la table virtuelle pour la hiérarchie à héritage unique. Mais appeler des méthodes d'interface a également tous les problèmes d'héritage multiple. Ceci est généralement résolu en plaçant des vtables supplémentaires pour toutes les interfaces implémentées dans la structure de classe. Cela économise de l'espace par rapport aux implémentations d'héritage virtuel typiques en C ++, mais rend la répartition des méthodes d'interface plus compliquée - qui peut être partiellement compensée par la mise en cache.
Par exemple, dans la JVM OpenJDK, chaque classe contient un tableau de vtables pour toutes les interfaces implémentées (une interface vtable est appelée un itable ). Lorsqu'une méthode d'interface est appelée, ce tableau recherche linéairement l'itable de cette interface, puis la méthode peut être distribuée via cet itable. La mise en cache est utilisée pour que chaque site d'appel se souvienne du résultat de l'envoi de la méthode, de sorte que cette recherche ne doit être répétée que lorsque le type d'objet concret change. Pseudocode pour l'envoi de méthode:
// Dispatch SomeInterface.method
Method const* resolve_method(
Object const* instance, Klass const* interface, uint itable_slot) {
Klass const* klass = instance->klass;
for (Itable const* itable : klass->itables()) {
if (itable->klass() == interface)
return itable[itable_slot];
}
throw ...; // class does not implement required interface
}
(Comparez le vrai code dans l' interpréteur OpenJDK HotSpot ou le compilateur x86 .)
C # (ou plus précisément, le CLR) utilise une approche connexe. Cependant, ici les itables ne contiennent pas de pointeurs vers les méthodes, mais sont des mappages de slots: ils pointent vers des entrées dans la table principale de la classe. Comme pour Java, la recherche de l'itable correct n'est que le pire des cas, et il est prévu que la mise en cache sur le site d'appel puisse éviter cette recherche presque toujours. Le CLR utilise une technique appelée Virtual Stub Dispatch afin de patcher le code machine compilé JIT avec différentes stratégies de mise en cache. Pseudocode:
Method const* resolve_method(
Object const* instance, Klass const* interface, uint interface_slot) {
Klass const* klass = instance->klass;
// Walk all base classes to find slot map
for (Klass const* base = klass; base != nullptr; base = base->base()) {
// I think the CLR actually uses hash tables instead of a linear search
for (SlotMap const* slot_map : base->slot_maps()) {
if (slot_map->klass() == interface) {
uint vtable_slot = slot_map[interface_slot];
return klass->vtable[vtable_slot];
}
}
}
throw ...; // class does not implement required interface
}
La principale différence avec le pseudocode OpenJDK est que, dans OpenJDK, chaque classe possède un tableau de toutes les interfaces implémentées directement ou indirectement, tandis que le CLR ne conserve qu'un tableau de mappages d'emplacements pour les interfaces qui ont été directement implémentées dans cette classe. Nous devons donc remonter la hiérarchie d'héritage jusqu'à ce qu'une carte de slot soit trouvée. Pour les hiérarchies d'héritage profondes, cela se traduit par des économies d'espace. Celles-ci sont particulièrement pertinentes dans CLR en raison de la façon dont les génériques sont implémentés: pour une spécialisation générique, la structure de classe est copiée et les méthodes de la table principale peuvent être remplacées par des spécialisations. Les mappages d'emplacements continuent de pointer vers les entrées de table appropriées et peuvent donc être partagés entre toutes les spécialisations génériques d'une classe.
Pour finir, il existe plus de possibilités pour implémenter la répartition d'interface. Au lieu de placer le pointeur vtable / itable dans l'objet ou dans la structure de classe, nous pouvons utiliser de gros pointeurs vers l'objet, qui sont essentiellement une (Object*, VTable*)
paire. L'inconvénient est que cela double la taille des pointeurs et que les upcasts (d'un type concret à un type d'interface) ne sont pas gratuits. Mais il est plus flexible, a moins d'indirection, et signifie également que les interfaces peuvent être implémentées en externe à partir d'une classe. Les approches associées sont utilisées par les interfaces Go, les caractères Rust et les classes de types Haskell.
Références et lectures complémentaires:
- Wikipédia: mise en cache en ligne . Discute des approches de mise en cache qui peuvent être utilisées pour éviter une recherche de méthode coûteuse. Généralement non nécessaire pour la répartition basée sur vtable, mais très souhaitable pour les mécanismes de répartition plus coûteux comme les stratégies de répartition d'interface ci-dessus.
- OpenJDK Wiki (2013): Appels d'interface . Discute des objets utilisables.
- Pobar, Neward (2009): SSCLI 2.0 Internals. Le chapitre 5 du livre traite en détail des cartes des emplacements. N'a jamais été publié mais mis à disposition par les auteurs sur leurs blogs . Le lien PDF a depuis déménagé. Ce livre ne reflète probablement plus l'état actuel du CLR.
- CoreCLR (2006): Virtual Stub Dispatch . Dans: Book Of The Runtime. Discute des cartes d'emplacements et de la mise en cache pour éviter les recherches coûteuses.
- Kennedy, Syme (2001): Design and Implementation of Generics for the .NET Common Language Runtime . ( Lien PDF ). Discute de diverses approches pour implémenter des génériques. Les génériques interagissent avec la répartition des méthodes car les méthodes peuvent être spécialisées et les vtables doivent donc être réécrites.