Estou assistindo a palestra de Chandler Carruth no CppCon 2019:
Não há abstrações de custo zero
nele, ele dá o exemplo de como ficou surpreso com a quantidade de sobrecarga que você incorre usando um std::unique_ptr<int>
over over int*
; esse segmento começa aproximadamente no ponto 17:25.
Você pode dar uma olhada nos resultados da compilação de seu exemplo de par de trechos (godbolt.org) - para testemunhar que, de fato, parece que o compilador não está disposto a passar o valor unique_ptr - que, de fato, na linha inferior é apenas um endereço - dentro de um registro, apenas na memória direta.
Um dos pontos que Carruth destaca por volta das 27:00 é que o C ++ ABI requer parâmetros de valor (alguns, mas não todos; talvez - tipos não primitivos? Tipos não trivialmente construtíveis?) Para serem passados na memória em vez de dentro de um registro.
Minhas perguntas:
- Isso é realmente um requisito da ABI em algumas plataformas? (qual?) Ou talvez seja apenas uma pessimização em certos cenários?
- Por que a ABI é assim? Ou seja, se os campos de uma estrutura / classe se encaixam em registros ou mesmo em um único registro - por que não devemos ser capazes de passá-lo nesse registro?
- O comitê de padrões de C ++ discutiu esse ponto nos últimos anos ou nunca?
PS - Para não deixar essa pergunta sem código:
Ponteiro simples:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Ponteiro exclusivo:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
fonte
this
ponteiro que aponte para um local válido.unique_ptr
tem aqueles. Derramar o registro para esse fim anularia toda a otimização "passar em um registro".Respostas:
Um exemplo é o System V Application Binary Interface Arquitetura AMD64 Suplemento Processor . Essa ABI é para CPUs compatíveis com x86 de 64 bits (arquitetura Linux x86_64). É seguido no Solaris, Linux, FreeBSD, macOS, Windows Subsystem para Linux:
Observe que apenas 2 registradores de uso geral podem ser usados para passar 1 objeto com um construtor de cópia trivial e um destruidor trivial, ou seja, apenas valores de objetos com
sizeof
não mais que 16 podem ser passados em registradores. Consulte Convenções de chamada de Agner Fog para obter um tratamento detalhado das convenções de chamada, em particular §7.1 Passando e retornando objetos. Existem convenções de chamada separadas para a passagem de tipos SIMD nos registradores.Existem ABIs diferentes para outras arquiteturas de CPU.
É um detalhe de implementação, mas quando uma exceção é manipulada, durante o desenrolamento da pilha, os objetos com duração automática de armazenamento sendo destruídos devem ser endereçáveis em relação ao quadro da pilha de funções, porque os registros foram derrotados naquele tempo. O código de desenrolamento de pilha precisa dos endereços dos objetos para chamar seus destruidores, mas os objetos nos registradores não têm um endereço.
Pedanticamente, os destruidores operam em objetos :
e um objeto não pode existir no C ++ se nenhum armazenamento endereçável for alocado para ele porque a identidade do objeto é o seu endereço .
Quando um endereço de um objeto com um construtor de cópia trivial mantido em registros é necessário, o compilador pode simplesmente armazenar o objeto na memória e obter o endereço. Se o construtor de cópias não é trivial, por outro lado, o compilador não pode apenas armazená-lo na memória, mas precisa chamar o construtor de cópias que faz uma referência e, portanto, requer o endereço do objeto nos registradores. A convenção de chamada provavelmente não pode depender se o construtor de cópia foi incorporado no chamado ou não.
Outra maneira de pensar sobre isso é que, para tipos trivialmente copiáveis, o compilador transfere o valor de um objeto em registradores, dos quais um objeto pode ser recuperado por armazenamentos de memória simples, se necessário. Por exemplo:
no x86_64 com o System V ABI compila em:
Em sua palestra instigante, Chandler Carruth menciona que uma mudança quebrada na ABI pode ser necessária (entre outras coisas) para implementar o movimento destrutivo que poderia melhorar as coisas. Na IMO, a alteração da ABI pode ser ininterrupta se as funções que usam a nova ABI optarem explicitamente por ter um novo vínculo diferente, por exemplo, declará-las em
extern "C++20" {}
bloco (possivelmente em um novo espaço de nome em linha para migrar APIs existentes). Para que apenas o código compilado com as novas declarações de função com a nova ligação possa usar a nova ABI.Observe que a ABI não se aplica quando a função chamada foi incorporada. Assim como na geração do código no tempo do link, o compilador pode incorporar funções definidas em outras unidades de tradução ou usar convenções de chamada personalizadas.
fonte
Com ABIs comuns, o destruidor não trivial -> não pode passar nos registros
(Uma ilustração de um ponto na resposta de @ MaximEgorushkin usando o exemplo de @ harold em um comentário; corrigida conforme o comentário de @ Yakk.)
Se você compilar:
você obtém:
ou seja, o
Foo
objeto é passadotest
em um registrador (edi
) e também retornado em um registrador (eax
).Quando o destruidor não é trivial (como o
std::unique_ptr
exemplo de OPs) - ABIs comuns exigem posicionamento na pilha. Isso é verdade mesmo que o destruidor não use o endereço do objeto.Portanto, mesmo no caso extremo de um destruidor de não fazer nada, se você compilar:
você obtém:
com carregamento e armazenamento inúteis.
fonte
std::unique_ptr
em um registro não conforme.register
palavra-chave destinava-se a tornar trivial para a máquina física armazenar algo em um registro, bloqueando coisas que praticamente tornam mais difícil "não ter endereço" na máquina física.Se algo estiver visível no limite da unidade de compliação, seja definido implícita ou explicitamente, ele se tornará parte da ABI.
O problema fundamental é que os registros são salvos e restaurados o tempo todo enquanto você move para baixo e para cima na pilha de chamadas. Portanto, não é prático ter uma referência ou ponteiro para eles.
O alinhamento e as otimizações resultantes disso são bons quando isso acontece, mas um designer da ABI não pode confiar nisso. Eles precisam projetar a ABI assumindo o pior caso. Eu não acho que os programadores ficariam muito felizes com um compilador em que a ABI mudou dependendo do nível de otimização.
Um tipo trivialmente copiável pode ser passado em registradores porque a operação de cópia lógica pode ser dividida em duas partes. Os parâmetros são copiados para os registradores usados para transmitir parâmetros pelo chamador e, em seguida, copiados para a variável local pelo receptor. Se a variável local possui ou não um local de memória, isso é apenas uma preocupação do chamado.
Um tipo em que um construtor de copiar ou mover deve ser usado, por outro lado, não pode ter sua operação de cópia dividida dessa maneira, portanto deve ser transmitida na memória.
Não tenho idéia se os órgãos de normas consideraram isso.
A solução óbvia para mim seria adicionar movimentos destrutivos adequados (em vez da casa intermediária atual de um "estado válido mas não especificado") ao idioma, em seguida, introduzir uma maneira de sinalizar um tipo como permitindo "movimentos destrutivos triviais" "mesmo que não permita cópias triviais.
mas essa solução exigiria quebrar a ABI do código existente para implementar nos tipos existentes, o que pode trazer um pouco de resistência (embora a ABI quebre como resultado de novas versões padrão do C ++ não sejam sem precedentes, por exemplo, as alterações std :: string em C ++ 11 resultou em uma quebra ABI ..
fonte
unique_ptr
eshared_ptr
semântico:shared_ptr<T>
permite fornecer ao ctor 1) um ptr x ao objeto derivado U a ser excluído com o tipo estático U com a expressãodelete x;
(para que você não precise de um dtor virtual aqui) 2) ou até uma função de limpeza personalizada. Isso significa que o estado de tempo de execução é usado dentro doshared_ptr
bloco de controle para codificar essas informações. OTOHunique_ptr
não possui essa funcionalidade e não codifica o comportamento de exclusão no estado; a única maneira de personalizar a limpeza é criar outra instância de modelo (outro tipo de classe).Primeiro, precisamos voltar ao que significa passar por valor e por referência.
Para linguagens como Java e SML, a passagem por valor é direta (e não há passagem por referência), assim como a cópia de um valor variável é, pois todas as variáveis são apenas escalares e possuem cópia semântica: elas são o que contam como aritmética digite C ++ ou "referências" (ponteiros com nome e sintaxe diferentes).
Em C, temos tipos escalares e definidos pelo usuário:
No C ++, os tipos definidos pelo usuário podem ter semântica de cópia definida pelo usuário, o que permite uma programação verdadeiramente "orientada a objetos" com objetos com propriedade de seus recursos e operações de "cópia profunda". Nesse caso, uma operação de cópia é realmente uma chamada para uma função que quase pode executar operações arbitrárias.
Para estruturas C compiladas como C ++, "copiar" ainda é definido como chamar a operação de cópia definida pelo usuário (construtor ou operador de atribuição), que são gerados implicitamente pelo compilador. Isso significa que a semântica de um programa de subconjunto comum de C / C ++ é diferente em C e C ++: em C, todo um tipo de agregado é copiado; em C ++, uma função de cópia gerada implicitamente é chamada para copiar cada membro; o resultado final é que, em ambos os casos, cada membro é copiado.
(Acho que há uma exceção quando uma estrutura dentro de uma união é copiada.)
Portanto, para um tipo de classe, a única maneira (fora da união de cópias) de criar uma nova instância é através de um construtor (mesmo para aqueles com construtores triviais gerados por compiladores).
Você não pode pegar o endereço de um rvalue por meio de um operador unário,
&
mas isso não significa que não há objeto rvalue; e um objeto, por definição, tem um endereço ; e esse endereço é mesmo representado por uma construção de sintaxe: um objeto do tipo classe só pode ser criado por um construtor e possui umthis
ponteiro; mas para tipos triviais, não há construtor gravado pelo usuário; portanto, não há lugar para colocarthis
até que a cópia seja construída e nomeada.Para o tipo escalar, o valor de um objeto é o rvalor do objeto, o valor matemático puro armazenado no objeto.
Para um tipo de classe, a única noção de um valor do objeto é outra cópia do objeto, que só pode ser feita por um construtor de cópias, uma função real (embora, para tipos triviais, essa função seja tão trivial, às vezes isso pode ser criado sem chamar o construtor). Isso significa que o valor do objeto é o resultado da alteração do estado do programa global por uma execução . Não acessa matematicamente.
Portanto, passar por valor realmente não é uma coisa: é passar por chamada de construtor de cópia , o que é menos bonito. Espera-se que o construtor de cópia execute uma operação sensata de "cópia" de acordo com a semântica apropriada do tipo de objeto, respeitando seus invariantes internos (que são propriedades abstratas do usuário, não propriedades intrínsecas do C ++).
Passar pelo valor de um objeto de classe significa:
Observe que o problema não tem nada a ver com a cópia em si ser um objeto com um endereço: todos os parâmetros de função são objetos e têm um endereço (no nível semântico do idioma).
A questão é se:
No caso de um tipo de classe trivial, você ainda pode definir o membro da cópia de membro do original, para definir o valor puro do original devido à trivialidade das operações de cópia (construtor e atribuição de cópia). Não é assim com funções especiais arbitrárias do usuário: um valor do original deve ser uma cópia construída.
Objetos de classe devem ser construídos pelo chamador; um construtor formalmente tem um
this
ponteiro, mas o formalismo não é relevante aqui: todos os objetos têm formalmente um endereço, mas somente aqueles que realmente usam seu endereço de maneiras não puramente locais (ao contrário do*&i = 1;
que é puramente o uso local de endereço) precisam ter um bem definido endereço.Um objeto deve absolutamente passar por endereço se parecer que ele possui um endereço nessas duas funções compiladas separadamente:
Aqui, mesmo que
something(address)
seja uma função ou macro pura ou qualquer outra coisa (comoprintf("%p",arg)
) que não possa armazenar o endereço ou se comunicar com outra entidade, temos o requisito de passar por endereço, porque o endereço deve ser bem definido para um objeto únicoint
que possui um único identidade.Não sabemos se uma função externa será "pura" em termos de endereços passados para ela.
Aqui, o potencial para um uso real do endereço em um construtor ou destruidor não trivial do lado do chamador é provavelmente o motivo para seguir a rota segura e simplista e dar ao objeto uma identidade no chamador e transmitir seu endereço, conforme ele faz Certifique-se de que qualquer uso não trivial de seu endereço no construtor, após a construção e no destruidor seja consistente :
this
deve parecer o mesmo sobre a existência do objeto.Um construtor ou destruidor não trivial, como qualquer outra função, pode usar o
this
ponteiro de uma maneira que exija consistência sobre seu valor, mesmo que algum objeto com material não trivial possa não:Observe que, nesse caso, apesar do uso explícito de um ponteiro (sintaxe explícita
this->
), a identidade do objeto é irrelevante: o compilador pode muito bem usar copiar bit a bit o objeto para movê-lo e fazer "copiar elisão". Isso se baseia no nível de "pureza" do uso dethis
funções-membro especiais (o endereço não escapa).Mas pureza não é um atributo disponível no nível de declaração padrão (existem extensões do compilador que adicionam descrição de pureza a declarações de funções não embutidas), portanto, você não pode definir uma ABI com base na pureza do código que pode não estar disponível (o código pode ou não pode não estar em linha e disponível para análise).
A pureza é medida como "certamente pura" ou "impura ou desconhecida". O terreno comum, ou o limite superior da semântica (na verdade, máximo), ou LCM (Mínimo Múltiplo Comum) é "desconhecido". Assim, a ABI resolve o desconhecido.
Resumo:
Possível trabalho futuro:
A anotação de pureza é útil o suficiente para ser generalizada e padronizada?
fonte
void foo(unique_ptr<int> ptr)
pega o objeto de classe por valor . Esse objeto tem um membro ponteiro, mas estamos falando sobre o próprio objeto de classe sendo passado por referência. (Como não é trivialmente copiável, é necessário que seu construtor / destruidor seja consistentethis
.) Esse é o argumento real e não está conectado ao primeiro exemplo de passagem por referência explicitamente ; nesse caso, o ponteiro é passado em um registro.int
: escrevi um exemplo de "fileno inteligente" que ilustra que "propriedade" não tem nada a ver com "carregar um ptr".unique_ptr<T*>
, esse é o mesmo tamanho e layout queT*
cabe em um registro. Objetos de classe copiáveis de maneira trivial podem ser passados por valor em registradores no x86-64 System V, como na maioria das convenções de chamada. Isso faz uma cópia dounique_ptr
objeto, diferente doint
exemplo em que o chamado&i
é o endereço do chamadori
porque você passou por referência no nível C ++ , não apenas como um detalhe de implementação do asm.unique_ptr
objeto; está usando,std::move
então é seguro copiá-lo, porque isso não resultará em duas cópias do mesmounique_ptr
. Mas para um tipo trivialmente copiável, sim, ele copia todo o objeto agregado. Se esse membro é único, as convenções de boas chamadas tratam o mesmo como um escalar desse tipo.struct{}
é uma estrutura C ++. Talvez você deva dizer "estruturas simples" ou "ao contrário de C". Porque sim, há uma diferença. Se você usaratomic_int
como um membro struct, C o copiará de maneira não atômica, erro C ++ no construtor de cópia excluído. Eu esqueço o que o C ++ faz em estruturas comvolatile
membros. C permitirá que vocêstruct tmp = volatile_struct;
copie a coisa toda (útil para um SeqLock); C ++ não.