本节是第五讲的第十三小节,上节为大家介绍了标准库一些常用模块,本节将为大家介绍Python语言的面向对象程序设计。
面向对象程序设计(Object-Oriented Programming)
在前面所有章节中,我们广泛地使用了对象这一概念,但实际上釆用的程序设计风格是过程型程序设计。Python是一种多范型语言——这种语言没有强制程序员使用某种特定的程序设计风格,而是允许程序员釆用过程型、函数型或面向对象的程序设计风格,也可以是这些编程风格的有效组合。采用过程型程序设计风格编写任何程序都是完全可能的,对代码规模非常小的程 序(比如,最多500行),也很少会造成什么问题。但是,对大多数程序而言,尤其对中等规模或大规模的程序,釆用面向对象的程序设计风格提供了很多优势。
面向对象方法(The Object-Oriented Approach)
本节将基于使用程序对圆(也可能是对大量的圆)进行描述这一问题,揭示纯过程型程序设计方法存在的问题。用于描述一个圆所需要的最少数据包括圆心坐标(X, y)以及圆的半径,简单的方法是使用一个三元组对圆进行描述,比如:
circle = (11,60, 8)
这种描述方法存在的一个不足在于:三元组中的每个元素代表的含义不够明显,, 可以理解为(x,y, radius),但也可以理解为(radius, x, y),从而带来二义性;另一个不足在于,只能通过元素的索引位置对其进行存取。如果有两个函数distance_from_ origin(x, y)与 edge_distance_from_origin(x, y, radius).那么在使用元组 circle 作为参数 调用时,就需要进行元组拆分:
distance = distance_from_origin(*circle[:2]) distance = edge_distance_from_origin(*cirde)
上面的两个语句都假定三元组circle的表示形式为(x, y, radius)。通过使用指定的元组,可以解决获知元素顺序的问题以及使用元组拆分的问题:
import collections
Circle = collections.namedtuple("Cirde","x y radius")
circle = Circle(13, 84, 9)
distance = distance_from_origin(circle.x, circle.y)
通过这种方法,可以创建三元组Circle,其中包含指定的属性,使得函数调用更容易理解,这是因为只能通过属性名来对这些元素(属性)进行存取。遗憾的是,问题仍然存在。比如,这种方法无法阻止创建一个无效的圆:
circle = Cirde(33, 56, -5)
圆半径为负值没有任何意义,但上面的语句却创建了这样一个圆,并且没有任何异常提示,就好像圆半径本来就可以为负值一样。只有在调用edge_distance_from_ origin(x, y, radius)函数——并且该函数对圆半径为负值进行实际检査时,这种程序设计错误才会暴露出来。创建对象时无法对其进行有效性验证,这或许是纯过程型程序设计方法最大的弊端所在。
如果希望创建的圆是可变的,以便对其进行移动(通过改变其圆心坐标)或改变其大小(通过改变圆半径),那么可以通过collections.namedtuple._replace方法实现这 一目的:
circle =circle._replace(radius=12)
就像我们创建Circle一样,这里也没有什么机制阻止(或警告)我们设置无效数据。 如果圆需要大量的改变,为方便起见,我们可能更愿意使用可变的数据类型,比如列表:
circle = [36, 77, 8]
这种方式仍然没有提供任何阻止使用无效数据的机制,我们所能提供的根据名称对元素进行存取的方法就是创建一些常量,并使用类似于circle[RADIUS] = 5的语句。 但使用列表会带来一些附加的问题——比如,我们可以合法地调用circle.sort()!使用字典也是一种替代方法,比如,circle = dict(x=36, y=77, radius=8),但仍然无法防止使用无效的半径值,也无法防止调用不适当的方法。
面向对象的概念与术语(Object-Oriented Concepts and Terminology)
对上面的实例,我们需要做的是将表示圆必需的数据进行打包,并对可应用于该数据的方法进行限制,以便只能进行有效的操作。这两个要求都可以通过创建一个自 定义的Circle数据类型实现。本节的后面部分,我们将了解如何创建自定义的Circle 数据类型,这里我们首先介绍一些基础知识,并对一些术语进行解释。如果一开始对这些术语感到陌生,不必担心,在讲述具体实例时会更加清晰。
我们可替换地使用术语类、类型与数据类型。在Python中,我们可以创建完全整合到语言中的自定义类,并可以像使用内置数据类型一样使用。我们已经讲过很多类, 比如dict, int与str。我们使用术语对象,偶尔也使用术语实例,来指代特定类的一个实例。比如,5是一个int对象,“oblong”是一个str对象。
大多数类都封装了数据以及可用于该数据的方法。比如,str类存放的数据是 Unicode字符构成的字符串,并支持str.upper()等方法。很多类支持一些附加的功能, 比如,我们可以使用操作符 对两个字符串(或两个任意序列)进行连接操作,也可以使用内置的len()函数计算序列的长度。这些功能是由特殊方法实现的——这些方法与通常的方法类似,不同之处在于函数名的起始处与结尾处总是使用两个下划线,并且是预定义的。比如,需要创建一个类,该类支持使用操作符 进行连接操作,支持 len()函数,我们可以通过在类中实现__add__()与__len__()这两个特殊方法达到这一目标。相反地,我们绝不应该在定义任何方法时使用起始处与结尾处都是两个下划线的方法名,除非该方法是我们预定义的特殊方法,并适用于我们的类。这种约束可以确保不会和Python的后续版本冲突,即便引入了新的预定义特殊方法。
对象通常包含属性——方法是可调用的属性,其他属性则是数据。比如,complex 对象包含imag属性与real属性以及大量的方法,包括__add__()与__sub__(支持二元 、-操作符),以及conjugate()等常见方法。数据属性(通常也简单地引用为“属性”) 通常简单地实现为其例变量,也就是说,对某个特定对象而言独一无二的变量。我们将了解这方面的例子,以及如何将数据属性提供为特性,特性是对象数据的一个项, 可以像实例变量一样进行存取,但此时的存取是由方法进行处理的。我们还将看到, 使用特性使数据验证变得更加容易。
在方法(方法其实也是一个函数,其第一个参数是调用该方法的实例本身)内部, 有几种变量可以进行存取。对象的实例变量可以通过指定其名称以及实例自身来进行存取,局部变量可以在方法内部创建,存取时不需要限定。类变量(有时也称为静态变量)可以通过指定其名称与类名进行存取,全局变量(也即模块变量)可以不需要限定进行存取。
有些Python文献使用名空间这一概念,名空间实质上是名到对象的映射。模块是名空间——比如,在语句import math之后,通过对对象名与名空间名进行指定,我们可以存取math模块中的对象(比如,math.pi and math.sin())。类似地,类与对象也是名空间,比如,如果z = complex(1, 2),那么对象z有两个属性可以进行存取(z.real 与 z.imag)。
面向对象的优势之一是如果我们有一个类,就可以对其进行专用化,这意味着创建一个新类,新类继承原始类的所有属性(数据与方法),通常可以添加或替换原始类中的某些方法,或添加更多的实例变量。我们可以对任何Python类进行子类化(另一个用于对类进行专用化的术语),而不管这个Python类是内置的、来自标准库的,或者是 我们自己的自定义类 。进行子类化的能力是面向对象程序设计提供的巨大优势之一, 因为通过这种方法,可以直接使用现有类(其中包含经过测试的大量功能)作为新类的基础,并根据需要对原始类进行扩充,以非常干净而直接的方式添加新的数据属性与功能,并且可以将新类的对象传递给为原始类而写的函数与方法,并能正确工作。
我们使用术语基类来指定那些被继承的类,基类可以是直接的父类,也可以位于继承树中向上回溯较多的位置。另一个用于基类的术语是超级类。我们使用术语子类、 衍生类、衍生来描述某个类是从其他类继承(也即专用化)的情况。在Python中,每个内置的类、库类以及我们创建的每个类都直接或间接地从最顶层的基类——object 衍生而来。如图勾勒了继承树体系以及一些相关术语。
在子类中,任何方法都可能被重写,也就是说重新实现,这一点与Java (除了其"final”方法之外)是相同的。如果有一个MyDict (继承自dict的类)类的对象,并调用同时由dict与MyDict定义的方法,Python将正确地调用MyDict版本的方法--这也就是所谓的动态方法绑定,也称为多态性。如果我们在重新实现后的方法内部调用该方法的基类版本,就可以使用内置的super()函数实现。
Python还支持duck typing---"如果走起来像只鸭子,叫起来也像只鸭子,那它就是一只鸭子”。换句话说,如果我们在某个对象上调用某种方法,就不必管该对象属于哪一个类,只要其上存在我们需要调用的方法即可。在前一章中,我们看到,在需要一个文件对象时,我们可以通过调用内置的open()函数来提供一个文件对象——或创建并提供一个io.StringlO对象,因为io.StringlO对象有相同的API (应用程序编程接口),也就是说,包含与open()函数(以文本模式)返回的文件对象一样的方法。
继承用于模型化is-a关系,也就是说,某个类的对象本质上与其他类的对象是相同的,但有一些变化,比如额外的数据属性或额外的方法。另一种方法是使用聚集(也称为合成)—系指某个类中包含来自其他类的一个或多个实例变量。聚集用于模型化 has-a关系。在Python中,每个类都使用了继承——因为所有自定义类都将object作为其最终基类,大多数类还使用了聚集,因为大多数类都包含了不同类型的实例变量。
有些面向对象语言提供了Python没有提供的两个功能。第一个是重载,也就是说,在同一个类内,使得方法有同样的名称,但有不同的参数列表。由于Python具有非常丰富的参数处理功能,因此,这并不会成为限制因素。第二个是访问控制——实际上并不存在强制数据隐私的“防弹”机制,不过,如果我不创建属性(实例变量或方法) 时走属性名前以两个下划线引导;Python就会阻止无心地访问,因此也可以认为是私有的。(这是通过名称操纵实现的,第8章中我们将看到一个实例。)
就像我们可以使用大写字母作为自定义模块的首字母一样,对自定义类也可以这样做。我们可以根据实际需要定义多个类,或者直接在程序中,或者在模块中一类名并不必须与模块名匹配,模块中也可以包含我们需要数量的类个数。
自定义类(Custom Classes)
前面的章节中,我们创建了自定义类:自定义异常。下面给出的是用于创建自定义类的两种新语法:
class className:
suite
class className(base_classes):
suite
由于我们创建的异常子类没有添加任何新属性(没有实例数据或方法),我们使用 pass (也即不做操作)作为其suite,由于该suite仅一行语句,我们将其与class语句本身放置在同一行。注意的是,与def语句类似,class也是一条语句,因此,我们可以根据需要动态地创建类。类的方法是使用def语句在类的suite中创建的,类实例则是使用必要的参数对类进行调用来创建的,比如,x = complex(4, 8)会创建一个复数, 并将x设置为对该复数的对象引用。
属性与方法(Attributes and Methods)
我们首先从一个非常简单的类Point开始,该类存放坐标(x, y),定义于文件 Shape.py中,其完整实现(docstrings除外)如下:
class Point:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
def distance_from_origin(self):
return math.hypot(self.x, self.y)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def _repr_(self):
return "Point({0.x!r}, {0.y!r})".format(self)
def _str_(self):
return "({0.x!r}, {0.y!r})".format(self)
由于没有指定基类,因此Point是object的直接子类,就像我们写成Point(object) 一样。在对每个方法进行讨论之前,我们先看几个使用实例:
import Shape
a = Shape.Point()
repr(a) # returns: 'Point(0,0)'
b = Shape.Point(3, 4)
str(b) #returns: '(3,4)'
b.distance_from_origin() #returns: 5.0
b.x = -19
str(b) #returns: '(-19,4)'
a == b, a != b #returns: (False, True)
Point类有两个数据属性,self.x与self.y,还包含5个方法(这里不包括继承来的方法),其中4个属于特殊方法,这些方法都在下面的图中展示。
导入Shape模块后,Point类就可以像其他类一样进行使用,其数据属性可以直接存取(比如y = a.y), Point类与Python所有其他类进行了完美的整合,这是通过支持等号操作符(==)以及以表象形式或字符串形式生成字符串来实现的。Python也完全可以根据等号操作符来提供不等于操作符(!=)(如果我们需要施加完全的控制,比如操作符彼此不是这种恰好相反的含义,那么单独指定每个操作符也是可以的)。
在对方法进行调用时,Python会自动提供第一个参数——这个参数是对对象自身的对象引用(在C 与Java中称为this)。我们必须在参数列表中包含这一参数,根据约定,这一参数称为self。所有的对象属性(数据以及方法属性)都必须由self进行限定。与其他语言相比,Python的这一要求需要多一些键盘输入,但其优势在于提供了绝对的清晰:如果我们使用self进行了限定,我们就总是知道存取的是对象属性。
要创建一个对象,需要两个必需的步骤。首先要创建一个原始的或未初始化的对象,之后必须对该对象进行初始化,以备使用。有些面向对象语言(比如C 或Java) 将这两个步骤结合在一起,Python则将其作为两个单独的步骤。在创建对象时(比如 p = Shape.Point()),首先调用特殊方法_new_()来创建该对象,之后调用特殊方法 _init_()对其进行初始化。
在实际的编程中,我们创建的几乎所有Python类都只需要重新实现_init_()方 法,因为如果我们不提供自己的_new_()方法,Python就会自动调用object._new_() 方法,并且这一方法也已足够。(本章后面部分我们将展示一个不常见的实例,其中我们确实需要重新实现_new_()方法。)不是必须要在子类中对方法进行重新实现,这是面向对象程序设计的另一个好处——如果基类方法已足够,那么我们可以不在子类中对其进行重新实现。这种方式之所以有效,是因为如果我们对某对象调用了一个方法,而该对象所在类没有实现该方法,Python就会自动地在类树中进行回溯,直到找到该方法如果一直无法找到该方法,就会产生一个AttributeError异常。
比如,如果执行语句p = Shape.Point(),那么Python会从査找Point. __new__()方法开始。由于我们没有重新实现这一方法,因此Python会在Point的基类中搜索这一 方法。这种情况下,只有一个基类object,并且包含该方法,因此,Python将调用object. __new__()并创建一个原始的、未初始化的对象。之后,Python继续搜索初始化程序 _init_(),由于我们对其进行了重新实现,因此Python不再需要向上回溯,并直接调用Point.__init__(),最后,Python将p设置为到新近创建并已初始化的对象类型Point 的对象引用。
这些方法所需代码都很少,并且使用和定义之间有一定距离,为方便起见,我们在具体讨论某个方法之前都给出了其具体实现:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
两个实例变量(self.x与self.y)都是在初始化程序中创建的,并为其赋值为参数 x与y。在创建新Point对象时,Python可以找到这一初始化程序,因此不再调用object.__init__()方法。这是因为,在找到要调用的方法后,Python就不再继续向上回溯。
面向对象的信徒可能会从对基类_init__()方法的调用开始(调用super()._ init_()),以这种方式调super()函数的效果是调用基类的__init__()方法。对直接继承自object的类,没有必要这样处理,我们只有在必需的时候才调用基类的方法——比如,在创建那些设计为子类化的类时,或创建那些不是直接继承自object的类。在某种程度上,也可以将其理解为一种编码风格——在自定义类的_init_()方 法的起始处,总是调用super().__init__()是一种合理的做法。
def distance_from_origin(self):
return math.hypot(self.x, self.y)
这是一个常规的方法,该方法基于对象的实例变量进行计算。方法相当短并仅以调用其对象作为参数是很常见的,因为通常方法需要的所有数据在对象内部都是可用的。
def _eq_(self, other):
return self.x == other.x and self.y == other.y
方法名的起始和结尾不应该使用两个下划线——除非是预定义的特殊方法。对所 有比较操作符,Python都提供了特殊方法,如下所示。
特殊方法 使用 描述
__It__ (self,other) x < y 如果x比y小,则返回true
__le__(self,other) x <= y 如果x小于或等于y,则返回true
__eg__(self,other) x = y 如果x与y相等,则返回TRue
__ne__(self,other) x != y 如果x与y不相等,则返回TRue
__ge__(seif,other) x>=y 如果x大于或等于y,则返回true
__gt__(self,other) x > y 如果x大于y,则返回true
默认情况下,自定义类的所有实例都支持==,这种比较总是返回False——通过对特殊方法__eq__()进行重新实现,可以改变这一行为。如果我们实现了__eq__()但没有实现__ne__(), Python会自动提供__ne__。(不等于)以及不等于操作符(!=)。
默认情况下,自定义类的所有实例都是可哈希运算的,因此,可对其调用hash(), 也可以将其作为字典的键,或存储在集合中。但是如果我们重新实现了__eq__(),实例就不再是可哈希运算的,后面讨论FuzzyBool类时,我们将了解如何解决这一问题。
通过实现这一特殊方法,我们可以对Point对象进行比较,但是如果我们试图将Point对象与其他类型对象进行比较,比如int;就会产生AttributeError异常(因为intS 不包括x属性)。另一方面,我们又可以将Point对象与那些恰好也包含x属性的对象进行比较(这归功于Python的duck typing),但可能会产生奇怪的结果。
如果我们希望避免不适当的比较,有几种方法可以釆用。第一种是使用断言,比 如,assert isinstance(other, Point)。第二种方法是产生TypeError异常,声明不支持这两个对象的比较操作,比如,if not isinstance(other, Point): raise TypeError()。第三种方法 (这种方法也可能是最Python 化的)如下:if not isinstance(other, Point): return Notlmplemented。对第三种情况,如果返回了 Notlmplemented, Python就会尝试调用 other.__eq__(self)来查看other类型是否支持与Point类型的比较,如果也没有类似的方法,或也返回Notlmplemented, Python将放弃搜索,并产生TypeError异常。(注意, 只有对表中列出的比较特殊方法进行了重新实现,才可能返回Notlmplemented。)
内置的isinstance()函数以一个对象与一个类(或类构成的元组)为参数,如果该对象属于给定的类(或类元组中的某个类),或属于给定的类(或类元组中的某个类) 的基类,就返回True。
def _repr_(self):
return "Point({0.x!r}, {0.y!r})".format(self)
内置的repr()函数会对给定的对象调用__repr__()特殊方法,并返回相应结果。结果字符串有两种类型,一种可以使用内置的eval()函数进行评估,并生成一个与repr() 调用对象等价的对象,如果不能这样做就返回另一种字符串。
下面给出的实例展示了如何在Point对象与字符串之间进行变换:
p = Shape.Point(3, 9)
repr(p) # returns: 'Point(3, 9)'
q = eval(p.__module__ "." repr(p))
repr(q) # returns: 'Point(3, 9)'
如果使用import Shape,那么在使用eval()进行评估时,我们必须给出模块名。(如果使用其他的导入语句,比如from Shape import Point,就不需要这样做。)Python为每个对象提供了一些私有属性,__module__就是其中的一个,__module__是用于存放对象的模块名(这里是“Shape”)的一个字符串。在上面的代码段结束时,我们有两个Point对象,即p与q,二者包含相同的属性值,因此对其进行比较时是相等的。
def __str__(self):
return "({0.x!r}, {0.y!r})”.format(self)
内置的str()函数与repr()函数的工作机制类似,不同之处在于其调用的是对象的 __str__()特殊方法,产生的结果也主要是为了便于理解,而不是为了传递给eval()函数。 继续前面的实例,str(p)或str(q)将返回字符串'(3,9)'。
上面我们介绍了一个简单的Point类——也介绍了 Python的一些后台处理细节,这些细节对我们理解面向对象程序设计是重要的。Point类存放的是坐标(x, y)——用于表示圆的基础部分,在本章开始已经进行过一些讨论。在下一小节中,我们将学习如何创建自定义的Circle类,该类继承自Point,因此,我们不需要对x属性、y属性 或distance_from_origin()方法的代码进行重复编写。
以上内容部分摘自视频课程05后端编程Python-13面向对象(上),更多实操示例请参照视频讲解。跟着张员外讲编程,学习更轻松,不花钱还能学习真本领。