2.5 面向对象编程

面向对象编程(OOP)是一种组织程序的方法,它汇集了本章中介绍的许多思想。 像数据抽象中的函数一样,类在数据的使用和实现之间创建了抽象障碍。与分派字典一样,对象响应行为请求。 与可变数据结构一样,对象具有不能从全局环境直接访问的局部状态。 Python对象系统提供了方便的语法来促进使用这些技术来组织程序。 许多这种语法在其他面向对象的编程语言中是共享的。

对象系统提供的不仅仅是便利。 它为设计程序提供了一个新的隐喻,在这些程序中,一些独立的代理在计算机中相互作用。 每个对象都以一种抽象两者复杂性的方式将本地状态和行为捆绑在一起。 对象之间进行通信,通过它们的交互计算出有用的结果。 对象不仅传递消息,它们还在相同类型的其他对象之间共享行为,并从相关类型继承特征。

面向对象编程的范例有自己的词汇表来支持对象隐喻。 我们已经看到,对象是一个具有方法和属性的数据值,可以通过点表示法访问。 每个对象也有一个类型,称为它的类。 为了创建新的数据类型,我们实现了新的类。

2.5.1 对象和类

类充当所有类型为该类的对象的模板。每个对象都是某个特定类的实例。到目前为止,我们使用的对象都有内置类,但是也可以创建新的用户定义的类。类定义指定类的对象之间共享的属性和方法。我们将通过重新访问银行帐户的示例来介绍类语句。

在引入局部状态时,我们看到银行账户被自然地建模为具有平衡的可变值。 银行帐户对象应该有一个提取方法,该方法更新帐户余额并返回请求的金额(如果可用的话)。 要完成抽象:银行帐户应该能够返回其当前余额、返回帐户持有人的姓名和存款金额。

Account类允许我们创建银行账户的多个实例。 创建一个新的对象实例的行为被称为实例化类。 Python中实例化类的语法与调用函数的语法相同。 在本例中,我们使用参数'Kirk'(帐户持有人的名字)来调用Account。

>>> a = Account('Kirk')

对象的属性是与对象相关联的名值对,可以通过点表示法访问。与类的所有对象不同,特定于特定对象的属性称为实例属性。每个帐户都有自己的余额和帐户持有人名称,它们是实例属性的示例。在更广泛的编程社区中,实例属性也可以称为字段、属性或实例变量。

>>> a.holder
'Kirk'
>>> a.balance
0

对对象进行操作或执行特定于对象的计算的函数称为方法。方法的返回值和副作用可以依赖并更改对象的其他属性。例如,deposit是Account对象a的一个方法。它接受一个参数,即存入的金额,更改对象的balance属性,并返回结果余额。

>>> a.deposit(15)
15

我们说方法是在特定对象上调用的。调用withdraw方法的结果是,要么批准取款并扣减金额,要么拒绝请求并返回错误消息。

>>> a.withdraw(10)  # The withdraw method returns the balance after withdrawal
5
>>> a.balance       # The balance attribute has changed
5
>>> a.withdraw(10)
'Insufficient funds'

如上所述,方法的行为可以依赖于对象不断变化的属性。两次使用相同参数的withdraw调用会返回不同的结果。

2.5.2 定义类

用户定义的类由由单个子句组成的class语句创建。类语句定义类名,然后包含一组定义类属性的语句:

class <name>:
    <suite>

当一个类语句被执行时,一个新的类被创建并绑定到<name> 在当前环境的第一个框架中。 然后执行套房。 在套件内的任何名称对于类语句,通过def或赋值语句创建或修改类的属性。

类通常是围绕操纵实例属性来组织的,实例属性是与该类的每个实例相关联的名值对。 该类通过定义初始化新对象的方法来指定其对象的实例属性。 例如,初始化Account类对象的一部分是将初始余额分配为0。

<suite> 类的对象语句包含为该类对象定义新方法的def语句。 初始化对象的方法在Python中有一个特殊的名称,__init__(单词“init”的两侧各有两个下划线),它被称为该类的构造函数。

>>> class Account:
        def __init__(self, account_holder):
            self.balance = 0
            self.holder = account_holder

Account的__init__方法有两个形式参数。 第一个self绑定到新创建的Account对象。 第二个形参account_holder绑定到在调用类进行实例化时传递给类的实参。

构造函数将实例属性名balance绑定到0。 它还将属性名称持有者绑定到名称accountholder的值。 形参account_holder是__init方法中的本地名称。 另一方面,通过最终赋值语句绑定的名称持有者将持续存在,因为它使用点表示法存储为self属性。

定义了Account类之后,就可以实例化它了。

>>> a = Account('Kirk')

这个对Account类的“调用”创建了一个新对象,它是Account的一个实例,然后用两个参数调用构造函数__init__:新创建的对象和字符串'Kirk'。按照惯例,我们使用参数名self作为构造函数的第一个参数,因为它被绑定到正在实例化的对象。几乎所有的Python代码都采用了这种约定。

现在,我们可以使用点表示法访问对象的balance和holder。

>>> a.balance
0
>>> a.holder
'Kirk'

同一性。每个新的account实例都有自己的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

虽然方法定义在声明方式上与函数定义没有区别,但方法定义在执行时确实有不同的效果。 由类语句中的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 消息传递和点表达式

在类中定义的方法,以及通常在构造函数中分配的实例属性,是面向对象编程的基本元素。 这两个概念在数据值的消息传递实现中复制了调度字典的大部分行为。 对象使用点表示法接受消息,但这些消息不是任意字符串值的键,而是类的本地名称。 对象也有命名的本地状态值(实例属性),但是可以使用点表法访问和操作该状态,而不必在实现中使用非本地语句。

消息传递的中心思想是,数据值应该通过响应与它们所表示的抽象类型相关的消息而具有行为。 点表示法是Python的一个语法特性,它将消息传递隐喻形式化。 使用内置对象系统的语言的优点是,消息传递可以与其他语言特性无缝地交互,比如赋值语句。 我们不要求不同的消息“获取”或“设置”与本地属性名相关联的值;语言语法允许我们直接使用消息名称。

点表示。代码片段spock_account.deposit称为点表达式。 点表达式由表达式、点和名称组成:

<expression> . <name>

<表达式>可以是任何有效的Python表达式,但必须是一个简单的名称(而不是计算结果为名称的表达式)。对于<表达式>的值的对象,点表达式计算为具有给定的的属性的值。

内置函数getattr还根据名称返回对象的属性。它是点表示法的等价函数。使用getattr,我们可以使用字符串查找属性,就像使用分派字典一样。

>>> getattr(spock_account, 'balance')
10

我们还可以用hasattr测试对象是否有命名属性。

>>> hasattr(spock_account, 'deposit')
True

对象的属性包括它的所有实例属性,以及类中定义的所有属性(包括方法)。 方法是类的属性,需要特殊处理。

方法和函数。 当在对象上调用方法时,该对象将隐式地作为方法的第一个参数传递。 也就是说,对象就是<表达式>的值,在点的左边会自动作为第一个参数传递给点表达式右边命名的方法。 因此,对象被绑定到参数self。

为了实现自动自绑定,Python区分了函数(我们从正文开始就一直在创建)和绑定方法(绑定方法将函数和将调用该方法的对象结合在一起)。绑定的方法值已经与它的第一个参数(调用它的实例)相关联,该参数将在调用该方法时被命名为self。

通过对点表达式的返回值调用type,我们可以看到交互式解释器中的区别。 作为类的属性,方法只是一个函数,而作为实例的属性,它是一个绑定方法:

>>> type(Account.deposit)
<class 'function'>
>>> type(spock_account.deposit)
<class 'method'>

这两个结果的不同之处在于,第一个是带有参数self和amount的标准双参数函数。 第二个是一个单参数方法,在调用该方法时,名称self将自动绑定到名为spock_account的对象,而形参amount将绑定到传递给该方法的实参。 这两个值,无论是函数值还是绑定的方法值,都与同一个desposit函数体相关联。

我们可以以两种方式调用deposit:作为函数和作为绑定方法。 在前一种情况下,必须显式地为self形参提供一个实参。 在后一种情况下,self参数是自动绑定的。

>>> Account.deposit(spock_account, 1001)  # The deposit function takes 2 arguments
1011
>>> spock_account.deposit(1000)           # The deposit method takes 1 argument
2011

函数getattr的行为与点表法完全相同:如果它的第一个参数是一个对象,但名称是类中定义的一个方法,那么getattr返回一个绑定的方法值。 另一方面,如果第一个参数是一个类,则getattr将直接返回属性值,这是一个普通函数。

命名约定。 类名通常使用CapWords约定编写(也称为CamelCase,因为名称中间的大写字母看起来像驼峰)。 方法名称遵循命名函数的标准约定,使用用下划线分隔的小写单词。

在某些情况下,有些实例变量和方法与对象的维护和一致性有关,我们不希望对象的用户看到或使用它们。 它们不是类定义的抽象的一部分,而是实现的一部分。 Python的约定是,如果属性名以下划线开头,则只能在类本身的方法中访问,而不是由类的用户访问。

2.5.4 类属性

某些属性值在给定类的所有对象之间共享。 这些属性与类本身相关联,而不是与类的任何单独实例相关联。 例如,假设一家银行按固定利率对账户余额支付利息。 利率可能会变化,但它是所有账户共享的单一价值。

类属性是由类语句集合中的赋值语句创建的,而不属于任何方法定义。 在更广泛的开发人员社区中,类属性也可以称为类变量或静态变量。 下面的类语句为帐户创建了一个名为interest的类属性。

>>> class Account:
        interest = 0.02            # A class attribute
        def __init__(self, account_holder):
            self.balance = 0
            self.holder = account_holder
        # Additional methods would be defined here

仍然可以从类的任何实例访问此属性

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

计算一个点表达式:

  1. 计算<表达式> 在点的左边,它产生点表达式的对象。

  2. <name> 匹配该对象的实例属性; 如果存在具有该名称的属性,则返回其值。

  3. 如果 <name> 如果实例属性中没有出现<name> 在类中查找,这会产生一个类属性值。

  4. 除非它是一个函数,否则将返回该值,在这种情况下,将返回绑定方法。

在这个计算过程中,在类属性之前找到实例属性,就像在环境中局部名称优先于全局名称一样。 在求值过程的第四步中,类中定义的方法与点表达式的对象相结合,形成一个绑定方法。 一旦引入了类继承,在类中查找名称的过程将很快产生额外的细微差别。

属性赋值。 所有在左侧包含点表达式的赋值语句都会影响该点表达式对象的属性。 如果对象是一个实例,则赋值设置一个实例属性。 如果对象是一个类,则赋值设置一个类属性。 该规则的结果是,对对象属性的赋值不能影响其类的属性。 下面的例子说明了这一区别。

如果将account实例的命名属性interest赋值,则创建一个与现有class属性同名的新实例属性。

>>> kirk_account.interest = 0.08

属性值将从点表达式返回。

>>> kirk_account.interest
0.08

但是,class属性interest仍然保留其原始值,对于所有其他帐户都返回该值。

>>> spock_account.interest
0.04

对类属性的更改将影响spock_account,但kirk_account的实例属性将不受影响。

>>> Account.interest = 0.05  # changing the class attribute
>>> spock_account.interest     # changes instances without like-named instance attributes
0.05
>>> kirk_account.interest     # but the existing instance attribute is unaffected
0.08

2.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
14

支票帐户是帐户的专门化。 在OOP术语中,通用帐户将作为CheckingAccount的基类,而CheckingAccount将是account的子类。 (术语父类和超类也用于基类,而子类也用于子类。)

子类继承其基类的属性,但可能覆盖某些属性,包括某些方法。 对于继承,我们只指定子类和基类之间的不同之处。 我们在子类中未指定的任何东西都会自动假定其行为与基类一样。

除了作为一个有用的组织特性之外,继承在我们的对象隐喻中也有作用。 继承意味着表示类之间的is-关系,这与have -a关系相反。 支票帐户是一种特定类型的帐户,因此从帐户继承CheckingAccount是继承的适当用法。 另一方面,银行拥有它管理的银行账户列表,因此任何一方都不应该从另一方继承。 相反,account对象的列表将自然地表示为bank对象的实例属性。

2.5.6 使用继承

首先,我们给出Account类的完整实现,其中包括该类及其方法的文档字符串。

>>> class Account:
        """A bank account that has a non-negative balance."""
        interest = 0.02
        def __init__(self, account_holder):
            self.balance = 0
            self.holder = account_holder
        def deposit(self, amount):
            """Increase the account balance by amount and return the new balance."""
            self.balance = self.balance + amount
            return self.balance
        def withdraw(self, amount):
            """Decrease the account balance by amount and return the new balance."""
            if amount > self.balance:
                return 'Insufficient funds'
            self.balance = self.balance - amount
            return self.balance

CheckingAccount的完整实现如下所示。通过将计算结果为基类的表达式放在类名后的圆括号中来指定继承。

>>> class CheckingAccount(Account):
        """A bank account that charges for withdrawals."""
        withdraw_charge = 1
        interest = 0.01
        def withdraw(self, amount):
            return Account.withdraw(self, amount + self.withdraw_charge)

这里,我们引入了一个特定于CheckingAccount类的类属性withdraw_charge。我们给interest属性赋一个较低的值。我们还定义了一个新的withdraw方法来覆盖Account类中定义的行为。如果类套件中没有其他语句,则所有其他行为都继承自基类帐户。

>>> checking = CheckingAccount('Sam')
>>> checking.deposit(10)
10
>>> checking.withdraw(5)
4
>>> checking.interest
0.01

check .deposit表达式计算为一个存入存款的绑定方法,该方法在Account类中定义。当Python解析不是实例属性的点表达式中的名称时,它会在类中查找该名称。事实上,在类中“查找”名称的行为试图在原始对象的类的继承链中的每个基类中找到这个名称。我们可以递归地定义这个过程。在类中查找名称。

  1. 如果它命名了类中的一个属性,则返回该属性的值。

  2. 否则,在基类中查找名称(如果有的话)。

在执行deposit情况下,Python首先在实例中查找名称,然后在CheckingAccount类中查找。 最后,它将查看Account类,其中定义了deposit。 根据点表达式的计算规则,因为deposit是在类中为检查实例查找的函数,所以点表达式计算为绑定的方法值。 该方法使用参数10调用,参数10调用self绑定到check对象并将amount绑定到10的deposit方法。

对象的类始终保持不变。 尽管在Account类中找到了deposit方法,但在调用deposit时,self绑定到CheckingAccount的实例,而不是Account的实例。

调用父类。 已经被覆盖的属性仍然可以通过类对象访问。 例如,我们通过调用Account的withdraw方法实现了CheckingAccount的withdraw方法,该方法带有一个包含withdraw_charge的参数。

注意到我们调用了self.withdraw_charge ,而不是等价的CheckingAccount.withdraw_charge。 前者优于后者的好处是继承自CheckingAccount的类可以覆盖取款费用。 如果是这样的话,我们希望我们的withdraw实现找到新的值而不是旧的值。

接口。 在面向对象的程序中,不同类型的对象共享相同的属性名是非常常见的。 对象接口是属性和这些属性上的条件的集合。 例如,所有帐户都必须有接受数字参数的存款和取款方法,以及一个余额属性。 类Account和CheckingAccount都实现了这个接口。 继承特别以这种方式促进名称共享。 在某些编程语言(如Java)中,必须显式声明接口实现。 在Python、Ruby和Go等其他语言中,任何具有适当名称的对象都实现一个接口。

如果程序中使用对象(而不是实现对象)的部分不假设对象类型,而只假设对象的属性名称,那么它们对未来的更改是最健壮的。 也就是说,它们使用对象抽象,而不是对其实现进行任何假设。

例如,假设我们运行了一个彩票,我们希望向每个帐户列表中存入5美元。 下面的实现没有对这些帐户的类型进行任何假设,因此同样适用于具有deposit方法的任何类型的对象:

>>> def deposit_all(winners, amount=5):
        for account in winners:
            account.deposit(amount)

我们将在本章后面更详细地讨论这个主题。

2.5.7 多重继承

Python支持子类从多个基类继承属性的概念,这是一种称为多重继承的语言特性。

假设我们有一个从Account继承的SavingsAccount,但每次向客户存款时都要收取少量费用。

>>> class SavingsAccount(Account):
        deposit_charge = 2
        def deposit(self, amount):
            return Account.deposit(self, amount - self.deposit_charge)

然后,一位聪明的经理构思出一个具有支票账户和储蓄账户双重优点的活期账户:提现费、存款费和低利率。它既是支票账户又是储蓄账户!“ 如果我们建造了它,”行政人员解释道,“就会有人签约并支付所有费用。”我们甚至会给他们一美元。”

>>> class AsSeenOnTVAccount(CheckingAccount, SavingsAccount):
        def __init__(self, account_holder):
            self.holder = account_holder
            self.balance = 1           # A free dollar!

事实上,这个实现已经完成了。取款和存款都将产生费用,分别使用CheckingAccount和SavingsAccount中的函数定义。

>>> such_a_deal = AsSeenOnTVAccount("John")
>>> such_a_deal.balance
1
>>> such_a_deal.deposit(20)            # $2 fee from SavingsAccount.deposit
19
>>> such_a_deal.withdraw(5)            # $1 fee from CheckingAccount.withdraw
13

无歧义引用按预期正确解析:

>>> such_a_deal.deposit_charge
2
>>> such_a_deal.withdraw_charge
1

但是,如果引用是模糊的,比如对Account和CheckingAccount中定义的withdraw方法的引用,该怎么办?下图描述了AsSeenOnTVAccount类的继承图。每个箭头从一个子类指向一个基类。

对于这样一个简单的“菱形”形状,Python从左到右,然后向上解析名称。在这个例子中,Python按顺序检查以下类中的属性名,直到找到具有该名称的属性:

asenontvaccount, CheckingAccount, SavingsAccount, Account, object

继承排序问题没有正确的解决方案,因为在某些情况下,我们可能倾向于优先考虑某些继承的类而不是其他类。 然而,任何支持多重继承的编程语言都必须以一致的方式选择某种顺序,以便该语言的用户能够预测其程序的行为。

进一步阅读。Python使用一种称为C3方法解析排序的递归算法来解析这个名称。任何类的方法解析顺序都可以在所有类上使用mro方法查询。

>>> [c.__name__ for c in AsSeenOnTVAccount.mro()]
['AsSeenOnTVAccount', 'CheckingAccount', 'SavingsAccount', 'Account', 'object']

查找方法解析顺序的精确算法不是本文的主题,但Python的主要作者在原始论文的参考资料中进行了描述。

2.5.8 对象的角色

Python对象系统的设计是为了使数据抽象和消息传递既方便又灵活。 类、方法、继承和点表达式的专门化语法都使我们能够在程序中形式化对象隐喻,这提高了我们组织大型程序的能力。

特别是,我们希望我们的对象系统能够在程序的不同方面促进关注点的分离。 程序中的每个对象封装并管理程序状态的某些部分,每个类语句定义实现程序整体逻辑的某些部分的函数。 抽象障碍加强了大型程序不同方面之间的边界。

面向对象编程特别适合为具有独立但相互作用的部分的系统建模的程序。 例如,不同的用户在社交网络中互动,不同的角色在游戏中互动,不同的形状在物理模拟中互动。 当表示这样的系统时,程序中的对象通常会自然地映射到被建模的系统中的对象,而类表示它们的类型和关系。

另一方面,类可能不提供实现某些抽象的最佳机制。 功能抽象为表示输入和输出之间的关系提供了一种更自然的隐喻。 人们不应该强迫自己在类中容纳程序中的每一点逻辑,特别是在定义用于操作数据的独立函数更自然的情况下。 函数也可以强制关注点分离。

Python等多范式语言允许程序员将组织范式与适当的问题相匹配。 在软件工程中,为了简化或模块化程序,学习何时引入新类(而不是新函数)是一项重要的设计技能,值得仔细注意。

Last updated

Was this helpful?