Offensichtliche Dinge, die die C-Sprache tun sollte
(digitalmars.com)- Selbst bis C23 hat sich Standard-C weiterentwickelt, aber Walter Bright meint, dass alte Unbequemlichkeiten wie Auswertung konstanter Ausdrücke, Vorwärtsreferenzen und Header-Abhängigkeiten den Entwicklungsfluss weiterhin behindern
- ImportC, der in den D-Compiler eingebaute C-Compiler, nutzt den Vorteil einer neuen Implementierung und kann Funktionen, die die Bedingungen erfüllen, an
constant-expression-Positionen zur Compile-Zeit ausführen - Wenn CTFE möglich wird, können Unit-Tests wie
_Static_assert(sum(3, 4) == 7,...)bei jedem Kompilieren ohne separate ausführbare Datei ausgeführt werden - Die Beschränkung der Deklarationsreihenfolge in Standard-C führt zu wiederholten Vorwärtsdeklarationen und umgekehrt angeordnetem Code, aber ImportC wählt einen Ansatz, der weniger an die Reihenfolge globaler Deklarationen gebunden ist
- Wenn man Deklarationen aus einer
.c-Datei wie mit__import dex;direkt importiert, kann man separate.h-Dateien reduzieren und auch den Debugging-Aufwand durch Abweichungen zwischen Header und Implementierung senken
Verbliebene Unbequemlichkeiten in Standard-C
- Standard-C wurde bis C23 regelmäßig verbessert, aber Walter Bright sieht weiterhin Bereiche, die noch nicht behoben wurden
- Die Dlang-Community hat einen C-Compiler in den Compiler der Programmiersprache D eingebettet, und dieser Compiler wird ImportC genannt
- ImportC wurde von Grund auf neu entwickelt, sodass es Spielraum gab, mit moderner Compilertechnik Schwächen von C anzugehen
- Im Artikel werden vier Punkte behandelt
- Auswertung von
constant-expression - Unit-Tests zur Compile-Zeit
- Vorwärtsreferenzen von Deklarationen
- Import von Deklarationen
- Auswertung von
Funktionsausführung zur Compile-Zeit und Auswertung konstanter Ausdrücke
- C kann einfache Ausdrücke per constant folding zur Compile-Zeit berechnen, aber in Standard-C können Funktionen nicht zur Compile-Zeit ausgeführt werden
- Wenn man den Beispielcode
enum E { A = 3, B = 4, C = sum(5, 6) };mit gcc kompiliert, erscheint ein Fehler, dass der Enumeratorwert vonCkeine ganzzahlige Konstante ist - ImportC kann denselben Code kompilieren
- Die Richtung ist, dass der Compiler an allen Stellen der C-Syntax, an denen
constant-expressionvorkommt, Funktionen zur Compile-Zeit ausführen können sollte - Die betreffende Funktion darf allerdings keine Operationen wie I/O, Zugriff auf veränderliche globale Variablen oder Systemaufrufe ausführen
Unit-Tests, die bei jedem Kompilieren laufen
- Wenn CTFE als Compile-Zeit-Auswertung von Funktionen möglich wird, kann sich auch die Art von Unit-Tests ändern
- Dass Unit-Tests in C-Code selten sind, liegt an der Umständlichkeit, separate Build-Ziele und ausführbare Dateien zu erstellen und auszuführen
- Wenn man den Beispielcode
_Static_assert(sum(3, 4) == 7, "test #1");mit gcc kompiliert, tritt ein Fehler auf, dass der Ausdruck in der statischen Assertion keine Konstante ist - ImportC kann diesen Code kompilieren
- Dieser Ansatz vereinfacht Unit-Tests für Funktionen, die zur Compile-Zeit ausgeführt werden können
- Kein separater Build erforderlich
- Keine zusätzlichen Arbeitsschritte erforderlich
- Die Tests laufen jedes Mal, wenn der Code kompiliert wird
- Auch in der ImportC-Testsuite wird dieser Ansatz breit eingesetzt
Durch Vorwärtsreferenzen entstehende Wiederholung von Deklarationen
- In Standard-C sind Vorwärtsreferenzen nicht erlaubt, weil der Compiler nur Deklarationen kennt, die lexikalisch vorher erscheinen
- Wenn im Beispielcode
floo()zuerst das weiter hinten definiertedex()aufruft, meldet gcc einen Fehler, dass die implizite Deklaration vondexmit dem Typ der tatsächlichen Definition kollidiert - Vertauscht man die Reihenfolge von
floounddex, wird derselbe Code korrekt kompiliert - ImportC kann globale Deklarationen unabhängig von ihrer Reihenfolge akzeptieren
- Wenn Vorwärtsreferenzen nicht möglich sind, muss für jede spätere Definition eine separate Deklaration hinzugefügt werden
- Zuerst schreibt man eine Deklaration wie
char dex(char *s, int i); - Danach muss die Funktionsdefinition erneut geschrieben werden
- Zuerst schreibt man eine Deklaration wie
- Diese Vorgehensweise ist unnötige Wiederholungsarbeit und führt dazu, dass Programmierer leaf-Funktionen oben und globale Interface-Funktionen unten platzieren, also in umgekehrter Reihenfolge
- Das wird kritisiert, weil es so sei, als würde man einen Zeitungsartikel von unten nach oben lesen
Deklarationen ohne Header-Dateien importieren
- Um in C externe Module zu verwenden, erstellt man normalerweise Deklarationen in einer
.h-Datei und bindet sie in der.c-Datei per include ein - Die Beispielstruktur ist wie folgt
floo.cinkludiertdex.hund ruftdex()aufdex.hdeklariertchar dex(char *s, int i);dex.cinkludiertdex.hund definiertdex()
- Für jedes externe Modul eine
.h-Datei zu erstellen, erhöht den Wiederholungsaufwand - Wenn die
.h-Datei nicht exakt mit der.c-Datei übereinstimmt, kann es viel Zeit kosten herauszufinden, was falsch ist - Der vorgeschlagene Ansatz ist,
dex.cdirekt zu importieren- In
floo.cwird__import dex;verwendet - In
dex.csteht nur eine Definition wiechar dexx(char *s, int i) { return s[i]; }
- In
- Auf diese Weise muss überhaupt keine
.h-Datei mehr geschrieben werden - ImportC unterstützt auch diesen Ansatz
Verwandte Dokumente
- ImportC: ImportC-Dokumentation
- D Language: Dokumentation zur D-Sprache
1 Kommentare
Meinungen auf Hacker News
Eines der Dinge, die ich am meisten vermisse, wenn ich eine andere Sprache als C verwende, sind Header-Dateien.
Mir gefällt an C-Code besonders, wie klar öffentlich/privat und Interface/Implementierung getrennt sind. Es ist angenehm, dass man schon durch Überfliegen der
.h-Datei einer Library erkennen kann, wie sie zu benutzen ist; meist steht die Nutzungsdokumentation in der.hund wird nicht mit der.cdupliziert. Man kann die Dokumentation auch in die.cschreiben, aber aus Sicht der Nutzer ist es dann deutlich weniger angenehm, das Interface zu lesen.In Rust reicht zum Beispiel
cargo doc --open, um Interface und Verwendung einer Library anzusehen. Die gesamte öffentliche API wird automatisch in durchsuchbarer Form erzeugt, ohne Code zwischen Header und Source zu duplizieren oder manuell pflegen zu müssen.Sie funktionieren nur dann richtig, wenn man sich an Konventionen hält, und wirken im Vergleich zu Sprachen wie Ada, in denen Interface- und Implementierungsspezifikationen gut entworfen sind und kein wiederholtes Parsen nötig ist, ziemlich kümmerlich. Ich mag es, C zu verwenden, aber dieser Teil hätte besser entworfen werden müssen.
C-Header-Dateien fügen Text schlicht unverändert ein und sind damit eher auf dem Niveau von „scheint irgendwie zu funktionieren“. Ada dagegen hatte Konzepte von Packages und Package Bodys; das Package entsprach der Header-Datei, der Package Body der Implementierung. Als ich vor langer Zeit Ada benutzt habe, konnten alle auf Basis des Packages kompilieren, selbst wenn die Implementierung des Package Bodys noch nicht fertig war, sodass man das Interface schon vor der Implementierung abstimmen konnte. In eine andere Richtung gefällt mir Pythons
importals eine Art „Header-Datei“, weil es sich natürlich auf das Dateisystem abbildet und man sich nicht mit der Include-Semantik von C befassen muss.Wenn es Header-Dateien gibt, denkt man über das Interface als etwas anderes als die Implementierung nach. Deshalb empfinden Menschen, die an diesen Ansatz gewöhnt sind, die Vorstellung als unangenehm, dass ein Interface von einem Tool generiert wird. Ein Interface ist kein Nebenprodukt der Implementierung, sondern etwas separat und bewusst Entworfenes, und für manche ist es wichtiger als die Implementierung. Umgekehrt empfinden Menschen, die an automatisch generierte Dokumentation gewöhnt sind, es als unangenehm, wenn das Interface nicht aus der Implementierungsquelle als einziger Source of Truth erzeugt wird. Wenn man lange mit Sprachen mit und ohne separate Interface-Dateien arbeitet, verhärtet man sich in seinem jeweiligen Lager und vergisst das Gefühl dafür, wie man in der anderen Richtung gedacht hat.
Entweder separat wie in Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F# und D, oder generierbar per Text- oder Grafik-Tool wie in Object Pascal, D, Haskell, Java, C#, F#, Swift, Go und Rust. Sie alle haben stärkere Typen, schnellere Kompilierung und richtige Namespaces. An den Toolchains von Rust und Swift gibt es noch einiges zu tun, aber C-Tools waren im Vergleich zu dem, was außerhalb von Bell Labs geschah, immer primitiv. Hätte AT&T das kommerziell nutzen können, wäre die Geschichte wohl anders verlaufen; so bekamen wir am Ende statt reifer Orangen kostenlose Zitronen. Trotzdem haben wir so etwas wie TypeScript für C gebaut, und heute unterstützt es auch Collection-Typen mit Bounds-Checking sowie richtige Module.
Da der Autor in diesem Bereich viel mehr Wissen und Erfahrung hat, würde mich interessieren, wie er die folgenden Probleme lösen würde.
Konstantenausdruck-Auswertung wird einfacher, wenn man sie nur innerhalb einer Translation Unit vornimmt, aber dadurch wird stark eingeschränkt, was ohne Code-Wiederholung möglich ist. Compile-Time-Unit-Tests sind bis zu einem gewissen Grad möglich, wenn Tests als Makros ausgedrückt werden können, und mit dem vorherigen Punkt wird es einfacher. Vorwärtsreferenzen von Deklarationen könnten auf starken Widerstand stoßen, weil der Compiler von einem Pass auf zwei Pässe umgestellt würde und das Performance-Auswirkungen hätte. Für Leute, die riesige Codebases kompilieren und dabei sogar Translation Units parallelisieren, könnte das schwer akzeptabel sein. Deklarationen importieren ist eine Änderung, die die Kompatibilität bricht. Ich habe in C schon einmal nach einer Variablendefinition eine
.c-Datei importiert, die Variable geändert und dieselbe.cerneut importiert, um etwas Template-Ähnliches zu implementieren. Ich habe auch vor die SQLite C Amalgamation mehrere Definitionen gesetzt und Funktionen hinzugefügt, um interne Funktionen offenzulegen; solche Anwendungsfälle würden wohl alle kaputtgehen. Mich würde interessieren, ob es für solche Probleme Lösungen gibt.In C geht das mit
#include, und in D importiert man das Modul mit dem benötigten Code. Tests als Makros auszudrücken, passt nicht, wenn man Funktionen testen will. Das Beispiel war zur Veranschaulichung einfach gehalten; in der tatsächlichen Nutzung kann es deutlich komplexer werden. In puncto Performance kompiliert D schneller als C-Compiler, vor allem weil der C-Präprozessor ein fossiles Konstrukt ist, das mehrere Durchläufe erfordert, während D statt#includeimport verwendet und.h-Dateien nicht immer wieder neu kompiliert. Ds Strategie besteht darin, Parsing und semantische Analyse zu trennen; das kann zwar etwas langsamer sein, aber es fallen keine Kosten dafür an, doppelte Deklarationen erneut zu kompilieren und zusammenzufalten. Compile-Time Function Execution kann bei intensiver Nutzung zum Bottleneck werden, aber bei leichter Nutzung ist die Performance in Ordnung. Wenn man Templates in C per Hack implementiert, ist man bereits über die Sprache hinausgegangen und braucht eine mächtigere Sprache. D besitzt Metaprogramming erster Klasse, und andere Template-Sprachen folgen häufig dem Weg, den D vorgezeichnet hat.Man kann dann nicht mehr darauf vertrauen, dass der Abhängigkeitsgraph in topologisch sortierter Reihenfolge vorliegt.
Mich würde auch interessieren, ob moderne C-Compiler tatsächlich immer noch Single-Pass-Compiler sind.
Das Beispiel zur Auswertung konstanter Ausdrücke im Artikel ist zwar ziemlich einfach, aber in komplexeren Fällen dürften Compiler-Geschwindigkeit und Speicherverbrauch deutlich schlechter werden, und um das sinnvoll zu nutzen, bräuchte man wohl eine virtuelle Maschine.
Daher kann ich nachvollziehen, dass man es für „zu komplex“ hielt, um es in den Standard aufzunehmen. Ich hätte mir gewünscht, dass C++ oder C statt der in C++20 definierten seltsamen Mischung eher in Richtung Deklarations-Import gegangen wären. Zum Beispiel so, dass man mit
#import "string.c" as strein Modul unter einem Symbol importiert und dann auf alle nicht-statischen Symbole der Datei wiestr.trim(" Hello World ");zugreifen kann. Unabhängig davon gefällt mir etwas wie__import dex;, bei dem der Dateipfad nicht explizit ist, nicht. In diesem Fall weiß man nicht, obdex.doderdex.cimportiert wirdWenn man sie häufig nutzt, kostet das natürlich Compile-Zeit und Speicher. Was die virtuelle Maschine angeht: Constant Folding selbst ist bereits eine Art virtuelle Maschine, und hier würde man sie nur um die Behandlung von Funktionsaufrufen erweitern. Die Semantik von C ist einfach, daher ist das nicht so schlimm. Der vorgeschlagene Import-Ansatz entspricht ziemlich genau dem, was D-Imports tun: https://dlang.org/spec/module.html#import-declaration. Das Problem, ob es
dex.doderdex.cist, tritt tatsächlich auf; die Antwort darauf ist die Konfiguration der Import-Pfade. Ähnlich wie die Include-Pfade eines C-CompilersIn C gibt es bereits konstante Ausdrücke. Das größere Hindernis ist, dass der Compiler Zugriff auf den Quellcode der Funktion haben muss, weshalb es wahrscheinlich auf Funktionen in derselben Übersetzungseinheit beschränkt würde. Außerdem könnte das menschliche Problem noch viel größer sein: Ein Komitee mit Vertretern mehrerer Compiler müsste sich auf die Semantik einer solchen Konstantenauswertung einigen
constexpr.Da fast alle C-Compiler ohnehin auch C++-Compiler sind, frage ich mich, ob es wirklich so schlimm wäre, in C
constexpr-Funktionen und -Auswertung zu unterstützenIch schreibe für C-Code immer Unit-Tests.
Mit einem guten Build-System und etwas Boilerplate ist das nicht schwer. Die Tests der
npy-Bibliothek rufen intest_load_uint8()npy_load("tests/npy/uint8.npy")auf, prüfen Dimensionen, Größe und Typ perassert, geben anschließend mitnpy_freefrei und werden inmainetwa mitPRINT_RUN(test_load_uint8);ausgeführt. Man könnte Teile der Tests auch per Präprozessor erzeugen, aber ich halte es lieber einfachBeispiele für Unit-Tests des ImportC-Compilers sehen etwa so aus:
_Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");und_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Da die Semantik zur Compile-Zeit geprüft wird, muss man nicht linken und ausführen. Je mehr Tests es gibt, desto schneller wird dieser Ansatz insgesamt, und je schneller die Testsuite läuft, desto höher ist die ProduktivitätFrüher habe ich mich einmal tief in schicke Unit-Test-Frameworks eingearbeitet, aber nachdem ich erkannt hatte, dass sie kaum Vorteile bringen, bin ich bei fast derselben Form gelandet. Was andere vielleicht kritisch sehen: Ich bin bereit, die zu testende
.c-Datei per#includeeinzubinden, um statische Funktionen aufzurufen. Es wird genau eine.c-Datei eingebunden. Außerdem mache ich vor dem#includenoch eine kleine Präprozessor-Behandlung, damitNDEBUGnicht definiert ist, falls jemand im „Release-Modus“ baut und dadurchassertdeaktiviert würdeIch verwende etwas Ähnliches: https://github.com/ensisoft/detonator/blob/master/base/test_.... Es hat viel von
boost.test.minimalübernommen und war ursprünglich ein einzelner Header, aber im Lauf der Jahre musste eine einzelne Übersetzungseinheit hinzugefügt werden. Meine Schlussfolgerung ist: Wenn man die Codebasis in einem Zustand hält, in dem die Tests immer bestehen, braucht man deutlich weniger Komplexität bei Fehlermeldungen oder Fehlertoleranz der TestwerkzeugeCompile-Time-Unit-Tests sind eine ebenso schlechte Idee wie „ungenutzte Imports/Variablen/Ergebnisse“ zu Fehlern statt Warnungen zu machen.
Das ist eine „Kindermädchen-Funktion“, die Entwicklern Kontrolle wegnimmt und sie am Ende zwingt, bürokratische Schritte abzuarbeiten, um die Arbeit zu erledigen. Solche Build-fehlschlagenden Tests sind gut für Builds, bei denen man denkt: „Jetzt ist es fertig“, aber nicht für die „noch in Arbeit“-Builds, die 99 % aller Builds ausmachen. Es ist, als würde man sagen: „Du darfst die Tischsäge nicht benutzen, bevor du die Bohrmaschine weggeräumt hast.“
Wenn man versucht hat, einen Gedanken auszudrücken, und ein Compile-Time-Test meldet, dass er falsch ist, dann ist dieser Gedanke vielleicht tatsächlich noch unvollständig, oder man hat nicht alle Folgen dieser Ausdrucksweise ausreichend bedacht. Das ähnelt der Typprüfung in Haskell. Ein Programm, das die Typprüfung nicht besteht, kann nicht kompiliert werden, und der Programmierer wird gezwungen, immer nur vollständige Gedanken auszudrücken. Theoretisch kann das zu besser durchdachten Programmen führen. Allerdings macht es das Schreiben schwieriger, weil der Programmierer auch die Ecken finden muss, von denen er weiß, dass sie „ungültig sind, aber ihn nicht interessieren“
static_assertähneln und sind sehr wertvoll, um inkompatible Verwendungen von Bibliotheksfunktionen aufzudecken.Ich halte das für eine ziemlich gute Idee
„Dass Leaf-Funktionen zuerst kommen und globale Interface-Funktionen zuletzt“, ist für mich genau verkehrt herum
Aus mehreren Gründen bevorzuge ich es, Code in topologisch sortierter Reihenfolge zu schreiben. Das ähnelt der Art, wie man Code innerhalb einer Funktion schreibt, macht klar, wo Funktionen innerhalb eines Moduls stehen sollten, und vor allem werden zyklische Abhängigkeiten zwischen internen Code-Teilen eines Moduls sehr deutlich. Zyklische Abhängigkeiten mag ich nicht besonders, weil sie eine Codebasis stärker verflechten und es erschweren, ein Modul als eigenständige Einheit zu verstehen. In Python können sie außerdem Probleme erzeugen, die erst zur Laufzeit sichtbar werden[0], und zyklische Imports sind so verbreitet, dass aktuelle Type-Checker diese Diagnose offenbar standardmäßig deaktivieren[1]. Sprachen wie C, OCaml und SML, die keine Vorwärtsreferenzen unterstützen, erlauben es, bei zyklischen Abhängigkeiten das „Prinzip der geringsten Überraschung“ anzuwenden. OCaml verbietet sogar rekursive Abhängigkeiten zwischen Funktionen, sofern man sie nicht etwa als
let rec fn1 = .. and fn2 = ..deklariert; beim Schreiben ist das etwas lästig, beim Lesen aber eine wichtige Information[0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
[1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (siehe
reportImportCycles)Es gibt die Erklärung, dass der Compiler nur lexikalisch Vorhergehendes kennt und sich dadurch eine „von unten nach oben“-Reihenfolge ergibt, bei der Leaf-Funktionen zuerst und globale Interface-Funktionen zuletzt kommen. Aber selbst in Sprachen wie Python, die Vorwärtsreferenzen erlauben, ist diese Reihenfolge verbreitet[0]
Ich frage mich, ob das ein Überbleibsel aus Sprachen ohne Vorwärtsreferenzen ist oder ob es für bestimmte Arten von Code tatsächlich sinnvoller ist
[0] https://stackoverflow.com/a/73131538
Bezeichner im Funktionskörper werden lediglich erst aufgelöst, wenn die Funktion selbst ausgeführt wird; zu diesem Zeitpunkt ist alles im Modul-Scope bereits definiert. Man kann das direkt überprüfen, indem man beliebige Namen in einen Funktionskörper schreibt und das Modul lädt
Es gibt keine strikte Regel, aber so liest es sich für mich sauberer und sinnvoller. Besonders wenn die Implementierung groß ist, möchte ich nicht erst durch alles hindurchscrollen müssen, bevor ich sehe, was sie grundsätzlich tut. Mich würde interessieren, wie andere das handhaben
Zu den „offensichtlichen Dingen“, die C leisten sollte, gehören Unterstützung für einen Slice-Typ, der Pointer und Länge enthält, reentrant-fähige und möglichst Thread-sichere Versionen von APIs, die globalen Zustand verwenden, die Standardisierung von etwas wie
deferin Go oder Zig oder demcleanup-Attribut von GCC, sowie portable Unterstützung für Unicode und UTF-8Dass der Compiler überall in der C-Syntax, wo konstante Ausdrücke vorkommen, Funktionen zur Compile-Zeit ausführen können sollte, solange die Funktion kein I/O macht, nicht auf veränderliche globale Variablen zugreift, keine Systemaufrufe ausführt usw., lässt sich leicht aushebeln
Man muss nur eine sehr lange laufende reine Funktion wählen. Wenn zum Beispiel
int busybeaver(int n) {...}eine reine Funktion ist, die die maximale Laufzeit einer Busy-Beaver-Maschine mit n Zuständen zurückgibt, wird Code wieint x = busybeaver(99);zum ProblemC23 hat
constexpr, aber es kann noch nicht auf Funktionen angewendet werdenEs gibt allerdings einen Vorschlag dazu: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf