Использование __new__ в унаследованных классах данных

avatar
EdG
8 августа 2021 в 18:40
548
2
7

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

from dataclasses import dataclass

@dataclass
class Country:
    iso2 : str
    iso3 : str
    name : str

countries = [ Country('AW','ABW','Aruba'),
              Country('AF','AFG','Afghanistan'),
              Country('AO','AGO','Angola')]
countries_by_iso2 = {c.iso2 : c for c in countries}
countries_by_iso3 = {c.iso3 : c for c in countries}

@dataclass
class CountryLink:
    person_id : int
    country : Country

country_links = [ CountryLink(123, countries_by_iso2['AW']),
                  CountryLink(456, countries_by_iso3['AFG']),
                  CountryLink(789, countries_by_iso2['AO'])]

print(country_links[0].country.name)

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

@dataclass
class CountryLinkFromISO2(CountryLink):
    def __new__(cls, person_id : int, iso2 : str):
        if iso2 not in countries_by_iso2:
            return None
        new_obj = super().__new__(cls)
        new_obj.country = countries_by_iso2[iso2]
        return new_obj

@dataclass
class CountryLinkFromISO3(CountryLink):
    def __new__(cls, person_id : int, iso3 : str):
        if iso3 not in countries_by_iso3:
            return None
        new_obj = super().__new__(cls)
        new_obj.country = countries_by_iso3[iso3]
        return new_obj

country_links = [ CountryLinkFromISO2(123, 'AW'),
                  CountryLinkFromISO3(456, 'AFG'),
                  CountryLinkFromISO2(789, 'AO')]

На первый взгляд это работает, но потом я столкнулся с проблемой:

a = CountryLinkFromISO2(123, 'AW')
print(type(a))
print(a.country)
print(type(a.country))

возвращает:

<class '__main__.CountryLinkFromISO2'>
AW
<class 'str'>

Унаследованный объект имеет правильный тип, но его атрибут country представляет собой просто строку, а не ожидаемый тип Country. Я поместил операторы печати в __new__, которые проверяют тип new_obj.country, и он правильный перед строкой return.

Я хочу добиться того, чтобы a был объектом типа CountryLinkFromISO2, который наследует изменения, внесенные мною в <78800289333299>, и чтобы он имел атрибут country, который берется из словаря countries_by_iso2. Как мне этого добиться?

Источник
Nathaniel Ford
8 августа 2021 в 19:05
0

Вы уверены, что хотите переопределить __new__, а не __init__? Вы также можете рассмотреть возможность использования такой библиотеки, как attrs.

EdG
8 августа 2021 в 19:16
0

@NathanielFord __init__ всегда будет возвращать экземпляр класса, чего я не хочу, если ввод недействителен. У меня мог бы быть __init__, вызывающий исключение, но это означает, что каждый раз, когда я пытаюсь вызвать свой код, мне приходится помещать его в блок try/except, что неуклюже и может вызвать проблемы с производительностью.

Nathaniel Ford
9 августа 2021 в 17:44
0

Я думаю, что Марк предоставил правильный курс (фабричный метод), но вам следует проверить attrs или аналогичные библиотеки для их валидаторов.

Ответы (2)

avatar
Mark
8 августа 2021 в 19:09
7

То, что класс данных делает это за кулисами, не означает, что у ваших классов нет __init__(). Они это делают, и это выглядит так:

def __init__(self, person_id: int, country: Country):
    self.person_id = person_id
    self.country = country

При создании класса с помощью:

CountryLinkFromISO2(123, 'AW')

эта строка "AW" передается в __init__() и устанавливает значение в строку.

Использование __new__() таким образом является хрупким, а возврат None из конструктора довольно непитоновский (imo). Возможно, вам было бы лучше создать настоящую фабричную функцию, которая возвращает либо None, либо класс, который вы хотите. Тогда вам вообще не нужно возиться с __new__().

@dataclass
class CountryLinkFromISO2(CountryLink):
    @classmethod
    def from_country_code(cls, person_id : int, iso2 : str):
        if iso2 not in countries_by_iso2:
            return None
        return cls(person_id, countries_by_iso2[iso2])

a = CountryLinkFromISO2.from_country_code(123, 'AW')

Если по какой-то причине ему нужно работать с __new__(), вы можете вернуть None из нового, когда совпадений нет, и установить страну в

:<63736363>00

@dataclass
class CountryLinkFromISO2(CountryLink):
    def __new__(cls, person_id : int, iso2 : str):
        if iso2 not in countries_by_iso2:
            return None
        return super().__new__(cls)
    
    def __post_init__(self):        
        self.country = countries_by_iso2[self.country]

EdG
8 августа 2021 в 19:27
0

Означает ли это, что __init__, неявно созданный декоратором класса данных, выполняется после __new__ с теми же аргументами позиционно и, следовательно, перезаписывает предыдущее значение country? Возврат None из конструктора может быть не pythonic, но лучшей альтернативой будут блоки try/except, которые, как я понимаю, могут вызвать проблемы с производительностью.

Mark
8 августа 2021 в 20:00
1

Да __init__() создается декоратором класса данных. Это задокументировано здесь и является одной из причин использования классов данных. И __init__() вызывается после __new__(). Это не относится к классам данных.

EdG
8 августа 2021 в 20:19
0

Итак, если я хочу избежать __init__, заменяющего значение country, которое было установлено в __new__, нужно ли мне вручную указывать как __init__, так и __new__, чтобы он принимал те же позиционные аргументы а намеренно не перезаписывать?

avatar
Jasmijn
8 августа 2021 в 19:10
3

Поведение, которое вы видите, связано с тем, что классы данных устанавливают свои поля в __init__, что происходит после запуска __new__.

Питоновский способ решить эту проблему — предоставить альтернативный конструктор. Я бы не стал делать подклассы, так как они используются только для своего конструктора.

Например:

@dataclass
class CountryLink:
    person_id: int
    country: Country

    @classmethod
    def from_iso2(cls, person_id: int, country_code: str):
        try:
            return cls(person_id, countries_by_iso2[country_code])
        except KeyError:
            raise ValueError(f'invalid ISO2 country code {country_code!r}') from None

    @classmethod
    def from_iso3(cls, person_id: int, country_code: str):
        try:
            return cls(person_id, countries_by_iso3[country_code])
        except KeyError:
            raise ValueError(f'invalid ISO3 country code {country_code!r}') from None

country_links = [ CountryLink.from_iso2(123, 'AW'),
                  CountryLink.from_iso3(456, 'AFG'),
                  CountryLink.from_iso2(789, 'AO')]
EdG
8 августа 2021 в 19:19
0

Этот метод будет означать, что я должен использовать блок try/except каждый раз, когда я создаю свой класс. Не повлияет ли это на производительность?

Jasmijn
8 августа 2021 в 19:30
0

Блоки try/except имеют минимальное влияние на производительность, если не возникает никаких исключений. Это может быть даже быстрее, потому что нет in проверки и if-ветки. В любом случае, я не думаю, что это может быть узким местом в вашем коде.

Matthew Purdon
28 апреля 2022 в 19:31
0

можно утверждать, что принудительная проверка неверных данных — это хорошая вещь