检查函数参数类型算Pythonic吗?

22 投票
6 回答
16777 浏览
提问于 2025-04-15 17:18

我知道,在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 个回答

5

确实如此。

“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类的内部结构才能理解它)。如果你没有查看这个特定的对象,而是把它序列化成文件,发送到另一个部门,几个月后再加载呢?到你发现并修正错误时,你的工作可能已经陷入麻烦了……

话虽如此,你并不需要自己编写类型检查的装饰器。已经有专门为此目的的模块,比如:

13

在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),这会失败,因为aLoggedObject的一个实例。如果你让鸭子类型自然发挥,这样就可以正常工作。

如果有人不小心传入了错误类型的对象,可能会抛出像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
34

每个人的喜好可能不同,但在Python中,有一种风格叫做“Pythonic”,就是你可以直接使用需要的对象。如果这些对象不支持你想要的操作,程序就会报错。这种方式被称为鸭子类型

这种风格有几个好处:首先,它支持多态性,也就是说,只要新对象能执行正确的操作,你就可以用它们替代旧的代码。其次,它让代码运行得更顺畅,因为你不需要进行很多检查。

当然,如果你用错了参数,类型检查会给你更清晰的错误信息,而鸭子类型可能就不那么明确了。不过,正如我说的,每个人的喜好可能不同。

撰写回答