7
\$\begingroup\$

I want to create two classes, one for attributes and one for functional behaviors. The point of the attributes class was to hold all of the attributes along with property methods (setters/getters), and I wanted to split the classes up this way so that everything related to the attributes would not fill up the entire other class.

Due to the way python handles mutable default arguments, I have to handle the attributes using None for default arguments, but I have a very large number of parameters. This has caused the code to become unnecessarily long when passing arguments around on the initialization:

class Attributes:
    def __init__(self,
            arg1,
            arg2,
            arg3,
            arg4,
            arg5):

        self.arg1 = default_arg1 if arg1 is None else arg1
        self.arg2 = default_arg2 if arg2 is None else arg2
        self.arg3 = default_arg3 if arg3 is None else arg3
        self.arg4 = default_arg4 if arg4 is None else arg4
        self.arg5 = default_arg5 if arg5 is None else arg5

    # attribute methods like getters and setters

class Functionality(Attributes):
    def __init__(self,
            arg1 = None,
            arg2 = None,
            arg3 = None,
            arg4 = None,
            arg5 = None):

        super(Functionality, self).__init__(
            arg1,
            arg2,
            arg3,
            arg4,
            arg5
        )

    # Methods that give functionality to the class

I want to be able to use this class as follows:

example_obj = Functionality(
    arg1 = example_arg1,
    arg4 = example_arg4
)

Is there any cleaner way to do this? I want to add more attributes (more than 20 attributes instead of 5), but the above code involves writing basically the same thing way too many times.

\$\endgroup\$
6
\$\begingroup\$

I'm going to address your existing code in a manner that generally goes from most minimal to most significant changes. I'd strongly recommend actually using the solution at the very bottom of this post; the minimal changes are more useful:

  1. From a historical (pre-dataclasses) perspective
  2. For understanding how classes and inheritance work

but there's no reason to torture yourself hand-writing all of this when dataclasses will do the work for you.

Delegating to defaults of super class

When you're inheriting __init__ from another class, you typically don't want to reproduce their parameters explicitly; it creates too much confusion and too much interdependency (making more opportunities for code to get out of sync). There are two standard solutions here:

Option 1: Explicitly delegate via **kwargs

When Functionality doesn't make direct use of any parameter, don't accept it by name. Just accept **kwargs (when you're talking about this many defaulted parameters, no one should be passing arguments positionally anyway; it's a nightmare for readability/maintainability) and pass it along. So Functionality would look like:

class Functionality(Attributes):
    def __init__(self, **kwargs):
        # Other stuff required for initialization
        super().__init__(**kwargs)  # Python 3.x doesn't require you to pass args to super in most cases
        # Other stuff required for initialization

    # Methods that give functionality to the class

Option 2: Inherit __init__ implicitly

In this case, it might be even simpler though; the __init__ of Functionality doesn't do anything beyond delegate to Attributes. If your real code is the same (nothing but a super().__init__ call), you'd just omit the definition of __init__ on Functionality and let it inherit Attributes's __init__ directly.

class Functionality(Attributes):
    # No __init__ defined at all; uses Attributes.__init__ automatically

    # Methods that give functionality to the class

Either way, you're no longer using separate defaults for each class.

Note: If the parent class must not accept default arguments, but the child class should, see the end of this answer for Preferred solution (especially if the child must not have defaults, and the parent class should): dataclasses everywhere (not described here since it relies on techniques for solving your mutable defaults problem, which I haven't gotten to yet).


Avoid problems with mutable defaults

There are two simple solutions for avoiding the problems with mutable defaults.

Option 1: Deep copy unconditionally

For the defaulted class itself, go ahead and use mutable arguments as defaults, but the safe way, making deep copies. This is often safer even when not passed as defaults, since without copying, you'd be aliasing values from the caller, and changes made by either you or the caller would affect the other.

Adding explicit mutable defaults, you end up with:

from copy import deepcopy

class Attributes:
    def __init__(self, arg1=[], arg2={}, arg3=set(), arg4=MyMutable(), arg5=OtherMutable()):
        self.arg1 = deepcopy(arg1)
        self.arg2 = deepcopy(arg2)
        self.arg3 = deepcopy(arg3)
        self.arg4 = deepcopy(arg4)
        self.arg5 = deepcopy(arg5)

    # attribute methods like getters and setters

Option 2: Let dataclasses do your work for you

As an alternative to hand-writing all of Attributes, I'd suggest making it a dataclass, which means you don't need to repeat the names, and allows you to define default_factorys for each field to generate default values on demand. That would allow you to write a simpler Attributes (with far less name repetition) matching what I wrote above like so:

from dataclasses import dataclass, field

@dataclass
class Attributes:
    arg1: list = field(default_factory=list)
    arg2: dict = field(default_factory=dict)
    arg3: set = field(default_factory=set)
    arg4: MyMutable = field(default_factory=MyMutable)
    arg5: OtherMutable = field(default_factory=OtherMutable)

    # attribute methods like getters and setters

and it will generate __init__ (as well as __repr__ and __eq__ for good measure; you can turn them off, or other features on, by parameterizing the @dataclass decorator) for you, including automatically calling your default_factory to initialize the field if and only if the caller did not provide the parameter.

Note: Unlike the other solution, this doesn't ensure caller-provided arguments are copied, so the risk of aliasing would remain. You could always define a __post_init__ to copy any fields you think this is likely to be a problem for.

Side-note: For my example, I just annotated the types as list, dict, and set; to annotate properly, you'd usually the typing classes (List, Dict, Set) and annotate with the types the containers are expected to hold, e.g. arg1: List[int] = field(default_factory=list) if arg1 is expected to be a list of ints.

Preferred solution (especially if the child must not have defaults, and the parent class should): dataclasses everywhere

In your example, Attributes() fails for lack of arguments, while Functionality() does not (substituting defaults),

dataclasses still cover that case adequately. You'd define both parent and child as dataclasses, but not provide defaults for the fields on the parent:

from dataclasses import dataclass, field

@dataclass
class Attributes:
    arg1: list
    arg2: dict
    arg3: set
    arg4: MyMutable
    arg5: OtherMutable

    # Optionally define __post_init__ to modify arguments, perform other work

    # attribute methods like getters and setters

@dataclass
class Functionality(Attributes):
    arg1: list = field(default_factory=list)
    arg2: dict = field(default_factory=dict)
    arg3: set = field(default_factory=set)
    arg4: MyMutable = field(default_factory=MyMutable)
    arg5: OtherMutable = field(default_factory=OtherMutable)

    # Optionally define __post_init__ to modify arguments, perform other work

    # Methods that give functionality to the class

Since no defaults are set on Attributes, direct use of Attributes will require you all arguments to be provided. But dataclasses are helpful again here; the fields of a child class of a dataclass are:

  1. All of the fields of the parent(s)
  2. Plus all of the fields of the child
  3. When a given field is defined in both, the child's definition wins

So we can provide default_factorys solely on the child (and get defaulting behavior), but not on the parent (so it never defaults).

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Works wonderfully! Will have to try looking more into dataclasses but for now using everything else suffices for me. \$\endgroup\$ Nov 3 '20 at 19:44
  • \$\begingroup\$ @SimplyBeautifulArt: Glad it helps. dataclasses is one of those things where, technically, everything it does could be written by hand (by definition; the module is written in Python), but it would be a royal pain to handle all the corner cases (e.g. your scenario where Attributes lacks default arguments, while Functionality takes mutable default arguments). What would otherwise be substantial changes often just means flipping a flag (e.g. if the class should be immutable/hashable, you just use @dataclass(frozen=True) and it generates __hash__ and protects against instance mutation). \$\endgroup\$ Nov 3 '20 at 20:08
  • \$\begingroup\$ I see. Good to know if I ever want a more general use of a class for handling data. \$\endgroup\$ Nov 3 '20 at 20:15
0
\$\begingroup\$

By passing all of the arguments inside of a dictionary one avoids a lengthy __init__ in Functionality and also allows setting all default arguments in 3 lines without causing issues with mutable arguments.

class Attributes:
    def __init__(self, attributes):
        
        default_attributes = {
            'arg1' : default_arg1,
            'arg2' : default_arg2,
            'arg3' : default_arg3,
            'arg4' : default_arg4,
            'arg5' : default_arg5
        }
        
        for attribute in default_attributes.keys():
            if attribute not in attributes:
                attributes[attribute] = default_attributes[attribute]
        
        arg1 = attributes['arg1']
        arg2 = attributes['arg2']
        arg3 = attributes['arg3']
        arg4 = attributes['arg4']
        arg5 = attributes['arg5']
class Functionality(Attributes):
    def __init__(self, attributes = None):
        super(Functionality, self).__init__({} if attributes is None else attributes)

The only difference in usage is it now has to be used with a dictionary.

example_obj = Functionality({
    'arg1' : example_arg1,
    'arg2' : example_arg2
})
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.