Locks as Permits
So what to do? Ideally, the BankAccount class should do the following:
Support both locking models (internal and external).
Be efficient; that is, use no unnecessary locking.
Be safe; that is, BankAccount objects cannot be manipulated without appropriate locking.
Let's make a worthwhile observation: Whenever you lock a BankAccount, you do so by using a Lock<BankAccount> object. Turning this statement around, wherever there's a Lock<BankAccount>, there's also a locked BankAccount somewhere. Thus, you can think ofand usea Lock<BankAccount> object as a permit. Owning a Lock<BankAccount> gives you rights to do certain things. The Lock<BankAccount> object should not be copied or aliased (it's not a transmissible permit). As long as a permit is still alive, the BankAccount object stays locked. When the Lock<BankAccount> is destroyed, the BankAccount's mutex is released.
The net effect is that at any point in your code, having access to a Lock<BankAccount> object guarantees that a BankAccount is locked. (You don't know exactly which BankAccount is locked, howeveran issue that we'll address soon.)
For now, let's make a couple of enhancements to the Lock class template defined in the previous section. We'll call the enhanced version StrictLock. Essentially, a StrictLock's role is only to live on the stack as an automatic variable. StrictLock must adhere to a non-copy and non-alias policy. StrictLock disables copying by making the copy constructor and the assignment operator private. While we're at it, let's disable operator new and operator delete; StrictLocks are not intended to be allocated on the heap. StrictLock avoids aliasing by using a slightly less orthodox and less well-known technique: disable address taking.
template <class T> class StrictLock { T& obj_; StrictLock(); // disable default constructor StrictLock(const StrictLock&); // disable copy constructor StrictLock& operator=(const StrictLock&); // disable assignment void* operator new(std::size_t); // disable heap allocation void* operator new[](std::size_t); void operator delete(void*); void operator delete[](void*); StrictLock* operator&(); // disable address taking public: StrictLock(T& obj) : obj_(obj) { obj.AcquireMutex(); } ~StrictLock() { obj_.ReleaseMutex(); } };
Silence can be sometimes louder than wordswhat's forbidden to do with a StrictLock is as important as what you can do. Let's see what you can and what you cannot do with a StrictLock instantiation:
You can create a StrictLock<T> only starting from a valid T object. Notice that there is no other way you can create a StrictLock<T>.
BankAccount myAccount("John Doe", "123-45-6789"); StrictLock<BankAccount> myLock(myAccount); // ok
You cannot copy StrictLocks to one another. In particular, you cannot pass StrictLocks by value to functions or have them returned by functions:
extern StrictLock<BankAccount> Foo(); // compile-time error extern void Bar(StrictLock<BankAccount>); // compile-time error
However, you still can pass StrictLocks by reference to and from functions:
// ok, Foo returns a reference to Lock<BankAccount> extern StrictLock<BankAccount>& Foo(); // ok, Bar takes a reference to Lock<BankAccount> extern void Bar(StrictLock<BankAccount>&);
You cannot allocate a StrictLock on the heap. However, you still can put StrictLocks on the heap if they're members of a class.
StrictLock<BankAccount>* pL = new StrictLock<BankAccount>(myAcount); //error! // operator new is not accessible class Wrapper { Lock memberLock_; ... }; Wrapper* pW = new Wrapper; // ok
(Making StrictLock a member variable of a class is not recommended. Fortunately, disabling copying and default construction makes StrictLock quite an unfriendly member variable.)
You cannot take the address of a StrictLock object. This interesting feature, implemented by disabling unary operator&, makes it very unlikely to alias a StrictLock object. Aliasing is still possible by taking references to a StrictLock:
Lock<BankAccount> myLock(myAccount); // ok Lock<BankAccount>* pAlias = &myLock; // error! // Lock<BankAccount>::operator& is not accessible Lock<BankAccount>& rAlias = myLock; // ok
Fortunately, references don't engender as bad aliasing as pointers because they're much less versatile (references cannot be copied or reseated).
You can even make Lock final; that is, impossible to derive from. This task is left in the form of an exercise to the reader.
All these rules were put in place with one purposeenforcing that owning a StrictLock<T> is a reasonably strong guarantee that (1) you locked a T object, and (2) that object will be unlocked at a later point.
Now that we have such a strict StrictLock, how do we harness its power in defining a safe, flexible interface for BankAccount? The idea is as follows:
Each of BankAccount's interface functions (in our case, Deposit and Withdraw) comes in two overloaded variants.
One version keeps the same signature as before, and the other takes an additional argument of type StrictLock<BankAccount>. The first version is internally locked; the second one requires external locking. External locking is enforced at compile time by requiring client code to create a StrictLock<BankAccount> object.
BankAccount avoids code bloating by having the internal locked functions forward to the external locked functions, which do the actual job.
A little code is worth 1,000 words, a (hacked into) saying goes, so here's the new BankAccount class:
class BankAccount { int balance_; public: void Deposit(int amount, StrictLock<BankAccount>&) { // Externally locked balance_ += amount; } void Deposit(int amount) { Lock guard(*this); // Internally locked Deposit(amount, guard); } void Withdraw(int amount, StrictLock<BankAccount>&) { // Externally locked balance_ -= amount; } void Withdraw(int amount) { Lock guard(*this); // Internally locked Withdraw(amount, guard); } };
Now, if you want the benefit of internal locking, you simply call Deposit(int) and Withdraw(int). If you want to use external locking, you lock the object by constructing a StrictLock<BankAccount> and then you call Deposit(int, StrictLock<BankAccount>&) and Withdraw(int, StrictLock<BankAccount>&). For example, here's the ATMWithdrawal function implemented correctly:
void ATMWithdrawal(BankAccount& acct, int sum) { StrictLock<BankAccount> guard(acct); acct.Withdraw(sum, guard); acct.Withdraw(2, guard); }
This function has the best of both worldsit's reasonably safe and efficient at the same time.
It's worth noting that StrictLock being a template gives extra safety compared to a straight polymorphic approach. In such a design, BankAccount would derive from a Lockable interface. StrictLock would manipulate Lockable references so there's no need for templates. This approach is sound; however, it provides fewer compile-time guarantees. Having a StrictLock object would only tell that some object derived from Lockable is currently locked. In the templated approach, having a StrictLock<BankAccount> gives a stronger guaranteeit's a BankAccount that stays locked.