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

Я привык к обычным классам 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']

Несколько странностей в этой распечатке:

  1. Первое, что выводится, — это «инициализация name_list», а сначала идет оператор печати «инициализация составного_имя».
  2. Кажется, атрибут составного имени пересчитывается каждый раз, когда он вызывается, хотя я использовал декоратор вычисляемого поля.
  3. Я добавил последнюю строку «print(p.composite_name_list), потому что в противном случае он вообще ничего не напечатал бы! Другими словами, создание экземпляра класса Person не приводит автоматически к созданию двух моих вычисляемых свойств.

В стандартном 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?

Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
0
154
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я не знаю, лучший ли это метод, но он работает. Вы инициализируете переменные как «Нет», а затем используете функцию 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)

У этого решения есть концептуальный недостаток: Person является изменяемым объектом, и как только вы разрешаете изменение first_name, соответствующий composite_name не обновляется. Дополнительную информацию смотрите в моем ответе.

Axel Donath 22.04.2024 10:15
Ответ принят как подходящий

вступление

Я думаю, что есть некоторые недопонимания относительно того, как работает 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». Однако я продолжу использовать свой ответ в своем коде, поскольку предлагаемое вами решение по-прежнему вычисляет атрибут только в момент его первого использования. Для меня его необходимо создавать во время инициализации класса, поскольку вычисления являются дорогостоящими, и это приведет к ухудшению пользовательского опыта, если оно будет рассчитываться только по запросу.

ThaNoob 22.04.2024 10:38

Оглядываясь назад, ваш ответ в разделе «искусственная неизменяемость» — это именно то, что мне нужно. Просто предлагаемого вами решения нет. Я думаю, что на практике это даст тот же результат, что и мое решение (за исключением замороженной части, которая для меня не является обязательным требованием). Есть ли какая-либо причина, по которой вы предпочитаете подход «искусственной неизменяемости» подходу model_post_init?

ThaNoob 22.04.2024 10:43

Я думаю, что главное отличие — это валидация. В моем подходе «до» с валидатором модели производные атрибуты все еще проверяются, а в model_post_init — нет (за исключением случаев, когда вы используете опцию конфигурации validate_assignment=True). Однако, поскольку производные величины используют проверенные входные данные, это может быть нормально.

Axel Donath 22.04.2024 10:54

Другие вопросы по теме