当基类返回可导入模块中的子类实例时,避免循环导入

ElR*_*udi 8 python inheritance circular-dependency

概括

TLDR:当基类在可导入模块中返回子类实例时,如何避免循环导入错误?

我从其他位置/问题收集了一些解决方案(参见下面的 AD),但恕我直言,没有一个是令人满意的。

初始点

基于这个这个问题,我有以下假设的工作示例作为起点:

# onefile.py

from abc import ABC, abstractmethod

class Animal(ABC):
    def __new__(cls, weight: float):
        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in [Dog, Cat]:
                try:
                    return subcls(weight)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...


class Dog(Animal):
    def __init__(self, weight: float = 5):
        if not (1 < weight < 90):
            raise ValueError("No dog has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)


class Cat(Animal):
    def __init__(self, weight: float = 5):
        if not (0.5 < weight < 15):
            raise ValueError("No cat has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)


if __name__ == "__main__":

    a1 = Dog(34)
    try:
        a2 = Dog(0.9)  # ValueError
    except ValueError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")

    a3 = Cat(0.8)
    try:
        a4 = Cat(25)  # ValueError
    except ValueError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")

    a5 = Animal(80)  # can only be dog; should return dog.
    assert type(a5) is Dog
    a6 = Animal(0.7)  # can only be cat; should return cat.
    assert type(a6) is Cat
    a7 = Animal(10)  # can be both; should return dog.
    assert type(a7) is Dog
    try:
        a8 = Animal(400)
    except NotImplementedError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")
Run Code Online (Sandbox Code Playgroud)

该文件运行正确。

在单独的文件中重构为可导入模块

我想要Cat,DogAnimal作为模块中的可导入类zoo。为此,我创建了一个文件夹zoo,其中包含文件animal.pydog.pycat.py__init__.py。该文件usage.py保存在父文件夹中。这些文件如下所示:


# zoo/animal.py

from abc import ABC, abstractmethod
from .dog import Dog
from .cat import Cat

class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in [Dog, Cat]:
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...


# zoo/dog.py

from .animal import Animal

class Dog(Animal):
    def __init__(self, weight: float = 5):
        if not (1 < weight < 90):
            raise ValueError("No dog has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)


# zoo/cat.py

from .animal import Animal

class Cat(Animal):
    def __init__(self, weight: float = 5):
        if not (0.5 < weight < 15):
            raise ValueError("No cat has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)


# zoo/__init__.py

from .dog import Dog
from .cat import Cat
from .animal import Animal


# usage.py
  
from zoo import Dog, Cat, Animal

a1 = Dog(34)
try:
    a2 = Dog(0.9)  # ValueError
except ValueError:
    pass
else:
    raise RuntimeError("Should have raised Exception!")

a3 = Cat(0.8)
try:
    a4 = Cat(25)  # ValueError
except ValueError:
    pass
else:
    raise RuntimeError("Should have raised Exception!")

a5 = Animal(80)  # can only be dog; should return dog.
assert type(a5) is Dog
a6 = Animal(0.7)  # can only be cat; should return cat.
assert type(a6) is Cat
a7 = Animal(10)  # can be both; should return dog.
assert type(a7) is Dog
try:
    a8 = Animal(400)
except NotImplementedError:
    pass
else:
    raise RuntimeError("Should have raised Exception!")
Run Code Online (Sandbox Code Playgroud)

这是目前不起作用的;重构重新引入了ImportError (...) (most likely due to a circular import). 问题在于animal.py引用dog.pycat.py,反之亦然。

可能的解决方案

有一些可能性(一些取自链接的问题);这里有一些选项。代码示例仅显示文件的相关部分如何更改。

A:导入模块并移至Animal类定义之后

from abc import ABC, abstractmethod

class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in [dog.Dog, cat.Cat]:  # <-- instead of [Dog, Cat]
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)

    (...)

from . import dog  # <-- import module instead of class, and import at end, to avoid circular import error
from . import cat  # <-- same
Run Code Online (Sandbox Code Playgroud)

这有效。

缺点:

  • 从 开始dog.py,这个Dog类确实是只需要的。令人困惑的是它是完全导入的(尽管这被一些人认为是最佳实践)。
  • 更大的问题:导入需要放在文件末尾,这绝对是不好的做法。

B:将导入移至函数内

# zoo/animal.py

from abc import ABC, abstractmethod

class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        from .dog import Dog  # <-- imports here instead of at module level
        from .cat import Cat  # <-- imports here instead of at module level

        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in [Dog, Cat]:
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)
  
    (...)
Run Code Online (Sandbox Code Playgroud)

这也有效。

缺点:

  • 违背了仅模块级导入的最佳实践。
  • 如果多个地点需要DogCat需要,则需要重复导入。

C:删除导入并按名称查找类

# zoo/animal.py

from abc import ABC, abstractmethod

class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        if cls is Animal:
            # Try to return subclass instance instead.
            subclasses = {sc.__name__: sc for sc in Animal.__subclasses__()}  # <-- create dictionary
            for subcls in [subclasses["Dog"], subclasses["Cat"]]:   # <-- instead of [Dog, Cat]
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)
   
   (...)
Run Code Online (Sandbox Code Playgroud)

这也有效。为了避免每次都创建字典,也可以使用此答案中所示的注册表。

缺点:

  • 冗长且难以阅读。
  • 如果类名更改,此代码就会中断。

D:更新虚拟变量

# zoo/animal.py

from abc import ABC, abstractmethod

_Dog = _Cat = None  # <-- dummies, to be assigned by subclasses.


class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in [_Dog, _Cat]:  # <-- instead of [Dog, Cat]
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)
    
    (...)

# zoo/dog.py

from . import animal
from .animal import Animal


class Dog(Animal):
    (...)


animal._Dog = Dog  # <-- update protected variable

# zoo/cat.py analogously
Run Code Online (Sandbox Code Playgroud)

这也有效。

缺点:

  • 读者不清楚_Dog_Cat中的变量zoo/animal.py代表什么。
  • 文件之间的耦合;从外部更改/使用模块的“受保护”变量。

E:更好的解决方案?

在我看来,AD 都不令人满意,我想知道是否还有其他方法。 这就是你进来的地方。 ;) 可能没有其他方法 - 在这种情况下,我很想知道你首选的方法是什么,以及为什么。

非常感谢

Ser*_*sta 2

恕我直言,您只需要一个简单的包,并在文件中进行适当的初始化__init__.py

整体结构:

zoo folder accessible from the Python path
| __init__.py
| animal.py
| dog.py
| cat.py
|  other files...
Run Code Online (Sandbox Code Playgroud)

Animal.py - 不直接依赖于任何其他模块

from abc import ABC, abstractmethod

subclasses = []    # will be initialized from the package __init__ file

class Animal(ABC):
    def __new__(cls, *args, **kwargs):
        if cls is Animal:
            # Try to return subclass instance instead.
            for subcls in subclasses:
                try:
                    return subcls(*args, **kwargs)
                except ValueError:
                    pass
            raise NotImplementedError("No appropriate subclass found.")
        return super().__new__(cls)

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...
Run Code Online (Sandbox Code Playgroud)

dog.py - 取决于动物

from .animal import Animal

class Dog(Animal):
    def __init__(self, weight: float = 5):
        if not (1 < weight < 90):
            raise ValueError("No dog has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)
Run Code Online (Sandbox Code Playgroud)

cat.py:id。狗

init .py:导入所需的子模块并初始化animal.subclasses

from .animal import Animal
from .dog import Dog
from .cat import Cat
from . import animal as _animal    # the initial _ makes the variable protected

_animal.subclasses = [Dog, Cat]
Run Code Online (Sandbox Code Playgroud)

从那时起,记录的接口仅包含包zoo本身及其类AnimalDogCat

可以这样使用:

from zoo import Animal, Dog, Cat

if __name__ == "__main__":

    a1 = Dog(34)
    try:
        a2 = Dog(0.9)  # ValueError
    except ValueError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")

    a3 = Cat(0.8)
    try:
        a4 = Cat(25)  # ValueError
    except ValueError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")

    a5 = Animal(80)  # can only be dog; should return dog.
    assert type(a5) is Dog
    a6 = Animal(0.7)  # can only be cat; should return cat.
    assert type(a6) is Cat
    a7 = Animal(10)  # can be both; should return dog.
    assert type(a7) is Dog
    try:
        a8 = Animal(400)
    except NotImplementedError:
        pass
    else:
        raise RuntimeError("Should have raised Exception!")
Run Code Online (Sandbox Code Playgroud)

这种结构允许简单的直接依赖。它甚至可以改进为允许可选子类可以通过在中声明(或导入)的特定函数添加__init__.py