Я привык к обычным классам Python и сейчас пытаюсь изучить pydantic. Это оказалось намного сложнее, чем я ожидал. Что я часто делаю, так это инициирую класс с некоторыми первоначальными входными данными и на основе этих начальных данных «вычисляю» множество атрибутов для этого класса. Я не могу разобраться в создании «вычисляемых» атрибутов в pydantic.
Я создал следующий пример, чтобы продемонстрировать проблему:
from pydantic import BaseModel, computed_field
from typing import List
class Person(BaseModel):
first_name: str
last_name: str
@computed_field
@property
def composite_name(self) -> str:
print("initializing composite_name")
return f"{self.first_name} {self.last_name}"
@computed_field
@property
def composite_name_list(self) -> List[str]:
print("initializing name_list")
return [f"{self.composite_name} {i}" for i in range(5)]
p = Person(first_name = "John", last_name = "Doe")
print(p.composite_name_list)
В приведенном выше коде я ожидаю, что этот код запустит Composite_name и создаст атрибут Composite_Name. Затем я ожидаю, что он запустит Composit_name_list и создаст атрибут Composite_name_list. Таким образом, он будет выполнять каждую из этих функций ровно один раз и один раз напечатает «инициализацию составного_имя», а затем «инициализацию списка_имен».
Вместо этого я получаю распечатку:
initializing name_list
initializing composite_name
initializing composite_name
initializing composite_name
initializing composite_name
initializing composite_name
['John Doe 0', 'John Doe 1', 'John Doe 2', 'John Doe 3', 'John Doe 4']
Несколько странностей в этой распечатке:
В стандартном Python я бы просто создал этот класс следующим образом:
class PersonStandardPython:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
self.composite_name = f"{first_name} {last_name}"
self.composite_name_list = [f"{self.composite_name} {i}" for i in range(5)]
Как я могу получить результат, аналогичный моей стандартной реализации Python, сохраняя при этом преимущества строгой типизации pydantics?





Я не знаю, лучший ли это метод, но он работает. Вы инициализируете переменные как «Нет», а затем используете функцию model_post_init(), чтобы изменить их сразу после вызова функции init() (которая вызывается под капотом pydantic).
from pydantic import BaseModel, Field
from typing import List, Any
class Person(BaseModel):
first_name: str
last_name: str
composite_name: str = Field(default=None) # Pre-declared fields, initialized to None
composite_name_list: List[str] = Field(default=None)
def model_post_init(self, __context: Any) -> None:
self.composite_name = f"{self.first_name} {self.last_name}"
self.composite_name_list = [f"{self.composite_name} {i}" for i in range(5)]
a = Person(first_name = "John", last_name = "Doe")
print(a.composite_name_list)
Я думаю, что есть некоторые недопонимания относительно того, как работает computed_field и как его следует использовать. computed_field действует очень похоже на property в Python, поэтому дополнительно использует декоратор свойств. Он имитирует внешний вид атрибута, вычисляя его значение только «по запросу» (см. документацию Python). Затем декоратор computed_field добавляет это свойство только в список допустимых полей модели Pydantic, и, таким образом, его можно использовать, например, для сериализация.
Обычно вычисляемые поля/свойства можно использовать для повторного вычисления другого значения на основе изменяемых атрибутов. В случае вашего первого примера можно было бы изменить first_name или last_name, и composite_name все равно вернет правильное имя, например:
p = Person(first_name = "John", last_name = "Doe")
print(p.composite_name)
p.first_name = "Jane"
print(p.composite_name)
Что должно напечатать:
John Doe
Jane Doe
Напротив, во втором примере, если вы изменили first_name, composite_name все равно будет установлено значение, присвоенное ему при инициализации, например:
p = PersonStandardPython(first_name = "John", last_name = "Doe")
print(p.composite_name)
p.first_name = "Jane"
print(p.composite_name)
Что должно напечатать:
John Doe
John Doe
Таким образом, оба случая демонстрируют совершенно разное поведение в отношении изменчивости. Если вы хотите, чтобы ваш объект Person был изменяемым, ваш первый пример совершенно верен! Вам просто нужно посмотреть на него еще раз и понять его поведение. Итак, позвольте мне остановиться на трех пунктах, которые вы упомянули:
Первое, что выводится, — это «инициализация name_list», а сначала идет оператор печати «инициализация составного_имя».
Это вполне ожидаемо. Поскольку compute_field работает как свойство, он сначала выполняет код, определенный в composite_name_list, прежде чем будет осуществлен доступ к другому свойству composite_name.
Кажется, атрибут составного имени пересчитывается каждый раз, когда он вызывается, хотя я использовал декоратор вычисляемого поля.
Опять же, это вполне ожидаемо. Поскольку он работает так же, как property, он повторно выполняет код, определенный в методе. Однако вы можете кэшировать результат вычисленного свойства (подробнее об этом позже).
Я добавил последнюю строку «print(p.composite_name_list), потому что в противном случае он вообще ничего не напечатал бы! Другими словами, создание экземпляра класса Person не приводит автоматически к созданию двух моих вычисляемых свойств.
Этого и следовало ожидать, поскольку код выполняется только при доступе к вычисляемому полю. Он «отложен» и не вычисляется при инициализации объекта.
В качестве альтернативы с помощью Pydantic вы можете добиться «искусственной неизменяемости» (см. документацию по искусственной неизменяемости). Таким образом, вы можете вычислить производные атрибуты при инициализации или раньше и предотвратить дальнейшее изменение атрибутов, на которых они основаны. Для этого вы можете использовать frozen=True в определении класса и, например, model_validator:
from pydantic import BaseModel, model_validator
from typing import List, Optional
class Person(BaseModel, frozen=True):
first_name: str
last_name: str
composite_name: Optional[str] = None
composite_name_list: Optional[List[str]] = None
@model_validator(mode = "before")
@classmethod
def init_derived_attribute(cls, data, info):
first_name = data.get("first_name")
last_name = data.get("last_name")
composite_name = f"{first_name} {last_name}"
data["composite_name"] = composite_name
data["composite_name_list"] = [f"{composite_name} {i}" for i in range(5)]
return data
p = Person(first_name = "John", last_name = "Doe")
print(p.composite_name)
p.first_name = "Jane" # this now raises an error!
Хотя приведенный выше пример работает нормально, я думаю, что это не самое чистое решение. Вы упомянули, что больше всего хотели бы избежать повторного расчета поля. Решение этой проблемы простое. Вы можете использовать cached_property из стандартной functools библиотеки. Однако в этом случае вам все равно следует объединить его с искусственной неизменяемостью, чтобы гарантировать, что объект не может быть изменен в памяти и производное свойство не синхронизируется. Вот окончательный код, который я бы предложил:
from pydantic import BaseModel, computed_field
from typing import List
from functools import cached_property
class Person(BaseModel, frozen=True):
first_name: str
last_name: str
@computed_field
@cached_property
def composite_name(self) -> str:
print("initializing composite_name")
return f"{self.first_name} {self.last_name}"
@computed_field
@cached_property
def composite_name_list(self) -> List[str]:
print("initializing name_list")
return [f"{self.composite_name} {i}" for i in range(5)]
p = Person(first_name = "John", last_name = "Doe")
print(p.composite_name_list)
Что печатает:
initializing name_list
initializing composite_name
['John Doe 0', 'John Doe 1', 'John Doe 2', 'John Doe 3', 'John Doe 4']
Хотя порядок выполнения остается прежним (см. выше, это ожидаемо), он избегает повторного вычисления composite_name и печатает его только один раз. Для всех последующих доступов он кэшируется. Здесь важно отметить, что обычно разумно использовать cache_property только в том случае, если вычисления довольно «дорогие». Если вы действительно просто объединяете две строки, делая это несколько раз, все будет в порядке.
Если вы хотите, чтобы ваш класс Person был изменяемым, ваше первое предложенное решение вполне подойдет! Повторное вычисление гарантирует, что производные поля/свойства всегда «актуальны» по сравнению с другими атрибутами, из которых они получены. В качестве альтернативы вы можете перейти на одно из решений с «искусственной неизменностью», которое я предложил выше, оба из которых позволяют избежать повторных вычислений.
Спасибо за подробный ответ. Я приму ваш ответ, поскольку считаю, что он лучше всего объясняет мое непонимание декоратора «computed_field». Однако я продолжу использовать свой ответ в своем коде, поскольку предлагаемое вами решение по-прежнему вычисляет атрибут только в момент его первого использования. Для меня его необходимо создавать во время инициализации класса, поскольку вычисления являются дорогостоящими, и это приведет к ухудшению пользовательского опыта, если оно будет рассчитываться только по запросу.
Оглядываясь назад, ваш ответ в разделе «искусственная неизменяемость» — это именно то, что мне нужно. Просто предлагаемого вами решения нет. Я думаю, что на практике это даст тот же результат, что и мое решение (за исключением замороженной части, которая для меня не является обязательным требованием). Есть ли какая-либо причина, по которой вы предпочитаете подход «искусственной неизменяемости» подходу model_post_init?
Я думаю, что главное отличие — это валидация. В моем подходе «до» с валидатором модели производные атрибуты все еще проверяются, а в model_post_init — нет (за исключением случаев, когда вы используете опцию конфигурации validate_assignment=True). Однако, поскольку производные величины используют проверенные входные данные, это может быть нормально.
У этого решения есть концептуальный недостаток:
Personявляется изменяемым объектом, и как только вы разрешаете изменениеfirst_name, соответствующийcomposite_nameне обновляется. Дополнительную информацию смотрите в моем ответе.