How should I pass a class as an attribute to another class with attrs?

160 Views Asked by At

So, I just stumbled upon a hurdle concerning the use of attrs, which is quite new to me (I guess this also applies to dataclasses?). I have two classes, one I want to use as an attribute for another. This is how I would do this with regular classes:

class Address:
    def __init__(self) -> None:
        self.street = None

class Person:
    def __init__(self, name) -> None:
        self.name = name
        self.address = Address()

Now with attrs, I tried to do the following:

from attrs import define

@define
class Address:
    street: str | None = None

@define
class Person:
    name: str
    self.address = Address()

Now if I try the following, I don't get the same result for a class and a dataclass, for which the reason wasn't obvious to me at first:

person_1 = Person("Joe")
person_2 = Person("Jane")

person_1.address.street = "street"
person_2.address.street = "other_street"

print(person_1.address.street)

I would expect the output to be "street", which is what happens with a regular class. But with attrs, the output is "other_street". I then compared the hashes of person_1.address and person_2.address, and voila, they are the same.

After some thinking this is logical, with attrs I instantiate Address immediately, so everyone gets the same instance of Address, with regular classes I only instantiate them when I instantiate the parent class.

Now, there is a fix available with attrs:

from attrs import define, field

@define
class Address:
    street: str | None = None

@define
class Person:
    name: str
    address: Address = field(init=False)

    def __attrs_post_init__(self):
        self.address = Address()

But this seems really cumbersome to implement every time. Is there a nice solution to this? One way would be to put the instantiation of Address outside of the class like this:

address_1 = Address()
person_1 = Person("Joe", address)

But my issue with that is, that often I want to instantiate the class in an empty state (for example to seperate input from computed values), and this way adds an extra step to instantiation which I need to remember.

So in conclusion: In this case, attrs, dataclass, pydantic etc. blur the line between what belongs to the class and what belongs to the instance, and in my case that led to an hour of "wtf happened here". So back to normal classes? I really like the default and validation possibilities of attrs though. Or is there a best practice way to handle this kind of setup?

2

There are 2 best solutions below

0
blhsing On BEST ANSWER

You can use the factory argument to specify a callable that is called to return a new instance for the field during instantiation:

from attrs import define, field

@define
class Address:
    street: str | None = None

@define
class Person:
    name: str
    address: Address = field(factory=Address)
2
Marcin Orlowski On

The felt into Python's popular trap -> default mutable attributes (like lists or custom objects) are shared across instances and you'd face the same problem declaring method in your class like:

def foo(users = []):
    ...

as that default list will be shared across all instances of that class. The recommended way to code is:

def foo(users = None):
    if foo is None:
       foo = []
    ...

as that gets evaluated at runtime, resulting in new list being created each time.

As for your Person class - you already use attrs package, so for regular class use its Factory:

from attrs import define, Factory

... 
self.address = Factory(Address)

For dataclasses use default_factory (docs):

from dataclasses import dataclass, field

@dataclass
class Person:
    name: str
    address: Address = field(default_factory=Address)

Once you done so, you can confirm you are having two different instances of Address:

from dataclasses import dataclass, field

@dataclass
class Address:
    street: str | None = None

@dataclass
class Person:
    name: str
    address: Address = field(default_factory=Address)

# Test
person_1 = Person('Joe')
person_2 = Person('Jane')
print(f'p1 id: {id(person_1.address)}')
print(f'p2 id: {id(person_2.address)}')

person_1.address.street = 'foo'
person_2.address.street = 'bar'
print(f'p1: {person_1.address.street}')
print(f'p2: {person_2.address.street}')

should work as expected

p1 id: 140138458659184
p2 id: 140138458660000
p1: foo
p2: bar