¿Cómo puedo prevenir el infierno de encabezado?

45

Estamos comenzando un nuevo proyecto, desde cero. Alrededor de ocho desarrolladores, una docena de subsistemas, cada uno con cuatro o cinco archivos fuente.

¿Qué podemos hacer para prevenir el "infierno de encabezado", también conocido como "encabezados de espagueti"?

  • ¿Un encabezado por archivo fuente?
  • ¿Más uno por subsistema?
  • ¿Separar Typdefs, estucts y enumeraciones de prototipos de funciones?
  • ¿Separar el subsistema interno del subsistema externo?
  • ¿Insiste en que cada archivo, ya sea el encabezado o la fuente, debe ser compilable de forma independiente?

No estoy pidiendo una "mejor" forma, sino un indicador de a qué vigilar y qué podría causar dolor, para que podamos tratar de evitarlo.

Este será un proyecto de C ++, pero C info ayudaría a futuros lectores.

Mawg
fuente
16
Obtenga una copia del diseño de software C ++ a gran escala , no solo enseñará a evitar problemas con los encabezados, sino muchos más problemas relacionados con las dependencias físicas entre los archivos de origen y de objetos en un proyecto C ++.
Doc Brown
66
Todas las respuestas aquí son geniales. Quería agregar que la documentación para usar objetos, métodos, funciones, debería estar en los archivos de encabezado. Todavía veo documentos en los archivos de origen. No me hagas leer la fuente. Ese es el punto del archivo de encabezado. No debería necesitar leer la fuente a menos que sea un implementador.
Bill Door
1
Estoy seguro de que he trabajado contigo antes. A menudo :-(
Mawg
55
Lo que describe no es un gran proyecto. Un buen diseño siempre es bienvenido, pero es posible que nunca se enfrente a problemas de "sistemas a gran escala"
Sam
2
Boost en realidad tiene un enfoque de todo incluido. Cada característica individual tiene su propio archivo de encabezado, pero cada módulo más grande también tiene un encabezado que incluye todo. Esto resulta ser realmente poderoso para minimizar el infierno de encabezado sin obligarlo a incluir algunos cientos de archivos cada vez.
Cort Ammon

Respuestas:

39

Método simple: un encabezado por archivo fuente. Si tiene un subsistema completo en el que no se espera que los usuarios conozcan los archivos de origen, tenga un encabezado para el subsistema que incluya todos los archivos de encabezado necesarios.

Cualquier archivo de encabezado debe ser compilable por sí mismo (o digamos que se debe compilar un archivo fuente que incluya cualquier encabezado). Es un dolor si encuentro qué archivo de encabezado contiene lo que quiero, y luego tengo que buscar los otros archivos de encabezado. Una forma simple de hacer cumplir esto es hacer que cada archivo fuente incluya primero su archivo de encabezado (gracias doug65536, creo que lo hago la mayor parte del tiempo sin siquiera darme cuenta).

Asegúrese de utilizar las herramientas disponibles para mantener bajos los tiempos de compilación: cada encabezado debe incluirse solo una vez, usar encabezados precompilados para mantener bajos los tiempos de compilación, usar módulos precompilados si es posible para mantener los tiempos de compilación más bajos.

gnasher729
fuente
Donde se vuelve complicado son las llamadas de función entre subsistemas, con parámetros de tipos declarados en el otro subsistema.
Mawg
66
Difícil o no, "#include <subsystem1.h>" debería compilarse. Cómo lo logras, depende de ti. @FrankPuffer: ¿Por qué?
gnasher729
13
@Mawg Eso indica que necesita un subsistema compartido y separado que incluya los puntos en común de los subsistemas distintos, o que necesita encabezados de "interfaz" simplificados para cada subsistema (que luego usan los encabezados de implementación, tanto internos como entre sistemas) . Si no puede escribir los encabezados de la interfaz sin inclusiones cruzadas, el diseño de su subsistema está en mal estado y necesita rediseñar las cosas para que sus subsistemas sean más independientes. (Lo que puede incluir la extracción de un subsistema común como un tercer módulo).
RM
8
Una buena técnica para garantizar que un encabezado sea independiente es tener una regla de que el archivo fuente siempre incluye primero su propio encabezado . Esto detectará casos en los que necesite mover la dependencia del archivo de implementación al archivo de encabezado.
doug65536
44
@FrankPuffer: No elimine sus comentarios, especialmente si otros responden a ellos, ya que las respuestas no tienen contexto. Siempre puede corregir su declaración en un nuevo comentario. ¡Gracias! Estoy interesado en saber lo que realmente dijo, pero ahora se ha ido :(
MPW
18

Con mucho, el requisito más importante es reducir las dependencias entre sus archivos de origen. En C ++ es común usar un archivo fuente y un encabezado por clase. Por lo tanto, si tiene un buen diseño de clase, ni siquiera se acercará al infierno del encabezado.

También puede ver esto al revés: si ya tiene un infierno de encabezado en su proyecto, puede estar bastante seguro de que el diseño del software debe mejorarse.

Para responder a sus preguntas específicas:

  • ¿Un encabezado por archivo fuente? → Sí, esto funciona bien en la mayoría de los casos y hace que sea más fácil encontrar cosas. Pero no lo conviertas en una religión.
  • ¿Más uno por subsistema? → No, ¿por qué quieres hacer esto?
  • ¿Separar Typdefs, estucts y enumeraciones de prototipos de funciones? → No, las funciones y los tipos relacionados pertenecen juntos.
  • ¿Separar el subsistema interno del subsistema externo? → Sí, por supuesto. Esto reducirá las dependencias.
  • ¿Insiste en que todos los archivos, ya sean de cabecera o de origen, sean compatibles? → Sí, nunca requiera que se incluya un encabezado antes de otro encabezado.
Frank Puffer
fuente
12

Además de las otras recomendaciones, en la línea de reducción de dependencias (principalmente aplicable a C ++):

  1. Solo incluya lo que realmente necesita, donde lo necesita (nivel más bajo posible). P.ej. no incluya en un encabezado si necesita las llamadas solo en la fuente.
  2. Utilice declaraciones directas en los encabezados siempre que sea posible (el encabezado contiene solo punteros o referencias a otras clases).
  3. Limpie las inclusiones después de cada refactorización (coméntelas, vea dónde falla la compilación, muévalas allí, elimine las líneas de inclusión aún comentadas).
  4. No empaque demasiadas instalaciones comunes en el mismo archivo; divídalos por funcionalidad (por ejemplo, Logger es una clase, por lo tanto, un encabezado y un archivo fuente; SystemHelper dito, etc.).
  5. Siga los principios de OO, incluso si todo lo que obtiene es una clase que consiste únicamente en métodos estáticos (en lugar de funciones independientes), o use un espacio de nombres en su lugar .
  6. Para ciertas instalaciones comunes, el patrón singleton es bastante útil, ya que no necesita solicitar la instancia de algún otro objeto no relacionado.
Murphy
fuente
55
En el n. ° 3, la herramienta incluye lo que usa puede ayudar, evitando el enfoque de recompilación manual de adivinar y verificar.
RM
1
¿Podría explicar cuál es el beneficio de los singletons en este contexto? Realmente no lo entiendo.
Frank Puffer
@FrankPuffer Mi razonamiento es este: sin un singleton, alguna instancia de una clase generalmente posee la instancia de una clase auxiliar, como un Logger. Si una tercera clase quiere usarlo, necesita solicitar una referencia de la clase auxiliar del propietario, lo que significa que usa dos clases y, por supuesto, incluir sus encabezados, incluso si el usuario no tiene negocios con el propietario de otra manera. Con un singleton solo necesita incluir el encabezado de la clase auxiliar y puede solicitar la instancia directamente de él. ¿Ves un defecto en esta lógica?
Murphy
1
# 2 (declaraciones de reenvío) puede marcar una gran diferencia en el tiempo de compilación y la comprobación de dependencias. Como muestra esta respuesta ( stackoverflow.com/a/9999752/509928 ), se aplica tanto a C ++ como a C
Dave Compton
3
También puede usar declaraciones directas al pasar por valor, siempre que la función no esté definida en línea. Una declaración de función no es un contexto en el que se necesita la definición de tipo completo (una definición de función o llamada de función es dicho contexto).
StoryTeller - Unslander Monica
6

Un encabezado por archivo fuente, que define lo que implementa / exporta su archivo fuente.

Tantos archivos de encabezado como sea necesario, incluidos en cada archivo de origen (comenzando con su propio encabezado).

Evite incluir (minimizar la inclusión de) archivos de encabezado dentro de otros archivos de encabezado (para evitar dependencias circulares). Para más detalles, vea esta respuesta a "¿pueden dos clases verse entre sí usando C ++?"

Hay un libro completo sobre este tema, Diseño de software C ++ a gran escala por Lakos. Describe tener "capas" de software: las capas de alto nivel usan capas de nivel inferior, no al revés, lo que nuevamente evita dependencias circulares.

ChrisW
fuente
4

Yo diría que su pregunta es fundamentalmente sin respuesta, ya que hay dos tipos de infierno de encabezado:

  • Del tipo en el que necesitas incluir un millón de encabezados diferentes, y ¿quién demonios puede recordarlos todos? ¿Y mantener esas listas de encabezados? Ugh
  • Del tipo en el que incluye una cosa, y descubre que ha incluido toda la Torre de Babel (¿o debería decir torre de Boost? ...)

La cuestión es que si intentas evitar lo primero, terminas, en cierta medida, con lo último, y viceversa.

También hay un tercer tipo de infierno, que son las dependencias circulares. Estos pueden aparecer si no tienes cuidado ... evitarlos no es súper complicado, pero debes tomarte el tiempo para pensar cómo hacerlo. Vea la charla de John Lakos sobre nivelación en CppCon 2016 (o solo las diapositivas ).

einpoklum - reinstalar a Monica
fuente
1
No siempre se pueden evitar las dependencias circulares. Un ejemplo es un modelo en el que las entidades se refieren entre sí. Al menos puede intentar limitar la circularidad en el subsistema, esto significa que, si incluye un encabezado del subsistema, extrae la circularidad.
nalply
2
@nalply: quise decir evitar la dependencia circular de los encabezados, no del código ... si no evita la dependencia del encabezado circular, probablemente no podrá construir. Pero sí, punto tomado, +1.
einpoklum - reinstalar a Monica
1

Desacoplamiento

En última instancia, se trata de desacoplarme al final del día en el nivel de diseño más fundamental sin los matices de las características de nuestros compiladores y enlazadores. Quiero decir que puede hacer cosas como hacer que cada encabezado defina solo una clase, use pimpls, envíe declaraciones a tipos que solo necesitan ser declarados, no definidos, tal vez incluso use encabezados que solo contengan declaraciones directas (ej .:) <iosfwd>, un encabezado por archivo fuente , organizar el sistema de manera coherente en función del tipo de cosa que se declara / define, etc.

Técnicas para reducir las "dependencias en tiempo de compilación"

Y algunas de las técnicas pueden ayudar un poco, pero puede encontrarse agotando estas prácticas y aún así encontrar que su archivo fuente promedio en su sistema necesita un preámbulo de dos páginas de #includedirectivas para hacer algo levemente significativo con tiempos de compilación disparados si se enfoca demasiado en reducir las dependencias de tiempo de compilación en el nivel de encabezado sin reducir las dependencias lógicas en los diseños de su interfaz, y aunque eso no puede considerarse "encabezados de espagueti" estrictamente hablando, yo Todavía diría que se traduce en problemas perjudiciales similares a la productividad en la práctica. Al final del día, si sus unidades de compilación todavía requieren una gran cantidad de información para ser visible para hacer algo, entonces se traducirá en un aumento de los tiempos de construcción y multiplicará las razones por las que potencialmente tiene que regresar y cambiar las cosas mientras hace que los desarrolladores sienten que están golpeando el sistema simplemente tratando de terminar su codificación diaria. Eso'

Puede, por ejemplo, hacer que cada subsistema proporcione una interfaz y un archivo de encabezado muy abstracto. Pero si los subsistemas no están desacoplados entre sí, entonces obtienes algo parecido al espagueti nuevamente con interfaces de subsistema que dependen de otras interfaces de subsistema con un gráfico de dependencia que parece un desastre para funcionar.

Declaraciones directas a tipos externos

De todas las técnicas que agoté para tratar de obtener una antigua base de código que tardó dos horas en compilarse, mientras que los desarrolladores a veces esperaban 2 días para su turno en CI en nuestros servidores de compilación (casi puedes imaginar esas máquinas de compilación como bestias de carga agotadas intentando frenéticamente mantenerse al día y fallar mientras los desarrolladores empujan sus cambios), lo más cuestionable para mí fue declarar los tipos definidos en otros encabezados. Y logré reducir esa base de código a 40 minutos más o menos después de años de hacer esto en pequeños pasos incrementales mientras intentaba reducir el "espagueti de encabezado", la práctica más cuestionable en retrospectiva (como en hacerme perder de vista la naturaleza fundamental de el diseño mientras el túnel se visualizaba en las interdependencias de los encabezados) declaraba los tipos definidos en otros encabezados.

Si imagina un Foo.hppencabezado que tiene algo como:

#include "Bar.hpp"

Y solo utiliza Baren el encabezado una forma que requiere declaración, no definición. entonces puede parecer una obviedad declarar class Bar;para evitar hacer Barvisible la definición de encabezado. Excepto en la práctica, a menudo encontrará que la mayoría de las unidades de compilación que usan Foo.hpptodavía necesitan Bardefinirse de todos modos con la carga adicional de tener que incluirse Bar.hppencima Foo.hpp, o se encuentra con otro escenario donde eso realmente ayuda y 99 El% de sus unidades de compilación puede funcionar sin incluir Bar.hpp, excepto que plantea la pregunta de diseño más fundamental (o al menos creo que debería ser hoy en día) de por qué necesitan ver la declaración Bary por quéFoo incluso debe molestarse en saberlo si es irrelevante para la mayoría de los casos de uso (¿por qué cargar un diseño con dependencias a otro que casi nunca se usa?).

Debido a que conceptualmente no hemos desacoplado Foode Bar. Acabamos de hacerlo para que el encabezado de Foono necesite tanta información sobre el encabezado de Bar, y eso no es tan sustancial como un diseño que realmente los hace completamente independientes uno del otro.

Scripting Embebido

Esto es realmente para bases de código de mayor escala, pero otra técnica que encuentro inmensamente útil es usar un lenguaje de script integrado para al menos las partes de más alto nivel de su sistema. Descubrí que podía incrustar Lua en un día y que podía llamar de manera uniforme a todos los comandos de nuestro sistema (afortunadamente, los comandos eran abstractos). Desafortunadamente, me encontré con un obstáculo en el que los desarrolladores desconfiaban de la introducción de otro idioma y, quizás lo más extraño, con el rendimiento como su mayor sospecha. Sin embargo, aunque podría entender otras preocupaciones, el rendimiento no debería ser un problema si solo utilizamos el script para invocar comandos cuando los usuarios hacen clic en botones, por ejemplo, que no realizan bucles fuertes propios (qué estamos tratando de hacer, preocuparse por las diferencias de nanosegundos en los tiempos de respuesta para un clic de botón?).

Ejemplo

Mientras tanto, la forma más efectiva que he presenciado después de agotar las técnicas para reducir los tiempos de compilación en grandes bases de código son arquitecturas que realmente reducen la cantidad de información requerida para que funcione cualquier cosa en el sistema, no solo desacoplar un encabezado de otro de un compilador perspectiva, pero exigiendo a los usuarios de estas interfaces que hagan lo que tienen que hacer mientras conocen (tanto desde el punto de vista humano como del compilador, un desacoplamiento verdadero que va más allá de las dependencias del compilador) lo mínimo.

El ECS es solo un ejemplo (y no estoy sugiriendo que use uno), pero al encontrarlo me demostró que puede tener algunas bases de código realmente épicas que aún se construyen sorprendentemente rápido mientras utilizan felizmente plantillas y muchas otras ventajas porque el ECS, por naturaleza, crea una arquitectura muy desacoplada donde los sistemas solo necesitan saber sobre la base de datos de ECS y, por lo general, solo un puñado de tipos de componentes (a veces solo uno) para hacer lo suyo:

ingrese la descripción de la imagen aquí

Diseño, Diseño, Diseño

Y este tipo de diseños arquitectónicos desacoplados a nivel conceptual humano es más efectivo en términos de minimizar los tiempos de compilación que cualquiera de las técnicas que exploré anteriormente a medida que su base de código crece y crece y crece, porque ese crecimiento no se traduce en su promedio la unidad de compilación multiplica la cantidad de información requerida en la compilación y los tiempos de enlace para trabajar (cualquier sistema que requiera que su desarrollador promedio incluya una gran cantidad de cosas para hacer cualquier cosa también los requiere y no solo el compilador debe saber sobre una gran cantidad de información para hacer cualquier cosa ) También tiene más beneficios que tiempos de construcción reducidos y encabezados desenredados, ya que también significa que sus desarrolladores no necesitan saber mucho sobre el sistema más allá de lo que se requiere de inmediato para hacer algo con él.

Si, por ejemplo, puede contratar a un desarrollador de física experto para desarrollar un motor de física para su juego AAA que abarque millones de LOC, y puede comenzar muy rápidamente mientras conoce la información mínima absoluta en cuanto a tipos e interfaces disponibles así como los conceptos de su sistema, entonces eso naturalmente se traducirá en una cantidad reducida de información para que él y el compilador requieran construir su motor de física, y también se traducirá en una gran reducción en los tiempos de construcción, mientras que generalmente implica que no hay nada parecido a los espaguetis en cualquier parte del sistema. Y eso es lo que sugiero que priorice por encima de todas estas otras técnicas: cómo diseña sus sistemas. Agotar otras técnicas será la guinda en la parte superior si lo haces mientras que, de lo contrario,

Dragon Energy
fuente
1
Una excelente respuesta! Althoguh tuve que cavar un poco para descubrir qué es el grano :-)
Mawg
0

Es una cuestión de opinión. Vea esta respuesta y esa . Y también depende mucho del tamaño del proyecto (si cree que tendrá millones de líneas de origen en su proyecto, no es lo mismo que tener unas pocas docenas de miles de ellas).

A diferencia de otras respuestas, recomiendo un encabezado público (bastante grande) por subsistema (que podría incluir encabezados "privados", quizás con archivos separados para implementaciones de muchas funciones en línea). Incluso podría considerar un encabezado que solo tenga varias #include directivas.

No creo que se recomienden muchos archivos de encabezado. En particular, no recomiendo un archivo de encabezado por clase, o muchos archivos de encabezado pequeños de unas pocas docenas de líneas cada uno.

(Si tiene una gran cantidad de archivos pequeños, deberá incluir muchos de ellos en cada pequeña unidad de traducción , y el tiempo de construcción general podría verse afectado)

Lo que realmente desea es identificar, para cada subsistema y archivo, el desarrollador principal responsable de ello.

Por último, para un proyecto pequeño (por ejemplo, de menos de cien mil líneas de código fuente), no es muy importante. Durante el proyecto, podrá refactorizar el código con bastante facilidad y reorganizarlo en diferentes archivos. Simplemente copiará y pegará fragmentos de código en archivos nuevos (encabezados), no es un gran problema (lo que es más difícil es diseñar sabiamente cómo reorganizaría sus archivos, y eso es específico del proyecto).

(mi preferencia personal es evitar archivos demasiado grandes y demasiado pequeños; a menudo tengo archivos de origen de varios miles de líneas cada uno; y no tengo miedo de un archivo de encabezado, incluidas las definiciones de funciones en línea) de muchos cientos de líneas o incluso un par de miles de ellos)

Tenga en cuenta que si desea utilizar encabezados precompilados con GCC (que a veces es un enfoque sensato para reducir el tiempo de compilación), necesita un único archivo de encabezado (incluidos todos los demás y también los encabezados del sistema).

Observe que en C ++, los archivos de encabezado estándar extraen mucho código . Por ejemplo, #include <vector>está tirando más de diez mil líneas en mi GCC 6 en Linux (18100 líneas). Y se #include <map> expande a casi 40KLOC. Por lo tanto, si tiene muchos archivos de encabezado pequeños, incluidos encabezados estándar, terminará volviendo a analizar muchos miles de líneas durante la compilación, y su tiempo de compilación se ve afectado. Es por eso que no me gusta tener muchas líneas pequeñas de fuente C ++ (de unos cientos de líneas como máximo), pero estoy a favor de tener menos archivos C ++ pero más grandes (de varios miles de líneas).

(por lo que tener cientos de pequeños archivos C ++ que siempre incluyen, incluso indirectamente, varios archivos de encabezado estándar proporciona un tiempo de compilación enorme, lo que molesta a los desarrolladores)

En el código C, a menudo los archivos de encabezados se expanden a algo más pequeño, por lo que la compensación es diferente.

Busque también, en busca de inspiración, prácticas previas en proyectos de software libre existentes (por ejemplo, en github ).

Tenga en cuenta que las dependencias podrían tratarse con un buen sistema de automatización de compilación . Estudie la documentación de GNU make . Tenga en cuenta varios -Mindicadores de preprocesador para GCC (útil para generar dependencias automáticamente).

En otras palabras, su proyecto (con menos de cien archivos y una docena de desarrolladores) probablemente no sea lo suficientemente grande como para preocuparse realmente por el "infierno de encabezado", por lo que su preocupación no está justificada . Podría tener solo una docena de archivos de encabezado (o incluso mucho menos), podría elegir tener un archivo de encabezado por unidad de traducción, incluso podría elegir tener un solo archivo de encabezado, y lo que elija hacer no será un "cabecera del infierno" (y refactorizar y reorganizar sus archivos se mantendría razonablemente fácil, por lo que la elección inicial no es realmente importante ).

(No centre sus esfuerzos en el "infierno de encabezado" -que no es un problema para usted-, pero enfóquelos para diseñar una buena arquitectura)

Basile Starynkevitch
fuente
Los tecnicismos que menciona pueden ser correctos. Sin embargo, tal como lo entendí, el OP estaba pidiendo pistas sobre cómo mejorar el mantenimiento y la organización del código, no el tiempo de compilación. Y veo un conflicto directo entre estos dos objetivos.
Murphy
Pero todavía es una cuestión de opinión. Y el OP aparentemente está comenzando un proyecto no tan grande.
Basile Starynkevitch