Грабли 1: Восстание одиноких фениксов

от автора

Хотел написать статью о теоретических недостатках паттерна Singleton, но недолгий поиск показал, что материалов на эту тему достаточно. А вот реальных примеров архитектурных проблем с одиночками, как мне кажется, не хватает. Постараюсь восполнить этот пробел с помощью данного поста. В конце будут приведены выводы из собственных ошибок, которые пока позволяют избегать повторения проблем.

Грабли

Итак, для начала собственно грабли. В унаследованном проекте активно использовались классы-одиночки. Было их больше десяти. Использовалось динамическое создание экземпляров при первом обращении, т.е. метод getInstance имел следующий вид

Singleton* Singleton::getInstance() {     if (sInstance == NULL)  {         sInstance = new Singleton;     }     return sInstance; } 

И тут началась охота за утечками памяти. Уничтожение одиночек предусмотрено не было, потому профилировщик выдавал кучу ложных «утечек», среди которых практически невозможно было найти реальную проблему. Да и искать не хотелось. Потому решено было написать метод destroyInstance, который позволял бы разрушать экземпляры классов одиночек по запросу.

Ситуацию существенно упрощал тот факт, что реализация Singleton-логики была вынесена в отдельный шаблонный класс — в глобальной переделке необходимости не было. Достаточно было добавить удаляющий метод и его вызов для каждого одиночки… И вот тут-то сработала теория. В основном два ее пункта

  1. Зависимости от классов одиночек не очевидны из интерфейсов классов и модулей, для обнаружения нужно исследовать непосредственную реализацию, что не всегда легко и даже возможно
  2. У singleton-класса нет явного владельца. На то он и одиночка. От того и страдает.

Вызвав все destroyInstance, я открыл профилировщик в предвкушении охоты за реальными утечками и… окунулся все в то же море ложных срабатываний! Большинство одиночек жило в свое удовольствие и дальше, несмотря на пережитое разрушение. Первым делом, как всегда, был обвинен компилятор. И, как всегда, реальная проблема оказалась в собственных руках.

Все корректно разрушалось. Но неконтролируемые зависимости существовали и между одиночками. Часто в деструкторе одного использовался другой. А поскольку getInstance не просто возвращал существующий экземпляр класса, но и при необходимости создавал новые, стали происходить массовые неконтролируемые восстания из пепла. Сразу вспомнилась книга Александреску [1]. Только, в отличие от меня, он занимался некромантией (в разделе 6.6, что почти символично) умышленно.

Ситуацию нужно было исправлять. Первая мысль — при уничтожении экземпляра выставлять флаг, препятствующий повторному созданию объекта. Но это вело к необходимости масштабной модификации и без того запутанного кода, который был написан в расчете на то, что getInstance всегда возвращает корректный указатель. В результате, осознав, что легкого решения нет, я почти полностью искоренил одиночек (оставив одного), чему до сих пор только радуюсь.

Выводы

Проблемы, связанные с управлением временем жизни singleton-классов, часто поднимаются и обсуждаются. Мне же кажется, что корень проблемы в неправильном понимании сути этого паттерна. Одиночка должен быть один. И это решает все. Да, в результате требуется более тщательное проектирование, выявление зависимостей между сущностями. Но оно того стоит. Потому что в конце концов получается единая точка управление приложением, проясняются интерфейсы и логика.

Конечно, наверняка существуют приложения, где этот подход неприемлем. Но это исключения, и к ним нужно относиться именно как с исключениям. Если приложение одно, то и полностью самостоятельный в плане своего создания и уничтожения класс должен быть один.

Например, классический пример с клавиатурой, дисплеем и логом [1]. Владельцем этих класссов можно сделать глобальный Application. Название избитое, но вот его плюсы

  • Определенность. После вызова Application::free можно не бояться фениксов зомби — все будет уничтожено именно в том порядке, в котором задумывалось разработчиком. Лог же удаляется последним простым вызовом оператора delete mLog после удаления всех остальных объектов.
  • Простота и переносимость. Не требуется использования (и даже знания) тонкостей языка. Архитектура свободно адаптируется для основных объектно-ориентированных языков. К тому же простые решения гораздо меньше подвержены ошибкам и предъявляют более низкие требования к квалификации персонала. Другими словами они дешевле и лучше сложных[3]
  • Тестируемость. Использование ухищрений типа atexit [1,2] для планирования уничтожения классов-одиночек делает невозможным сброс глобального состояния приложения без полного завершения его работы. В результате, например, для выполнения двух независимых тестов придется каждый раз перезапускать программу.

Единственной проблемой может остаться бесконтрольное создание/копирование этих классов. Так, теоретически, программист может не найти, что объект управления клавиатурой нужно получать из Application, и создать свой экземпляр. Такое развитие событий можно легко пресечь, объявив все нежелательные конструкторы закрытыми. Это сделает невозможным «ручное» создание. Чтобы класс-владелец все еще мог инстанцировать объект клавиатуры, достаточно объявить его другом.

class Keyboard { private: 	Keyboard();  	friend class Application; }; 

Подобная реализация сыграет и важную информационную роль. Даже безо всякой документации, просто изучив интерфейс класса Keyboard, разработчик сможет понять, что за экземплярами нужно обращаться к Application.

Класс-одиночка практически всегда может быть заменен лучшим решением. По крайней мере, в моем случае за последний год каждая попытка сделать еще один singleton при более подробном анализе неизменно оказывалось проявлением лени и нежелания лишний раз подумать над планируемой архитектурой.

[1] Александреску А. Современное проектирование на С++: Обобщенное программирование и прикладные шаблоны проектирования.
[2] atexit
[3] KISS

ссылка на оригинал статьи http://habrahabr.ru/post/185128/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *