动态语言中的类型类

10 投票
6 回答
1305 浏览
提问于 2025-04-17 22:56

我得承认,我对Python的了解仅仅是基础,目前正在学习Haskell。

我想知道在Python或者Clojure(或者其他一些动态强类型语言)中,是否存在或有意义的类型类这个概念?

换句话说,如果我有一个函数名叫f,那么根据传给f的参数类型,可能会调用不同的函数实现(就像Haskell中属于Eq类型类的==函数)。在像Clojure或Python这样的动态语言中,有这样的概念吗?

6 个回答

1

在某种程度上,这种功能可以通过类方法来实现。例如,__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++ 中叫做虚类)。在动态类型语言中,你不太会看到它们,因为接口的主要目的是声明一组方法及其相关的类型,而实现这个接口的类必须遵循这些规定。如果没有静态类型,声明这些类型的意义就不大了。

2

在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语法,这可以让你定义具有可变长度参数列表的函数,而不需要使用默认值)。

4

多重分发(在Julia语言中的例子)和类型类有相似的目的。多重分发可以在编译时实现多态(就像类型类一样),而在典型的动态语言(比如Python)中,对象接口通常只能在运行时实现多态。多重分发的性能比你在动态面向对象语言中看到的普通接口要好,所以在动态语言中使用它是非常合理的。

Python中有一些多重分发的实现,但我不确定它们是否能提供编译时多态。

5

多方法在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"
4

在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)

在动态语言中,你其实无法做到这一点,因为没有类型推断。你可以通过传递一些代表“我想要的结果类型”的对象来稍微模拟一下,利用这些对象作为调度器,但这并不完全一样。

撰写回答