2025, Oct 22 11:31

Монкипатчинг __new__ в CPython: почему откат ломается и как вернуть поведение

Разбор эффекта CPython при монкипатчинге __new__: почему возврат object.__new__ даёт TypeError и как безопасно восстановить исходное поведение через пересоздание

Monkey patching процесса создания объектов в Python кажется простым — до тех пор, пока не трогаешь __new__. На первый взгляд безобидная правка, например замена аллокатора класса с последующей попыткой вернуть всё обратно, может привести к TypeError в CPython. При этом “то же самое”, выполненное на чистом классе, работает без проблем. В этом материале разобраны точное поведение, практические причины такого эффекта и безопасный способ восстановить исходную семантику, если __new__ был патчен.

Как воспроизвести проблему

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

class Alpha:
    def __init__(self, val):
        self.val = val
def hook_new(typ, *args, **kwargs):
    print("hook_new called")
    return object.__new__(typ)
Alpha.__new__ = hook_new
Alpha(10)
# hook_new вызван
# <__main__.Alpha object at 0x...>

Теперь вы пробуете восстановить оригинал, присвоив object.__new__ напрямую. Вызов падает с TypeError.

Alpha.__new__ = object.__new__
Alpha(10)
# TypeError: object.__new__() принимает ровно один аргумент (тип для инстанцирования)

Парадоксально, но если с самого начала выставить __new__ равным object.__new__, без предшествующего монкипатчинга, всё работает.

class Beta:
    def __init__(self, val):
        self.val = val
Beta.__new__ = object.__new__
Beta(10)
# работает нормально

Что происходит на самом деле

Это особенность CPython. После того как у класса монкипатчат __new__, интерпретатор внутренне помечает путь выделения объектов этого типа как «touched». С этого момента, даже если позже вернуть object.__new__, цепочка вызова уже не ведёт себя как у «нетронутого» класса. Отличие проявляется в обработке аргументов: object.__new__ в CPython обрабатывается особым образом, пока он ни разу не был переопределён, но как только слот считается использованным, CPython перестаёт игнорировать лишние аргументы по-прежнему.

Логично ожидать, что удаление пропатченного атрибута вернёт поведение за счёт поиска атрибута у object.__new__. На практике в CPython удаление атрибута не восстанавливает исходную семантику из‑за того самого внутреннего состояния «touched».

Очевидная попытка отката (и почему она всё равно не срабатывает)

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

class Alpha:
    def __init__(self, val):
        self.val = val
def hook_new(typ, *args, **kwargs):
    print("hook_new called")
    return object.__new__(typ)
Alpha.__new__ = hook_new
Alpha(10)
# Пытаемся откатить
del Alpha.__new__
Alpha(10)
# В CPython всё равно выбрасывает TypeError после «прикосновения» к типу

Итог связан с внутренними деталями CPython и оптимизациями доступа к __new__. Это побочный эффект реализации, а не поведение, гарантированное языковой спецификацией.

Практическое обходное решение, которое надёжно возвращает поведение

Чтобы вернуться к семантике «как до патча», пересоздайте сам объект класса. Это можно сделать, вызвав type с исходным именем класса, базами и неглубокой копией его пространства имён. Получится «свежий» тип, у которого слот __new__ ещё не помечен.

class RootBase:
    pass
class Alpha(RootBase):
    def __init__(self, val):
        self.val = val
def hook_new(typ, *args):
    print("hook_new called", *args)
    return object.__new__(typ)
# Патчим
Alpha.__new__ = hook_new
Alpha(23)
# Удаляем атрибут; в CPython этого недостаточно
del Alpha.__new__
# Alpha(23) всё ещё упадёт здесь в CPython
# Собираем класс заново из его частей
Alpha = type(Alpha.__name__, Alpha.__bases__, dict(Alpha.__dict__))
# Снова работает с исходной семантикой
Alpha(23)

Замечания по реализациям

Этот эффект специфичен для деталей реализации CPython и, возможно, тянет на отчёт об ошибке. Другая известная реализация Python — PyPy — таким поведением не страдает. В PyPy после монкипатчинга __new__ достаточно удалить атрибут, и нормальное создание объектов восстанавливается.

class Gamma:
    def __init__(self, val):
        self.val = val
def hook_new(typ, *args):
    print("hook_new called", *args)
    return object.__new__(typ)
Gamma.__new__ = hook_new
Gamma(23)  # выводит сообщение
del Gamma.__new__
Gamma(23)  # на PyPy снова работает

Почему это важно для продакшен-кода

Если вы инструментируете или делаете хотфиксы сторонних классов на лету, конструктор — самое хрупкое место для вмешательства. Из‑за внутренней обработки __new__ в CPython кажущийся обратимым патч на деле не откатывается простой переназначением или удалением атрибута. В результате можно получить код, который на одном интерпретаторе работает, а на другом — нет, и наоборот. Знание об этом краевом случае убережёт от неожиданных падений TypeError при создании объектов, особенно в долгоживущих процессах, где классы меняются динамически.

Практические рекомендации и выводы

Если нужно перехватить создание объектов через __new__, в CPython рассматривайте класс как «запятнанный» до конца жизни процесса. Не рассчитывайте, что присвоение object.__new__ обратно или удаление атрибута вернёт точное исходное поведение. Когда требуется чистый лист, пересоздавайте класс через type, передавая текущее имя, базовые классы и неглубокую копию его dict. Если вы поддерживаете несколько реализаций Python, проверьте поведение в CI-матрице: PyPy этой проблемой не страдает и может проходить там, где CPython падает.

Иными словами, монкипатчинг __new__ — мощный инструмент, но с интерпретатор-специфичной семантикой. Нужна обратимость — полагайтесь на пересоздание класса, а не на удаление или переназначение атрибута.

Статья основана на вопросе на StackOverflow от Gerges и ответе от jsbueno.