检查函数参数类型算Pythonic吗?
我知道,在Python中,检查函数参数的类型通常不是很受欢迎,但我觉得在某些情况下这样做是有道理的。
在我的项目中,有一个抽象基类叫做Coord
,还有一个子类Vector
,它有更多的功能,比如旋转、改变大小等等。数字的列表和元组也会被认为是Coord
类型。我的很多函数和方法都接受这些Coord
类型作为参数。我设置了一些装饰器来检查这些方法的参数。下面是一个简化的版本:
class accepts(object):
def __init__(self, *types):
self.types = types
def __call__(self, func):
def wrapper(*args):
for i in len(args):
if not isinstance(args[i], self.types[i]):
raise TypeError
return func(*args)
return wrapper
这个版本非常简单,但仍然有一些bug。它只是用来说明这个观点。使用时可以这样:
@accepts(numbers.Number, numbers.Number)
def add(x, y):
return x + y
注意:我只是检查参数类型是否是抽象基类。
这样做是个好主意吗?有没有更好的方法可以避免在每个方法中重复类似的代码?
编辑:
如果我做同样的事情,但不是在装饰器中提前检查类型,而是在装饰器中捕获异常,这样可以吗:
class accepts(object):
def __init__(self, *types):
self.types = types
def __call__(self, func):
def wrapper(*args):
try:
return func(*args)
except TypeError:
raise TypeError, message
except AttributeError:
raise AttributeError, message
return wrapper
这样做会更好吗?
6 个回答
确实如此。
“Pythonic”这个说法并没有一个明确的定义,但一般来说,它指的是用合适的方式写代码,不要写得过于冗长,遵循Python的风格指南(PEP 8),并努力让代码易于阅读。我们还有“Python之禅”(import this
)作为指导。
在你的函数上方加上@accepts(...)
这样的注解,是帮助可读性还是影响可读性呢?可能是有帮助的,因为规则#2说“明确比隐含要好”。还有一个专门为此目的设计的PEP-484。
在运行时检查类型算不算Pythonic呢?当然,这会影响执行速度——但Python的目标从来不是写出最快的代码,其他的都不重要。当然,快的代码比慢的好,但可读的代码比乱七八糟的代码好,易于维护的代码比黑客式的代码好,可靠的代码比有bug的代码好。所以,根据你正在编写的系统,你可能会发现这种权衡是值得的,使用运行时类型检查也是值得的。
特别是,规则#10“错误不应该默默无声”可以被视为支持额外的类型检查。举个简单的例子:
class Person:
def __init__(self, firstname: str, lastname: str = ""):
self.firstname = firstname
self.lastname = lastname
def __repr__(self) -> str:
return self.firstname + " " + self.lastname
当你这样调用时:p = Person("John Smith".split())
会发生什么?嗯,起初什么都不会发生。(这已经是个问题:一个无效的Person
对象被创建了,但这个错误却默默无声地过去了)。然后过了一段时间你想查看这个人,结果得到
>>> print(p)
TypeError: can only concatenate tuple (not "str") to tuple
如果你刚创建了这个对象,并且你是个有经验的Python程序员,那么你会很快发现问题所在。但如果不是呢?错误信息几乎没用(也就是说,你需要了解Person
类的内部结构才能理解它)。如果你没有查看这个特定的对象,而是把它序列化成文件,发送到另一个部门,几个月后再加载呢?到你发现并修正错误时,你的工作可能已经陷入麻烦了……
话虽如此,你并不需要自己编写类型检查的装饰器。已经有专门为此目的的模块,比如:
在Python中,鼓励使用鸭子类型的一个原因是,有可能有人会把你的对象包装起来,这样它看起来像是错误的类型,但实际上仍然可以正常工作。
这里有一个包装对象的类的例子。一个LoggedObject
在所有方面都像它所包装的对象,但当你调用LoggedObject
时,它会先记录这个调用,然后再执行这个调用。
from somewhere import log
from myclass import A
class LoggedObject(object):
def __init__(self, obj, name=None):
if name is None:
self.name = str(id(obj))
else:
self.name = name
self.obj = obj
def __call__(self, *args, **kwargs):
log("%s: called with %d args" % (self.name, len(args)))
return self.obj(*args, **kwargs)
a = LoggedObject(A(), name="a")
a(1, 2, 3) # calls: log("a: called with 3 args")
如果你明确地测试isinstance(a, A)
,这会失败,因为a
是LoggedObject
的一个实例。如果你让鸭子类型自然发挥,这样就可以正常工作。
如果有人不小心传入了错误类型的对象,可能会抛出像AttributeError
这样的异常。虽然如果你明确检查类型,异常可能会更清晰,但我认为总体来说,这种情况下使用鸭子类型是更好的选择。
有时候你确实需要测试类型。我最近学到的一个例子是:当你写处理序列的代码时,有时你真的需要知道你是否有一个字符串,或者它是其他类型的序列。考虑这个:
def llen(arg):
try:
return max(len(arg), max(llen(x) for x in arg))
except TypeError: # catch error when len() fails
return 0 # not a sequence so length is 0
这个函数应该返回一个序列的最长长度,或者它里面任何嵌套的序列。它是可以工作的:
lst = [0, 1, [0, 1, 2], [0, 1, 2, 3, 4, 5, 6]]
llen(lst) # returns 7
但是如果你调用llen("foo")
,它会无限递归,直到栈溢出。
问题在于字符串有一个特殊的属性,它们总是表现得像一个序列,即使你从字符串中取出最小的元素;一个字符的字符串仍然是一个序列。所以我们不能在没有明确检查字符串的情况下编写llen()。
def llen(arg):
if isinstance(arg, str): # Python 3.x; for 2.x use isinstance(arg, basestring)
return len(arg)
try:
return max(len(arg), max(llen(x) for x in arg))
except TypeError: # catch error when len() fails
return 0 # not a sequence so length is 0
每个人的喜好可能不同,但在Python中,有一种风格叫做“Pythonic”,就是你可以直接使用需要的对象。如果这些对象不支持你想要的操作,程序就会报错。这种方式被称为鸭子类型。
这种风格有几个好处:首先,它支持多态性,也就是说,只要新对象能执行正确的操作,你就可以用它们替代旧的代码。其次,它让代码运行得更顺畅,因为你不需要进行很多检查。
当然,如果你用错了参数,类型检查会给你更清晰的错误信息,而鸭子类型可能就不那么明确了。不过,正如我说的,每个人的喜好可能不同。