Große ältere C++-Anwendungen:Tools

Große ältere C++-Anwendungen:Tools

In den letzten Wochen habe ich über die Inhalte meines Vortrags „Large C++ Legacy Applications“ geschrieben:Ich habe darüber geschrieben, wie der Umgang mit diesen Anwendungen ein Teamspiel ist, wie wichtig es ist, Refactoring, Tests und Modularisierung zu planen. Dieser Beitrag schließt die Serie mit einem Blick auf die uns zur Verfügung stehenden Tools ab.

Werkzeuge

Es gibt Tools, mit denen wir das für uns hinterlassene Chaos umgestalten und bereinigen können. Am offensichtlichsten sind die in unsere IDEs integrierten Tools:Viele moderne IDEs bieten Unterstützung, die über die bloße Syntaxhervorhebung hinausgeht. Beim Schreiben des Codes werden uns Warnungen angezeigt, d. h. sie bieten eine statische Analyse. Dies kann uns helfen, zweifelhafte Stellen in unserem Code zu finden, was wiederum Fehler verhindern und die Lesbarkeit verbessern kann.

Integrierte IDE-Tools

Mir sind nur sehr wenige IDEs bekannt, die Tools für einfache Refactoring-Schritte bereitstellen, wie das Extrahieren und Inlining von Funktionen und Variablen. Diese Art von Funktionalität ist in IDEs für andere Sprachen wie Eclipse, IntelliJ und Visual Studio für C# üblich. Die komplexere Syntax von C++ scheint es jedoch schwieriger zu machen, dieselbe Funktionalität für C++-IDEs bereitzustellen.

Eines der bekannteren Beispiele für IDEs mit neu entstehender Refactoring-Unterstützung ist CLion, das ich auch in der „4C-Umgebung“ für Fix verwende. Die Refactoring-Unterstützung hat definitiv ihre Grenzen, aber soweit ich das sehe, ist die Entwicklung auf einem guten Weg.

IDE-Plugins

Einige IDEs bieten Plugin-Funktionalität, die es Drittanbietern ermöglicht, Refactoring-Hilfsmittel hinzuzufügen. Die prominentesten Beispiele sind wohl Visual Assist X und Resharper für C++. Ich habe beides nicht selbst verwendet, aber soweit ich weiß, sind diese Tools von mindestens einer ähnlichen Qualität wie CLion, wenn es um Refactoring-Unterstützung geht.

Statische Analysatoren

Während Compiler und auch IDEs bereits viele Warnungen über Code ausgeben, der nicht ganz richtig aussieht, gibt es keinen Ersatz für einen richtigen statischen Analysator. Es gibt viele subtile Dinge, die in großen Codebasen schief gehen können. Statische Analysatoren sind Werkzeuge, die entwickelt wurden, um alle Arten von kleinen Auslassungen und subtilen Fehlern zu finden, also sollten Sie ein oder zwei davon verwenden.

Erwägen Sie die Verwendung einer neueren IDE und eines neueren Compilers

Modernes IDE-Tooling wird immer besser, ist aber meistens nur in den neueren IDEs verfügbar. Plugins funktionieren möglicherweise nicht mit älteren IDEs, und moderne statische Analyseprogramme warnen möglicherweise vor Code, der nicht behoben werden kann, wenn Sie die Anforderungen eines alten Compilers erfüllen müssen.

Neben der Tool-Unterstützung unterstützen neuere Compiler auch die neuen C++-Standards. Dies kann es uns ermöglichen, Code weniger mühsam, sicherer und leistungsfähiger zu schreiben.

Aber so einfach ist es natürlich nicht.

Umschalten des Compilers

Der Wechsel zu einem anderen Compiler kann eine große Aufgabe sein. Das gilt insbesondere, wenn wir mehrere Versionen überspringen, von der 32-Bit- zur 64-Bit-Kompilierung und/oder zu einem anderen Compiler-Anbieter.

Eines der vielen kleinen Probleme, die wir haben können, ist die Größe von Zeigern und ganzzahligen Typen. Es gibt Code, der vor ein oder zwei Jahrzehnten geschrieben wurde und einfach davon ausgeht, dass die Größe eines Zeigers immer 32 Bit oder 4 Byte beträgt und sein wird. Anderer Code wird nur dann ohne Warnungen kompiliert, wenn long und int haben die gleiche Größe.

Versuchen Sie zum Beispiel, eine Codebasis aus einer Million Zeilen nach der Zahl 4 zu durchsuchen – es ist nicht das Beste, mehrere Tage damit zu verbringen. Genauso wenig wie der Prozess, diesen subtilen Fehler zu finden, bei dem der Speicherplatz, den Sie zwei Zeigern zugewiesen haben, plötzlich nur noch für einen einzigen Zeiger ausreicht.

Oder versuchen Sie, das Problem in diesem Code zu sehen:

std::pair<std::string, std::string> splitOnFirstComma(std::string const& input) {
  unsigned position = input.find(',');
  if (position == std::string::npos) {
    return std::make_pair(input, "");
  }
  std::string first = input.substr(0, position);
  std::string second = input.substr(position+1, std::string::npos);
  return std::make_pair(first, second);
}

unsigned ist ein vorzeichenloser int , die normalerweise 32-Bit hat. Vergleichen Sie es mit dem 64-Bit npos schlägt dann immer fehl, was einen dieser fiesen subtilen Fehler einführt, die wir alle so sehr lieben.

All diese Kleinigkeiten müssen beim Umschalten des Compilers berücksichtigt, gefunden und behoben werden. Dies ist normalerweise eine Reihe kleiner, isolierter Refactorings. Sofern Sie kein proprietäres Framework verwenden, das mit Ihrem alten Compiler und Ihrer IDE geliefert wird, ist dieses für den neueren Compiler, zu dem Sie wechseln möchten, nicht verfügbar. Dann kann das Wechseln des Compilers ein großes Projekt für sich werden.

Kontinuierliche Integration

Das Ausführen aller Tests, die noch keine echten Unit-Tests sind, und aller statischen Analysetools kann einige Zeit in Anspruch nehmen. Ich habe an Projekten gearbeitet, bei denen die Kompilierung von Grund auf eine halbe Stunde gedauert hat, „Unit“-Tests eine weitere Stunde und die statische Analyse ebenfalls in dieser Größenordnung lag.

Wir können es uns nicht leisten, dies mehrmals täglich auf unseren lokalen Rechnern auszuführen. Daher führen wir in der Regel eine reduzierte Testsuite und nur inkrementelle Builds aus. Es ist jedoch entscheidend, den vollständigen Build von Grund auf neu auszuführen, alle Tests und statischen Analysen so oft wie möglich, insbesondere wenn wir umgestalten. Um dies zu erreichen, kann sich die Verwendung eines Continuous Integration (CI)-Servers als sehr nützlich erweisen.

Ich selbst habe Jenkins hauptsächlich in Unternehmensumgebungen eingesetzt. Für viele GitHub C++-Projekte ist Travis CI eine natürliche Wahl. Aber es gibt auch eine Menge anderer Optionen, siehe zum Beispiel diesen Beitrag auf code-maze.com.

Refactoring ohne Toolunterstützung

Was ist, wenn wir mit unserem alten Compiler festsitzen und keine Unterstützung von ausgefallenen Tools haben? Nun, ein Werkzeug steht uns noch zur Verfügung:Der Compiler selbst. Mit sehr kleinen Schritten in der richtigen Reihenfolge können wir die Syntaxprüfungen nutzen, die der Compiler bereitstellt zu tun.

Wenn wir beispielsweise alle Verwendungen einer Funktion finden möchten, benennen Sie einfach ihre Deklaration und Definition um und kompilieren Sie. Der Compiler wird sich bei jeder Verwendung dieser Funktion über unbekannte Funktionsnamen beschweren. Dies setzt natürlich voraus, dass Sie keine andere Deklaration mit demselben Namen haben.

Mit C++11 können wir final hinzufügen zu einer virtuellen Funktion in der Basisklasse, um alle Klassen zu finden, die die Funktion überschreiben – der Compiler muss sich über jede einzelne von ihnen beschweren.

Beispiel:Funktion ausklammern

Lassen Sie mich diesen Beitrag mit einem Schritt-für-Schritt-Beispiel abschließen, um Hilfe vom Compiler zu erhalten, während Sie eine Funktion ausklammern. Betrachten Sie diesen Originalcode:

std::shared_ptr<Node> createTree(TreeData const& data) {
  auto rootData = data.root();
  auto newNode = std::make_shared<Node>();
  newNode->configure(rootData);
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData));
  }
  return newNode;
}

Die Zeilen 2-4 wollen wir in eine eigene Funktion createNode auslagern . Ich gehe von einem C++11-konformen Compiler aus, aber ähnliche Dinge können auch mit älteren Compilern gemacht werden.

Der erste Schritt besteht darin, einen zusätzlichen Geltungsbereich um die betreffenden Zeilen hinzuzufügen, um zu sehen, welche Entitäten in der neuen Funktion erstellt und außerhalb davon verwendet werden. Dies sind die Rückgabewerte:

std::shared_ptr<Node> createTree(TreeData const& data) {
  {
    auto rootData = data.root();
    auto newNode = std::make_shared<Node>();
    newNode->configure(rootData);
  }
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData)); //ERROR: newNode was not declared...
  }
  return newNode;
}

Unsere Funktion muss also newNode zurückgeben . Der nächste Schritt besteht darin, unseren Code erneut zu kompilieren, indem der neue Bereich in ein Lambda eingefügt wird. Wir können dem Lambda bereits den Namen der neuen Funktion geben:

std::shared_ptr<Node> createTree(TreeData const& data) {
  auto createNode = [&]{
    auto rootData = data.root();
    auto newNode = std::make_shared<Node>();
    newNode->configure(rootData);
    return newNode;
  };
  auto newNode = createNode();
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData));
  }
  return newNode;
}

Die Erfassung durch Verweis macht alle Variablen, die vor dem Lambda definiert wurden, darin zugänglich. Welche das sind, erfahren Sie als Nächstes, indem Sie einfach die Erfassung entfernen:

std::shared_ptr<Node> createTree(TreeData const& data) {
  auto createNode = []{
    auto rootData = data.root(); //ERROR: data is not captured
    auto newNode = std::make_shared<Node>();
    newNode->configure(rootData);
    return newNode;
  };
  auto newNode = createNode();
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData));
  }
  return newNode;
}

Wir müssen also data erhalten in unsere Funktion. Dies kann erreicht werden, indem man es zu einem Parameter macht und es explizit an den Aufruf übergibt:

std::shared_ptr<Node> createTree(TreeData const& data) {
  auto createNode = [](TreeData const& data){
    auto rootData = data.root();
    auto newNode = std::make_shared<Node>();
    newNode->configure(rootData);
    return newNode;
  };
  auto newNode = createNode(data);
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData));
  }
  return newNode;
}

Jetzt haben wir keine Abhängigkeiten des Lambda zu seinem äußeren Geltungsbereich und umgekehrt. Das heißt, wir können es als echte Funktion extrahieren:

auto createNode(TreeData const& data) {
  auto rootData = data.root();
  auto newNode = std::make_shared<Node>();
  newNode->configure(rootData);
  return newNode;
}

std::shared_ptr<Node> createTree(TreeData const& data) {
  auto newNode = createNode(data);
  for (auto&& subTreeData : data.children()) {
    newNode->add(createTree(subTreeData));
  }
  return newNode;
}

Je nach Bedarf können wir jetzt noch etwas weiter polieren, z.B. Angabe des Rückgabetyps von createNode und mit rootData als Parameter anstelle von data . Die Hauptaufgabe des Extrahierens der Funktion wird jedoch erledigt, indem man sich einfach darauf verlässt, dass der Compiler uns sagt, was zu tun ist, indem er Compilerfehler auf die richtige Weise auslöst.

Schlussfolgerung

Tools, die uns beim Refactoring und der Analyse unserer Legacy-Codebasis helfen, sind wichtig für das notwendige Refactoring. Es ist jedoch möglich, wenn auch mühsam, unseren Code auch ohne solche Tools umzugestalten. Es gibt also keine wirkliche Entschuldigung dafür, unseren alten Code für ein weiteres Jahrzehnt verrotten zu lassen.