Предисловие


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

Хочу предупредить, что человек я совершенно зелёный. Я не гуру программирования, не сеньор и возможно, что даже не middle. Адекватный опыт разработки имею исключительно в Unity, по сему затрагиваю только данную среду. Изначально было страшно делиться своими мыслями, но вспомнив, что порой тут публикуют серьёзные дяди, решил попытаться. Я люблю пользователей данного сайта, и даже если моя карма уйдёт в Марианскую впадину, то комментарии всегда помогут мне понять то, чего не смог понять ранее и найти ту самую истину!

За что же ненавидят паттерн Singleton


Причин невероятно много, при том, как мне стало понятно, многие из них берутся абсолютно из воздуха, в попытке показать себя бравым защитником SOLID кода.

Давайте разберём некоторые из них!

Нарушение принципа единственной ответственности. Простите, что? По какой же причине наш Одиночка со 100% вероятностью нарушает этот принцип? То, что класс имеет глобальную точку доступа, не переносит его в разряд GOD-класса. SoundManager? NetworkManager? SceneManager? Даже в официальном уроке Unity3D SoundManager создан именно Singleton'ом. Видимо, данный пункт отпадает.

Singleton невозможно тестировать. Иными словами, аргумент людей заключается в том, что Одиночка сильно связан с объектом, изначально хранится в памяти и невозможно запустить тест, исключив его из работы приложения. Возможно, для людей не понимающих жизненного цикла класса MonoBehaviour всё так и будет. Но давайте поговорим на чистоту. То, что наш единственный instance создаётся в методе Awake(), не означает, что это обязательное условие для создания Singleton'а. Вы можете завернуть его инициализацию в любой другой метод и создать его только тогда, когда он вам впервые понадобится. Кроме того, люди почему-то говорят о том, что Singleton чудесным образом передаётся сквозь сцены. Для чего же тогда используется DontDestroyOnLoad? Загадка.

Тесная связь. Написав тысячи строк ужасно связанного кода, я как никто понимаю что такое страдать от багов, появляющихся в каком-то классе, при изменении другого. Но как вы считаете, является ли Singleton источником этой проблемы? Я — нет. Проектирование, и ещё раз проектирование. В одной из статей (простите, не смогу указать ссылку, ибо читал её невероятно давно) говорилось о том, что нужно уделять до 80% времени на проектирование структуры приложения, после чего дело пойдёт как по маслу. Я абсолютно согласен с этим.

На самом деле, скажу вам больше, я фанатичен по отношению к модульности. Продумать и создать структуру, в которой каждый модуль будет самодостаточен, расширяем и прост — моя мечта по части программирования на текущий момент.

Сегодня я посмотрел невероятно информативную презентацию об использовании Scriptable Objects. Она называется «Unite Austin 2017 — Game Architecture with Scriptable Objects». Материал будет полезен каждому Unity-разработчику. Выступающий приравнивал Singleton к стихийным бедствия, ядерной войне, говорил о том, что их студия старается создавать в играх prefab'ы, которые были бы самодостаточными и не имели тесных связей. Повторюсь, на мой взгляд — решение превосходное. Но что если наша игра требует связей?

Один из основных примеров использования Scriptable Objects — это игра Heart Stone, которая, как вы возможно знаете, частично сделана на Unity3D. Круто. Прекрасно. Восхитительно. Только беспокоит одно большое «НО». Создание карточек для Heart Stone может ограничиться табличкой в Excel, где мы забиваем имя карты, её описание, ману, атаку и конечно же хит-поинты. Готово. А вам потребовалось вот создать игру в которой всё завязано на генерации. Генерируется ландшафт, генерируются предметы, генерируются персонажи, генерируется даже то, что отвечает за генерацию. Паттерн одной из фабрик? Строитель? Ну, возможно. Наши сенсеи вряд ли станут врать. Минусами этих паттернов является загруженность лишним кодом и его общее усложнение. Я ни в коем случае не говорю о том, что нужно отказаться от использования любых паттернов кроме Singleton'а, я лишь хочу призвать людей не бояться Одиночек и не плодить паттерны там, где они не нужны, что может привести к неуместному усложнению архитектуры.

Немного дополнений


Зачастую мы можем видеть решения, которые выглядят совсем как «не синглтоны». Разработчики извращаются любыми методами. Делают public переменную для класса, перетаскивая в инспекторе единственный объект с нужным классом на другие сотни объектов, которым нужна эта ссылка. Вызывают в методе Start() GameObject.Find(). Зато не Singleton, здорово, правда? Связанность есть, Антипаттерна нет. Чудеса.

Основной проблемой Singleton'а среди начинающих разработчиков является то, что не нужно сильно задумываться о структуре. Из-за чего весь код обрастает Одиночками. Доступ к любому методу без ссылок, монолитность архитектуры. Всё это плохо, но каждый пишет код в меру своей грамотности. Будем честны. Программирование — это не просто вид деятельности, это полная смена образа мышления. Невозможно прочесть книгу на 700 страниц, сесть и сделать идеальную архитектуру. Всё приходит с опытом. И если ваш путь начался с монолитов — это совсем не повод отчаиваться. Понимание наличия проблемы — первый шаг к её решению!

Also, чрезмерное использование Singleton'ов абсолютно идентично чрезмерному использованию любого другого паттерна. Никто не даст вам печеньку за то, что вы сделаете абстрактную фабрику и 30 интерфейсов для класса, единственным назначением которого будет смена фонового изображения.

Семь раз отмерь, один раз отрежь!

Возможно, если комментарии будут очень насыщенными, я смогу дополнить эту статью или даже сделать 2 часть. Буду надеяться на критику моей писанины. Всем спасибо за внимание!

Комментарии (28)


  1. shai_hulud
    09.01.2018 19:04
    -2

    >в официальном уроке Unity3D SoundManager
    Дизайн API юнити это наоборот отрицательный пример. У них даже не было одной конвенции наименования, чего уж говорить о хорошем дизайне API. Положительный пример это XNA.

    >То, что класс имеет глобальную точку доступа
    Всё на этом можно закрывать обсуждение. Объект имеющий состояние и имеющий общую точку входа, это непредсказуемый с точки зрения состояния объект с непредсказуемым количеством связей. Точка после который продукт становится поделием.


  1. shai_hulud
    09.01.2018 19:07
    -2

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


    1. Incomnia_Cat Автор
      09.01.2018 19:17
      +1

      Бедный Brackeys с его «недотуторами». Не эволюционировал видимо ещё. Повторяюсь, я не призываю плодить Синглтоны, но порой они оказываются удобными. Да и сама работа с Unity наталкивает на их использование очень часто. Надеюсь теперь меня за ссылку не побьют модеры.
      blogs.eae.utah.edu/jkenkel/why-i-overuse-the-singleton-pattern-in-unity


    1. Suvitruf
      09.01.2018 19:30
      +1

      Как категорично то. По-моему, точка невозврата в профессиональном развитии разработчика — это когда он начинает обвинять других разработчиков в чём-то, не разобравшись в вопросе спора сам. У синглтона есть своё применение.


      1. shai_hulud
        09.01.2018 20:15
        -2

        Я готов вступить в аргументированный спор. И даже предоставил пару начальных аргументов:
        > это непредсказуемый с точки зрения состояния объект с непредсказуемым количеством связей

        Аппеляция к авторитету учебного заведения от Incomnia_Cat не являются аргументом, как минимум по тому что статью пишет конкретный человек, а не учебное заведение. Даже если взять кокрентного человека, то дурак не всегда говорит глупости, а умный не всегда говорит умные вещи.

        И «апеллирование к тону» конкретно от вас не являются аргументом.
        > У синглтона есть своё применение.
        Я не писал, что у него нет применения. Некоторые люди делают целые проекты на синглтонах. Многие вставляют вместо застежки ремня безопасности заглушку, что бы бортовая система автомобиля не ругалась на непристегнутого водителя. У заглушки ремня безопасности есть применение.


        1. Incomnia_Cat Автор
          09.01.2018 20:29

          Вот я глупый наверное. О каком количестве связей идёт речь? У меня есть class ItemGenerator к которому я получаю доступ только при открытии сундуков или при получении лута с моба. Где возьмутся остальные связи?


          1. edge790
            09.01.2018 21:20

            Проблем нет, если новых фич/кода не предвидется.
            Проблемы начинаются, когда вы, например, придумаете повторяемые квесты с рандомным дропом, захотите сделать особенный лут для какого-нибудь ивента(например повысите вероятность получения вещей в 2 раза), а потом придумаете, что не только должна повышаться вероятность получения вещей, но и в некоторых случаях их количества(например 2 зелья здоровья, вместо одного, 20 монет вместо 10 и т.д.), или вероятность выпадения "уникального хэллоуинского/новогоднего наряда", а потом ещё и доп. лут к 23 февраля и 8 марта...


            Это одна из настоящих проблем Coupling'а(сильной связанности). Если вы будете писать без DI(Dependency Injection, внедрения зависимостей), то вам придётся писать огромные if'ы и ваш элегантный код превратится в макаронный, а с DI проблем с синглтонами нет, потому что вы сможете сделать под каждый случай свой синглтон. Например сделаем interface LootManager и следующие наследники:


            1. FixedLootManager — для дропа из квестовых монстров и сундуков из "кампании". Бросает ровно то что нам нужно.
            2. RandomLootManager — ScriptableObject, который генерирует случайный лут для уровня данного уровня монстра
            3. LevelDependentLootManager — SO, который генерирует лут в зависимости от уровня героя
            4. HighierRatesLootManager — SO для генерации лута с увеличенными рейтами(шансом выпадения).
            5. HalloweenSpecialLootManager — SO для генерации лута для хэллоуинских ивентов

            и т.д.
            Согласитесь, это выглядит получше.


            1. Incomnia_Cat Автор
              09.01.2018 22:02

              Согласен полность. И пример ваш мне очень понравился, достаточно ясный. Но ваш посыл в том, что LootManager разрастётся до GOD-класса. Мб я не так понимаю что есть бог? И я не говорил про весь лут, я говорил лишь о генерации итемов. Хотя мы всё равно ведём разговор абстракциями. То есть у меня есть броня\оружие. Дальнее и не очень. Разрастаться ему особо некуда.

              И лично я как раз стараюсь разбивать задачи когда понимаю, что код нужен расширяемый. Но опять же повторю. Проектирование решает все проблемы.


        1. redfs
          09.01.2018 21:05

          это непредсказуемый с точки зрения состояния объект с непредсказуемым количеством связей
          Поправлю. Синглтон — это все-таки не объект, а шаблон проектирования. И суть его в общем случае не в гарантии единственности экземляра своего класса, а в гарантии единственности экземляра некоторого класса. «Свой класс» — это частный случай, и, как правило, именно такое использование синглтона вызывает непредсказуемость.
          А поглядите, например, на совместное использование шаблонов синглтон + фабрика. Тут даже с единой ответственностью все в порядке.

          В общем синглтон обманчиво прост, подводные камни есть, но Suvitruf прав — готовить его можно.


          1. shai_hulud
            10.01.2018 11:03

            Поправлю. Синглтон — это все-таки не объект, а шаблон проектирования.

            Даже больше, это шаблон времени жизни объекта, и к прямого отношения к тому что сейчас обсуждается не имеет, только косвенное. Тут обсуждается глобально доступный объект, который сам управляет своим созданием/доступом и имеет состояние.


            Сферический:


            class Manager
            {
                public static Manager Instance = new Manager();
            }


            1. redfs
              10.01.2018 14:05

              Тут обсуждается глобально доступный объект, который сам управляет своим созданием/доступом и имеет состояние. Сферический
              Благодарю за уточнение, если речь только об этом, то я с вами совершенно согласен.


  1. GLeBaTi
    09.01.2018 19:48

    Одни «за», другие «против».
    Хотелось бы узнать, бывали ли у кого случаи когда они делали приложение с синглтонами, а потом сильно пожалели об этом?


    1. edge790
      09.01.2018 21:00

      Я боюсь, что это любой проект, который со временем обрастает кодом:
      Сложности тестирования делают ваш код менее стабильным, а наличие God-Object'а и вовсе может привести к сложно обноруживающимся багам.
      Более подробный ответ об проблемах и "альтернативах" я описал ниже.


  1. Gexon
    09.01.2018 20:03

    Also, если наша игра требует связей, то еще раз пересматриваем архитектуру.


  1. Incomnia_Cat Автор
    09.01.2018 20:10
    -2

    Обиженные «тру SOLID'ы» побежали мне карму минусовать. Вы хоть комментируйте, я же так опыта не наберусь!


  1. edge790
    09.01.2018 20:50

    Мой "основной" язык — Java, так что расскажу как дела обстоят там и почему правы и те люди, которые говорят что синглтон зло, и почему правы вы:


    Нарушение принципа единственной ответственности

    В Java (и скорее всего в C#) синглтон создает сам себя, хранит сам себя и делает свою бизнес логику. Это ужасно, это отвратительно, это плохо.
    НО! В Unity этим занят сам движок. Вы только делаете ScriptableObject и сам движок управляет жизненным циклом синглтона. Про "God Object" поговорим дальше, пока считаем что эта проблема решена.


    Singleton невозможно тестировать
    Тесная связь

    Эти проблемы связанны напрямую. Coupling(т.н. "связывание", оно же "сильная связанность") — это когда объект содержит другой объект. В итоге мы не может протестировать их по одиночке. Юнит тестирование класса аггрегирующего другой класс будет невозможно, т.к. мы будем завязаны на использование внутреннего объекта. Это тоже ужасно, отвратительно и плохо
    Но опять нам на выручку приходит игровой движок, который по совместительству выполняет роль Dependency Injection(внедрение зависимостей, DI).
    Многие начинающие программисты на юнити используют его даже не подозревая этого, потому что оно настолько простое. Перетаскивание объектов в скрипты, Изменение их значений через UI — всё это внедрение зависимостей и эта простота не уменьшает возможности.
    Хотите синглтон(ScriptableObject)? Пожалуйста! Но будет гораздо разумнее использовать наследование и встроенный DI.
    Примеры:


    1. Сделать разные свойства игры для разных сложностей: сделаем игру тетрис, три разных ScriptableObject'а описывающих разные значения высоты и ширины поля и скорости игры. В зависимости от выбранных настроек инжектим разные ScriptableObject'ы.

    2. Сделать разные характеристики для юнитов в стратегии, для последующего теста и менять их только изменяя SO при создании юнита.

    3. Сделать разные AI для разного поведения опонента в экономической стратегии: более агрессивный и более миролюбивый.


    Каждый из приведенных выше примеров позволит протестировать разный ScriptableObject отдельно, и заменить их на Mock'и при тестировании классов, которые их используют.
    При каждом изменении мы сможем заменить старый SO на новый, оставив старый, на случай "отката", если идея не взлетит.
    Проблема с God-Object тоже пропадает при использовании DI.


    1. edge790
      09.01.2018 20:52

      Перечитал ещё раз свой комментарий и заметил, что из него плохо понятно чем плох первый пункт минусов синглтона(про единую ответственность).
      Т.к. предыдущий коммент получился большой, решил написать отдельно:
      Каждый класс должен выполнять ровно ту функцию, для которой он был создан. В случае с синглтоном тут две функции: управление своим жизненным циклом и "бизнесс-логика" вложенная в него.
      Несмотря на то что "управление своим жизненным циклом" кажется простой задачей, всё становится гораздо сложнее с многопоточными приложениями.
      Наш стильный маленький "бойлерплейт"(повторяющийся код, необходимый для правильного выполнения программы) обрастает синхронизациями, и прочими вещами, которые осложняют понимание бизнес-логики кода и повышают шансы на то, что кто-то ненароком его сломает.


      1. Incomnia_Cat Автор
        09.01.2018 21:10

        Примерно поэтому я и указал, что говорю исключительно о Unity. Я не притендую на 100% истинность, но Unity — это не для бизнес приложений. Там отсутствует многопоточность (да, это решается с помощью реактивного программирования, но всё же). А когда люди уходят от Синглтонов, они идут в сторону Инжектора Зависимостей. Что на мой взгляд оставляет проблему тесной связи, но в другой обёртке.

        Про тестирование вообще отдельный разговор. Говорил с одним владельцем конторы по тестированию игр. Тот сказал «Ты правда веришь в то, что всю систему можно обложить Unit-тестами?». Опять же, не говорю не тестировать. Но баги будут всегда.


        1. Charoplet
          10.01.2018 10:52

          А когда люди уходят от Синглтонов, они идут в сторону Инжектора Зависимостей. Что на мой взгляд оставляет проблему тесной связи, но в другой обёртке.

          Связь остается, но она уже не тесная, что имеет большое значение. Выше уже был пример про внедрение разных LootManager'ов.


      1. Incomnia_Cat Автор
        09.01.2018 21:15

        И ещё. edge790. Вы описали 3 примера, которые подходят под мою фразу о проектировании. Unity богата на префабы. И идеальным вариантом будет описывать поведение объектов, внутри их самих. А вот уже то где генерировать, когда генерировать и с какими параметрами — можно переложить на Singleton.


  1. DisaDisa
    09.01.2018 23:35

    Перед обсуждениями за и против, я рекомендую прочитать вот эту главу прекрасной книги
    live13.livejournal.com/467905.html

    На мой взгляд больше проблема не в самом синглтоне, а в непонимании того что, есть depency injection, который мб и менее очевиден на первый взгляд, но решат все проблемы которые решает и порождает синглтон


  1. Darkirius
    10.01.2018 11:17

    Хочется спросить, а чем плохо использовать Singleton по аналогии с DI? Для Unity3D это очень удобный подход. Создаем класс GameManager и прописываем в нем ссылки на нужные объекты (классы), после чего создаем объекты (классы).

    Например:

    <code>
    public GameSettings GameSettings { get; private set; }
    public ConfigController ConfigController { get; private set; }
    public KeyboardController KeyboardController { get; private set; }
    public IFormDetector FormDetector { get; private set; }
    public DepthTracker DepthTracker { get; private set; }
    public GameController gameController { get; private set; }
    
    void Start ()
    {
        GameSettings = Resources.Load("GameSettings") as GameSettings;
        ConfigController = this.transform.GetOrAddComponent<ConfigController>();
        KeyboardController = this.transform.GetOrAddComponent<KeyboardController>();
        FormDetector = this.transform.GetOrAddComponent<PixelCompare>();
        DepthTracker = this.transform.GetOrAddComponent<DepthTracker>();
        gameController = this.transform.GetOrAddComponent<GameController>();
    }
    </code>


    А если ещё и добавить puplic static с возвратом объекта(класса), то можно получить ещё и короткие ссылки.

    Например:
    <code>
    public static GameController GetGameController()
    {
        return GameManager.Singleton.gameController;
    }
    </code>


    Вот и получилось элегантное решение и возможно вызвать в любой момент GameManager.GetGameController().


    1. fstleo
      10.01.2018 11:55
      +1

      Поздравляю с изобретением паттерна Service locator! Осталось заменить геттеры конкретных классов на общий геттер интерфейсов и получится Dependency Injection.


      1. Charoplet
        10.01.2018 13:12

        Кроме того, получится велосипед. Зачем делать свое подобие DI-фреймворку, если уже есть готовые и более продвинутые решения? Разве что вам нужно реализовать какие-то специфичные фичи, которые в готовых фрейворках никак не сделать.


        1. Darkirius
          10.01.2018 17:34

          1. Да я согласен про велосипед, но речь идет о применении Singleton.
          2. Как я понимаю мы обсуждаем работу в рамках Unity3D и желательно видеть результат в инспекторе, а не просто выводить в лог.
          3. Данный код позволяет повесить класс на объект и управлять классом через инспектор, а то что предлагаете Вы, не позволяет сделать визуальный дебаг класса.


      1. Darkirius
        10.01.2018 17:39

        Я так понимаю вы просто не согласны с этой статьёй: habrahabr.ru/post/270005. А также Вы исключили возможность использовать данный пример кода как единую входную точку. Попробуйте оценить этот код с точки зрения: инициализация самого проекта.


    1. DisaDisa
      10.01.2018 15:12

      Если говорить про данный код
      Что будет если кто-то будет требовать менеджеры в Awake/OnEnable?

      Если говорить в общем
      Пример — у нас мультиплеер-шутер и в данном случае мне нужно чтобы игрок мог менять контрол и аудиоменеджер, т.е. чтобы в рантайме я мог заменить GameController c обычного на 'Spectrator' и у него должен быть другой AudioManager, в котором мы можем слышать все переговоры, а не только своей комманды, т.е. я бы хотел иметь возможность делать еще один инстанс GameMode, а это решение мне такой возможности не дает и заставляет просто прописывать всё это во внутрь самих менеджеров


      1. Darkirius
        10.01.2018 17:30

        Идея данного кода состоит в том, что он срабатывает первым и создает окружение вокруг себя, это своего рода единая точка входа. Соответственно все что сработает в Awake/OnEnable получит доступ к данному коду.