0

When you have two classes that need to have attributes that refer to each other

# DOESN'T WORK
class A:
    b = B()

class B:
    a = A()
# -> ERROR: B is not defined

The standard answers say to use the fact that python is dynamic, ie.

class A:
    pass

class B:
    a = A()

A.b = B()

Which technically solves the problem. However, when there are three or more co-dependent classes, or when the classes are more than a few lines long, this approach results in spaghetti code that is extremely difficult to navigate. For example, I find myself writing code like:

class A:
    <50 lines>
    # a = B() but its set later
    <200 more lines>

class B:
    <50 lines>
    a = A()
    <100 lines>

A.b = B()  # to allow for circular referencing

This ends up violating DRY (since I write code in two places) and/or moves related code to opposite ends of my module, since I can't put A.b = B() in the class it's relevant to.

Is there a better method to allow circularly dependent class properties in python, that doesn't involve scattering related code to often-distant parts of the module?

martineau
  • 119,623
  • 25
  • 170
  • 301
Dragon
  • 173
  • 10
  • yes the better method would be to have a middle resource so that you do not have circular dependency, i.e revisit your design decisions – gold_cy Jun 23 '19 at 23:03

1 Answers1

0

After a bit of experimentation, I have found a way to (mostly) do what I want.

class DeferredAttribute:
    """ A single attribute that has had its resolution deferred """
    def __init__(self, fn):
        """fn - when this attribute is resolved, it will be set to fn() """
        self.fn = fn

    def __set_name__(self, owner, name):
        DeferredAttribute.DEFERRED_ATTRIBUTES.add((owner, name, self))

    @classmethod
    def resolve_all(cls):
        """ Resolves all deferred attributes """
        for owner, name, da in cls.DEFERRED_ATTRIBUTES:
            setattr(owner, name, da.fn())
        cls.DEFERRED_ATTRIBUTES.clear()

The idioms to use this are

class A:
    @DeferredAttribute
    def b():
        return B()

class B:
    a = A()

DeferredAttribute.resolve_all()

And this produces classes A and B that are exactly the same as if you ran the code

class A:
    pass

class B:
    a = A()

A.b = B()

Conclusion: On the upside, this helps code organization by avoiding repetition and localizing related code.

On the downside, it messes up some expectations of dynamic programming; until resolve_deferred_attributes is called, the value A.b will be a special value, not an instance of B. It seems possible to partially fix this by adding the appropriate methods to DeferredAttribute, but I don't see a way to make it perfect.

Editor Note: The code above makes my IDE (PyCharm) yell at me with an error, saying that def b(): should take a parameter (though it runs fine). If you want, you can change the error to a warning by by altering the code:

In the resolve_all method, change:
    setattr(owner, name, da.fn())

    ->

    fn = da.fn
    if isinstance(fn, staticmethod):
        setattr(owner, name, fn.__func__())
    else:
        setattr(owner, name, fn())

And in the use code, change:
    @defer_attribute
    def b():
        ...

    -> 

    @defer_attribute
    @staticmethod
    def b():
        ...

I haven't found a way to eliminate the warnings entirely, aside from turning them off.

Dragon
  • 173
  • 10