Perhaps the most efficient and convenient approach that I can think of for this task, involves using metaclasses in Python to automatically generate a __post_init__()
method for the class, which will set the default value specified for a field if a None
value is passed in for that field to __init__()
.
Assume we have these contents in a module metaclasses.py
:
import logging
LOG = logging.getLogger(__name__)
logging.basicConfig(level='DEBUG')
def apply_default_values(name, bases, dct):
"""
Metaclass to generate a __post_init__() for the class, which sets the
default values for any fields that are passed in a `None` value in the
__init__() method.
"""
# Get class annotations, which `dataclasses` uses to determine which
# fields to add to the __init__() method.
cls_annotations = dct['__annotations__']
# This is a dict which will contain: {'b': 'Bravo', 'c': 'Charlie'}
field_to_default_val = {field: dct[field] for field in cls_annotations
if field in dct}
# Now we generate the lines of the __post_init()__ method
body_lines = []
for field, default_val in field_to_default_val.items():
body_lines.append(f'if self.{field} is None:')
body_lines.append(f' self.{field} = {default_val!r}')
# Then create the function, and add it to the class
fn = _create_fn('__post_init__',
('self', ),
body_lines)
dct['__post_init__'] = fn
# Return new class with the __post_init__() added
cls = type(name, bases, dct)
return cls
def _create_fn(name, args, body, *, globals=None):
"""
Create a new function. Adapted from `dataclasses._create_fn`, so we
can also log the function definition for debugging purposes.
"""
args = ','.join(args)
body = '\n'.join(f' {b}' for b in body)
# Compute the text of the entire function.
txt = f'def {name}({args}):\n{body}'
# Log the function declaration
LOG.debug('Creating new function:\n%s', txt)
ns = {}
exec(txt, globals, ns)
return ns[name]
Now in our main module, we can import and use the metaclass we just defined:
from dataclasses import dataclass
from metaclasses import apply_default_values
@dataclass
class Specs1(metaclass=apply_default_values):
a: str
b: str = 'Bravo'
c: str = 'Charlie'
r1 = Specs1('Apple', None, 'Cherry')
print(r1)
Output:
DEBUG:metaclasses:Creating new function:
def __post_init__(self):
if self.b is None:
self.b = 'Bravo'
if self.c is None:
self.c = 'Charlie'
Specs1(a='Apple', b='Bravo', c='Cherry')
To confirm that this approach is actually as efficient as stated, I've set up a small test case to create a lot of Spec
objects, in order to time it against the version in @Lars's answer, which essentially does the same thing.
from dataclasses import dataclass
from timeit import timeit
from metaclasses import apply_default_values
@dataclass
class Specs1(metaclass=apply_default_values):
a: str
b: str = 'Bravo'
c: str = 'Charlie'
@dataclass
class Specs2:
a: str
b: str
c: str
def __post_init__(self):
if self.b is None:
self.b = 'Bravo'
if self.c is None:
self.c = 'Charlie'
n = 100_000
print('Manual: ', timeit("Specs2('Apple', None, 'Cherry')",
globals=globals(), number=n))
print('Metaclass: ', timeit("Specs1('Apple', None, 'Cherry')",
globals=globals(), number=n))
Timing for n=100,000
runs, the results show it's close enough to not really matter:
Manual: 0.059566365
Metaclass: 0.053688744999999996
__post_init__
method is probably the only way to achieve this – Quimperfield
had a flag fortrigger_if_none_passed
. – Flavescent