Perché non dovrei includere file cpp e usare invece un'intestazione?


147

Così ho finito il mio primo incarico di programmazione in C ++ e ho ricevuto il mio voto. Ma secondo la classificazione, ho perso i voti per including cpp files instead of compiling and linking them. Non sono troppo chiaro su cosa significhi.

Guardando indietro al mio codice, ho scelto di non creare file di intestazione per le mie classi, ma ho fatto tutto nei file cpp (sembrava funzionare bene senza file di intestazione ...). Suppongo che il selezionatore significasse che ho scritto '#include "mycppfile.cpp";' in alcuni dei miei file.

Il mio ragionamento per l #include'inging i file cpp era: - Tutto ciò che doveva andare nel file header era nel mio file cpp, quindi ho fatto finta che fosse come un file header - In stile scimmia-vedi-scimmia, ho visto che l'altro i file di intestazione erano #includenei file, quindi ho fatto lo stesso per il mio file cpp.

Quindi cosa ho fatto di sbagliato esattamente, e perché è male?


36
Questa è davvero una buona domanda. Mi aspetto che molti neofiti del c ++ saranno aiutati da questo.
Mia Clarke,

Risposte:


175

Per quanto ne so, lo standard C ++ non conosce alcuna differenza tra file di intestazione e file di origine. Per quanto riguarda la lingua, qualsiasi file di testo con codice legale è uguale a qualsiasi altro. Tuttavia, sebbene non illegale, includere i file di origine nel programma eliminerà praticamente tutti i vantaggi che potresti ottenere dalla separazione dei file di origine.

In sostanza, ciò che #includefa è dire al preprocessore di prendere l'intero file che hai specificato e copiarlo nel tuo file attivo prima che il compilatore ci metta le mani sopra. Quindi quando includi tutti i file sorgente nel tuo progetto insieme, fondamentalmente non c'è alcuna differenza tra quello che hai fatto, e semplicemente creando un enorme file sorgente senza alcuna separazione.

"Oh, non è un grosso problema. Se funziona, va bene", ti sento piangere. E in un certo senso, avresti ragione. Ma in questo momento hai a che fare con un piccolo minuscolo programma e una CPU piacevole e relativamente libera per compilarlo per te. Non sarai sempre così fortunato.

Se ti immergi nei regni della seria programmazione per computer, vedrai progetti con conteggi di linee che possono raggiungere milioni, anziché dozzine. Sono molte righe. E se si tenta di compilare uno di questi su un moderno computer desktop, possono essere necessarie poche ore anziché secondi.

"Oh no! Sembra orribile! Comunque posso impedire questo terribile destino ?!" Sfortunatamente, non c'è molto che puoi fare al riguardo. Se ci vogliono ore per compilare, ci vogliono ore per compilare. Ma questo conta davvero solo la prima volta - una volta che lo hai compilato una volta, non c'è motivo di compilarlo di nuovo.

A meno che tu non cambi qualcosa.

Ora, se hai avuto due milioni di righe di codice unite in un gigantesco colosso, e devi fare una semplice correzione di bug come, diciamo x = y + 1, ciò significa che devi compilare di nuovo tutte e due le righe per testare questo. E se scopri che intendevi fare un x = y - 1invece, di nuovo, ti aspettano due milioni di righe di compilazione. Sono molte le ore di tempo sprecate che potrebbero essere spese meglio facendo qualsiasi altra cosa.

"Ma odio essere improduttivo! Se solo ci fosse un modo per compilare parti distinte della mia base di codice individualmente, e in qualche modo collegarle insieme in seguito!" Un'idea eccellente, in teoria. E se il tuo programma avesse bisogno di sapere cosa sta succedendo in un altro file? È impossibile separare completamente il tuo codebase a meno che tu non voglia invece eseguire un mucchio di piccoli file .exe piccoli.

"Ma sicuramente deve essere possibile! La programmazione suona come pura tortura altrimenti! E se trovassi un modo per separare l' interfaccia dall'implementazione ? Dire prendendo semplicemente le informazioni sufficienti da questi segmenti di codice distinti per identificarle nel resto del programma e mettere invece in una sorta di file header ? E in questo modo, posso usare la #include direttiva preprocessore per portare solo le informazioni necessarie per la compilazione! "

Hmm. Potresti essere su qualcosa lì. Fammi sapere come funziona per te.


13
Buona risposta, signore. È stata una lettura divertente e facile da capire. Vorrei che il mio libro di testo fosse scritto così.
ialm,

@veol Search for Head Prima serie di libri - Non so se hanno una versione C ++. headfirstlabs.com
Amarghosh,

2
Questa è (definita) la migliore formulazione finora che abbia ascoltato o contemplato. Justin Case, un abile principiante, ha raggiunto un progetto con un milione di sequenze di tasti, non ancora spedito e un lodevole "primo progetto" che sta vedendo la luce dell'applicazione in una vera base di utenti, ha riconosciuto un problema risolto dalle chiusure. Sembra notevolmente simile agli stati avanzati della definizione originale del problema di OP meno il "codificato quasi cento volte e non riesco a capire cosa fare per null (come nessun oggetto) vs null (come nipote) senza usare la programmazione per eccezioni".
Nicholas Jordan,

Ovviamente tutto questo cade a pezzi per i template perché la maggior parte dei compilatori non supporta / implementa la parola chiave 'export'.
KitsuneYMG,

1
Un altro punto è che hai molte librerie all'avanguardia (se pensi a BOOST) che usa le classi solo intestazioni ... Ho, aspetta? Perché un programmatore esperto non separa l'interfaccia dall'implementazione? Parte della risposta potrebbe essere ciò che Blindly ha detto, un'altra parte potrebbe essere che un file è meglio di due quando è possibile, e un'altra parte è che il collegamento ha un costo che può essere piuttosto elevato. Ho visto programmi eseguiti dieci volte più velocemente con l'inclusione diretta di sorgenti e compilatori ottimizzati. Perché il collegamento principalmente ottimizza i blocchi.
Kriss,

45

Questa è probabilmente una risposta più dettagliata di quanto tu volessi, ma penso che una spiegazione decente sia giustificata.

In C e C ++, un file sorgente è definito come un'unità di traduzione . Per convenzione, i file di intestazione contengono dichiarazioni di funzioni, definizioni di tipi e definizioni di classi. Le implementazioni effettive delle funzioni risiedono in unità di traduzione, ad esempio file .cpp.

L'idea alla base di ciò è che le funzioni e le funzioni dei membri class / struct vengono compilate e assemblate una volta, quindi altre funzioni possono chiamare quel codice da una posizione senza fare duplicati. Le tue funzioni sono dichiarate come "esterne" implicitamente.

/* Function declaration, usually found in headers. */
/* Implicitly 'extern', i.e the symbol is visible everywhere, not just locally.*/
int add(int, int);

/* function body, or function definition. */
int add(int a, int b) 
{
   return a + b;
}

Se vuoi che una funzione sia locale per un'unità di traduzione, la definisci come 'statica'. Cosa significa questo? Significa che se includi file sorgente con funzioni extern, otterrai errori di ridefinizione, perché il compilatore incontra la stessa implementazione più di una volta. Quindi, vuoi che tutte le tue unità di traduzione vedano la dichiarazione della funzione ma non il corpo della funzione .

Quindi, come si fa a mescolare tutto alla fine? Questo è il lavoro del linker. Un linker legge tutti i file oggetto generati dalla fase assembler e risolve i simboli. Come ho detto prima, un simbolo è solo un nome. Ad esempio, il nome di una variabile o una funzione. Quando le unità di traduzione che chiamano funzioni o dichiarano tipi non conoscono l'implementazione di tali funzioni o tipi, si dice che quei simboli sono irrisolti. Il linker risolve il simbolo non risolto collegando l'unità di traduzione che contiene il simbolo indefinito insieme a quello che contiene l'implementazione. Uff. Questo vale per tutti i simboli visibili esternamente, siano essi implementati nel tuo codice o forniti da una libreria aggiuntiva. Una libreria è in realtà solo un archivio con codice riutilizzabile.

Ci sono due notevoli eccezioni. Innanzitutto, se hai una piccola funzione, puoi renderla in linea. Ciò significa che il codice macchina generato non genera una chiamata di funzione esterna, ma è letteralmente concatenato sul posto. Poiché di solito sono piccoli, le dimensioni generali non contano. Potete immaginare che siano statici nel modo in cui funzionano. Quindi è sicuro implementare funzioni incorporate nelle intestazioni. Le implementazioni di funzioni all'interno di una definizione di classe o di struttura sono spesso integrate automaticamente dal compilatore.

L'altra eccezione sono i modelli. Poiché il compilatore deve visualizzare l'intera definizione del tipo di modello durante l'istanza, non è possibile disaccoppiare l'implementazione dalla definizione come con le funzioni autonome o le classi normali. Bene, forse questo è possibile ora, ma ottenere il supporto diffuso del compilatore per la parola chiave "export" ha richiesto molto, molto tempo. Pertanto, senza il supporto per "esportazione", le unità di traduzione ottengono le loro copie locali di tipi e funzioni di modelli istanziati, in modo simile al funzionamento delle funzioni inline. Con il supporto per "export", non è così.

Per le due eccezioni, alcune persone trovano "più bello" inserire le implementazioni di funzioni inline, funzioni con template e tipi con template in file .cpp e quindi #includere il file .cpp. Non importa se si tratta di un'intestazione o di un file sorgente; al preprocessore non importa ed è solo una convenzione.

Un breve riepilogo dell'intero processo dal codice C ++ (diversi file) a un eseguibile finale:

  • Il preprocessore viene eseguito, che analizza tutte le direttive che iniziano con un '#'. La direttiva #include concatena il file incluso con un valore inferiore, ad esempio. Fa anche macro-sostituzione e incolla token.
  • Il compilatore effettivo viene eseguito sul file di testo intermedio dopo la fase del preprocessore ed emette il codice assembler.
  • L' assemblatore viene eseguito sul file di assieme ed emette codice macchina, in genere viene chiamato file oggetto e segue il formato eseguibile binario del sistema operativo in questione. Ad esempio, Windows utilizza PE (formato eseguibile portatile), mentre Linux utilizza il formato ELF Unix System V, con estensioni GNU. In questa fase, i simboli sono ancora contrassegnati come indefiniti.
  • Infine, viene eseguito il linker . Tutte le fasi precedenti sono state eseguite su ciascuna unità di traduzione in ordine. Tuttavia, la fase del linker funziona su tutti i file oggetto generati che sono stati generati dall'assemblatore. Il linker risolve i simboli e fa molta magia come la creazione di sezioni e segmenti, che dipende dalla piattaforma di destinazione e dal formato binario. I programmatori non sono tenuti a saperlo in generale, ma sicuramente aiutano in alcuni casi.

Ancora una volta, questo è stato sicuramente più di quello che hai chiesto, ma spero che i dettagli nitidi ti aiutino a vedere il quadro più ampio.


2
Grazie per la tua completa spiegazione. Lo ammetto, non ha ancora senso per me e penso che dovrò rileggere la tua risposta (e ancora).
ialm,

1
+1 per una spiegazione eccellente. peccato che probabilmente spaventerà tutti i neofiti del C ++. :)
goldPseudo

1
Heh, non sentirti male. Su Stack Overflow, la risposta più lunga è raramente la risposta migliore.

int add(int, int);è una dichiarazione di funzione . La parte prototipo è giusta int, int. Tuttavia, tutte le funzioni in C ++ hanno un prototipo, quindi il termine ha davvero senso solo in C. Ho modificato la tua risposta a questo effetto.
melpomene,

exportper i template è stato rimosso dalla lingua nel 2011. Non è mai stato realmente supportato dai compilatori.
melpomene,

10

La soluzione tipica è utilizzare i .hfile solo per le dichiarazioni e i .cppfile per l'implementazione. Se è necessario riutilizzare l'implementazione, si include il .hfile corrispondente nel file in .cppcui viene utilizzata la classe / funzione / qualunque cosa necessaria e si collega a un .cppfile già compilato (o un .objfile - di solito utilizzato all'interno di un progetto - o un file .lib - di solito utilizzato per il riutilizzo da più progetti). In questo modo non è necessario ricompilare tutto se cambia solo l'implementazione.


6

Pensa ai file cpp come a una scatola nera e ai file .h come le guide su come usare quelle scatole nere.

I file cpp possono essere compilati in anticipo. Questo non funziona in te # includerli, poiché deve "includere" il codice nel tuo programma ogni volta che lo compila. Se includi solo l'intestazione, puoi semplicemente utilizzare il file di intestazione per determinare come utilizzare il file cpp precompilato.

Anche se questo non farà molta differenza per il tuo primo progetto, se inizi a scrivere grandi programmi cpp, le persone ti odieranno perché i tempi di compilazione esploderanno.

Leggi anche questo: File di intestazione Include modelli


Grazie per l'esempio più concreto. Ho provato a leggere il tuo link, ma ora sono confuso ... qual è la differenza tra includere un'intestazione esplicitamente e una dichiarazione diretta?
ialm,

Questo è un grande articolo. Veol, qui includono le intestazioni in cui il compilatore ha bisogno di informazioni sulla dimensione della classe. La dichiarazione diretta viene utilizzata quando si utilizzano solo puntatori.
pankajt,

forward declaraion: int someFunction (int necessarieValue); notare l'uso di informazioni sul tipo e (di solito) nessuna parentesi graffa. Questo, come indicato, dice al compilatore che a un certo punto avrai bisogno di una funzione che accetta un int e restituisce un int, il compilatore può riservare una chiamata per esso usando queste informazioni. Ciò sarebbe chiamato una dichiarazione diretta. I compilatori più fantasiosi dovrebbero essere in grado di trovare la funzione senza averne bisogno, tra cui un'intestazione può essere un modo utile per dichiarare un mucchio di dichiarazioni in avanti.
Nicholas Jordan,

6

I file di intestazione in genere contengono dichiarazioni di funzioni / classi, mentre i file .cpp contengono le implementazioni effettive. Al momento della compilazione, ogni file .cpp viene compilato in un file oggetto (in genere l'estensione .o) e il linker combina i vari file oggetto nell'eseguibile finale. Il processo di collegamento è generalmente molto più veloce della compilazione.

Vantaggi di questa separazione: se si sta ricompilando uno dei file .cpp nel progetto, non è necessario ricompilare tutti gli altri. Devi solo creare il nuovo file oggetto per quel particolare file .cpp. Il compilatore non deve guardare gli altri file .cpp. Tuttavia, se si desidera chiamare funzioni nel file .cpp corrente che sono state implementate negli altri file .cpp, è necessario indicare al compilatore quali argomenti prendono; quello è lo scopo di includere i file di intestazione.

Svantaggi: durante la compilazione di un determinato file .cpp, il compilatore non può "vedere" ciò che è all'interno degli altri file .cpp. Quindi non sa come vengono implementate le funzioni e, di conseguenza, non può essere ottimizzato in modo aggressivo. Ma penso che non devi preoccuparti di questo ancora (:


5

L'idea di base che le intestazioni sono solo incluse e i file cpp sono solo compilati. Questo diventerà più utile una volta che hai molti file cpp e ricompilare l'intera applicazione quando modifichi solo uno di essi sarà troppo lento. O quando le funzioni nei file inizieranno a seconda dell'altra. Quindi, dovresti separare le dichiarazioni di classe nei tuoi file header, lasciare l'implementazione nei file cpp e scrivere un Makefile (o qualcos'altro, a seconda di quali strumenti stai usando) per compilare i file cpp e collegare i file oggetto risultanti in un programma.


3

Se #includi un file cpp in molti altri file nel tuo programma, il compilatore proverà a compilare il file cpp più volte e genererà un errore poiché ci saranno più implementazioni degli stessi metodi.

La compilazione richiederà più tempo (il che diventa un problema per i progetti di grandi dimensioni), se si apportano modifiche ai file cpp #inclusi, che quindi costringono la ricompilazione di qualsiasi file # includendoli.

Basta inserire le dichiarazioni nei file di intestazione e includerle (poiché in realtà non generano codice di per sé) e il linker collegherà le dichiarazioni con il codice cpp corrispondente (che quindi verrà compilato una sola volta).


Quindi, oltre ad avere tempi di compilazione più lunghi, inizierò ad avere problemi quando includo il mio file cpp in molti file diversi che usano le funzioni nei file cpp inclusi?
ialm,

Sì, questo si chiama collisione dello spazio dei nomi. Di interesse qui è se il collegamento contro le librerie introduce problemi di spazio dei nomi. In generale, trovo che i compilatori producano tempi di compilazione migliori per l'ambito dell'unità di traduzione (tutto in un file) che introduce problemi di spazio dei nomi - il che porta a separare di nuovo .... è possibile includere il file di inclusione in ogni unità di traduzione, (supposto di) c'è persino un pragma (#pragma una volta) che dovrebbe far rispettare questo, ma questa è una supposizione supposta. Fai attenzione a non fare affidamento cieco su librerie (file .O) ovunque, poiché i collegamenti a 32 bit non vengono applicati.
Nicholas Jordan,

2

Mentre è certamente possibile fare come hai fatto, la pratica standard è quella di inserire dichiarazioni condivise in file header (.h) e definizioni di funzioni e variabili - implementazione - in file sorgente (.cpp).

Come convenzione, questo aiuta a chiarire dove si trova tutto e fa una chiara distinzione tra interfaccia e implementazione dei tuoi moduli. Significa anche che non devi mai controllare per vedere se un file .cpp è incluso in un altro, prima di aggiungere qualcosa che potrebbe rompersi se fosse definito in più unità diverse.


2

riutilizzabilità, architettura e incapsulamento dei dati

ecco un esempio:

dici di creare un file cpp che contiene una semplice forma di routine di stringhe tutte in un mystring di classe, metti la decl di classe per questo in un mystring.h compilando mystring.cpp in un file .obj

ora nel tuo programma principale (es. main.cpp) includi header e link con mystring.obj. per usare il mystring nel tuo programma non ti interessano i dettagli su come il mystring è implementato poiché l'intestazione dice cosa può fare

ora se un amico vuole usare la tua classe di mystring gli dai mystring.h e mystring.obj, non ha necessariamente bisogno di sapere come funziona finché funziona.

in seguito se hai più di questi file .obj puoi combinarli in un file .lib e collegarti a quello.

puoi anche decidere di modificare il file mystring.cpp e implementarlo in modo più efficace, questo non influirà sul tuo main.cpp o sul tuo programma di amici.


2

Se funziona per te, allora non c'è niente di sbagliato in questo, tranne per il fatto che arrufferà le piume delle persone che pensano che ci sia un solo modo di fare le cose.

Molte delle risposte fornite qui riguardano le ottimizzazioni per progetti software su larga scala. Queste sono buone cose da sapere, ma non ha senso ottimizzare un piccolo progetto come se fosse un grande progetto - questo è ciò che è noto come "ottimizzazione prematura". A seconda dell'ambiente di sviluppo, potrebbe esserci una notevole complessità aggiuntiva nell'impostazione di una configurazione build per supportare più file di origine per programma.

Se, nel corso del tempo, i vostri evolve progetto e si scopre che il processo di compilazione richiede troppo tempo, allora si può refactoring il codice per utilizzare più file di origine per build più veloce incrementale.

Molte delle risposte parlano della separazione dell'interfaccia dall'implementazione. Tuttavia, questa non è una caratteristica intrinseca dei file include ed è abbastanza comune #include i file "header" che incorporano direttamente la loro implementazione (anche la Libreria Standard C ++ lo fa in misura significativa).

L'unica cosa veramente "non convenzionale" su ciò che hai fatto è stata nominare i tuoi file inclusi ".cpp" invece di ".h" o ".hpp".


1

Quando compili e colleghi un programma, il compilatore compila prima i singoli file cpp e poi li collegano (connettono). Le intestazioni non verranno mai compilate, a meno che non vengano prima incluse in un file cpp.

In genere le intestazioni sono dichiarazioni e cpp sono file di implementazione. Nelle intestazioni si definisce un'interfaccia per una classe o una funzione ma si tralascia il modo in cui si implementano effettivamente i dettagli. In questo modo non è necessario ricompilare tutti i file cpp se si modifica uno.


se lasci l'implementazione fuori dal file header, mi scusi ma mi sembra un'interfaccia Java giusto?
Gansub,

1

Ti suggerirò di passare alla progettazione del software C ++ su larga scala di John Lakos . Al college di solito scriviamo piccoli progetti in cui non incontriamo tali problemi. Il libro evidenzia l'importanza di separare le interfacce e le implementazioni.

I file di intestazione di solito hanno interfacce che non dovrebbero essere cambiate così frequentemente. Allo stesso modo uno sguardo a modelli come il linguaggio del costruttore virtuale ti aiuterà a cogliere ulteriormente il concetto.

Sto ancora imparando come te :)


Grazie per il suggerimento sul libro. Non so se riuscirò mai arrivare alla fase di fare su larga scala C ++ programmi anche se ...
IAML

è divertente programmare programmi su larga scala e per molti una sfida. Sto iniziando a piacermi :)
pankajt

1

È come scrivere un libro, vuoi stampare i capitoli finiti solo una volta

Di 'che stai scrivendo un libro. Se si inseriscono i capitoli in file separati, è necessario stampare un capitolo solo se è stato modificato. Lavorare su un capitolo non cambia nessuno degli altri.

Ma includere i file cpp è, dal punto di vista del compilatore, come modificare tutti i capitoli del libro in un unico file. Quindi se lo cambi devi stampare tutte le pagine dell'intero libro per poter stampare il tuo capitolo rivisto. Non esiste alcuna opzione "stampa pagine selezionate" nella generazione del codice oggetto.

Torna al software: ho Linux e Ruby src in giro. Una misura approssimativa di righe di codice ...

     Linux       Ruby
   100,000    100,000   core functionality (just kernel/*, ruby top level dir)
10,000,000    200,000   everything 

Ognuna di queste quattro categorie ha un sacco di codice, quindi la necessità di modularità. Questo tipo di base di codice è sorprendentemente tipico dei sistemi del mondo reale.

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.