Circular imports again

Viewed 107

I'm having a module-structure in python3.8 like this:

module
|- __init__.py
|- foo.py
|- bar.py

with

# init.py
from .foo import Foo
from .bar import Bar
__all__ = ['Foo', 'Bar', ]

I now want to implement a multiplication foo * bar. So I'm writing:

# foo.py
from .bar import Bar

class Foo:
  def __init__(self):
    self.value = 5

  def __mul__(self, other):
    if not isinstance(other, Bar): raise ValueError('')
    return self.value * other.number

and

# bar.py

from .foo import Foo

class Bar:
  def __init__(self):
    self.number = 2

  def __mul__(self, other):
    if not isinstance(other, Foo): raise ValueError('')
    return other * self

This unfortunately doesn't work as expected. Is there a way for type-checking in this case? I know I could import Foo inside of Bar.__mul__ – but that seems rather untidy to me.

3 Answers

Import Foo when you need it:

# bar.py

class Bar:
  def __init__(self):
    self.number = 2

  def __mul__(self, other):
    from .foo import Foo
    if not isinstance(other, Foo): raise ValueError('')
    return other * self

Bar in this case is already known so there's no more issue

start of edit

OK, a little more hacky, but you could define the __mul__ function outside of the classes:

class Bar:
    def __init__(self, value=10):
        self.value = value

class Foo:
    def __init__(self, value=5):
        self.value = value

#    def __mul__(self, other):
#        return self.value * other.value

def mul_func(self, other):
    if isinstance(other, Bar):
        return self.value * other.value
    raise ValueError('')


Foo.__mul__ = mul_func

a = Foo(5)
b = Bar(10)
c = Foo(20)

a*b
a*c   # value error

end of edit

I would have 2 solutions, either use a workaround with a baseclass, or use something else than the type to know you're doing it right.

Edit : I would be curious as to what's the original problem. Since all of this seems hacky to me, or maybe I'm just not seeing the problem in the good way. If we use the mybase.py solution, why not just use a single file for the original foo.py and bar.py. I guess the implementation is up to you

Solution 1 : The baseclass

Your architecture would be as follows :

module
|- __init__.py
|- foo.py
|- bar.py
|- mybase.py

And then, you would have your file mybase.py such as...

class MyFooBase:
    pass

class MyBarBase:
    pass

Then, in your files foo.py and bar.py, you'd have the following file (I will only show one as it's obvious what the other would look like)...

from .mybase import MyFooBase, MyBarBase

class Bar(MyBarBase):
  def __init__(self):
    self.number = 2

  def __mul__(self, other):
    if not isinstance(other, MyFooBase): raise ValueError('')
    return other.value * self.number

Solution 2 : Use something else than the type.

(See in comment)

Keeping units of logic separated into separate packages and modules can be useful, but to insist that a file only contain one class is verging on dogmatic. Unless these classes are huge, the best solution is to stick them in the same module. Given you're having to resort to various tricks to avoid circular imports, is that really worth being able to keep once class per file?

There are a number of things you can do.

  1. Only import the module and not the class (from module import foo), and refer to class via the module (eg. isinstance(obj, foo.Foo))
  2. Use the "seek forgiveness, not permission" mantra of Python. Assume the user of your classes is using them correctly and that the relevant attributes are present on the operands. If you want more informative error messages then you can wrap attribute fetches in try: except AttributeError: statements, which can raise your preferred error. You must make sure to put the bare minimum of logic in such try/except blocks. eg. other_value = other.value, and then use the other_value name outside of the try/except block. This will stop your try/except block hiding possible bugs in your program.
  3. Only get one class to implement __mul__, this class can then also implement __rmul__ to make up for the lack of __mul__ on the other class. This allows all the instance checking to be done in just one of the classes rather than both.

str and int are examples of where the standard library uses __rmul__ to compute a result (eg. for 2 * 'a'). That is, when given 2 * 'a', the interpreter first tries int.__mul__(2, 'a'). However, this returns NotImplemented. As a result, the interpreter will then try str.__rmul__('a', 2) instead (which returns 'aa'). You only get his behaviour though when the normal operator function returns NotImplemented. If there is an exception then this exception is propagated rather than trying __rmul__. When implementing the r variant of an operator, you should be mindful that that self will be the right operand in the expression, and that sometimes this might change how you want to compute the result (eg. 3 - 1 != 1 - 3).

Example for point 3.

class X:
    def __init__(self, val):
        self.val = val

class Y:
    def __init__(self, val):
        self.val = val

    def _mul(self, x):
        if not isinstance(x, X):
            return NotImplemented
        return Y(self.val * x.val)

    def __mul__(self, other):
        return self._mul(other)

    def __rmul__(self, other):
        return self._mul(other)


y = Y(2) * X(3)  # using mul
assert isinstance(y, Y)
assert y.val == 6
assert isinstance(X(3) * Y(2), Y) # using rmul
Related