摘要:类似消息传递中的分发字典,对象响应行为请求。消息传递和点表达式方法定义在类中,而实例属性通常在构造器中赋值,二者都是面向对象编程的基本元素。使用带有内建对象系统语言的优点是,消息传递能够和其它语言特性,例如赋值语句无缝对接。
2.5 面向对象编程
来源:2.5 Object-Oriented Programming
译者:飞龙
协议:CC BY-NC-SA 4.0
面向对象编程(OOP)是一种用于组织程序的方法,它组合了这一章引入的许多概念。就像抽象数据类型那样,对象创建了数据使用和实现之间的抽象界限。类似消息传递中的分发字典,对象响应行为请求。就像可变的数据结构,对象拥有局部状态,并且不能直接从全局环境访问。Python 对象系统提供了新的语法,更易于为组织程序实现所有这些实用的技巧。
但是对象系统不仅仅提供了便利;它也为程序设计添加了新的隐喻,其中程序中的几个部分彼此交互。每个对象将局部状态和行为绑定,以一种方式在数据抽象背后隐藏二者的复杂性。我们的约束程序的例子通过在约束和连接器之前传递消息,产生了这种隐喻。Python 对象系统使用新的途径扩展了这种隐喻,来表达程序的不同部分如何互相关联,以及互相通信。对象不仅仅会传递消息,还会和其它相同类型的对象共享行为,以及从相关的类型那里继承特性。
面向对象编程的范式使用自己的词汇来强化对象隐喻。我们已经看到了,对象是拥有方法和属性的数据值,可以通过点运算符来访问。每个对象都拥有一个类型,叫做类。Python 中可以定义新的类,就像定义函数那样。
2.5.1 对象和类类可以用作所有类型为该类的对象的模板。每个对象都是某个特定类的实例。我们目前使用的对象都拥有内建类型,但是我们可以定义新的类,就像定义函数那样。类的定义规定了在该类的对象之间共享的属性和方法。我们会通过重新观察银行账户的例子,来介绍类的语句。
在介绍局部状态时,我们看到,银行账户可以自然地建模为拥有balance的可变值。银行账户对象应该拥有withdraw方法,在可用的情况下,它会更新账户余额,并返回所请求的金额。我们希望添加一些额外的行为来完善账户抽象:银行账户应该能够返回它的当前余额,返回账户持有者的名称,以及接受存款。
Account类允许我们创建银行账户的多个实例。创建新对象实例的动作被称为实例化该类。Python 中实例化某个类的语法类似于函数的调用语句。这里,我们使用参数"Jim"(账户持有者的名称)来调用Account。
>>> a = Account("Jim")
对象的属性是和对象关联的名值对,它可以通过点运算符来访问。属性特定于具体的对象,而不是类的所有对象,也叫做实例属性。每个Account对象都拥有自己的余额和账户持有者名称,它们是实例属性的一个例子。在更宽泛的编程社群中,实例属性可能也叫做字段、属性或者实例变量。
>>> a.holder "Jim" >>> a.balance 0
操作对象或执行对象特定计算的函数叫做方法。方法的副作用和返回值可能依赖或改变对象的其它属性。例如,deposit是Account对象a上的方法。它接受一个参数,即需要存入的金额,修改对象的balance属性,并返回产生的余额。
>>> a.deposit(15) 15
在 OOP 中,我们说方法可以在特定对象上调用。作为调用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( ):
当类的语句被执行时,新的类会被创建,并且在当前环境第一帧绑定到
类通常围绕实例属性来组织,实例属性是名值对,不和类本身关联但和类的每个对象关联。通过为实例化新对象定义方法,类规定了它的对象的实例属性。
class语句的
>>> class Account(object): def __init__(self, account_holder): self.balance = 0 self.holder = account_holder
Account的__init__方法有两个形参。第一个是self,绑定到新创建的Account对象上。第二个参数,account_holder,在被调用来实例化的时候,绑定到传给该类的参数上。
构造器将实例属性名称balance与0绑定。它也将属性名称holder绑定到account_holder上。形参account_holder是__init__方法的局部名称。另一方面,通过最后一个赋值语句绑定的名称holder是一直存在的,因为它使用点运算符被存储为self的属性。
定义了Account类之后,我们就可以实例化它:
>>> a = Account("Jim")
这个对Account类的“调用”创建了新的对象,它是Account的实例,之后以两个参数调用了构造函数__init__:新创建的对象和字符串"Jim"。按照惯例,我们使用名称self来命名构造器的第一个参数,因为它绑定到了被实例化的对象上。这个惯例在几乎所有 Python 代码中都适用。
现在,我们可以使用点运算符来访问对象的balance和holder。
>>> a.balance 0 >>> a.holder "Jim"
身份。每个新的账户实例都有自己的余额属性,它的值独立于相同类的其它对象。
>>> b = Account("Jack") >>> 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(object): 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,它绑定到方法所调用的对象上。例如,让我们假设deposit在特定的Account对象上调用,并且传递了一个对象值:要存入的金额。对象本身绑定到了self上,而参数绑定到了amount上。所有被调用的方法能够通过self参数来访问对象,所以它们可以访问并操作对象的状态。
为了调用这些方法,我们再次使用点运算符,就像下面这样:
>>> tom_account = Account("Tom") >>> tom_account.deposit(100) 100 >>> tom_account.withdraw(90) 10 >>> tom_account.withdraw(90) "Insufficient funds" >>> tom_account.holder "Tom"
当一个方法通过点运算符调用时,对象本身(这个例子中绑定到了tom_account)起到了双重作用。首先,它决定了withdraw意味着哪个名称;withdraw并不是环境中的名称,而是Account类局部的名称。其次,当withdraw方法调用时,它绑定到了第一个参数self上。求解点运算符的详细过程会在下一节中展示。
2.5.3 消息传递和点表达式方法定义在类中,而实例属性通常在构造器中赋值,二者都是面向对象编程的基本元素。这两个概念很大程度上类似于数据值的消息传递实现中的分发字典。对象使用点运算符接受消息,但是消息并不是任意的、值为字符串的键,而是类的局部名称。对象也拥有具名的局部状态值(实例属性),但是这个状态可以使用点运算符访问和操作,并不需要在实现中使用nonlocal语句。
消息传递的核心概念,就是数据值应该通过响应消息而拥有行为,这些消息和它们所表示的抽象类型相关。点运算符是 Python 的语法特征,它形成了消息传递的隐喻。使用带有内建对象系统语言的优点是,消息传递能够和其它语言特性,例如赋值语句无缝对接。我们并不需要不同的消息来“获取”和“设置”关联到局部属性名称的值;语言的语法允许我们直接使用消息名称。
点表达式。类似tom_account.deposit的代码片段叫做点表达式。点表达式包含一个表达式,一个点和一个名称:
.
内建的函数getattr也会按名称返回对象的属性。它是等价于点运算符的函数。使用getattr,我们就能使用字符串来查找某个属性,就像分发字典那样:
>>> getattr(tom_account, "balance") 10
我们也可以使用hasattr测试对象是否拥有某个具名属性:
>>> hasattr(tom_account, "deposit") True
对象的属性包含所有实例属性,以及所有定义在类中的属性(包括方法)。方法是需要特别处理的类的属性。
方法和函数。当一个方法在对象上调用时,对象隐式地作为第一个参数传递给方法。也就是说,点运算符左边值为
为了自动实现self的绑定,Python 区分函数和绑定方法。我们已经在这门课的开始创建了前者,而后者在方法调用时将对象和函数组合到一起。绑定方法的值已经将第一个函数关联到所调用的实例,当方法调用时实例会被命名为self。
通过在点运算符的返回值上调用type,我们可以在交互式解释器中看到它们的差异。作为类的属性,方法只是个函数,但是作为实例属性,它是绑定方法:
>>> type(Account.deposit)>>> type(tom_account.deposit)
这两个结果的唯一不同点是,前者是个标准的二元函数,带有参数self和amount。后者是一元方法,当方法被调用时,名称self自动绑定到了名为tom_account的对象上,而名称amount会被绑定到传递给方法的参数上。这两个值,无论函数值或绑定方法的值,都和相同的deposit函数体所关联。
我们可以以两种方式调用deposit:作为函数或作为绑定方法。在前者的例子中,我们必须为self参数显式提供实参。而对于后者,self参数已经自动绑定了。
>>> Account.deposit(tom_account, 1001) # The deposit function requires 2 arguments 1011 >>> tom_account.deposit(1000) # The deposit method takes 1 argument 2011
函数getattr的表现就像运算符那样:它的第一个参数是对象,而第二个参数(名称)是定义在类中的方法。之后,getattr返回绑定方法的值。另一方面,如果第一个参数是个类,getattr会直接返回属性值,它仅仅是个函数。
实践指南:命名惯例。类名称通常以首字母大写来编写(也叫作驼峰拼写法,因为名称中间的大写字母像驼峰)。方法名称遵循函数命名的惯例,使用以下划线分隔的小写字母。
有的时候,有些实例变量和方法的维护和对象的一致性相关,我们不想让用户看到或使用它们。它们并不是由类定义的一部分抽象,而是一部分实现。Python 的惯例规定,如果属性名称以下划线开始,它只能在方法或类中访问,而不能被类的用户访问。
2.5.4 类属性有些属性值在特定类的所有对象之间共享。这样的属性关联到类本身,而不是类的任何独立实例。例如,让我们假设银行以固定的利率对余额支付利息。这个利率可能会改变,但是它是在所有账户中共享的单一值。
类属性由class语句组中的赋值语句创建,位于任何方法定义之外。在更宽泛的开发者社群中,类属性也被叫做类变量或静态变量。下面的类语句以名称interest为Account创建了类属性。
>>> class Account(object): interest = 0.02 # A class attribute def __init__(self, account_holder): self.balance = 0 self.holder = account_holder # Additional methods would be defined here
这个属性仍旧可以通过类的任何实例来访问。
>>> tom_account = Account("Tom") >>> jim_account = Account("Jim") >>> tom_account.interest 0.02 >>> jim_account.interest 0.02
但是,对类属性的单一赋值语句会改变所有该类实例上的属性值。
>>> Account.interest = 0.04 >>> tom_account.interest 0.04 >>> jim_account.interest 0.04
属性名称。我们已经在我们的对象系统中引入了足够的复杂性,我们需要规定名称如何解析为特定的属性。毕竟,我们可以轻易拥有同名的类属性和实例属性。
像我们看到的那样,点运算符由表达式、点和名称组成:
.
为了求解点表达式:
求出点左边的
如果
这个值会被返回,如果它是个函数,则会返回绑定方法。
在这个求值过程中,实例属性在类的属性之前查找,就像局部名称具有高于全局的优先级。定义在类中的方法,在求值过程的第三步绑定到了点运算符的对象上。在类中查找名称的过程有额外的差异,在我们引入类继承的时候就会出现。
赋值。所有包含点运算符的赋值语句都会作用于右边的对象。如果对象是个实例,那么赋值就会设置实例属性。如果对象是个类,那么赋值会设置类属性。作为这条规则的结果,对对象属性的赋值不能影响类的属性。下面的例子展示了这个区别。
如果我们向账户实例的具名属性interest赋值,我们会创建属性的新实例,它和现有的类属性具有相同名称。
>>> jim_account.interest = 0.08
这个属性值会通过点运算符返回:
>>> jim_account.interest 0.08
但是,类属性interest会保持为原始值,它可以通过所有其他账户返回。
>>> tom_account.interest 0.04
类属性interest的改动会影响tom_account,但是jim_account的实例属性不受影响。
>>> Account.interest = 0.05 # changing the class attribute >>> tom_account.interest # changes instances without like-named instance attributes 0.05 >>> jim_account.interest # but the existing instance attribute is unaffected 0.082.5.5 继承
在使用 OOP 范式时,我们通常会发现,不同的抽象数据结构是相关的。特别是,我们发现相似的类在特化的程度上有区别。两个类可能拥有相似的属性,但是一个表示另一个的特殊情况。
例如,我们可能希望实现一个活期账户,它不同于标准的账户。活期账户对每笔取款都收取额外的 $1,并且具有较低的利率。这里,我们演示上述行为:
>>> ch = CheckingAccount("Tom") >>> 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
CheckingAccount是Account的特化。在 OOP 的术语中,通用的账户会作为CheckingAccount的基类,而CheckingAccount是Account的子类(术语“父类”和“超类”通常等同于“基类”,而“派生类”通常等同于“子类”)。
子类继承了基类的属性,但是可能覆盖特定属性,包括特定的方法。使用继承,我们只需要关注基类和子类之间有什么不同。任何我们在子类未指定的东西会自动假设和基类中相同。
继承也在对象隐喻中有重要作用,不仅仅是一种实用的组织方式。继承意味着在类之间表达“is-a”关系,它和“has-a”关系相反。活期账户是(is-a)一种特殊类型的账户,所以让CheckingAccount继承Account是继承的合理使用。另一方面,银行拥有(has-a)所管理的银行账户的列表,所以二者都不应继承另一个。反之,账户对象的列表应该自然地表现为银行账户的实例属性。
2.5.6 使用继承我们通过将基类放置到类名称后面的圆括号内来指定继承。首先,我们提供Account类的完整实现,也包含类和方法的文档字符串。
>>> class Account(object): """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)
这里,我们引入了类属性withdraw_charge,它特定于CheckingAccount类。我们将一个更低的值赋给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类中。最后,它会在Account中查找,这里是deposit定义的地方。根据我们对点运算符的求值规则,由于deposit是在checking实例的类中查找到的函数,点运算符求值为绑定方法。这个方法以参数10调用,这会以绑定到checking对象的self和绑定到10的amount调用deposit方法。
对象的类会始终保持不变。即使deposit方法在Account类中找到,deposit以绑定到CheckingAccount实例的self调用,而不是Account的实例。
译者注:CheckingAccount的实例也是Account的实例,这个说法是有问题的。
调用祖先。被覆盖的属性仍然可以通过类对象来访问。例如,我们可以通过以包含withdraw_charge的参数调用Account的withdraw方法,来实现CheckingAccount的withdraw方法。
要注意我们调用self.withdraw_charge而不是等价的CheckingAccount.withdraw_charge。前者的好处就是继承自CheckingAccount的类可能会覆盖支取费用。如果是这样的话,我们希望我们的withdraw实现使用新的值而不是旧的值。
2.5.7 多重继承Python 支持子类从多个基类继承属性的概念,这是一种叫做多重继承的语言特性。
假设我们从Account继承了SavingsAccount,每次存钱的时候向客户收取一笔小费用。
>>> 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 # 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
但是如果引用有歧义呢,比如withdraw方法的引用,它定义在Account和CheckingAccount中?下面的图展示了AsSeenOnTVAccount类的继承图。每个箭头都从子类指向基类。
对于像这样的简单“菱形”,Python 从左到右解析名称,之后向上。这个例子中,Python 按下列顺序检查名称,直到找到了具有该名称的属性:
AsSeenOnTVAccount, CheckingAccount, SavingsAccount, Account, object
继承顺序的问题没有正确的解法,因为我们可能会给某个派生类高于其他类的优先级。但是,任何支持多重继承的编程语言必须始终选择同一个顺序,便于语言的用户预测程序的行为。
扩展阅读。Python 使用一种叫做 C3 Method Resolution Ordering 的递归算法来解析名称。任何类的方法解析顺序都使用所有类上的mro方法来查询。
>>> [c.__name__ for c in AsSeenOnTVAccount.mro()] ["AsSeenOnTVAccount", "CheckingAccount", "SavingsAccount", "Account", "object"]
这个用于查询方法解析顺序的算法并不是这门课的主题,但是 Python 的原作者使用一篇原文章的引用来描述它。
2.5.8 对象的作用Python 对象系统为使数据抽象和消息传递更加便捷和灵活而设计。类、方法、继承和点运算符的特化语法都可以让我们在程序中形成对象隐喻,它能够提升我们组织大型程序的能力。
特别是,我们希望我们的对象系统在不同层面上促进关注分离。每个程序中的对象都封装和管理程序状态的一部分,每个类语句都定义了一些函数,它们实现了程序总体逻辑的一部分。抽象界限强制了大型程序不同层面之间的边界。
面向对象编程适合于对系统建模,这些系统拥有相互分离并交互的部分。例如,不同用户在社交网络中互动,不同角色在游戏中互动,以及不同图形在物理模拟中互动。在表现这种系统的时候,程序中的对象通常自然地映射为被建模系统中的对象,类用于表现它们的类型和关系。
另一方面,类可能不会提供用于实现特定的抽象的最佳机制。函数式抽象提供了更加自然的隐喻,用于表现输入和输出的关系。一个人不应该强迫自己把程序中的每个细微的逻辑都塞到类里面,尤其是当定义独立函数来操作数据变得十分自然的时候。函数也强制了关注分离。
类似 Python 的多范式语言允许程序员为合适的问题匹配合适的范式。为了简化程序,或使程序模块化,确定何时引入新的类,而不是新的函数,是软件工程中的重要设计技巧,这需要仔细关注。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/38139.html
摘要:对象表示信息,但是同时和它们所表示的抽象概念行为一致。通过绑定行为和信息,对象提供了可靠独立的日期抽象。名称来源于实数在中表示的方式浮点表示。另一方面,对象可以表示很大范围内的分数,但是不能表示所有有理数。 2.1 引言 来源:2.1 Introduction 译者:飞龙 协议:CC BY-NC-SA 4.0 在第一章中,我们专注于计算过程,以及程序设计中函数的作用。我们看到了...
摘要:以这种方式实现对象系统的目的是展示使用对象隐喻并不需要特殊的编程语言。我们的实现并不遵循类型系统的明确规定。反之,它为实现对象隐喻的核心功能而设计。是分发字典,它响应消息和。 2.6 实现类和对象 来源:2.6 Implementing Classes and Objects 译者:飞龙 协议:CC BY-NC-SA 4.0 在使用面向对象编程范式时,我们使用对象隐喻来指导程序...
摘要:为通用语言设计解释器的想法可能令人畏惧。但是,典型的解释器拥有简洁的通用结构两个可变的递归函数,第一个求解环境中的表达式,第二个在参数上调用函数。这一章接下来的两节专注于递归函数和数据结构,它们是理解解释器设计的基础。 3.1 引言 来源:3.1 Introduction 译者:飞龙 协议:CC BY-NC-SA 4.0 第一章和第二章描述了编程的两个基本元素:数据和函数之间的...
摘要:我们是一个大型开源社区,旗下群共余人,数量超过个,网站日超过,拥有博客专家和简书程序员优秀作者认证。我们组织公益性的翻译活动学习活动和比赛组队活动,并和等国内著名开源组织保持良好的合作关系。 Special Sponsors showImg(https://segmentfault.com/img/remote/1460000018907426?w=1760&h=200); 我们是一个...
阅读 1493·2021-09-22 15:35
阅读 1970·2021-09-14 18:04
阅读 843·2019-08-30 15:55
阅读 2426·2019-08-30 15:53
阅读 2640·2019-08-30 12:45
阅读 1160·2019-08-29 17:01
阅读 2548·2019-08-29 15:30
阅读 3461·2019-08-29 15:09