Je suis curieux de savoir si le fait de marquer une classe C ++ dérivée existante comme final pour permettre les optimisations de dé-virtualisation changera ABI lors de l'utilisation de C ++ 11. Je m'attends à ce que cela n'ait aucun effet car je vois cela principalement comme un indice pour le compilateur sur la façon dont il peut optimiser les fonctions virtuelles et, en tant que tel, je ne vois aucun moyen de changer la taille de la structure ou de la vtable, mais peut-être que je manque quelque chose?

Je suis conscient que cela modifie l'API ici afin que le code qui dérive davantage de cette classe dérivée ne fonctionne plus, mais je ne suis préoccupé que par ABI dans ce cas particulier.

13
Dan 20 nov. 2018 à 10:10

3 réponses

Meilleure réponse

Final sur une déclaration de fonction X::f() implique que la déclaration ne peut pas être remplacée, donc tous les appels de ce nom cette déclaration peuvent être liés tôt (pas ces appels qui nomment une déclaration dans une classe de base): si une fonction virtuelle est final dans l'ABI , les vtables produites peuvent être incompatibles avec celle produite à peu près la même classe sans final: les appels à des fonctions virtuelles dont les déclarations de nom marquées final peuvent être supposées directes: essayer d'utiliser une entrée de vtable (qui devrait exister dans l'ABI sans fin) est illégale.

Le compilateur pourrait utiliser la garantie finale pour réduire la taille des vtables (qui peuvent parfois augmenter beaucoup) en n'ajoutant pas une nouvelle entrée qui serait généralement ajoutée et qui doit être conforme à l'ABI pour une déclaration non finale.

Des entrées sont ajoutées pour une déclaration remplaçant une fonction qui n'est pas une base primaire (intrinsèquement, toujours) ou pour un type de retour non trivialement covariant (une covariante de type retour sur une base non primaire).

Classe de base intrinsèquement primaire: le cas le plus simple d'héritage polymorphe

Le cas simple de l'héritage polymorphe, une classe dérivée héritant non virtuellement d'une seule classe de base polymorphe, est le cas typique d'une base toujours primaire: le sous-objet de base polymorphe est au début, l'adresse de l'objet dérivé est la même que l'adresse du sous-objet de base, les appels virtuels peuvent être effectués directement avec un pointeur vers l'un ou l'autre, tout est simple.

Ces propriétés sont vraies, que la classe dérivée soit un objet complet (qui n'est pas un sous-objet), un objet le plus dérivé ou une classe de base. (Ce sont des invariants de classe garantis au niveau ABI pour les pointeurs d'origine inconnue.)

Considérant le cas où le type de retour n'est pas covariant; ou:

Covariance triviale

Un exemple: le cas où il est covariant de même type que *this; un péché:

struct B { virtual B *f(); };
struct D : B { virtual D *f(); }; // trivial covariance

Ici B est intrinsèquement, invariablement le principal dans D: dans tous les D (sous) objets jamais créés, un B réside à la même adresse: le {{X4} La conversion de} en B* est triviale, donc la covariance est également triviale: c'est un problème de typage statique.

Chaque fois que c'est le cas (conversion ascendante triviale), la covariance disparaît au niveau de la génération de code.

Conclusion

Dans ces cas, le type de la déclaration de la fonction de substitution est trivialement différent du type de la base:

  • tous les paramètres sont presque les mêmes (avec seulement une différence insignifiante sur le type de this)
  • le type de retour est presque le même (avec seulement une différence possible sur le type d'un type de pointeur (*) retourné)

(*) puisque renvoyer une référence est exactement le même que renvoyer un pointeur au niveau ABI, les références ne sont pas traitées spécifiquement

Aucune entrée vtable n'est donc ajoutée pour la déclaration dérivée.

(Donc, rendre la classe finale ne serait pas une simplification de la table.)

Jamais de base principale

Évidemment, une classe ne peut avoir qu'un seul sous-objet, contenant un membre de données scalaire spécifique (comme vptr (*)), à l'offset 0. Les autres classes de base avec des membres de données scalaires seront à un offset non trivial, nécessitant des conversions de base non triviales de pointeurs. Ainsi, plusieurs héritages intéressants (**) créeront des bases non primaires.

(*) Le vptr n'est pas un membre de données normal au niveau de l'utilisateur; mais dans le code généré, c'est à peu près un membre de données scalaire normal connu du compilateur. (**) La disposition des bases non polymorphes n'est pas intéressante ici: pour les besoins de la vtable ABI, une base non polymorphe est traitée comme un sous-objet membre, car elle n'affecte en aucune façon les vtables.

L'exemple intéressant conceptuellement le plus simple d'une conversion de pointeur non primaire et non triviale est:

struct B1 { virtual void f(); };
struct B2 { virtual void f(); };
struct D : B1, B2 { };

Chaque base a son propre membre scalaire vptr, et ces vptr ont des objectifs différents:

  • B1::vptr pointe vers une structure B1_vtable
  • B2::vptr pointe vers une structure B2_vtable

Et ceux-ci ont une disposition identique (parce que les définitions de classe sont superposables, l'ABI doit générer des dispositions superposables); et ils sont strictement incompatibles car

  1. Les vtables ont des entrées distinctes:

    • B1_vtable.f_ptr pointe vers le dernier remplacement pour B1::f()
    • B2_vtable.f_ptr pointe vers le dernier remplacement pour B2::f()
  2. B1_vtable.f_ptr doit être au même décalage que B2_vtable.f_ptr (à partir de leurs membres de données vptr respectifs dans B1 et B2)

  3. Les remplaçants finaux de B1::f() et B2::f() ne sont pas intrinsèquement (toujours, invariablement) équivalents (*): ils peuvent avoir des remplaçants finaux distincts qui font des choses différentes. (***)

(*) Deux fonctions d'exécution appelables (**) sont équivalentes si elles ont le même comportement observable au niveau ABI. (Les fonctions appelables équivalentes peuvent ne pas avoir la même déclaration ou les mêmes types C ++.)

(**) Une fonction d'exécution appelable est n'importe quel point d'entrée: n'importe quelle adresse qui peut être appelée / sautée; il peut s'agir d'un code de fonction normal, d'un thunk / trampoline, d'une entrée particulière dans une fonction à entrées multiples. Les fonctions d'exécution appelables n'ont souvent aucune déclaration C ++ possible, comme «le remplacement final appelé avec un pointeur de classe de base».

(***) Qu'ils ont parfois le même remplacement final dans une autre classe dérivée:

struct DD : D { void f(); }

N'est pas utile pour définir l'ABI de D.

Nous voyons donc que D prouvé a besoin d'une base polymorphe non primaire; par convention, ce sera D2; la première base polymorphe nommée (B1) devient primaire.

Donc B2 doit être à un décalage non trivial, et la conversion de D en B2 n'est pas triviale: elle nécessite du code généré.

Ainsi, les paramètres d'une fonction membre de D ne peuvent pas être équivalents aux paramètres d'une fonction membre de B2, car l'implicite this n'est pas trivialement convertible; alors:

  • D doit avoir deux vtables différentes: une vtable correspondant à B1_vtable et une avec B2_vtable (elles sont en pratique rassemblées dans une grande vtable pour D mais conceptuellement elles sont deux structures distinctes).
  • l'entrée vtable d'un membre virtuel de B2::g qui est surchargée dans D a besoin de deux entrées, une dans le D_B2_vtable (qui est juste une mise en page B2_vtable avec des valeurs différentes) et un dans le D_B1_vtable qui est un B1_vtable: a B1_vtable plus des entrées pour les nouvelles fonctionnalités d'exécution de D.

Puisque le D_B1_vtable est construit à partir d'un B1_vtable, un pointeur vers D_B1_vtable est trivialement un pointeur vers un B1_vtable, et la valeur vptr est la même.

Notez qu'en théorie, il serait possible d'omettre l'entrée pour D::g() dans D_B1_vtable si la charge de faire tous les appels virtuels de D::g() via la base B2, qui comme aucune covariance non triviale n'est utilisée (#), est également une possibilité.

(#) ou si une covariance non triviale se produit, la "covariance virtuelle" (covariance dans une relation dérivée à base impliquant l'héritage virtuel) n'est pas utilisée

Pas de base intrinsèquement primaire

L'héritage régulier (non virtuel) est simple comme l'appartenance:

  • un sous-objet de base non virtuel est une base directe d'exactement un objet (ce qui implique qu'il y a toujours exactement un remplacement final de toute fonction virtuelle lorsque l'héritage virtuel n'est pas utilisé);
  • le placement d'une base non virtuelle est fixe;
  • Les sous-objets de base qui n'ont pas de sous-objets de base virtuels, tout comme les membres de données, sont construits exactement comme des objets complets (ils ont exactement un code de fonction de constructeur d'exécution pour chaque constructeur C ++ défini).

Un cas plus subtil d'héritage est l'héritage virtuel: un sous-objet de base virtuel peut être la base directe de nombreux sous-objets de classe de base. Cela implique que la disposition des bases virtuelles n'est déterminée qu'au niveau de classe le plus dérivé: le décalage d'une base virtuelle dans un objet le plus dérivé est bien connu et une constante de temps de compilation; dans un objet de classe dérivé arbitraire (qui peut ou non être un objet le plus dérivé), il s'agit d'une valeur calculée à l'exécution.

Ce décalage ne peut jamais être connu car C ++ prend en charge à la fois l'héritage unificateur et de duplication:

  • l'héritage virtuel est unificateur: toutes les bases virtuelles d'un type donné dans un objet le plus dérivé sont un seul et même sous-objet;
  • l'héritage non virtuel se duplique: toutes les bases non virtuelles indirectes sont sémantiquement distinctes, car leurs membres virtuels n'ont pas besoin d'avoir des surcharges finales communes (contrairement à Java où cela est impossible (AFAIK)):

    struct B {vide virtuel f (); }; struct D1: B {vide virtuel f (); }; // remplacement final struct D2: B {vide virtuel f (); }; // remplacement final struct DD: D1, D2 {};

Voici DD deux derniers remplaçants distincts de B::f():

  • DD::D1::f() est le dernier remplacement pour DD::D1::B::f()
  • DD::D2::f() est le dernier remplacement pour DD::D2::B::f()

Dans deux entrées vtable distinctes.

La duplication de l'héritage , où vous dérivez indirectement plusieurs fois d'une classe donnée, implique plusieurs vptr, vtables et éventuellement un code ultime vtable distinct (le but ultime de l'utilisation d'une entrée vtable: la sémantique de haut niveau de l'appel d'un virtuel fonction - pas le point d’entrée).

Non seulement C ++ prend en charge les deux, mais les combinaisons de faits sont autorisées: duplication de l'héritage d'une classe qui utilise l'héritage unificateur:

struct VB { virtual void f(); };
struct D : virtual VB { virtual void g(); int dummy; };
struct DD1 : D { void g(); };
struct DD2 : D { void g(); };
struct DDD : DD1, DD2 { };

Il n'y a qu'un seul DDD::VB mais il y a deux sous-objets D remarquablement distincts dans DDD avec des remplacements finaux différents pour D::g(). Qu'un langage de type C ++ (qui prend en charge la sémantique d'héritage virtuel et non virtuel) garantit ou non que des sous-objets distincts ont des adresses différentes, l'adresse de DDD::DD1::D ne peut pas être la même que l'adresse de DDD::DD2::D.

Ainsi, le décalage d'un VB dans un D ne peut pas être fixé (dans tout langage qui prend en charge l'unification et la duplication des bases).

Dans cet exemple particulier, un objet réel VB (l'objet à l'exécution) n'a pas de membre de données concret à l'exception du vptr, et le vptr est un membre scalaire spécial car il s'agit d'un membre partagé de type "invariant" (non const): il est fixé sur le constructeur (invariant après construction complète) et sa sémantique est partagée entre les bases et les classes dérivées. Comme VB n'a pas de membre scalaire qui ne soit pas invariant de type, que dans un DDD le sous-objet VB peut être une superposition sur DDD::DD1::D, tant que la vtable de { {X5}} correspond à la vtable de VB.

Cela ne peut cependant pas être le cas pour les bases virtuelles qui ont des membres scalaires non invariants, c'est-à-dire des membres de données réguliers avec une identité, c'est-à-dire des membres occupant une plage d'octets distincte: ces membres de données «réels» ne peuvent pas être superposés sur autre chose. Ainsi, un sous-objet de base virtuelle avec des membres de données (membres dont l'adresse est garantie d'être distinct par C ++ ou tout autre langage distinct de type C ++ que vous implémentez) doit être placé à un emplacement distinct: des bases virtuelles avec des membres de données normalement (## ) ont des décalages intrinsèquement non triviaux.

(##) avec potentiellement un cas spécial très étroit avec une classe dérivée sans membre de données avec une base virtuelle avec certains membres de données

Nous voyons donc que les classes "presque vides" (classes sans donnée membre mais avec un vptr) sont des cas particuliers lorsqu'elles sont utilisées comme classes de base virtuelles: ces bases virtuelles sont candidates à la superposition sur des classes dérivées, ce sont des primaires potentielles mais pas des primaires inhérentes:

  • le décalage auquel ils résident ne sera déterminé que dans la classe la plus dérivée;
  • le décalage peut être nul ou non;
  • un offset nul implique la superposition de la base, donc la vtable de chaque classe directement dérivée doit correspondre à la vtable de la base;
  • un offset non nul implique des conversions non triviales, donc les entrées dans les vtables doivent traiter la conversion des pointeurs vers la base virtuelle comme nécessitant une conversion d'exécution (sauf lorsqu'elle est superposée évidemment car cela ne serait pas nécessaire, impossible).

Cela signifie que lors du remplacement d'une fonction virtuelle dans une base virtuelle, un ajustement est toujours supposé être potentiellement nécessaire, mais dans certains cas, aucun ajustement ne sera nécessaire.

Une base moralement virtuelle est une relation de classe de base qui implique un héritage virtuel (éventuellement plus un héritage non virtuel). Effectuer une conversion dérivée en base, en particulier convertir un pointeur d en dérivé D, en base B, une conversion en ...

  • ... une base non moralement virtuelle est intrinsèquement réversible dans tous les cas:

    • il existe une relation un à un entre l'identité d'un sous-objet B d'un D et d'un D (qui peut être un sous-objet lui-même);
    • l'opération inverse peut être effectuée avec un static_cast<D*>: static_cast<D*>((B*)d) is d;
  • (dans n'importe quel langage de type C ++ avec un support complet pour unifier et dupliquer l'héritage) ... une base moralement virtuelle est intrinsèquement non réversible dans le cas général (bien qu'elle soit réversible dans le cas courant avec des hiérarchies simples). Notez que:

    • static_cast<D*>((B*)d) est mal formé;
    • dynamic_cast<D*>((B*)d) fonctionnera pour les cas simples.

Appelons donc covariance virtuelle le cas où la covariance du type de retour est basée sur une base moralement virtuelle. Lors de la substitution avec la covariance virtuelle, la convention d'appel ne peut pas supposer que la base sera à un décalage connu. Ainsi, une nouvelle entrée vtable est intrinsèquement nécessaire pour la covariance virtuelle, que la déclaration remplacée soit ou non dans un primaire inhérent:

struct VB { virtual void f(); }; // almost empty
struct D : virtual VB { }; // VB is potential primary

struct Ba { virtual VB * g(); };
struct Da : Ba { // non virtual base, so Ba is inherent primary
  D * g(); // virtually covariant: D->VB is morally virtual
};

Ici VB peut être à l'offset zéro dans D et aucun ajustement n'est nécessaire (par exemple pour un objet complet de type D), mais ce n'est pas toujours le cas dans un { {X3}} sous-objet: lorsqu'il s'agit de pointeurs vers D, on ne peut pas savoir si c'est le cas.

Lorsque Da::g() remplace Ba::g() avec une covariance virtuelle, le cas général doit être supposé donc une nouvelle entrée de table virtuelle est strictement nécessaire pour Da::g() car il n'y a pas de pointeur vers le bas possible conversion de VB en D qui inverse la conversion du pointeur D en VB dans le cas général.

Ba est un élément primaire inhérent à Da, donc la sémantique de Ba::vptr est partagée / améliorée:

  • il y a des garanties / invariants supplémentaires sur ce membre scalaire, et la vtable est étendue;
  • aucun nouveau vptr n'est nécessaire pour Da.

Ainsi, Da_vtable (intrinsèquement compatible avec Ba_vtable) a besoin de deux entrées distinctes pour les appels virtuels à g():

  • dans la partie Ba_vtable de la vtable: Ba::g() entrée vtable: appelle le remplacement final de Ba::g() avec un paramètre implicite de Ba* et renvoie une valeur VB*.
  • dans la nouvelle partie membres de la vtable: Da::g() vtable entry: appelle le dernier overrider de Da::g() (qui est intrinsèquement identique au dernier overrider de Ba::g() en C ++) avec un paramètre implicite de Da* et renvoie une valeur D*.

Notez qu'il n'y a pas vraiment de liberté ABI ici: les fondamentaux de la conception vptr / vtable et leurs propriétés intrinsèques impliquent la présence de ces multiples entrées pour ce qui est une fonction virtuelle unique au niveau du langage élevé.

Notez que rendre le corps de la fonction virtuelle en ligne et visible par l'ABI (de sorte que l'ABI par classes avec différentes définitions de fonction en ligne puisse être rendu incompatible, permettant plus d'informations pour informer la disposition de la mémoire) ne serait pas utile, car le code en ligne ne ferait que définir ce que fait un appel à une fonction virtuelle non surchargée: on ne peut pas baser les décisions ABI sur des choix qui peuvent être surchargés dans des classes dérivées.

[Exemple de covariance virtuelle qui finit par n'être que trivialement covariante, car dans un D complet, le décalage pour VB est trivial et aucun code d'ajustement n'aurait été nécessaire dans ce cas:

struct Da : Ba { // non virtual base, so inherent primary
  D * g() { return new D; } // VB really is primary in complete D
                            // so conversion to VB* is trivial here
};

Notez que dans ce code, une génération de code incorrecte pour un appel virtuel par un compilateur bogué qui utiliserait l'entrée Ba_vtable pour appeler g() fonctionnerait en fait car la covariance finit par être triviale, comme VB est primaire en complet D.

La convention d'appel est pour le cas général et une telle génération de code échouerait avec du code qui renvoie un objet d'une classe différente.

--end exemple]

Mais si Da::g() est final dans l'ABI, seuls les appels virtuels peuvent être effectués via la déclaration VB * g();: la covariance est rendue purement statique, la conversion dérivée en base est effectuée au moment de la compilation comme dernière étape de le thunk virtuel, comme si la covariance virtuelle n'était jamais utilisée.

Extension possible de la finale

Il existe deux types de virtualité en C ++: les fonctions membres (mises en correspondance par la signature de fonction) et l'héritage (correspondance par nom de classe). Si final cesse de remplacer une fonction virtuelle, pourrait-il être appliqué aux classes de base dans un langage de type C ++?

Nous devons d'abord définir ce qui remplace un héritage de base virtuelle:

Une relation de sous-objet "presque directe" signifie qu'un sous-objet indirect est contrôlé presque comme un sous-objet direct:

  • un sous-objet presque direct peut être initialisé comme un sous-objet direct;
  • le contrôle d'accès n'est jamais vraiment un obstacle à l'accès (des sous-objets privés presque directs inaccessibles peuvent être rendus accessibles à discrétion).

L'héritage virtuel fournit un accès presque direct:

  • le constructeur pour chaque base virtuelle doit être appelé par ctor-init-list du constructeur de la classe la plus dérivée;
  • lorsqu'une classe de base virtuelle est inaccessible car déclarée privée dans une classe de base, ou héritée publiquement dans une classe de base privée d'une classe de base, la classe dérivée a le pouvoir discrétionnaire de déclarer à nouveau la base virtuelle comme base virtuelle, la rendant ainsi accessible.

Un moyen de formaliser le remplacement de base virtuelle consiste à créer une déclaration d'héritage imaginaire dans chaque classe dérivée qui remplace les déclarations d'héritage virtuel de classe de base:

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D
  // , virtual VB  // imaginary overrider of D inheritance of VB
  {
  // DD () : VB() { } // implicit definition
}; 

Désormais, les variantes C ++ qui prennent en charge les deux formes d'héritage n'ont pas besoin d'avoir une sémantique C ++ d'accès presque direct dans toutes les classes dérivées:

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D, virtual final VB {
  // DD () : VB() { } // implicit definition
}; 

Ici, la virtualité de la base VB est figée et ne peut pas être utilisée dans d'autres classes dérivées; la virtualité est rendue invisible et inaccessible aux classes dérivées et l'emplacement de VB est fixe.

struct DDD : DD {
  DD () : 
    VB() // error: not an almost direct subobject
  { } 
}; 
struct DD2 : D, virtual final VB {
  // DD2 () : VB() { } // implicit definition
}; 
struct Diamond : DD, DD2 // error: no unique final overrider
{                        // for ": virtual VB"
}; 

Le gel de la virtualité rend illégal l'unification de Diamond::DD::VB et Diamond::DD2::VB mais la virtualité de VB nécessite une unification qui fait de Diamond une définition de classe contradictoire et illégale: aucune classe ne peut jamais dériver à la fois de DD et de DD2 [analogique / exemple: tout comme aucune classe utile ne peut dériver directement de A1 et A2:

struct A1 {
  virtual int f() = 0;
};
struct A2 {
  virtual unsigned f() = 0;
};
struct UselessAbstract : A1, A2 {
  // no possible declaration of f() here
  // none of the inherited virtual functions can be overridden
  // in UselessAbstract or any derived class
};

Ici, UselessAbstract est abstrait et aucune classe dérivée ne l'est également, ce qui rend cette ABC (classe de base abstraite) extrêmement idiote, car tout pointeur vers UselessAbstract est un pointeur nul.

- fin analogique / exemple]

Cela fournirait un moyen de geler l'héritage virtuel, de fournir un héritage privé significatif des classes avec une base virtuelle (sans cela, les classes dérivées peuvent usurper la relation entre une classe et sa classe de base privée).

Une telle utilisation de final gèlerait bien sûr l'emplacement d'une base virtuelle dans une classe dérivée et ses autres classes dérivées, évitant ainsi des entrées de vtable supplémentaires qui ne sont nécessaires que parce que l'emplacement de la base virtuelle n'est pas fixe.

4
curiousguy 27 nov. 2018 à 22:01

Je pense que l'ajout du mot-clé final ne devrait pas être une rupture ABI, mais le supprimer d'une classe existante peut rendre certaines optimisations invalides. Par exemple, considérez ceci:

// in car.h
struct Vehicle { virtual void honk() { } };
struct Car final : Vehicle { void honk() override { } };

// in car.cpp

// Here, the compiler can assume that no derived class of Car can be passed,
// and so `honk()` can be devirtualized. However, if Car is not final
// anymore, this optimization is invalid.
void foo(Car* car) { car->honk(); }

Si foo est compilé séparément et par exemple livré dans une bibliothèque partagée, la suppression de final (et donc la possibilité pour les utilisateurs de dériver de Car) pourrait rendre l'optimisation invalide.

Je ne suis pas sûr à 100% à ce sujet, une partie est de la spéculation.

1
Louis Dionne 25 nov. 2018 à 04:25

Si vous n'introduisez pas de nouvelles méthodes virtuelles dans votre classe final (ne remplacez que les méthodes de la classe parente), vous devriez être d'accord (la table virtuelle sera la même que l'objet parent, car elle doit pouvoir être appelé avec un pointeur vers le parent), si vous introduisez des méthodes virtuelles, le compilateur peut en effet ignorer le spécificateur virtual et ne générer que des méthodes standard, par exemple:

class A {
    virtual void f();
};

class B final : public A {
    virtual void f(); // <- should be ok
    virtual void g(); // <- not ok
};

L'idée est qu'à chaque fois en C ++ que vous pouvez appeler la méthode g(), vous avez un pointeur / référence dont le type statique et dynamique est B: statique car la méthode n'existe pas sauf pour {{X2} } et ses enfants, dynamiques car final s'assure que B n'a pas d'enfants. Pour cette raison, vous n'avez jamais besoin de faire une répartition virtuelle pour appeler l'implémentation right g() (car il ne peut y en avoir qu'une seule), et le compilateur pourrait (et ne devrait) pas l'ajouter au virtuel table pour B - alors qu'il est forcé de le faire si la méthode peut être remplacée. C'est essentiellement le point pour lequel le mot clé final existe pour autant que je sache

0
pqnet 28 nov. 2018 à 01:24