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

Первым паттерном, который мы рассмотрим, разумеется, станет синглетон. Как только его по-русски не называют, кстати. Синглтон. Синглетон. Наконец, ОДИНОЧКА. Не, ну вы представляете, ОДИНОЧКА?! Покажите мне живого человека, который так говорит? Я ни одного за 30 лет использования паттернов GoF не видел.

Казалось бы, что о нём можно сказать разумного, доброго, вечного, а главное — нового? Паттерн довольно тривиальный, всего лишь способ создать объект класса, который нельзя инстанцировать более одного раза, а потом использовать этот объект везде, где нужно (часто в совсем разных местах). И довольно спорный во многих случаях. Особенно в Python, где я обычно не советую его использовать так, как в C++.

Скрытый текст

Во всяком случае, дизайн системы, где Singleton используется не для создания одного-двух её столпов, а регулярно и без особого контроля, часто плох: в конце концов, Singleton — это всего лишь вариация на тему глобальных объектов. Да, обычно с лучшим контролем времени создания (создание в привычных реализациях — ленивое). Да, с возможностью задать параметры создания (но это иногда и источник путаницы: параметры значимы только при первом вызове, дальше они просто игнорируются). А как всё это тестировать прикажете? Нет, есть, конечно, способы (например, протестировать класс без использования механизма единственности или подменить функцию создания, что в Python будет особенно просто), но всё равно для тестов это не самая удобная вещь.

Чаще всего я применял синглетон для корневого объекта какой-нибудь библиотеки, который либо вообще не имеет своего состояния, будучи просто средством создания содержательных объектов, либо имеет состояние, максимально скрытое от пользователя-программиста (до такой степени, что непосредственно на внешнюю функциональность библиотеки вообще не влияет).

Скрытый текст

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

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

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

Калька с C++

Сначала посмотрим на то, как реализовать синглетоны в Python наподобие C++. Прямая калька с C++ будет выглядеть примерно так:

import threading


class _Log:
    _lock = threading.Lock()
    _object: '_Log | None' = None
    def __init__(self, fname: str):
        pass


def get_log(fname: str) -> _Log:
    if _Log._object is None:
        with _Log._lock :
            if _Log._object is None:
                _Log._object = _Log(fname)
    return _Log._object

Если вам нужна ещё и потокобезопасность (в данном случае get_log() может параллельно вызываться из разных потоков), всё немного усложняется, и придётся написать примерно следующее:

import threading


class _Log:
    _lock = threading.Lock()
    _object: '_Log | None' = None
    def __init__(self, fname: str):
        pass



def get_log(fname: str) -> _Log:
    if _Log._object is None:
        with _Log._lock :
            if _Log._object is None:
                _Log._object = _Log(fname)
    return _Log._object

Этот пример использует стандартный приём, называемый Double-Checked Locking (DCL). По-русски аналог — «двойная проверка с блокировкой». Представьте себе, что get_log() вызывают из разных потоков параллельно. Поскольку непосредственно доступ к переменным класса (в данном случае именно класса, хотя экземпляра тоже) в CPython скрыт под GIL, проверить, создан ли объект ранее, можно всегда и совершенно без синхронизации. Впрочем, так делают и в C++, лишь бы присваивание указателю и его чтение (по отдельности, не вместе, конечно) были атомарны. И если глобальная переменная _OBJECT уже содержит созданный объект, можно не волноваться, он создан ранее. В этом случае никакая синхронизация нам не потребуется. А вот если вы попали в первый оператор if, гарантировать, что кто-то не сделал того же самого параллельно, нельзя. И поэтому дальше мы убеждаемся, что «в живых остался только один», потом снова проверяем, что объект ранее никем не инициализирован, и только потом создаём его. Если внутрь первого if  войдут одновременно два (возможно, больше, но это мало что меняет) потока, то успевший захватить блокировку первым создаст объект, а второй, захватив освобождённую первым блокировку позднее, обнаружит, что объект уже кем-то создан.

Скрытый текст

Сам по себе этот паттерн параллельного программирования довольно интересен и заслуживает запоминания, но стоит ли использовать весь этот подход конкретно для создания синглетонов в Python, мы ещё выясним.

Для начала надо понять одно допущение, на котором основан DCL. Этот кодировочный паттерн предполагает, что как минимум можно параллельно осуществлять присваивание переменной _object и попытку её прочитать без ошибок синхронизации, известных как race conditions («условия гонок»). Впрочем, в традиционном CPython с GIL это так и есть.

По своей идее вышеприведённый код не то чтобы страшен. Не может быть, чтобы люди не придумали чего-то «пожёстче». Они и придумали. Я для смеха попросил ChatGPT предложить мне известные ему реализации паттерна в Python (почти все я видел и ранее в реальном коде). Некоторые я даже приведу здесь, но не для того, чтобы кто-то это повторил, а для того, чтобы отговорить кого-то данный кошмар использовать, ибо это просто, как говорили лет 20 назад, адъ и Израиль.

Магический метод new

Итак, первый шедевр — использование магического метода__new__. Рассмотрим его в несколько подходов. Сначала проиллюстрируем общую идею.

import threading


class Singleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            with cls._lock:
                if not cls._instance:
                    cls._instance = super(Singleton,
cls).__new__(cls, *args, **kwargs)
        return cls._instance

s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

Этот класс, однако, не вполне настоящий. У него вообще нет конструктора, что для реальных синглетонов не очень вероятно. Добавим. И для «проверки на вшивость» напечатаем в нём, что конструктор отработал. Заодно убедимся, что объект инициализируется ровно один раз.

import threading


class Singleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            with cls._lock:
                if not cls._instance:
                    cls._instance = super(Singleton,
cls).__new__(cls, *args, **kwargs)
        return cls._instance

    def __init__(self):
        print("INIT")

s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

Запустим этот код. Что мы видим?

INIT
INIT
True

Как так?! Объект один, но каждый раз при попытке доступа вызывается __init__, что является практически катастрофой. При этом поведение объяснимо: если __new__ вернул объект своего класса или же объект класса-потомка, вызывается конструктор, так в документации и написано. Но что теперь делать? В принципе, можно в объекте завести флажок, в первый ли раз вызывается __init__, и пользоваться им, ничего не делая в последующие разы. Но до чего же это неудобно! Причём сделать это в специальном базовом классе, чтобы создавать классы-синглетоны простым наследованием, не очень получается: конструктор вызывается только у самого последнего производного класса, и все конструкторы предков вызываются по цепочке MRO вручную (если конструктор не определён, там, конечно, обратятся к предкам, но это нам не помогает никак).

Можно, конечно, схитрить. Заставить пользователей нашего класса определять метод инициализации с нестандартным именем, скажем, _singleton_init(). Но это кажется некрасивым. Пользователь базового класса, создающий свой синглетон, может этого просто не понять. Тем не менее мы посмотрим на такую реализацию дальше.

Резюмирую: этот метод реализации синглетона кажется естественным, но он имеет большую проблему с корректностью вызова конструктора класса: конструктор вызывается многократно — при каждой попытке получить доступ к объекту. Есть ещё маленькая сложность с многопоточностью, но о ней позже.

Предок с new и ад многопоточности

Доведём до ума (если это выражение тут вообще уместно) предыдущий пример, реализовав наш план с  методом _singleton_init(). Отладочные печати я тоже добавлю.

import threading
from abc import abstractmethod


class Singleton:
    _instance = None
    _lock = threading.RLock()

    def __new__(cls, *a, **kw):
        if not cls._instance:
            with cls._lock:
                if not cls._instance:
                    cls._instance = super(Singleton, cls).__new__(cls)
        return cls._instance

    @abstractmethod
    def _singleton_init(self, *a, **kw):
        ...

    def __init__(self, *a, **kw):
        if not hasattr(self, '_init'):
            self._init = True
            self._singleton_init(*a, **kw)


class Singleton1(Singleton):
    def _singleton_init(self, i: int):
        self.value = i
        print(f"Singleton1.__init__({i})")


class Singleton2(Singleton):
    def _singleton_init(self, i: int):
        self.value = i
        print(f"Singleton2.__init__({i})")


s11 = Singleton1(1)
s12 = Singleton1(2)
s21 = Singleton2(1)
s22 = Singleton2(2)
print(s11 is s12 and s21 is s22 and s11 is not s21)  # True

Очевидно, что код рабочий. Каждый псевдоконструктор _singleton_init() вызывается ровно однажды. Все экземпляры Singleton1 едины, Singleton2 — тоже, но между классами смешения экземпляров нет. И псевдоконструктор каждого вызывается ровно однажды, а именно при первом обращении. Всё хорошо? Ну, если забыть про этот нелепый _singleton_init() вместо __init__, конечно. Всё ведь хорошо, да? То, что пустой (_instance = None) атрибут экземпляра находится в базовом классе и один на всех, не страшно. При создании экземпляра производного класса в том классе тоже появится атрибут с таким же именем, скрывая пустой.

А вот то, что у нас объект синхронизации находится в базовом классе, уже интереснее. Потому, кстати, я заменил Lock на RLock. Зачем?

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

class Singleton1(Singleton):
    def _singleton_init(self, i: int):
        self.value = Singleton2(i).value + 1
        print(f"Singleton1.__init__({i})")

Он будет при этом повторно захватывать тот же объект синхронизации. В том же потоке. И для того, чтобы такой захват был возможен, нам нужен рекурсивный объект — RLock. При первом захвате он перейдёт в состояние «захвачен потоком таким-то один раз», потом — «захвачен потоком таким-то два раза», потом вернётся в «один раз», и только потом будет освобожден. А вот если использовать обычный Lock, то ему неважно, тот же поток захватывает его, другой ли — объект захвачен, значит, ждём. Естественно, вечно (ясно, что наш поток ничего не освободит). Это самоблокировка, наиболее глупый сценарий мёртвой блокировки (deadlock). Обычно потоков нужно хотя бы два, да и объектов синхронизации тоже. Но если использовать нерекурсивную блокировку, вполне реально, как видите, справиться даже одним потоком и одним объектом. Долго ли умеючи-то?

В общем, казалось бы, мы успешно применили DCL, что решает нашу проблему потокобезопасности. И да, действительно решает. Но есть нюанс.

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

Есть два синглетона (A и B) и два потока (1 и 2), оба наследуются от класса Singleton. А ещё есть какие-то другие данные, и с ними связан другой объект синхронизации. Дальше картина такая:

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

  • Одновременно поток 2 начинает похожим образом инстанцировать синглетон B, успевая при этом первым захватить блокировку. Поток 1 при этом ждёт. Выполняется только поток 2.

  • Поток 2 доходит до вызова конструктора B, и его конструктор тоже хочет получить доступ к данным, которые ранее заблокировал поток 1. Естественно, он обнаруживает, что блокировку захватить не может, начиная ждать её.

  • Поток 1 ждёт освобождения блокировки на создание синглетонов, одновременно не давая потоку 2 захватить блокировку данных. Поток же 2 ждёт освобождения блокировки данных, одновременно не давая потоку 1 захватить блокировку создания синглетонов. Оба потока зависают.

Такая мёртвая блокировка всегда имеет шанс возникнуть, если есть два (или более) объекта синхронизации, а два потока (или более) хотят захватить их все, но делают это в разной последовательности. Запомнить, что так делать не надо, просто. Но в нашем случае комизм ситуации в том, что блокировку при создании синглетона вы попросту не видите, она скрыта в недрах базового класса, так что если вы не подумаете об этом, то вполне можете забыть о потенциальной проблеме и сделать то, чего делать нельзя.

Когда мы дойдём до поведенческих паттернов (behavioral patterns), мы ещё столкнёмся с паттерном Observer (Наблюдатель, Подписчик — как только его не переводят), который всегда представляет собой большую проблему в многопоточном окружении и требует особых плясок с бубном. Здесь всё немного проще, но если неудачно организовать синхронизацию, тоже реально нарваться. Это означает, что нам недостаточно просто пронаследоваться от Singleton и забыть..

Метакласс

Говорят, если вы думаете, не использовать ли метаклассы, они вам не нужны, и это во многом так, хотя метаклассы позволяют делать много интересного. Можно редактировать иерархию классов. Можно добавлять к классам методы. Да вообще с классом можно сделать чуть ли не что угодно, даже контролировать процесс создания его экземпляров через метод __call__. Вот и реализация синглетонов через метакласс подоспела:

import threading


class SingletonMeta(type):
    _instances = {} # Словарь для хранения экземпляров
    _lock = threading.RLock()

    def __call__(cls, *args, **kwargs):
        """
        Управляет созданием экземпляров класса.
        """
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:
                    instance = super().__call__(*args,
                        **kwargs)
                    cls._instances[cls] = instance
        return cls._instances[cls]


class MySingleton(metaclass=SingletonMeta):
    def __init__(self, *args, **kwargs):
        ...

Здесь проблемы с повторным вызовом конструктора, естественно, нет: конструктор вызывается в super().__call__(), к которому мы обращаемся ровно один раз.

Спросите ChatGPT, он обязательно расскажет, как это элегантно и прекрасно. Некоторая красота, конечно, есть, это правда. Но мы по-прежнему заложили в свои синглетоны мину с объектом синхронизации, наличие которого не вполне очевидно пользователю метакласса.

Borg, или шалость удалась

Название паттерну, который предлагается как альтернатива синглетону, придумали хорошее: в «Star Trek» Borg — коллективный разум, все его представители имеют полностью коллективное сознание. Да и вообще явно весёлый человек в своё время его придумал. Пусть, дескать, каждый, кто хочет, создаёт объект класса, и все объекты даже будут как будто разные, но внутри одинаковые — все данные у них всегда одни и в одном экземпляре существуют. Ну что, шутка вышла хорошая.

class Borg:
    _shared_state = {}  # Общий словарь для всех экземпляров

    def __new__(cls, *args, **kwargs):
        obj = super(Borg, cls).__new__(cls)
        obj.__dict__ = cls._shared_state
        return obj

    def __init__(self, value):
        self.value = value


b1 = Borg(10)
b2 = Borg(20)
print(b1.value)
print(b2.value)

Как видите, здесь словарь атрибутов объекта при создании экземпляра заменяется на общий для всех экземпляров словарь. Наследования, естественно, не предполагается (все потомки будут иметь общий __dict__). Но это нетрудно реализовать и через метаклассы, да и через базовый класс тоже довольно легко. Было бы зачем. Вот скажите, зачем вам делать синглетон, где вы имеете разные объекты с разным id, которые одинаковы по своей сути? Это остроумно (вообще я рукоплещу автору за идею), но вряд ли практично, так как может вызывать путаницу. Одно дело явно вызвать get_что-то-там(), понимая, что вы получите всегда один и тот же объект, а совсем другое — создавать обычные классы, возможно, даже не догадываясь об их «борговской» природе, видеть, что создаются разные объекты, а потом удивляться, что их атрибуты меняются непонятно когда и непонятно как.

И — вишенка на торте! — этот код два раза напечатает 20, а вовсе не 10, как в случае с синглетоном. Потому что второе создание борга тоже вызвало __init__. Браво! В общем, для практического кода паттерн кажется ужасным.

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

def borg():
    class _BorgBase:
        _shared_state = {}
        def __new__(cls, *args, **kwargs):
            obj = super().__new__(cls)
            obj.__dict__ = cls._shared_state
            return obj
    return _BorgBase


class Borg1(borg()):
    def __init__(self, value):
        self.value = value


class Borg2(borg()):
    def __init__(self, value):
        self.value = value

Идея проста. Класс _BorgBase определён не один раз, а внутри области видимости функции borg(). Каждый вызов функции порождает новый класс со своим словарём атрибутов для экземпляров. И экземпляры Borg1 абсолютно идентичны по данным, Borg2 — тоже, но между собой Borg1 и Borg2 не связаны, что и будет естественным поведением (насколько вообще поведение боргов может быть естественным).

Скрытый текст

Кстати, сам приём создания класса внутри функции — штука иногда полезная. И это, как правило, много проще всех этих адских метаклассов, но позволяет делать многое из того, для чего метаклассы используют.

В чём же сильная сторона решения? А в том, что ему не нужны объекты синхронизации. Создание словаря атрибутов объекта происходит очень рано, на этапе объявления класса (в случае с borg() — класса-наследника), а поскольку объявление, вероятно, происходит на уровне модуля, инициализация класса всегда производится ровно один раз и потокобезопасно, думать о синхронизации вообще не приходится. Это плюс. Но странности подхода он не перевешивает.

А как, собственно, надо?

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

Давайте подумаем, чего мы обычно ждём от синглетона, и прежде всего это:

  1. Ленивая инстанциация (при первом использовании).

  2. Единственность экземпляра до конца работы программы.

Но, согласитесь, это ведь уже напоминает нам что-то. Что-то очень, очень знакомое, присутствующее в языке, наверное, с начала его существования (я начал с Python 2.4 в 2007 году, и оно уже было). И это… модуль. Просто питоновский модуль. Он инициализируется при первом import. Сколько бы раз вы его ни импортировали, он инициализируется ровно в одном экземпляре, если вы не используете специальные экзотические трюки типа ручной загрузки модулей, конечно.

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

Посему вот такая реализация синглетона (естественно, это не просто кусок кода, а отдельный модуль), нравится мне куда больше:

class _MySingleton:
    ...

    
_MY_SINGLETON = _MySingleton()


def get_my_singleton():
    return _MY_SINGLETON

Часто можно и функции не делать, а просто глобальный объект использовать, но и так неплохо. Только надо помнить, что не в момент вызова функции создаётся наш объект, а при первом импорте модуля.

Единственное, чего у модуля нет — это параметров инициализации. Но ведь синглетон, как правило, настраивается (если вообще настраивается) один раз, и его параметры в 99% случаев — это просто часть глобальной конфигурации системы. Часто это можно решить наличием файла settings.py, который импортирует каждый нуждающийся. А ещё можно настройки синглетона держать в специальном модуле, хотя это тоже стрельба из пушки по воробьям.

Допустим, есть у меня в системе картографическая коллекция (попросту — контейнер с информацией о множестве карт) плюс интерфейс для некоторых операций с картами.  Для инициализации нужен путь к коллекции карт.

Скрытый текст

К вопросу, нужно ли такое вообще делать синглетоном. Даже если создание двух экземпляров корневых объектов коллекции в одном приложении очень маловероятно, зачем это запрещать? И даже если это физически невозможно (допустим, коллекция использует при инициализации какой-то ресурс, имеющийся в ограниченном количестве или даже в единственном экземпляре), почему просто не отдать контроль за временем жизни пользователю? Пусть он инстанцирует (и разрушит) корневой объект тогда, когда ему надо. У него есть общая картина приложения, ему это совсем не трудно. В общем, когда я проектировал реальную картографическую библиотеку, коллекция карт совсем не была синглетоном.

Абстрагируемся от того, нужен ли тут синглетон, просто представим, что нужен. Теперь реализуем два модуля примерно такого содержания:

## chartcoll.py
# Это модуль для пользователей
CHART_PATH = "."

class IChartCollection:
    pass  # Здесь будет интерфейс

def get_chart_collection(path: str) -> IChartCollection:
    global CHART_PATH
    CHART_PATH = path
    import chartcoll_impl
    return chartcoll_impl.CHART_COLLECTION


## chartcoll_impl.py
# Этот модуль спрятан
from chartcoll import CHART_PATH, IChartCollection

class ChartCollection(IChartCollection):
    def __init__(self, path: str):
        pass

CHART_COLLECTION = ChartCollection(CHART_PATH)

Точкой доступа к нашему синглетону становится chartcoll.py, и, строго говоря, выглядит всё это для пользователя совершенно как старый добрый синглетон в стиле C++. С тем отличием, что этот модуль содержит только интерфейс, а глобальный объект находится совсем в другом модуле, chartcoll_impl.py.  chartcoll_impl.py свободно может импортировать первый модуль и прочитать путь из CHART_PATH. Громоздко? Может быть, но учитывая, что синглетонов в системе не должно быть много, это приемлемо. Зато ни слова о синхронизации сказать не пришлось. Присваивание строки переменной обычно атомарно (впрочем, поговорим и об этом), загрузку модуля синхронизирует сам интерпретатор. И главное — это нужно в тех редких случаях, когда вам не обойтись без передачи параметра. А вообще — надо ли?

Скрытый текст

Возможно, в реальной системе стоит всё сделать иначе. Корневой объект API библиотеки — сам главный модуль библиотеки. Инициализировать его особо не надо, он просто представляет собой средство создания объектов библиотеки. И всё. Дальше пользователь волен как угодно их создавать и как угодно передавать в разные части программы. Никаких синглетонов.

В общем, логика проста:

  1. Объект без состояния, служащий просто корнем интерфейса — не надо даже класса создавать. Просто модуль.

  2. Объект посложнее, но без параметров создания — просто глобальный объект в модуле. Или — внезапно — вообще класс, а не объект (только методы не забудьте сделать статическими). Импортировали модуль — и используете.

  3. Объект, полагающийся на глобальные настройки — ну, наверное, он может прочитать их из модуля settings. Можно, конечно, использовать и подход с двумя файлами, но я бы скорее не стал.

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

А на кой он вообще нужен?

Я давно недолюбливаю этот паттерн. Даже в C++ обычно использовал его разве что в ситуации, когда мне было нужно выдать интерфейс корневого API некоей библиотеки. И уже этот API был лишь средством создания других объектов библиотеки. В C++ последовательность инициализации модулей вообще бывает непредсказуема, там приходится придумывать что-то. Но в Python она предсказуема до невозможности: первым загружается тот, кого первым импортировали. И если вы импортировали какой-то модуль к себе, то после этого импорта вы имеете полное право рассчитывать, что модуль инициализирован.

Словом, зачем тут вообще синглетоны по модели C++? Привычные реализации этого паттерна до ужаса непитоничны (unpythonic). И всё равно, по сути, синглетон — что-то вроде глобального объекта, лишь немного более цивилизованного. Он, конечно, облегчает жизнь тем, кому лень обдумывать способы передачи нужного контекста в нужное место программы, но чаще не так уж и ценен. И даже если вы решили его применить, вместо традиционных реализаций проще использовать модули.

О жизни без GIL

И последнее. Сейчас появляется вариант CPython без GIL. Пока это экзотика, и я подозреваю, что до продакшена это всё дойдёт не так скоро, поскольку большинство расширений, которые отчасти и делают Python таким привлекательным, завязаны на GIL.

Скрытый текст

Когда в 2007 году я открыл для себя язык Python, его третья версия только вышла, ещё никто ею толком не пользовался. Реально поддерживаемой большинством расширений она стала лет через семь-восемь. Вероятнее всего, версия Python без GIL будет пробивать себе дорогу не меньше.

И единственной реализацией синглетона, которая будет заведомо и без проблем работать там без изменений, окажется простое использование модуля и глобальной переменной в нём. Или класса (не объекта) в модуле. Даже без плясок с get_что-то-там(). Просто переменной.

Почему? Да потому что модуль не может быть ни полуинициализированным, ни дважды инициализированным, ни инициализируемым из нескольких потоков параллельно, на уровне механизма импорта модулей о потокобезопасности позаботятся без вас. Именно такая реализация будет работать везде, вообще везде. И только она.

Выводы

То, что в C++ — смерть (глобальные объекты), в Python оказывается самой адекватной реализацией синглетона просто по причине принципиально другого подхода к инициализации модулей.  И не надо колоть дрова поперёк волокон, таща в язык неприменимый к его особенностям опыт из других языков. Есть то, что конфликтует с принципами языка, и о таких вещах стоит забыть.

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


  1. Tishka17
    01.12.2025 14:30

    Начиная с какой-то версии в модуле можно определить __getattr__ и таким образом реализовывать ленивое создание объекта https://peps.python.org/pep-0562


    1. reim Автор
      01.12.2025 14:30

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


      1. reim Автор
        01.12.2025 14:30

        Тьфу, не создание, адресация атрибута, конечно.


  1. Andrey_Solomatin
    01.12.2025 14:30

    Я так его и делаю, когда тесты не пишу. А когда пишу избегаю этот паттерн.


    1. reim Автор
      01.12.2025 14:30

      Да, чем его меньше, тем обычно лучше. Я несколько раз наступил на грабли ещё на C++ в 90-х, потом, как и писал, делал только для корневого интерфейса модуля.


  1. ammo
    01.12.2025 14:30

    Мысль хорошая, но очень длинно.

    TLDR: просто используйте глобальную переменную в отдельном модуле.


    1. reim Автор
      01.12.2025 14:30

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


    1. Antra
      01.12.2025 14:30

      При этом ведь важно всегда это модуль импортировать одинаково?

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

      От такого есть защита, или только на внимательность уповать?


      1. reim Автор
        01.12.2025 14:30

        К счастью, такого нет, защита не нужна, она встроенная. Обычные импорты, сколько и как ни делай, дают ровно один элемент в sys.modules. Способ задания пути влияет на поиск модуля и адресацию его в конкретном месте — и только. Вот если грузить исходники руками, задавая имя модуля — да, поломать можно, но это надо именно ломать, не ограничиваясь стандартным механизмом.


        1. Antra
          01.12.2025 14:30

          Это радует. Спасибо.


  1. funca
    01.12.2025 14:30

    GoF была хорошей книжкой 20 лет тому назад. Но сейчас от неё толку не больше, чем от египетских иероглифов. Все полезные паттерны уже реализованы в самих языках, а вредные мирно покоятся в своих гробницах. Не надо их оттуда откапывать, там нет золота - только прах, кости и древние проклятия с граблями.


    1. reim Автор
      01.12.2025 14:30

      Это, на мой взгляд, не так. Эти паттерны вообще не про языки, хотя реализуются в разных языках иногда по-разному. Скажем, какой-нибудь Visitor как использовался для обхода и преобразования AST в парсерах и компиляторов 30 лет назад, так и используется. Итераторы и фабрики как были, так и есть (и наличие в Python стандартного протокола итератора ничего не меняет, полно и нестандартных реализаций). Прототипы, адаптеры — что поменялось? И всё так же. ООП изменилось не так сильно, менялись в основном языки. Никуда эти идеи не делись, просто опыта их реализации добавилось, появились новее решения. Это не так ново, как в 90-х, когда я это прочитал, но по-прежнему существует.


      1. funca
        01.12.2025 14:30

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

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

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


        1. reim Автор
          01.12.2025 14:30

          Реализации меняются. И сильно. Примеры тут — это вообще не напоминает примеры в GoF, но ключевое в их паттернах — идеи. Итератор — не языковая конструкция, это идея отделить контекст итерации от итерируемого контейнера. Синглетон — это не про функцию, возвращающую свою статическую переменную, как это часто делали в C++. Это про саму идею объекта, существующего в одном экземпляре.

          О самих паттернах я тут не пишу. Я пишу, как это можно (и как не надо) их реализовывать в современном Python, это более прикладная вещь.


  1. shorin_nikita
    01.12.2025 14:30

    Полностью не согласен. GoF паттерны - это не магия, это язык для коммуникации между разработчиками. Да, многие реализованы встроенными средствами, но нужно понимать, ЧТО ты используешь и ПОЧЕМУ. Singleton, Observer, Factory - это не просто наборы кода, это определения проблем и решений. Разработчик, который не знает этих паттернов, просто переизобретает их заново каждый день, но хуже.


    1. reim Автор
      01.12.2025 14:30

      Со статьёй или с предыдущим оратором? Я-то тоже поныне всё это использую.


    1. Dhwtj
      01.12.2025 14:30

      Понятийный аппарат сильно обновился с тех пор:

      Билдер и адаптер используются. Итератор только как часть языка. Фабрика как конструктор.

      Сейчас говорят

      Observer - pub/sub, events, reactive streams

      Chain of Responsibility - middleware, pipeline

      Decorator - middleware, wrapper

      Command - handler, action, callback

      Strategy - policy, просто "передай функцию"

      State - state machine, FSM

      Visitor - (просто не говорят, делают match)

      Facade - API, client, wrapper

      Proxy - middleware, interceptor

      Новый понятийный аппарат, которого у GoF нет тоже разросся. Например:

      Streams - потоки данных (reactive)

      Actors - изолированные сущности с mailbox

      Channels - коммуникация между задачами

      Effects - контролируемые сайд-эффекты (ФП)

      Combinators - композиция функций


      1. reim Автор
        01.12.2025 14:30

        От того, что вы что-то переименовали, а для чего-то предложили другие способы реализации, идеи поменялись. Кстати, набор паттернов расширяется, далее паттерны того же уровня, что GoF, сейчас есть и новые.

        Итераторы, например, не часть языка. В Python есть, скажем, протокол итератора. В 90% случаев, конечно, его и надо реализовывать. Но не всегда. Полно объектов с методом next(), например. И иногда это даже оправдано.

        Собственно, до итераторов, надеюсь, дойду, пока нет смысла обсуждать.

        А вот Observer? До сих пор на многих языках полно интерфейсов с методом on_что-то-там(), и это старый добрый Observer. Хотя есть другие способы нотификации объектов о событиях, но никакой не универсален. А сама идея жива.


  1. Dhwtj
    01.12.2025 14:30

    Синглтон противоречит ООП. Это не объект. Объект имеет жизненный цикл, синглтон не имеет.

    Если хотите в питон что-то на всю жизнь программы, то создайте иммутабельно где-то в main прокидывайте в DI

    GoF устарели ужасно. Согласен, решение обычно уже встроено в язык.

    Большинство GoF — это обходы ограничений C++/Java того времени:

    - Итератор встроен везде

    - Стратегия это функции высшего порядка

    - Команда это замыкание

    - Фабрика часто просто функция

    Актуальными остались немногие: Composite, Decorator (частично), State (иногда). И то — сильно проще реализуются.

    Влашин об этом хорошо: в ФП половина паттернов исчезает, потому что функция — уже и стратегия, и фабрика, и команд


    1. reim Автор
      01.12.2025 14:30

      Я, собственно, написал, для чего синглетон можно и нужно использовать (например, корневой интерфейс библиотеки, желательно — без состояния) и почему обычно его стоит избегать. И это очень узкая область. Никакому ООП он не противоречит совершенно, просто у него сейчас очень мало валидных сценариев использования. И потом, как можно не иметь жизненного цикла? И как синглетон может не быть объектом? У него просто специфический жизненный цикл.

      А что паттерны GoF устарели — не согласен категорически. ООП (не языки) изменилось за 30 лет мало. Подозреваю, вы просто не нуждаетесь во многих паттернах в повседневной деятельности.

      Мой личный топ — Builder (а как собрать, скажем, картографический объект, где куча возможных элементов?), Visitor (а как ещё после парсинга грамматики работать с AST?), Observer (это вообще в современном ПО часто), Iterator (на каждом шагу), Chain of Responsibility (в обработке запросов очень частый), Adapter. Это только то, что постоянно используется. Есть редкие, парочку вообще никогда не пришлось использовать в реальном ПО — Bridge, скажем.

      И никуда это не уходило. Просто очень много теперь есть и других паттернов. И архитектурных (уровнем повыше), и кодировочных (уровнем пониже). Паттерны — это вообще стандартные решения. И паттерны GoF вполне живы, просто много есть других паттернов.


      1. Dhwtj
        01.12.2025 14:30

        паттерны GoF вполне живы

        Как задачи живы, конечно. Но современное решение ну совсем не как в книжках GoF. А иногда насколько тривиальное, что и говорить не о чем.

        Builder это вообще ФП по определению. ООП тут костыль.

        Enum + match вместо Visitor/State/Strategy

        Closures/функции как жители первого класса вместо Command/Strategy

        Iterator встроен в язык Rust

        Chain of Responsibility через оператор "?"

        Вот честно, пришлось даже прикладывать немалые усилия чтобы вспомнить что стоит за каждым названием, а вот реализация на Rust вспомнилось сразу. Да и на c# не очень сложно, но не как в книжках


        1. reim Автор
          01.12.2025 14:30

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

          Что до встроенных итераторов — опять же, это языковой механизм. В Python вот тоже есть протокол итератора, но это не значит, что нельзя сделать класс с методом next(). Изредка это даже имеет смысл.

          А вот почему builder — это ФП? Вы говорите о реализации его цепочкой вызовов? Так цепочка вызовов — это не суть паттерна. Суть в том, что есть "рабочий стол" (объект-builder), есть способы "прикрутить" к собираемому новые части, а в конце — забрать результат. Когда-то я много писал конвертацию карт. И там было полно билдеров вообще без цепочек вызовов (ты изначально не знаешь, сколько и чего добавишь, цепочку не написать). Имхо, если правильно понял вас, именно цепочки напоминают ФП. Но это просто один способ оформления паттерна.

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


          1. Dhwtj
            01.12.2025 14:30

            Билдер основан хорош на иммутабельности, поэтому концептуально он ФП.

            Хорошо, не обязательно. Но я уже привык

            // Mutable builder тоже валидно
            let mut b = Builder::new();
            b.name("x");
            b.port(8080);
            let result = b.build();
            
            // Immutable/consuming — ближе к ФП
            let result = Builder::new()
                .name("x")      // self - Self
                .port(8080)     // self - Self  
                .build();       // self - T

            Оба работают. Но второй лучше:

            • Нет промежуточного мутабельного состояния

            • Цепочка трансформаций

            • Нельзя случайно переиспользовать builder после build()


            1. reim Автор
              01.12.2025 14:30

              Погодите, каким образом именно паттерн, а не один из способов реализации? Это лишь один из нескольких вариантов. Вот, кстати, хорошо, что заговорили, надо будет об этом в следующей статье написать.

              Самая частая моя реализация — есть, скажем, CrtObjectBuilder. Его инициализируешь, потом постепенно наполняешь, вызывая всякие add_attr(), add_id(), add_ref(), add_points() неизвестное количество раз, он копит. Потом зовешь build(), тот возвращает CrtObject, пакуя данные. В этом варианте вообще нет иммутабельности, но это тоже билдер.


            1. reim Автор
              01.12.2025 14:30

              Это хорошо. Но когда вы знаете, какой набор компонентов планируете добавить. А в случае с построением картографического объекта я часто этого вообще не знал. Допустим, получил от читалки исходного формата — добавил. Там это всё накапливается. Это тот случай, когда иммутабельность будет как минимум не очень эффективна. И реально долго заполняются контейнеры компонентов. Иногда добавление вообще в Observer.

              Так что вариант в стиле ФП хорош, но он не покрывает 100%. В вашем примере он, наверное, идеален, но в моём — непригоден.


    1. reim Автор
      01.12.2025 14:30

      Мне кажется, вы перепутали два уровня. В языке появились механизмы для более простой реализации паттернов. Это не значит, что паттерны потеряли смысл. Паттерн — это мотивация плюс решение. От того, что для реализация команды вы стали использовать замыкание, паттерн никуда не делся. Замыкание — это не сам паттерн, это лишь языковой механизм для его воплощения. У него ещё много применений. И книга GoF больше про саму идею паттернов, этот слой там не устаревает практически.


  1. 4youbluberry
    01.12.2025 14:30

    from typing import Final
    
    class Settings:
      ...
    
    settings: Final[Settings] = Settings()

    В питоне вы можете свободно импортировать экземпляр класса, Final не позволит переприсвоить значение в settings.

    Это никак не помешает вам создать ещё один экземпляр класса, но немного осмысленности и ваш код в разы более читаемый и интуитивный


    1. reim Автор
      01.12.2025 14:30

      Тоже, кстати, вариант.


    1. reim Автор
      01.12.2025 14:30

      Но я всё равно бы скорее стал работать с классом без экземпляра. Впрочем, это тоже рабочий способ.