Patterns in der Softwareentwicklung: Das Nullobjekt-Entwurfsmuster

Das Verhaltensmuster Nullobjekt kapselt ein Nichtstun-Verhalten innerhalb eines Objekts. Es ist oft sehr angenehm, ein solches Nullobjekt zu verwenden.

In Pocket speichern vorlesen Druckansicht 32 Kommentare lesen

(Bild: Piyawat Nandeenopparit/Shutterstock.com)

Lesezeit: 4 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Ein Nullobjekt kapselt ein Nichtstun-Verhalten innerhalb eines Objekts. Es ist oft sehr angenehm, ein Nullobjekt zu verwenden.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Ein Null-Objekt

  • kapselt das Nichtstun-Verhalten innerhalb eines Objekts,
  • unterstützt den Workflow ohne bedingte Logik und
  • verbirgt die speziellen Anwendungsfälle vor dem Anwender.

Ehrlich gesagt gibt es nicht viel über das Nullobjekt zu schreiben. Deshalb möchte ich ein Beispiel vorstellen, in dem ein Nullobjekt zum Einsatz kommt.

Angenommen, man implementiert eine Bibliothek, die in verschiedenen Bereichen wie Concurrency eingesetzt werden soll. Um auf Nummer sicher zu gehen, schützt man die kritischen Abschnitte mit einem Lock. Wenn die Bibliothek nun in einer Single-Thread-Umgebung verwendet wird, ergibt sich ein Performanzproblem, weil man einen teuren Synchronisationsmechanismus implementiert hat, der unnötig ist. In diesem Fall hilft Strategized Locking.

Strategized Locking ist die Anwendung des Strategiemusters auf Locks angewandt. Das bedeutet, dass man die Lockingstrategie in ein Objekt packt und es zu einer austauschbaren Komponente des Systems macht.

Es gibt zwei typische Möglichkeiten, Strategized Locking zu implementieren: Polymorphismus zur Laufzeit (Objektorientierung) oder Polymorphismus zur Compilezeit (Templates).

Beide Wege verbessern die Anpassung und Erweiterung der Locking-Strategie, erleichtern die Pflege des Systems und unterstützen die Wiederverwendung von Komponenten. Zusätzlich unterscheiden sich die Implementierungen der Locking-Strategie zur Laufzeit oder zur Compile-Zeit in verschiedenen Aspekten.

  • Laufzeit-Polymorphismus
  • ermöglicht es, die Locking-Strategie während der Laufzeit zu konfigurieren,
  • ist für Entwickler mit objektorientiertem Hintergrund leichter zu verstehen.
  • Compile-Zeit-Polymorphismus
  • besitzt keine zusätzlichen Laufzeitkosten,
  • besitzt flache Hierarchien.
  • Laufzeit-Polymorphismus
  • benötigt eine zusätzliche Zeiger- oder Referenzindirektion,
  • kann eine tiefe Ableitungshierarchie erzeugen.
  • Compile-Zeit-Polymorphismus
  • kann sehr wortreiche Fehlermeldungen erzeugen.

Das Programm strategizedLockingRuntime.cpp stellt drei verschiedene Mutexe vor.

// strategizedLockingRuntime.cpp

#include <iostream>
#include <mutex>
#include <shared_mutex>

class Lock {
public:
    virtual void lock() const = 0;
    virtual void unlock() const = 0;
};

class StrategizedLocking {
    Lock& lock;                                  // (1)
public:
    StrategizedLocking(Lock& l): lock(l){
        lock.lock();                             // (2)
    }
    ~StrategizedLocking(){
        lock.unlock();                           // (3)
    }
};

struct NullObjectMutex{                          
    void lock(){}
    void unlock(){}
};

class NoLock : public Lock {                     // (4)
    void lock() const override {
        std::cout << "NoLock::lock: " << '\n';
        nullObjectMutex.lock();
    }
    void unlock() const override {
        std::cout << "NoLock::unlock: " << '\n';
         nullObjectMutex.unlock();
    }
    mutable NullObjectMutex nullObjectMutex;     // (9)
};

class ExclusiveLock : public Lock {              // (5)
    void lock() const override {
        std::cout << "    ExclusiveLock::lock: " << '\n';
        mutex.lock();
    }
    void unlock() const override {
        std::cout << "    ExclusiveLock::unlock: " << '\n';
        mutex.unlock();
    }
    mutable std::mutex mutex;                    // (10)
};

class SharedLock : public Lock {                 // (6)
    void lock() const override {
        std::cout << "        SharedLock::lock_shared: " << '\n';
        sharedMutex.lock_shared();                // (7)
    }
    void unlock() const override {
        std::cout << "        SharedLock::unlock_shared: " << '\n';
        sharedMutex.unlock_shared();              // (8)
    }
    mutable std::shared_mutex sharedMutex;        // (11)
};

int main() {
    
    std::cout << '\n';
    
    NoLock noLock;
    StrategizedLocking stratLock1{noLock};
    
    {
        ExclusiveLock exLock;
        StrategizedLocking stratLock2{exLock};
        {
            SharedLock sharLock;
            StrategizedLocking startLock3{sharLock};
        }
    }
    
    std::cout << '\n';
    
}

Die Klasse StrategizedLocking hat ein Lock (1). StrategizedLocking modelliert Scoped Locking und sperrt daher den Mutex im Konstruktor (2) und gibt ihn im Destruktor (3) wieder frei. Lock ist eine abstrakte Klasse und definiert die Schnittstelle aller abgeleiteten Klassen. Dies sind die Klassen NoLock (4), ExclusiveLock (5) und SharedLock (6). SharedLock ruft lock_shared (7) und unlock_shared (8) auf seinem std::shared_mutex auf. Jede dieser Locks besitzt einen der Mutexe NullObjectMutex (9), std::mutex (10) oder std::shared_mutex (11). NullObjectMutex ist ein Noop-Platzhalter. Die Mutexe werden als veränderbar deklariert. Daher sind sie in konstanten Mitgliedsfunktionen wie lock und unlock verwendbar.

Die Template-basierte Implementierung ist der objektorientierten Implementierung sehr ähnlich.

// strategizedLockingCompileTime.cpp

#include <iostream>
#include <mutex>
#include <shared_mutex>


template <typename Lock>
class StrategizedLocking {
    Lock& lock;
public:
    StrategizedLocking(Lock& l): lock(l){
        lock.lock();
    }
    ~StrategizedLocking(){
        lock.unlock();
    }
};

struct NullObjectMutex {
    void lock(){}
    void unlock(){}
};

class NoLock{ // (1)
public:
    void lock() const {
        std::cout << "NoLock::lock: " << '\n';
        nullObjectMutex.lock();
    }
    void unlock() const {
        std::cout << "NoLock::unlock: " << '\n';
         nullObjectMutex.lock();
    }
    mutable NullObjectMutex nullObjectMutex;
};

class ExclusiveLock { // (2)
public:
    void lock() const {
        std::cout << " ExclusiveLock::lock: " << '\n';
        mutex.lock();
    }
    void unlock() const {
        std::cout << " ExclusiveLock::unlock: " << '\n';
        mutex.unlock();
    }
    mutable std::mutex mutex;
};

class SharedLock { // (3)
public:
    void lock() const {
        std::cout << " SharedLock::lock_shared: " << '\n';
        sharedMutex.lock_shared();
    }
    void unlock() const {
        std::cout << " SharedLock::unlock_shared: " << '\n';
        sharedMutex.unlock_shared();
    }
    mutable std::shared_mutex sharedMutex;
};

int main() {

    std::cout << '\n';
    
    NoLock noLock;
    StrategizedLocking<NoLock> stratLock1{noLock};
    
    {
        ExclusiveLock exLock;
        StrategizedLocking<ExclusiveLock> stratLock2{exLock};
        {
            SharedLock sharLock;
            StrategizedLocking<SharedLock> startLock3{sharLock};
        }
    }
    
    std::cout << '\n';

}

Die Programme strategizedLockingRuntime.cpp und strategizedLockingCompileTime.cpp erzeugen die gleiche Ausgabe:

Die Locks NoLock (1), ExclusiveLock (2) und SharedLock (3) haben keine abstrakte Basisklasse. Das hat zur Folge, dass StrategizedLocking mit einem Objekt instanziiert werden kann, das nicht die richtige Schnittstelle unterstützt. Diese Instanziierung führt zwangsläufig zu einem Fehler beim Kompilieren. Dieses Schlupfloch lässt sich in C++20 elegant mit Concepts schließen.

Anstelle von template <typename Lock> class StrategizedLocking lässt sich das Concept BasicLockable: template <BasicLockable Lock> class StrategizedLocking verwenden. Das bedeutet, dass alle verwendeten Locks das Concept BasicLockable unterstützen müssen. Ein Concept ist eine benannte Anforderung, und viele Concepts sind bereits in der C++20 Concepts Library definiert. Das Concept BasicLockable wird nur im Text des C++20-Standards verwendet. Daher definiere und verwende ich das Concept BasicLockable in der folgenden verbesserten Implementierung des Strategized Locking zur Compile-Zeit.

// strategizedLockingCompileTimeWithConcepts.cpp

#include <iostream>
#include <mutex>
#include <shared_mutex>

template <typename T>                     // (1)
concept BasicLockable = requires(T lo) {
    lo.lock();
    lo.unlock();
};
    
template <BasicLockable Lock>             // (2)
class StrategizedLocking {
    Lock& lock;
public:
    StrategizedLocking(Lock& l): lock(l){
        lock.lock();
    }
    ~StrategizedLocking(){
        lock.unlock();
    }
};

struct NullObjectMutex {
    void lock(){}
    void unlock(){}
};

class NoLock{
public:
    void lock() const {
        std::cout << "NoLock::lock: " << '\n';
        nullObjectMutex.lock();
    }
    void unlock() const {
        std::cout << "NoLock::unlock: " << '\n';
         nullObjectMutex.lock();
    }
    mutable NullObjectMutex nullObjectMutex;
};

class ExclusiveLock {
public:
    void lock() const {
        std::cout << "    ExclusiveLock::lock: " << '\n';
        mutex.lock();
    }
    void unlock() const {
        std::cout << "    ExclusiveLock::unlock: " << '\n';
        mutex.unlock();
    }
    mutable std::mutex mutex;
};

class SharedLock {
public:
    void lock() const {
        std::cout << "        SharedLock::lock_shared: " << '\n';
        sharedMutex.lock_shared();
    }
    void unlock() const {
        std::cout << "        SharedLock::unlock_shared: " << '\n';
        sharedMutex.unlock_shared();
    }
    mutable std::shared_mutex sharedMutex;
};

int main() {

    std::cout << '\n';
    
    NoLock noLock;
    StrategizedLocking<NoLock> stratLock1{noLock};
    
    {
        ExclusiveLock exLock;
        StrategizedLocking<ExclusiveLock> stratLock2{exLock};
        {
            SharedLock sharLock;
            StrategizedLocking<SharedLock> startLock3{sharLock};
        }
    }
    
    std::cout << '\n';

}

BasicLockable in (1) setzt voraus, dass ein Objekt lo vom Datentyp T die Member-Funktionen lock und unlock unterstützen muss. Die Verwendung des Concepts ist einfach. Anstelle von typename verwende ich das Concept BasicLockable in der Template-Deklaration von StrategizedLocking (2).

Um einen benutzerdefinierten Datentyp in einer range-based for-Schleife zu verwenden, muss dieser das Iterator-Protokoll umsetzen. In meinem nächsten Artikel gehe ich genauer auf das Iterator-Protokoll ein. (rme)