如何在一个冻结的数据类自定义__init__方法中设置属性?

36

我正在尝试构建一个@dataclass,它定义了架构但实际上不使用给定成员进行实例化。(基本上,我正在劫持方便的@dataclass语法用于其他目的)。这几乎做到了我想要的:

@dataclass(frozen=True, init=False)
class Tricky:
    thing1: int
    thing2: str

    def __init__(self, thing3):
        self.thing3 = thing3

但是在__init__方法中我遇到了一个FrozenInstanceError

dataclasses.FrozenInstanceError: cannot assign to field 'thing3'

我需要frozen=True(为了可哈希性)。有没有办法在冻结的@dataclass上在__init__中设置自定义属性?


1
“(基本上,我正在将方便的@dataclass语法用于其他目的)” 嗯,不要那样做?或者不要使用frozen并实现自己的 __hash__,因为您并没有真正使用dataclass...” - juanpa.arrivillaga
self.thing3 是从哪里来的? - vb_rises
1
那你使用这个语法是为了什么呢?因为 @dataclass 语法甚至不是特定于数据类的,它只是使用标准注释和类型提示。采用数据类解决了什么问题? - Martijn Pieters
@juanpa.arrivillaga:或者直接使用unsafe_hash=True而不是frozen=True - Martijn Pieters
5个回答

27
问题在于默认的__init__实现使用带有冻结类的object.__setattr__(),如果提供自己的实现,则必须使用它,这会使您的代码相当混乱:
@dataclass(frozen=True, init=False)
class Tricky:
    thing1: int
    thing2: str

    def __init__(self, thing3):
        object.__setattr__(self, "thing3", thing3)

不幸的是,Python没有提供使用默认实现的方式,因此我们不能简单地执行以下操作:

@dataclass(frozen=True, init=False)
class Tricky:
    thing1: int
    thing2: str

    def __init__(self, thing3, **kwargs):
        self.__default_init__(DoSomething(thing3), **kwargs)

然而,我们可以很容易地实现这种行为:

def dataclass_with_default_init(_cls=None, *args, **kwargs):
    def wrap(cls):
        # Save the current __init__ and remove it so dataclass will
        # create the default __init__.
        user_init = getattr(cls, "__init__")
        delattr(cls, "__init__")

        # let dataclass process our class.
        result = dataclass(cls, *args, **kwargs)

        # Restore the user's __init__ save the default init to __default_init__.
        setattr(result, "__default_init__", result.__init__)
        setattr(result, "__init__", user_init)

        # Just in case that dataclass will return a new instance,
        # (currently, does not happen), restore cls's __init__.
        if result is not cls:
            setattr(cls, "__init__", user_init)

        return result

    # Support both dataclass_with_default_init() and dataclass_with_default_init
    if _cls is None:
        return wrap
    else:
        return wrap(_cls)

然后

@dataclass_with_default_init(frozen=True)
class DataClass:
    value: int

    def __init__(self, value: str):
        # error:
        # self.value = int(value)

        self.__default_init__(value=int(value))

更新:我打开了这个bug,希望在3.9之前实现。


2
很遗憾,相关问题被标记为“不予修复”并已关闭。 - rudolfbyker
4
你好,为什么你把你的解决方案称为“pretty hacky”?(object.__setattr__(self, "thing3", thing3)) 它运行良好且紧凑。调用“object”是一种不好的做法吗? - pierre_j

12
我需要 frozen=True(用于可哈希性)。
没有必要强制将类冻结以使其可哈希。您可以选择仅在代码中不改变属性,并设置unsafe_hash=True
但是,您应该将thing3声明为字段,并不使用自定义的__init__
from dataclasses import dataclass, field
from typing import Any

@dataclass(unsafe_hash=True)
class Tricky:
    thing1: int = field(init=False)
    thing2: str = field(init=False)
    thing3: Any

    def __post_init__(self):
        self.thing1 = 42
        self.thing2 = 'foo'

这里thing1thing2都设置了init=False,因此它们不会传递给__init__方法。然后你可以在一个__post_init__()方法中设置它们。

请注意,现在需要不冻结类,否则你无法在自定义的__init____post_init__中设置thing1thing2

演示:

>>> Tricky('bar')
Tricky(thing1=42, thing2='foo', thing3='bar')
>>> hash(Tricky('bar'))
-3702476386127038381

如果你只需要模式定义,那么你根本不需要使用数据类。你可以从任何类中获取类注释,可以作为原始注释或者使用typing.get_type_hints()

Tricky 的真正目的是数据访问对象,其中成员定义数据库表的模式。我实际上不想设置 thing1thing2;它们纯粹在这里用来定义模式。(正如我所说的,显然不是 dataclass 的本意,但语法很好。)客户端将定义 Tricky 的子类,每个子类可能定义不同的成员,但所有子类都需要在超类中定义的 thing3。如果可能,我不希望我的子类必须使用 field() 表示法,因为这会使我漂亮且清晰的模式定义变得混乱。 - Sasgorilla
3
不要使用dataclasses。该符号不特定于库。 - Martijn Pieters
如果我不使用dataclass,你知道__dataclass_fields__的等效物是什么吗?也就是说,我如何只获取使用name: type语法定义的字段? - Sasgorilla
4
访问注解请使用 __annotations__。或者使用 typing.get_type_hints() 获取类型提示。 - Martijn Pieters

10

这里有一个更简单的选项——只需添加一个静态的make函数:

@dataclass(frozen=True)
class Tricky:
    thing1: str
    thing2: int
    thing3: bool

    @classmethod
    def make(cls, whatever: str, you: bool, want: float):
        return cls(whatever + "..", you * 4, want > 5)

x = Tricky.make("foo", false, 3)

根据你的make方法所做的事情,遵循Rust的命名约定- from_foo()可能是个不错的主意。

@dataclass(frozen=True)
class Coord:
    lat: float
    lon: float

    @classmethod
    def from_os_grid_reference(cls, x: int, y: int):
        return cls(...)

    @classmethod
    def from_gps_nema_string(cls, nema_string: str):
        return cls(...)

这个答案有漏洞。如果你需要一个cls参数,请使用@classmethod - Hugues
我会更进一步,将makefrom_..方法转换为独立的函数(例如make_tricky等)- 这样做更容易添加类型注释(无需将其括在引号中),缩进少一级,与实际类实现更独立,并且可能名称更短。 - Jan Spurny
现在,类型注解不再是一个问题(不需要引号)了,因为Python 3.11引入了PEP 673。您可以在Python 3.11的官方文档中了解更多信息。 - Alex Povel

4

原来,dataclasses并没有提供您正在寻找的功能。但是Attrs提供了这个功能:

from attr import attrs, attrib


@attrs(frozen=True)
class Name:
    name: str = attrib(converter=str.lower)

同类问题的答案相同:请参见https://dev59.com/lL7pa4cB1Zd3GeqPpwbc#64695607


4

@Shmuel H. 发布的内容对我无效,仍然引发了 FrozenInstanceError 异常。

下面这种方式适用于我:

我在这里做的是接受一个值并检查它是否与 strptime 函数中定义的格式兼容,如果兼容则将其赋值,否则打印异常信息。

@dataclass(frozen=True)
class Birthday:
    value: InitVar[str]
    date: datetime = field(init=False)

    def __post_init__(self, value: str):
        try:
            self.__dict__['date'] = datetime.strptime(value, '%d/%m/%Y')
        except Exception as e:
            print(e)

这段代码抛出了一个异常。我在 def __post_init__(self): 中移除了"value"参数,并且使用了 "setattr",如下所示:object.__setattr__(self, 'date', datetime.strptime(self.value, '%d/%m/%Y')),看起来它能够正常工作。请同时查看 "magomar" 在以下链接中的答案:https://stackoverflow.com/questions/59222092/how-to-use-the-post-init-method-in-dataclasses-in-python - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接