Недавно мне в очередной раз довелось читать молодым коллегам курс по языку 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:
        _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

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

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 оказывается самой адекватной реализацией синглетона просто по причине принципиально другого подхода к инициализации модулей.  И не надо колоть дрова поперёк волокон, таща в язык неприменимый к его особенностям опыт из других языков. Есть то, что конфликтует с принципами языка, и о таких вещах стоит забыть.

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


  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. Andrey_Solomatin
          01.12.2025 14:30

          Если импортировать один и тот-же модуль из разных папок, например добавить текущую директорию и её родителя в sys.path то можно. Разные пути, разные модули.


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

            Но тоже надо постараться. Редактировать sys.path между загрузками, скажем.


  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. reim Автор
          01.12.2025 14:30

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

          Вот буквально на днях я писал парсер языка на PLY. И понял, что мне нельзя запихать в yacc полную грамматику: сообщения об ошибках становятся невразумительными. И я стал парсить код как набор операторов, который иерархически организован в блоки. Каждый оператор парсится, структура блоков тоже. Собираю AST, даже если сочетания операторов бредовые и бессмысленные. А сгенерировал AST — пошёл по нему визиторами и там ВРУЧНУЮ проверил осмысленность сочетаний, выдавая осмысленные ошибки, а не «Syntax error near token '\n'» на десять строк выше места проблемы, поскольку правило не матчится именно оттуда. Если в терминах C, то сгенерированный парсер пропустит такой бред: i = i+1; {main() {return 0;}}. Паттерн можно назвать «Ограничение сферы ответственности сгенерированных парсеров». Позволяет анализировать структуру даже не вполне корректного кода. Он теперь есть в голове, в следующий раз уже знаю, что делать. не вполне корректного кода, даёт осмысленные сообщения об ошибках. Есть сфера применения, есть решение. Язык реализации и средство автогенерации парсеров по грамматике значения не имеют. Вполне личный паттерн. Правда, уже узнал, что так иногда и другие делают. Значит, вообще нормальный паттерн. Для тех, кто пишет парсеры. Будет в моей кулинарной книге и дальше.

          И, кстати, эта конкретная статья ровно про предостережение, чего делать НЕ НАДО. Я намеренно собрал рекомендации, которые считаю — в основном — идиотскими (Но они очень стойкие. Каждый второй лепит подобное на каком-то этапе. Спросишь ChatGPT — обязательно посоветует их.) С объяснением, что все эти реализованные руками потокобезопасности и прочее не так универсальны, и если уж язык предоставляет готовое подходящее средство, использовать надо именно его, а не DCL и прочую хрень.


          1. funca
            01.12.2025 14:30

            Во время когда GoF написали свою книжку возможности C++ и Java были куда скромнее. Паттерны фактически показывали способы как обходить типичные ограничения языка в парадигме ООП. С точки зрения современных языков они выглядят довольно низкоуровневыми. Ну и избыточными - зачем городить такой огород, когда всё уже есть из коробки? Наличие функций высшего порядка, генераторов и библиотеки абстрактных типов, как в python, даёт возможность просто писать код, не замечая, что у других тут могут быть какие-то особенные сложности.

            По-моему ценность GoF больше в том, что они вдохновили других авторов по аналогии разбирать и искать типичные решения типичных проблем в своих областях. Книги, которые рассматривают не ООП, а различные архитектуры: Enterprise Integration Patterns, Reactive Design Patterns или Microservices Patterns были уже полезными.


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

              Книг было довольно много. Была ещё в девяностых серия книг про архитектурные паттерны, паттерны параллельного и распределённого программирования и прочее. Это паттерны на других уровнях абсиракции, и архитектору без этого, спору нет, никак.

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

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


  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. BobovorTheCommentBeast
          01.12.2025 14:30

          Как enum + match заменяет Стейт, когда он буквально создан для того, что бы уйти от всяких свичкейсов и их ограничений.

          PS Стейт наверное один из самых красивых паттернов (и самый усложняющей код)


          1. Dhwtj
            01.12.2025 14:30

            Усложняющий если писать по GoF

            struct State {
                virtual void handle(Context& ctx) = 0;
            };
            
            struct Done : State {
                void handle(Context& ctx) override { /* stay */ }
            };
            
            struct Running : State {
                void handle(Context& ctx) override {
                    ctx.setState(new Done());
                }
            };
            
            struct Idle : State {
                void handle(Context& ctx) override {
                    ctx.setState(new Running());
                }
            };

            Вместо этого даже c++ может красиво написать

            enum class State { Idle, Running, Done };
            
            State handle(State s) {
                switch (s) {
                    case State::Idle: return State::Running;
                    case State::Running: return State::Done;
                    case State::Done: return State::Done;
                }
            }


            1. cupraer
              01.12.2025 14:30

              Стейт, устанавливаемый снаружи! Что дальше? Стейт на операторах goto?


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

              Ну, вообще они от свичей уходили. Для сложной стейк-машины это очень много ошибок даёт. На трёх состояниях, конечно, и так можно. Если честно, в Питоне нет нужды создавать объекты, если от стейта нужен только handle, а не целое семейство методов. Функции на состояние совершенно достаточно. А в сочетании с генератором или async можно реализовать весьма сложную логику внутренних стейтов и иначе — это если у вас, как часто рисуют на диаграммах UML, внутри состояния есть своя машина.

              Кстати, стейт в лексерах lex/yacc делается в одном объекте, но разными наборами методов.


              1. Dhwtj
                01.12.2025 14:30

                GoF уходили от switch на полиморфизм чтобы гарантировать что обрабатываются все варианты.

                Теперь это делает компилятор.


            1. BobovorTheCommentBeast
              01.12.2025 14:30

              Фишка стейта не в самих хендлерах, которые действительно можно свичкейзом, а в SwitchState, AtEnter, AtExit методах


              1. Dhwtj
                01.12.2025 14:30

                Enter/Exit — это размазывание логики перехода между двумя местами. С SwitchState - тремя. Переход знает откуда и куда, пусть там и живёт вся логика.

                Исключение — если реально нужны инварианты: "при любом входе в Running запусти таймер". Но чаще это признак что состояние слишком крупное или переходов слишком много.


                1. BobovorTheCommentBeast
                  01.12.2025 14:30

                  Стейт паттерн это именно то, когда состояние очень крупное и переходов много.


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

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


    1. Tishka17
      01.12.2025 14:30

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

      и тем не менее надо понимтаь что это, когда полезете реализовывать кастомный

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

      Можно всё свести к передаче функций в функции, польза паттернов GoF в том что они приводят примеры как в разных ситуациях похожие штуки меняются.

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

      у GoF нет просто фабрики, там абстрактная фабрика. Польза в абстракции.

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

      Так можно и про ООП сказать, всё объект, паттерны лишь описывают наследолвание да композицию. Можно абстрагировать до потери смысла, только пользы от этого ноль. Польза паттернов же есть - они приземляют очень общие механики до конкретных способов применения. Понятно, что в ФП надо приземлять чуточку по другому, но сценарии использования всё равно бывают разные.

      Я соглашусь, что в некоторых местах имеет смысл перекомпоновать паттерны (например мне всё ещё непонятно почему lazy proxy и прокси с проверкой доступа - один паттерн, а декоратор - другой), но карданально это не изменит. А вот синглтон - практически единсвтенная вещь там которая вреда приносит больше чем пользы (даже если предположить что польза есть).

      Против DI ничего не имею - отличная вещь, всем рекомендую вместо синглтонов


      1. Andrey_Solomatin
        01.12.2025 14:30

        Есть ещё путаница с концепцией языка. Тот же декоратор в питоне может быть и прокси и кэш и декоратор и для обзёрвера использоваться, и фабрику можно с ним строить.


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

          Именно! Потому я и говорю, что паттерны актуальны. Паттерны — это в первую очередь "что и зачем". А реализация — это про "как".


    1. Andrey_Solomatin
      01.12.2025 14:30

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

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


      1. Dhwtj
        01.12.2025 14:30

        Аналогично.

        Поэтому я не воспринимаю их как паттерны а как часть языка


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

        Я тоже смотрю чуть с другого. В спецификацию языка включили не паттерны, а средства их реализации. Скажем, асинхронное выполнение кода — тоже вполне себе паттерн. Очень долго всё было на колбэках. А потом в языки один за другим стали пролезать способы писать этот код совсем иначе, даже до C++ уже доехало. Были паттерны (повыше уровнем, чем 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

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


  1. bbc_69
    01.12.2025 14:30

    Синглтон. Помню, на прошлой работе коллега сначала вкорячивал синглтон, а потом его убирал, т.к. оно тестам мешает. Лично я пришёл к выводу, что лучшая реализация - это конвенция. Инициализируешь в модуле и говоришь всем, что settings менять нельзя. А в тестах можно делать, что хочешь. С ..._imp.py хорошая идея, в тех же тестах можно использовать.

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


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

      Да, случаев, когда он нужен, немного. Settings — действительн сами по себе синглетон. И да, инициализация в модуле и соглашения. В случае с settings я часто использую соглашение, что все донастройки settings — в главном модуле, в функции main и только до начала инициализации содержательной части системы (ну или в главной запускалке тестов — тоже до какой-то содержательной активности). Больше никто этого делать не должен. С чем стартовали, с тем живём. Ну, скажем, после разбора параметров командной строки и просмотра окружения выставил я язык локализации — и начал поднимать компоненты. И всё, дальше туда не лезть, только читать.

      Можно, конечно, запретить запись в settings централизованно. ))) Но одно дело — в сами поля settings, а вот каждый элемент каждого словаря или списка замучишься защищать. Так что, если в проекте нет толпы джуниоров, можно не париться.


      1. Andrey_Solomatin
        01.12.2025 14:30

        Если есть mypy или что-то подобное, то можно защитить при помощи аннотаций типов. Поставить на объект типа dict аннотацию только для чтения https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping и ловить все изменения. Это вполне бюджетно.


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

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

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


          1. Antra
            01.12.2025 14:30

            Раз пошла такая пьянка... (с)
            Что с ty от astral-sh? Уже можно использовать для мелких целей? Поскольку в Antigravity все равно нет pylance, так может использовать не только uv и ruff, но и ty как в IDE, так и для проверки типов?


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

              Я пока не пробовал. Как в своё время стал юзать mypy, так поныне и юзаю. Если услышу, что что-то сильно лучше появилось, попробую, но пока только знаю, что есть несколько других чекеров — и всё.

              В шаблоне проекта у меня mypy, flake8 как линтер, наконец black для форматирования.

              Лишний раз экспериментировать с инфраструктурой, если нет наводки, что что-то уж очень хорошо, мне лень.

              Вот мой примерный шаблон проекта для генерации исполняемого модуля через PyInstaller, там много старья типа запускалки тестов, написанной почти 20 лет тому. Но работает, а работает — не трогай:

              https://github.com/twinpigs-agile/python-prj-template


              1. Andrey_Solomatin
                01.12.2025 14:30

                Я на ruff пересел, вместо flake8 и black. Устанавливаются быстрей, работают быстрей, делают тоже самое. Ну и кроме этого всю контору перевели на него и uv.


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

                  Ну, так у меня оно и так ест не так много. Black же вообще даже не в пайплайне сборки, это один запуск перед коммитом. Если б они делали что-то другое... А так-то зачем?


                  1. Andrey_Solomatin
                    01.12.2025 14:30

                    Если вы работаете с кодом один, тогда норм запускать только локально. Хотя мне лень запускать и я через https://pre-commit.com/ настроил.


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

                      Black — локально. А пайплайн сборки (включая PR) на GitHub Actions. Естественно, линтер и контроль типов в него включены.

                      То есть форматирование своих изменений каждый делает сам. Не отформатированное просто не принимается.


                      1. Andrey_Solomatin
                        01.12.2025 14:30

                        Не отформатированное просто не принимается.

                        Это делается человеком? Обычно black --check на CI запускают.


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

                        flake8

                        И оно, конечно, в пайплайне на PR


                      1. Andrey_Solomatin
                        01.12.2025 14:30

                        flake8 проверяет форматирование, но не гарантирует, что оно 100% совпадает с black. То есть если кто-то забыл отформатировать, то возможно следующий запуск отформатирует что-то из прошлого комитета. Может быть у вас железная дисциплина и никто не забывает запустить black перед комитетом.


                    1. funca
                      01.12.2025 14:30

                      Вместо pre-commit можно использовать prek https://github.com/j178/prek . Он с большего совместим, быстрее, запускает проверки параллельно, проще добавлять кастомные правила.

                      mise, prek, poethepoet, uv, ruff, import-linter, basedpyright.


                      1. Andrey_Solomatin
                        01.12.2025 14:30

                        Спасибо, посмотрю.


              1. Andrey_Solomatin
                01.12.2025 14:30

                Вот мой примерный шаблон проекта для генерации исполняемого модуля через PyInstaller, там много старья типа запускалки тестов, написанной почти 20 лет тому. Но работает, а работает — не трогай:

                Хотя бы ради ваших студентов обновите на то, что в тренде.


              1. bbc_69
                01.12.2025 14:30

                наконец black для форматирования.

                Нет ничего хуже, чем black для форматирования.


                1. Andrey_Solomatin
                  01.12.2025 14:30

                  У него своя философия которая экономит время при работе в команде. Чем пользуетесь вы и сколько у вас настроек не по умолчанию?


                  1. bbc_69
                    01.12.2025 14:30

                    КМК, это философия совсем не подходит к Питону.

                    С форматтерами проблема, да. Лично мне больше всего нравился yapf, но он заброшенный и имел какие-то проблемы. В итоге остановился на autopep8. Да, не настолько функционален, зато не превращает код в нечитаемое нечто.


                    1. Andrey_Solomatin
                      01.12.2025 14:30

                      Мне по началу тоже было непривычно, но потом норм. Простота настройки и отсутствие споров какой параметр лучше того стоит.


      1. bbc_69
        01.12.2025 14:30

        Мой поинт был в том, что запретить-то можно. Только гемморрно и чаще мешает, чем помогает.

        И у меня settings инициализировался ровно один раз при чтении файла конфига. Всё.


  1. cupraer
    01.12.2025 14:30

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


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

      Вы вообще о чём? Какие ноды, какой кластер? Синглетон в одном процессе, и это не имеет никакого отношения к распределённым вычислениям вообще. Как и все паттерны GoF, кстати.

      P. S. Распределёнными системами я занимался, но тут-то это при чём?


      1. cupraer
        01.12.2025 14:30

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

        Синглетон в одном процессе […]

        Тогда к чему все эти приседания с бесполезными локами и мьютексами? — Так и пишите, работает в одном процессе, на 640КБ оперативы, чего должно хватить всем.


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

          Ну-ну. Куча кода работает вообще в одном процессе. Что-то масштабируется горизонтально, но там синглетон и нужен в каждом процессе.

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


          1. cupraer
            01.12.2025 14:30

            масштабируется горизонтально, но там синглетон и нужен в каждом процессе

            Уровень ясен, прошу прощения, что влез в вашу песочницу.


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

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


              1. cupraer
                01.12.2025 14:30

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


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

                  Я вас уже простил, вечная вам память.


    1. Dhwtj
      01.12.2025 14:30

      запускается несколько синглтонов

      Просто одинаковая строка в конфиге каждой ноды. То есть да, но в терминах синглтонов никто не думает в таких случаях


      1. cupraer
        01.12.2025 14:30

        Что нафиг значит «в таких случаях»? Вот есть у вас синглтон, который занят тем, чем обычно заняты синглтоны: что-то там апдейтит внутри себя атомарненько (read-only синглтоны адекватные люди не используют, вместо них отлично подходит статический файлик с конфигом).

        Тут в ваш бизнес приходит успешный успех, люди регистрируются, как не в себя, вы решаете «горизонтально отмасштабироваться» (не по-настоящему, а как это принято в прошловековых языках, не готовых к кластеризации — просто втыкаете рядом еще один сервер, не меняя код). Всё работает, нагрузка держится, баблосы пилятся. Только то, что там внутри себя держит синглтон — на разных нодах разное, никак друг с другом не связанное, и ваше приложение вдруг начинает раздавать 100 скидок вместо запланированных пятидесяти (не надо мне про базу рассказывать, это просто пример).


        1. Andrey_Solomatin
          01.12.2025 14:30

          Только то, что там внутри себя держит синглтон — на разных нодах разное, никак друг с другом не связанное,

          начинает раздавать 100 скидок вместо запланированных пятидесяти

          Что-то кажется пропущенно в середине. Я понимаю, что вы хотите проиллюстрировать, но не понимаю вашего примера.

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


          1. cupraer
            01.12.2025 14:30

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

            Что-то кажется пропущено в середине.

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

            Теперь мы воткнули еще три ноды — и раздали 200 скидок, 50×4, потому что у каждой ноды свой счетчик.


            1. funca
              01.12.2025 14:30

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

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

              GoF писали текстовый редактор для десктопа и в нем черпали вдохновение. Понятно, что ни о каких микросервисах там речи не шло. Что работало в их ситуации, в другой может и не работать. Здесь нет противоречия.


              1. cupraer
                01.12.2025 14:30

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

                Конечно. Вот только в современном мире они состоят из названия и, если повезет, примеров кода. Проблемы, контексты, ограничения — это все за бортом.

                Здесь нет противоречия.

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

                Микросервисы тут ни при чем. В современных реалиях даже на самом жалком ноуте — 16 ядер, а банальные монолиты — масштабируются кубером (и примерно все паттерны в лучшем случае ломаются сразу, а в худшем — превращаются в мины замедленного действия).


            1. Antra
              01.12.2025 14:30

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

              ... пока не перезапустили приложение...

              Теперь мы воткнули еще три ноды — и раздали 200 скидок, 50×4, потому что у каждой ноды свой счетчик.

              IMHO не в синглтоне здесь проблема.

              Как бы вы обеспечивали 50 скидок из своего примера даже на одной ноде безо всяких синглтонов, в т.ч. после перезагрузки сервера?

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


          1. cupraer
            01.12.2025 14:30

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

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


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

        Do not feed the troll


        1. cupraer
          01.12.2025 14:30

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


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

            Чё, подгорает, зелёненький мой?