Ziel
Ich habe einen Flugsimulator. Ich möchte gern, dass er durch unbeteiligte Dritte erweiterbar ist:
- Man soll eigene Flugzeuge hinzufügen können (Flugphysik, Avionik, HUD, KI, etc.).
- Man soll eigene Levels hinzufügen können (Landschaften, Gegnerkonstellationen).
- Man soll seinen Flugzeugen und Levels eigenes Aussehen verleihen können.
- Man soll seinen Flugzeugen und Levels eigenen Klang verleihen können.
Diese Ziele versuche ich, durch eine C-API zu erreichen. Könnte vielleicht auch anders gehen – eine Skriptsprache wurde ja schon diskutiert. Oftmals wird sowas komplett Data-Driven gelöst, indem einfach alles komplett über Konfigurationsdateien und Formate einstellbar und bis zur Unkenntlichkeit verdrehbar ist. Ist aber aus anderen Gründen nicht mein Stil (Protip: Eine Code-API kann eine Daten-API implementieren, aber ohne die Komplexität von Dateiformaten in die Geschäftslogik zu ziehen).
Eine C-API also.
Philosophie
Mein Spiel soll gerade eben das Nötigste bereitstellen, was man braucht, um den Job zu erledigen. Viele [who?] mögen mächtige oder umfangreiche APIs, mit denen man jede Menge verrücktes Zeug machen kann [citation needed]. Ich nicht. Ich bilde mir ein, dass es mir die Wartung erschwert, und Optimierungen wie Multi-Threading. Dass mächtigere Schnittstellen zu höherer Komplexität führen und damit zu mehr Problemen. Vielleicht ist das aber auch bloß mein Charakter.
Ich traue meinen Moddern nicht. Ich sehe täglich, wie Schnittstellen missbraucht werden. Ich denke nicht einmal, dass andere schlechte Programmierer sind. Ich glaube schlicht, dass eine API eine Denkweise ausdrückt, und dass es nicht trivial ist, andere Denkweisen zu verstehen. Also ist es prinzipiell schwierig, andere APIs richtig zu benutzen, weil man die Denkweise des Verfassers verinnerlichen muss. Deshalb sollte man Schnittstellen so eindeutig und kohärent gestalten wie möglich. Niemals zwei Lösungswege anbieten, denn dann nehmen die meisten Modder die falsche – wer denkt schon wie ich!
Callbacks sind dabei ein gutes Mittel. Man überlässt nicht dem Modder die Kontrolle, was das Spiel tut, sondern lässt ihn nur anmelden, was er verändern möchte und mit welcher Funktion – wenn der perfekte Zeitpunkt gekommen ist, ruft ihn die Engine auf, nicht umgekehrt. So verhindere ich, dass zur falschen Zeit die falschen Dinge gemacht werden; und so muss sich kein Modder mit verrückten Abhängigkeiten und Implementierungsdetails der Engine befassen.
- Objekte bekommen ein Callback für Physik hinterlegt
- Objekte bekommen ein Callback für Grafik-Rendering hinterlegt
- Objekte bekommen ein Callback für Sound-Rendering hinterlegt
- Irgendwann werden auch Callbacks für KI kommen, aber so weit bin ich gerade noch nicht.
(Nachteil von Callbacks: In nicht-C-Sprachen sind sie mitunter knifflig. In C# muss man bspw. Funktionen erst in den Speicher pinnen, bevor man sie an eine C-API übergeben kann. Die Dinger müssen halt fertig übersetzt sein, der Garbage Collector darf sie nicht wegräumen, und ein Thunk muss die Laufzeitumgebung wiederherstellen, und und und.)
Versuch 1: libc, Win32
Jeder C/C++-Programmierer kennt eine C-API: Die C-Laufzeitbibliothek. printf() und so. Sieht nach Pipi-Kram aus, tut aber was echt ernstes: Verbindet euer Programm mit einer dynamischen Bibliothek, die solche Dinge wie printf() implementiert. Ich benutze sie hier mal als nicht-Spieleprogrammierung-Beispiel, weil die Arbeit damit so verbreitet ist.
Uns fällt kaum auf, dass das eine echte API ist, weil sie auf die einfachste Art und Weise überhaupt implementiert wird: Als Sammlung dynamisch gelinkter Funktionen. printf() und Co sind vordeklariert. Startet das Programm, wird die nötige DLL gesucht, geladen, und die Sammlung Funktionszeiger gefüllt. Jeder Aufruf ist dann ein Sprung durch einen Funktionszeiger. Das merkt man nicht, weil sich der Compiler drum kümmert.
Win32 macht dasselbe, auch wenn es durch seitenlange Microsoft-Docs-Datenblätter sehr viel ernster aussieht. Kernel32.dll enthält die Win32-API für Low-Level-Systemzeug. CreateFile() ist eine exportierte Funktion aus dieser DLL. Wir rufen sie auf, als wäre sie eine von uns. Perfekte Welt.
Inflation
Der libc/Win32-Ansatz funktioniert nur so lange gut, bis Abwärtskompatibilität ins Spiel kommt: Wir merken, dass CreateFile() eben doch nicht alles tut, was wir brauchen, und dass wir es erweitern müssen. Wir wollen aber nichts kaputtmachen.
Solche APIs gehen dann immer den selben Weg: Inflation. Aus printf() werden sprintf(), fprintf(), vwprintf(), vfwprintf(), vswprintf(), vsnprintf() …
Aus CreateFile() werden CreateFileA() und CreateFileW(), dann CreateFileTransactedA(), CreateFile2() …
Keine der „alten“ Funktionen kann geändert, abgeschaltet, oder entfernt werden, ohne existierende Programme kaputtzumachen. Wenn man das SDK kontrolliert, kann man sowas noch halbwegs mit dem Holzhammer durchdrücken. Wenn aber Drittparteien angefangen haben, die API in andere Sprachen zu übernehmen (Python, C#, …) – und das will ich ja, denn dafür halte ich das alles in C – ist alles verloren.
OMG Abwärtskompatibilität
Wenn man die alten Funktionen am Leben erhält, kommt man in eine kleine Zwickmühle: Früher oder später wird jemand anfangen, die neuen und die alten Funktionen zu vermischen. Schließlich ist der Sinn von Abwärtskompatibilität, dass alter Code nicht kaputtgeht. Unser ReadFile1() muss also mglw. erweitert werden um mit Dateien umzugehen, die via CreateFile2() erzeugt wurden. Oder zumindest um eine Prüfung, dann einen Fehler zu melden! Es gibt Wege drumherum (neue Typen für alles), aber die beschleunigen wiederum die Inflation. Man kommt da nicht mehr raus, und das Wachstum ist üblicherweise quadratisch.
OMFG Multi-Threading
Unsere CPUs haben seit einigen Jahren mehrere Rechenkerne, ne? Sogar unsere Handys. Wenn libc mit Multi-Threading konfrontiert wird (wir machen mal fröhlich fseek(), während im anderen Thread ein fwrite() im Gange ist), ist die Antwort fast immer: Implementation Defined. Schlimmstenfalls explodiert der Computer. May work until it doesn’t.
Bei Win32 wiederum ist die Antwort immer: „Läuft. Wir synchronisieren intern!“. Zu dem Preis, dass dann alles langsamer ist, weil sogar in Single-Threaded Programmen jeder Aufruf durch einen Mutex läuft.
Siehe auch The Rise of Worse is Better.
Beides lässt mir die Haare zu Berge stehen.
Zusammenfassung Versuch 1
+ einfach zu implementieren
+ einfach zu nutzen (landläufig bekannt)
- Schnittstelle wächst unaufhaltsam
- Implementierung wächst unaufhaltsam
- Konsistenzprüfung und Synchronisation schwierig
- Was passiert eigentlich, wenn eine abhängige DLL nicht gefunden wird? ;) Spoiler: Job wechseln
Nächstes Mal geht es mit dem zweiten Versuch weiter: Wie Vulkan und DirectX das Ganze lösen. Stay tuned.