1 Punkte von GN⁺ 2024-03-01 | 1 Kommentare | Auf WhatsApp teilen
  • 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,undefined und 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
  • 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 char 7 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_uring nö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.sh und test.sh kapseln, 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
    • Prüfen, ob Beispielprogramme von Dependencies gebaut und ausgeführt werden
      • Auch bei mbedtls konnte der Beispiel-Build per CMake-Variable deaktiviert werden
    • 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_TEST standardmäßig zu deaktivieren und nur bei direkter Entwicklung einzuschalten
    • Prüfen, ob eine komplette Third-Party-Dependency gebaut wird, obwohl tatsächlich nur ein kleiner Teil genutzt wird
      • mbedtls bietet 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 mold experimentieren
    • Wenn möglich, auch Compiler vergleichen
      • In einem Projekt war clang doppelt so schnell wie gcc, in einem anderen gab es keinen Unterschied
  • 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-xxx nutzen
      • 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
    • Mit Lintern wie cppcheck ungenutzte 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
  • 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-tidy und cppcheck kö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-format formatieren
    • 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,undefined empfohlen
    • 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
  • 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::clamp braucht, ist es möglicherweise besser, es selbst zu schreiben

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
    • 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
    • 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 checkout mö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_subdirectory oder git submodule foreach make kann 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 Code wird 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

 
GN⁺ 2024-03-01
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 -Wall ohne 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 valgrind nach Lese-/Schreibfehlern zu suchen, und bevor man die Gesamtstruktur versteht, ist es sicherer, Refactorings lokal begrenzt zu halten.

    • Wenn man Explizitheit und Reproduzierbarkeit will, sollte man gerade nicht mit Docker anfangen. Wenn man nicht sehr sorgfältig ist, liefert Docker nur die am stärksten verwässerte Form von Reproduzierbarkeit und kann sogar ein trügerisches Sicherheitsgefühl vermitteln.
      Dockerfiles, die auf veränderliche Image-Tags ohne Integritäts-Hash zeigen oder apt-get aufrufen, 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 0 sind reproduzierbare Builds, und Stufe 1 ist, alle Tests laufen zu lassen und flaky Tests zu markieren.
      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.
    • Für Microservices ist Docker okay, aber man kann nicht immer alles mit Docker ausliefern. Das gilt besonders für Leute, die die App innerhalb von Docker verwenden wollen, und Docker-in-Docker sollte es überhaupt nicht geben.
      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/lib Milliarden Dinge in genau den richtigen Versionen installiert sind.
    • Mit -Wall sauber zu bauen ist in Ordnung, aber -Wall -Werror in 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 -Werror nö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.

    • Ich habe in Agenturprojekten öfter Codebasen retten müssen, und als Erstes lasse ich immer Unit-Tests laufen und prüfe die Coverage.
      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.
    • Umgekehrt ruiniert Auto-Formatting die Versionshistorie und macht es schwieriger zu analysieren, wann und warum eine Zeile hinzugefügt wurde.
    • Ich würde Auto-Formatting auf eine Legacy-Codebase absolut nicht empfehlen.
      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.
    • CI ist hier anders als die anderen Punkte. Als erster Schritt braucht man mindestens einen Happy-Path-Test-Harness, der mit reproduzierbaren Ergebnissen läuft und bei jedem Commit ausgeführt wird; das hilft auch dabei, die Codebase zu verstehen.
      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.
    • Im Detail ist der Bereich, den man laut Original herausreißen soll, auf toten Code beschränkt, der durch Compiler-Warnungen und nicht mehr unterstützte Architekturen identifiziert wird.
      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...

    • Ich kann kaum glauben, in freier Wildbahn jemanden zu treffen, der Source Navigator tatsächlich benutzt hat.
      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::cmake in 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.

    • Das, was der Autor aufzählt, ist normalerweise keine IDE-Integration. Ich habe in einer C++-Entwicklungsumgebung noch nie gesehen, dass cpplint oder clang-tidy oder 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.
    • Ich finde, dass es keine akzeptable Antwort mehr ist, wenn eine Person VIM und eine andere emacs, QTCreator oder VSCode verwendet.
      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.
    • Automatische Formatierung kann man auch außerhalb des Editors machen. Man richtet pre-commit ein und lässt es in der CI laufen; das ist eine Kleinigkeit.
      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.
    • Offenbar wurde die Notiz übersehen, dass man die grundlegenden Anforderungen des Chefs nicht einfach ablehnen kann. Du weißt schon, die Person, die das Gehalt zahlt.
    • Lokale, optionale pre-commit-Hooks müssen durch verpflichtende Schritte in der CI abgesichert werden.
      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.

    • Ich würde das nicht zum ersten Schritt machen. Damit verschwendet man womöglich ihre Zeit.
      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.
    • Es ist vielleicht besser, die Codebasis zuerst schnell zu überfliegen, die größten WTFs zu finden und dann Fragen zu stellen.
      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.
    • Wer schon einmal den früheren Maintainer zu Legacy-Code befragt hat, weiß: Selbst wenn die Übergabe erst gestern war, können sie dir über etwas Nützliches, das sie vor mehr als sechs Monaten gemacht haben, kaum noch etwas sagen.
      Das ist noch der Best Case; häufig lautet die Antwort auf jede Frage nur: „So war es schon, als ich es übernommen habe.“
    • Ich musste einmal Software auf einem alten angepassten OS in einem abgeschotteten militärischen Netzwerk ausrollen. Es war kein riesiges Programm, nur etwa 50.000 Zeilen.
      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.
    • So einfach ist das nicht. Mein Schritt 0 wäre, das Ganze in ein UML-Tool zu laden und Klassen- sowie andere Diagramme erzeugen zu lassen.
      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.

    • Der Originaltext spricht sich gegen großes Refactoring aus. Die vorgeschlagenen schrittweisen Arbeiten gehen eher in die Richtung, den Code nach und nach leichter handhabbar zu machen.
      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.
    • Die richtige Antwort hängt von der Zukunft ab. Ich habe schon an C++-Code gearbeitet, für den bereits ein Ersatzprodukt am Markt war, während der alte Code noch ein paar weitere Releases brauchte.
      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 einigen glücklichen Fällen kann man Funktionen einfach anstückeln.
      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.

    • Ich frage mich, ob es Software-Tipps gibt, die bei der Automatisierung solcher Analysen helfen.
  • 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.

    • Tatsächlich kann man dieses Ziel auch innerhalb einer C++-Codebasis erreichen, indem man nicht speichersicheres C++ entfernt und Richtlinien durchsetzt.
      „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

    • Als Autor würde ich sagen: Dieses Thema werde ich im zweiten Artikel behandeln.
      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.
    • Es war ein Artikel über C++, und das Wort „Rust“ kam kein einziges Mal vor; es wurden nur die dutzenden „memory safe“-Sprachen erwähnt. Umso interessanter ist diese Art, ein Rust-Meme hineinzupressen, um den Artikel abzuwerten.
      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.