Come posso evitare l'inferno dell'intestazione?


45

Stiamo iniziando un nuovo progetto, da zero. Circa otto sviluppatori, una dozzina di sottosistemi, ciascuno con quattro o cinque file di origine.

Cosa possiamo fare per evitare "header hell", AKA "spaghetti headers"?

  • Un'intestazione per file di origine?
  • Più uno per sottosistema?
  • Separare i typdef, gli stuct e gli enum dai prototipi delle funzioni?
  • Separare il sottosistema interno da quello esterno del sottosistema?
  • Insistere sul fatto che ogni singolo file, se intestazione o sorgente deve essere compilabile autonomamente?

Non sto chiedendo un modo "migliore", solo un puntatore su cosa fare attenzione e cosa potrebbe causare dolore, in modo che possiamo cercare di evitarlo.

Questo sarà un progetto C ++, ma le informazioni C potrebbero aiutare i futuri lettori.


16
Ottieni una copia di C ++ Software Design su larga scala , non solo insegnerà a evitare problemi con le intestazioni, ma molti altri problemi riguardanti le dipendenze fisiche tra i file sorgente e oggetti in un progetto C ++.
Doc Brown,

6
Tutte le risposte qui sono fantastiche. Volevo aggiungere che la documentazione per l'uso di oggetti, metodi, funzioni, dovrebbe trovarsi nei file di intestazione. Vedo ancora doc nei file di origine. Non farmi leggere la fonte. Questo è il punto del file di intestazione. Non dovrei aver bisogno di leggere la fonte se non sono un implementatore.
Bill Door,

1
Sono sicuro di aver lavorato con te prima. Spesso :-(
Mawg,

5
Quello che descrivi non è un grande progetto. Un buon design è sempre il benvenuto, ma potresti non dover mai affrontare problemi di "sistemi su larga scala".
Sam,

2
Boost in realtà ha un approccio tutto compreso. Ogni caratteristica individuale ha il suo file di intestazione, ma ogni modulo più grande ha anche un'intestazione che include tutto. Questo risulta essere davvero potente per ridurre al minimo l'intestazione-hell senza forzarti a #includere ogni centinaia di file ogni volta.
Cort Ammon,

Risposte:


39

Metodo semplice: un'intestazione per file di origine. Se si dispone di un sottosistema completo in cui non è previsto che gli utenti siano a conoscenza dei file di origine, disporre di un'intestazione per il sottosistema, inclusi tutti i file di intestazione richiesti.

Qualsiasi file di intestazione dovrebbe essere compilabile da solo (o diciamo che dovrebbe essere compilato un file di origine che include una singola intestazione). È un dolore se trovo quale file di intestazione contiene ciò che voglio, e quindi devo dare la caccia agli altri file di intestazione. Un modo semplice per imporre questo è che ogni file sorgente includa prima il suo file header (grazie a doug65536, penso di farlo quasi sempre senza nemmeno accorgermene).

Assicurati di utilizzare gli strumenti disponibili per ridurre i tempi di compilazione: ogni intestazione deve essere inclusa una sola volta, usa le intestazioni precompilate per mantenere i tempi di compilazione bassi, se possibile usa i moduli precompilati per mantenere i tempi di compilazione più bassi.


Dove diventa difficile sono le chiamate di funzione tra sottosistemi, con parametri di tipi dichiarati nell'altro sottosistema.
Mawg,

6
Tricky o no, deve essere compilato "#include <subsystem1.h>". Come lo raggiungi, fino a te. @FrankPuffer: perché?
gnasher729,

13
@Mawg Indica che è necessario disporre di un sottosistema separato e condiviso che includa le caratteristiche comuni dei sottosistemi distinti oppure che siano necessarie intestazioni di "interfaccia" semplificate per ciascun sottosistema (che viene quindi utilizzato dalle intestazioni di implementazione, sia interne che tra sistemi) . Se non puoi scrivere le intestazioni dell'interfaccia senza cross-include, la progettazione del tuo sottosistema è incasinata e devi riprogettare le cose in modo che i tuoi sottosistemi siano più indipendenti. (Il che può includere l'estrazione di un sottosistema comune come terzo modulo.)
RM

8
Una buona tecnica per garantire che un'intestazione sia indipendente è avere una regola secondo cui il file di origine include sempre prima la propria intestazione . Ciò rileverà i casi in cui è necessario spostare le dipendenze dal file di implementazione nel file di intestazione.
Doug65536,

4
@FrankPuffer: ti preghiamo di non eliminare i tuoi commenti, soprattutto se altri rispondono, poiché rende le risposte senza contesto. Puoi sempre correggere la tua dichiarazione in un nuovo commento. Grazie! Sono interessato a sapere cosa hai effettivamente detto, ma ora non c'è più :(
MPW

18

Il requisito di gran lunga più importante è ridurre le dipendenze tra i file di origine. In C ++ è comune usare un file sorgente e un'intestazione per classe. Pertanto, se hai un design di buona classe, non ti avvicinerai nemmeno all'inferno dell'intestazione.

Puoi anche vederlo viceversa: se hai già un inferno nel tuo progetto, puoi essere certo che la progettazione del software debba essere migliorata.

Per rispondere a domande specifiche:

  • Un'intestazione per file di origine? → Sì, funziona nella maggior parte dei casi e semplifica la ricerca di materiale. Ma non renderlo una religione.
  • Più uno per sottosistema? → No, perché dovresti farlo?
  • Separare i typdef, gli stuct e gli enum dai prototipi delle funzioni? → No, funzioni e tipi correlati appartengono insieme.
  • Separare il sottosistema interno da quello esterno del sottosistema? → Sì, certo. Ciò ridurrà le dipendenze.
  • Insistere sul fatto che ogni singolo file, sia intestazione o sorgente da standalones compliable? → Sì, non è mai necessario includere alcuna intestazione prima di un'altra intestazione.

12

Oltre alle altre raccomandazioni, sulla falsariga di ridurre le dipendenze (principalmente applicabile al C ++):

  1. Includi solo ciò di cui hai veramente bisogno, dove ne hai bisogno (livello più basso possibile). Per esempio. non includere in un'intestazione se hai bisogno delle chiamate solo nella fonte.
  2. Utilizzare le dichiarazioni forward nelle intestazioni ove possibile (l'intestazione contiene solo puntatori o riferimenti ad altre classi).
  3. Ripulisci le inclusioni dopo ogni refactoring (commentale, vedi dove la compilazione fallisce, spostale lì, rimuovi le linee di inclusione ancora commentate).
  4. Non mettere troppe strutture comuni nello stesso file; dividerli per funzionalità (es. Logger è una classe, quindi un'intestazione e un file sorgente; SystemHelper dito. ecc.).
  5. Attenersi ai principi OO, anche se tutto ciò che si ottiene è una classe costituita esclusivamente da metodi statici (anziché funzioni autonome) - oppure utilizzare uno spazio dei nomi .
  6. Per alcune strutture comuni il modello singleton è piuttosto utile, poiché non è necessario richiedere l'istanza da qualche altro oggetto non correlato.

5
Al n. 3, lo strumento include ciò che usi può aiutare, evitando l'approccio di ricompilazione manuale indovinare e controllare.
RM

1
Potresti spiegare qual è il vantaggio dei singoli in questo contesto? Davvero non capisco.
Frank Puffer,

@FrankPuffer La mia logica è questa: senza un singleton alcune istanze di una classe possiedono di solito l'istanza di una classe helper, come un Logger. Se una terza classe vuole usarla, deve richiedere al proprietario un riferimento della classe helper, il che significa che usi due classi e, naturalmente, includi le loro intestazioni, anche se l'utente non ha rapporti d'affari con il proprietario. Con un singleton devi solo includere l'intestazione della classe helper e puoi richiedere l'istanza direttamente da essa. Vedi un difetto in questa logica?
Murphy,

1
# 2 (dichiarazioni a termine) può fare un'enorme differenza nel tempo di compilazione e nel controllo delle dipendenze. Come mostra questa risposta ( stackoverflow.com/a/9999752/509928 ), si applica sia a C ++ che a C
Dave Compton,

3
È possibile utilizzare le dichiarazioni forward anche quando si passa per valore, purché la funzione non sia definita in linea. Una dichiarazione di funzione non è un contesto in cui è necessaria la definizione completa del tipo (una definizione di funzione o una chiamata di funzione è tale contesto).
StoryTeller - Unslander Monica,

6

Un'intestazione per file di origine, che definisce ciò che il suo file di origine implementa / esporta.

Tutti i file di intestazione necessari, inclusi in ciascun file di origine (a partire dalla propria intestazione).

Evitare di includere (minimizzare l'inclusione di) file di intestazione in altri file di intestazione (per evitare dipendenze circolari). Per i dettagli vedere questa risposta a "due classi possono vedersi l'un l'altro usando C ++?"

C'è un intero libro su questo argomento, Software di progettazione C ++ su larga scala di Lakos. Descrive di avere "livelli" di software: i livelli di alto livello usano livelli di livello inferiore non viceversa, il che evita nuovamente dipendenze circolari.


4

Direi che la tua domanda è fondamentalmente senza risposta, poiché ci sono due tipi di inferno di intestazione:

  • Il tipo in cui è necessario includere un milione di intestazioni diverse e chi all'inferno può persino ricordarle tutte? E mantenere quegli elenchi di intestazioni? Ugh.
  • Il tipo in cui includi una cosa e scopri di aver incluso l'intera Torre di Babele (o dovrei dire Torre di Boost? ...)

il fatto è che se si tenta di evitare il primo si finisce, in una certa misura, con il secondo e viceversa.

C'è anche un terzo tipo di inferno, che è la dipendenza circolare. Questi potrebbero apparire se non stai attento ... evitarli non è super complicato, ma devi prenderti il ​​tempo per pensare a come farlo. Guarda John Lakos parlare di Levelization in CppCon 2016 (o solo nelle diapositive ).


1
Non puoi sempre evitare dipendenze circolari. Un esempio è un modello in cui le entità si riferiscono l'una all'altra. Almeno puoi provare a limitare la circolarità nel sottosistema, questo significa che se includi un'intestazione del sottosistema, hai estratto la circolarità.
inalterato il

2
@nalply: intendevo evitare la dipendenza circolare delle intestazioni, non del codice ... se non eviti la dipendenza circolare delle intestazioni probabilmente non sarai in grado di costruire. Ma sì, punto preso, +1.
einpoklum - ripristina Monica il

1

Disaccoppiamento

Alla fine si tratta di disaccoppiarmi alla fine della giornata al livello di progettazione più fondamentale privo della sfumatura delle caratteristiche dei nostri compilatori e linker. Voglio dire che puoi fare cose come fare in modo che ogni intestazione definisca solo una classe, usare i pimpl, inoltrare dichiarazioni ai tipi che devono solo essere dichiarati, non definiti, forse anche usare intestazioni che contengono solo dichiarazioni in avanti (es:), <iosfwd>un'intestazione per file sorgente , organizzare il sistema in modo coerente in base al tipo di oggetto dichiarato / definito, ecc.

Tecniche per ridurre le "dipendenze in fase di compilazione"

E alcune delle tecniche possono aiutare un po 'ma puoi ritrovarti a esaurire queste pratiche e trovare ancora il tuo file sorgente medio nel tuo sistema richiede un preambolo di due pagine di #includedirettive per fare qualcosa di leggermente significativo con tempi di costruzione alle stelle se ti concentri troppo sulla riduzione delle dipendenze in fase di compilazione a livello di intestazione senza ridurre le dipendenze logiche nei tuoi progetti di interfaccia e mentre ciò potrebbe non essere considerato "intestazioni di spaghetti" in senso stretto, io direi ancora che si traduce in problemi dannosi simili alla produttività nella pratica. Alla fine della giornata se le tue unità di compilazione richiedono ancora un carico di informazioni per essere visibili per fare qualsiasi cosa, allora si tradurrà in un aumento dei tempi di costruzione e moltiplicherà i motivi per cui devi potenzialmente tornare indietro e cambiare le cose mentre fai sviluppatori sembra che stiano testando il sistema solo cercando di terminare la loro codifica giornaliera. E'

Ad esempio, è possibile fare in modo che ciascun sottosistema fornisca un'interfaccia e un file di intestazione molto astratti. Ma se i sottosistemi non sono disaccoppiati l'uno dall'altro, si ottiene qualcosa che assomiglia di nuovo agli spaghetti con le interfacce del sottosistema a seconda delle altre interfacce del sottosistema con un grafico delle dipendenze che sembra un disastro per funzionare.

Dichiarazioni inoltrate a tipi esterni

Di tutte le tecniche che ho esaurito per cercare di ottenere un ex codebase che richiedeva due ore per essere costruito mentre gli sviluppatori a volte aspettavano 2 giorni per il loro turno in CI sui nostri server di build (puoi quasi immaginare quelle macchine di build come bestie esauste di carico che cercano freneticamente per tenere il passo e fallire mentre gli sviluppatori spingono le loro modifiche), il più discutibile per me era inoltrare i tipi definiti in altre intestazioni. E sono riuscito a portare quel codice a 40 minuti o giù di lì dopo anni di tempo in piccoli passi incrementali mentre cercavo di ridurre gli "spaghetti all'intestazione", la pratica più discutibile col senno di poi (come nel farmi perdere di vista la natura fondamentale di progettare mentre il tunnel visionava le interdipendenze delle intestazioni) veniva dichiarato in avanti tipi definiti in altre intestazioni.

Se immagini Foo.hppun'intestazione con qualcosa di simile:

#include "Bar.hpp"

E utilizza solo Barnell'intestazione un modo che richiede la sua dichiarazione, non la definizione. allora potrebbe sembrare un gioco da ragazzi dichiarare class Bar;per evitare di rendere Barvisibile la definizione nell'intestazione. Tranne che nella pratica spesso troverai che la maggior parte delle unità di compilazione che usano Foo.hppfiniscono Barper essere necessariamente definite in ogni caso con l'onere aggiuntivo di doversi includere Bar.hppsopra Foo.hpp, o ti imbatti in un altro scenario in cui ciò aiuta davvero e 99 La% delle tue unità di compilazione può funzionare senza includere Bar.hpp, tranne per il fatto che solleva la domanda di progettazione più fondamentale (o almeno penso che dovrebbe al giorno d'oggi) del motivo per cui devono anche vedere la dichiarazione Bare perchéFoo ha anche bisogno di preoccuparsi di saperlo se è irrilevante per la maggior parte dei casi d'uso (perché caricare un progetto con dipendenze rispetto a un altro a malapena usato?).

Perché concettualmente non abbiamo realmente disaccoppiato Fooda Bar. Abbiamo appena fatto in modo l'intestazione del Foonon ha bisogno di quante più informazioni circa l'intestazione di Bar, e che non è quasi come sostanziale come un disegno che rende veramente questi due completamente indipendenti l'uno dall'altro.

Scripting incorporato

Questo è davvero per basi di codice su larga scala, ma un'altra tecnica che trovo immensamente utile è quella di utilizzare un linguaggio di scripting incorporato almeno per le parti più di alto livello del sistema. Ho scoperto che sono stato in grado di incorporare Lua in un giorno e averlo in modo uniforme in grado di chiamare tutti i comandi nel nostro sistema (i comandi erano astratti, per fortuna). Sfortunatamente mi sono imbattuto in un blocco stradale in cui gli sviluppatori non hanno diffidato dell'introduzione di un'altra lingua e, forse più stranamente, con prestazioni come il loro più grande sospetto. Tuttavia, mentre potrei capire altre preoccupazioni, le prestazioni dovrebbero essere un problema se utilizziamo lo script solo per invocare comandi quando gli utenti fanno clic sui pulsanti, ad esempio, che non eseguono loop propri (cosa stiamo cercando di fare, preoccuparsi delle differenze di nanosecondi nei tempi di risposta per un clic sul pulsante?).

Esempio

Nel frattempo il modo più efficace a cui abbia mai assistito dopo aver esaurito le tecniche per ridurre i tempi di compilazione in grandi codebase sono architetture che riducono realmente la quantità di informazioni necessarie per far funzionare qualsiasi cosa nel sistema, non solo disaccoppiando un'intestazione da un'altra da un compilatore prospettiva ma che richiede agli utenti di queste interfacce di fare ciò che devono fare pur conoscendo (sia dal punto di vista umano che del compilatore, il vero disaccoppiamento che va oltre le dipendenze del compilatore) il minimo indispensabile.

L'ECS è solo un esempio (e non sto suggerendo di usarne uno), ma incontrarlo mi ha mostrato che puoi avere alcune basi di codice davvero epiche che si costruiscono ancora sorprendentemente rapidamente mentre usi felicemente modelli e molte altre chicche perché l'ECS, di nature, crea un'architettura molto disaccoppiata in cui i sistemi devono solo conoscere il database ECS e in genere solo una manciata di tipi di componenti (a volte solo uno) per fare le loro cose:

inserisci qui la descrizione dell'immagine

Design, design, design

E questo tipo di progetti architettonici disaccoppiati a livello umano e concettuale è più efficace in termini di minimizzazione dei tempi di compilazione rispetto a qualsiasi delle tecniche che ho esplorato sopra mentre la tua base di codice cresce e cresce e cresce, perché quella crescita non si traduce nella tua media unità di compilazione che moltiplica la quantità di informazioni richieste al momento della compilazione e dei tempi di collegamento per funzionare (qualsiasi sistema che richiede allo sviluppatore medio di includere un carico di roba per fare qualsiasi cosa richiede anche loro e non solo il compilatore di conoscere una grande quantità di informazioni per fare qualsiasi cosa ). Ha anche più vantaggi di tempi di costruzione ridotti e districare le intestazioni, poiché significa anche che i tuoi sviluppatori non hanno bisogno di sapere molto sul sistema oltre a ciò che è immediatamente necessario per fare qualcosa con esso.

Se, ad esempio, puoi assumere uno sviluppatore di fisica esperto per sviluppare un motore fisico per il tuo gioco AAA che si estende su milioni di LOC, e può iniziare molto rapidamente conoscendo le informazioni minime e nulle assoluto per quanto riguarda i tipi e le interfacce disponibili così come i concetti del tuo sistema, allora questo si tradurrà naturalmente in una quantità ridotta di informazioni che sia lui che il compilatore dovranno richiedere per costruire il suo motore fisico, e allo stesso modo si tradurranno in una grande riduzione dei tempi di costruzione, implicando in generale che non c'è niente di simile agli spaghetti ovunque nel sistema. Ed è quello che sto suggerendo di dare priorità a tutte queste altre tecniche: come progettate i vostri sistemi. Lo scarico di altre tecniche sarà la ciliegina sulla torta se lo fai mentre, altrimenti,


1
Un'ottima risposta! Althoguh ho dovuto scavare un po 'per scoprire cosa sono i brufoli :-)
Mawg

0

È una questione di opinione. Vedi questa risposta e quella . E dipende anche molto dalle dimensioni del progetto (se ritieni di avere milioni di linee di origine nel tuo progetto, non è lo stesso di averne alcune decine di migliaia).

Contrariamente ad altre risposte, raccomando un'intestazione pubblica (piuttosto grande) per sottosistema (che potrebbe includere intestazioni "private", forse con file separati per implementazioni di molte funzioni incorporate). Potresti anche considerare un'intestazione con solo diverse #include direttive.

Non penso che si consigliano molti file di intestazione. In particolare, non consiglio un file di intestazione per classe o molti piccoli file di intestazione di poche decine di righe ciascuno.

(Se hai molti file di piccole dimensioni, dovrai includerne molti in ogni piccola unità di traduzione e il tempo di costruzione complessivo potrebbe risentirne)

Quello che vuoi davvero è identificare, per ogni sottosistema e file, lo sviluppatore principale responsabile.

Alla fine, per un piccolo progetto (ad esempio meno di centomila righe di codice sorgente), non è molto importante. Durante il progetto sarai abbastanza facilmente in grado di riformattare il codice e riorganizzarlo in diversi file. Copierete e incollerete solo pezzi di codice in nuovi file (di intestazione), non un grosso problema (ciò che è più difficile è progettare saggiamente come riorganizzerete i vostri file, e questo è specifico del progetto).

(la mia preferenza personale è quella di evitare file troppo grandi e troppo piccoli; spesso ho file di origine di diverse migliaia di righe ciascuno; e non ho paura di un file di intestazione -incluso definizioni di funzioni incorporate- di molte centinaia di righe o anche un paio di migliaia di loro)

Si noti che se si desidera utilizzare le intestazioni precompilate con GCC (che a volte è un approccio sensato per ridurre i tempi di compilazione) è necessario un singolo file di intestazione (compresi tutti gli altri, e anche le intestazioni di sistema).

Si noti che in C ++, i file di intestazione standard stanno estraendo molto codice . Ad esempio #include <vector>sta tirando più di diecimila linee sul mio GCC 6 su Linux (18100 linee). E si #include <map> espande a quasi 40KLOC. Quindi, se hai molti file di intestazione di piccole dimensioni, comprese le intestazioni standard, finisci per analizzare molte migliaia di righe durante la compilazione e il tempo di compilazione risente. Questo è il motivo per cui non mi piace avere molte piccole linee sorgente C ++ (di poche centinaia di righe al massimo), ma preferisco avere meno file C ++ ma più grandi (di diverse migliaia di righe).

(quindi avere centinaia di piccoli file C ++ che includono sempre, anche indirettamente, diversi file di intestazione standard offre un enorme tempo di costruzione, il che infastidisce gli sviluppatori)

Nel codice C, molto spesso i file delle intestazioni si espandono in qualcosa di più piccolo, quindi il compromesso è diverso.

Cerca anche l'ispirazione nella pratica precedente in progetti di software libero esistenti (ad es. Su github ).

Si noti che le dipendenze potrebbero essere gestite con un buon sistema di automazione della build . Studia la documentazione di GNU make . Prestare attenzione a vari -Mflag di preprocessore su GCC (utile per generare dipendenze automaticamente).

In altre parole, il tuo progetto (con meno di un centinaio di file e una dozzina di sviluppatori) non è probabilmente abbastanza grande da essere realmente preoccupato da "inferno di intestazione", quindi la tua preoccupazione non è giustificata . Potresti avere solo una dozzina di file di intestazione (o anche molto meno), puoi scegliere di avere un file di intestazione per unità di traduzione, puoi anche scegliere di avere un singolo file di intestazione e qualunque cosa tu scelga di fare non sarà un "header hell" (e il refactoring e la riorganizzazione dei file rimarrebbero ragionevolmente facili, quindi la scelta iniziale non è molto importante ).

(Non concentrare i tuoi sforzi su "header hell", che non è un problema per te), ma concentrali per progettare una buona architettura)


I tecnicismi citati potrebbero essere corretti. Tuttavia, come ho capito, l'OP chiedeva suggerimenti su come migliorare la manutenibilità e l'organizzazione del codice, non il tempo di compilazione. E vedo un conflitto diretto tra questi due obiettivi.
Murphy,

Ma è ancora una questione di opinione. E a quanto pare l'OP sta iniziando un progetto non così grande.
Basile Starynkevitch,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.