Uma variação pimpl pode ser implementada sem nenhuma penalidade de desempenho?

9

Um dos problemas do pimpl é a penalidade de desempenho em usá-lo (alocação de memória adicional, membros de dados não contíguos, indiretos adicionais, etc.). Gostaria de propor uma variação no idioma pimpl que evite essas penalidades de desempenho às custas de não obter todos os benefícios do pimpl. A idéia é deixar todos os membros de dados privados na própria classe e mover apenas os métodos privados para a classe pimpl. O benefício comparado ao pimpl básico é que a memória permanece contígua (sem necessidade de direcionamento adicional). Os benefícios comparados a não usar pimpl são:

  1. Esconde as funções privadas.
  2. Você pode estruturá-lo para que todas essas funções tenham ligação interna e permitam ao compilador otimizá-lo mais agressivamente.

Então, minha idéia é fazer com que o pimpl seja herdado da própria classe (parece um pouco louco, eu sei, mas tenha paciência comigo). Seria algo como isto:

No arquivo Ah:

class A
{
    A();
    void DoSomething();
protected:  //All private stuff have to be protected now
    int mData1;
    int mData2;
//Not even a mention of a PImpl in the header file :)
};

No arquivo A.cpp:

#define PCALL (static_cast<PImpl*>(this))

namespace //anonymous - guarantees internal linkage
{
struct PImpl : public A
{
    static_assert(sizeof(PImpl) == sizeof(A), 
                  "Adding data members to PImpl - not allowed!");
    void DoSomething1();
    void DoSomething2();
    //No data members, just functions!
};

void PImpl::DoSomething1()
{
    mData1 = bar(mData2); //No Problem: PImpl sees A's members as it's own
    DoSomething2();
}

void PImpl::DoSomething2()
{
    mData2 = baz();
}

}
A::A(){}

void A::DoSomething()
{
    mData2 = foo();
    PCALL->DoSomething1(); //No additional indirection, everything can be completely inlined
}

Até onde eu vejo, não há absolutamente nenhuma penalidade de desempenho no uso deste vs pimpl e alguns possíveis ganhos de desempenho e uma interface de arquivo de cabeçalho mais limpa. Uma desvantagem que isso tem em relação ao pimpl padrão é que você não pode ocultar os membros dos dados, portanto as alterações nesses membros ainda acionarão uma recompilação de tudo o que depende do arquivo de cabeçalho. Mas, do meu ponto de vista, é esse benefício ou o desempenho de manter os membros contíguos na memória (ou fazer isso- "Por que a tentativa nº 3 é deplorável"). Outra ressalva é que, se A é uma classe de modelo, a sintaxe fica irritante (você sabe, você não pode usar mData1 diretamente, é necessário fazer isso-> mData1 e precisa começar a usar o tipo de texto e talvez as palavras-chave do modelo para tipos dependentes tipos de modelos, etc.). Outra ressalva é que você não pode mais usar o privado na classe original, apenas membros protegidos; portanto, não pode restringir o acesso de nenhuma classe herdada, não apenas o pimpl. Eu tentei, mas não pude contornar esse problema. Por exemplo, tentei tornar o pimpl uma classe de modelo de amigo na esperança de tornar a declaração de amigo suficientemente ampla para permitir que eu definisse a classe de pimpl real em um espaço de nome anônimo, mas isso simplesmente não funciona. Se alguém tiver alguma idéia de como manter os membros de dados privados e ainda permitir que uma classe pimpl herdada definida em um espaço de nome anônimo acesse esses, eu realmente gostaria de vê-lo! Isso eliminaria minha reserva principal de usar isso.

Porém, sinto que essas advertências são aceitáveis ​​pelos benefícios do que proponho.

Tentei procurar online alguma referência a esse idioma "pimpl somente de função", mas não consegui encontrar nada. Estou realmente interessado no que as pessoas pensam sobre isso. Existem outros problemas com este ou os motivos pelos quais não devo usá-lo?

ATUALIZAR:

Eu encontrei essa proposta que mais ou menos tenta realizar exatamente o que sou, mas fazendo isso alterando o padrão. Eu concordo completamente com essa proposta e espero que ela se enquadre no padrão (não conheço nada desse processo, portanto não tenho idéia da probabilidade de isso acontecer). Eu preferiria que fosse possível fazer isso por meio de um mecanismo de linguagem incorporado. A proposta também explica os benefícios do que estou tentando alcançar muito melhor do que eu. Ele também não tem o problema de quebrar o encapsulamento, como minha sugestão tem (privado -> protegido). Ainda assim, até que a proposta chegue ao padrão (se isso acontecer), acho que minha sugestão possibilita esses benefícios, com as ressalvas que listei.

UPDATE2:

Uma das respostas menciona o LTO como uma possível alternativa para obter alguns dos benefícios (acho que otimizações mais agressivas). Não sei exatamente o que acontece em várias passagens de otimização do compilador, mas tenho um pouco de experiência com o código resultante (uso o gcc). Simplesmente colocar os métodos privados na classe original forçará aqueles a ter um vínculo externo.

Eu posso estar errado aqui, mas a maneira como interpreto isso é que o otimizador em tempo de compilação não pode eliminar a função, mesmo que todas as suas instâncias de chamada estejam completamente embutidas nessa TU. Por alguma razão, até o LTO se recusa a se livrar da definição da função, mesmo que pareça que todas as instâncias de chamada em todo o binário vinculado estejam todas inline. Encontrei algumas referências afirmando que é porque o vinculador não sabe se, de alguma forma, você ainda chamará a função usando ponteiros de função (embora eu não entenda por que o vinculador não consegue descobrir que o endereço desse método nunca é usado )

Este não é o caso se você usar minha sugestão e colocar esses métodos privados em um pimpl dentro de um espaço para nome anônimo. Se eles forem incorporados, as funções NÃO aparecerão (com -O3, que inclui -finline-functions) no arquivo de objeto.

Pelo que entendi, o otimizador, ao decidir se alinha ou não uma função, leva em consideração seu impacto no tamanho do código. Portanto, usando minha sugestão, estou tornando um pouco "mais barato" para o otimizador incorporar esses métodos particulares.

dcmm88
fonte
O uso de PCALLé um comportamento indefinido. Você não pode converter um Apara um PImple usá-lo, a menos que o objeto subjacente seja realmente do tipo PImpl. No entanto, a menos que eu esteja enganado, os usuários apenas criarão objetos do tipo A.
BeeOnRope

Respostas:

8

Os pontos de venda do padrão Pimpl são:

  • encapsulamento total: não há membros de dados (privados) mencionados no arquivo de cabeçalho do objeto de interface.
  • estabilidade: até você quebrar a interface pública (que no C ++ inclui membros particulares), você nunca precisará recompilar o código que depende do objeto da interface. Isso torna o Pimpl um ótimo padrão para bibliotecas que não desejam que seus usuários recompilem todo o código em todas as alterações internas.
  • polimorfismo e injeção de dependência: a implementação ou o comportamento do objeto de interface pode ser facilmente trocado no tempo de execução, sem exigir que o código dependente seja recompilado. Ótimo se você precisar zombar de algo para um teste de unidade.

Para esse efeito, o Pimpl clássico consiste em três partes:

  • Uma interface para o objeto de implementação, que deve ser público e usar métodos virtuais para a interface:

    class IFrobnicateImpl
    {
    public:
        virtual int frobnicate(int) const = 0;
    };

    Essa interface é necessária para ser estável.

  • Um objeto de interface que faz proxy com a implementação privada. Não precisa usar métodos virtuais. O único membro permitido é um ponteiro para a implementação:

    class Frobnicate
    {
        std::unique_ptr<IFrobnicateImpl> _impl;
    public:
        explicit Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl = nullptr);
        int frobnicate(int x) const { return _impl->frobnicate(x); }
    };
    
    ...
    
    Frobnicate::Frobnicate(std::unique_ptr<IFrobnicateImpl>&& impl /* = nullptr */)
    : _impl(std::move(impl))
    {
        if (!_impl)
            _impl = std::make_unique<DefaultImplementation>();
    }

    O arquivo de cabeçalho desta classe deve ser estável.

  • Pelo menos uma implementação

O Pimpl nos compra uma grande estabilidade para uma classe de biblioteca, ao custo de uma alocação de heap e envio virtual adicional.

Como sua solução se compara?

  • Ele acaba com o encapsulamento. Como seus membros estão protegidos, qualquer subclasse pode mexer com eles.
  • Ele acaba com a estabilidade da interface. Sempre que você alterar seus membros de dados - e essa alteração estiver a apenas uma refatoração de distância - você terá que recompilar todo o código dependente.
  • Ele elimina a camada de despacho virtual, impedindo a troca fácil da implementação.

Portanto, para cada objetivo do padrão Pimpl, você falha em cumprir esse objetivo. Portanto, não é razoável chamar seu padrão de variação do Pimpl; é muito mais uma classe comum. Na verdade, é pior do que uma classe comum porque suas variáveis ​​de membro são privadas. E por causa desse elenco, que é um ponto flagrante de fragilidade.

Observe que o padrão Pimpl nem sempre é ideal - há uma troca entre estabilidade e polimorfismo, por um lado, e compactação de memória, por outro. É semanticamente impossível para um idioma ter os dois (sem compilação JIT). Portanto, se você está otimizando minuciosamente a compactação de memória, claramente o Pimpl não é uma solução adequada para o seu caso de uso. Você provavelmente também deixará de usar metade da biblioteca padrão, pois essas terríveis classes de vetores e seqüências envolvem alocações dinâmicas de memória ;-)

amon
fonte
Sobre sua última observação sobre o uso de strings e vetores - é muito próximo da verdade :). Entendo que o pimpl tem benefícios que minha sugestão simplesmente não oferece. Porém, ele traz benefícios, que são apresentados de forma mais eloquente no link que adicionei no UPDATE. Considerando que o desempenho desempenha um papel muito importante no meu caso de uso, como você compararia minha sugestão a não usar o pimpl? Porque, para o meu caso de uso, não é minha sugestão x pimpl, é minha sugestão x pimpl, pois o pimpl tem custos de desempenho que eu não quero incorrer.
precisa saber é o seguinte
1
@ dcmm88 Se o desempenho é seu objetivo nº 1, obviamente coisas como qualidade de código e encapsulamento são um jogo justo. A única coisa que sua solução melhora em relação às classes normais é evitar uma recompilação quando as assinaturas de método privado são alteradas. No meu livro, isso não é muito, mas se essa é uma classe fundamental em uma grande base de código, a implementação estranha pode valer a pena. Consulte este gráfico XKCD à mão para determinar quanto tempo de desenvolvimento você pode dedicar para reduzir o tempo de compilação . Dependendo do seu salário, a atualização do seu computador pode ser mais barata.
amon
1
Também permite que as funções privadas tenham ligação interna, permitindo uma otimização mais agressiva.
dcmm88
1
Parece que você expõe a interface pública da classe pimpl no arquivo de cabeçalho da classe original. AFAUI toda a idéia é esconder o máximo possível. Geralmente, da maneira como vejo o pimpl sendo implementado, o arquivo de cabeçalho tem apenas uma declaração de encaminhamento da classe pimpl e o cpp tem a definição completa da classe pimpl. Eu acho que a capacidade de troca vem com um padrão de design diferente - o padrão da ponte. Portanto, não tenho certeza se é justo dizer que minha sugestão não fornece, quando o pimpl geralmente não fornece.
achou
Desculpe pelo último comentário. Você realmente tem uma resposta muito grande, e o ponto está no fim, então eu perdi
#
3

Para mim, as vantagens não superam as desvantagens.

Vantagens :

Ele pode acelerar a compilação, pois salva uma reconstrução se apenas as assinaturas de método privado forem alteradas. Mas a reconstrução é necessária se as assinaturas de método público ou protegido ou os membros de dados privados foram alterados, e é raro que eu precise alterar as assinaturas de método privado sem tocar em nenhuma dessas outras opções.

Ele pode permitir otimizações de compilador mais agressivas, mas o LTO deve permitir muitas das mesmas otimizações (pelo menos, eu acho que pode - eu não sou um guru de otimização de compiladores), além de outras mais, e pode ser padronizado e automático.

Desvantagens:

Você mencionou algumas desvantagens: a incapacidade de usar privado e as complexidades com modelos. Para mim, porém, a maior desvantagem é que é simplesmente estranho: um estilo de programação não convencional, com saltos no estilo pimpl não muito padrão entre interface e implementação, que não serão familiares a futuros mantenedores ou novos membros da equipe e que podem ser mal suportado por ferramentas (veja, por exemplo, esse bug do GDB ).

As preocupações padrão sobre otimização aplicam-se aqui: Você mediu que as otimizações melhoram significativamente seu desempenho? Seu desempenho seria melhor fazendo isso ou gastando o tempo necessário para mantê-lo e investi-lo em criação de perfis de pontos ativos, aprimorando algoritmos etc.? Pessoalmente, prefiro escolher um estilo de programação claro e direto, supondo que isso libere tempo para fazer otimizações direcionadas. Mas essa é minha perspectiva para os tipos de código nos quais trabalho - para o domínio do seu problema, as vantagens e desvantagens podem ser diferentes.

Nota lateral: Permitindo particular com pimpl somente de método

Você perguntou sobre como permitir membros privados com sua sugestão de pimpl somente de método. Infelizmente, considero o método pimpl apenas de método um tipo de hack, mas se você decidiu que as vantagens superam as desvantagens, é melhor adotar o hack.

Ah:

#ifndef A_impl
#define A_impl private
#endif

class A
{
public:
    A();
    void DoSomething();
A_impl:
    int mData1;
    int mData2;
};

A.cpp:

#define A_impl public
#include "A.h"
Josh Kelley
fonte
Estou um pouco envergonhado de dizer, mas gosto da sua sugestão privada #define A_impl :). Também não sou um guru da otimização, mas tenho alguma experiência com LTO, e brincar com isso foi o que me levou a pensar nisso. Atualizarei minha pergunta com as informações que tenho sobre o uso e por que ela ainda não oferece todos os benefícios que o pimpl somente de método fornece.
precisa saber é o seguinte
@ dcmm88 - Obrigado pela informação sobre LTO. Não é algo com o qual tenho muita experiência.
Josh Kelley
1

Você pode usar std::aligned_storagepara declarar armazenamento para seu pimpl na classe de interface.

class A
{
std::aligned_storage< 128 > _storage;
public:
  A();
};

Na implementação, você pode construir sua classe Pimpl no local _storage:

class Pimpl
{
  int _some_data;
};

A::A()
{
  ::new(&_storage) Pimpl();
  // accessing Pimpl: *(Pimpl*)(_storage);
}

A::~A()
{
  ((Pimpl*)(_storage))->~Pimpl(); // calls destructor for inline pimpl
}
Fabio
fonte
Esqueceu de mencionar que você precisa aumentar o tamanho do armazenamento de vez em quando, forçando assim a recompilação. Com frequência, você poderá compactar muitas alterações que não acionam recompilações - se você der uma folga no armazenamento.
Fabio
0

Não, você não pode implementá-lo sem uma penalidade de desempenho. O PIMPL é, por sua própria natureza, uma penalidade de desempenho, pois você está aplicando um indireto em tempo de execução.

Obviamente, isso depende exatamente do que você deseja indiretamente. Algumas informações simplesmente não são usadas pelo consumidor - como exatamente o que você pretende colocar em seus 64 bytes alinhados de 4 bytes. Mas outras informações são como o fato de que você deseja 64 bytes alinhados por 4 bytes para o seu objeto.

PIMPLs genéricos sem penalidades de desempenho não existem e nunca existirão. É a mesma informação que você nega ao usuário que ele deseja usar para otimizar. Se você der a eles, seu IMPL não será abstraído; se você negar, eles não podem otimizar. Você não pode ter as duas coisas.

DeadMG
fonte
Forneci uma possível sugestão de "pimpl parcial" que, segundo afirmo, não possui penalidade de desempenho e possíveis ganhos de desempenho. Você pode se ressentir por eu chamar isso de algo relacionado ao pimpl, mas estou ocultando alguns detalhes da implementação (os métodos privados). Eu poderia chamá-lo de oculto de implementação de método privado, mas optei por chamá-lo de variação de pimpl, ou pimpl somente de método.
precisa saber é o seguinte
0

Com todo o respeito e não com a intenção de matar essa emoção, não vejo nenhum benefício prático que isso sirva de uma perspectiva em tempo de compilação. Muitos dos benefícios pimplssurgirão ao ocultar detalhes do tipo definido pelo usuário. Por exemplo:

struct Foo
{
    Bar data;
};

... nesse caso, o maior custo de compilação vem do fato de que, para definir Foo, precisamos conhecer os requisitos de tamanho / alinhamento Bar(o que significa que precisamos recursivamente exigir a definição de Bar).

Se você não estiver ocultando os membros dos dados, um dos benefícios mais significativos de uma perspectiva em tempo de compilação será perdido. Também há algum código com aparência potencialmente perigosa, mas o cabeçalho não fica mais claro e o arquivo de origem está ficando mais pesado com mais funções de encaminhamento; portanto, é provável que aumente, em vez de diminuir, o tempo de compilação geral.

Cabeçalhos mais leves é a chave

Para diminuir o tempo de compilação, você deve poder mostrar uma técnica que resulta em um cabeçalho de peso dramaticamente mais leve (normalmente, permitindo que você não recursivamente mais #includealguns outros cabeçalhos, porque ocultou detalhes que não exigem mais determinadas struct/classdefinições). É aí que o genuíno pimplspode ter um efeito significativo, interrompendo cadeias em cascata de inclusões de cabeçalho e produzindo cabeçalhos muito mais independentes, com todos os detalhes privados ocultos.

Maneiras mais seguras

Se você quiser fazer algo assim de qualquer maneira, também será muito mais simples usar um frienddefinido em seu arquivo de origem, em vez de um que herda a mesma classe que você não instancia com truques de conversão de ponteiros para chamar métodos um objeto não instanciado ou simplesmente use funções autônomas com ligação interna dentro do arquivo de origem que recebem os parâmetros apropriados para realizar o trabalho necessário (qualquer um deles pode pelo menos permitir que você oculte alguns métodos privados do cabeçalho para uma economia muito trivial em tempos de compilação e um pouco de espaço de manobra para evitar a recompilação em cascata).

Alocador fixo

Se você deseja o tipo mais barato de pimpl, o truque principal é usar um alocador fixo. Especialmente ao agregar pimpls a granel, o maior assassino é a perda de localidade espacial e as falhas de página obrigatórias adicionais ao acessar o pimpl pela primeira vez. Ao pré-alocar pools de memória que agrupam a memória para os pimpls que estão sendo alocados e retornam a memória ao pool, em vez de liberá-la na desalocação, o custo de um monte de instâncias do pimpl diminui drasticamente. Ainda não é gratuito, do ponto de vista de desempenho, mas é muito mais barato e muito mais compatível com cache / página.


fonte