动态语言中的类型类
我得承认,我对Python的了解仅仅是基础,目前正在学习Haskell。
我想知道在Python或者Clojure(或者其他一些动态强类型语言)中,是否存在或有意义的类型类这个概念?
换句话说,如果我有一个函数名叫f
,那么根据传给f
的参数类型,可能会调用不同的函数实现(就像Haskell中属于Eq
类型类的==
函数)。在像Clojure或Python这样的动态语言中,有这样的概念吗?
6 个回答
在某种程度上,这种功能可以通过类方法来实现。例如,__repr__
方法在某种程度上和 Haskell 里的 Show
类型类是差不多的:
$ ghci
>>> x = 1
>>> show x
"1"
>>> x = [1,2,3]
>>> show x
"[1,2,3]"
或者在 Python 中
$ python
>>> x = 1
>>> x.__repr__()
'1'
>>> x = [1,2,3]
>>> x.__repr__()
'[1,2,3]'
很明显,在每种情况下,调用的函数是不同的,这取决于你在使用 show
/ __repr__
时所针对的对象的类型(在 Haskell 中)或类(在 Python 中)。
对于支持接口的语言来说,接口是一个更接近的概念——它们是抽象类,里面的所有方法也是抽象的(在 Java 中叫做接口,在 C++ 中叫做虚类)。在动态类型语言中,你不太会看到它们,因为接口的主要目的是声明一组方法及其相关的类型,而实现这个接口的类必须遵循这些规定。如果没有静态类型,声明这些类型的意义就不大了。
在Python中,你不能在同一个范围内定义多个同名的函数。如果你这样做了,第二个函数会覆盖第一个函数,只有第二个函数会被调用(至少在同一个范围内是这样——显然,你可以在不同的类中定义同名的方法)。而且,参数列表是不考虑类型的,所以即使你能定义两个函数,解释器也只会根据参数的数量来区分它们,而不是根据参数的类型。你需要做的是写一个可以处理多种不同参数列表的函数,然后在这个函数内部根据需要检查参数的类型。
实现这个的最简单方法是使用默认参数和关键字参数。
默认参数
假设你有一个这样的函数:
def BakePie(crust, filling="apple", oventemp=(375,F), universe=1):
...
你可以像这样使用位置参数来调用这个函数:
BakePie("graham cracker")
BakePie("buttery", "cherry")
BakePie("fluffy", "lemon meringue", (400,F))
BakePie("fluffy", "key lime", (350,F), 7)
关键字参数
这些都可以正常工作,但你可能不总是想改变每一个默认值。如果你想在一个不同的宇宙里做一个标准的苹果派呢?那么你可以使用关键字参数来调用它:
BakePie("buttery", universe=42)
在这种情况下,填充和烘烤温度的默认参数会被使用,只有宇宙(还有必须提供的外壳,因为没有默认值)这个参数会被改变。这里有一个规则,就是在调用函数时,所有的关键字参数必须放在位置参数的右边。关键字参数的顺序不重要,例如,这样也可以:
BakePie("fluffy", oventemp=(200, C), filling="pecan")
但这样就不行:
BakePie(filling="boysenberry", "crumb")
更多关于关键字参数的内容
现在,如果你的函数的行为完全取决于传入的参数,那该怎么办呢?比如,你有一个乘法函数,它可以接收两个整数,或者一个整数和一个整数列表,或者两个整数列表,并进行乘法运算。在这种情况下,你作为调用者会想使用关键字参数。你可以这样设置函数定义:
def GenericMultiply(int1=False, int2=False, ilist1=False, ilist2=False):
# check which parameters have values, then do stuff
(或者用None代替False。)
当你需要乘以两个整数时,可以这样调用:
GenericMultiply(int1=6, int2=7)
注意:你也可以仅用两个位置参数来完成上述操作,并在函数内部手动检查它们的类型,可以使用type()函数,或者使用try:except:块,调用仅适用于列表或整数的方法。
进一步阅读
在Python中还有很多其他方法可以实现这个功能,我只是描述了最简单的一种。如果你想了解更多,我推荐查看官方Python教程中关于定义函数的部分,以及接下来的部分“更多关于定义函数”(这部分会详细讲解位置参数和关键字参数,以及*args和**kwargs语法,这可以让你定义具有可变长度参数列表的函数,而不需要使用默认值)。
多重分发(在Julia语言中的例子)和类型类有相似的目的。多重分发可以在编译时实现多态(就像类型类一样),而在典型的动态语言(比如Python)中,对象接口通常只能在运行时实现多态。多重分发的性能比你在动态面向对象语言中看到的普通接口要好,所以在动态语言中使用它是非常合理的。
Python中有一些多重分发的实现,但我不确定它们是否能提供编译时多态。
多方法在Clojure中似乎能解决问题。比如,我们可以定义一个 plus
函数,它可以把数字相加,但如果是其他类型的东西,就把它们的字符串形式连接起来。
(defmulti plus (fn [& xs] (every? number? xs)))
(defmethod plus true [& xs] (apply + xs))
(defmethod plus false [& xs] (apply str xs))
(plus 1 8) ;9
(plus 1 \8) ;"18"
多方法是函数((ifn? plus)
的结果是 true
),所以它们的地位和其他函数一样高。
(let [z (partial plus 5)] (z \3)) ;"53"
在Clojure中,你可以通过多方法或者协议来实现类似的功能,或者在Python中使用简单的成员函数(类方法)。不过,这些方法都有一个重要的缺失,那就是Haskell中的返回类型多态性。
编译器知道你“期望”一个函数返回什么类型,因此可以根据这个期望调用不同的实现。这意味着同一个函数,如果用相同的参数调用,可能会根据返回值的处理方式做出完全不同的事情。例如:
Prelude> read "[5,6,7]" :: [Int]
[5,6,7]
Prelude> read "[5,6,7]" :: [Double]
[5.0,6.0,7.0]
同样,你甚至可以有多态常量,每个类型类实例都有不同的值:
Prelude Data.Word> (minBound, minBound, minBound) :: (Int, Bool, Word8)
(-9223372036854775808,False,0)
在动态语言中,你其实无法做到这一点,因为没有类型推断。你可以通过传递一些代表“我想要的结果类型”的对象来稍微模拟一下,利用这些对象作为调度器,但这并不完全一样。