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.
- Rainer Grimm
Ein Nullobjekt kapselt ein Nichtstun-Verhalten innerhalb eines Objekts. Es ist oft sehr angenehm, ein Nullobjekt zu verwenden.
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.
Strategized Locking
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.
Vorteile
- 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.
Nachteile
- Laufzeit-Polymorphismus
- benötigt eine zusätzliche Zeiger- oder Referenzindirektion,
- kann eine tiefe Ableitungshierarchie erzeugen.
- Compile-Zeit-Polymorphismus
- kann sehr wortreiche Fehlermeldungen erzeugen.
Implementierung basierend auf Laufzeit-Polymorphismus
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.
Implementierung basierend auf Compilezeit-Polymorphismus
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.
Das Concept BasicLockable
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).
Wie geht's weiter?
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)