2025, Nov 28 15:02

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

Почему динамические свойства в Python, созданные в цикле, ссылаются на последний атрибут: замыкания, бэкинг-поля и фабрика property — решение без eval.

Динамические свойства в Python: почему геттеры, созданные в цикле, всё время читают последний атрибут

Динамическое создание атрибутов удобно, пока свойства, добавленные в цикле, внезапно не начинают ссылаться на одно и то же имя. Распространённый приём — создавать свойство для каждого атрибута через setattr, а затем выяснять, что каждый геттер и сеттер как будто направлен на последний элемент списка. Возникает желание «починить» это через eval, но в этом нет нужды. Ниже — что на самом деле происходит и как решить задачу аккуратно.

Проблемный шаблон

Замысел прост: для каждого имени атрибута сгенерировать свойство и прикрепить его к классу. Пример ниже показывает идею.

fields = ["a", "b", "c"]
class Widget:
    pass
for name in fields:
    def read(self):
        return getattr(self, name)
    def write(self, value):
        setattr(self, name, value)
    # All properties end up bound to the last value in `name`
    setattr(Widget, name, property(read, write))

Кажется, что если обернуть setattr в eval, который строит лямбды с «вшитыми» именами, всё начинает работать как задумано, но это не продуманное решение корневой проблемы.

Что на самом деле идёт не так

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

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

Решение: привязывайте текущее имя через фабрику

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

def build_prop(field):
    hidden_name = f"_{field}"
    def read(self):
        return getattr(self, hidden_name)
    def write(self, value):
        setattr(self, hidden_name, value)
    return property(read, write)
fields = ["a", "b", "c"]
class Demo:
    pass
for field in fields:
    setattr(Demo, field, build_prop(field))
x1 = Demo()
x2 = Demo()
x1.a = 42
x1.b = 17
x2.a = 96
x2.b = 123
print(x1.a, x1.b, x1._a, x1._b)
print(x2.a, x2.b, x2._a, x2._b)

В результате всё работает как ожидается: каждое свойство корректно связано со своим собственным бэкинговым атрибутом.

Почему это важно

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

Выводы

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