学习Python Doc第八天: 类

目录

今天我们学习Python 中的类。Python提供的类包含其他任何语言中的类具有的特征。和其他语言相比,Python的类有一些新东西。如若你熟悉 C++Modula-3 那么学习 Python 中的类就是小菜一碟。

1 对象和对象的名字

像其他语言一样,对象可以有多个别名。初识 Python ,可能不会觉得这个别名有什么用。但是,涉及到可修改对象比如 list dictionary 或者其他类型时, 别名会发挥意想不到的特效。别名很多时候可以像指针一样去理解,在传递参数的时候传递的是指针,所以传递一个对象开销很小。

2 Python 的作用域和命名空间

像其他语言一样, Python 也有自己的一套作用于和命名空间机制。如果要严肃的学习 Python ,立志成为 Python 高手,深刻的理解命名空间和作用域是非常有必要的。

命名空间是从名称到对象的映射。大多数命名空间现在的实现就如同 Python 的字典, 但通常这一点并不明显(除了在性能上), 而且它有可能在将来发生改变。 Python 的官方文档太晦涩难懂了,简直了。

我们用 属性 这个词来称呼任何点后面跟的名称。 比如, 在表达式 z.real 中, real 就是对象 z 的属性. 更直接的说, 对模块中名称的引用就是属性引用: 在表达式 modname.funcname 中, modname 是模块对象而 funcname 是它的一个属性. 在这种情况下模块的属性和它里面所定义的全局名称之间就刚好有一个直接的映射关系: 他们共享同一个命名空间。

命名空间是在不同时刻创建的,并且有着不同的生命期. 包含内置名称的命名空间是在 Python 解释器启动时创建的, 而且它永远不被删除. 一个模块的全局命名空间在模块的定义被读取的时候创建; 通常情况下, 模块的命名空间一直持续到解释器退出时. 被最高级别的解释器调用的语句, 不论是从脚本还是从交互读取的, 都被认为是一个名叫 __main__ 的模块的一部分, 所以它们有自己的全局命名空间. (内置名称实际上也存在于一个模块中; 这个模块叫 builtins .)

函数的局部命名空间在函数调用时被创建, 在函数返回时或者发生异常而终止时被删除. (事实上, 忘记可能是更好的方式来描述真正发生了什么.) 当然, 递归调用会有它们自己的局部命名空间.

在 Python 中, 一个作用域只是一个结构上的区域, 在这里命名空间可以直接访问. “直接访问” 就意味着无须特殊的指明引用.

尽管作用域是静态的决定的, 它们使用时却是动态的. 在执行时的任何时刻, 至少有三个嵌套的作用域其命名空间可以直接访问:

  1. 最内层的作用域, 首先被搜索, 包含局部变量名
  2. 任意函数的作用域, 它从最接近的作用域开始搜索, 包括非局部的, 但也是非全局的名字
  3. 紧邻最后的作用域包含了当前模块的全局变量
  4. 最外层的作用域 (最后搜索) 是包含内置名字的命名空间

如果一个名字在全局声明, 那么所有的引用和赋值都直接到这个模块的全局名中. 为了在最内部作用域中重新绑定变量, 可以使用 nonlocal 语句; 如果没有声明 nonlocal , 那些变量只是只读 (尝试给这样的变量赋值, 只是会简单的创建一个新的局部变量, 而外部的并没有什么改变)。

一般来说, 局部作用域引用当前函数的局部变量名. 在函数外部, 局部变量引用和全局作用域相同的命名空间: 模块的命名空间. 类定义又放置了另一个命名空间.

意识到作用域是在结构上被决定的这很重要. 一个定义在模块中的函数的全局作用域, 就是模块的命名空间, 无论它从哪里被访问. 另一个方面, 搜寻名字的过程是动态完成的, 在运行时 — 但是, 语言的定义一般是静态的, 在 “编译” 时完成, 所以不要依赖动态命名! (事实上, 局部变量都是静态的被决定的.)

Python 的一个怪事就是:如果 global 语句没有起效果,赋值总是会使用最里层作用域的值. 赋值并没有拷贝数据 — 它们仅仅是绑定名字到对象上. 删除也是如此: del x 移除了 x 从局部作用域的绑定. 事实上, 所有操作引入新的名字都使用局部作用域: 特别的, import 语句, 和函数定义都将模块或函数绑定到了当前作用域.

global 语句可以用于指示, 在全局作用域中的变量可以在这里重新绑定; nonlocal 则表示在一个闭合的作用域中的变量可以在此处绑定.

2.1 一个域和命名空间的例子

这是一个例子用于说明如何引用不同的作用域和命名空间, globalnonlocal 如何影响变量绑定:

def scope_test():
    def do_local():
        spam = "local spam"
    def do_nonlocal():
        nonlocal spam
        spam = "nonlocal spam"
    def do_global():
        global spam
        spam = "global spam"

    spam = "test spam"
    do_local()
    print("After local assignment:", spam)
    do_nonlocal()
    print("After nonlocal assignment:", spam)
    do_global()
    print("After global assignment:", spam)

调用函数:

scope_test()

输出为:

After local assignment: test spam
After nonlocal assignment: nonlocal spam
After global assignment: nonlocal spam

可以看到, 注意局部的赋值 (默认) 并没有改变 scope_test 绑定的 spam . 而 nonlocal 则改变了 scope_test 中的 spam , 而 global 则改变了模块级别的绑定. 你可以看到在 global 赋值之前并没有绑定 spam 的值.

3 类的第一印象

3.1 类定义

最简单的类定义如下:

class ClassName:
    <statement-1>
    .
    .
    .
    <statement-N>

类的定义, 和函数定义 ( def 语句) 一样必须在使用它们前执行. (你可以将一个类定义放置于 if 语句的分支中, 或一个函数中.)

事实上, 类定义内部的语句一般是函数的定义, 但其他的语句也是允许的, 而且还很有用, 我们在后面将会继续讨论该问题. 类内的函数定义一般有一个特殊形式的参数列表, 习惯上称之为方法。

当进入一个类定义, 新的命名空间就被创建了, 这一般作为局部的作用域。因此, 所有的局部变量都在这个新的作用域中。 特别是, 函数定义会绑定.

当离开一个类定义后, 一个 class object 就被创建。 通过类的定义, 就将这个命名空间包装了起来; 我们将在后面学到更多关于类对象的知识. 原来的局部作用域 (在进入一个类定义前的作用域) 将会复位, 而类对象就会在这里绑定, 并且命名为类定义时的名字 (在此例中是 ClassName ).

3.2 类对象

类对象支持两种操作: 属性引用和实例化.

属性引用使用的语法和 Python 中所有的属性引用一样. 合法的属性名是那些在类的命名空间中定义的名字. 所以一个类定义如果是这样:

class MyClass:
    """A simple example class"""
    i = 12345
    def f(self):
        return 'hello world'

那么, MyClass.iMyClass.f 就是合法的属性引用, 分别返回一个整数和一个函数对象. 类属性也可以被指定, 所以你可以给 MyClass.i 赋值以改变其数值. __doc__ 也是一个合法的属性, 返回属于这个类的 docstring : "A simple example class".

类的 实例化 使用函数的形式. 只要当作一个无参的函数然后返回一个类的实例就可以了:

x = MyClass();

创建了一个新的实例, 并且将其指定给局部变量 x .

实例化的操作 (“调用” 一个类对象) 创建了空的对象. 在创建实例时, 很多类可能都需要有特定的初始状态. 所以一个类可以定义一个特殊的方法, 称为 __init__() , 像这样:

def __init__(self):
    self.data = []

当然, 为了更大的灵活性, 方法 __init__() 可以有更多的参数. 在这种情况下, 给类的参数会传给 __init__() . 例如,

class Complex:
    def __init__(self, realpart,imagpart):
        self.r = realpart
        self.i = imagpart

调用:

x = Complex(3.0,5)

x.r = 3.0 x.i =5

3.3 实例对象

那么我们现在可以对实例对象做什么? 实例对象唯一能理解的操作就是属性引用. 有两种合法的属性, 数据属性和方法.

实例属性引用的另一种是方法. 一个方法就是 “属于” 一个对象的函数. 在 Python 中, 方法的概念并不是类实例所特有: 其他对象类型也可以有方法. 例如, 列表对象有 append , insert , remove , sort , 及等等的方法. 但是, 在下面的讨论中, 我们指的就是类实例对象的方法.

合法的方法名依赖于实例的类. 在定义中, 类的属性如果是那些定义的函数对象, 而这也就是实例的方法. 所以在我们的例子中, x.f 是一个合法的方法引用, 因为 MyClass.f 是一个函数, 但是 x.i 就不是, 因为 MyClass.i 就不是. 但是 x.fMyClass.f 并不一样 — 它是一个 method object , 而不是 function object .

3.4 方法对象

通常, 一个方法在其绑定后就可以调用了:

x.f()

MyClass 这个例子中, 这将会返回字符串 'hello world' . 但是, 像这样的调用并不是必须的: x.f 是一个方法对象, 它可以被保存起来以供下次调用. 例如:

xf = x.f
while True:
    print(xf())

那么在方法调用是发生了什么? 你可能注意到 x.f() 调用时并没有参数, 尽管 f() 定义时是有一个参数的. 那么这个参数怎么了? 当然, Python 在一个参数缺少时调用一个函数是会发生异常的 — 就算这个参数没有真正用到…

事实上, 你会猜想到: 关于方法, 特殊的东西就是, 对象作为参数传递给了函数的第一个参数. 在我们的例子中, x.f() 是严格等价于 MyClass.f(x). 在多数情况下, 调用一个方法 (有个 n 个参数), 和调用相应的函数 (也有那 n 个参数, 但是再额外加入一个使用该方法的对象), 是等价的.

如果你仍然不知道方法如何工作, 那么看看实现或许会解决这些问题. 当一个实例属性被引用时, 但是不是数据属性, 那么它的类将被搜索. 如果该名字代表一个合法的类属性并且是一个函数对象, 一个方法对象就会被创建, 通过包装 (指向) 实例对象, 而函数对象仍然只是在抽象的对象中: 这就是方法对象. 当方法对象用一个参数列表调用, 新的参数列表会从实例对象中重新构建, 然后函数对象则调用新的参数列表.

3.5 随机备注

数据属性覆写了同名的方法属性; 为了避免这个偶然的名字冲突, 在大型的程序中这会导致很难寻找的 bug, 使用某些命名约定是非常明智的, 这样可以最小的避免冲突. 可能的约定包括大写方法名称, 在数据类型前增加特殊的前缀 (或者就是一个下划线), 或对于方法使用动词, 而数据成员则使用名词.

数据属性可以被该类的方法或者普通的用户 (“客户”) 引用. 换句话说, 类是不能实现完全的抽象数据类型. 事实上, 在 Python 中没有任何东西是强制隐藏的 — 这完全是基于约定. (在另一方面, Python 是用 C 实现的, 这样就可以实现细节的隐藏和控制访问; 这可以通过编写 Python 的扩展实现.)

客户需要小心地使用数据属性 — 客户会弄乱被方法控制的不变量, 通过使用它们自己的方法属性. 注意用户可以增加它们自己的数据到实例对象上, 而没有检查有没有影响方法的有效性, 只要避免名字冲突 – 在说一次, 命名约定可以避免很多这样令人头疼的问题.

在引用数据属性 (或其他方法 !) 并没有快速的方法. 我发现这的确增加了方法的可读性: 这样就不会被局部变量和实例中的变量所困惑, 特别是在随便看看一个方法时.

通常, 方法的第一个参数称为 self. 这更多的只是约定: self 对于 Python 来说没有任何意义. 但注意, 如果不遵循这个约定, 对于其他的程序员来说就比较难以理解了, 一个 class browser 程序可能会依赖此约定.

作为类属性的任何函数对象, 定义了一个方法用于那个类的实例. 函数是否在一个类体中其实并不重要: 指定一个函数对象给类中的局部变量也是可以的. 例如:

# Function defined outside the class
def f1(self, x, y):
    return min(x, x+y)

class C:
    f = f1
    def g(self):
        return 'hello world'
    h = g

现在 f , gh 都是类 C 的属性, 并且指向函数对象, 而且都是类 C 实例的方法. hg 是等价的. 注意这个只会是读者感到困惑.

方法可以通过使用 self 参数调用其他的方法:

class Bag:
    def __init__(self):
        self.data = []
    def add(self, x):
        self.data.append(x)
    def addtwice(self, x):
        self.add(x)
        self.add(x)

方法可以引用全局变量, 就像普通函数中那样. 与这个方法相关的全局作用域, 是包含那个类定义的模块. (类本身永远不会作为全局作用域使用.) 如果的确需要在方法中使用全局数据, 那么需要合法的使用: 首先一件事, 被导入全局作用域的函数和模块可以被方法使用, 就如定义在里面的函数和类一样. 通常来说, 定义在全局作用域中, 包含方法的类是它自己本身, 并且在后面我们会知道为何方法应该引用自己的类.

4 继承

当然, 一个有 class 的语言如果没有继承就没有多大的价值了. 派生类的定义如下:

class DerivedClassName(BaseClassName):
    <statement-1>
    .
    .
    .
    <statement-N>

BaseClassName 的定义对于派生类而言必须是可见的. 在基类的地方, 任意的表达式都是允许的. 这就会非常有用, 比如基类定义在另一个模块:

class DerivedClassName(modname.BaseClassName):

派生类就可以像基类一样使用. 当一个类被构建, 那么它就会记下基类. 这是用于解决属性引用的问题: 当一个属性在这个类中没有被找到, 那么就会去基类中寻找. 然后搜索就会递归, 因为基类本身也有可能是从其他的类派生.

实例化一个派生类没有什么特别: DerivedClassName() 会创建这个类的新实例. 方法的引用如下: 相应的类的属性会被搜寻, 如果需要回去搜寻基类, 如果返回一个函数对象, 那么这个引用就是合法的.

派生类会覆写基类的方法. 因为当调用同样的对象的其他方法时方法并没有什么特别的, 基类的方法会因为先调用派生类的方法而被覆写. (对于 C++ 程序员: 所有的方法在 Python 中都是 vitual 的.)

一个在派生类中覆写的方法可能需要基类的方法. 最简单的方式就是直接调用基类的方法: 调用 BaseClassName.methodname(self, arguments) . 这对于可续来说也是很方便的. (这仅在 BaseClassName 可访问时才有效.)

Python 有两个内置函数用于继承:

  1. 使用 isinstance() 检查实例的类型: isinstance(obj, int) 只有在 obj.__class__int 或其派生类时才为 True.
  2. 使用 issubclass() 用于检查类的继承关系: issubclass(bool, int) 会返回 True, 因为 boolint 的派生类. 但是, issubclass(float, int) 会是 False 因为 float 并不是 int 的派生类.

4.1 多重继承

Python 支持多重继承. 一个多重继承的类定义看起来像这样:

class DerivedClassName(Base1, Base2, Base3):
    <statement-1>
    .
    .
    .
    <statement-N>

对于大多数目的, 在最简单的情况下, 你可以将属性搜寻的方式是, 从下至上, 从左到右, 在继承体系中, 同样的类只会被搜寻一次. 如果一个属性在 DerivedClassName 中没有被找到, 它就会搜寻 Base1, 然后 (递归地) 搜寻 Base1 的基类, 然后如果还是没有找到, 那么就会搜索 Base2, 等等.

事实上, 这更加的复杂; 方法的搜寻顺序会根据调用 super() 而变化. 这个方法在某些其他多重继承的语言中以 call-next-method 被熟知, 而且比单继承的语言中要有用.

动态的顺序是很有必要的, 因为在那些处于菱形继承体系中 (这里至少有个父类被多次派生). 比如, 所有的类都从 object 派生, 所以到达 object 的路径不止一条. 为了防止基类被多次访问, 动态的算法线性化了搜寻的路径, 先从左至右搜索指定的类, 然后这样就可以让每个父类只搜寻一次, 并且单一 (这就意味一个类可以被派生, 但是不会影响其父类的搜寻路径. 使用了这些, 就使得以多重继承设计的类更可靠和可扩展.

4.2 私有变量

Python 之中, 并不存在那种无法访问的 “私有” 变量. 但是, 在多数的 Python 代码中有个约定: 以一个下划线带头的名字 (如 _spam) 应该作为非公共的 API (不管是函数, 方法或者数据成员). 这应该作为具体的实现, 而且变化它也无须提醒.

因为有一个合法的情况用于使用私有的成员 (名义上是说在派生类中避免名字的冲突), 因此就有这样的一种机制称为 name mangling. 任何如 __spam 形式的标识符, (在开头至少有两个下划线) 将被替换为 _classname__spam, 此处的 classname 就是当前的类. 这样的处理无须关注标识符的句法上的位置, 尽管它是在一个类的定义中.

注意, 这样的规则只是用于防止冲突; 它仍然可以访问或修改, 尽管认为这是一个私有变量. 在某些特殊情况下, 如测试等, 是有用的.

注意, 传递给 exec()eval() 的代码并不会考虑被调用类的类名是当前的类; 这个和 global 语句的效果一样, 字节编译的代码也有同样的限制. 而对于 getattr(), setattr()delattr() 也有这种限制, 直接访问 __dict__ 也是有这样的问题.

4.3 异常也是类

用户定义的异常其实也是类. 使用这个机制, 就可以创建可扩展的异常继承体系.

有两种合法的形式用于 raise 语句:

raise Class
raise Instance

在第一种形式下, Class 必须是 type 的实例或者其派生. 第一种形式可以简化为这样这样:

raise Class()

一个在 except 中的类, 可以与一个异常相容, 如果该异常是同样的类, 或是它的基类 (但是并不是另一种, 一个 except 语句列出的派生类与其基类并不相容). 如下面的代码, 以那种顺序打印出 B, C, D:

class B(Exception):
    pass
class C(B):
    pass
class D(C):
    pass

for c in [B, C, D]:
    try:
        raise c()
    except D:
        print("D")
    except C:
        print("C")
    except B:
        print("B")

4.4 迭代器

到目前为止, 你可能注意到, 大多数的容器对象都可以使用 for 来迭代:

for element in [1, 2, 3]:
    print(element)
for element in (1, 2, 3):
    print(element)
for key in {'one':1, 'two':2}:
    print(key)
for char in "123":
    print(char)
for line in open("myfile.txt"):
    print(line)

这种形式简洁明了并且方便. 迭代器的使用遍布于 Python 之中. 在这个外表之下, for 语句对容器对象调用了 iter() . 这个函数返回一个迭代器对象, 它定义了 __next__() 方法, 用以在每次访问时得到一个元素. 当没有任何元素时, __next__() 将产生 StopIteration 异常, 它告诉 for 停止迭代. 你可以使用内置函数 next() 来调用 __next__() 方法; 这个例子展示了它如何工作:

>>> s = 'abc'
>>> it = iter(s)
>>> it
<iterator object at 0x00A1DB50>
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)

Traceback (most recent call last):
  File "<stdin>", line 1, in ?
    next(it)
StopIteration

在看到迭代器的机制之后, 就可以很简单的将迭代行为增加到你的类中:定义一个 __iter__() 方法用以返回一个具有 __next__() 的对象. 如果这个类定义了 __next__() , 那么 __iter__() 仅需要返回 self :

class Reverse:
    "Iterator for looping over a sequence backwards"
    def __init__(self, data):
        self.data = data
        self.index = len(data)
    def __iter__(self):
        return self
    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index]

4.5 生成器

Generator (生成器) 是一个用于创建迭代器简单而且强大的工具. 它们和普通的函数很像, 但是当它们需要返回值时, 则使用 yield 语句. 每次 next() 被调用时, 生成器会从它上次离开的地方继续执行 ( 它会记住所有的数据值和最后一次执行的语句). 一个例子用以展示如何创建生成器:

def reverse(data):
    for index in range(len(data)-1, -1, -1):
        yield data[index]

调用:

>>> for char in reverse('golf'):
...     print(char)
...
f
l
o
g

任何可用生成器实现的东西都能用基于迭代器的类实现, 这个在前面有所描述. 让生成器看起来很紧密的原因是它自动创建了 __iter()__next__().

另一个关键的特性在于, 局部变量和执行状态都被自动保存下来. 这就使函数更容易编写并且更加清晰, 相对于使用实例的变量, 如 self.indexself.data.

除了自动创建方法和保存程序状态, 当生成器终止时, 它们会自动产生 StopIteration 异常. 在这些结合起来后, 这就使得能够很简单的创建迭代器, 除了仅需要编写一个函数.