问题描述
子类的方法与基类的相应方法具有相同的签名是一种很好的做法。如果违反这一原则,PyCharm 会给出警告:
Signature of method does not match signature of base method in class
这一原则有(至少)一个例外:Python 初始化方法 __init__
。子类具有与其父类不同的初始化参数是很常见的。它们可能有额外的参数,也可能有较少的参数,通常通过使用父类参数的常量值来获得。
由于 Python 不支持具有不同签名的多个初始化方法,因此具有不同构造函数的 Pythonic 方式是所谓的工厂方法(参见例如:https://stackoverflow.com/a/682545/10816965)。
PyCharm 认为这些工厂方法也不例外,子类的方法应该与其对应的父类具有相同的签名。当然,我可以忽略这些警告——因为这些工厂方法类似于 __init__
或 __new__
,我认为人们可能会认为这些警告放错了地方。
然而,我想知道我是否在这里遗漏了一些东西,我的编码风格不是最佳实践。
所以我的问题是:这是 PyCharm 的一种意外行为,还是对于这种模式确实有更 Pythonic 的方式?
class A:
@classmethod
def from_something(cls,something):
self = cls()
# f(self,something)
return self
def __init__(self):
pass
class B(A):
@classmethod
def from_something(cls,something,param): # PyCharm warning:
# Signature of method 'B.from_something()' does not match
# signature of base method in class 'A'
self = cls(param)
# g(self,param)
return self
def __init__(self,param):
super().__init__()
self.param = param
class C:
@classmethod
def from_something(cls,param):
self = cls(param)
# f(self,param):
self.param = param
class D(C):
@classmethod
def from_something(cls,something): # PyCharm warning: Signature of
# method 'D.from_something()' does not match signature of base
# method in class 'C'
self = cls()
# g(self,something)
return self
def __init__(self):
super().__init__(None)
解决方法
要考虑的主要问题是:
- 基类和派生类之间覆盖方法(未重载)签名的兼容性。
要完全解决和理解这一点,让我们首先考虑:
在 Python 中,方法仅按名称查找,如属性。您可以通过查看 __dict__
(例如 Class.__dict__
和 instance.__dict__
)
自定义类,3.2. The standard type hierarchy
一个类有一个由字典对象实现的命名空间。类属性引用被转换为该字典中的查找,例如,C.x
被转换为 C.__dict__["x"]
(尽管有许多钩子允许其他方式定位属性)。 如果在那里找不到属性名称,则继续在基类中搜索属性。
如果我们在没有方法 Bottom
的情况下定义 def right(self,one):
,我们将从 __dict__
获得
>>> Bottom.__dict__
{'__module__': '__main__','__init__': <function Bottom.__init__ at 0x0000024BD43058B0>,'__doc__': None}
如果我们在类 Bottom
中重写方法 def right(self,one):
,__dict__
现在将拥有
>>> Bottom.__dict__
{'__module__': '__main__','right': <function Bottom.right at 0x0000024BD43483A0>,'__doc__': None}
这与 Java 等其他面向对象语言不同,后者具有 method overloading,基于参数的数量和类型进行解析/查找,而不仅仅是名称。在这方面,Python 是 overriding 方法,因为查找是单独在名称/类/实例上进行的。 Python 确实支持类型提示“重载”(严格意义上的),参见 Function/method overloading,PEP 484 -- Type Hints
9.5. Inheritance,Python 教程。
派生类可以覆盖其基类的方法。由于方法在调用同一对象的其他方法时没有特殊权限,基类的方法调用同一基类中定义的另一个方法可能最终会调用覆盖它的派生类的方法。强>
让我们验证上面的操作,一个最小的例子:
class Top:
def left(self,one):
self.right(one,2)
def right(self,one,two):
pass
def __init__(self):
pass
class Bottom(Top):
def right(self,one):
pass
def __init__(self,one):
super().__init__()
运行示例:
>>> t = Top()
>>> t.left(1)
>>> b = Bottom()
>>> b.left(1)
Traceback (most recent call last):
File "<input>",line 18,in <module>
File "<input>",line 3,in left
TypeError: right() takes 2 positional arguments but 3 were given
在这里你看到改变派生类中方法的签名会中断基类内部的方法调用。
这是一个严重的副作用,因为派生类只是约束了基类。您创建了一个反模式的向上依赖项。通常,您希望约束/依赖项在继承中向下移动,而不是向上移动。你刚刚从单向依赖变成了双向依赖。 (在实践中,这会给程序员增加更多的工作量,他们现在必须考虑并解决额外的依赖性 - 这与 Principle of least astonishment 背道而驰,下一个查看您的代码的程序员可能会不高兴。)
或者对于这种模式是否确实有更 Pythonic 的方式?
Pythonic 在这里意味着你可以两种方式都做,你有选择:
- 使用不同的签名覆盖该方法需要:
- 意识到其中的含义并增加了双向依赖性。
- 如果您选择关闭 linter 警告,可能会让您之后的程序员更加不高兴(他们现在被剥夺了公平警告)。
- 这可能是更Pythonic 的选择,因为它更简单。您可以做的是在文档字符串中记录工厂方法返回类的实例,并且在可变参数签名中传递的参数应遵循构造函数的参数,如下所示:
class Top:
def __init__(self):
pass
@classmethod
def from_something(cls,*args,**kwargs) -> "Top":
"""Factory method initializes and returns an instance of the class.
The arguments should follow the signature of the constructor.
"""
class Bottom(Top):
def __init__(self,one):
super().__init__()
@classmethod
def from_something(cls,**kwargs) -> "Bottom":
"""Factory method initializes and returns an instance of the class.
The arguments should follow the signature of the constructor.
"""
- 附言对于提示返回类型的类型的最后 "gotcha" 参见 this excellent post,在本示例中,为了简单起见,使用了“前向声明”。它不会更改签名,只会更改方法的
__annotations__
属性。但是,“成为 Pythonic” 我们可以汇到 a BDFL post 我不完全确定提示返回的类型是否违反了 Liskov Substitution Principle 但 Mypy 和 PyCharm linter 似乎让我逃过了一劫...