Skip to content

Latest commit

 

History

History
363 lines (233 loc) · 35.3 KB

File metadata and controls

363 lines (233 loc) · 35.3 KB

Урок 5. Магический метод __new__ и управление созданием объектов. Пример паттерна Singleton

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

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

Два этапа создания объекта

Когда выполняется строка:

p = Point(1, 2)

интерпретатор проходит через последовательность из нескольких шагов. Сначала вызывается __new__, которому передаётся класс, на основе которого нужно создать объект, а также все аргументы, указанные при вызове. Задача __new__ — вернуть новый объект. Затем этот объект автоматически передаётся в __init__ вместе с теми же аргументами, и __init__ заполняет его данными. Уже настроенный объект возвращается в переменную p.

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

Что произойдёт, если __new__ ничего не вернёт

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

class Point:
    def __new__(cls, *args, **kwargs):
        print("Вызов метода __new__")
        print("cls =", cls)

    def __init__(self, x=0, y=0):
        print("Вызов метода __init__")
        self.x = x
        self.y = y
p = Point(1, 2)
print(p)
Вызов метода __new__
cls = <class '__main__.Point'>
None

Строка «Вызов метода __init__» не появилась вовсе — метод не вызвался. А переменная p в итоге хранит None. Причина в том, что тело __new__ не содержит оператора return, а функция без явного return в Python всегда возвращает None. Раз __new__ вернул None, Python интерпретирует это как «объект не создан», и вызывать __init__ попросту не для кого — не с каким объектом работать. В результате всё выражение Point(1, 2) целиком сводится к None.

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

Правильная реализация __new__

Чтобы объект действительно создавался, __new__ должен вернуть настоящий экземпляр класса. Взять его неоткуда, кроме как через базовую реализацию, унаследованную от object — того самого класса, от которого в Python неявно наследуются все остальные классы, даже если наследование не прописано явно.

class Point:
    def __new__(cls, *args, **kwargs):
        print("Вызов метода __new__")
        obj = super().__new__(cls)
        return obj

    def __init__(self, x=0, y=0):
        print("Вызов метода __init__")
        self.x = x
        self.y = y
p = Point(1, 2)
Вызов метода __new__
Вызов метода __init__

Строка super().__new__(cls) обращается к реализации __new__, определённой в object, и просит её создать «сырой» объект указанного класса cls. Этот вызов — единственный по-настоящему универсальный способ выделить память под объект; переопределяя __new__ в собственном классе, программист почти никогда не пишет логику выделения памяти с нуля, а лишь добавляет какие-то условия или действия вокруг вызова базовой реализации.

Здесь стоит явно проговорить, откуда вообще берётся object в классе, который был объявлен без указания родителя:

class Point:
    ...

Начиная с Python 3 любой класс, объявленный без явного указания базового класса в скобках, автоматически наследуется от object. Запись class Point: полностью эквивалентна записи class Point(object): — вторая форма просто делает наследование видимым, тогда как в первой оно происходит неявно. Именно в object реализованы базовые версии всех фундаментальных магических методов — __new__, __init__, __str__, __repr__ и многих других, — и когда собственный класс их не переопределяет, используются именно эти базовые реализации.

Почему первый параметр называется cls, а не self

Сигнатура __new__ отличается от сигнатуры обычных методов:

def __new__(cls, *args, **kwargs):

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

Аргументы *args и **kwargs, указанные в сигнатуре, нужны потому, что и __new__, и __init__ получают одни и те же аргументы, переданные при вызове класса. Строка Point(1, 2) внутри себя фактически превращается в два отдельных вызова — сначала Point.__new__(Point, 1, 2), затем Point.__init__(объект, 1, 2), — и оба метода должны быть в состоянии принять переданные 1 и 2, даже если, как в подавляющем большинстве случаев, __new__ эти аргументы просто игнорирует, передавая всю содержательную работу инициализации методу __init__.

Зачем вообще вмешиваться в __new__

Раз в обычных классах __new__ практически ничего не делает, кроме вызова super().__new__(cls), закономерен вопрос: зачем вообще существует возможность его переопределять?

Ответ в том, что иногда решение о том, создавать объект или нет — и если создавать, то какой именно, — нужно принять до того, как объект начнёт существовать. __init__ для этой задачи не подходит в принципе: к моменту его вызова объект уже создан, память под него уже выделена, и __init__ может только заполнить его данными, но не отменить его существование и не подменить одним объектом другой. А вот __new__, поскольку он сам возвращает объект, может вернуть не только новый объект, но и уже существующий, ранее созданный — либо объект другого класса, либо вовсе отказаться что-либо создавать. Именно эта возможность лежит в основе паттерна Singleton, к которому мы сейчас перейдём.

Паттерн Singleton

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

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

Убедимся сначала, что без специальных мер класс создаёт независимые объекты при каждом вызове:

class DataBase:
    def __init__(self, user, password, port):
        self.user = user
        self.password = password
        self.port = port

    def connect(self):
        print(f"Подключение к БД: {self.user}, {self.password}, {self.port}")
db1 = DataBase("root", "1234", 3306)
db2 = DataBase("admin", "5678", 5432)

print(id(db1))   # 140485739124560
print(id(db2))   # 140485739124720

id возвращает уникальный идентификатор объекта в памяти, и разные значения подтверждают: db1 и db2 — два полностью независимых объекта.

Реализация Singleton через __new__

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

class DataBase:
    __instance = None

    def __new__(cls, *args, **kwargs):
        if cls.__instance is None:
            cls.__instance = super().__new__(cls)
        return cls.__instance

    def __init__(self, user, password, port):
        self.user = user
        self.password = password
        self.port = port

    def connect(self):
        print(f"Подключение к БД: {self.user}, {self.password}, {self.port}")

Логика __new__ здесь предельно проста и стоит того, чтобы разобрать её построчно. Условие if cls.__instance is None проверяет, создавался ли объект раньше — изначально атрибут __instance равен None, а значит, объекта ещё не существует. Если это так, выполняется стандартное создание нового объекта через super().__new__(cls), и результат сразу сохраняется в cls.__instance — теперь класс «помнит» этот единственный экземпляр. Если же условие не выполняется — то есть __instance уже содержит ранее созданный объект, — метод сразу возвращает его, минуя создание нового.

db1 = DataBase("root", "1234", 3306)
db2 = DataBase("admin", "5678", 5432)

print(id(db1))   # 140485739124560
print(id(db2))   # 140485739124560

На этот раз оба идентификатора совпадают: db1 и db2 — это на самом деле один и тот же объект, лишь дважды «полученный» через вызов класса.

Скрытая проблема: повторный вызов __init__

Проверим работу класса на практике:

db1 = DataBase("root", "1234", 3306)
db2 = DataBase("admin", "5678", 5432)

db1.connect()
db2.connect()
Подключение к БД: admin, 5678, 5432
Подключение к БД: admin, 5678, 5432

Результат озадачивает: db1 создавался с параметрами root, 1234, 3306, но метод connect, вызванный именно у db1, вывел данные admin. Учитывая, что db1 и db2 — один и тот же объект, это, строго говоря, ожидаемо, но причина требует отдельного объяснения.

Дело в том, что Python вызывает __init__ после каждого вызова класса — независимо от того, был ли объект, возвращённый __new__, только что созданным или уже существовавшим ранее. Иначе говоря, полная последовательность действий при выполнении DataBase("admin", "5678", 5432) выглядит так: __new__ обнаруживает, что cls.__instance уже не None, и возвращает существующий объект — тот же самый, что был создан при первом вызове; но сразу вслед за этим Python, как и всегда, вызывает __init__ у возвращённого объекта, передавая новые аргументы "admin", "5678", 5432. __init__ не знает и не может знать, что объект уже был инициализирован раньше — он просто выполняет присваивания self.user = user и так далее, перезаписывая старые значения новыми.

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

Упрощённое решение: флаг инициализации

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

class DataBase:
    __instance = None
    __initialized = False

    def __new__(cls, *args, **kwargs):
        if cls.__instance is None:
            cls.__instance = super().__new__(cls)
        return cls.__instance

    def __init__(self, user, password, port):
        if self.__initialized:
            return

        self.user = user
        self.password = password
        self.port = port
        self.__initialized = True

Теперь __init__ действительно исполняется по существу только один раз: при первом вызове self.__initialized равен False, метод выполняет присваивания и в конце переключает флаг в True; при всех последующих вызовах __init__ видит True в самом начале и немедленно завершает работу через return, не трогая уже установленные атрибуты.

Такое решение работает, но справедливо считается не более чем рабочим компромиссом, а не элегантной архитектурой: оно добавляет в класс дополнительное скрытое состояние, о котором нужно постоянно помнить, и требует, чтобы точно такая же проверка была продублирована в каждом классе, реализующем Singleton подобным способом. В более серьёзных случаях эту задачу решают на уровне метаклассов или через переопределение __call__ у специального класса-фабрики — но эти инструменты требуют понимания материала, который будет разбираться в курсе значительно позже. На данном этапе достаточно понимать сам принцип: __new__ управляет тем, какой объект будет возвращён, а проблему повторной инициализации приходится решать отдельно, и не существует единственного «канонического» способа, который решал бы её без каких-либо компромиссов.

Другие практические применения __new__

Хотя Singleton — самый наглядный пример, возможности __new__ этим не исчерпываются. Три ситуации, в которых __new__ переопределяют на практике:

Во-первых, паттерны проектирования, требующие контроля над созданием объекта — помимо Singleton, сюда относится, например, паттерн Flyweight, при котором объекты с одинаковым содержимым не создаются заново, а разделяются между всеми, кому они нужны, что экономит память при большом количестве похожих по значению объектов.

Во-вторых, работа с неизменяемыми (immutable) встроенными типами — int, str, tuple и подобными. Поскольку такие объекты, единожды созданные, не могут быть впоследствии изменены, всё их состояние должно быть определено уже в момент создания, а не после него — а значит, если требуется настроить класс, унаследованный от одного из этих типов, значимая часть логики неизбежно переносится в __new__, потому что к моменту вызова __init__ содержимое объекта уже зафиксировано и изменить его нельзя.

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

Подводные камни

Главная ошибка при работе с __new__ — забыть вернуть объект. Если тело метода не содержит return, результатом вызова класса всегда становится None, а __init__ при этом не вызывается вовсе, поскольку инициализировать попросту нечего. Эта ошибка особенно коварна тем, что не сопровождается явным исключением — программа продолжает выполняться, просто переменная неожиданно оказывается равна None, и ошибка проявляется значительно позже, в момент, когда предпринимается попытка обратиться к атрибуту несуществующего объекта.

Вторая ловушка — ожидание, что __new__, вернув уже существующий объект, тем самым предотвратит повторный вызов __init__. Как было показано выше, это не так: Python вызывает __init__ после каждого обращения к классу безусловно, независимо от того, что вернул __new__. Если логика Singleton реализована только через __new__, без какой-либо защиты в __init__, состояние объекта будет непредсказуемо перезаписываться при каждом новом обращении к классу, даже если сам объект остаётся тем же самым.

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

Итоги урока

Создание объекта в Python — процесс из двух последовательных этапов: __new__ создаёт объект и обязан его вернуть, а __init__, получая уже созданный объект первым аргументом (в виде self), настраивает его, ничего не возвращая. __new__ получает первым аргументом не объект, а класс (cls), поскольку в момент его вызова объекта ещё не существует — создавать его не с чем, кроме как на основе класса.

Обычно __new__ в собственных классах не переопределяют вовсе, полагаясь на стандартную реализацию, унаследованную от object — базового класса, от которого неявно наследуются все классы Python. Но там, где нужно управлять самим фактом создания объекта — вернуть уже существующий экземпляр вместо нового, отказаться от создания, кешировать объекты по значению или ограничить их число, — единственный способ реализовать такую логику проходит через __new__, потому что к моменту вызова __init__ решение о том, какой объект создавать, уже необратимо принято.

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

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


Вопросы для проверки

  1. В чём принципиальная разница между тем, что делает __new__, и тем, что делает __init__?
  2. Почему первым параметром __new__ является cls, а не self?
  3. Что произойдёт, если __new__ не вернёт никакого значения?
  4. Что делает вызов super().__new__(cls) внутри переопределённого __new__?
  5. Почему класс Point, объявленный как class Point:, всё равно может использовать super().__new__(cls), хотя наследование нигде явно не указано?
  6. Что гарантирует паттерн Singleton?
  7. Почему в базовой реализации Singleton через __new__ метод __init__ продолжает вызываться при каждом обращении к классу, даже если объект уже существовал?
  8. В чём заключается решение с флагом __initialized, и почему оно считается лишь рабочим компромиссом?
  9. В каких ситуациях на практике действительно переопределяют __new__, помимо реализации Singleton?

Задачи

Задача 1.

Объявите класс AbstractClass, объекты которого создавать нельзя: вызов AbstractClass() должен возвращать не объект, а строку "Ошибка: нельзя создавать объекты абстрактного класса".


Задача 2.

Объявите класс OnlyNumbers, создаваемый как OnlyNumbers(value). Если value — число (int или float), должен создаваться полноценный объект класса. Если передано что-то другое — вызов должен вернуть строку "Передано не число".


Задача 3.

Реализуйте кеширование объектов по значению. Класс CachedData создаётся как CachedData(data). Если объект с таким же data уже создавался ранее, должен возвращаться тот же самый объект. Уже созданные экземпляры храните в атрибуте класса cache — словаре, общем для всех объектов.


Задача 4. (на повторение)

Объявите класс LimitedString, создаваемый как LimitedString(text, max_length). Если длина text превышает max_length, в атрибуте объекта text должна сохраняться обрезанная строка.


Задача 5.

Реализуйте упрощённую фабрику. Даны классы:

class Integer:
    def __init__(self, value):
        self.value = int(value)


class String:
    def __init__(self, value):
        self.value = str(value)

Объявите класс Converter, создаваемый как Converter(value), который должен вернуть объект Integer, если value — число, и объект String — в противном случае.

Пример использования:

a = Converter(10)
b = Converter("hello")

print(type(a), a.value)   # <class '__main__.Integer'> 10
print(type(b), b.value)   # <class '__main__.String'> hello

Задача 6.

Объявите класс SingletonFive, создаваемый как SingletonFive(name). Класс должен создавать первые 5 объектов как независимые экземпляры, а начиная с шестого вызова — всегда возвращать пятый, ранее созданный объект.


Задача 7.

В программе есть переменная TYPE_OS (1 — Windows, 2 — Linux) и два класса — DialogWindows и DialogLinux, каждый с атрибутом класса name_class.

TYPE_OS = 1  # 1 - Windows, 2 - Linux


class DialogWindows:
    name_class = "DialogWindows"


class DialogLinux:
    name_class = "DialogLinux"

Объявите класс Dialog, создаваемый как Dialog(name), который должен вернуть объект DialogWindows, если TYPE_OS == 1, и объект DialogLinux — иначе. Возвращённому объекту должен быть присвоен атрибут name.

Пример использования:

dlg1 = Dialog("Первый")

TYPE_OS = 2

dlg2 = Dialog("Второй")

print(type(dlg1), dlg1.name)   # <class '__main__.DialogWindows'> Первый
print(type(dlg2), dlg2.name)   # <class '__main__.DialogLinux'> Второй

Задача 8 (на повторение).

Объявите класс Point, объекты которого создаются как Point(x, y) и хранят координаты в атрибутах self.x и self.y. Добавьте метод clone(self), создающий новый независимый объект Point с теми же координатами.


Предыдущий урок | Следующий урок