Грабли 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
    Поделиться публикацией

    Комментарии 18

      +3
      Что-то я ничего не понял.
      Что за множество одиночек?
      Как правило одиночки должны жить на протяжении жизни всего приложения, поэтому они и создаются, зачем их в течении жизни создавать и уничтожать? оО
        0
        В приложении существовало большое количество одиночек — классы, отвечающие за ведение лога, загрузку ресурсов, вывод звука. Даже класс с основной логикой приложения был Singleton. А уничтожать их нужно для того, потому что профилировщик видел new без delete и считал все выделенное одиночками утечкой.
          0
          Так были ли утечки или так считал только профилировщик?
            0
            Конечно были) Просто их трудно было найти из-за того, что профилировщик выводил информацию о них в общий список с ругательствами на память, выделенную Singleton'ами. В результате получалось около 10 тысяч сообщений. В общем, довольно проблемная была система. Когда же удалось разобраться с одиночками, осталось несколько сотен реальных проблем.
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Так конечно правильно, но всё равно, это не окончательное решение проблемы. Поскольку статические члены удаляются в порядке, обратном тому, в котором они были созданы, возможна ситуация обращения к уже удалённому статическому члену.
            0
            Как это не static. Тогда бы вообще механизм не работал. Я привел пример реализации метода, чтобы показать что использовался не такой подход
            Singleton* Singleton::getInstance() {
                  static Singleton instance;
                  return &instance;
            }
            

            То, что метод статический, указывается в заголовочном файле. Я его не приводил, посчитав, что интерфейс Singleton'a уже всем давно примелькался)
            • НЛО прилетело и опубликовало эту надпись здесь
                0
                Интерфейс реализации не содержит
                class Singleton
                {
                public static Singleton* getInstance();
                //…
                }
                А пример в статье уже из соответствующего cpp-файла
                • НЛО прилетело и опубликовало эту надпись здесь
                    –1
                    Да, со стековым проблем с псевдоутечкой не было бы. Но осталась бы проблема взаимосвязи. Чтобы обеспечить удаление стековых элементов в заданном порядке, придется создать их в обратном порядке, т.е где-то нужно будет записать что-то типа
                    Singleton1::getInstance();
                    Singleton2::getInstance();
                    //....
                    SingletonN::getInstance();
                    

                    Это позволит рассчитывать, что при завершении работы будет удален сначала SingletonN, потом Singleton[[N-1]] и так далее. Но если, например, в деструкторе Singleton1 будет вызов Singleton2::getInstance, то все может и упасть. Кроме того, вызов getInstance только ради того, чтобы обеспечить правильный порядок разрушения похож скорее на костыль, делающий невозможным создание при первом использовании (например, если за время работы приложения Singleton1 ни разу не пригодился, то логично вообще не создавать его экземпляр).
                    • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        Вот и я к тому же выводу пришел. Иногда этот паттерн помогает, но основывать на нем всю архитектуру явно не стоит.
                        0
                        Я, конечно, не архитектор, но Великая Сила подсказывает мне, что если код заточен на порядок удаления синглтонов — то что-то не так в архитектуре. И, скорее всего, синглтонов здесь не должно быть вообще.

                        p.s: синглтон уже достаточно давно считается антипаттерном, и надо стараться избегать его использование.
            • НЛО прилетело и опубликовало эту надпись здесь
                0
                Он не пострадал. Его книга вспомнилась из-за восстания удаленных классов. А так он сам и писал, что одного правильного решения, как удалять Singleton классы не существует — все по ситуации.
                  0
                  Не надо их удалять. И вообще, не стоит их много создавать. Один два синглтона на приложение хватит. Можно еще thread_local заюзать и будет по синглтону на поток. Что может быть удобно.
                0
                Как вариант, можно создавать и разрушать синглтоны (в правильном порядке) в одном месте — в конструкторе и деструкторе, соответсвенно, вспомогательного класса, создав его глобальный статический экземпляр:

                class SingletonOwner
                {
                public:
                    SingletonOwner()
                    {
                        Singleton0::instance();
                        Singleton1::instance();
                        Singleton2::instance();
                        ...
                    }
                    ~SingletonOwner()
                    {
                        Singleton0::instance()->free();
                        Singleton1::instance()->free();
                        Singleton2::instance()->free();
                        ...
                    }
                };
                
                ...
                static SingletonOwner singletonOwner;
                ...
                

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое