Since dataclasses
adds new arguments to @dataclass(...)
in newer Python versions, such as kw_only
in Python 3.10, using a decorator to wrap the @dataclass
decorator might not be an ideal option moving forward.
One alternative is to use a newer descriptor approach in Python 3. While the below solution does not work when slots=True
is passed in to the @dataclass
decorator, it does appear to work well enough in the general case.
Here is an implementation of a simple descriptor class Frozen
, which raises an error if an attribute is set more than once - i.e. outside of __init__()
:
class Frozen:
__slots__ = ('private_name', )
def __set_name__(self, owner, name):
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
value = getattr(obj, self.private_name)
return value
def __set__(self, obj, value):
if hasattr(obj, self.private_name):
msg = f'Attribute `{self.private_name[1:]}` is immutable!'
raise TypeError(msg) from None
setattr(obj, self.private_name, value)
Usage:
from dataclasses import dataclass
@dataclass
class Foo:
# optional: define __slots__ to reduce memory usage
__slots__ = ('_x', '_y', 'z')
x: int = Frozen()
y: int = Frozen()
z: int
f = Foo(1, 2, 3)
print(f)
f.z = 4 # will work
f.z = 5 # will work
f.x = 4 # raises an error -> TypeError: Attribute `x` is immutable!
For Frozen
which allows you to set a default
value for a field, see my post here which indicates how to set it up.
Timings
If curious, I have also timed the descriptor approach above with the custom __setattr__()
approach as outlined in the top answer.
Here is my sample code with the timeit
module:
from timeit import timeit
@dc
class Foo:
# uncomment if you truly want to add __slots__:
# __slots__ = ('_x', '_y', 'z')
x: int = Frozen()
y: int = Frozen()
z: int
@dataclass(semi=True)
class Foo2:
# put immutable attributes and __dict__ into slots
__slots__ = ('__dict__', 'x', 'y')
x: int
y: int
z: int
n = 100_000
print('Foo.__init__() -> descriptor: ', timeit('Foo(1, 2, 3)', number=n, globals=globals()))
print('Foo.__init__() -> setattr: ', timeit('Foo2(1, 2, 3)', number=n, globals=globals()))
f1 = Foo(1, 2, 3)
f2 = Foo2(1, 2, 3)
print('foo.z -> descriptor: ', timeit('f1.z', number=n, globals=globals()))
print('foo.z -> setattr: ', timeit('f2.z', number=n, globals=globals()))
Results, on my Mac M1:
Foo.__init__() -> descriptor: 0.0345854579936713
Foo.__init__() -> setattr: 3.2137108749884646
foo.z -> descriptor: 0.003795791999436915
foo.z -> setattr: 0.002478832990163937
This indicates creating a new Foo
instance is much faster with a descriptor approach (up to 100x), but calling __setattr__()
is slightly faster with a custom setattr
approach, presumably because implementing a __slots__
attribute reduces memory overhead, and also reduces the average lookup time for instance attributes.