88

I'm trying to create a frozen dataclass but I'm having issues with setting a value from __post_init__. Is there a way to set a field value based on values from an init param in a dataclass when using the frozen=True setting?

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}'

and this is the trace

 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'
nicholishen
  • 2,082
  • 1
  • 8
  • 13

3 Answers3

97

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

def __post_init__(self):
    object.__setattr__(self, 'value', RANKS.index(self.rank) + 1)
user2357112
  • 235,058
  • 25
  • 372
  • 444
  • 50
    This works. However, it does seem that the `dataclass` generated `__setattr__` should know to not raise `FrozenInstanceError` when being called from `__post_init__` on a name that has `init=False`. Using `object.__setattr__` like this is ugly / tedious. – John B Jan 20 '19 at 23:25
  • 10
    `super().__setattr__('attr_name', value)` seems cleaner to me. And should works as long as the dataclass do not inherit from another frozen dataclass – Conchylicultor Dec 13 '19 at 02:22
  • 3
    @Conchylicultor I agree, but this answer simply follows the [documentation](https://docs.python.org/3/library/dataclasses.html#frozen-instances): `There is a tiny performance penalty when using frozen=True: __init__() cannot use simple assignment to initialize fields, and must use object.__setattr__().` – xuhdev Nov 12 '20 at 22:54
  • As a person with Java background I'm totally confused. Isn't `object` the base `class` that all instance classes inherit from (extend) it? Then what exactly does it mean to `object.__setattr__`? Can I use `object.__dict__['attr_name'] = value` then? – Alireza Mohamadi Jan 04 '22 at 10:38
  • @AlirezaMohamadi: Unlike Java, Python lets you explicitly call method implementations from specific classes. Here, we call the `__setattr__` implementation from `object`, bypassing the override in the frozen dataclass. – user2357112 Jan 04 '22 at 10:46
  • `object.__dict__['attr_name'] = value` is wrong because it would try to set an entry in `object`'s `__dict__` instead of `self`. `self.__dict__['attr_name'] = value` would work for most attributes, but it would fail with anything that needs to go through a [descriptor](https://docs.python.org/3/reference/datamodel.html#implementing-descriptors), such as attributes that use `__slots__` (if `dataclasses` ever adds `__slots__` support). – user2357112 Jan 04 '22 at 10:47
  • > (if dataclasses ever adds __slots__ support). @user2357112supportsMonica slots support was added in Python 3.10 some months ago : https://docs.python.org/3/whatsnew/3.10.html#dataclasses (along with the kw_only arg that makes dataclass inheritance usable at last) – florianlh Feb 03 '22 at 10:15
5

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)
Tim
  • 2,557
  • 1
  • 10
  • 23
  • 1
    this solution gives us an interesting level of flexibility! the only problem with this is that when we let this implementation be merged to our code base we are also giving "tools and approaches" that will ease the life of the "bad practice engineers". now they have an example in the code base of something that breaks what i like to call `semantic consistency` and will use it to defend its smelly implementations. "semantic consistency" is always a good way of empowering good practices, but we are not doing this when calling `__init__` inside `__post_init__` :'( – gbrennon Dec 10 '21 at 05:21
  • 1
    @gbrennon great observation! I completely agree. I've added another approach :) – Tim Dec 10 '21 at 21:01
  • 2
    [dataclasses.replace](https://docs.python.org/3/library/dataclasses.html#dataclasses.replace) is intended for this purpose. – Hymns For Disco Dec 23 '21 at 00:29
  • @HymnsForDisco nice! But it seems to require specifying init-only variables without default values. – Tim Dec 23 '21 at 01:26
2

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.

Max Görner
  • 535
  • 4
  • 13
  • I like this approach! But one advantage of using `__post_init__` is to guarantee invariants. With the `@classmethod`/`@staticmethod` approach, people using the dataclass can still construct it directly. – jli May 21 '22 at 23:05
  • 1
    You are right, important invariants are not enforced. However, if they are important, one still could implement a `__post_init__` to ensure these invariants. – Max Görner May 22 '22 at 14:48