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.
Respostas:
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.
fonte
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:
fonte
Além das outras recomendações, na linha de redução de dependências (principalmente aplicáveis ao C ++):
fonte
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.
fonte
Eu diria que sua pergunta é fundamentalmente sem resposta, já que existem dois tipos de inferno de cabeçalho:
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 ).
fonte
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.
#include
diretivas 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.hpp
cabeçalho com algo como:E ele usa apenas
Bar
no cabeçalho uma maneira que requer declaração, não definição. então, pode parecer um acéfalo declararclass Bar;
para evitar tornar a definição deBar
visível no cabeçalho. Exceto na prática, muitas vezes você encontrará a maioria das unidades de compilação queFoo.hpp
ainda precisamBar
ser definidas de qualquer maneira, com o ônus adicional de ter queBar.hpp
se incluir por cima delasFoo.hpp
, ou você se depara com outro cenário em que isso realmente ajuda. % de suas unidades de compilação podem funcionar sem incluirBar.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çãoBar
e 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
Foo
deBar
. Acabamos de fazer com que o cabeçalho deFoo
não precise de tanta informação sobre o cabeçalhoBar
, 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:
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,
fonte
É 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
-M
sinalizadores 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)
fonte