Como posso evitar o inferno do cabeçalho?

45

Estamos iniciando um novo projeto, do zero. Cerca de oito desenvolvedores, uma dúzia de subsistemas, cada um com quatro ou cinco arquivos de origem.

O que podemos fazer para evitar o "inferno do cabeçalho", também conhecido como "cabeçalhos de espaguete"?

  • Um cabeçalho por arquivo de origem?
  • Mais um por subsistema?
  • Typdefs, stucts e enums separados dos protótipos de função?
  • Separar o subsistema interno do material externo do subsistema?
  • Insista para que todos os arquivos, cabeçalho ou fonte, sejam compiláveis ​​de forma independente?

Não estou pedindo o melhor caminho, apenas um indicador do que observar e o que poderia causar sofrimento, para que possamos tentar evitá-lo.

Este será um projeto em C ++, mas as informações em C ajudariam futuros leitores.

Mawg
fonte
16
Obtenha uma cópia do Design de software C ++ em larga escala , além de ensinar a evitar problemas com cabeçalhos, mas muitos outros problemas relacionados às dependências físicas entre arquivos de origem e objetos em um projeto C ++.
Doc Brown
6
Todas as respostas aqui são ótimas. Eu queria acrescentar que a documentação para o uso de objetos, métodos, funções deveria estar nos arquivos de cabeçalho. Ainda vejo documentos nos arquivos de origem. Não me faça ler a fonte. Esse é o objetivo do arquivo de cabeçalho. Eu não deveria precisar ler a fonte, a menos que eu seja um implementador.
Bill Porta
1
Tenho certeza de que já trabalhei com você antes. Frequentemente :-(
Mawg 17/02/2019
5
O que você descreve não é um grande projeto. Um bom design é sempre bem-vindo, mas você pode nunca enfrentar problemas de "Sistemas de grande escala".
Sam
2
O Boost realmente tem uma abordagem de tudo incluído. Cada recurso individual possui seu próprio arquivo de cabeçalho, mas cada módulo maior também possui um cabeçalho que inclui tudo. Isso se mostra realmente poderoso para minimizar o inferno de cabeçalhos sem forçar você a incluir # centenas de arquivos a cada vez.
Cort Ammon

Respostas:

39

Método simples: um cabeçalho por arquivo de origem. Se você tiver um subsistema completo em que os usuários não devem saber sobre os arquivos de origem, tenha um cabeçalho para o subsistema, incluindo todos os arquivos de cabeçalho necessários.

Qualquer arquivo de cabeçalho deve ser compilável por si só (ou seja, um arquivo de origem, incluindo qualquer cabeçalho único, deve ser compilado). É doloroso se eu descobrir qual arquivo de cabeçalho contém o que eu quero e então precisar caçar os outros arquivos de cabeçalho. Uma maneira simples de aplicar isso é fazer com que cada arquivo de origem inclua seu arquivo de cabeçalho primeiro (obrigado doug65536, acho que faço isso na maioria das vezes sem nem perceber).

Certifique-se de usar as ferramentas disponíveis para manter os tempos de compilação baixos - cada cabeçalho deve ser incluído apenas uma vez, use cabeçalhos pré-compilados para manter os tempos de compilação baixos, use módulos pré-compilados, se possível, para manter os tempos de compilação ainda mais baixos.

gnasher729
fonte
Onde fica complicado são as chamadas de função entre subsistemas, com parâmetros dos tipos declarados no outro subsistema.
MAWG
6
Tricky ou não, "#include <subsystem1.h>" deve compilar. Como você consegue isso, depende de você. @FrankPuffer: Por quê?
gnasher729
13
@Mawg Isso indica que você precisa de um subsistema compartilhado separado que inclua os pontos comuns de subsistemas distintos ou de cabeçalhos simplificados de "interface" para cada subsistema (que é usado pelos cabeçalhos de implementação, internos e entre sistemas) . Se você não conseguir escrever os cabeçalhos da interface sem incluir cruzadas, o design do seu subsistema será confuso e você precisará reprojetar as coisas para que seus subsistemas sejam mais independentes. (Que pode incluir a retirada de um subsistema comum como um terceiro módulo.)
RM
8
Uma boa técnica para garantir que um cabeçalho seja independente é ter uma regra de que o arquivo de origem sempre inclui seu próprio cabeçalho primeiro . Isso capturará os casos em que você precisa mover as inclusões de dependência do arquivo de implementação para o arquivo de cabeçalho.
Doug65536
4
@FrankPuffer: por favor, não exclua seus comentários, especialmente se outros responderem a eles, pois isso torna as respostas sem contexto. Você sempre pode corrigir sua declaração em um novo comentário. Obrigado! Estou interessado em saber o que você realmente disse, mas agora se foi :(
MPW
18

De longe, o requisito mais importante é reduzir as dependências entre os arquivos de origem. Em C ++, é comum usar um arquivo de origem e um cabeçalho por classe. Portanto, se você tiver um bom design de classe, nem chegará perto do inferno.

Você também pode ver o contrário: se você já tem um inferno no seu projeto, pode ter certeza de que o design do software precisa ser aprimorado.

Para responder suas perguntas específicas:

  • Um cabeçalho por arquivo de origem? → Sim, isso funciona bem na maioria dos casos e facilita a localização de itens. Mas não faça disso uma religião.
  • Mais um por subsistema? → Não, por que você quer fazer isso?
  • Typdefs, stucts e enums separados dos protótipos de função? → Não, funções e tipos relacionados pertencem juntos.
  • Separar o subsistema interno do material externo do subsistema? → Sim, claro. Isso reduzirá as dependências.
  • Insista em que cada arquivo, seja cabeçalho ou fonte, seja autônomo, compatível? → Sim, nunca exija que qualquer cabeçalho seja incluído antes de outro cabeçalho.
Frank Puffer
fonte
12

Além das outras recomendações, na linha de redução de dependências (principalmente aplicáveis ​​ao C ++):

  1. Inclua apenas o que você realmente precisa, onde você precisa (nível mais baixo possível). Por exemplo. não inclua em um cabeçalho se você precisar das chamadas apenas na fonte.
  2. Use declarações avançadas nos cabeçalhos sempre que possível (o cabeçalho contém apenas ponteiros ou referências a outras classes).
  3. Limpe as inclusões após cada refatoração (comente-as, veja onde a compilação falha, mova-as para lá, remova as linhas de inclusão ainda comentadas).
  4. Não empacote muitas instalações comuns no mesmo arquivo; divida-os por funcionalidade (por exemplo, o Logger é uma classe, portanto, um cabeçalho e um arquivo de origem; SystemHelper aqui. etc.).
  5. Atenha-se aos princípios de OO, mesmo que tudo que você obtenha seja uma classe que consiste apenas em métodos estáticos (em vez de funções independentes) - ou use um espaço para nome .
  6. Para certas instalações comuns, o padrão singleton é bastante útil, pois você não precisa solicitar a instância de outro objeto não relacionado.
Murphy
fonte
5
No # 3, a ferramenta incluir o que você usa pode ajudar, evitando a abordagem de recompilação manual de adivinhar e verificar.
RM
1
Você poderia explicar qual é o benefício dos singletons nesse contexto? Eu realmente não entendo.
31817 Frank Puffer
@FrankPuffer Minha lógica é a seguinte: sem um singleton, uma instância de uma classe geralmente possui a instância de uma classe auxiliar, como um Logger. Se uma terceira classe quiser usá-lo, precisará solicitar uma referência da classe auxiliar ao proprietário, o que significa que você usa duas classes e, é claro, inclui os cabeçalhos - mesmo que o usuário não tenha negócios com o proprietário. Com um singleton, você só precisa incluir o cabeçalho da classe auxiliar e pode solicitar a instância diretamente dele. Você vê uma falha nessa lógica?
Murphy
1
O nº 2 (declarações futuras) pode fazer uma enorme diferença no tempo de compilação e na verificação de dependência. Como esta resposta ( stackoverflow.com/a/9999752/509928 shows), aplica-se tanto a C ++ e C
Dave Compton
3
Você também pode usar declarações de encaminhamento ao passar por valor, desde que a função não esteja definida em linha. Uma declaração de função não é um contexto em que a definição de tipo completa é necessária (uma definição de função ou chamada de função é esse contexto).
StoryTeller - Unslander Monica
6

Um cabeçalho por arquivo de origem, que define o que seu arquivo de origem implementa / exporta.

Quantos arquivos de cabeçalho forem necessários, incluídos em cada arquivo de origem (começando com seu próprio cabeçalho).

Evite incluir (minimizar a inclusão de) arquivos de cabeçalho em outros arquivos de cabeçalho (para evitar dependências circulares). Para obter detalhes, consulte esta resposta para "duas classes podem se ver usando C ++?"

Há um livro inteiro sobre esse assunto, Design de software C ++ em larga escala da Lakos. Ele descreve ter "camadas" de software: as camadas de alto nível usam camadas de nível inferior e não vice-versa, o que evita novamente dependências circulares.

ChrisW
fonte
4

Eu diria que sua pergunta é fundamentalmente sem resposta, já que existem dois tipos de inferno de cabeçalho:

  • O tipo em que você precisa incluir um milhão de cabeçalhos diferentes e quem diabos consegue se lembrar de todos eles? E manter essas listas de cabeçalhos? Ugh.
  • O tipo em que você inclui uma coisa e descobre que incluiu toda a Torre de Babel (ou devo dizer torre de Boost? ...)

o problema é que, se você tenta evitar o primeiro, acaba, em certa medida, com o último e vice-versa.

Há também um terceiro tipo de inferno, que é dependências circulares. Eles podem aparecer se você não tomar cuidado ... evitá-los não é muito complicado, mas você precisa dedicar um tempo para pensar em como fazê-lo. Veja John Lakos talk on Levelization em CppCon 2016 (ou apenas as lâminas ).

einpoklum - restabelece Monica
fonte
1
Você nem sempre pode evitar dependências circulares. Um exemplo é um modelo no qual as entidades se referem uma à outra. Pelo menos você pode tentar limitar a circularidade no subsistema, ou seja, se você incluir um cabeçalho do subsistema, abstrai a circularidade.
nalply
2
@ nalply: eu quis evitar a dependência circular dos cabeçalhos, não do código ... se você não evitar a dependência circular do cabeçalho, provavelmente não será capaz de criar. Mas sim, ponto levado, +1.
einpoklum - reinstala Monica 24/02
1

Dissociação

Em última análise, trata-se de desacoplar para mim no final do dia, no nível de design mais fundamental, sem as nuances das características de nossos compiladores e vinculadores. Quero dizer, você pode fazer coisas como fazer com que cada cabeçalho defina apenas uma classe, use pimpls, encaminhe declarações para tipos que só precisam ser declarados, não definidos, talvez até use cabeçalhos que apenas contenham declarações avançadas (ex:) <iosfwd>, um cabeçalho por arquivo de origem , organize o sistema de forma consistente com base no tipo de coisa que está sendo declarada / definida etc.

Técnicas para reduzir "dependências em tempo de compilação"

E algumas das técnicas podem ajudar bastante, mas você pode se cansar dessas práticas e ainda achar que o arquivo de origem médio em seu sistema precisa de um preâmbulo de duas páginas. #includediretivas para fazer algo ligeiramente significativo com tempos de construção disparados, se você concentrar muito na redução de dependências em tempo de compilação no nível do cabeçalho sem reduzir dependências lógicas em seus designs de interface e, embora isso possa não ser considerado "cabeçalho de espaguete", eu ainda diria que isso se traduz em questões prejudiciais semelhantes à produtividade na prática. No final do dia, se suas unidades de compilação ainda exigirem um monte de informações visíveis para fazer qualquer coisa, isso se traduzirá em aumento do tempo de compilação e multiplicará os motivos pelos quais você deve voltar e precisar mudar as coisas enquanto cria desenvolvedores sentem que estão dando uma cabeçada no sistema, apenas tentando concluir a codificação diária. Isto'

Você pode, por exemplo, fazer com que cada subsistema forneça um arquivo e uma interface de cabeçalho muito abstratos. Mas se os subsistemas não forem dissociados um do outro, você obterá algo parecido com espaguete novamente com interfaces de subsistema, dependendo de outras interfaces de subsistema com um gráfico de dependência que parece uma bagunça para funcionar.

Encaminhar declarações para tipos externos

De todas as técnicas que eu exausto para tentar obter uma antiga base de código que levou duas horas para ser construída, enquanto os desenvolvedores às vezes esperavam 2 dias pela sua vez no CI em nossos servidores de construção (você quase pode imaginar essas máquinas de construção como animais exaustos de carga, tentando freneticamente para acompanhar e falhar enquanto os desenvolvedores pressionam suas alterações), o mais questionável para mim foi declarar tipos definidos em outros cabeçalhos. E consegui reduzir essa base de código para 40 minutos ou mais, depois de anos fazendo isso em pequenos passos incrementais, enquanto tentava reduzir o "espaguete de cabeçalho", a prática mais questionável em retrospectiva (como em me fazer perder de vista a natureza fundamental do enquanto o túnel visava interdependências de cabeçalho) era encaminhar declarando tipos definidos em outros cabeçalhos.

Se você imaginar um Foo.hppcabeçalho com algo como:

#include "Bar.hpp"

E ele usa apenas Barno cabeçalho uma maneira que requer declaração, não definição. então, pode parecer um acéfalo declarar class Bar;para evitar tornar a definição de Barvisível no cabeçalho. Exceto na prática, muitas vezes você encontrará a maioria das unidades de compilação que Foo.hppainda precisam Barser definidas de qualquer maneira, com o ônus adicional de ter que Bar.hppse incluir por cima delas Foo.hpp, ou você se depara com outro cenário em que isso realmente ajuda. % de suas unidades de compilação podem funcionar sem incluir Bar.hpp, exceto que isso levanta a questão mais fundamental do design (ou pelo menos eu acho que deveria nos dias de hoje) de por que eles precisam ver a declaração Bare por queFoo ainda precisa se preocupar em saber se é irrelevante para a maioria dos casos de uso (por que sobrecarregar um design com dependências para outro quase nunca usado?).

Porque conceitualmente não temos realmente dissociado Foode Bar. Acabamos de fazer com que o cabeçalho de Foonão precise de tanta informação sobre o cabeçalho Bar, e isso não é tão substancial quanto um design que genuinamente os torna completamente independentes um do outro.

Script incorporado

Isso é realmente para bases de código de maior escala, mas outra técnica que considero imensamente útil é usar uma linguagem de script incorporada para pelo menos as partes de mais alto nível do seu sistema. Eu descobri que era capaz de incorporar Lua em um dia e que ele era capaz de chamar todos os comandos em nosso sistema de maneira uniforme (os comandos eram abstratos, felizmente). Infelizmente, encontrei um obstáculo em que os desenvolvedores desconfiavam da introdução de outro idioma e, talvez o mais bizarro, com o desempenho como sua maior suspeita. No entanto, embora eu possa entender outras preocupações, o desempenho não deve ser um problema se estivermos apenas utilizando o script para chamar comandos quando os usuários clicarem em botões, por exemplo, que não executam loops pesados ​​(o que estamos tentando fazer, se preocupe com diferenças de nanossegundos nos tempos de resposta com um clique no botão?).

Exemplo

Enquanto isso, a maneira mais eficaz que já testemunhei após técnicas exaustivas para reduzir o tempo de compilação em grandes bases de código são arquiteturas que reduzem genuinamente a quantidade de informações necessárias para que qualquer coisa no sistema funcione, não apenas separando um cabeçalho de outro de um compilador perspectiva, mas exigindo que os usuários dessas interfaces façam o que precisam fazer enquanto conhecem (do ponto de vista humano e do compilador, o verdadeiro desacoplamento que vai além das dependências do compilador) o mínimo necessário.

O ECS é apenas um exemplo (e não estou sugerindo que você use um), mas, ao encontrá-lo, mostrou-me que você pode ter algumas bases de código realmente épicas que ainda constroem surpreendentemente rapidamente enquanto utilizam modelos e muitas outras vantagens porque o ECS, por natureza, cria uma arquitetura muito dissociada, na qual os sistemas precisam apenas conhecer o banco de dados do ECS e, geralmente, apenas um punhado de tipos de componentes (às vezes apenas um) para fazer suas coisas:

insira a descrição da imagem aqui

Design, Design, Design

E esses tipos de projetos arquitetônicos dissociados em um nível humano e conceitual são mais eficazes em termos de minimização do tempo de compilação do que qualquer uma das técnicas que eu explorei acima à medida que a sua base de código cresce, cresce e cresce, porque esse crescimento não se traduz na média unidade de compilação que multiplica a quantidade de informações necessárias nos tempos de compilação e link para funcionar (qualquer sistema que exija que o desenvolvedor médio inclua um monte de coisas para fazer qualquer coisa também exige isso, e não apenas o compilador para saber sobre muitas informações para fazer qualquer coisa ) Ele também tem mais benefícios do que tempos de construção reduzidos e desembaraçar cabeçalhos, pois também significa que seus desenvolvedores não precisam saber muito sobre o sistema além do que é imediatamente necessário para fazer algo com ele.

Se, por exemplo, você pode contratar um desenvolvedor de física especializado para desenvolver um mecanismo de física para o seu jogo AAA, que abrange milhões de LOC, e ele pode começar muito rapidamente, conhecendo as informações mínimas absolutas, no que diz respeito a tipos e interfaces disponíveis assim como os conceitos do sistema, isso naturalmente se traduzirá em uma quantidade reduzida de informações para ele e o compilador exigirem a construção de seu mecanismo de física, e também se traduzirá em uma grande redução nos tempos de compilação, ao mesmo tempo em que geralmente implica que não há nada parecido com espaguete em qualquer lugar do sistema. E é isso que estou sugerindo para priorizar acima de todas essas outras técnicas: como você projeta seus sistemas. O esgotamento de outras técnicas estará no topo se você o fizer enquanto, caso contrário,

Dragon Energy
fonte
1
Uma excelente resposta! Althoguh eu tive que escavar um pouco para descobrir o que pimpls é :-)
MAWG
0

É uma questão de opinião. Veja esta resposta e essa . E isso também depende muito do tamanho do projeto (se você acredita que terá milhões de linhas de origem em seu projeto, não é o mesmo que ter algumas dezenas de milhares delas).

Em contraste com outras respostas, eu recomendo um cabeçalho público (bastante grande) por subsistema (que pode incluir cabeçalhos "privados", talvez com arquivos separados para implementações de muitas funções embutidas). Você pode até considerar um cabeçalho tendo apenas várias #include diretivas.

Eu não acho que muitos arquivos de cabeçalho sejam recomendados. Em particular, eu não recomendo um arquivo de cabeçalho por classe ou muitos arquivos de cabeçalho pequenos de algumas dezenas de linhas cada.

(Se você tiver muitos arquivos pequenos, precisará incluí-los em todas as pequenas unidades de tradução , e o tempo geral de compilação poderá sofrer)

O que você realmente deseja é identificar, para cada subsistema e arquivo, o principal desenvolvedor responsável por isso.

Por fim, para um projeto pequeno (por exemplo, com menos de cem mil linhas de código-fonte), isso não é muito importante. Durante o projeto, você poderá facilmente refatorar o código e reorganizá-lo em arquivos diferentes. Você apenas copia e cola pedaços de código em novos arquivos (cabeçalho), não é grande coisa (o mais difícil é projetar sabiamente como você reorganizaria seus arquivos, e isso é específico do projeto).

(minha preferência pessoal é evitar arquivos muito grandes e muito pequenos; geralmente tenho arquivos de origem com vários milhares de linhas cada; e não tenho medo de um arquivo de cabeçalho - incluindo definições de funções embutidas - de muitas centenas de linhas ou mesmo algumas milhares deles)

Observe que se você deseja usar cabeçalhos pré-compilados com o GCC (que às vezes é uma abordagem sensata para diminuir o tempo de compilação), é necessário um único arquivo de cabeçalho (incluindo todos os outros e os cabeçalhos do sistema).

Observe que, em C ++, os arquivos de cabeçalho padrão estão recebendo muito código . Por exemplo, #include <vector>está puxando mais de dez mil linhas no meu GCC 6 no Linux (18100 linhas). E se #include <map> expande para quase 40KLOC. Portanto, se você tiver muitos arquivos de cabeçalho pequenos, incluindo cabeçalhos padrão, você acaba analisando novamente milhares de linhas durante a compilação e o tempo de compilação sofre. É por isso que eu não gosto de ter muitas linhas de origem C ++ pequenas (no máximo, algumas centenas de linhas), mas prefere ter arquivos C ++ menores, porém maiores (com milhares de linhas).

(portanto, ter centenas de arquivos C ++ pequenos que sempre incluem, mesmo que indiretamente, vários arquivos de cabeçalho padrão fornece um tempo de criação enorme, o que incomoda os desenvolvedores)

No código C, muitas vezes os arquivos de cabeçalho se expandem para algo menor, portanto, o trade-off é diferente.

Procure também, por inspiração, a prática anterior em projetos de software livre existentes (por exemplo, no github ).

Observe que as dependências podem ser tratadas com um bom sistema de automação de construção . Estude a documentação do GNU make . Esteja ciente de vários -Msinalizadores de pré-processador para o GCC (útil para gerar automaticamente dependências).

Em outras palavras, seu projeto (com menos de cem arquivos e uma dúzia de desenvolvedores) provavelmente não é grande o suficiente para ser realmente preocupado com o "inferno do cabeçalho"; portanto, sua preocupação não é justificada . Você pode ter apenas uma dúzia de arquivos de cabeçalho (ou até muito menos), pode optar por ter um arquivo de cabeçalho por unidade de tradução, pode até optar por ter um único arquivo de cabeçalho, e o que você escolher não será um "inferno do cabeçalho" (e refatorar e reorganizar seus arquivos seria razoavelmente fácil, portanto a escolha inicial não é realmente importante ).

(Não concentre seus esforços no "inferno do cabeçalho" - o que não é um problema para você -, mas concentre-os no design de uma boa arquitetura)

Basile Starynkevitch
fonte
Os detalhes técnicos mencionados podem estar corretos. No entanto, como eu entendi, o OP estava pedindo dicas de como melhorar a manutenção e organização do código, e não o tempo de compilação. E vejo um conflito direto entre esses dois objetivos.
Murphy
Mas ainda é uma questão de opinião. E o OP aparentemente está iniciando um projeto não tão grande.
Basile Starynkevitch