Uma vez no Slack, me deparei com um novo acrônimo para o meu
glossário de acrônimos em C ++ : "VTT".
Godbolt :
test.o: In function `MyClass': test.cc:3: undefined reference to `VTT for MyClass'
"VTT" neste contexto significa "tabela de tabela virtual". Essa é uma estrutura de dados auxiliar usada (no Itanium C ++ ABI) ao criar algumas classes de base que são herdadas das classes de base virtuais. As VTTs seguem as mesmas regras de layout que as tabelas virtuais (vtable) e as informações de tipo (typeinfo); portanto, se você receber o erro acima, poderá substituir mentalmente “vtable” por “VTT” e iniciar a depuração. (Muito provavelmente, você deixou a
função principal da classe indefinida). Para ver por que a VTT, ou uma estrutura semelhante, é necessária, vamos começar com o básico.
Ordem de design para herança não virtual
Quando temos uma hierarquia de herança, as classes base são construídas
a partir das mais básicas . Para construir Charlie, primeiro precisamos construir as classes pai MrsBucket e MrBucket, recursivamente, para construir MrBucket, primeiro devemos construir as classes pai GrandmaJosephine e GrandpaJoe.
Assim:
struct A {}; struct B : A {}; struct C {}; struct D : C {}; struct E : B, D {};
Ordem de design para classes base virtuais
Mas a herança virtual confunde todos os cartões! Com a herança virtual, podemos ter uma hierarquia em forma de diamante na qual duas classes principais diferentes podem compartilhar um ancestral comum.
struct G {}; struct M : virtual G {}; struct F : virtual G {}; struct E : M, F {};
Na última seção, cada construtor foi responsável por chamar o construtor de sua classe base. Mas agora temos herança virtual, e os construtores M e F devem, de alguma forma, saber que não é necessário construir G, porque é comum. Se M e F fossem responsáveis pela construção dos objetos base nesse caso, o objeto base comum seria construído duas vezes, o que não é muito bom.
Para trabalhar com subobjetos de herança virtual, o Itanium C ++ ABI divide cada construtor em duas partes: o construtor de objeto base e o construtor de objeto completo. O construtor do objeto base é responsável por construir todos os subobjetos de herança não virtual (e seus subobjetos, e instalar seu vptr em sua tabela v, e executar o código entre chaves no código C ++). O construtor do objeto completo, chamado toda vez que você cria o objeto C ++ completo, é responsável por construir todos os subobjetos da herança virtual do objeto derivado e, em seguida, faz o resto.
Considere a diferença entre o nosso exemplo ABCDE da seção anterior e o seguinte exemplo:
struct A {}; struct B : virtual A {}; struct C {}; struct D : virtual C {}; struct E : B, D {};
O construtor do objeto completo E primeiro chama os construtores do objeto base dos subobjetos virtuais A e C; então, os construtores do objeto de herança não virtual de base B e D. são chamados. B e D não são mais responsáveis pela construção de A e C, respectivamente.
Projetando tabelas vtable
Suponha que tenhamos uma classe com alguns métodos virtuais, por exemplo, como (
Godbolt ):
struct Cat { Cat() { poke(); } virtual void poke() { puts("meow"); } }; struct Lion : Cat { std::string roar = "roar"; Lion() { poke(); } void poke() override { roar += '!'; puts(roar.c_str()); } };
Quando construímos o Lion, começamos construindo o subobjeto básico do gato. O construtor de Cat chama poke (). Neste ponto, temos apenas um objeto Cat - ainda não inicializamos os dados do membro necessários para criar o objeto Lion. Se o construtor Cat chamar Lion :: poke (), ele poderá tentar alterar o membro não inicializado de std :: string roar e obteremos UB. Portanto, o padrão C ++ nos obriga a fazer isso no construtor Cat, uma chamada ao método virtual poke () deve chamar Cat :: poke (), não Lion :: poke ()!
Não tem problema. O compilador simplesmente faz com que Cat :: Cat () (a versão para o objeto base e a versão para o objeto completo) inicie definindo o vptr do objeto para a vtable do objeto Cat. Lion :: Lion () chamará Cat :: Cat () e, em seguida, redefinirá vptr para o ponteiro da tabela de objetos Cat dentro de Lion, antes de executar o código entre parênteses. Não tem problema!
Compensações de herança virtual
Deixe o gato virtualmente herdar do Animal. Em seguida, a vtable para Cat armazena não apenas os ponteiros de função para as funções membro virtuais do Cat, mas também o deslocamento do subobjeto virtual Animal no Cat. (
Godbolt .)
struct Animal { const char *data = "hi"; }; struct Cat : virtual Animal { Cat() { puts(data); } }; struct Nermal : Cat {}; struct Garfield : Cat { int padding; };
O construtor Cat consulta o membro Animal :: data. Se esse objeto Cat for o subobjeto base do objeto Nermal, seus dados de membro estarão no deslocamento 8, logo atrás do vptr. Mas se o objeto Cat for o subobjeto subjacente do objeto Garfield, os dados do membro estarão no deslocamento 16, atrás de vptr e Garfield :: padding. Para lidar com isso, o Itanium ABI armazena os deslocamentos dos objetos de base virtuais na tabela do objeto Cat. A tabela para Cat-in-Nermal preserva o fato de que Animal, o subobjeto de gato subjacente, é armazenado no deslocamento 8; A tabela para Cat-in-Garfield mantém o fato de que Animal, o subobjeto de gato subjacente, é armazenado no deslocamento 16.
Agora combine isso com a seção anterior. O compilador deve garantir que Cat :: Cat () (a versão do objeto base e a versão do objeto completo) inicie a instalação do vptr no vtable para Cat-in-Nermal ou no vtable para Cat-in-Garfield, dependendo do tipo mais facilidade derivada! Mas como isso funciona?
O construtor do objeto completo para o objeto mais derivado deve pré-calcular qual tabela vtable ele deseja que o vptr do subobjeto base faça referência durante o tempo de construção do objeto e, em seguida, o construtor do objeto completo para o objeto mais derivado deve passar essas informações para o construtor do objeto base do subobjeto base como um parâmetro oculto! Vejamos o código gerado para Cat :: Cat () (
Godbolt ):
_ZN3CatC1Ev: # Cat movq $_ZTV3Cat+24, (%rdi) # this->vptr = &vtable-for-Cat; retq _ZN3CatC2Ev: # Cat movq (%rsi), %rax # fetch a value from rsi movq %rax, (%rdi) # this->vptr = *rsi; retq
O construtor do objeto base aceita não apenas esse parâmetro oculto em% rdi, mas também o parâmetro VTT oculto em% rsi! O construtor de objeto base carrega o endereço de (% rsi) e armazena o endereço na tabela de tabelas do objeto Cat.
Quem chama o construtor do objeto Cat base é responsável por prever qual endereço Cat :: Cat () deve ser escrito no vptr e por definir o ponteiro em (% rsi) para esse endereço.
Por que precisamos de outro nível de identidade?
Considere o construtor de um objeto Nermal completo.
_ZN3CatC2Ev: # Cat movq (%rsi), %rax # rsi movq %rax, (%rdi) # this->vptr = *rsi; retq _ZN6NermalC1Ev: # Nermal pushq %rbx movq %rdi, %rbx movl $_ZTT6Nermal+8, %esi # %rsi = &VTT-for-Nermal callq _ZN3CatC2Ev # Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq _ZTT6Nermal: .quad _ZTV6Nermal+24 # vtable-for-Nermal .quad _ZTC6Nermal0_3Cat+24 # construction-vtable-for-Cat-in-Nermal
Por que o _ZTC6Nermal0_3Cat + 24 está localizado na seção de dados e seu endereço é passado para% rsi, em vez de apenas passar diretamente o _ZTC6Nermal0_3Cat + 24?
# ? _ZN3CatC2Ev: # Cat movq %rsi, (%rdi) # this->vptr = rsi; retq _ZN6NermalC1Ev: # Nermal pushq %rbx movq %rdi, %rbx movl $_ZTC6Nermal0_3Cat+24, %esi # %rsi = &construction-vtable-for-Cat-in-Nermal callq _ZN3CatC2Ev # Cat movq $_ZTV6Nermal+24, (%rbx) # this->vptr = &vtable-for-Nermal popq %rbx retq
Isso ocorre porque podemos ter vários níveis de herança! Em cada nível de herança, o construtor do objeto base deve definir vptr e, em seguida, possivelmente, passar o controle da cadeia para o próximo construtor base, que pode definir vptrs para outro valor. Isso implica em uma lista ou uma tabela de ponteiros para o vtable.
Aqui está um exemplo concreto (
Godbolt ):
struct VB { int member_of_vb = 42; }; struct Grandparent : virtual VB { Grandparent() {} }; struct Parent : Grandparent { Parent() {} }; struct Gretel : Parent { Gretel() : VB{1000} {} }; struct Hansel : Parent { int padding; Hansel() : VB{2000} {} };
O objeto construtor de base Grandparent deve definir seu vptr como Grandparent - outra coisa, que é a classe mais derivada. O construtor do objeto base Parent deve primeiro chamar Grandparent :: Grandparent () com o% rsi apropriado e, em seguida, definir vptr como Parent - outra coisa, que é a classe mais derivada. Uma maneira de implementar isso para Gretel:
Gretel::Gretel() [ ]: pushq %rbx movq %rdi, %rbx movl $1000, 8(%rdi) # imm = 0x3E8 movl $VTT for Gretel+8, %esi callq Parent::Parent() [ ] movq $vtable for Gretel+24, (%rbx) popq %rbx retq VTT for Gretel: .quad vtable for Gretel+24 .quad construction vtable for Parent-in-Gretel+24 .quad construction vtable for Grandparent-in-Gretel+24
Você pode ver no Godbolt que o construtor do objeto base da classe Parent primeiro chama Grandparent :: Grandparent () com% rsi + 8 e depois define seu próprio vptr como (% rsi). Então, aqui estamos usando o fato de que Gretel, por assim dizer, estabeleceu cuidadosamente um caminho de migalhas ao longo do qual todas as suas classes básicas seguiram durante a construção.
O mesmo VTT é usado no destruidor (
Godbolt ). Tanto quanto eu sei, a linha nula da tabela VTT nunca é usada. O construtor Gretel carrega a vtable para Gretel + 24 no vptr, mas ele sabe que esse endereço é estático, nunca precisa ser carregado pelo VTT. Eu acho que a linha zero da tabela foi preservada simplesmente por razões históricas. (E, claro, o compilador não pode simplesmente jogá-lo fora, porque será uma violação do Itanium ABI e será impossível vincular ao código antigo que adere ao Itanium-ABI).
Isso é tudo, vimos uma tabela de tabelas virtuais, ou VTT.
Maiores informações
Você pode encontrar informações da VTT nesses locais:
StackOverflow: “
O que é o VTT para uma classe? "
“
Notas da VTable sobre herança múltipla no GCC C ++ Compiler v4.0.1 ” (Morgan Deters, 2005)
A seção
Itanium C ++ ABI , “Ordem VTT”
Finalmente, tenho que reiterar que a VTT é um recurso do Itanium C ++ ABI e é usado no Linux, OSX, etc. A ABV do MSVC usada no Windows não possui VTT e usa um mecanismo completamente diferente para herança virtual. Até agora, não sei quase nada sobre a MSVC ABI, mas talvez um dia eu descubra tudo e escreva um post sobre isso!