为什么在Python中使用抽象基类?

311
因为我习惯于使用 Python 中的旧式鸭子类型,所以我不理解为什么需要 ABC(抽象基类)。文档介绍了如何使用它们。
我试图阅读PEP中的理论基础,但是我没能理解。如果我正在寻找一个可变序列容器,我会检查是否有 __setitem__ ,或者更可能尝试使用它(EAFP)。 我没有遇到过真实生活中使用numbers模块的情况,该模块使用了 ABCs,但这是我理解的最接近的例子。
请问有人能向我解释这个理论基础吗?
6个回答

325

@Oddthinking的回答没有错,但我认为它忽略了Python在鸭子类型的世界中拥有抽象基类的真正实际原因。

抽象方法很好,但我认为它们并没有填补任何鸭子类型已经覆盖的用例。抽象基类的真正力量在于它们允许您自定义isinstanceissubclass的行为方式。(__subclasshook__基本上是Python __instancecheck____subclasscheck__钩子的友好API。)将内置结构适应于自定义类型非常符合Python的哲学。

Python的源代码是典范。这里是标准库中collections.Container的定义方式(截至撰写本文时):

class Container(metaclass=ABCMeta):
    __slots__ = ()

    @abstractmethod
    def __contains__(self, x):
        return False

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Container:
            if any("__contains__" in B.__dict__ for B in C.__mro__):
                return True
        return NotImplemented

这个 __subclasshook__ 的定义表明,只要一个类有 __contains__ 属性,即使它没有直接继承 Container 类,也被视为 Container 的子类。因此我可以写出这个例子:
class ContainAllTheThings(object):
    def __contains__(self, item):
        return True

>>> issubclass(ContainAllTheThings, collections.Container)
True
>>> isinstance(ContainAllTheThings(), collections.Container)
True

换句话说,如果您实现了正确的接口,那么您就是一个子类!ABCs为在Python中定义接口提供了一种正式的方法,同时保持了鸭子类型的精神。此外,这种方法以符合开放-封闭原则的方式工作。
Python的对象模型看起来与更“传统”的OO系统(我指的是Java*)表面上相似——我们有类、对象、方法——但当您深入了解时,您会发现它更加丰富和灵活。同样,Python对抽象基类的概念可能对于Java开发人员来说是可识别的,但实际上它们的目的完全不同。
有时我会编写多态函数,可以对单个项目或多个项目进行操作,并且我发现isinstance(x,collections.Iterable)比hasattr(x,'__iter__')或等效的try...except块更易读。(如果您不了解Python,这三个中哪一个会使代码意图最清晰?)

话虽如此,我发现我很少需要编写自己的ABC,并且通常是通过重构发现需要一个。如果我看到一个多态函数进行了许多属性检查,或者许多函数进行了相同的属性检查,则这种味道表明存在一个等待提取的ABC。

*不涉及Java是否为“传统”面向对象系统的争论......


附录: 即使抽象基类可以重写 isinstanceissubclass 的行为,它仍然不会进入虚拟子类的MRO。这是客户端的一个潜在陷阱:并非每个对象都有 MyABC 定义的方法,即使 isinstance(x, MyABC) == True

class MyABC(metaclass=abc.ABCMeta):
    def abc_method(self):
        pass
    @classmethod
    def __subclasshook__(cls, C):
        return True

class C(object):
    pass

# typical client code
c = C()
if isinstance(c, MyABC):  # will be true
    c.abc_method()  # raises AttributeError

很不幸,这是“千万不要这样做”的陷阱之一(Python中相对较少的陷阱之一!):避免同时使用__subclasshook__和非抽象方法定义ABC。此外,您应该使__subclasshook__的定义与ABC定义的抽象方法集一致。

32
如果你实现了正确的接口,那么你就是一个子类。感谢您的提醒,我不知道Oddthinking是否注意到了这一点,但我确实错过了它。顺便说一句,isinstance(x, collections.Iterable)对我来说更清晰,我也了解Python。 - Muhammad Alkarouri
在给定的示例中,难道不需要执行 Container.register(ContainAllTheThings) 才能工作吗? - BoZenKhaa
1
@BoZenKhaa 答案中的代码是有效的!试一下吧!__subclasshook__ 的意思是“任何满足此谓词的类都被视为子类,用于 isinstanceissubclass 检查,无论它是否已在 ABC 中注册,以及 无论它是否是直接子类”。正如我在答案中所说,如果你实现了正确的接口,你就是一个子类! - Benjamin Hodgson
当然会,我只是对代码中的一个错别字视而不见,谢谢 :-)。好文章。我应该弄个集成开发环境。 - BoZenKhaa
很好的解释。从这个意义上讲,ABC类是否类似于Golang接口的概念:“如果你实现了方法,那么你就是一个实例”? - tpoker
显示剩余2条评论

202

简短版

ABCs在客户端和实现类之间提供了更高级别的语义契约。

详细版

类与其调用者之间存在一种契约。该类承诺执行某些操作并具有某些属性。

契约有不同的层次。

在非常低的层次上,契约可能包括方法的名称或其参数数量。

在静态类型语言中,编译器实际上会强制执行该契约。在Python中,您可以使用EAFP或类型内省来确认未知对象是否符合预期的契约。

但是,在契约中还存在更高级别的语义承诺。

例如,如果存在__str__()方法,则应返回对象的字符串表示形式。它可能删除对象的所有内容,提交事务并从打印机中输出空白页面...但是,Python手册中描述了它应该执行的常见理解。

这是一个特殊情况,语义契约在手册中有所描述。 print() 方法应该做什么?它应该将对象写入打印机或屏幕上的一行,还是其他什么?这取决于 - 您需要阅读注释以了解此处完整的契约。一个简单检查 print() 方法是否存在的客户端代码已经确认了契约的一部分 - 即可以进行方法调用,但并不意味着对调用的更高级别语义达成了共识。

定义抽象基类(ABC)是产生类实现者和调用者之间契约的一种方式。它不仅仅是方法名称列表,而是对这些方法应该执行的操作的共同理解。如果您从此 ABC 继承,那么您承诺遵循注释中描述的所有规则,包括 print() 方法的语义。

Python 的鸭子类型在灵活性方面具有许多优势,但并不能解决所有问题。ABC 提供了 Python 自由形式和静态类型语言束缚与纪律之间的中间解决方案。


16
我认为你说得有道理,但我无法跟上你的思路。那么,在合同方面,实现 __contains__ 的类和继承自 collections.Container 的类之间有什么区别?在你的例子中,Python 中始终存在着对 __str__ 的共识。实现 __str__ 承诺的内容与继承某些 ABC 然后实现 __str__ 相同。在两种情况下,您都可以违反合同;没有像静态类型检查中那样的可证明语义。 - Muhammad Alkarouri
16
collections.Container是一个退化情况,它仅包括\_\_contains\_\_方法,并且仅用于表示预定义约定。单独使用ABC并不能增加多少价值,我同意这一点。我怀疑它被添加是为了允许(例如)Set继承它。当你到达Set时,突然属于该ABC具有相当大的语义。一个项不能属于集合两次。这不可以通过方法的存在来检测。 - Oddthinking
5
是的,我认为Setprint()更适合作为例子。我试图找到一个含义模糊、仅凭名称和Python手册无法确定其正确性的方法名称。 - Oddthinking
4
可以用“Set”作为例子重新改写答案吗?@Oddthinking,使用“Set”会更加合理。 - Ehtesh Choudhury
2
我认为这篇文章讲解得非常好:https://dbader.org/blog/abstract-base-classes-in-python - szabgab
显示剩余5条评论

147

ABC的一个方便之处在于,如果您没有实现所有必要的方法(和属性),则在实例化时会出现错误,而不是在您实际尝试使用缺少的方法时可能会出现AttributeError,这样可以避免更晚的错误。

from abc import ABCMeta, abstractmethod

# python2
class Base(object):
    __metaclass__ = ABCMeta

    @abstractmethod
    def foo(self):
        pass

    @abstractmethod
    def bar(self):
        pass

# python3
class Base(object, metaclass=ABCMeta):
    @abstractmethod
    def foo(self):
        pass

    @abstractmethod
    def bar(self):
        pass

class Concrete(Base):
    def foo(self):
        pass

    # We forget to declare `bar`


c = Concrete()
# TypeError: "Can't instantiate abstract class Concrete with abstract methods bar"

来自https://dbader.org/blog/abstract-base-classes-in-python的示例。

编辑:包括Python3语法,感谢@PandasRocks。


如果Base在另一个文件中被定义,那么你必须从Base.Base中继承它或将导入行更改为“from Base import Base”。 - Ryan Tennill
值得注意的是,在Python3中语法略有不同。请参考此答案:https://dev59.com/nojca4cB1Zd3GeqPsSf1 - PandasRocks
14
来自C#背景,这是使用抽象类的*主要原因。你提供功能,但声明该功能需要进一步实现。其他答案似乎忽略了这一点。 - Josh Noe

26

使用该技术,可以更轻松地确定对象是否支持给定的协议,而无需检查协议中所有方法的存在性,也不会由于不支持而在“敌方”领土深处触发异常。


14

抽象方法确保在父类中调用的任何方法也必须出现在子类中。以下是调用和使用抽象方法的常规方式。该程序是用Python3编写的。

常规调用方式:

class Parent:
    def method_one(self):
        raise NotImplemented()

    def method_two(self):
        raise NotImplementedError()

class Son(Parent):
   def method_one(self):
       return 'method_one() is called'

c = Son()
c.method_one()

'method_one() 被调用了'
c.method_two()

未实现错误
使用抽象方法:
from abc import ABCMeta, abstractmethod

class Parent(metaclass=ABCMeta):
    @abstractmethod
    def method_one(self):
        raise NotImplementedError()
    @abstractmethod
    def method_two(self):
        raise NotImplementedError()

class Son(Parent):
    def method_one(self):
        return 'method_one() is called'

c = Son()

“TypeError: 无法用抽象方法 method_two 实例化 Son 抽象类。”
“由于子类中未调用 method_two 方法,因此出现了错误。正确的实现如下:”
from abc import ABCMeta, abstractmethod

class Parent(metaclass=ABCMeta):
    @abstractmethod
    def method_one(self):
        raise NotImplementedError()
    @abstractmethod
    def method_two(self):
        raise NotImplementedError()

class Son(Parent):
    def method_one(self):
        return 'method_one() is called'
    def method_two(self):
        return 'method_two() is called'

c = Son()
c.method_one()

"method_one() 被调用了"

2
谢谢。这不是与Cerberos上面的答案所说的一样吗? - Muhammad Alkarouri
5
我认为这个答案更好,因为它还展示了如果您不使用abstractmethod会发生什么。 - Louis Yang
你的意思是所有使用 @abstractmethod 装饰器定义的函数必须在子类中实现。如果我们有一个没有实现的函数,就会得到 Can't instantiate abstract class 错误。如果这是真的,那么这是非常有用的事情。 - Prakhar Sharma

1
ABC(抽象基类)使得可以创建设计模式和框架。请查看Brandon Rhodes的这个PyCon演讲: Python Design Patterns 1 Python本身内置的协议(不仅包括迭代器、装饰器和槽(它们本身实现了FlyWeight模式))都是由于ABC而可能的(虽然在CPython中是作为虚拟方法/类实现的)。
在Python中,鸭子类型确实使一些模式变得微不足道,Brandon提到过,但是许多其他模式继续出现并且在Python中很有用,例如适配器。
总之,ABC使您能够编写可扩展和可重复使用的代码。根据GoF:
  1. 面向接口编程,而不是面向实现(继承会破坏封装性;面向接口编程促进松耦合/控制反转/“好莱坞原则”:我们不会打电话给你,我们会叫你)

  2. 优先使用对象组合而非类继承(委托工作)

  3. 封装变化的概念(开闭原则使类对扩展开放,但对修改关闭)

此外,随着 Python 的静态类型检查器(例如 mypy)的出现,可以将 ABC 用作类型,而不是对每个函数接受的参数或返回的对象使用 Union[...]。想象一下,每当您的代码库支持新对象时,都必须更新类型而不是实现?这会非常快地变得难以维护(不具有可扩展性)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接