Sie haben gerade eine Legacy-C++-Codebasis geerbt – was nun?
(gaultier.github.io)- Wer eine große, komplexe Legacy-C++-Codebasis übernimmt, sollte nicht „sauberen Code“ als Ziel ausgeben, sondern Sicherheit, Developer Experience, Korrektheit und Performance auf ein akzeptables Niveau bringen
- Der Ausgangspunkt ist, Zustimmung und Zeitgrenzen in der Organisation zu sichern, die unterstützten Plattformen im README festzuhalten und lokale Builds sowie Tests zuverlässig zum Laufen zu bringen
- Verbesserungen bei Build- und Testgeschwindigkeit sollten gemessen und mit niedrigschwelligen Verbesserungen begonnen werden, etwa dem Entfernen von Dependency-Tests, dem Aufräumen unnötiger Targets oder Experimenten mit schnellen Linkern wie
mold - Nachdem ungenutzter Code und Code für nicht unterstützte Plattformen reduziert wurden, sollte mit
clang-tidy,cppcheck,clang-format,-fsanitize=address,undefinedund CI eine automatische Validierungsschleife aufgebaut werden - Rewrites oder die Einführung moderner C++-Standards sind kein Selbstzweck, sondern Mittel zum Zweck; auch eine Neuschreibung in einer speichersicheren Sprache ist nur bei starken Gründen erwägenswert
Zuerst Menschen und Prozesse ordnen, nicht den Code
- Die Verbesserung einer Legacy-C++-Codebasis sollte keine Feierabend-Aktion im Alleingang und kein langfristiger „Todesmarsch“ sein, sondern nachhaltige Software-Engineering-Arbeit
- Man sollte Vorgesetzten, Kolleginnen und Kollegen sowie nichttechnischen Beteiligten Problem, Lösung und Zeitgrenzen in einfachen Worten erklären
- Wenn ein neuer Mitarbeiter drei Wochen braucht, bis lokaler Build und erster Beitrag gelingen, kann man als Ziel vorschlagen, das auf wenige Minuten zu reduzieren
- Wenn ein einfaches Fuzzing-Setup die App in wenigen Sekunden 253-mal zum Absturz bringt, ist das eine Grundlage, um das Risiko in der Produktionsumgebung zu erklären
- Wenn das Deployment von einem einzigen FreeBSD-9-Build-Server abhängt, der seit acht Jahren nicht mehr unterstützt wird, bedeutet ein Serverausfall, dass Deployments blockiert sind
- Wenn ein branchenüblicher Linter undefiniertes Verhalten, das die Ursache eines Produktionsfehlers war, sofort erkennt, gibt es genug Gründe, ihn bei jeder Änderung auszuführen
- Wenn wegen einer manuell kopierten und angepassten Kryptobibliothek unklar ist, ob man von Schwachstellen betroffen ist, braucht es Dependency-Aufräumen und automatische Benachrichtigungen
- Auch Ansätze, die vermieden werden sollten, müssen klar sein
- Ohne Tests die gesamte Codebasis zwei Wochen lang auf den neuesten C++-Standard hochziehen
- Über Monate hinweg große Änderungen in einem separaten Branch bauen und hoffen, dass sie irgendwann gemergt werden
- Von Anfang an mit einer kompletten Neuschreibung starten, unter der Annahme, sie sei in wenigen Wochen erledigt
- Mit „Codebasis verbessern“ anfangen, ohne zu wissen, was bis wann erledigt werden soll
Sichere Verbesserungsweise
- Jede Änderung sollte klein und inkrementell sein; vor und nach der Änderung muss die App funktionieren und Tests sowie Linter müssen durchlaufen
- Dringende Bugfixes müssen weiterhin wie bisher möglich sein und dürfen durch Verbesserungsarbeiten nicht blockiert werden
- Jede Änderung sollte eine messbare Verbesserung bringen und auch Nichtfachleuten erklärbar oder vorführbar sein
- Selbst wenn die gesamte Arbeit wegen Prioritäten oder Budget gestoppt wird, sollte gegenüber dem Ausgangszustand ein messbarer Nettogewinn bleiben
Unterstützte Plattformen im README aufführen
- Im README sollten die offiziell unterstützten
<architecture>-<operating-system>-Paare stehen- Beispiel:
x86_64-linux,aarch64-darwin
- Beispiel:
- Diese Liste ist die Grundlage dafür zu prüfen, ob auf allen unterstützten Plattformen gebaut wird, und später ein Argument dafür, Code für nicht unterstützte Plattformen zu entfernen
- Falls nötig, können auch Architekturversionen wie ARMv6 oder ARMv7 angegeben werden
- Die Liste der unterstützten Plattformen hilft, folgende Fragen zu beantworten
- Ob man sich auf Floating Point, SIMD oder SHA256-Hardwareunterstützung verlassen kann
- Ob 32-Bit-Unterstützung nötig ist
- Ob die Software auf Big-Endian-Plattformen läuft
- Ob die Möglichkeit berücksichtigt werden muss, dass
char7 Bit breit ist
- Auch Entwickler-Workstations müssen zwingend in diese Liste aufgenommen werden
Zuerst lokalen Build und Tests stabilisieren
- Selbst zentrale C++-Codebasen erfolgreicher Produkte lassen sich oft nicht zuverlässig kompilieren; Ziel ist kein „Build, der manchmal klappt“, sondern ein konsistenter Build auf allen unterstützten Plattformen
- Der beste Zustand ist, dass direkt auf der Entwicklermaschine gebaut und ausgeführt werden kann
- Wenn das Projekt so groß ist, dass der RAM nicht reicht, kann man einen großen Server für Builds ausleihen
- Wenn plattformspezifische APIs wie
io_uringnötig sind, kann man einen Shim implementieren oder in einer virtuellen Maschine auf der Workstation bauen - Trotzdem ist ein direkter lokaler Build die beste Wahl
- Wenn es keine Tests gibt, sollten vor Codeänderungen zuerst Tests geschrieben werden
- Der einfachste Ausgangspunkt ist, Ein- und Ausgaben eines real ausgeführten Programms zu erfassen und daraus End-to-End-Tests zu erstellen
- Solche Tests garantieren nicht, dass das bestehende Verhalten korrekt ist, reduzieren aber Regressionen bei Änderungen
- Wenn es eine Testsuite gibt, von der einige Tests fehlschlagen, deaktiviert man sie zunächst und bringt zuerst den gesamten Testlauf in einen Zustand, in dem er auch nach mehreren Stunden besteht
Build- und Testanleitung dokumentieren
- Im README sollte stehen, wie man die App baut und testet
- Ideal sind ein einzelner Build-Befehl und ein einzelner Test-Befehl
- Wenn der Ablauf anfangs kompliziert ist, kann man die Befehle in
build.shundtest.shkapseln, um die Komplexität zu verstecken - Ziel ist ein Zustand, in dem auch Personen ohne C++-Expertise Build und Tests ohne Rückfragen ausführen können
- Projektstruktur oder Architektur zu dokumentieren, macht eher später Sinn, nachdem unnötiger Code entfernt wurde
Build- und Testzeiten mit niedrigem Aufwand verkürzen
- Ohne das Build-System umzuwerfen oder heroische Optimierungen zu betreiben, sollte man zuerst niedrigschwellige Verbesserungen messen
- Punkte, die zuerst geprüft werden sollten
- Prüfen, ob Tests von Dependencies jedes Mal gebaut und ausgeführt werden
- Es gab Fälle, in denen bei
unittest++als CMake-Subprojekt auch die Tests des Test-Frameworks jedes Mal gebaut und ausgeführt wurden
- Es gab Fälle, in denen bei
- Prüfen, ob Beispielprogramme von Dependencies gebaut und ausgeführt werden
- Auch bei
mbedtlskonnte der Beispiel-Build per CMake-Variable deaktiviert werden
- Auch bei
- Prüfen, ob das eigene Projekt Tests und Beispiele standardmäßig baut, wenn es als Subprojekt in ein anderes Projekt eingebunden wird
- Empfohlen wird, Build-Variablen wie
MYPROJECT_TESTstandardmäßig zu deaktivieren und nur bei direkter Entwicklung einzuschalten
- Empfohlen wird, Build-Variablen wie
- Prüfen, ob eine komplette Third-Party-Dependency gebaut wird, obwohl tatsächlich nur ein kleiner Teil genutzt wird
mbedtlsbietet viele Compile-Time-Flags, um nicht benötigte Teile abzuschalten
- Prüfen, ob Target-Abhängigkeiten falsch sind und schon kleine Änderungen einen kompletten Rebuild auslösen
- Viele Build-Systeme können den Dependency-Graph ausgeben
- Mit schnellen Linkern wie
moldexperimentieren - Wenn möglich, auch Compiler vergleichen
- In einem Projekt war clang doppelt so schnell wie gcc, in einem anderen gab es keinen Unterschied
- Prüfen, ob Tests von Dependencies jedes Mal gebaut und ausgeführt werden
- Zusätzliche Experimente, deren Nutzen aber klein oder negativ sein kann
- LTO aus/an/thin
- Debug-Informationen auslagern
- Make und Ninja vergleichen
- Dateisystemtyp und -einstellungen
- Wenn Builds zu lange dauern, ist das Ändern des Codes selbst praktisch kaum realistisch
Unnötigen Code entfernen
- Es gibt Fälle, in denen mehr als 30 % einer Codebasis komplett toter Code waren; solcher Code erhöht fortlaufend die Kosten für Kompilierung und Refactoring
- Vorgehen zum Entfernen
- Warnungen des Compilers vom Typ
-Wunused-xxxnutzen- Beispiel:
-Wunused-function - Meist reicht Löschen, neu bauen und testen; selten kann es aber Symptom eines Bugs sein, bei dem die falsche Funktion aufgerufen wurde, daher sollte man mit vollständiger Automatisierung vorsichtig sein
- Beispiel:
- Mit Lintern wie
cppcheckungenutzte Funktionen oder Klassenfelder finden- Bei Vererbung und virtuellen Funktionen kann es viele False Positives geben, aber solche Tools können ungenutzte Elemente finden, die der Compiler nicht erkennt
- Eine weitere Möglichkeit ist, dass der Linker jede Funktion in eine eigene Section legt und die Entfernung ungenutzter Sections ausgibt; wegen Rauschen durch Standardbibliotheksfunktionen ist das aber eventuell nicht praktikabel
- Der Vergleich von generiertem Assembly und Quellcode eignet sich nicht gut für virtuelle Funktionen
- Warnungen des Compilers vom Typ
- Mithilfe der Liste unterstützter Plattformen Code für nicht unterstützte Plattformen entfernen
- Alter Solaris-Support in einem Projekt, das tatsächlich nur auf FreeBSD lief
- Code mit eigenem Zufallszahlengenerator, obwohl auf der realen Plattform immer ein Zufallszahlengenerator vorhanden ist
- Fallback-Code für fehlende POSIX-2001-Unterstützung, obwohl die Software nur auf modernem Linux und macOS läuft
- Code, der prüft, ob die CPU Big-Endian ist, und Byte-Swapping durchführt
- Code, der vor Jahren eingeführt wurde, aber nie zu einer tatsächlichen Funktion führte
- Ein PR, der große Mengen Code löscht, kann zugleich geringere Build-Zeiten und niedrigere Wartungskosten zeigen
Linter, Formatter und Sanitizer einführen
- Bei Lintern sollte man nicht zu viele Regeln aktivieren, sondern mit einigen grundlegenden Regeln in den Entwicklungszyklus einsteigen
clang-tidyundcppcheckkönnen nützlich sein, sind aber möglicherweise langsam und rauschanfällig- Gar keine Linter zu haben ist keine Option; beim ersten Lauf finden sie oft viele echte Probleme, die durch Compiler-Warnungen allein nicht aufgefallen wären
- Code-Formatierung sollte zum passenden Zeitpunkt einmalig angewendet werden
- Man sollte auf einen Zeitpunkt ohne aktive Branches warten, um grauenhafte Merge-Konflikte zu vermeiden
- Keine Zeit mit Style-Debatten verschwenden, sondern die gesamte Codebasis ohne Ausnahmen mit einem Tool wie
clang-formatformatieren - Die Konfiguration wird mitcommittet
- Sanitizer sind nötig, um schwer auffindbare Bugs zu finden, die reale Produktionsauswirkungen haben können
- Als Default wird
-fsanitize=address,undefinedempfohlen - In der Regel gibt es keine False Positives; wenn etwas erkannt wird, sollte es behoben werden
- Auch Tests sollten mit aktivierten Sanitizern laufen, um Probleme zu finden
- Wenn das Performance-Budget es erlaubt, kann man auch erwägen, Teile der Produktion mit aktivierten Sanitizern auszuführen
- Als Default wird
- Selbst wenn der Compiler für Deployments keine Sanitizer unterstützt, kann man für Entwicklung und Tests einen Compiler wie clang verwenden
- Aktivierte Sanitizer können lange verborgene Bugs und Speicherlecks sichtbar machen; deren Behebung kann viel Arbeit und Refactoring erfordern
- Wenn möglich, sollten auch Third-Party-Dependencies beim Testen mit aktivierten Sanitizern kompiliert werden, um Probleme innerhalb von Dependencies zu finden
Mit CI automatisieren
- CI automatisiert die bisher eingerichteten Linter, Formatierung, Tests usw. in einer sauberen Umgebung
- Bei jeder Änderung sollte ein Produktions-Binary erzeugt werden können
- Die meisten CI-Systeme unterstützen Matrizen für mehrere Plattformen, sodass geprüft werden kann, ob die im README aufgeführten unterstützten Plattformen tatsächlich bauen
- Eine typische Pipeline kann so einfach sein wie
make all test lint fmt - Von Lintern und Sanitizern gemeldete Probleme sollten die Pipeline fehlschlagen lassen; sonst behebt sie niemand
Code schrittweise vereinfachen
- Sobald eine stabile Build-, Test- und Validierungsschleife vorhanden ist, kann der Code nach und nach vereinfacht werden
- Es gibt Fälle, in denen eine komplexe Klasse tatsächlich nur einen Pointer allokierte und auf null prüfte und damit im Grunde durch einen Boolean ersetzbar war
- Für diese Phase ist es schwer, eine Zeitgrenze zu setzen, weil mit jeder Vereinfachung neue Ansatzpunkte für weitere Vereinfachungen sichtbar werden; hier sollte man konservativ urteilen
- Ziel sollten konkrete Werte wie Sicherheit, Korrektheit und Performance sein, nicht subjektive Kriterien wie „Clean Code“
- Ein Upgrade des C++-Standards ist kein Ziel, sondern ein Mittel
- Zum Beispiel kann es helfen, Code mit manuellem Iterator-Inkrement in eine
for (auto x : items)-Schleife umzuwandeln - Wenn man nur ein einzelnes
std::clampbraucht, ist es möglicherweise besser, es selbst zu schreiben
- Zum Beispiel kann es helfen, Code mit manuellem Iterator-Inkrement in eine
Neuschreibung in einer speichersicheren Sprache braucht eine eigene Abwägung
- Teile in einer speichersicheren Sprache neu zu schreiben ist möglich, bringt aber viele Punkte mit sich, die bedacht werden müssen
- Das sollte nur bei starken Gründen angegangen werden
Dependency-Management bevorzugt Builds aus dem Source
- C++ hat kein einheitliches Dependency-Management, und viele Projekte verwenden den Paketmanager des Systems
- Probleme des Ansatzes mit Systempaketen
- Installationsanweisungen hängen von OS, Distribution und Distributionsversion ab
- Beim Wechsel von Ubuntu 20.04 auf 22.04 können Paketversionen wechseln, sodass 100 Dependencies gleichzeitig aktualisiert werden müssen
- Third-Party-Dependencies, die es nicht als Paket gibt, müssen am Ende doch aus dem Source gebaut werden
- Pakete sind nicht mit den gewünschten Flags gebaut
- Sanitizer, LTO,
-march, Debug-Informationen, Frame Pointer, Unterschiede bei der C++-ABI usw. können zum Problem werden
- Sanitizer, LTO,
- Der Source der exakt verwendeten Version ist bei Audit, Entwicklung und Debugging schwer einzusehen
- Dependencies zu patchen und neu zu bauen ist schwierig
- Zwischen macOS, Ubuntu und FreeBSD exakt dieselben Paketversionen zu verwenden ist schwierig
- Automatisierte BOM-Erzeugung wird schwieriger
- Möglicherweise gibt es die benötigte Paketvariante nicht, etwa als statische oder dynamische Bibliothek
- C++-Paketmanager wie Conan oder vcpkg können eine Verbesserung sein, haben aber Einschränkungen
- Externe Dependencies können CI komplexer und langsamer machen
- Nicht jede Paketversion ist verfügbar
- Bei Conans mbedtls gibt es etwa einen Sprung von
2.16.12auf2.23.0
- Bei Conans mbedtls gibt es etwa einen Sprung von
- Das benötigte OS oder die Architektur werden möglicherweise nicht unterstützt
- Empfohlen wird, Dependencies per git submodule zu holen und aus dem Source zu kompilieren
- Einfachheit
- Im Vergleich zu manuellem Vendoring lassen sich git-Historie und diffs nutzen
- Die Dependency-Version ist exakt auf Commit-Ebene bekannt
- Ein einzelnes Dependency-Upgrade ist per
git checkoutmöglich - Funktioniert auf allen Plattformen
- Compile-Flags und Compiler können pro Dependency gewählt werden
- Auch Entwickler ohne C++-Erfahrung kennen git
- Funktioniert rekursiv
- Mit CMakes
add_subdirectoryodergit submodule foreach makekann jedes Submodul gebaut werden - Wenn Submodule schwierig sind, ist auch ein Ansatz wie bei Neovim möglich, bei dem ein einzelnes Skript Dependencies holt und baut
- Wenn der Dependency-Graph sehr groß ist, kann ein hybrides lokales/remote Build-System wie Buck2 und die Wiederverwendung von Build-Artefakten nötig sein
- Paketmanager kompilierter Sprachen wie Go und Rust nutzen den Ansatz, aus dem Source zu kompilieren
Ergänzungen aus Vorschlägen von Lesern
- Tests müssen stärker betont werden, und C++-Testsuites sollten unter Sanitizern laufen, damit kein falsches Sicherheitsgefühl entsteht
- vcpkg kann eine bessere Wahl als git submodule sein, wenn es Anforderungen und Cross-Compilation erfüllt
- Nix kann als C++-Dependency-Manager dienen, leidet aber unter Komplexität und Langsamkeit
- Wenn man nur einen Bug pro Jahr behebt, ist die Investition in ein großes Refactoring eine Ermessensfrage; das Entfernen toten Codes und Sanitizer sind aber auch bei seltenen Änderungen wertvoll
- Codeentfernung sollte sich auf Fälle konzentrieren, in denen statische Analyse bestätigen kann, dass nichts ihn aufruft; im Zweifel ist es sicherer, nicht zu entfernen
- Bei virtuellen Methoden wird das Aufrufziel zur Laufzeit bestimmt, daher sind sie statisch schwer zu entfernen
- Ein 15-minütiges Gespräch mit Vertrieb, Produktmanagerinnen und Nutzern darüber, ob Funktionen und Plattformen genutzt werden, kann viel technische Arbeit einsparen
- Code in ein LLM zu geben und Fragen dazu zu stellen, sollte rechtlich sicher sein und möglichst lokal erfolgen; die Ergebnisse sind vorsichtig zu behandeln
- Es gibt auch die Idee, mit Code-Analyse-Tools Diagramme und Klassenbeziehungen zu erzeugen, um die Gesamtstruktur zu verstehen
- Wenn es kein Source-Control-System gibt, ist Schritt 0, den Code in ein VCS zu bringen
- Es gibt auch die Ansicht, CI als Schritt 1 zu betrachten; selbst wenn lokal schneller ist, ist das eine plausible Perspektive
- Das Entfernen kaum genutzter Plattformen kann Kombinationskomplexität reduzieren und große Vereinfachungen ermöglichen
- Vollständig reproduzierbare Builds sind bei typischen C++-Codebasen möglicherweise nicht realistisch, aber verlässliche Builds sind realistisch
- Bei einem Commit, der die gesamte Codebasis formatiert, kann man git so konfigurieren, dass dieser bestimmte Commit bei blame ignoriert wird, um die Nachverfolgung der Historie zu entlasten
- Eine weitere Idee ist, mit VCS-Historiendaten häufig geänderte Bereiche und Dateien zu finden, die gemeinsam geändert werden
- Dieser Ansatz lässt sich nicht nur auf C++, sondern auch auf andere Legacy-Codebasen anwenden; C++-spezifische Teile wie Sanitizer kann man dann weglassen
Working effectively with Legacy Codewird als Buch mit einschlägigen Ratschlägen empfohlen- Man sollte sich auf realen Wert konzentrieren; betrachtet man große C++-Codebasen aber aus Sicherheitsperspektive, können viele Schwachstellen gefunden werden, und das hat als Risikoreduktion Wert, nicht als finanzieller Gewinn
1 Kommentare
Hacker-News-Kommentare
Es gibt gute Ratschläge und auch einige eher kontroverse. Nachdem ich mehrere große C++-Projekte übernommen habe, scheinen mir ein paar Aufgaben gerade am Anfang besonders viel zu bringen.
Fang mit reproduzierbaren Builds an und kapsle die Build-Umgebung mit Docker oder deinem bevorzugten Packaging-Tool, damit Tools und Abhängigkeiten explizit und reproduzierbar sind.
Wenn du dafür sorgst, dass mit
-Wallohne Warnungen gebaut wird, kommen schlechter Code, undefiniertes Verhalten und Bugs zum Vorschein, und später sehe ich sofort Warnungen, wenn ich selbst verdächtigen Code einbringe.Es ist außerdem ein schneller Stabilitätsgewinn, früh mit Tools wie
valgrindnach Lese-/Schreibfehlern zu suchen, und bevor man die Gesamtstruktur versteht, ist es sicherer, Refactorings lokal begrenzt zu halten.Dockerfiles, die auf veränderliche Image-Tags ohne Integritäts-Hash zeigen oder
apt-getaufrufen, sind üblich, und beides lässt viel Spielraum dafür, subtil in unterschiedlichen Zuständen zu enden.Schwerer zu lernen, aber Lösungen wie Bazel oder Nix bieten eine deutlich bessere Grundlage.
Stufe 2 ist, alle Tests unter Sanitizern laufen zu lassen und alles zu markieren, was fehlschlägt, Stufe 3 ist, all diese Sanitizer-Fehler zu beheben, und Stufe 4 ist dann der ganze Rest.
Container sind gut, aber furchtbar, wenn sie nur dazu dienen sollen, so zu tun, als gäbe es keine Probleme.
Man sollte alle Abhängigkeiten bündeln und vermeiden, dass etwas nur funktioniert, wenn auf
/usr/libMilliarden Dinge in genau den richtigen Versionen installiert sind.-Wallsauber zu bauen ist in Ordnung, aber-Wall -Werrorin Produktions-Builds lehne ich entschieden ab.Manche Compiler-Warnungen sind eher Meinungssache, und wenn eine neue Compiler-Version neue Warnungen hinzufügt, kann zuvor sauberer Code plötzlich abgelehnt werden.
Wenn
-Werrornötig ist, dann besser nur in Debug-Builds.Ich würde die Reihenfolge von 2 und 3 vertauschen. Erst CI, Linting, Auto-Formatting usw. aufzusetzen hat Vorrang vor dem Herausreißen von Dingen.
Denn man weiß noch nicht, was entfernt werden sollte oder welche Folgen das Entfernen haben wird.
Linter und statische Analysewerkzeuge geben viele Einsichten dazu, welche Teile des Programms Arbeit brauchen.
Was ein statisches Analysewerkzeug heute markiert, ist später oft genau der Bereich, in dem man Funktionen, Klassen oder ganze Dateien entfernen kann, die STL-Konzepte neu implementieren.
Selbst gebaute Iterator-Bibliotheken, selbst gebaute Smart Pointer oder die Verwendung von C-String-Funktionen kann man durch STL-Algorithmen, echte Smart Pointer und C++-String-Klassen ersetzen.
Aber bevor man den Code gescannt hat, sieht man das oft nicht gut, und bevor man auf schnelle Test-Builds oder Deployments hinarbeitet, ist das Ergebnis auch schwer zu bewerten.
Wo etwas fehlt, füge ich grundlegende Smoke-Tests hinzu.
Das macht einen nicht langsamer, sondern im Gegenteil schneller. Wenn man eine brauchbare Coverage hat, kann man beim Refactoring viel schneller vorgehen, also hoher Ertrag für kleine Investition.
Große C++-Projekte haben neben Codegenerierungs-Skripten meist auch Skripte, die Code parsen, um Daten für die Codegenerierung zu sammeln.
Auto-Formatting kann so etwas kaputtmachen, und ich habe sogar schon verfluchte Projekte gesehen, in denen Nutzer öffentliche Header-Dateien mit fragilen Skripten parsen.
Wahrscheinlich wird man an vielen Stellen etwas anfassen, und wenn sich lokal viele Änderungen ansammeln, kann man leicht versehentlich Commits mit vermischten Anliegen erzeugen.
Dann ist CI der Retter.
Wenn man diesen Weg geht, würde ich empfehlen, ihn zumindest erst einmal auszukommentieren statt sofort zu löschen, damit der Diff einfach bleibt, bis der Branch per Squash-Merge zusammengeführt werden kann.
Tools und Techniken zum Codeverständnis fehlen. Früher habe ich ein in Tcl/Tk geschriebenes Tool namens Source Navigator verwendet, das sich hervorragend zum Indizieren von Codebasen eignete.
Man konnte die Aufrufhierarchie der aktuellen Methode ansehen und daraus UML-Sequenzdiagramme erzeugen.
Ein ähnliches Tool ist das unten erwähnte Source Insight.
Und Notizen sind wichtig. Entscheidend ist, sie so zu schreiben, als würde man jemand anderen unterrichten.
Über die Jahre bin ich ziemlich gut darin geworden, Code zu verstehen, und zeitweise habe ich allein aktiv eine Java-Codebase für algorithmischen Handel betreut und weiterentwickelt, die pro Tag rund 200 Millionen Dollar über 4 bis 5 Börsen gehandelt hat.
Die Dokumentation zu diesem Code war 35 MB groß, und abgesehen vom Key-Person-Risiko war das ein angenehm hohes Maß an Verantwortungsgefühl.
Ehrlich gesagt gibt es in den meisten großen Codebasen viel Overengineering und Redundanz.
[1] Referenzen in "Source Insight" https://d4.alternativeto.net/6S4rr6_0rutCUWnpHNhVq7HMs8GTBs6...
In einem Uni-Kurs zu Rechnerarchitektur wurden wir gezwungen, dieses Relikt zu benutzen, weil es einen bizarren Funktionsumfang und Unterstützung für einen ARM-Emulator hatte, die anderswo nicht verfügbar waren.
Wir haben es für Bare-Metal-ARM-Assemblerprogrammierung verwendet.
Bei der Stelle, dass
std::cmakein die Standardbibliothek aufzunehmen ein Gamechanger wäre, hat sich mir der Magen umgedreht.Ich verstehe den Rat, mit der Kettensäge alles herauszutrennen, was nicht absolut notwendig ist, damit ein Unternehmen oder Open-Source-Projekt die Funktionen bereitstellen kann, die es bewirbt und verkauft, aber das ist extrem riskant.
Im Kern ist das ein Problem von Chestertons Zaun: Wenn man nicht vollständig versteht, warum etwas in die Software aufgenommen wurde und wie die Software derzeit genutzt wird, kann man es nicht entfernen.
Im schlimmsten Fall veröffentlicht man etwa einen Monat später ein Release, und die Nutzer stellen fest, dass eine wichtige Funktion auf subtile Weise kaputt ist, und dann verbringt man Tage damit, nachzuvollziehen, wie genau sie kaputtgegangen ist.
CI, Linter, Fuzzing, automatische Formatierung usw. hinzuzufügen, ist eine gute Idee, aber auch schwierig.
Wenn eine Person hauptsächlich VIM nutzt, eine andere emacs, wieder eine andere QTCreator und noch eine andere VSCode, ist es sehr schwer, alle auf denselben Stand zu bringen.
Wenn es sich um einen optionalen Schritt handelt, der die Installation neuer Tools erfordert, passiert es in der Praxis nicht, und ein Linter hilft auch nicht, wenn beim Öffnen des Projekts sofort mehr als 2000 Warnungen auftauchen.
cpplintoderclang-tidyoder ein Fuzzer in die IDE integriert wären, und für eine automatische Ausführung bei jedem Tastendruck sind sie zu langsam.Nur automatische Formatierung ist gelegentlich integriert.
Diese Dinge kann man unabhängig von der jeweiligen Entwicklungsumgebung auf der Kommandozeile ausführen, daher sollte man nicht schon im Vorfeld aufgeben, nur weil zwei verschiedene Texteditoren verwendet werden, sondern es zumindest versuchen.
Wenn ein C++-Team überhaupt keine Tools installieren will, ist Ärger vorprogrammiert. Man sollte auch erwägen, die Tools zu containerisieren, um es einfacher zu machen.
Wir sollten professionell arbeiten, und wenn die Arbeit eine IDE und eine Toolchain voraussetzt, dann muss man sie lernen und benutzen.
Wenn etwas auf meinem Rechner, in meiner IDE und auf meine bevorzugte Weise gebaut wird und funktioniert, dann ist das eher ein Kunsthandwerk als Software.
Selbst wenn die Installation lokaler Hooks optional ist, wird man es am Ende doch tun, wenn Pull Requests dadurch in der CI fehlschlagen.
Das hier sind wirklich Grundlagen, aber es scheint eine ziemlich große Wissenslücke beim richtigen Umgang mit CI und Infrastruktur zu geben.
Tests, Linting, Fuzzing, Formatierung, YAML-Formatvalidierung, Prüfung auf fehlende EOF-Zeilenumbrüche usw. sollten lokal ausführbar sein, damit Entwickler CI-Fehlschläge vor dem Push vermeiden können.
Wenn ein Linter beim Öffnen des Projekts Tausende von Warnungen ausgibt, dann sollte der Entwickler, der den Linter hinzufügt, dafür sorgen, dass die Zahl der Warnungen bei null liegt, bevor diese Änderung gemergt wird.
Das lässt sich erreichen, indem bestimmte Warnungen oder Dateien deaktiviert werden, Teile korrigiert werden oder eine Kombination daraus verwendet wird.
Ich denke, der erste Schritt ist, den vorherigen Maintainer zu kontaktieren, ihn zu besuchen, ihm einen Tee oder ein Bier zu spendieren und dann am Ende doch über die Codebasis zu sprechen.
Diese Zauberer namens frühere Maintainer können einem viel erzählen.
Die übrigen Vorschläge, etwa dafür zu sorgen, dass es auf mehreren Plattformen läuft oder Tests besteht, halte ich für gute Stresstests, die zu Robustheit und Verständnis führen.
Trotzdem würde ich sicher zuerst zu dieser tief hängenden Frucht greifen und mit den Leuten sprechen, die den Weg schon vor mir gegangen sind.
Wenn man zuerst selbst ein wenig daran arbeitet und an mehreren Stellen nicht weiterkommt, ist das Gespräch mit dem früheren Maintainer viel produktiver.
Sie werden diesen Einsatz auch zu schätzen wissen.
Wenn du eine Codebasis übernimmst, die keine Tests hat, bei der der Build jedes zweite Mal fehlschlägt, deren Abhängigkeiten unklar sind und die nur auf einem einzelnen Server mit einem stark veralteten OS gebaut werden kann, dann ist nicht sicher, ob der frühere Maintainer wirklich ein Zauberer war.
Man muss auch überlegen, ob all diese Probleme nur aus Zeitmangel entstanden sind oder ob es sich um einen „Zauberer“ handelt, der sie absichtlich kaputt gehalten hat, um seine Jobsicherheit zu erhöhen, oder der einfach nichts Neues lernen wollte.
Das ist noch der Best Case; häufig lautet die Antwort auf jede Frage nur: „So war es schon, als ich es übernommen habe.“
Während der Arbeit stieß ich auf verschiedene Bugs und Probleme und wollte die Entwickler kontaktieren, die diese für meinen Arbeitgeber maßgeschneiderte Software geschrieben hatten.
Es stellte sich heraus, dass sie von einem einzelnen Auftragnehmer geschrieben worden war, und der war schon vor Jahren gestorben.
In der Verteidigungsindustrie kommt so etwas häufig vor. Es gibt viele maßgeschneiderte Einmalanfertigungen für bestimmte Systeme, und besonders auf der Hardware-Seite ist es nicht ungewöhnlich, dass die Ingenieure, die die Ausrüstung gebaut haben, längst weg oder im Ruhestand sind.
Das hilft enorm.
Dass eine C++-Codebasis Tests hat, finde ich optimistisch.
Ich verstehe nicht ganz, warum der Fokus so stark auf Refactoring oder Verbesserungen liegt. Wenn sich Funktionen durch Anstückeln an den Code hinzufügen lassen, sollte man es einfach so machen und sonst nichts anfassen.
Wenn die Änderung groß genug ist, kann man aus dem Legacy-Code das herausziehen, was man braucht, etwa durch externe Funktionsaufrufe, das Einführen einer Netzwerkschicht oder das Auslagern gleichen Codes in eine Bibliothek, und den Rest in einer neuen Umgebung erledigen.
Solange nicht künftig viele Leute an dem Code arbeiten müssen und dafür bestimmte Annahmen und Standards nötig sind, damit die Gruppe leicht zusammenarbeiten kann, würde ich kein großes Refactoring versuchen.
Das Anstückeln funktioniert nur bis zu einem gewissen Punkt; irgendwann ändert man etwas, plötzlich geht irgendwo etwas kaputt, und weil alles zu chaotisch ist, braucht man viel länger als nötig, um die Ursache zu finden.
Die Kernaussage des Originalposts ist eher, große Rewrites zu vermeiden, aber dennoch aufzuräumen und zu ordnen, damit Änderungen bei erhaltener Kohäsion leichter werden.
Manchmal musste dieselbe Funktion in beide Versionen eingebaut werden.
Code, bei dem man weiß, dass das letzte Release bald kommt, behandelt man ganz anders als Code, den man noch jahrzehntelang warten und erweitern wird.
In der Realität ist ein neues Feature aber oft eine Änderung bereits vorhandenen Verhaltens, und plötzlich muss man an vielen Stellen schwergewichtig refactoren.
In diesem Thread gibt es viel guten Rat. Ergänzend, nicht nur für C/C++: Wenn man den Spielraum hat, ein Versionsverwaltungssystem zu nutzen, sollte man dessen Wert voll ausschöpfen.
Viele Teams verwenden es nur als einfaches Kollaborationstool, aber es kann weit mehr.
Man kann die Historie heranziehen und daraus eine einfache Datenbank machen. Das muss kein RDB sein; man kann auch mit einer JSON-Datei oder einer Tabellenkalkulation anfangen.
Schon mit einem datengetriebenen Ansatz lassen sich fast sofort viele nützliche Informationen gewinnen.
Dateien und Funktionen, die häufig geändert wurden, sind wahrscheinlich Hotspots für künftige Arbeit; wenn man Unit-Tests einführen oder Merge-Konflikte reduzieren will, sollte man sich darauf konzentrieren.
Wenn Dateien, die weit voneinander entfernt wirken, oft gemeinsam geändert werden, kann das auf eine implizite Struktur hindeuten, die aus dem Code allein nicht sichtbar ist.
Auch das tatsächliche Ownership-Modell der einzelnen Module lässt sich aus der Historie ableiten, und unklare Ownership kann ein Signal für Refactoring-Bedarf sein.
Bei C/C++ sollte man Verbesserungen der Build-Zeit ebenfalls datenbasiert auf wichtige Module fokussieren. Statt blind beliebige Dateiabhängigkeiten zu entfernen, kann man häufiger geänderte Module aufspalten und zusammen mit Header-Abhängigkeiten den tatsächlichen Einfluss auf die Build-Zeit bewerten.
Wenn man andere Entwicklerwerkzeuge mit dem VCS integriert, geht noch mehr, und im Zeitalter der LLMs könnte man dem Modell vielleicht Projekt-Historie und Metadaten geben und nach interessanten Einsichten fragen.
Ohne riesiges Kontextfenster braucht es dafür womöglich spezielles Model-Engineering, aber mein Gefühl sagt mir, dass es einen Versuch wert ist.
Der Rat, CI, Linter, Fuzzing, automatische Formatierung usw. hinzuzufügen, sollte feiner aufgeschlüsselt werden.
CI sollte sicherstellen, dass es nicht nur auf meinem Rechner, sondern auch anderswo baut, um compilierungsbasierte Regressionen zu verhindern.
Compiler-Warnungen und statische Analysewerkzeuge sind im Allgemeinen klüger als ich; wenn also eine Warnung kommt, dass ich mit Zeigern etwas Merkwürdiges tue und das beunruhigend aussieht, ist das ein starkes Signal, nachzusehen.
Unit-Tests sollten prüfen, ob wichtiger Code bis auf niedriger Ebene tatsächlich genau das tut, was erwartet wird; wahrscheinlich ist das in Wirklichkeit nicht der Fall, und dann muss man verstehen, warum.
Wenn man etwas behebt, kann etwas anderes kaputtgehen, weil vorhandener Code möglicherweise auf fehlerhaftem Verhalten aufbaut.
Automatische Formatierung hat keine Priorität; ich halte es für besser, dem Stil der bisherigen Maintainer zu folgen.
Auch die Vorstellung, dass die letzte Phase bei einer übernommenen C++-Codebasis ein Rewrite in eine speichersichere Sprache sein sollte, passt oft nicht besonders gut.
Es ist schwer, zusätzliche Ressourcen für etwas zu bekommen, das nicht kaputt ist; außerdem braucht man dann nicht nur C++, sondern auch Wissen über eine weitere Sprache, und das Testen kann komplizierter werden.
Wegen Speicher- oder Performance-Beschränkungen ist der Einsatz mehrerer Sprachen womöglich ohnehin ungeeignet, und dass man überhaupt eine Legacy-Codebasis übernommen hat, ist fast schon das Eingeständnis, dass Ressourcen wie Zeit, Geld oder Wissen fehlen, um sie neu zu schreiben.
„In X neu schreiben“ erhöht oft nur die Komplexität, weil es gerade im Trend ist.
Wenn man ohnehin schon große Teile der Codebasis in C++ neu schreibt, ist es besser, sich an eine stärker eingeschränkte Teilmenge von C++ zu halten; ich halte High Integrity C++ für sinnvoll.
Wenn man den aktuellen MISRA-Standard bekommen kann, ist das wahrscheinlich ebenfalls gut.
Statt dass das gesamte Team erneut die Fallstricke einer neuen Sprache lernt, ist es besser, bei einer Sprache zu bleiben, die man bereits kennt, und Richtlinien durchzusetzen, die bekannte Stolperfallen vermeiden.
Es wirkt seltsam, dass der Autor nach viel Kritik an BOM-Automatisierung, Paketversionsverwaltung und der Herkunft von Abhängigkeiten dann git submodules als besser als Paketmanager vorschlägt.
Bevor man so etwas kritisiert, sollte man vcpkg ausprobiert haben.
Es hat ein paar raue Kanten, aber fast alles davon wird durch vcpkg auf intuitive Weise erfüllt.
Abhängigkeits-Updates sind etwas schwieriger als mit git submodules, aber ich würde sagen, das ist eher ein Feature als ein Bug. Abhängigkeiten werden in individuellen Sandboxes gebaut und dann in ein angegebenes Verzeichnis installiert.
Mit vcpkg kann man statt des offiziellen Repositories ein internes Repository als Registry festlegen und so den vendored-in-Charakter beibehalten; außerdem kann man die Toolchain chainloaden, damit alles mit einem festen Satz von Flags compiliert wird, und Anpassungen pro Port sind ebenfalls möglich.
Genau solche Abstraktionen machen Paketmanager nützlich und erklären ihre Beliebtheit, weil nicht jeder selbst endlose Zeichenketten aus Compiler-Flags, Makros, Warnungen und Ähnlichem direkt handhaben muss.
Der Artikel war interessant, und ich habe auch etwas daraus gelernt. Ich habe mich nur gefragt, welche Sprache gemeint ist, wenn Leute von einer „speichersicheren Sprache“ sprechen.
Ob damit gemeint ist, Teile in Go, Java oder C# neu zu schreiben, oder ob es eher ein sarkastischer Seitenhieb auf Rust ist, bei dem man sich noch eine Hintertür offenlässt
Die Schlussfolgerung hängt vollständig vom Team und den Einschränkungen ab. Zum Beispiel ist wichtig, ob Garbage Collection möglich ist und, falls ja, ob Go dann eine gute Wahl ist, oder ob Sicherheit die höchste Priorität hat.
Die meisten C++-Entwickler können Rust meiner Ansicht nach recht leicht einsetzen und dabei eine vergleichbare Performance erzielen.
Es gibt aber auch Fälle, in denen es von vornherein keinen guten Grund gab, dass das Projekt überhaupt in C++ sein musste, und ich habe auch erfolgreiche Neuschreibungen in Java gesehen.
Apple schreibt Teile seines C++-Codes in Swift neu.
Als Faustregel ist eine Sprache gut, mit der das Team oder das Unternehmen souverän umgehen kann.
Inzwischen sind wir also an dem Punkt angekommen, an dem man vermutet, der Autor sei ein bösartiger, verschlüsselter Rust-Programmierer. Vermutlich nur, weil Rust ursprünglich gar nicht vorkam und man sich deshalb nicht direkt darüber beschweren konnte.