9. 类
与其他编程语言相比,Python 的类机制用最少的新的语法和语义引入了类。它是 C++ 和 Modula-3 类机制的混合。Python 的类提供了面向对象编程的所有标准功能: 类继承机制允许多重继承,派生类可以覆盖其基类的任意方法或嵌套类,方法能够以相同的名称调用基类中的方法。对象可以包含任意数量和种类的数据。和模块一样,类同样具有 Python 的动态性质:它们在运行时创建,并可以在创建之后进一步修改。
用 C++ 术语来讲,通常情况下类成员(包括数据成员)是 公有 的(其它情况见下文 私有变量),所有的成员函数都是虚 的。与 Modula-3 一样,在成员方法中没有简便的方式引用对象的成员:方法函数的声明用显式的第一个参数表示对象本身,调用时会隐式地引用该对象。与 Smalltalk 一样,类本身也是对象。这给导入类和重命名类提供了语义上的合理性。与 C++ 和 Modula-3 不同,用户可以用内置类型作为基类进行扩展。此外,像 C++ 一样,类实例可以重定义大多数带有特殊语法的内置操作符(算术运算符、 下标等)。
(由于没有统一的达成共识的术语,我会偶尔使用 SmallTalk 和 C++ 的术语。我比较喜欢用 Modula-3 的术语,因为比起 C++,Python 的面向对象语法更像它,但是我想很少有读者听说过它。)
9.1. 名称和对象
对象是独立的,多个名字(在多个作用域中)可以绑定到同一个对象。这在其他语言中称为别名。第一次粗略浏览 Python 时经常不会注意到这个特性,而且处理不可变的基本类型(数字,字符串,元组)时忽略这一点也没什么问题。然而, 在Python 代码涉及可变对象如列表、 字典和大多数其它类型时,别名可能具有意想不到语义效果。这通常有助于优化程序,因为别名的行为在某些方面类似指针。例如,传递一个对象的开销是很小的,因为在实现上只是传递了一个指针;如果函数修改了参数传递的对象,调用者也将看到变化 —— 这就避免了类似 Pascal 中需要两个不同参数的传递机制。
9.2. Python 作用域和命名空间
在介绍类之前,首先我要告诉你一些有关 Python 作用域的的规则。类的定义非常巧妙的运用了命名空间,要完全理解接下来的知识,需要先理解作用域和命名空间的工作原理。另外,这一切的知识对于任何高级 Python 程序员都非常有用。
让我们从一些定义开始。
命名空间 是从名称到对象的映射。当前命名空间主要是通过 Python 字典实现的,不过通常不会引起任何关注(除了性能方面),它以后也有可能会改变。以下有一些命名空间的例子:内置名称集(包括函数名列如 abs()
和内置异常的名称);模块中的全局名称;函数调用中的局部名称。在某种意义上的一组对象的属性也形成一个命名空间。关于命名空间需要知道的重要一点是不同命名空间的名称绝对没有任何关系;例如,两个不同模块可以都定义函数 maximize
而不会产生混淆 —— 模块的使用者必须以模块名为前缀引用它们。
顺便说一句,我使用 属性 这个词称呼点后面的任何名称 —— 例如,在表达式 z.real
中,real
是 z
对象的一个属性。严格地说,对模块中的名称的引用是属性引用:在表达式modname.funcname
中, modname
是一个模块对象, funcname
是它的一个属性。在这种情况下,模块的属性和模块中定义的全局名称之间碰巧是直接的映射:它们共享同一命名空间 ![1]
属性可以是只读的也可以是可写的。在后一种情况下,可以对属性赋值。模块的属性都是可写的:你可以这样写 modname.the_answer = 42
。可写的属性也可以用 del
语句删除。例如, del modname.the_answer
将会删除对象 modname
中的 the_answer
属性。
各个命名空间创建的时刻是不一样的,且有着不同的生命周期。包含内置名称的命名空间在 Python 解释器启动时创建,永远不会被删除。模块的全局命名空间在读入模块定义时创建;通常情况下,模块命名空间也会一直保存到解释器退出。在解释器最外层调用执行的语句,不管是从脚本文件中读入还是来自交互式输入,都被当作模块 __main__
的一部分,所以它们有它们自己的全局命名空间。(内置名称实际上也存在于一个模块中,这个模块叫 builtins
。)
函数的局部命名空间在函数调用时创建,在函数返回或者引发了一个函数内部没有处理的异常时删除。(实际上,用遗忘来形容到底发生了什么更为贴切。)当然,每个递归调用有它们自己的局部命名空间。
作用域 是 Python 程序中可以直接访问一个命名空间的代码区域。这里的“直接访问”的意思是用没有前缀的引用在命名空间中找到的相应的名称。
虽然作用域是静态确定的,但是使用它们时是动态的。程序执行过程中的任何时候,至少有三个嵌套的作用域,它们的命名空间是可以直接访问的:
- 首先搜索最里面包含局部命名的作用域
- 其次从里向外搜索所有父函数的作用域,其中的命名既非局部也非全局
- 倒数第二个搜索的作用域是包含当前模块全局命名的作用域
- 最后搜索的作用域是最外面包含内置命名的命名空间
如果一个命名声明为全局的,那么对它的所有引用和赋值会直接搜索包含这个模块全局命名的作用域。如果要重新绑定最里层作用域之外的变量,可以使用 nonlocal
语句;如果不声明为nonlocal,这些变量将是只读的(对这样的变量赋值会在最里面的作用域创建一个 新的 局部变量,外部具有相同命名的那个变量不会改变)。
通常情况下,局部作用域引用当前函数的本地命名。函数之外,局部作用域引用的命名空间与全局作用域相同:模块的命名空间。类定义在局部命名空间中创建了另一个命名空间。
认识到作用域是由代码确定的是非常重要的:函数的全局作用域是函数的定义所在的模块的命名空间,与函数调用的位置或者别名无关。另一方面,命名的实际搜索过程是动态的,在运行时确定的——然而,Python 语言也在不断发展,以后有可能会成为静态的“编译”时确定,所以不要依赖动态解析!(事实上,本地变量是已经确定静态。)
Python的一个特别之处在于——如果没有使用 global
语法——其赋值操作总是在最里层的作用域。赋值不会复制数据——只是将命名绑定到对象。删除也是如此: del x
只是从局部作用域的命名空间中删除命名 x
。事实上,所有引入新命名的操作都作用于局部作用域:特别是 import
语句和函数定义将模块名或函数绑定于局部作用域。
global
语句可以用来指明某个特定的变量位于全局作用域并且应该在那里重新绑定; nonlocal
语句表示特定的变量位于一个封闭的作用域并且应该在那里重新绑定。
9.3. 初识类
类引入了少量的新语法、三种新对象类型和一些新语义。
9.3.1. 类定义语法
类定义的最简单形式如下所示:
类的定义就像函数定义( def
语句),要先执行才能生效。(你当然可以把它放进 if
语句的某一分支,或者一个函数的内部。)
实际应用中,类定义包含的语句通常是函数定义,不过其它语句也是可以的而且有时还会很有用——后面我们会再回来讨论。类中的函数定义通常有一个特殊形式的参数列表,这是由方法调用的协议决定的——同样后面会解释这些。
进入类定义部分后,会创建出一个新的命名空间,作为局部作用域——因此,所有的赋值成为这个新命名空间的局部变量。特别是这里的函数定义会绑定新函数的名字。
类定义正常退出时,一个 类对象 也就创建了。基本上它是对类定义创建的命名空间进行了一个包装;我们在下一节将进一步学习类对象的知识。原始的局部作用域(类定义引入之前生效的那个)得到恢复,类对象在这里绑定到类定义头部的类名(例子中是 ClassName
)。
9.3.2. 类对象
类对象支持两种操作:属性引用和实例化。
属性引用 使用的所有属性引用在 Python 中使用的标准语法: obj.name
。有效的属性名称是在该类的命名空间中的类对象被创建时的名称。因此,如果类定义看起来像这样:
那么 MyClass.i
和 MyClass.f
是有效的属性引用,分别返回一个整数和一个方法对象。也可以对类属性赋值,你可以通过给 MyClass.i
赋值来修改它。__doc__
也是一个有效的属性,返回类的文档字符串: "A simple example class"
.
类的实例化 使用函数的符号。可以假设类对象是一个不带参数的函数,该函数返回这个类的一个新的实例。例如(假设沿用上面的类):
创建这个类的一个新 实例 ,并将该对象赋给局部变量 x
.
实例化操作(“调用”一个类对象)将创建一个空对象。很多类希望创建的对象可以自定义一个初始状态。因此类可以定义一个名为 __init__()
的特殊方法,像下面这样:
当类定义了 __init__()
方法,类的实例化会为新创建的类实例自动调用 __init__()
。所以在下面的示例中,可以获得一个新的、已初始化的实例:
当然, __init__()
方法可以带有参数,这将带来更大的灵活性。在这种情况下,类实例化操作的参数将传递给 __init__()
。例如,
9.3.3. 实例对象
现在我们可以用实例对象做什么?实例对象唯一可用的操作就是属性引用。有两种有效的属性名:数据属性和方法。
数据属性 相当于 Smalltalk 中的"实例变量"或 C++ 中的"数据成员"。数据属性不需要声明;和局部变量一样,它们会在第一次给它们赋值时生成。例如,如果 x
是上面创建的MyClass
的实例,下面的代码段将打印出值 16
而不会出现错误:
实例属性引用的另一种类型是方法。方法是"属于"一个对象的函数。(在 Python,方法这个术语不只针对类实例:其他对象类型也可以具有方法。例如,列表对象有 append、insert、remove、sort 方法等等。但是在后面的讨论中,除非明确说明,我们提到的方法特指类实例对象的方法。)
实例对象的方法的有效名称依赖于它的类。根据定义,类中所有函数对象的属性定义了其实例中相应的方法。所以在我们的示例中, x.f
是一个有效的方法的引用,因为MyClass.f
是一个函数,但 x.i
不是,因为 MyClass.i
不是一个函数。但 x.f
与 MyClass.f
也不是一回事 —— 它是一个 方法对象 ,不是一个函数对象。
9.3.4. 方法对象
通常情况下,方法在绑定之后被直接调用:
在 MyClass
的示例中,这将返回字符串 'hello world'
。然而,也不是一定要直接调用方法: x.f
是一个方法对象,可以存储起来以后调用。例如:
会不断地打印 hello world
。
调用方法时到底发生了什么?你可能已经注意到,上面 x.f()
的调用没有参数,即使 f()
函数的定义指定了一个参数。该参数发生了什么问题?当然如果函数调用中缺少参数 Python 会抛出异常——即使这个参数实际上没有使用……
实际上,你可能已经猜到了答案:方法的特别之处在于实例对象被作为函数的第一个参数传给了函数。在我们的示例中,调用 x.f()
完全等同于 MyClass.f(x)
。一般情况下,以n 个参数的列表调用一个方法就相当于将方法所属的对象插入到列表的第一个参数的前面,然后以新的列表调用相应的函数。
如果你还是不明白方法的工作原理,了解一下它的实现或许有帮助。引用非数据属性的实例属性时,会搜索它的类。如果这个命名确认为一个有效的函数对象类属性,就会将实例对象和函数对象封装进一个抽象对象:这就是方法对象。以一个参数列表调用方法对象时,它被重新拆封,用实例对象和原始的参数列表构造一个新的参数列表,然后函数对象调用这个新的参数列表。
9.4. 补充说明
数据属性会覆盖同名的方法属性;为了避免意外的命名冲突,这在大型程序中可能带来极难发现的 bug,使用一些约定来减少冲突的机会是明智的。可能的约定包括大写方法名称的首字母,使用一个唯一的小写的字符串(也许只是一个下划线)作为数据属性名称的前缀,或者方法使用动词而数据属性使用名词。
数据属性可以被方法引用,也可以由一个对象的普通用户(“客户端”)使用。换句话说,类是不能用来实现纯抽象数据类型的。事实上,Python 中不可能强制隐藏数据——一切基于约定。(另一方面,如果需要,使用 C 编写的 Python 实现可以完全隐藏实现细节并控制对象的访问;这可以用来通过 C 语言扩展 Python。)
客户应该谨慎的使用数据属性——客户可能通过随意使用他们的数据属性而使那些由方法维护的常量变得混乱。注意:只要能避免冲突,客户可以向一个实例对象添加他们自己的数据属性,而不会影响方法的正确性——再次强调,命名约定可以避免很多麻烦。
从方法内部引用数据属性(或其他方法)并没有快捷方式。我觉得这实际上增加了方法的可读性:当浏览一个方法时,在局部变量和实例变量之间不会出现令人费解的情况。
通常,方法的第一个参数称为 self
。这仅仅是一个约定:名字 self
对 Python 而言绝对没有任何特殊含义。但是请注意:如果不遵循这个约定,对其他的 Python 程序员而言你的代码可读性就会变差,而且有些类 查看 器程序也可能是遵循此约定编写的。
类属性的任何函数对象都为那个类的实例定义了一个方法。函数定义代码不一定非得定义在类中:也可以将一个函数对象赋值给类中的一个局部变量。例如:
现在 f
、g
和 h
都是类 C
中引用函数对象的属性,因此它们都是 C
的实例的方法 —— h
完全等同于 g
。请注意,这种做法通常只会使阅读程序的人产生困惑。
方法可以通过使用 self
参数的方法属性,调用其他方法:
方法可以像普通函数那样引用全局命名。与方法关联的全局作用域是包含这个方法的定义的模块(类)。(类本身永远不会作为全局作用域使用。)尽管很少有好的理由在方法中使用全局数据,全局作用域确有很多合法的用途:其一是方法可以调用导入全局作用域的函数和方法,也可以调用定义在其中的类和函数。通常,包含此方法的类也会定义在这个全局作用域,在下一节我们会了解为何一个方法要引用自己的类。
每个值都是一个对象,因此每个值都有一个类(也称它的类型)。它存储为 object.__class__
。
9.5. 继承
当然,一个语言特性不支持继承是配不上“类”这个名字的。派生类定义的语法如下所示:
BaseClassName
必须与派生类定义在一个作用域内。用其他任意表达式代替基类的名称也是允许的。这可以是有用的,例如,当基类定义在另一个模块中时:
派生类定义的执行过程和基类是相同的。类对象创建后,基类会被保存。这用于解析属性的引用:如果在类中找不到请求的属性,搜索会在基类中继续。如果基类本身是由别的类派生而来,这个规则会递归应用。
派生类的实例化没有什么特殊之处: DerivedClassName()
创建类的一个新的实例。方法的引用按如下规则解析: 搜索对应的类的属性,必要时沿基类链逐级搜索,如果找到了函数对象这个方法引用就是合法的。
派生的类可能重写其基类的方法。因为方法调用同一个对象中的其它方法时没有特权,基类的方法调用同一个基类的方法时,可能实际上最终调用了派生类中的覆盖方法。(对于 C++ 程序员:Python 中的所有方法实际上都是 虚
的。)
派生类中的覆盖方法可能是想要扩充而不是简单的替代基类中的重名方法。有一个简单的方法可以直接调用基类方法:只要调用 BaseClassName.methodname(self, arguments)
。有时这对于客户端也很有用。(要注意只有 BaseClassName
在同一全局作用域定义或导入时才能这样用。)
Python 有两个用于继承的函数:
- 使用
isinstance()
来检查实例类型:isinstance(obj, int)
只有obj.__class__
是int
或者是从int
派生的类时才为True
。 - 使用
issubclass()
来检查类的继承:issubclass(bool, int)
是True
因为bool
是int
.的子类。然而,issubclass(float, int)
为False
,因为float
不是int
的子类。
9.5.1. 多继承
Python 也支持一种形式的多继承。具有多个基类的类定义如下所示:
对于大多数用途,在最简单的情况下,你可以认为继承自父类的属性搜索是从左到右的深度优先搜索,不会在同一个类中搜索两次,即使层次会有重叠。因此,如果在DerivedClassName
中找不到属性,它搜索 Base1
,然后(递归)基类中的 Base1
,如果没有找到,它会搜索 Base2
,依此类推。
事实上要稍微复杂一些;为了支持合作调用 super()
,方法解析的顺序会动态改变。这种方法在某些其它多继承的语言中也有并叫做call-next-method,它比单继承语言中的super调用更强大。
动态调整顺序是必要的,因为所有的多继承都会有一个或多个菱形关系(从最底部的类向上,至少会有一个父类可以通过多条路径访问到)。例如,所有的类都继承自 object
,所以任何多继承都会有多条路径到达 object
。为了防止基类被重复访问,动态算法线性化搜索顺序,每个类都按从左到右的顺序特别指定了顺序,每个父类只调用一次,这是单调的(也就是说一个类被继承时不会影响它祖先的次序)。所有这些特性使得设计可靠并且可扩展的多继承类成为可能。有关详细信息,请参阅https://www.python.org/download/releases/2.3/mro/。
9.6. 私有变量
在 Python 中不存在只能从对象内部访问的“私有”实例变量。然而,有一项大多数 Python 代码都遵循的公约:带有下划线(例如_spam
)前缀的名称应被视为非公开的 API 的一部分(无论是函数、 方法还是数据成员)。它应该被当做一个实现细节,将来如果有变化恕不另行通知。
因为有一个合理的类私有成员的使用场景(即为了避免名称与子类定义的名称冲突),Python 对这种机制有简单的支持,叫做name mangling。__spam
形式的任何标识符(前面至少两个下划线,后面至多一个下划线)将被替换为 _classname__spam
, classname
是当前类的名字。此重整无需考虑该标识符的句法位置,只要它出现在类的定义的范围内。
Name mangling 有利于子类重写父类的方法而不会破坏类内部的方法调用。例如:
请注意名称改编的目的主要是避免发生意外;访问或者修改私有变量仍然是可能的。这在特殊情况下,例如调试的时候,还是有用的。
注意传递给 exec
或 eval()
的代码没有考虑要将调用类的类名当作当前类;这类似于 global
语句的效果,影响只限于一起进行字节编译的代码。相同的限制适用于getattr()
、setattr()
和 delattr()
,以及直接引用 __dict__
时。
9.7. 零碎的说明
有时候类似于Pascal 的"record" 或 C 的"struct"的数据类型很有用,它们把几个已命名的数据项目绑定在一起。一个空的类定义可以很好地做到:
某一段 Python 代码需要一个特殊的抽象数据结构的话,通常可以传入一个类来模拟该数据类型的方法。例如,如果你有一个用于从文件对象中格式化数据的函数,你可以定义一个带有 read()
和 readline()
方法的类,以此从字符串缓冲读取数据,然后将该类的对象作为参数传入前述的函数。
实例的方法对象也有属性: m.__self__
是具有方法 m()
的实例对象, m.__func__
是方法的函数对象。
9.8. 异常也是类
用户定义的异常类也由类标识。利用这个机制可以创建可扩展的异常层次。
raise
语句有两种新的有效的(语义上的)形式:
第一种形式中,Class
必须是 type
或者它的子类的一个实例。第一种形式是一种简写:
except
子句中的类如果与异常是同一个类或者是其基类,那么它们就是相容的(但是反过来是不行的——except子句列出的子类与基类是不相容的)。例如,下面的代码将按该顺序打印 B、 C、 D:
请注意,如果except 子句的顺序倒过来 (except B
在最前面)。它就会打印 B,B,B —— 第一个匹配的异常被触发。
打印一个异常类的错误信息时,先打印类名,然后是一个空格、一个冒号,然后是用内置函数 str()
将类转换得到的完整字符串。
9.9. 迭代器
现在你可能注意到大多数容器对象都可以用 for
遍历:
这种访问风格清晰、 简洁又方便。迭代器的用法在 Python 中普遍而且统一。在后台, for
语句调用 容器对象的 iter()
方法。(这点类似于Java中的实现)该函数返回一个定义了 __next__()
方法的迭代器对象,它在容器中逐一访问元素。没有后续的元素时,__next__()
会引发StopIteration
异常,告诉 for
循环停止迭代。你可以使用内建的next()
来调用 __next__()
,例子如下:
看过迭代器协议背后的机制后,将很容易将迭代器的行为添加到你的类中。Define an __iter__()
method which returns an object with a __next__()
method. (翻译:你可以定义__iter__()方法,这个方法返回一个带有__next__()方法的对象,如果说你自己的类中有__next__()方法,那么__iter__()可以返回self)If the class defines __next__()
, then__iter__()
can just return self
:
9.10. 生成器
生成器是一种可以简单有效的创建迭代器的工具,他们像常规函数一样撰写但是在需要返回值时使用yield
语句.每当调用next()
,生成器从它上次停止的地方重新开始(它会记住所有的数据值和上次执行后的状态).以下示例演示了生成器可以非常简单地创建出来:
生成器能做到的任何事情,前一节所述的基于类的迭代器也能做到。生成器如此简洁归功于 __iter__()
和 __next__()
方法的自动创建.
另一个关键特征是在调用中本地变量和执行状态会自动保存,这让这个函数比调用实例函数. self.index
和 self.data
更易于编写且更清晰.
除了自动创建方法和保存程序状态外,当生成器终止后还会自动抛出 StopIteration
.异常(遍历完所有item).结合这些特点,创建迭代器就和写一个普通函数一样简单