webentwicklung-frage-antwort-db.com.de

Warum nicht C-Quelldateien vor der Kompilierung verketten?

Ich komme aus dem Bereich der Skripterstellung und der Präprozessor in C schien mir immer hässlich. Trotzdem habe ich es angenommen, als ich lerne, kleine C-Programme zu schreiben. Ich benutze den Präprozessor nur wirklich, um die Standardbibliotheken und Header-Dateien einzubeziehen, die ich für meine eigenen Funktionen geschrieben habe.

Meine Frage ist, warum C-Programmierer nicht einfach alle Includes überspringen und ihre C-Quelldateien einfach verketten und dann kompilieren? Wenn Sie alle Ihre Includes an einer Stelle platzieren, müssen Sie nur einmal definieren, was Sie benötigen, und nicht in allen Quelldateien.

Hier ist ein Beispiel für das, was ich beschreibe. Hier habe ich drei Dateien:

// includes.c
#include <stdio.h>
// main.c
int main() {
    foo();
    printf("world\n");
    return 0;
}
// foo.c
void foo() {
    printf("Hello ");
}

Durch etwas wie cat *.c > to_compile.c && gcc -o myprogram to_compile.c In meinem Makefile kann ich die Menge an Code reduzieren, die ich schreibe.

Dies bedeutet, dass ich nicht für jede von mir erstellte Funktion eine Header-Datei schreiben muss (da sie sich bereits in der Hauptquelldatei befindet), und dass ich auch nicht die Standardbibliotheken in jede von mir erstellte Datei aufnehmen muss. Das scheint mir eine großartige Idee zu sein!

Mir ist jedoch klar, dass C eine sehr ausgereifte Programmiersprache ist, und ich stelle mir vor, dass jemand anderes, der viel schlauer ist als ich, diese Idee bereits hatte und sich entschied, sie nicht zu verwenden. Warum nicht?

74
user3420382

Manche Software ist so aufgebaut.

Ein typisches Beispiel ist SQLite . Es wird manchmal als Zusammenführung kompiliert (wird zur Erstellungszeit aus vielen Quelldateien erstellt).

Aber dieser Ansatz hat Vor- und Nachteile.

Offensichtlich wird sich die Kompilierzeit um einiges erhöhen. So ist es nur praktisch, wenn Sie das Zeug selten kompilieren.

Vielleicht kann der Compiler etwas mehr optimieren. Mit Optimierungen der Verknüpfungszeit (z. B. bei Verwendung eines kürzlich-GCC, Kompilieren und Verknüpfen mit gcc -flto -O2) Können Sie den gleichen Effekt erzielen (natürlich auf Kosten einer längeren Erstellungszeit).

Ich muss nicht für jede Funktion eine Header-Datei schreiben

Das ist ein falscher Ansatz (eine Header-Datei pro Funktion). Für ein Einzelprojekt (mit weniger als hunderttausend Codezeilen, auch bekannt als KLOC = kilo line of code ) ist es - zumindest für kleine Projekte - durchaus sinnvoll, ein singlezu haben. _ gemeinsame Header-Datei (die Sie vorkompilieren können wenn Sie [~ # ~] gcc [~ # ~] verwenden), die Deklarationen aller öffentlichen Funktionen und Typen enthält, und möglicherweise Definitionen von static inline - Funktionen (die klein genug sind und häufig genug aufgerufen werden, um von inlining zu profitieren). Zum Beispiel ist die sash -Shell so organisiert (und ebenso der lout -Formatierer mit 52 KLOC).

Möglicherweise haben Sie auch einige Header-Dateien und möglicherweise einen einzelnen "Gruppierungs" -Header, der alle #include - enthält (und den Sie vorkompilieren können). Siehe zum Beispiel jansson (das tatsächlich eine einzelne public-Headerdatei hat) und [~ # ~] gtk [~ # ~] (das lots hat von internen Headern, aber die meisten Anwendungen verwenden haben nur einen #include <gtk/gtk.h>, der wiederum alle internen Header enthält). Auf der anderen Seite hat [~ # ~] posix [~ # ~] viele Header-Dateien und dokumentiert, welche in welcher Reihenfolge enthalten sein sollten.

Einige Leute bevorzugen es, viele Header-Dateien zu haben (und andere bevorzugen es sogar, eine einzelne Funktionsdeklaration in einem eigenen Header abzulegen). Ich nicht (für persönliche Projekte oder kleine Projekte, bei denen nur zwei oder drei Personen Code festlegen), aberes geht um Geschmack. Übrigens, wenn ein Projekt stark wächst, kommt es häufig vor, dass sich die Menge der Header-Dateien (und der Übersetzungseinheiten) erheblich ändert. Schauen Sie auch in [~ # ~] redis [~ # ~] (es hat 139 .h - Headerdateien und 214 .c - Dateien, d. H. Übersetzungseinheiten mit insgesamt 126 KLOC).

Eine oder mehrere Übersetzungseinheiten zu haben, ist auch eine Frage des Geschmacks (und der Zweckmäßigkeit sowie der Gewohnheiten und Konventionen). Ich bevorzuge Quelldateien (dh Übersetzungseinheiten), die nicht zu klein sind, normalerweise mehrere tausend Zeilen, und häufig (für ein kleines Projekt mit weniger als 60 KLOC) eine gemeinsame einzelne Header-Datei haben. Vergiss nicht, ein Build Automation Tool wie GNU make (oft mit parallel build through make -j) Zu verwenden; dann du Es werden mehrere Kompilierungsprozesse gleichzeitig ausgeführt.) Der Vorteil einer solchen Organisation von Quelldateien besteht darin, dass die Kompilierung relativ schnell vonstatten geht. Übrigens lohnt sich in einigen Fällen ein Metaprogrammierungs - Ansatz (interner Header oder Übersetzungseinheiten) C "Quell" -Dateien könnten von etwas anderem generiert sein (z. B. einem Skript in [~ # ~] awk [~ # ~] , einem speziellen C-Programm wie Bison oder dein eigenes Ding).

Denken Sie daran, dass C in den 1970er Jahren für Computer entwickelt wurde, die viel kleiner und langsamer sind als Ihr heutiger Lieblingslaptop (normalerweise war der Speicher zu dieser Zeit höchstens ein Megabyte oder sogar ein paar hundert Kilobyte, und der Computer war mindestens tausendmal langsamer) als Ihr Mobiltelefon heute).

Ich empfehle dringend,den Quellcode zu studieren und einige vorhandenefreie Software Projektezu erstellen (z. B. die auf GitHub oder SourceForge oder Ihre Lieblings-Linux-Distribution). Sie werden lernen, dass es sich um unterschiedliche Ansätze handelt. Denken Sie daran, dassin C Konventionen und Gewohnheiten in der Praxis sehr wichtig ist, alsogibt es verschiedene Möglichkeiten, organisieren Sie Ihr Projekt in den Dateien .c und .h. Lesen Sie mehr über den C-Präprozessor .

Das bedeutet auch, dass ich die Standardbibliotheken nicht in jede von mir erstellte Datei aufnehmen muss

Sie enthalten Header-Dateien, keine Bibliotheken (aber Sie sollten link Bibliotheken). Sie könnten sie jedoch in jede .c - Datei aufnehmen (und viele Projekte tun das), oder Sie könnten sie in einen einzelnen Header aufnehmen und diesen Header vorkompilieren, oder Sie könnten ein Dutzend von Headern haben und sie aufnehmen Nach Systemüberschriften in jeder Kompilierungseinheit. YMMV. Beachten Sie, dass die Vorverarbeitungszeit auf heutigen Computern sehr kurz ist (zumindest, wenn Sie den Compiler zur Optimierung auffordern, da Optimierungen mehr Zeit in Anspruch nehmen als das Parsen und Vorverarbeiten).

Beachten Sie, dass das, was in eine #include - d-Datei gehört, konventionell ist (und nicht durch die C-Spezifikation definiert ist). Einige Programme haben einen Teil ihres Codes in einer solchen Datei (die dann nicht als "Header", sondern nur als "enthaltene Datei" bezeichnet werden sollte und die dann nicht das Suffix .h, Sondern etwas anderes wie .inc). Schauen Sie sich zum Beispiel [~ # ~] xpm [~ # ~] files an. Im anderen Extremfall verfügen Sie möglicherweise im Prinzip nicht über eigene Header-Dateien (Sie benötigen weiterhin Header-Dateien aus der Implementierung, z. B. <stdio.h> Oder <dlfcn.h> Aus Ihrem POSIX-System) und können diese kopieren und einfügen doppelter Code in Ihren .c - Dateien -eg habe die Zeile int foo(void); in jeder .c - Datei, aber das ist eine sehr schlechte Übung und wird verpönt. Bei einigen Programmen handelt es sich jedoch um generierende C-Dateien, die einen gemeinsamen Inhalt haben.

BTW, C oder C++ 14 haben keine Module (wie OCaml). Mit anderen Worten, in C ist ein Modul meist ein Konvention.

(Beachten Sie, dass viele Tausend sehr klein.h - und .c - Dateien mit jeweils nur ein paar Dutzend Zeilen Ihre Erstellungszeit drastisch verlangsamen können Einige hundert Zeilen pro Zeile sind in Bezug auf die Erstellungszeit sinnvoller.)

Wenn Sie mit der Arbeit an einem Einzelprojekt in C beginnen, würde ich vorschlagen, zunächst eine Header-Datei und mehrere Übersetzungseinheiten .c Zu haben (und diese vorzukompilieren). In der Praxis werden Sie .c - Dateien viel häufiger ändern als .h - Dateien. Sobald Sie mehr als 10 KLOC haben, können Sie dies in mehrere Header-Dateien umgestalten. Solch ein Refactoring ist schwierig zu entwerfen, aber einfach durchzuführen (nur viel Kopieren und Einfügen von Codestücken). Andere Leute hätten andere Vorschläge und Hinweise (und das ist in Ordnung!). Vergessen Sie jedoch nicht, beim Kompilieren alle Warnungen und Debug-Informationen zu aktivieren (kompilieren Sie also mit gcc -Wall -g, Und setzen Sie möglicherweise CFLAGS= -Wall -g In Ihrem Makefile). Verwenden Sie den Debugger gdb (und valgrind ...). Fragen Sie nach Optimierungen (-O2), Wenn Sie ein bereits getestetes Programm bewerten. Verwenden Sie auch ein Versionskontrollsystem wie Git .

Im Gegenteil, wenn Sie ein größeres Projekt entwerfen, für das mehrere Personen arbeiten würde, ist es möglicherweise besser, mehrere Dateien zu haben - selbst mehrere Header-Dateien - (intuitiv hat jede Datei eine einzige Person, die hauptsächlich dafür verantwortlich ist mit anderen, die geringfügige Beiträge zu dieser Akte leisten).

In einem Kommentar fügen Sie Folgendes hinzu:

Ich spreche davon, meinen Code in viele verschiedene Dateien zu schreiben, aber ein Makefile zu verwenden, um sie zu verketten

Ich verstehe nicht, warum das nützlich wäre (außer in sehr seltsamen Fällen). Es ist viel besser (und üblich und üblich), jede Übersetzungseinheit (z. B. jede .c - Datei) in ihre Objektdatei (eine .o[~ # ~) zu kompilieren ] elf [~ # ~] file unter Linux) und link them later. Dies ist mit make einfach (in der Praxis, wenn Sie nur eine .c - Datei ändern, um z. B. einen Fehler zu beheben, wird nur diese Datei kompiliert und der inkrementelle Build ist sehr schnell) und Sie kann es auffordern, Objektdateien in parallel mit make -j zu kompilieren (und dann geht Ihr Build auf Ihrem Multi-Core-Prozessor sehr schnell).

103

Sie könnten das tun , aber wir trennen C-Programme gerne in separate Übersetzungseinheiten , hauptsächlich weil:

  1. Es beschleunigt Builds. Sie müssen nur die geänderten Dateien neu erstellen und diese können mit anderen kompilierten Dateien verknüpft werden , um das endgültige Programm zu bilden.

  2. Die C-Standardbibliothek besteht aus vorkompilierten Komponenten. Möchten Sie das alles wirklich neu kompilieren müssen?

  3. Es ist einfacher, mit anderen Programmierern zusammenzuarbeiten, wenn die Codebasis in verschiedene Dateien aufgeteilt ist.

26
Bathsheba

Ihr Ansatz, .c-Dateien zu verketten, ist völlig fehlerhaft:

  • Obwohl der Befehl cat *.c > to_compile.c fügt alle Funktionen in eine einzige Datei ein, Reihenfolge ist wichtig: Jede Funktion muss vor der ersten Verwendung deklariert werden.

    Das heißt, Sie haben Abhängigkeiten zwischen Ihren .c-Dateien, die eine bestimmte Reihenfolge erzwingen. Wenn Ihr Verkettungsbefehl diese Reihenfolge nicht einhält, können Sie das Ergebnis nicht kompilieren.

    Wenn Sie über zwei Funktionen verfügen, die sich gegenseitig rekursiv verwenden, führt kein Weg daran vorbei, eine Forward-Deklaration für mindestens eine der beiden Funktionen zu erstellen. Sie können diese Voraberklärungen auch in eine Header-Datei einfügen, in der die Benutzer sie voraussichtlich finden werden.

  • Wenn Sie alles in eine einzelne Datei verketten, erzwingen Sie eine vollständige Neuerstellung, wenn sich eine einzelne Zeile in Ihrem Projekt ändert.

    Beim klassischen Split-Kompilierungsansatz .c/.h erfordert eine Änderung in der Implementierung einer Funktion eine Neukompilierung genau einer Datei, während eine Änderung in einem Header eine Neukompilierung der Dateien erfordert, die tatsächlich diesen Header enthalten. Dies kann die Neuerstellung nach einer kleinen Änderung leicht um den Faktor 100 oder mehr beschleunigen (abhängig von der Anzahl der .c-Dateien).

  • Sie verlieren alle Möglichkeiten zum parallelen Kompilieren wenn Sie alles zu einer einzigen Datei verketten.

    Haben Sie einen fetten 12-Kern-Prozessor mit aktiviertem Hyper-Threading? Schade, Ihre verkettete Quelldatei wird von einem einzigen Thread kompiliert. Sie haben gerade eine Beschleunigung um einen Faktor von mehr als 20 verloren ... Ok, dies ist ein extremes Beispiel, aber ich habe eine Software mit make -j16 schon, und ich sage dir, es kann einen großen Unterschied machen.

  • Übersetzungszeiten sind in der Regel nicht linear.

    Normalerweise enthalten Compiler mindestens einige Algorithmen, die ein quadratisches Laufzeitverhalten aufweisen. Folglich gibt es normalerweise einen Schwellenwert, ab dem die aggregierte Kompilierung tatsächlich langsamer ist als die Kompilierung der unabhängigen Teile.

    Die genaue Position dieses Schwellenwerts hängt natürlich vom Compiler und den Optimierungsflags ab, die Sie ihm übergeben, aber ich habe gesehen, dass ein Compiler mehr als eine halbe Stunde für eine einzelne große Quelldatei benötigt. Sie möchten kein solches Hindernis in Ihrer Change-Compile-Test-Schleife haben.

Machen Sie keinen Fehler: Auch wenn all diese Probleme auftreten, gibt es Leute, die in der Praxis die Verkettung von .c-Dateien verwenden, und einige C++ - Programmierer kommen fast auf den gleichen Punkt, indem sie alles in Vorlagen verschieben (sodass die Implementierung in der gefunden wird .hpp-Datei und es gibt keine zugehörige .cpp-Datei), so dass der Präprozessor die Verkettung vornimmt. Ich verstehe nicht, wie sie diese Probleme ignorieren können, aber sie tun es.

Beachten Sie auch, dass viele dieser Probleme nur bei größeren Projekten auftreten. Wenn Ihr Projekt weniger als 5000 Codezeilen enthält, spielt es immer noch keine Rolle, wie Sie es kompilieren. Wenn Sie jedoch mehr als 50000 Codezeilen haben, möchten Sie auf jeden Fall ein Build-System, das inkrementelle und parallele Builds unterstützt. Andernfalls verschwenden Sie Ihre Arbeitszeit.

16
cmaster
  • Dank der Modularität können Sie Ihre Bibliothek freigeben, ohne den Code freizugeben.
  • Wenn Sie bei großen Projekten eine einzelne Datei ändern, wird das gesamte Projekt kompiliert.
  • Wenn Sie versuchen, große Projekte zu kompilieren, verfügen Sie möglicherweise nicht über genügend Arbeitsspeicher.
  • Sie können zirkuläre Abhängigkeiten in Modulen haben. Die Modularität hilft dabei, diese zu pflegen.

Möglicherweise hat Ihr Ansatz einige Vorteile, aber für Sprachen wie C ist es sinnvoller, jedes Modul zu kompilieren.

16
Mohit Jain

Denn Aufteilen ist gutes Programmdesign. Bei einem guten Programmdesign dreht sich alles um Modularität, autonome Codemodule und Wiederverwendbarkeit von Code. Wie sich herausstellt, bringt Sie der gesunde Menschenverstand bei der Programmentwicklung sehr weit: Dinge, die nicht zusammengehören, sollten nicht zusammengesetzt werden.

Durch das Platzieren von nicht verwandtem Code in verschiedenen Übersetzungseinheiten können Sie den Bereich von Variablen und Funktionen so weit wie möglich lokalisieren.

Das Zusammenführen von Dingen schafft enge Kopplung, was ungünstige Abhängigkeiten zwischen Codedateien bedeutet, die eigentlich nicht einmal voneinander wissen müssen. Aus diesem Grund ist eine "global.h", die alle Includes in einem Projekt enthält, eine schlechte Sache, da sie eine enge Kopplung zwischen allen nicht verwandten Dateien in Ihrem gesamten Projekt herstellt.

Angenommen, Sie schreiben Firmware, um ein Auto zu steuern. Ein Modul im Programm steuert das UKW-Autoradio. Anschließend verwenden Sie den Radiocode in einem anderen Projekt erneut, um das UKW-Radio in einem Smartphone zu steuern. Und dann wird Ihr Radiocode nicht kompiliert, weil er keine Bremsen, Räder, Getriebe usw. findet. Dinge, die für das UKW-Radio nicht den geringsten Sinn ergeben, geschweige denn das Smartphone, über das Sie Bescheid wissen müssen.

Was noch schlimmer ist, ist, dass bei einer engen Kopplung Fehler während des gesamten Programms eskalieren, anstatt lokal bei dem Modul zu bleiben, in dem sich der Fehler befindet. Dies macht die Fehlerfolgen weitaus schwerwiegender. Sie schreiben einen Fehler in Ihren FM-Radio-Code und plötzlich funktionieren die Bremsen des Autos nicht mehr. Obwohl Sie den Bremscode mit Ihrem Update, das den Fehler enthielt, nicht berührt haben.

Wenn ein Fehler in einem Modul völlig verwandte Dinge zerstört, liegt dies mit ziemlicher Sicherheit an einem schlechten Programmdesign. Ein gewisser Weg, um ein schlechtes Programmdesign zu erzielen, besteht darin, alles in Ihrem Projekt zu einem großen Blob zusammenzuführen.

15
Lundin

Header-Dateien sollten Schnittstellen definieren - das ist eine wünschenswerte Konvention. Sie sollen nicht alles deklarieren, was in einem entsprechenden .c Datei oder eine Gruppe von .c Dateien. Stattdessen deklarieren sie alle Funktionen im .c Datei (en), die ihren Benutzern zur Verfügung stehen. Ein gut gestalteter .h-Datei enthält ein Basisdokument der Schnittstelle, die durch den Code in .c-Datei, auch wenn kein einziger Kommentar enthalten ist. Eine Möglichkeit, sich dem Design eines C-Moduls zu nähern, besteht darin, zuerst die Header-Datei zu schreiben und sie dann in einem oder mehreren .c Dateien.

Folgerung: Funktionen und Datenstrukturen innerhalb der Implementierung eines .c Datei gehören normalerweise nicht in die Header-Datei. Möglicherweise benötigen Sie Forward-Deklarationen, aber diese sollten lokal sein und alle so deklarierten und definierten Variablen und Funktionen sollten static lauten: Wenn sie nicht Teil der Schnittstelle sind, sollte der Linker sie nicht sehen.

11
Kuba Ober

Der Hauptgrund ist die Kompilierungszeit. Das Kompilieren einer kleinen Datei, wenn Sie sie ändern, kann einige Zeit in Anspruch nehmen. Wenn Sie jedoch das gesamte Projekt kompilieren würden, wenn Sie eine einzelne Zeile ändern, würden Sie beispielsweise jedes Mal 10.000 Dateien kompilieren, was viel länger dauern könnte.

Wenn Sie - wie im obigen Beispiel - 10.000 Quelldateien haben und das Kompilieren einer davon 10 ms dauert, wird das gesamte Projekt schrittweise (nach dem Ändern einer einzelnen Datei) entweder in (10 ms + Verknüpfungszeit) erstellt, wenn Sie nur diese geänderte Datei kompilieren, oder (10 ms * 10000 + kurze Verknüpfungszeit), wenn Sie alles als einen einzigen verketteten Blob kompilieren.

8
Freddie Chopin

Obwohl Sie Ihr Programm immer noch modular schreiben und als einzelne Übersetzungseinheit erstellen können, werden Sie alles vermissen die Mechanismen, die C bietet, um diese Modularität durchzusetzen. Mit mehreren Übersetzungseinheiten können Sie die Schnittstellen Ihrer Module genau steuern, indem Sie z. extern und static Keywords.

Wenn Sie Ihren Code zu einer einzigen Übersetzungseinheit zusammenführen, werden Sie eventuell auftretende Modularitätsprobleme übersehen, da der Compiler Sie nicht davor warnt. In einem großen Projekt wird dies schließlich dazu führen, dass sich unbeabsichtigte Abhängigkeiten ausbreiten. Am Ende werden Sie Probleme haben, ein Modul zu wechseln, ohne in anderen Modulen globale Nebenwirkungen zu verursachen.

7

Wenn Sie alle Ihre Includes an einer Stelle platzieren, müssen Sie nur einmal definieren, was Sie benötigen, und nicht in allen Quelldateien.

Dies ist der Zweck von .h - Dateien, sodass Sie definieren können, was Sie einmal benötigen, und es überall einfügen können. Einige Projekte haben sogar einen everything.h - Header, der jede einzelne .h - Datei enthält. So kann Ihr pro auch mit separaten .c - Dateien erreicht werden.

Das bedeutet, dass ich nicht für jede Funktion, die ich erstelle, eine Header-Datei schreiben muss [...]

Sie sollten sowieso nicht eine Header-Datei für jede Funktion schreiben. Sie sollten eine Header-Datei für eine Reihe verwandter Funktionen haben. Ihr con ist also auch nicht gültig.

4
DepressedDaniel

Dies bedeutet, dass ich nicht für jede von mir erstellte Funktion eine Header-Datei schreiben muss (da sie sich bereits in der Hauptquelldatei befindet), und dass ich auch nicht die Standardbibliotheken in jede von mir erstellte Datei aufnehmen muss. Das scheint mir eine großartige Idee zu sein!

Die Profis, die Sie bemerkt haben, sind tatsächlich ein Grund, warum dies manchmal in kleinerem Maßstab geschieht.

Bei großen Programmen ist dies unpraktisch. Wie bei anderen guten Antworten kann dies die Build-Zeiten erheblich verlängern.

Es kann jedoch verwendet werden, um eine Übersetzungseinheit in kleinere Bits aufzuteilen, die den Zugriff auf Funktionen in einer Weise teilen, die an die Paketzugriffsmöglichkeiten von Java erinnert.

Die Art und Weise, wie das oben Genannte erreicht wird, erfordert etwas Disziplin und Hilfe des Präprozessors.

Beispielsweise können Sie Ihre Übersetzungseinheit in zwei Dateien aufteilen:

// a.c

static void utility() {
}

static void a_func() {
  utility();
}

// b.c

static void b_func() {
  utility();
}

Nun fügen Sie eine Datei für Ihre Übersetzungseinheit hinzu:

// ab.c

static void utility();

#include "a.c"
#include "b.c"

Und dein Build-System baut auch nicht a.c oder b.c, sondern baut nur ab.o aus ab.c.

Was macht ab.c vollbringen?

Es enthält beide Dateien, um eine einzelne Übersetzungseinheit zu generieren, und stellt einen Prototyp für das Dienstprogramm bereit. Damit der Code in beiden a.c und b.c konnte es sehen, unabhängig von der Reihenfolge, in der sie enthalten sind, und ohne dass die Funktion extern sein muss.

2
StoryTeller