Python 是动态强类型语言
Dynamic programming language: In computer science, a dynamic programming language is a class of high-level programming languages, which at runtime execute many common programming behaviours that static programming languages perform during compilation. These behaviors could include an extension of the program, by adding new code, by extending objects and definitions, or by modifying the type system.
以上内容摘自维基百科对于动态编程语言(Dynamic programming language)的定义。动态语言是相对于静态语言而言的。相比之下,静态语言有更严格的语法限制,在编译阶段就能够确定数据类型,典型的静态语言包括 C、C++ 和 Java 等。这一类语言的优势在于代码结构规范,易于调试和重构。缺点则是语法冗杂,编码方式不灵活。
而动态语言最典型的特点在于不需要编码时指定数据类型,类型信息由运行时推断得出。常见的动态语言都是一些脚本语言,比如 JavaScript、Python、PHP 等。这类语言虽然调试和重构的支持不如静态语言,但由于没有类型约束编码更加灵活。
Python 就是一门动态编程语言,编码时不用指定类型,且运行时可以变更数据类型:
1 | 1 a = |
尽管 “PEP 484 – Type Hints” 引入了类型提示,但它明确指出:Python 依旧是一门动态类型语言,作者从未打算强制要求使用类型提示,甚至不会把它变成约定。但是 API 作者能够添加可选的类型注解,执行某种静态类型检查。
另外值得注意的是,虽然 Python 支持运行时变更数据类型,但变量所指向的内存地址空间已经在变更时发生了变化。也就是说,数据类型变更后不再指向原先的内存地址空间。我们可以用查看对象内存地址的 id()
函数加以验证:
1 | '123456' a = |
强弱类型
确定了 Python 是动态语言后,接下来我们讨论强弱类型语言。首先,强弱类型与是否是动态语言没有必然联系,动态语言并不一定就是弱类型语言,Python 就是一门动态强类型语言。这里的“强弱”可以理解为用以描述编程语言对于混入不同类型的值进行运算时的处理方式。
比如在弱类型语言 JavaScript 中,我们可以直接对字符串和数值类型进行相加,虽然得出的结果并不一定是我们想要的:
1 | > '1' + 2 |
出现这种现象的原因是 JavaScript 支持变量类型的隐式转换。上面的例子就是将数值类型隐式转换为了字符串类型再进行相加。也因此,JavaScript 中才会存在三个等号的判等运算符 ===
。与 ==
不同,===
在判等时不会进行隐式转换,所以才会有下面这样的结果:
1 | > 1 == '1' |
而 Python 作为强类型语言,不支持类型的隐式转换,所以整型和字符型相加会直接报错:
1 | 1 + '2' |
所以,强弱类型语言的区别体现在:强类型语言在遇到函数声明类型和实际调用类型不符合的情况时会直接出错或者编译失败;而弱类型的语言可能会进行隐式转换,从而产生难以意料的结果。
鸭子类型
在面向对象的静态类型语言中,如果要实现一个带特定功能的序列类型,你可能会想到使用继承,以期能在添加特定功能的同时尽可能的重用代码。这符合面向对象的设计原则,但在 Python 中,继承却不是首选方案。
在 Python 这类动态类型语言中,有一种风格叫做鸭子类型(duck typing)。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口决定的,而是由”当前方法和属性的集合“决定。这个概念最早来源于 James Whitcomb Riley 提出的“鸭子测试”,“鸭子测试”可以这样表述:“如果一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么它就可以被称为鸭子。”
在 Python 中创建功能完善的序列类型无需使用继承,只需实现符合序列协议的方法。那么,协议又是什么呢?在面向对象编程中,协议是非正式的接口,只在文档中定义,不在代码中定义,可以看作是约定俗成的惯例。例如,Python 的迭代器协议就包含 __iter__
和 __next__
两个方法,任何实现了 __iter__
和 __next__
方法的类,Python 解释器会将其视为迭代器,所有迭代器支持的操作,该类也会支持,譬如 next()
方法和 for
循环。用鸭子类型来解释就是:这个类看起来像是迭代器,那它就是迭代器。
1 | from collections.abc import Iterator |
由于实现了迭代器协议,上面代码中的 IterDuck 类甚至不需要显式的继承 Iterator 类,Python 解释器就已经将它绑定为 Iterator 类的子类。
在鸭子类型中,关注点在于对象的行为,即提供的方法,而不在于对象所属的类型。
序列协议
序列协议之所以要专门作为单独的一节,是因为序列在 Python 中尤为重要,Python 会特殊对待看起来像是序列的对象。序列协议包含 __len__
和 __getitem__
两个方法。任何类,只要实现了 __len__
和 __getitem__
方法,就可以被看作是一个序列,即使这一次 Python 解释器不再将其绑定为 Sequence 类的子类。
由于序列的特殊性,如果你知道类的具体应用场景,甚至只需要实现序列协议的一部分。下面的代码演示了一个只实现了 __getitem__
方法的类,对于序列操作的支持程度:尽管只实现了 __getitem__
方法,但 SeqDuck 实例却可以使用 for
循环迭代以及 in
运算符。
1 | class SeqDuck: |
即使没有 __iter__
方法,SeqDuck 实例依然是可迭代的对象,因为当 Python 解释器发现存在 __getitem__
方法时,会尝试调用它,传入从 0 开始的整数索引进行迭代(这是一种后备机制)。同样的,即使没有 __contains__
方法,但 Python 足够智能,能够迭代 SeqDuck 实例检查有没有指定元素。
综上,鉴于序列协议的重要性,如果没有 __iter__
和 __contains__
方法,Python 会尝试调用 __getitem__
方法设法让迭代和 in
运算符可用。
绑定虚拟子类
你也许会有个疑问,为什么 IterDuck 和 SeqDuck 都没有显示继承父类,但 IterDuck 却是 Iterator 类的子类,而 SeqDuck 不是 Sequence 的子类呢?这要归因于 Python 的虚拟子类机制。一般情况下,使用 register()
方法可以将一个类注册为另一个类的虚拟子类,比如 collections.abc
模块中是这样将内置类型 tuple、str、range 和 memoryview 注册为序列类 Sequence 的虚拟子类的:
1 | Sequence.register(tuple) |
这也是为什么这些类的显示继承父类是 object,但同样能应用序列类的诸多方法。而对于用户自定义的类型来说,即使不注册,抽象基类也能把一个类识别为虚拟子类,这需要抽象基类实现一个名为 __subclasshook__
的特殊的钩子方法。如下是 collections.abc
模块中 Iterator 抽象基类的源码:
1 | # _collections_abc.py |
对于实现了迭代器协议,即 __iter__
和 __next__
方法的类来说,它就会被钩子方法检测到并绑定为 Iterator 的虚拟子类,这解释了为什么 issubclass(IterDuck, Iterator)
会验证通过。类似的,可迭代对象 Iterable 协议要更加宽松,因为它只检查了 __iter__
方法。
那么为什么 SeqDuck 没有被绑定为 Sequence 的子类呢?因为 Sequence 类没有实现 __subclasshook__
钩子方法。Python 对序列的子类要求更加严格,即使实现了序列协议 __len__
和 __getitem__
方法的类可以被视为一个序列,但依然不能称之为序列的子类。最典型的例子就是内置类型字典。虽然字典实现了这两个方法,但它不能通过整数偏移值获取元素,且字典内的元素顺序是无序的,所以不能将其视为 Sequence 的子类型。
1 | from collections.abc import Sequence |
特殊方法
想要更深入地理解鸭子类型,必须要了解 Python 中的特殊方法。前面我们提到的以双下划线开头和结尾的方法,比如 __iter__
,就称为特殊方法(special methods),或称为魔法方法(magic methods)。
Python 标准库和内置库包含了许多特殊方法,需要注意的是,永远不要自己命名一个新的特殊方法,因为你不知道下个 Python 版本会不会将其纳入到标准库中。我们需要做的,是重写现有的特殊方法,并且通常情况下,不需要显式的调用它们,应当使用更高层次的封装方法,比如使用 str()
代替 __str__()
,对特殊方法的调用应交由 Python 解释器进行。
Python 对于一些内置方法及运算符的调用,本质上就是调用底层的特殊方法。比如在使用 len(x)
方法时,实际上会去查找并调用 x 对象的 __len__
方法;在使用 for
循环时,会去查找并调用对象的 __iter__
方法,如果没有找到这个方法,那会去查找对象的 __getitem__
方法,正如我们之前所说的这是一种后备方案。
可以说,特殊方法是 Python 语言灵活的精髓所在,下面我们结合鸭子类型一章中的 SeqDuck 类与特殊方法,尝试还原 Python 解释器运行的逻辑。
1 | class SeqDuck: |
- Python 解释器读入 SeqDuck 类,对所有双下划线开头结尾的特殊方法进行检索。
- 检索到
__getitem__
方法,方法签名符合序列协议。 - 当需要对 SeqDuck 实例进行循环迭代时,首先查找
__iter__
方法,未找到。 - 执行
__getitem__
方法,传入从 0 开始的整数索引进行迭代直至索引越界终止循环。
该过程可以理解为 Python 解释器对 SeqDuck 类的功能进行了运行时扩充。显然这增强了 Python 语言的动态特性,但另一方面也解释了为什么 Python 运行效率较低。
下面我将对一些常用特殊方法进行介绍。
__new__
& __init__
在 Java 和 C# 这些语言中,可以使用 new
关键字创建一个类的实例。Python 虽然没有 new
关键字,但提供了 __new__
特殊方法。在实例化一个 Python 类时,最先被调用的就是 __new__
方法。大多数情况下不需要我们重写 __new__
方法,Python 解释器也会执行 object 中的 __new__
方法创建类实例。但如果要使用单例模式,那么 __new__
方法就会派上用场。下面的代码展示了如何通过 __new__
控制只创建类的唯一实例。
1 | class Singleton: |
__init__
方法则类似于构造函数,如果需要对类中的属性赋初值,可以在 __init__
中进行。在一个类的实例被创建的过程中,__new__
要先于 __init__
被执行,因为要先创建好实例才能进行初始化。__new__
方法的第一个参数必须是 cls
类自身,__init__
方法的第一个参数必须是 self
实例自身。
1 | class Employee: |
由于 Python 不支持方法重载,即同名方法只能存在一个,所以 Python 类只能有一个构造函数。如果需要定义和使用多个构造器,可以使用带默认参数的 __init__
方法,但这种方法实际使用还是有局限性。另一种方法则是使用带有 @classmethod
装饰器的类方法,可以像使用类的静态方法一样去调用它生成类的实例。
1 | class Person: |
__str__
& __repr__
str() is used for creating output for end user while repr() is mainly used for debugging and development. repr’s goal is to be unambiguous and str’s is to be readable.
__str__
和 __repr__
都可以用来输出一个对象的字符串表示。使用 str()
时会调用 __str__
方法,使用 repr()
时则会调用 __repr__
方法。str()
可以看作 string 的缩写,类似于 Java 中的 toString()
方法;repr()
则是 representation 的缩写。
这两个方法的区别主要在于受众。str()
通常是输出给终端用户查看的,可读性更高。而 repr()
一般用于调试和开发时输出信息,所以更加强调含义准确无异义。在 Python 控制台以及 Jupyter notebook 中输出对象信息会调用的 __repr__
方法。
1 | list(("a", 1, True)) x = |
如果类没有定义 __repr__
方法,控制台会调用 object 类的 __repr__
方法输出对象信息:
1 | class A: ... |
__str__
和 __repr__
也可以提供给 print
方法进行输出。如果只定义了一个方法则调用该方法,如果两个方法都定义了,会优先调用 __str__
方法。
1 | class Foo: |
__call__
在 Python 中,函数是一等公民。这意味着 Python 中的函数可以作为参数和返回值,可以在任何想调用的时候被调用。为了扩充类的函数功能,Python 提供了 __call__
特殊方法,允许类的实例表现得与函数一致,可以对它们进行调用,以及作为参数传递。这在一些需要保存并经常更改状态的类中尤为有用。
下面的代码中,定义了一个从 0 开始的递增器类,它保存了计数器状态,并在每次调用时计数加一:
1 | class Incrementor: |
允许将类的实例作为函数调用,如上面代码中的 inc()
,本质上与 inc.__call__()
直接调用对象的方法并无区别,但它可以以一种更直观且优雅的方式来修改对象的状态。
__call__
方法可以接收可变参数, 这意味着可以像定义任意函数一样定义类的 __call__
方法。当 __call__
方法接收一个函数作为参数时,那么这个类就可以作为一个函数装饰器。基于类的函数装饰器就是这么实现的。如下代码我在 func 函数上使用了类级别的函数装饰器 Deco,使得在执行函数前多打印了一行信息。
1 | class Deco: |
实际上类级别的函数装饰器必须要实现 __call__
方法,因为本质上函数装饰器也是一个函数,只不过是一个接收被装饰函数作为参数的高阶函数。有关装饰器可以详见装饰器一章。
__add__
Python 中的运算符重载也是通过重写特殊方法实现的。比如重载 “+” 加号运算符需要重写 __add__
,重载比较运算符 “==” 需要重写 __eq__
方法。合理的重载运算符有助于提高代码的可读性。下面我将就一个代码示例进行演示。
考虑一个平面向量,由 x,y 两个坐标构成。为了实现向量的加法(按位相加),重写了加号运算符,为了比较两个向量是否相等重写了比较运算符,为了在控制台方便验证结果重写了 __repr__
方法。完整的向量类代码如下:
1 | class Vector: |
在控制台验证结果:
1 | from vector import Vector |
重载了 “+” 运算符后,可以直接使用 v1 + v2
对 Vector 类进行向量相加,而不必要编写专门的 add()
方法,并且重载了 ==
运算符取代了 v1.equals(v2)
的繁冗写法。从代码可读性来讲直接使用运算符可读性更高,也更符合数学逻辑。
当然,运算符重载涉及的知识点不止于此,《流畅的 Python》将其作为单独的一章,可见其重要性。下一节我们将就运算符重载进行深入的讨论。