A null object encapsulates a do-nothing behavior within an object. It is often very convenient to use a null object.


Rainer Grimm has been working as a software architect, team leader and training manager for many years. He likes to write articles on the programming languages ​​C++, Python and Haskell, but also likes to speak frequently at specialist conferences. On his blog Modernes C++ he deals intensively with his passion for C++.

A null object

  • encapsulates the do-nothing behavior within an object,
  • supports workflow without conditional logic and
  • hides the special use cases from the user.

To be honest, there isn’t much to write about the null object. So I want to present an example using a null object.

Suppose you implement a library to be used in various areas such as concurrency. To be on the safe side, protect the critical sections with a lock. If the library is now used in a single-threaded environment, a performance problem arises because you have implemented an expensive synchronization mechanism that is unnecessary. In this case, Strategized Locking helps.

Strategized locking is the application of the strategy pattern applied to locks. This means putting the locking strategy in an object and making it an interchangeable component of the system.

There are two typical ways to implement Strategized Locking: run-time polymorphism (object orientation) or compile-time polymorphism (templates).

Both ways improve the customization and extension of the locking strategy, make the system easier to maintain, and support the reuse of components. In addition, the implementations of the locking strategy at runtime or at compile time differ in various aspects.

  • allows to configure the locking strategy at runtime,
  • is easier to understand for developers with an object-oriented background.
  • compile-time polymorphism
  • has no additional runtime costs,
  • has flat hierarchies.
  • needs an additional pointer or reference indirection,
  • can generate a deep derivation hierarchy.
  • compile-time polymorphism
  • can generate very verbose error messages.

The program strategizedLockingRuntime.cpp introduces three different mutexes.

// 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';
    
}

The class StrategizedLocking has a lock (1). StrategizedLocking models scoped locking and therefore locks the mutex in the constructor (2) and unlocks it again in the destructor (3). Lock is an abstract class and defines the interface of all derived classes. These are the classes NoLock (4), ExclusiveLock (5) and SharedLock (6). SharedLock calls lock_shared (7) and unlock_shared (8) on his std::shared_mutex on. Each of these locks owns one of the mutexes NullObjectMutex (9), std::mutex (10) or std::shared_mutex (11). NullObjectMutex is a noop placeholder. The mutexes are declared mutable. Hence they are in constant member functions like lock and unlock usable.

The template-based implementation is very similar to the object-oriented implementation.

// 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';

}

The programs strategizedLockingRuntime.cpp and strategizedLockingCompileTime.cpp produce the same output:



the locks NoLock (1), ExclusiveLock (2) and SharedLock (3) have no abstract base class. This has the consequence that StrategizedLocking can be instantiated with an object that does not support the correct interface. This instantiation inevitably leads to a compile-time error. This loophole can be closed elegantly in C++20 with concepts.

Instead of template <typename Lock> class StrategizedLocking the concept BasicLockable: template <BasicLockable Lock> class StrategizedLocking use. This means that all locks used are the Concept BasicLockable have to support. A concept is a named requirement, and many concepts are already defined in the C++20 Concepts Library. The Concept BasicLockable only used in the text of the C++20 standard. Therefore I define and use the concept BasicLockable in the following improved compile-time implementation of strategized locking.

// 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) assumes that an object lo of data type T the member functions lock and unlock must support. Using the concept is easy. Instead of typename I use the concept BasicLockable in the template declaration of StrategizedLocking (2).

To use a user-defined data type in a range-based for loop, it must implement the iterator protocol. I’ll go into more detail about the iterator protocol in my next article.


(rm)

To home page

California18

Welcome to California18, your number one source for Breaking News from the World. We’re dedicated to giving you the very best of News.

Leave a Reply