But it doesn't work when using __slots__
. What is the most efficient solution to use instead?
Assuming the goal as listed above, I would personally advise against dataclasses.asdict()
, as specific sections of the code might slow down processing of dataclass field values. For instance, it calls copy.deepcopy()
on any complex types that it encounters (such as datetime
) which as I understand, might not be desirable behavior; it is also rather inefficient to do this, as I demonstrate below.
In any case, here is the simplest (and most efficient) approach to resolve it.
The first step would be to create a helper Mixin class, named as SerializableMixin
or anything else. This would then access a class's __slots__
namespace, and generate the dict()
and json()
methods specifically for the given subclass. Note that this approach extends from my other answer.
Assuming your class is relatively simple, and doesn't contain any complex types or nested dataclasses:
from __future__ import annotations
import json
from typing import Any
class SerializableMixin:
"""
Mixin class to add a `dict()` and `json()` method on classes that define
a __slots__ attribute.
"""
def __init_subclass__(cls):
cls_slots = getattr(cls, '__slots__', None)
# only if/when slots have been declared on the class
if not cls_slots:
return
body_lines = ','.join(f"'{f}':self.{f}" for f in cls_slots)
# Compute the text of the entire function(s).
txt = (f'def dict(self):\n'
f' return {{{body_lines}}}\n'
f'def json(self):\n'
f' return dumps({{{body_lines}}})')
ns = {}
fn_locals = {'dumps': json.dumps}
exec(txt, fn_locals, ns)
cls.dict = ns['dict']
cls.json = ns['json']
# added for IDE auto-completion purposes
def dict(self) -> dict[str, Any]: ...
def json(self) -> str: ...
To handle more complex types such as datetime
, and nested dataclasses:
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from uuid import UUID
_defaults = {UUID: str,
datetime: datetime.isoformat}
def _default_fn(o: Any): # `default` argument to `json.dumps`
_dict = getattr(o, 'dict', None)
return _dict() if _dict else _defaults.get(type(o), str)(o)
class SerializableMixin:
"""
Mixin class to add a `dict()` and `json()` method on classes that define
a __slots__ attribute.
"""
def __init_subclass__(cls):
cls_slots = getattr(cls, '__slots__', None)
# only if/when slots have been declared on the class
if not cls_slots:
return
body_lines = ','.join(f"'{f}':self.{f}" for f in cls_slots)
# Compute the text of the entire function(s).
txt = (f'def dict(self):\n'
f' return {{{body_lines}}}\n'
f'def json(self):\n'
f' return dumps({{{body_lines}}}, default=default)')
ns = {}
fn_locals = {'dumps': json.dumps, 'default': _default_fn}
exec(txt, fn_locals, ns)
cls.dict = ns['dict']
cls.json = ns['json']
# added for IDE auto-completion purposes
def dict(self) -> dict[str, Any]: ...
def json(self) -> str: ...
In any case, usage would be as follows:
from dataclasses import dataclass
@dataclass(slots=True)
class Cat(SerializableMixin):
fur: bool
meow: int
kitty = Cat(True, 3)
print(kitty.dict())
print(kitty.json())
Output:
{'fur': True, 'meow': 3}
{"fur": true, "meow": 3}
To prove that this is indeed more efficient, I use the timeit
module to compare against a similar approach with dataclasses.asdict
:
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from timeit import timeit
from typing import Any
from uuid import UUID, uuid4
_defaults = {UUID: str,
datetime: datetime.isoformat}
def _default_fn(o: Any): # `default` argument to `json.dumps`
_dict = getattr(o, 'dict', None)
return _dict() if _dict else _defaults.get(type(o), str)(o)
class SerializableMixin:
"""
Mixin class to add a `dict()` and `json()` method on classes that define
a __slots__ attribute.
"""
def __init_subclass__(cls):
# only if/when slots have been declared on the class
if hasattr(cls, '__slots__'):
cls._generate_helper_methods()
@classmethod
def _generate_helper_methods(cls):
body_lines = ','.join(f"'{f}':self.{f}" for f in cls.__slots__)
# Compute the text of the entire function(s).
txt = (f'def dict(self):\n'
f' return {{{body_lines}}}\n'
f'def json(self):\n'
f' return dumps({{{body_lines}}}, default=default)')
ns = {}
fn_locals = {'dumps': json.dumps, 'default': _default_fn}
exec(txt, fn_locals, ns)
cls.dict = ns['dict']
cls.json = ns['json']
# added for IDE auto-completion purposes
def dict(self) -> dict[str, Any]: ...
def json(self) -> str: ...
@dataclass(slots=True)
class Inner(SerializableMixin):
# for Python version < 3.10, remove `slots=True` above and uncomment the below
# __slots__ = ('string', )
string: str = 'hello world'
@dataclass(slots=True)
class Cat(SerializableMixin):
# for Python version < 3.10, remove `slots=True` above and uncomment the below
# __slots___ = ('fur', 'meow', 'id', 'dt', 'inner')
fur: bool
meow: int
id: UUID = field(default_factory=uuid4)
dt: datetime = datetime.min
inner: Inner = field(default_factory=Inner)
@dataclass
class Cat2:
__slots___ = ('fur', 'meow', 'id', 'dt', 'inner')
fur: bool
meow: int
id: UUID = field(default_factory=uuid4)
dt: datetime = datetime.min
inner: Inner = field(default_factory=Inner)
def as_dict(self):
return dict(asdict(self).items())
# this approach is faster, since `asdict` already calls `copy.deepcopy()` for us
as_dict_alt = asdict
def as_json(self):
return json.dumps(self.as_dict(), default=_default_fn)
_id = uuid4()
kitty = Cat(True, 3, id=_id)
kitty2 = Cat2(True, 3, id=_id)
print('dict: ', timeit('kitty.dict()', globals=globals()))
print('asdict: ', timeit('kitty2.as_dict()', globals=globals()))
print('asdict (alt): ', timeit('kitty2.as_dict_alt()', globals=globals()))
print('dict -> json: ', timeit('kitty.json()', globals=globals()))
print('asdict -> json: ', timeit('kitty2.as_json()', globals=globals()))
# `kitty.dict()` is not equal because `inner` value is still an `Inner` object
assert kitty.dict() != kitty2.as_dict() == kitty2.as_dict_alt()
# `json()` string values should be equal
assert kitty.json() == kitty2.as_json()
print()
print('dict:\n ', kitty.dict())
print('json:\n ', kitty.json())
Results for Python 3.10, on Mac M1:
dict: 0.1584392080003454
asdict: 8.291836708000119
asdict (alt): 8.119467332999193
dict -> json: 3.4996820830001525
asdict -> json: 12.136880125000971