Определив блокировку и ее монитор как две отдельные абстракции, мы дали клиенту возможность использовать различные политики блокировки. Описание класса WriteLock аналогично, разница лишь в том, что он использует протокол монитора для записи.
Описания всех функций-членов синхронизированного класса используют блокировки для 'оборачивания' операций, унаследованных из суперкласса. Рассмотрим в качестве примера реализацию функции length для синхронизированной неограниченной очереди:
template<class Item, class StorageManager, class Monitor> unsigned int SynchronizedUnboundedQueue<Item, StorageManager, Monitor>::length() const {
ReadLock lock(monitor); return UnboundedQueue<Item, StorageManager>::length();
}
Данный фрагмент кода иллюстрирует механизм, приведенный на рис. 9-11. Как правило, объекты класса ReadLock используются для всех синхронизированных селекторов, а экземпляры WriteLock - для синхронизированных модификаторов. Простота и элегантность подобной архитектуры проявляется в том, что каждая функция представляет собой законченную операцию, в любом случае гарантирующую сохранность состояния объекта, причем без каких-либо явных действий со стороны агентов чтения/записи.
Действительно, клиенты, работающие с синхронизированными объектами, не должны придерживаться специальной последовательности действий, так как механизм синхронизации процессов поддерживается здесь в неявном виде. Это исключает появление ошибок типа неверной блокировки. Разработчику следует, однако, предпочитать защищенную форму синхронизированной, когда вызов нескольких функций нужно оформить как атомарную транзакцию; синхронизированная форма может гарантировать атомарность только отдельных функций-членов.
Наша архитектура обеспечивает синхронизированным формам отсутствие ситуаций типа 'смертельное объятие'. Например, операции присваивания объекта самому себе или сравнения его с самим собой потенциально опасны, так как требуют блокировки и левого и правого элементов выражения, которые в данном случае являются одним и тем же объектом. Будучи создан, объект не может изменить свою идентичность, поэтому тесты на самоидентичность выполнятся до блокировки какого-либо объекта. Именно поэтому описанный ранее оператор присваивания operator= включал такую проверку, как показывает следующая сокращенная запись:
template<class Item> Queue<Item>& Queue<Item>::operator=(const Queue<Item>& q) {
if (this == &q) return *this;
}
Любые функции-члены, среди аргументов которых есть экземпляры класса, к которому они принадлежат, должны проектироваться так, чтобы обеспечивалась корректная схема блокировки этих аргументов. Наше решение базируется на полиморфизме двух служебных функций, lock и unlock, определенных в каждом абстрактном базовом классе. Каждый абстрактный базовый класс по умолчанию содержит заглушку для этих двух функций; синхронизированные формы обеспечивают захват и освобождение аргумента. Вот почему описанный ранее оператор присваивания operator= включал вызовы этих двух функций, как показывает следующая сокращенная запись:
template<class Item> Queue<Item>& Queue<Item>::operator=(const Queue<Item>& q) {
((Queue<Item>&)q).lock(); ((Queue<Item>&)q).unlock (); return *this;
}
Явное приведение типа используется в данном случае для того, чтобы освободиться от ограничения const на аргумент.
9.3. Эволюция
Проектирование интерфейса классов
После того, как выработаны основные принципы построения архитектуры системы, остающаяся работа проста, но зачастую довольно скучна и утомительна. Следующий этап будет состоять в реализации трех или четырех семейств классов (таких, как очередь, множество и дерево) в соответствии с выбранной архитектурой, и в последующем их тестировании в нескольких приложениях [Вирфс-Брок считает, что необходимо тестировать среду разработки по крайней мере на трех приложениях, чтобы проверить правильность стратегических и тактических решений [15]].
Наиболее тяжелой частью данного этапа является создание подходящего интерфейса для каждого базового класса. И здесь, в процессе изолированной разработки отдельных классов (см. главу 6), нельзя забывать о задаче обеспечения глобального соответствия всех частей системы друг другу. В частности, для класса Set можно определить следующий протокол:
• setHashFunction Устанавливает функцию хеширования для элементов множества.
• clear Очищает множество.
• add Добавляет элемент к множеству.
• remove Удаляет элемент из множества.
• setUnion Объединяет с другим множеством.
• intersection Находит пересечение с другим множеством.
• difference Удаляет элементы, которые содержатся в другом множестве.
• extent Возвращает количество элементов в множестве.
• isEmpty Возвращает 1, если множество пусто.
• isMember Возвращает 1, если данный элемент принадлежит множеству.
• isSubset Возвращает 1, если множество является подмножеством другого множества.
• isProperSubset Возвращает 1, если множество является собственным подмножеством другого множества.
Подобным же образом можно определить протокол класса BinaryTree:
• clear Уничтожает дерево и всех его потомков.
• insert Добавляет новый узел в корень дерева.
• append Добавляет к дереву потомка.
• remove Удаляет потомка из дерева.
• share Структурно делит данное дерево.
• swapChild Переставляет потомка с деревом.
• child Возвращает данного потомка.
• leftChild Возвращает левого потомка.
• rightChild Возвращает правого потомка.
• parent Возвращает родителя дерева.
• setItem Устанавливает элемент, ассоциированный с деревом.
• hasChildren Возвращает 1, если у дерева есть потомки.
• isNull Возвращает 1, если дерево нулевое.
• isShared Возвращает 1, если дерево структурно разделено.
• isRoot Возвращает 1, если дерево имеет корень.