Blog 11 Min. Lesezeit

Die 7 am häufigsten verwendeten C++-Idiome (Teil 2)

Diesen Artikel teilen
Top 7 Most Commonly Used C++ Idioms (Part 2)

Imersten Teilhaben wir die Idiome RAII und Pimpl kennengelernt. In diesem zweiten Teil beschäftigen wir uns mit CRTP, Copy-and-Swap und Type Erasure.

Bevor wir uns diesen interessanten Idiomen widmen, betrachten wir zunächst die Vorteile, die das Beherrschen von C++-Idiomen bietet, und warum es sich lohnt, Zeit in ihr Verständnis zu investieren:

  1. Ausdrucksstärke: Idiome ermöglichen es Entwicklern, gängige Entwurfsmuster und Lösungen innerhalb der Sprachsyntax prägnant und klar auszudrücken. Durch die Nutzung sprachspezifischer Funktionen und Konventionen kann Code lesbarer und selbsterklärender werden.
  2. Performance: Sprachspezifische Idiome nutzen häufig Funktionen, die auf hohe Performance ausgelegt sind. In C++ verwendet etwa RAII (Resource Acquisition Is Initialization) Konstruktoren und Destruktoren zur effizienten Ressourcenverwaltung, wodurch Ressourcenlecks vermieden und die Performance verbessert werden können.
  3. Sicherheit und Zuverlässigkeit: Idiome können sprachspezifische Best Practices und Sicherheitsmechanismen fördern. In Rust beispielsweise sorgen Ownership- und Borrowing-Idiome bereits zur Compile-Zeit für Speichersicherheit und verhindern typische Fehler wie Nullzeiger-Dereferenzierungen und Data Races.
  4. Kompatibilität: Idiome orientieren sich häufig an den Konventionen und dem üblichen Stil einer Sprachgemeinschaft. Wer diese Praktiken befolgt, verbessert die Kompatibilität mit bestehenden Bibliotheken, Frameworks und Coding-Standards und erleichtert die Zusammenarbeit.
  5. Wartbarkeit: Durch sprachspezifische Idiome und Konventionen wird Code konsistenter und vorhersehbarer. Das erleichtert Wartung, Debugging und Refactoring, weil erfahrene Entwickler idiomatischen Code schneller verstehen können.
  6. Performance-Optimierung: Idiome in Sprachen wie C++ und Rust ermöglichen Optimierungen durch Funktionen wie Move-Semantik, Compile-Time-Polymorphie und Zero-Cost-Abstraktionen. So lässt sich effizienter Code schreiben, ohne Lesbarkeit oder Wartbarkeit zu opfern.
  7. Tool-Unterstützung: Sprachspezifische Idiome profitieren häufig von spezialisierten Tools und statischen Analysewerkzeugen, die auf Syntax und Semantik der jeweiligen Sprache zugeschnitten sind. Sie können idiomatische Muster erkennen und durchsetzen, potenzielle Probleme aufdecken und automatisierte Refactorings anbieten.

Zusammenfassend bieten Idiome einer Programmiersprache Vorteile bei Ausdrucksstärke, Performance, Sicherheit, Kompatibilität, Wartbarkeit, Optimierung und Tool-Unterstützung. Idiomatischer Code ist im jeweiligen Sprachökosystem effizient, zuverlässig und leichter zu warten.

Setzen wir unsere Erkundung der sieben Idiome fort:

3- Curiously Recurring Template Pattern (CRTP)

Das Curiously Recurring Template Pattern (CRTP) ist ein fortgeschrittenes, templatebasiertes Entwurfsmuster in C++. Es kombiniert Vererbung und statische Polymorphie, um Polymorphie zur Compile-Zeit zu ermöglichen. Da Methodenaufrufe bereits beim Kompilieren aufgelöst werden, erlaubt CRTP Optimierungen und Inlining. Gleichzeitig entfällt der Laufzeit-Overhead virtueller Funktionsaufrufe, was das Muster für performancekritischen Code interessant macht.

CRTP wird häufig in Bibliotheken und Frameworks eingesetzt, etwa für Mixin-Klassen, statische Schnittstellen und typsichere Callback-Mechanismen. Auch einige Entwurfsmuster nutzen CRTP, beispielsweise das Curiously Recurring Template Singleton Pattern (CRSP) zur Implementierung von Singletons.

Hier ist ein einfaches Beispiel, das CRTP veranschaulicht:

template <typename Derived>
class Base {
public:
    void commonFunction() {
        // Base class implementation
        static_cast<Derived*>(this)->specificFunction(); // Call derived class method
    }

    // Virtual destructor to ensure correct destruction
    virtual ~Base() = default;
};

class DerivedClass : public Base<DerivedClass> {
public:
    void specificFunction() {
        // Derived class implementation
        // ...
    }
};

int main() {
    DerivedClass d;
    d.commonFunction(); // Calls both base and derived class methods
    return 0;
}

In diesem Beispiel dientBase ist eine Template-Klasse, die als Basisklasse dient, und DerivedClass inherits from Base with itself as the template argument. Baseenthält eine MethodecommonFunction() that calls specificFunction()die in der abgeleiteten Klasse definiert ist. Wenn eine Instanz vonDerivedClassaufruftcommonFunction(), it invokes both the base class's and the derived class's implementations.

Zusammengefasst funktioniert CRTP folgendermaßen:

  1. Grundstruktur: Bei CRTP wird ein Basisklassen-Template definiert, dessen Template-Parameter die abgeleitete Klasse repräsentiert. Die abgeleitete Klasse erbt anschließend von dieser Basisklasse und übergibt ihren eigenen Typ als Template-Argument.
  2. Implementierung: Das Basisklassen-Template enthält typischerweise Methoden oder Member, die von der Funktionalität der abgeleiteten Klasse abhängen. Über statische Polymorphie können sie Methoden der abgeleiteten Klasse aufrufen.
  3. Verwendung: Die abgeleitete Klasse stellt eigene Implementierungen der vom Basisklassen-Template benötigten Methoden bereit. Dadurch kann sie das Verhalten anpassen und zugleich die gemeinsame Funktionalität der Basisklasse nutzen.

4 – Copy-and-SwapIdiom

Das Copy-and-Swap-Idiom ist eine C++-Technik zur Implementierung des Copy-Assignment-Operators mit starker Exception-Safety-Garantie. Dabei wird der Inhalt des aktuellen Objekts mit einer Kopie des zuzuweisenden Objekts vertauscht. So entsteht eine robuste und ausnahmesichere Zuweisung.

So funktioniert das Copy-and-Swap-Idiom:

  1. Eine Swap-Funktion definieren: Zunächst definieren Sie für Ihre Klasse eine Swap-Funktion. Sie vertauscht den internen Zustand des aktuellen Objekts mit dem eines anderen Objekts desselben Typs.
  2. Den Copy-Assignment-Operator implementieren: Implement the copy assignment operator (operator=) für Ihre Klasse. Statt die Zuweisung direkt auszuführen, erstellen Sie eine Kopie des Objekts auf der rechten Seite, tauschen deren Inhalt mithilfe der swap-Funktion mit dem aktuellen Objekt aus und überlassen dem Destruktor des temporären Objekts die Bereinigung der ausgetauschten Inhalte.
  3. Exception Safety: Da sowohl die Swap-Operation als auch die Zerstörung temporärer Objekte innerhalb des Copy-Assignment-Operators erfolgen, bleibt der Zustand des aktuellen Objekts bei einer Exception unverändert. Damit wird die starke Exception-Safety-Garantie erreicht.

Hier ist ein einfaches Beispiel für das Copy-and-Swap-Idiom:

#include <algorithm> // For std::swap

class MyClass {
private:
    int* data;
    size_t size;

public:
    // Constructor
    MyClass(size_t size) : size(size), data(new int[size]) {}

    // Destructor
    ~MyClass() {
        delete[] data;
    }

    // Copy constructor
    MyClass(const MyClass& other) : size(other.size), data(new int[other.size]) {
        std::copy(other.data, other.data + size, data);
    }

    // Swap function
    friend void swap(MyClass& first, MyClass& second) noexcept {
        using std::swap;
        swap(first.data, second.data);
        swap(first.size, second.size);
    }

    // Copy assignment operator using Copy and Swap idiom
    MyClass& operator=(MyClass other) noexcept {
        swap(*this, other); // Swap contents with a copy of 'other'
        return *this;
    }
};

In this example, the swapFunktion vertauscht diedata and sizeMember zweierMyClassObjekte. Der Copy-Assignment-Operator nutzt die Swap-Funktion, indem er eine Kopie des rechten Operanden erstellt (other), deren Inhalt mit dem aktuellen Objekt vertauscht (*this) und eine Referenz auf das aktuelle Objekt zurückgibt. Dadurch ist die Zuweisung sowohl ausnahmesicher als auch effizient.

Mit C++11 wurde jedoch die Move-Semantik eingeführt. Copy-and-Swap und Move-Semantik dienen beide der Verwaltung von Kopier- und Verschiebevorgängen, verfolgen aber unterschiedliche Ziele und werden unterschiedlich implementiert.

  1. Copy-and-Swap-Idiom:
  • Das Copy-and-Swap-Idiom ist eine Programmiertechnik zur Implementierung von Kopierzuweisungsoperatoren in C++. Dabei wird zunächst eine Kopie des zuzuweisenden Objekts erstellt und anschließend der Inhalt des aktuellen Objekts mit dieser Kopie ausgetauscht.
  • Der Copy-Konstruktor erzeugt eine Kopie des zuzuweisenden Objekts, sodass Änderungen am Original das neue Objekt nicht beeinflussen. Anschließend vertauscht die Swap-Operation die Inhalte des aktuellen und des kopierten Objekts.
  • Diese Technik bietet eine starke Exception-Safety-Garantie, da die Swap-Operation üblicherweise mit Operationen implementiert wird, die keine Exceptions auslösen.
  • Copy-and-Swap ist robust und sicher, kann bei großen oder teuer zu kopierenden Objekten jedoch unnötigen Overhead verursachen.
  1. Move-Semantik:
  • Die mit C++11 eingeführte Move-Semantik ermöglicht es, Objekte effizient von einem Ort an einen anderen zu verschieben. Dadurch können Ressourcen wie dynamisch reservierter Speicher oder Datei-Handles von temporären Objekten beziehungsweise Rvalue-Referenzen auf andere Objekte übertragen werden.
  • Anders als beim Kopieren wird beim Verschieben der Besitz an Ressourcen von einem Objekt auf ein anderes übertragen, ohne eine Kopie anzulegen. Besonders bei großen oder teuer zu kopierenden Objekten kann dies die Performance erheblich verbessern.
  • Move-Semantik wird über Move-Konstruktoren und Move-Assignment-Operatoren umgesetzt, die Ressourcen temporärer Objekte oder Rvalue-Referenzen effizient übertragen.
  • Move-Semantik ist besonders nützlich, wenn das Kopieren von Objekten teuer oder unnötig ist, etwa bei Rückgabewerten von Funktionen oder bei der Übergabe von Objekten per Wert.

Zusammenfassend sind Copy-and-Swap und Move-Semantik komplementäre C++-Techniken für das Kopieren und Verschieben von Objekten. Copy-and-Swap bietet starke Exception-Safety, kann bei großen Objekten aber zusätzlichen Aufwand verursachen. Move-Semantik steigert die Effizienz, indem Ressourcen ohne Kopie übertragen werden. Je nach Anforderungen und Eigenschaften der beteiligten Objekte lässt sich so das passende Verhältnis zwischen Sicherheit und Performance wählen.

5-Type ErasureIdiom

Type Erasure ist ein Entwurfsmuster in C++, mit dem sich polymorphes Verhalten abstrahieren lässt, ohne dass die öffentliche Schnittstelle an konkrete Typen gebunden ist. Generische Schnittstellen und Container können dadurch typsicher und flexibel mit Objekten unterschiedlicher Typen arbeiten. Besonders nützlich ist das Idiom bei heterogenen Sammlungen oder wenn die genauen Objekttypen zur Compile-Zeit nicht bekannt sind.

Die Grundidee von Type Erasure besteht darin, Objekte verschiedener Typen hinter einer gemeinsamen Schnittstelle zu kapseln. Ihre konkreten Typen werden verborgen, sodass sie einheitlich behandelt werden können. Typischerweise wird dies mit Templates und dynamischer Polymorphie umgesetzt.

Auf hoher Ebene funktioniert Type Erasure in C++ folgendermaßen:

  1. Eine konzeptionelle Schnittstelle definieren: Definieren Sie zunächst eine konzeptionelle Schnittstelle für das gemeinsame Verhalten beziehungsweise die Operationen, die Objekte unterschiedlicher Typen unterstützen sollen. Diese Schnittstelle wird häufig durch Templates oder abstrakte Klassen ausgedrückt.
  2. Einen Type-Erased-Wrapper implementieren: Erstellen Sie eine Type-Erased-Wrapper-Klasse oder ein entsprechendes Template, das Objekte verschiedener Typen aufnehmen und über die in Schritt 1 definierte gemeinsame Schnittstelle bereitstellen kann. Intern werden dafür typischerweise dynamische Speicherverwaltung und Polymorphie eingesetzt.
  3. Type-Erased-Objekte verwenden: Verwenden Sie Instanzen des Type-Erased-Wrappers überall dort, wo polymorphes Verhalten benötigt wird. Da der Wrapper eine einheitliche Schnittstelle bereitstellt, können Methoden aufgerufen und Eigenschaften verwendet werden, ohne den konkreten zugrunde liegenden Typ zu kennen.
  4. Mit Typinformationen umgehen: Optional können Mechanismen bereitgestellt werden, um Typinformationen aus den Type-Erased-Objekten abzufragen, etwa Runtime Type Information (RTTI) oder eigene Verfahren zur Typ-Introspektion.
  5. Vorteile von Type Erasure: Type Erasure bietet Flexibilität und Typsicherheit, weil sich heterogene Sammlungen und generische Algorithmen ohne komplexe Vererbungshierarchien implementieren lassen. Die Schnittstelle wird von der Implementierung entkoppelt, wodurch Codebasen leichter wartbar und erweiterbar werden.

Ein bekanntes Beispiel für Type Erasure in C++ ist die Verwendung vonstd::functionaus der C++-Standardbibliothek.std::functionkann aufrufbare Objekte unterschiedlicher Typen aufnehmen, beispielsweise Funktionen, Lambdas oder Funktionsobjekte, und stellt sie über eine gemeinsame Schnittstelle bereit, sodass sie einheitlich aufgerufen werden können.

Type Erasure ist eine leistungsfähige und vielseitige C++-Technik, kann durch dynamische Speicherallokation und virtuelle Funktionsaufrufe jedoch zusätzlichen Overhead verursachen. Wie bei jedem Entwurfsmuster sollten die Trade-offs anhand der konkreten Anforderungen und Einschränkungen der Anwendung abgewogen werden.

Hier ist ein vereinfachtes C++-Beispiel für Type Erasure. Ein Type-Erased-Wrapper speichert und verarbeitet Objekte verschiedener Typen über eine gemeinsame Schnittstelle:

#include <iostream>
#include <memory> // For std::unique_ptr

// Interface representing the common behavior for all types
class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

// Concrete implementation of the Shape interface for a Circle
class Circle : public Shape {
private:
    double radius;

public:
    Circle(double r) : radius(r) {}

    double area() const override {
        return 3.14159 * radius * radius;
    }
};

// Concrete implementation of the Shape interface for a Rectangle
class Rectangle : public Shape {
private:
    double width;
    double height;

public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

// Type-erased wrapper class to hold objects of different types
class AnyShape {
private:
    // Pointer to the base class (Shape), allowing polymorphic behavior
    std::unique_ptr<Shape> ptr;

public:
    // Constructor taking any object that implements the Shape interface
    template<typename T>
    AnyShape(T shape) : ptr(std::make_unique<Model<T>>(std::move(shape))) {}

    // Delegate area() method call to the underlying object
    double area() const {
        return ptr->area();
    }

private:
    // Private inner class (model) to hold objects of any type
    template<typename T>
    class Model : public Shape {
    private:
        T shape;

    public:
        Model(T s) : shape(std::move(s)) {}

        double area() const override {
            return shape.area();
        }
    };
};

int main() {
    // Create instances of different shapes
    Circle circle(5.0);
    Rectangle rectangle(4.0, 6.0);

    // Create type-erased wrapper objects
    AnyShape anyCircle(circle);
    AnyShape anyRectangle(rectangle);

    // Call the area() method on the type-erased objects
    std::cout << "Circle area: " << anyCircle.area() << std::endl;
    std::cout << "Rectangle area: " << anyRectangle.area() << std::endl;

    return 0;
}

In diesem Beispiel gibt es eine SchnittstelleShapemit einer rein virtuellen Funktionarea()die das gemeinsame Verhalten aller Formen repräsentiert. Anschließend gibt es konkrete Implementierungen derShapeSchnittstelle fürCircle and Rectangle.

The AnyShapeKlasse dient als Type-Erased-Wrapper für Objekte unterschiedlicher Typen, welche dieShapeSchnittstelle implementieren. Mithilfe von Template-Metaprogrammierung wird für jeden gespeicherten Typ eine innere Klasse (Model) erzeugt. Diese innere Klasse delegiert denarea()Methodenaufruf an das zugrunde liegende Objekt. Abschließend zeigt diemain()Funktion, wie Instanzen verschiedener Formen erzeugt, inAnyShapeObjekte verpackt und diearea()Methode einheitlich auf ihnen aufgerufen wird.

Diesen Artikel teilen