如何设置__ post_init__ 中的数据类字段的值?

我正在尝试创建一个冻结的数据类,但是在设置 __post_init__的值时遇到了问题。在使用 frozen=True设置时,是否有一种方法可以基于 dataclass中的 init param的值来设置字段值?

RANKS = '2,3,4,5,6,7,8,9,10,J,Q,K,A'.split(',')
SUITS = 'H,D,C,S'.split(',')




@dataclass(order=True, frozen=True)
class Card:
rank: str = field(compare=False)
suit: str = field(compare=False)
value: int = field(init=False)
def __post_init__(self):
self.value = RANKS.index(self.rank) + 1
def __add__(self, other):
if isinstance(other, Card):
return self.value + other.value
return self.value + other
def __str__(self):
return f'{self.rank} of {self.suit}'

这就是痕迹

 File "C:/Users/user/.PyCharm2018.3/config/scratches/scratch_5.py", line 17, in __post_init__
self.value = RANKS.index(self.rank) + 1
File "<string>", line 3, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'value'
27748 次浏览

Use the same thing the generated __init__ method does: object.__setattr__.

def __post_init__(self):
object.__setattr__(self, 'value', RANKS.index(self.rank) + 1)

Using mutation

Frozen objects should not be changed. But once in a while the need may arise. The accepted answer works perfectly for that. Here is another way of approaching this: return a new instance with the changed values. This may be overkill for some cases, but it's an option.

from copy import deepcopy


@dataclass(frozen=True)
class A:
a: str = ''
b: int = 0


def mutate(self, **options):
new_config = deepcopy(self.__dict__)
# some validation here
new_config.update(options)
return self.__class__(**new_config)

Another approach

If you want to set all or many of the values, you can call __init__ again inside __post_init__. Though there are not many use cases.

The following example is not practical, only for demonstrating the possibility.

from dataclasses import dataclass, InitVar




@dataclass(frozen=True)
class A:
a: str = ''
b: int = 0
config: InitVar[dict] = None


def __post_init__(self, config: dict):
if config:
self.__init__(**config)

The following call

A(config={'a':'a', 'b':1})

will yield

A(a='a', b=1)

without throwing error. This is tested on python 3.7 and 3.9.

Of course, you can directly construct using A(a='hi', b=1), but there maybe other uses, e.g. loading configs from a json file.

Bonus: an even crazier usage

A(config={'a':'a', 'b':1, 'config':{'a':'b'}})

will yield

A(a='b', b=1)

A solution I use in almost all of my classes is to define additional constructors as classmethods.

Based on the given example, one could rewrite it as follows:

@dataclass(order=True, frozen=True)
class Card:
rank: str = field(compare=False)
suit: str = field(compare=False)
value: int


@classmethod
def from_rank_and_suite(cls, rank: str, suit: str) -> "Card":
value = RANKS.index(self.rank) + 1
return cls(rank=rank, suit=suit, value=value)

By this one has all the freedom one requires without having to resort to __setattr__ hacks and without having to give up desired strictness like frozen=True.

This feels a little bit like 'hacking' the intent of a frozen dataclass, but works well and is clean for making modifications to a frozen dataclass within the post_init method. Note that this decorator could be used for any method (which feels scary, given that you expect the dataclass to be frozen), thus I compensated by asserting the function name this decorator attaches to must be 'post_init'.

Separate from the class, write a decorator that you'll use in the class:

def _defrost(cls):
cls.stash_setattr = cls.__setattr__
cls.stash_delattr = cls.__delattr__
cls.__setattr__ = object.__setattr__
cls.__delattr__ = object.__delattr__


def _refreeze(cls):
cls.__setattr__ = cls.stash_setattr
cls.__delattr__ = cls.stash_delattr
del cls.stash_setattr
del cls.stash_delattr


def temp_unfreeze_for_postinit(func):
assert func.__name__ == '__post_init__'
def wrapper(self, *args, **kwargs):
_defrost(self.__class__)
func(self, *args, **kwargs)
_refreeze(self.__class__)
return wrapper

Then, within your frozen dataclass, simply decorate your post_init method!

@dataclasses.dataclass(frozen=True)
class SimpleClass:
a: int


@temp_unfreeze_for_postinit
def __post_init__(self, adder):
self.b = self.a + adder

Commenting with my own solution as I stumbled upon this with the same question but found none of the solutions suited my application.

Here the property that, much like OP, I tried to create in a post_init method initially is the bit_mask property.

I got it to work the cached_property decorator in functools; since I wanted the property to be static/immutable much like the other properties in the dataclass.

The function create_bitmask is defined elsewhere in my code, but you can see that it depends on the other properties of the dataclass instantance.

Hopefully, someone else might find this helpful.

from dataclasses import dataclass
from functools import cached_property


@dataclass(frozen=True)
class Register:
subsection: str
name: str
abbreviation: str
address: int
n_bits: int
_get_method: Callable[[int], int]
_set_method: Callable[[int, int], None]
_save_method: Callable[[int, int], None]


@cached_property
def bit_mask(self) -> int:
# The cache is used to avoid recalculating since this is a static value
# (hence max_size = 1)
return create_bitmask(
n_bits=self.n_bits,
start_bit=0,
size=self.n_bits,
set_val=True
)


def get(self) -> int:
raw_value = self._get_method(self.address)
return raw_value & self.bit_mask


def set(self, value: int) -> None:
self._set_method(
self.address,
value & self.bit_mask
)


def save(self, value: int) -> None:
self._save_method(
self.address,
value & self.bit_mask
)