2.5 面向对象编程
面向对象编程(OOP)是一种组织程序的方法,它把本章介绍的许多思想汇聚到一起。和数据抽象里的函数一样,类在数据的使用和实现之间建立了抽象屏障(abstraction barrier)。和分派字典(dispatch dictionaries)一样,对象会响应行为请求。和可变数据结构一样,对象有无法从全局环境直接访问的局部状态(local state)。Python 的对象系统提供了方便的语法,让这些组织程序的技术更容易使用。这些语法大多为其他面向对象编程语言所共有。
对象系统带来的不只是便利。它为程序设计提供了一种新的隐喻:若干个独立的代理在计算机内交互。每个对象都把局部状态和行为捆绑在一起,从而把两者的复杂性一并隐藏起来。对象彼此通信,靠交互算出有用的结果。对象不只传递消息,还会在同类型的其他对象之间共享行为,并从相关的类型继承特征。
面向对象编程有自己的词汇来支撑对象隐喻。我们已经看到,对象(object)是带有方法和属性的数据值,可以通过点表达式(dot notation)访问。每个对象也都有一个类型,称为它的类(class)。要创建新类型的数据,我们就实现新类。
2.5.1 对象和类
类是所有以它为类型的对象的模板。每个对象都是某个特定类的实例。到目前为止我们用到的对象都有内置类,不过也可以创建新的用户自定义类。类定义规定了该类的对象共享哪些属性和方法。我们重温银行账户的例子,来介绍 class 语句。
在介绍局部状态时我们看到,银行账户天然可以建模成带 balance 的可变值。银行账户对象应该有一个 withdraw 方法,用来更新账户余额,并在金额可取时返回该金额。要补全这套抽象,银行账户还应该能返回当前余额、返回账户持有人的名字,以及接收存款金额。
Account 类让我们可以创建多个银行账户实例。创建新对象实例这个动作,称为实例化(instantiating)类。Python 中实例化类的语法,和调用函数的语法完全一样。这里,我们调用 Account 时传入参数 Kirk,也就是账户持有人的名字。
>>> a = Account('Kirk')对象的属性(attribute)是与对象关联的名称-值对,可以通过点表达式访问。只属于某个特定对象、而不是该类所有对象的属性,称为实例属性(instance attributes)。每个 Account 都有自己的余额和账户持有人名字,这些都是实例属性的例子。在更广的编程圈子里,实例属性也叫字段(field)、特性(property)或实例变量(instance variable)。
>>> a.holder
'Kirk'
>>> a.balance
0对对象进行操作、或执行与对象相关的计算的函数,称为方法。方法的返回值和副作用,既可能取决于对象的其他属性,也可能改变这些属性。例如,deposit 就是我们的 Account 对象 a 的一个方法。它接收一个参数,即要存入的金额,改变对象的 balance 属性,并返回结果余额。
>>> a.deposit(15)
15我们说,方法是在某个特定对象上调用(invoke)的。调用 withdraw 方法,要么批准提款并扣除金额,要么拒绝请求并返回一条错误消息。
>>> a.withdraw(10) # withdraw 方法返回扣除后的金额
5
>>> a.balance # 金额属性发生改变
5
>>> a.withdraw(10)
'Insufficient funds'如上所示,方法的行为可能取决于对象不断变化的属性。用相同参数调用 withdraw 两次,会返回不同的结果。
2.5.2 类的定义
用户自定义类由 class 语句创建,它只包含一个子句。class 语句定义类名,然后包含一组语句,用来定义类的属性:
class <name>:
<suite>执行 class 语句时,会创建一个新类,并在当前环境的第一个帧里绑定 <name>。然后执行语句体。在 class 语句的 <suite> 里,通过 def 或赋值语句绑定的任何名称,都会创建或修改类的属性。
类的组织通常围绕操作实例属性展开,也就是与该类每个实例关联的名称-值对。类规定其对象实例属性的方式,是定义一个初始化新对象的方法。例如,初始化 Account 类的对象时,有一件事就是给它一个起始余额 0。
class 语句的 <suite> 里包含 def 语句,def 语句为类的对象定义新方法。初始化对象的方法在 Python 里有个特殊的名字 __init__(init 两边各有两个下划线),叫做这个类的构造函数(constructor)。
>>> class Account:
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holderAccount 的 __init__ 方法有两个形式参数。第一个是 self,绑定到新创建的 Account 对象。第二个参数 account_holder,绑定到实例化时调用该类所传入的参数。
构造函数把实例属性名 balance 绑定到 0,还把属性名 holder 绑定到名称 account_holder 的值。形式参数 account_holder 是 __init__ 方法里的局部名称。而最后一条赋值语句绑定的名称 holder 则会一直存在,因为它通过点表达式存成了 self 的属性。
定义好 Account 类后,就可以实例化它。
>>> a = Account('Kirk')对 Account 类的这次「调用」会创建一个新对象,它是 Account 的一个实例,然后用两个参数调用构造函数 __init__:新创建的对象和字符串 'Kirk'。按惯例,构造函数的第一个参数名为 self,因为它绑定到的正是正在实例化的那个对象。几乎所有 Python 代码都遵循这个惯例。
现在,可以用点表达式访问对象的 balance 和 holder。
>>> a.balance
0
>>> a.holder
'Kirk'标识。每个新建的账户实例都有自己的 balance 属性,其值独立于同类的其他对象。
>>> b = Account('Spock')
>>> b.balance = 200
>>> [acc.balance for acc in (a, b)]
[0, 200]为了保证这种独立性,用户自定义类的每个实例对象都有唯一的标识。对象的标识用 is 和 is not 运算符比较。
>>> a is a
True
>>> a is not b
True尽管是用相同的调用构造出来的,绑定到 a 和 b 的对象并不相同。和之前一样,用赋值把一个对象绑定到新名称,并不会创建新对象。
>>> c = a
>>> c is a
True只有当用调用表达式的语法实例化一个类(比如 Account)时,才会创建出属于用户自定义类的新对象。
方法。对象方法也是在 class 语句的语句体里用 def 语句定义的。下面,deposit 和 withdraw 都定义为 Account 类对象上的方法。
>>> class Account:
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holder
def deposit(self, amount):
self.balance = self.balance + amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
return 'Insufficient funds'
self.balance = self.balance - amount
return self.balance方法定义在声明方式上和函数定义没有区别,但执行起来效果不同。class 语句里的 def 语句创建的函数值,会绑定到声明的名称,只是作为属性绑定在类内部。在类的实例上,可以用点表达式把这个值当作方法来调用。
每个方法定义同样都包含一个特殊的第一参数 self,它绑定到调用该方法的那个对象。例如,假设在某个 Account 对象上调用 deposit,并传入一个参数值:要存入的金额。对象本身绑定到 self,而传入的参数绑定到 amount。每个被调用的方法都能借助 self 参数访问对象,因此都能访问和操作对象的状态。
为了调用这些方法,我们再次使用点表达式,如下图所示。
>>> spock_account = Account('Spock')
>>> spock_account.deposit(100)
100
>>> spock_account.withdraw(90)
10
>>> spock_account.withdraw(90)
'Insufficient funds'
>>> spock_account.holder
'Spock'用点表达式调用方法时,对象本身(本例中绑定到 spock_account)扮演着双重角色。首先,它决定 withdraw 这个名称的含义:withdraw 不是环境里的名称,而是 Account 类内部的局部名称。其次,调用 withdraw 方法时,它绑定到第一个参数 self。
2.5.3 消息传递和点表达式
在类中定义的方法,以及在构造函数中赋值的实例属性,是面向对象编程的基本元素。这两个概念,复制了数据值的消息传递(message passing)实现里分派字典的大部分行为。对象用点表达式接收消息,但这些消息不是任意的字符串键,而是类内部的局部名称。对象还有带名字的局部状态值(即实例属性),但这种状态可以用点表达式访问和操作,实现时不必借助 nonlocal 语句。
消息传递的核心思想是:数据值应当有行为,方式就是响应与它所表示的抽象类型相关的消息。点表达式是 Python 的一项语法特性,它把消息传递这一隐喻形式化了。使用带有内置对象系统的语言,好处在于消息传递能和其他语言特性(比如赋值语句)顺畅配合。要「获取」或「设置」与某个局部属性名关联的值,不必动用另外的消息,语言的语法让我们可以直接使用消息名。
点表达式。代码片段 spock_account.deposit 称为点表达式。点表达式由表达式、点和名称组成:
<expression>.<name><expression> 可以是任何合法的 Python 表达式,但 <name> 必须是简单名称(而不是求值得到某个名称的表达式)。点表达式求值的结果,是 <expression> 的值所指对象中名为 <name> 的属性值。
内置函数 getattr 也可以按名称返回对象的属性,它是点表达式的函数版本。有了 getattr,就能像查分派字典那样,用字符串查找属性。
>>> getattr(spock_account, 'balance')
10还可以用 hasattr 测试对象是否具有某个指定名称的属性。
>>> hasattr(spock_account, 'deposit')
True对象的属性包括它所有的实例属性,以及其类中定义的所有属性(含方法)。方法是一种需要特殊处理的类属性。
方法和函数。在对象上调用方法时,该对象会隐式地作为第一个参数传给这个方法。也就是说,点左边 <expression> 的值所指的那个对象,会自动作为第一个参数,传给点表达式右边那个具名方法。于是,这个对象就绑定到了参数 self。
为了实现 self 的自动绑定,Python 区分了函数(我们从本书开头就一直在创建)和绑定方法(bound methods,把函数与将来调用该方法的对象绑在一起)。绑定方法的值已经和它的第一个参数(调用它的那个实例)关联在一起,调用这个方法时,该参数就叫 self。
在交互式解释器里,对点表达式的返回值调用 type,就能看出这种区别。作为类的属性,方法只是一个函数;而作为实例的属性,它是一个绑定方法:
>>> type(Account.deposit)
<class 'Function'>
>>> type(spock_account.deposit)
<class 'method'>这两个结果的差别只在于:第一个是参数为 self 和 amount 的标准双参数函数;第二个是单参数方法,调用时名称 self 会自动绑定到名为 spock_account 的对象,而参数 amount 会绑定到传给方法的参数。这两个值——无论是函数值还是绑定方法值——都关联到同一个 deposit 函数体。
deposit 有两种调用方式:当作函数调用,或当作绑定方法调用。前一种情况,必须显式给 self 参数传一个参数;后一种情况,self 参数自动绑定。
>>> Account.deposit(spock_account, 1001) # 函数 deposit 接受两个参数
1011
>>> spock_account.deposit(1000) # 方法 deposit 接受一个参数
2011函数 getattr 的行为和点表达式完全一样:如果第一个参数是对象、名称又是类中定义的方法,getattr 就返回一个绑定方法值。反过来,如果第一个参数是类,getattr 就直接返回属性值,也就是一个普通函数。
命名约定。类名通常按 CapWords 约定书写(也叫 CamelCase,因为名字中间的大写字母看起来像驼峰)。方法名则沿用函数命名的标准约定:用小写单词,以下划线分隔。
有时,有些实例变量和方法只关乎对象的维护和一致性,我们不希望对象的使用者看到或使用。它们不属于类定义的抽象,而属于实现。Python 的约定是:属性名以下划线开头,就只应在类自身的方法里访问,而不该由类的使用者访问。
2.5.4 类属性
有些属性值在某个类的所有对象之间共享。这类属性和类本身关联,而不属于类的某个具体实例。例如,假设银行按固定利率给账户余额付息。这个利率可能变化,但它是所有账户共享的同一个值。
类属性由 class 语句语句体中的赋值语句创建,位于任何方法定义之外。在更广的开发者社区里,类属性也叫类变量或静态变量。下面的 class 语句为 Account 创建了一个名为 interest 的类属性。
>>> class Account:
interest = 0.02 # 类属性
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holder
# 在这里定义更多的方法这个属性仍然可以从类的任何实例访问。
>>> spock_account = Account('Spock')
>>> kirk_account = Account('Kirk')
>>> spock_account.interest
0.02
>>> kirk_account.interest
0.02不过,对类属性做一次赋值,就会改变该类所有实例的这一属性值。
>>> Account.interest = 0.04
>>> spock_account.interest
0.04
>>> kirk_account.interest
0.04属性名称。我们的对象系统已经足够复杂,必须说明名称是如何解析到具体属性的。毕竟,很容易出现类属性和实例属性同名的情况。
正如我们所见,点表达式由表达式、点和名称组成:
<expression> . <name>求值点表达式:
- 求值点左边的
<expression>,得到点表达式的对象。 - 拿
<name>去匹配该对象的实例属性;如果存在这个名字的属性,就返回它的值。 - 如果实例属性里没有
<name>,就去类里查找<name>,得到类属性的值。 - 返回这个值,除非它是函数——若是函数,则改为返回绑定方法。
这个求值过程里,实例属性先于类属性找到,就像环境中局部名称优先于全局名称一样。在类中定义的方法,会在求值过程的第 4 步和点表达式的对象结合,组成绑定方法。在类里查找名称的过程还有别的细节,等我们讲到类继承时很快就会出现。
属性赋值。只要赋值语句的左边含有点表达式,赋值就会影响该点表达式所指对象的属性。如果对象是实例,赋值就设置实例属性;如果对象是类,赋值就设置类属性。由于这条规则,给对象的属性赋值不会影响它所在类的属性。下面的例子说明了这种区别。
如果给某个账户实例的 interest 属性赋值,就会新建一个与已有类属性同名的实例属性。
>>> kirk_account.interest = 0.08这个属性值会从点表达式返回。
>>> kirk_account.interest
0.08不过,类属性 interest 仍保留原来的值,其他账户读取到的仍是这个值。
>>> spock_account.interest
0.04改类属性 interest 会影响 spock_account,但 kirk_account 的实例属性不受影响。
>>> Account.interest = 0.05 # 改变类属性
>>> spock_account.interest # 实例属性发生变化(该实例中没有和类属性同名称的实例属性)
0.05
>>> kirk_account.interest # 如果实例中存在和类属性同名的实例属性,则改变类属性,不会影响实例属性
0.082.5.5 继承
在面向对象编程中,我们常常发现不同类型之间存在关联。具体来说,相似的类在专门化程度上各有不同。两个类可能属性相似,但其中一个是另一个的特例。
例如,我们可能想实现一个活期账户,它和标准账户不同:活期账户每次取款额外收取 1 美元手续费,利率也更低。下面演示我们期望的行为。
>>> ch = CheckingAccount('Spock')
>>> ch.interest # Lower interest rate for checking accounts
0.01
>>> ch.deposit(20) # Deposits are the same
20
>>> ch.withdraw(5) # withdrawals decrease balance by an extra charge
14CheckingAccount 是 Account 的专门化。用 OOP 的术语说,通用的账户是 CheckingAccount 的基类(base class),而 CheckingAccount 是 Account 的子类(subclass)。基类也叫父类(parent class)或超类(superclass),子类也叫孩子类(child class)。
子类继承基类的属性,但可以重写某些属性,包括某些方法。用继承时,我们只写明子类和基类不同的地方。子类里没写明的部分,就自动按基类的行为来。
继承除了是有用的组织手段,在对象隐喻里也占有一席之地。继承要表达的是类之间的 is-a(是一种)关系,与 has-a(有一个)关系相对。活期账户是一种特定类型的账户,所以让 CheckingAccount 继承 Account 是继承的合理用法。另一方面,银行是 has-a 一个它管理的账户列表,所以两者都不该继承对方。反过来,账户对象列表自然应该表示成银行对象的一个实例属性。
2.5.6 使用继承
首先,我们给出 Account 类的完整实现,其中包括该类及其方法的文档字符串。
>>> class Account:
"""一个余额非零的账户。"""
interest = 0.02
def __init__(self, account_holder):
self.balance = 0
self.holder = account_holder
def deposit(self, amount):
"""存入账户 amount,并返回变化后的余额"""
self.balance = self.balance + amount
return self.balance
def withdraw(self, amount):
"""从账号中取出 amount,并返回变化后的余额"""
if amount > self.balance:
return 'Insufficient funds'
self.balance = self.balance - amount
return self.balance下面给出 CheckingAccount 的完整实现。指定继承的方式,是把一个求值为基类的表达式放进类名后的括号里。
>>> class CheckingAccount(Account):
"""从账号取钱会扣出手续费的账号"""
withdraw_charge = 1
interest = 0.01
def withdraw(self, amount):
return Account.withdraw(self, amount + self.withdraw_charge)这里,我们引入一个 CheckingAccount 类特有的类属性 withdraw_charge。我们给 interest 属性赋了一个较低的值,还定义了一个新的 withdraw 方法,重写 Account 类中定义的行为。类语句体里没有其他语句,所以其他行为都继承自基类 Account。
>>> checking = CheckingAccount('Sam')
>>> checking.deposit(10)
10
>>> checking.withdraw(5)
4
>>> checking.interest
0.01表达式 checking.deposit 求值为用于存款的绑定方法,该方法在 Account 类中定义。当 Python 解析点表达式中不是实例属性的名称时,会在类里查找该名称。其实,在类里「查找」一个名称,会沿着原对象所属类的继承链,逐个基类去找这个名字。这个过程可以递归地定义。在类中查找名称:
- 如果该名称是类中的属性,就返回该属性的值。
- 否则,去基类里查找这个名字(如果有基类的话)。
对 deposit 来说,Python 会先在实例上找,再到 CheckingAccount 类里找,最后在定义 deposit 的 Account 类里找到。按我们对点表达式的求值规则,由于 deposit 是为 checking 实例在类中找到的函数,点表达式求值为绑定方法值。这个方法用参数 10 调用,此时 self 绑定到 checking 对象,amount 绑定到 10。
对象的类始终不变。尽管 deposit 方法是在 Account 类里找到的,但调用 deposit 时,self 绑定的是 CheckingAccount 的实例,而不是 Account 的实例。
调用父类。被重写的属性仍然可以通过类对象访问。例如,我们实现 CheckingAccount 的 withdraw 方法时,调用了 Account 的 withdraw 方法,传入的参数里加上了 withdraw_charge。
注意,我们调用的是 self.withdraw_charge,而不是等价的 CheckingAccount.withdraw_charge。前者比后者好的一点是,继承自 CheckingAccount 的类可能重写 withdraw_charge。如果是这样,我们希望 withdraw 的实现能找到新值,而不是旧值。
接口。在面向对象的程序里,不同类型的对象共用相同的属性名,这极为常见。对象接口(object interface)是一组属性,外加对这些属性的约束条件。例如,所有账户都必须有接受数值参数的 deposit 和 withdraw 方法,以及 balance 属性。Account 类和 CheckingAccount 类都实现了这个接口。继承正是以这种方式促成名称共享。在某些编程语言(如 Java)中,必须显式声明接口的实现;在另一些语言(如 Python、Ruby 和 Go)里,只要对象有合适的属性名,就算实现了某个接口。
程序中那些使用对象(而不是实现对象)的部分,只要不对对象类型做假设,只对属性名做假设,就最能适应未来的变化。也就是说,它们用的是对象抽象,而不对具体实现做任何假设。
例如,假设我们运营彩票,想给一个账户列表中的每个账户存入 5 美元。下面的实现不对这些账户的类型做任何假设,因此对任何带有 deposit 方法的对象都同样适用:
>>> def deposit_all(winners, amount=5):
for account in winners:
account.deposit(amount) # 这里调用的是实例 account 的 deposit 方法
# 对于不同实例来说,它们的 deposit 方法可能不同。这个例子相对于下面来讲,更加具有健壮性上面的函数 deposit_all 只假设每个 account 满足账户对象抽象,因此对任何同样实现该接口的账户类都适用。而假定某个具体的账户类,就违反了账户对象抽象的抽象屏障。例如,下面的实现就不一定适用于新的账户类型:
>>> def deposit_all(winners, amount=5):
for account in winners:
Account.deposit(account, amount) # 这里调用的是类 Account 中的 deposit 方法这个主题我们会在本章后面更详细地讨论。
2.5.7 多重继承
Python 支持子类从多个基类继承属性,这种语言特性称为多重继承(multiple inheritance)。
假设我们有一个 SavingsAccount 继承自 Account,但每次客户存款时都收取少量费用。
>>> class SavingsAccount(Account):
deposit_charge = 2
def deposit(self, amount):
return Account.deposit(self, amount - self.deposit_charge)于是,一位聪明的高管构想出一种 AsSeenOnTVAccount 账户,把 CheckingAccount 和 SavingsAccount 的优点集于一身:有提款费、存款费,利率还低。它把活期账户和储蓄账户合二为一!「要是把它做出来,」这位高管盘算道,「总会有人来注册,交上所有这些费用。我们还会白送他们一美元。」
>>> class AsSeenOnTVAccount(CheckingAccount, SavingsAccount):
def __init__(self, account_holder):
self.holder = account_holder
self.balance = 1 # 赠送的 1 $!事实上,这个实现已经完整了。取款和存款都会产生费用,分别用 CheckingAccount 和 SavingsAccount 中的函数定义。
>>> such_a_deal = AsSeenOnTVAccount("John")
>>> such_a_deal.balance
1
>>> such_a_deal.deposit(20) # 调用 SavingsAccount 的 deposit 方法,会产生 2 $的存储费用
19
>>> such_a_deal.withdraw(5) # 调用 CheckingAccount 的 withdraw 方法,产生 1 $的取款费用。
13没有歧义的引用,都能按预期正确解析:
>>> such_a_deal.deposit_charge
2
>>> such_a_deal.withdraw_charge
1但是,当引用有歧义时呢?比如同时定义在 Account 和 CheckingAccount 中的 withdraw 方法。下图画出了 AsSeenOnTVAccount 类的继承图,每个箭头都从子类指向基类。

对于像这样的简单「菱形」结构,Python 从左到右解析名称,再向上解析。这个例子里,Python 按顺序在下列类中查找该属性名,直到找到为止:
AsSeenOnTVAccount, CheckingAccount, SavingsAccount, Account, object继承排序问题没有正确的解法,因为有些情况下,我们可能更愿意让某些继承来的类优先于其他类。不过,任何支持多重继承的编程语言,都必须以一致的方式选定某种顺序,好让语言的使用者能预测程序的行为。
延伸阅读。Python 用一种叫 C3 方法解析顺序(C3 Method Resolution Ordering)的递归算法来解析这个名称。任何类的方法解析顺序,都可以用 mro 方法查询。
>>> [c.__name__ for c in AsSeenOnTVAccount.mro()]
['AsSeenOnTVAccount', 'CheckingAccount', 'SavingsAccount', 'Account', 'object']确定方法解析顺序的具体算法不在本书讨论范围内,不过 Python 的主要作者对此做了说明,并附有原始论文的参考。
2.5.8 对象的作用
Python 的对象系统,设计目标是让数据抽象和消息传递既方便又灵活。类、方法、继承和点表达式这些专门语法,让我们能在程序里把对象隐喻形式化,从而提升组织大型程序的能力。
具体来说,我们希望对象系统能促进程序不同方面之间的关注点分离(separation of concerns)。程序里的每个对象都封装和管理程序状态的一部分,每个 class 语句则定义实现程序整体逻辑某一部分的那些函数。抽象屏障把大型程序不同方面之间的边界固定下来。
面向对象编程特别适合为那种由相互独立又彼此作用的部分构成的系统建模。比如社交网络里不同用户的互动、游戏里不同角色的互动、物理模拟里不同形状的互动。表示这类系统时,程序中的对象常常能自然地对应到被建模系统中的对象,而类则代表它们的类型和关系。
另一方面,类未必是实现某些抽象的最佳机制。函数式抽象能更自然地表示输入和输出之间的关系。不必觉得程序里每一点逻辑都得塞进某个类,尤其在为操作数据单独定义函数更自然的时候。函数同样能保证关注点的分离。
Python 这样的多范式语言,让程序员能为不同的问题挑选合适的组织范式。学会判断何时该引入一个新类、何时该引入一个新函数,从而简化或模块化程序,是软件工程中一项重要的设计技能,值得认真对待。