C ++ Método preferido para lidar com a implementação de modelos grandes

10

Normalmente, ao declarar uma classe C ++, é recomendável colocar apenas a declaração no arquivo de cabeçalho e colocar a implementação em um arquivo de origem. No entanto, parece que esse modelo de design não funciona para classes de modelo.

Ao procurar on-line, parece haver duas opiniões sobre a melhor maneira de gerenciar classes de modelo:

1. Declaração e implementação completas no cabeçalho.

Isso é bastante simples, mas leva ao que é, na minha opinião, difícil de manter e editar arquivos de código quando o modelo se torna grande.

2. Escreva a implementação em um arquivo de inclusão de modelo (.tpp) incluído no final.

Esta parece ser uma solução melhor para mim, mas não parece ser amplamente aplicada. Existe uma razão para que essa abordagem seja inferior?

Eu sei que muitas vezes o estilo do código é ditado pela preferência pessoal ou pelo estilo legado. Estou iniciando um novo projeto (portando um projeto C antigo para C ++) e sou relativamente novo no design de OO e gostaria de seguir as melhores práticas desde o início.

fhorrobin
fonte
11
Veja este artigo de 9 anos em codeproject.com. O método 3 é o que você descreveu. Não parece ser tão especial como você acredita.
Doc Brown
.. ou aqui, mesma abordagem, artigo de 2014: codeofhonour.blogspot.com/2014/11/...
Doc Brown
2
Intimamente relacionado: stackoverflow.com/q/1208028/179910 . O Gnu normalmente usa uma extensão ".tcc" em vez de ".tpp", mas é praticamente idêntica.
Jerry Coffin
Eu sempre usei "ipp" como extensão, mas fiz o mesmo no código que escrevi.
Sebastian Redl

Respostas:

6

Ao escrever uma classe C ++ com modelo, você geralmente tem três opções:

(1) Coloque declaração e definição no cabeçalho.

// foo.h
#pragma once

template <typename T>
struct Foo
{
    void f()
    {
        ...
    }
};

ou

// foo.h
#pragma once

template <typename T>
struct Foo
{
    void f();
};

template <typename T>
inline void Foo::f()
{
    ...
}

Pró:

  • Uso muito conveniente (apenas inclua o cabeçalho).

Vigarista:

  • A implementação da interface e do método é mista. Este é "apenas" um problema de legibilidade. Alguns acham isso impossível de manter, porque é diferente da abordagem usual .h / .cpp. No entanto, esteja ciente de que isso não é problema em outros idiomas, por exemplo, C # e Java.
  • Alto impacto de reconstrução: se você declarar uma nova classe Foocomo membro, precisará incluir foo.h. Isso significa que alterar a implementação de Foo::fpropaga-se através dos arquivos de cabeçalho e de origem.

Vamos analisar mais detalhadamente o impacto da reconstrução: Para classes C ++ sem modelo, você coloca declarações em. He definições de método em .cpp. Dessa forma, quando a implementação de um método é alterada, apenas um .cpp precisa ser recompilado. Isso é diferente para as classes de modelo se o .h contiver todo o código. Veja o seguinte exemplo:

// bar.h
#pragma once
#include "foo.h"
struct Bar
{
    void b();
    Foo<int> foo;
};

// bar.cpp
#include "bar.h"
void Bar::b()
{
    foo.f();
}

// qux.h
#pragma once
#include "bar.h"
struct Qux
{
    void q();
    Bar bar;
}

// qux.cpp
#include "qux.h"
void Qux::q()
{
    bar.b();
}

Aqui, o único uso de Foo::festá dentro bar.cpp. No entanto, se você alterar a implementação de Foo::f, both bar.cppe qux.cppprecisar ser recompilado. A implementação de Foo::fvidas em ambos os arquivos, mesmo que nenhuma parte Quxuse diretamente nada Foo::f. Para projetos grandes, isso pode se tornar um problema em breve.

(2) Coloque a declaração em .h e a definição em .tpp e inclua-a em .h.

// foo.h
#pragma once
template <typename T>
struct Foo
{
    void f();
};
#include "foo.tpp"    

// foo.tpp
#pragma once // not necessary if foo.h is the only one that includes this file
template <typename T>
inline void Foo::f()
{
    ...
}

Pró:

  • Uso muito conveniente (apenas inclua o cabeçalho).
  • As definições de interface e método são separadas.

Vigarista:

  • Alto impacto de reconstrução (o mesmo que (1) ).

Essa solução separa a declaração e a definição do método em dois arquivos separados, assim como .h / .cpp. No entanto, essa abordagem tem o mesmo problema de reconstrução que (1) , porque o cabeçalho inclui diretamente as definições de método.

(3) Coloque a declaração em. He a definição em .tpp, mas não inclua .tpp em .h.

// foo.h
#pragma once
template <typename T>
struct Foo
{
    void f();
};

// foo.tpp
#pragma once
template <typename T>
void Foo::f()
{
    ...
}

Pró:

  • Reduz o impacto da reconstrução, assim como a separação .h / .cpp.
  • As definições de interface e método são separadas.

Vigarista:

  • Uso inconveniente: ao adicionar um Foomembro a uma classe Bar, você precisa incluir foo.hno cabeçalho. Se você chamar Foo::fum .cpp, também precisará incluir foo.tpplá.

Essa abordagem reduz o impacto da reconstrução, pois apenas os arquivos .cpp que realmente usam Foo::fprecisam ser recompilados. No entanto, isso tem um preço: todos esses arquivos precisam incluir foo.tpp. Pegue o exemplo acima e use a nova abordagem:

// bar.h
#pragma once
#include "foo.h"
struct Bar
{
    void b();
    Foo<int> foo;
};

// bar.cpp
#include "bar.h"
#include "foo.tpp"
void Bar::b()
{
    foo.f();
}

// qux.h
#pragma once
#include "bar.h"
struct Qux
{
    void q();
    Bar bar;
}

// qux.cpp
#include "qux.h"
void Qux::q()
{
    bar.b();
}

Como você pode ver, a única diferença é a inclusão adicional de foo.tpppol bar.cpp. Isso é inconveniente e adicionar uma segunda inclusão para uma classe, dependendo se você chama métodos, parece muito feio. No entanto, você reduz o impacto da reconstrução: só bar.cppprecisa ser recompilado se você alterar a implementação de Foo::f. O arquivo qux.cppnão precisa de recompilação.

Resumo:

Se você implementar uma biblioteca, normalmente não precisará se preocupar com o impacto da reconstrução. Os usuários da sua biblioteca agarram um release e o utilizam, e a implementação da biblioteca não muda no trabalho diário do usuário. Nesses casos, a biblioteca pode usar a abordagem (1) ou (2) e é apenas uma questão de gosto que você escolher.

No entanto, se você estiver trabalhando em um aplicativo ou em uma biblioteca interna da sua empresa, o código mudará frequentemente. Então você precisa se preocupar com o impacto da reconstrução. Escolher a abordagem (3) pode ser uma boa opção se você conseguir que seus desenvolvedores aceitem a inclusão adicional.

pschill
fonte
2

Semelhante à .tppidéia (que nunca vi usada), colocamos a maioria das funcionalidades embutidas em um -inl.hpparquivo incluído no final do .hpparquivo usual .

Como outros indicam, isso mantém a interface legível movendo a desordem das implementações embutidas (como modelos) em outro arquivo. Permitimos algumas linhas de interface, mas tentamos limitá-las a funções pequenas, geralmente de linha única.

Bill Door
fonte
1

Uma moeda profissional da 2ª variante é que seus cabeçalhos parecem mais organizados.

O engodo pode ser que você pode ter a verificação de erro do IDE embutido e as ligações do depurador estragadas.

πάντα ῥεῖ
fonte
A segunda também exige muita redundância de declaração de parâmetro de modelo, que pode se tornar muito detalhada, especialmente ao usar sfinae. E, ao contrário do OP, acho o segundo mais difícil de ler quanto mais código houver, especificamente por causa do clichê redundante.
Sopel
0

Eu prefiro muito a abordagem de colocar a implementação em um arquivo separado e ter apenas a documentação e as declarações no arquivo de cabeçalho.

Talvez a razão pela qual você não tenha visto muito essa abordagem seja muito prática, seja por não ter procurado os lugares certos ;-)

Ou - talvez seja porque é preciso um pouco de esforço extra no desenvolvimento do software. Mas para uma biblioteca de classes, esse esforço vale MUITO, IMHO, e se paga em uma biblioteca muito mais fácil de usar / ler.

Veja esta biblioteca, por exemplo: https://github.com/SophistSolutions/Stroika/

A biblioteca inteira é escrita com essa abordagem e, se você examinar o código, verá como ele funciona.

Os arquivos de cabeçalho têm o tamanho dos arquivos de implementação, mas são preenchidos apenas com declarações e documentação.

Compare a legibilidade do Stroika com a sua implementação std c ++ favorita (gcc ou libc ++ ou msvc). Todos eles usam a abordagem de implementação embutida no cabeçalho e, embora sejam extremamente bem escritos, IMHO, não são implementações legíveis.

Lewis Pringle
fonte