Standard Dataclasses

Introduction

The standard subpackage provides base classes, decorators, and class factories (metaclasses) to imbue dataclasses, and the instances which they produce, with attributes concealment and immutability.

>>> import classcore.standard as ccstd
>>> import dataclasses

Inheriting from a standard base:

>>> class Point2d( ccstd.DataclassObject ):
...     x: float
...     y: float
...
>>> point = Point2d( x = 3, y = 4 )
>>> dataclasses.is_dataclass( Point2d )
True
>>> type( Point2d )
<class 'classcore.standard.classes.Dataclass'>

is essentially equivalent to producing a new class with a standard metaclass:

>>> class Point2d( metaclass = ccstd.Dataclass ):
...     x: float
...     y: float
...
>>> point = Point2d( x = 5, y = 12 )
>>> dataclasses.is_dataclass( Point2d )
True

As can be seen above, dataclasses are produced without the need to explicitly decorate with the dataclasses.dataclass() decorator.

Concealment and Immutability

Both classes have immutable attributes. For example, we cannot delete the annotations from which the dataclass attributes were derived:

>>> del Point2d.__annotations__
Traceback (most recent call last):
...
classcore.exceptions.AttributeImmutability: Could not assign or delete attribute '__annotations__' on class ...

Nor, for example, can we add a default value for x:

>>> Point2d.x = 3
Traceback (most recent call last):
...
classcore.exceptions.AttributeImmutability: Could not assign or delete attribute 'x' on class ...

Also, all non-public attributes on the class are concealed from dir():

>>> dir( Point2d )
['x', 'y']

The instances of these classes also have immutable attributes:

>>> point.x = 3
Traceback (most recent call last):
...
classcore.exceptions.AttributeImmutability: Could not assign or delete attribute 'x' on instance of class ...

And concealed non-public attributes:

>>> dir( point )
['x', 'y']

Decoration versus Production

By contrast, if we decorate an existing dataclass, then it retains the default Python behavior (full mutability and visibility) with respect to its class attributes:

>>> @ccstd.dataclass_with_standard_behaviors( )
... class Point2d:
...     x: float
...     y: float
...
>>> point = Point2d( x = 8, y = 15 )
>>> dataclasses.is_dataclass( Point2d )
True
>>> type( Point2d )
<class 'type'>
>>> '__annotations__' in dir( Point2d )
True
>>> del Point2d.__annotations__

However, attributes on its instances are immutable and concealed, which is the same behavior as for the classes we produced:

>>> dir( point )
['x', 'y']
>>> point.x = 5
Traceback (most recent call last):
...
classcore.exceptions.AttributeImmutability: Could not assign or delete attribute 'x' on instance of class ...

Thus, if you do not desire class attributes concealment and immutability, you can choose to decorate classes rather than produce them.

Decoration Details

By default, the dataclasses are decorated with dataclasses.dataclass( kw_only = True, slots = True ). The implications of this are:

  • The choice of keyword-only arguments (kw_only = True) ensures that inheritance works correctly… at the expense of compact initialization from positional arguments.

  • The choice of instance attribute allocations over instance dictionaries (slots = True) can improve performance and helps enforce the Open-closed principle. Also, the package ensures that super can work correctly inside of the bodies of methods on slotted dataclasses, addressing a bug in the standard library dataclasses implementation of CPython versions up to 3.13.

  • Although the dataclasses are not decorated with frozen = True, they do enforce immutability on instance attributes after initialization has completed. The instance attributes are also recognized as immutable by static type checkers, such as Pyright. By not explicitly decorating with frozen = True, we allow the dataclasses to successfully execute __post_init__ hooks which modify their attributes and enforce immutability in the same way that it is enforced throughout the package.

The following example helps illustrate the difference in immutability enforcement:

>>> import math
>>> @ccstd.dataclass_with_standard_behaviors( )
... class Point2d:
...     x: float
...     y: float
...     hypotenuse: float = dataclasses.field( init = False )
...     def __post_init__( self ) -> None:
...         self.hypotenuse = math.sqrt( self.x*self.x + self.y*self.y )
...
>>> point = Point2d( x = 3, y = 4 )
>>> point.hypotenuse
5.0

As can be seen, the hypotenuse of the triangle was calculated and successfully assigned during initialization. Of course, after initialization, the hypotenuse or any other instance attribute cannot be modified:

>>> point.hypotenuse = 6
Traceback (most recent call last):
...
classcore.exceptions.AttributeImmutability: Could not assign or delete attribute 'hypotenuse' on instance of class ...

Trying the same thing with dataclasses.dataclass( frozen = True ), results in an error during initialization:

>>> import math
>>> @dataclasses.dataclass( frozen = True, kw_only = True, slots = True )
... class Point2d:
...     x: float
...     y: float
...     hypotenuse: float = dataclasses.field( init = False )
...     def __post_init__( self ) -> None:
...         self.hypotenuse = math.sqrt( self.x*self.x + self.y*self.y )
...
>>> point = Point2d( x = 5, y = 12 )
Traceback (most recent call last):
...
dataclasses.FrozenInstanceError: cannot assign to field 'hypotenuse'

Mutable Instances

To produce classes with immutable attributes but instances with mutable attributes, there is a convenience class, DataclassObjectMutable.

>>> class Point2d( ccstd.DataclassObjectMutable ):
...     x: float
...     y: float
...
>>> point = Point2d( x = 7, y = 24 )
>>> point.x, point.y = 20, 21
>>> point.x, point.y
(20, 21)