系统学习Python——类(class)与面向对象(Object-Oriented Programing, OOP)的基础知识

分类目录:《系统学习Python》总目录


其实,一直以来我们Python所编写的代码都是基于对象的。我们在脚本中传递对象、在表达式中使用对象和调用对象的方法等。不过,要让代码真正称得上是面向对象的(Object-Oriented, OO),那么对象一般需要参与到所谓的继承层次中。

类是在Python中实现一种新的对象,并支持继承的代码结构和部件。类是Python面向对象程序设计(Object-OrientedPrograming, OOP)的主要工具,因此在后续的文章中我们将会顺便讨论OOP的基础内容。OOP提供了一种不同寻常而往往更有效的编程方式,利用这种设计方法,我们将代码分解从而把元余程度降至最低,并且通过定制已有的代码来编写新的程序而不是在原处进行修改。

在Python中,类的创建使用了一条新的语句:class语句。正如你将看到的那样,通过class定义的对象,看起来很像我们之前研究过的内置类型。事实上,类只是运用并扩展了我们之前提到过的一些思想。概括地讲,类就是一些函数的包,这些函数大量地使用并处理内置对象类型。不过,类的设计是为了创建和管理新的对象,同时它们也支持继承,而继承是一种代码定制和复用的机制。

首先要提一句:在Python中,OOP完全是可选的,并且在初学阶段不需要使用类。实际上,即使利用函数这样较简单的结构或是简单顶层脚本代码,也可以完成很多事情。由于妥善使用类需要一些预先的规划,因此相比于那些采用快速开发上线模式工作的人,做长期产品开发的人对类会更感兴趣一些。

总之,类是Python所提供的最有用的工具之一。如果使用得当,类可以极大地减少开发时间。类也被广泛应用于流行Python工具中。所以大多数Python科程序员往往会发现,学习类的基础知识是很有帮助的。

简而言之,类就是一种定义新种类的东西的方式,它在程序领域中反映了现实中的对象。例如,假设现在要实现虚构的比萨制作机器人。如果通过类来实现,就可以建立其真实世界中结构和关系的模型。从以下的两个方面来看OOP非常有用:

  • 继承:比萨制作机器人本质上是一种机器人,它拥有一般机器人的属性。用OOP术语来讲,制作比萨的机器人继承了所有机器人的通用类型的属性。对于这些通用的属性只需将通用的代码实现一次,就能让未来我们所创建的所有种类的机器人都可以重用。
  • 组合:比萨制作机器人其实是一些组件的集合,这些组件作为一个整体共同工作。例如,为了让机器人成功运行,也许会需要滚面团的机械臂和启动烤箱的马达等。用OOP的术语来讲,机器人是一个组合的实例,它包含其他对象,这些对象来完成相应的指令。每个组件都可以写成类,并定义自己的行为以及关系。

像继承和组合这样的通用OOP概念,适用于能够分解成一系列对象的任何应用程序。例如,在典型的GUI系统中,用户界面被编写成图形组件的集合(按钮、标签等),当开始绘制图形组件的容器时,图形组件也会跟着绘制(组合)。此外,我们可以编写定制的图形组件(例如有独特字体的按钮、有新的配色的标签等),这些都是更通用的用户界面组件的特定化版本(继承)。

从更具体的编程角度来看,类是Python程序的组成单元,就像函数和模块一样:类是封装逻辑和数据的另一种方式。实际上,类也定义了新的命名空间,这点和模块很像。但是,和其他我们已见过的程序组成单元相比,类有三个重要的独特之处,使其在建立新对象时更为有用:

  • 多重实例:类本质上是产生对象的工厂。每当我们调用一个类的时候,就会产生一个有独立命名空间的新对象。每个由类产生的对象都能读取类的属性,并获得自己的命名空间来储存数据,这些数据是属于每个对象本身的。这和前面文章中介绍的闭包函数能在不同的调用之间保存状态非常相似,但在类中更加显式和自然,而且这只是类的一个功能类提供了一套完整的编程解决方案。
  • 通过继承进行定制:类也支持OOP的继承的概念。我们可以在类的外部以编写子类的方式,来重新定义其属性进而扩充这个类。更一般地来说,类可以建立命名空间的层次结构,而这种层次结构可以定义该结构中类创建的对象所使用的名称。这样,我们可以比其他工具更直接地支持多重可定制化的行为。
  • 运算符重载:通过提供特定的协议方法,类可以定义对象来响应在内置类型上的一些算。例如,通过类创建的对象可以进行切片、拼接和索引等运算。Python提供了一些可以由类使用的钩子,从而能够拦截并实现任何的内置类型运算。

从本质上讲,Python中的OOP机制主要依托于两个基础:一个特殊的函数第一位参数(来接收调用主体)以及继承属性搜索(使编码支持定制化)。除此之外,这个模型基本上就是处理内置类型的函数。虽然不是全新的一套体系,但OOP相较于扁平的面向过程编程模型增加了额外的层次结构,从而成为了一个更好的模型。OOP和我们之前见过的函数式工具一样,代表着一种在计算机硬件上进行抽象的步骤,以协助我们编写日益复杂精细的程序。

属性继承搜索

值得庆幸的是,比起C++或Java等其他语言,Python中OOP的理解和使用都很简单。作为一门动态类型脚本语言,Python把其他语言中那些使OOP隐酶的语法杂质和复杂性都去掉了。实际上,Python中大多数OOP的故事,都可简化成这个表达式:object.attribute

我们一直使用这个表达式读取模块的属性,调用对象的方法等。然而,当我们对class语句产生的对象使用这种方式时,这个表达式会在Python中启动一次搜索,即搜索对象连接的类树,来寻找attribute首次出现的类。当类参与其中时,上面的Python表达式实际上等同于下列自然语言:

找出attribute首次出现的地方,先搜索object,然后是该对象之上的所有类,由下往上,由左到右。

换句话来说,属性访问就只是搜索类树而已。我们称这种搜索为继承,因为树中位置较低的对象继承了树中位置较高的对象所拥有的属性。当从下至上进行搜索时,连接至树中的对象就是树中所有父节点定义的所有属性的并集,直到树的根部。

在Python中可以很直接地理解:我们通过代码建立连接对象树,而每次使用object.attribute表达式时,Python确实会在运行期间去“爬树”,来搜索属性。为了更具体的说明,图26-1是这种树的一个例子。
类树
上图的类树底端有两个实例l1l2,在它们的上面有一个类C1,而顶端有两个父类C2C3。所有这些对象都是命名空间(即变量的封装),而继承搜索就是由下至上搜索该树,来寻找属性名称所出现的最低的地方。代码中隐含了这种树的形状。

在上图中展示了一棵包含了五个对象的类树,其中的每个对象都附带着待搜索的属性。更确切地讲,该树把三个类的对象(C1C2C3)和两个实例对象(I1I2)连接至继承搜索树。注意:在Python对象模型中,类和通过类产生的实例是两种不同的对象类型:

  • 类:类是实例工厂。类的属性提供了行为(数据以及函数),所有从类产生的实例都继承该类的属性。
  • 实例:代表程序领域中具体的元素。实例的属性记录了每个实例自己的数据。

就搜索树来看,实例从它的类继承属性,而类则是从搜索树中所有比它更高的类中继承属性。在上图中,我们可以按照圆形在树中的相对位置再进一步分类。我们通常把树中位置较高的类称为父类(superclass),如:C2C3。树中位置较低的类则称为子类(subclass)这些术语表明了树中的相对位置和角色。父类提供了所有子类共享的行为,但是因为搜索过程是自底向上的,所以子类可能会在树中较低位置重新定义父类的名称,从而覆盖父类定义的行为。

最后几句话其实就是OOP软件定制的关键之处,让我们扩展这一概念。假设我们创建了上图的树,然后写了I2.w。这个代码会立即启用继承。因为这是一个object.attribute表达式,所以它会触发上图中对树的搜索,也就是说Python会查看I2和更高的对象来搜索属性w。更确切地讲,就是按照下面这个顺序搜索连接的对象:I2C1C2C3。找到首个w之后就会停止搜索(但如果找不到w,就引发一个错误)。此例中,直到搜索C3时才会找到w,因为w只出现在了该对象内。也就是说,通过自动搜索,I2.w会解析为C3.w.。用OOP术语来说,I2C3“继承”了属性w

最终,这两个实例从对应的类中继承了四个属性:wxyz。不同属性的引用则会循着树中不同的路径进行,如下所示:

  • I1.xI2.x两者都会在C1中找到x并停止搜索,因为C1C2位置更低。
  • I1.yI2.y两者都会在C1中找到y,因为这里是y唯一出现的地方。
  • I1.zI2.z两者都会在C2中找到z,因为C2C3更靠左侧。
  • I2.name会找到I2中的name,完全不需要“爬树”。

你可以配合上图中的树来追湖搜索的路径,从而体会Python中继承搜索的工作方式。上面列出的第一项也许是最需要注意的:因为C1在树中较低的地方重新定义了属性x,相当于实际上取代了其上C2中的版本。你马上就会知道,这样的重新定义就是OOP中软件定制的核心。通过重新定义和取代属性,C1有效地定制了它从父类中所继承的属性。

类和实例

虽然在Python模型中,类和实例是两种不同的对象类型,但在类树中看它们几乎是完全等价的:两者的主要目的都是作为另一种命名空间(即变量的封装,也就是我们可以附加属性的地方)。因此,如果类和实例听起来像模块,那也应该是如此。然而,类树中的对象类和实例的主要差异在于,类是一种产生实例的工厂。例如,在实际应用中,我们可能会有一个Employee类用于定义所谓的员工。通过这个类,我们可以产生实际的Employee实例。类和模块还有另一个差异:内存中一个特定模块只有一个实例(所以我们必须重载模块以取得其新代码),但是对类而言,只要有需要创建多少实例都可以。

从操作的角度来说,类通常都有函数,而实例有其他基本的数据项,类的函数中使用了这些数据。事实上,面向对象模型与经典的过程加记录的数据处理模型相比,并没有太多的差异。在OOP中,实例就像是带有“数据”的记录,而类则是处理这些记录的“程序”。不过OOP中还有继承层次的概念,这与以往的模型相比能更好地支持软件定制。

方法调用

在上文中,我们通过例子中的类树学习了属性的引用I2.w是怎样通过Python中的继承搜索转换为C3.w的。而对于调用方法(也就是附属于类的函数属性)的理解,也是同样重要的。
如果这个I2.w引用是一个函数调用,其实际的含义是“调用C3.w函数来处理I2”。也就是说,Python将会自动将I2.w()调用映射为C3.w(I2)调用,同时传入该实例作为继承的函数的第一位参数。

事实上,每当我们以这种方式调用附属于类的函数时,总会隐含着这个类的实例。这个隐含的主体或上下文就是将其称之为面向对象模型的一部分原因:当操作执行时,总是有个类的属性)。

就像之后我们会看到的那样,Python把隐含的实例传入方法中特殊的第一位参数,习惯上将其称为self。方法通过这个参数来处理调用的主体。稍后我们会介绍,方法能通过实例(例如:bob.giveRaise())或类(例如:Employee.giveRaise(bob))进行调用,而两种形式在我们的脚本中都有各自的用途。这些调用也展示了OOP中的两个思想:Python在运行bob.giveRaise()方法调用时做了两件事:

  1. bob中通过继承搜索寻找giveRaise
  2. bob传入找到的giveRaise函数,并赋值给self参数。

当你调用Employee.giveRaise(bob)时,你只是手动完成了这两个步骤。上面所述的写法从技术上讲只是默认的情况(我们后面会看到,Python还有其他的方法类型),不过它却适用于大多数Python中绝大多数的OOP代码。想要更好地了解方法是如何接收其主体的,我们需要写些代码。

编写类树

虽然这里的描述很抽象,但这些概念都对应着具体的代码。我们用class语句和类调用来构造树和其中的对象,这些内容稍后我们会详细介绍。简而言之:

  • 每个class语句会生成一个新的类对象
  • 每次类调用时,就会生成一个新的实例对象
  • 实例自动链接到创建它们的类
  • 类链接到其父类的方式是,将父类列在class头部的括号内,括号中从左至右的顺序会决定树中的次序。

例如,要建立上图的树,我们可以运行下面这种形式的Pytho代码。与函数定义一样,类通常被编写在模块文件中并在导入时被运行(出于简洁性,这里省略了class语句中的内容):

class C2: ...    
class C3: ...
class C1(C2, C3): ...

I1 = C1()
I2 = C1()

这里,我们通过运行三个class语句创建了三个类对象,然后通过把类C1当作函数调用了两次,来创建了两个实例对象。这些实例记住了它们来自哪个类,而类C1也记住了它所列出的父类。

从技术上讲,这个例子使用了所谓的多继承。也就是类树中的某个类有多于一个的父类。
多继承是一个能把多个工具组合在一起的有用工具。在Python中,如果class语句中的小括号内有一个以上的父类(像这里的C1),它们由左至右的次序会决定父类继承搜索的顺序。一个名称列在最左边的版本会被默认使用,不过你也可以显式地指明所在的类来选择一个名称(例如C3.z)。

因为继承搜索以这种方式进行,你要把属性附加哪个对象就变得非常重要:这个选择决定了属性名称的作用域。附加在实例上的属性只属于那些实例,但附加在类上的属性则由所有子类和实例共享。稍后,我们将会深入学习把属性增加在这些对象上的代码。我们将会发现:

  • 属性通常是在class语句的顶层语句块中通过赋值语句添加到类中,而不是嵌入在内部的函数def语句中。
  • 属性通常是通过对特殊的称为self的第一位参数的赋值,来附加给实例的,而这个self参数也被传入类中编写的方法函数。

例如,类通过方法函数(在class语句内用def语句编写而成)为实例提供行为。因为这样的嵌套def会在类中对名称进行赋值,实际效果就是把属性添加在了类对象之中,从而可以由所有实例和子类继承:

class C2: ...
class C3: ...
class C1(C2, C3):
	def setname(self, who):
		self.name=who

I1=C1()
I2=C1()
I1.setname(‘bob')
I2.setname('suel')
print(I1.name)

在这样的上下文中,def的语法没有什么特别之处。从操作的角度来看,当def出现在类的内部时,通常称为方法,而且会自动接收第一个特殊参数(按照惯例称为self),这个参数提供了被处理的实例的引用。所有你自己向方法中传人的参数都被赋给了self后面的参数。

因为类是多个实例的工厂,每当某个方法需要访问或修改当前正在处理的特定实例的属性时,该方法就需要通过这个自动传入的参数self。在前面的代码中,self用于储存两个实例分别的内部名称。

就像简单变量一样,类和实例属性不需要事先声明,而是在首次赋值后它的值才会存在。当某个方法对self属性赋值时,就会创建或修改类树底端的实例内的属性,因为self会自动引用当前处理的实例。

事实上,因为类树中所有对象都只不过是命名空间对象,我们可以通过恰当的名称来访问或修改其任何属性。只要名称C1I1都位于代码的作用域内,编写C1.setname和编写I1.setname是同样有效的。

运算符重载

就目前编写的代码而言,直到setname方法调用前,C1类都不会把name属性附加到实例上。事实上,调用I1.setname前引用I1.name会产生名称未定义的错误。如果类想确保像name这样的名称一定会在其实例中设置,通常会在构造时填好这个属性:

class C2: ...
class C3: ...
class C1(C2, C3):
	def __init__(self, who):
		self.namee = who

I1 = C1('hy592070616')
I2 = C1(’machinelearning')
print(I1.name)

如果已经编写或继承了名为__init__的方法,那么每次从类产生实例时Python都会自动调用__init__方法。新实例会如往常那样传入__init__的第一位self参数,而列在类调用小括号内的任何值会成为第二以及其后的参数。其效果就是在创建实例时初始化了这个实例,而不需要额外的方法调用。

考虑到__init__方法运行的时刻,它也称为构造函数。这是所谓的运算符重载方法这一大类方法中最常用的代表,我们会在后面的文章中详细介绍这些方法。运算符重载方法同样也在类树中被继承,而且它们的名称开头和结尾都带有双下划线从而变得十分特别。当能够支持这些操作的实例出现在对应的运算符旁时,Python就会自动运行它们,而且它们一般作为使用简单方法调用的替代方案。运算符重载方法也是可选的:如果缺省则不支持对应的运算。如果没有__init__方法,类调用将返回一个空实例,而不会将其初始化。

例如,要实现集合交集,类要么提供名为intersect的方法,要么编写名为__and__的方法来将&表达式运算符分发到处理交集所需要的逻辑。因为运算符机制让实例的用法和外观类似于内置类型,所以可以让某些类提供一致而自然的接口,进而与预期内置类型的代码兼容。

同样,除了__init__构造函数(它出现在大多数实际的类中),许多程序更适合于使用简单命名的方法,除非它们的对象和内置类型的行为类似。对于一个Emplovee(雇员来说giveRaise(涨工资)是有意义的,而&可能就没意义了。

OOP是关于代码重用

上文是Python中OOP的大部分内容,此外就是一些语法细节了。当然OOP除了继承之外,还有些其他的知识。例如,运算符重载比这里所讲述的更为常见:类也可以支持实现自己的操作,例如,索引运算、属性访问和打印等。不过大体而言,OOP就是在树中搜索属性和在函数加入一个特殊的第一位参数。

类所支持的代码重用的方式是Python其他程序组件难以提供的。事实上,代码重用也是它们最重要的目的。通过类,我们可以定制现有的软件来编写代码,而不是对现有代码进行在原处的修改或者对每个新项目都从头开始。这在实际编程中被证明是一种强大的范式。

从一个更基本的角度来看,类其实就是由函数和其他名称所构成的包,很像模块。然而,我们从类得到的自动属性继承搜索,支持了软件的高层次的定制,而这是我们通过模块和函数做不到的。此外,类提供了自然的结构,让代码可以把逻辑和名称局部化,这样也有助于程序的调试。

例如,因为方法只是有特殊第一位参数的函数,我们可以把要处理的对象传给简单函数,来模拟其行为。不过,方法参与了类的继承,让我们可以自然地通过为子类定义新的方法来定制现有的程序代码,而不需要对现有的代码做原处的修改。实际上,在模块及函数中并不存在类似的概念。

多态和类

假设你现在的任务是实现员工的数据库应用程序。作为一个Python OOP程序员你可能会先写一个通用的父类,来定义组织中所有员工默认的通用行为:

class Employee:
	def computesalary(self): ...
	def giveRaise(self): ...
	def promote(self): ...
	def retire(self): ...

一且你编写了这样的通用行为,就可以针对每个特定种类的员工进行定制,来体现各种不同类型和一般情况的差异。也就是说,你可以编写子类来定制每个类型员工的不同行为。该类型员工的其他行为则会继承那个通用化的类。例如,如果工程师有特殊的薪资计算规则(并非以小时计算),就可以在子类中只替换这一个方法:

class Engineer(Employee):
	def computesalary(self): ...

版本。然后,你可以建立员工所属的员工类种类的实例,从而使其获得正确的行为:

Bob = Employee()    #Default behavior
Sue = Employee()    #Default behavior
Tom = Engineer()     #Custom salary calculator

注意我们可以对树中的任何类创建实例,而不是只针对底端的类。创建的实例所用的类会决定其属性搜索从哪个层次开始,从而决定它将使用的版本。

最后,这三个实例对象可能会嵌人到一个更大的容器对象中(例如,列表或另一个类的实例),利用本文开头所提到的组合概念,从而可以代表部门或公司。当我们想查看这些员工的薪资时,将根据创建这个对象的类基于继承搜索来计算:

company = [Bob, Sue, Tom]
for emp in company:
	print(emp.computesalary)

多态是指运算的意义取决于运算的对象。也就是说,代码不应该关心它处理的对象是什么,而只应当关注这个对象应该做什么。在这里,computesalary()方法在调用前,会通过继承搜索在每个对象中找到。在其他应用中,多态可用于隐藏(封装)接口差异性。例如,一个处理数据流的程序或许是预期给有输人和输出方法的对象编写的,而不关心那些方法实际在做的是什么:

def processor(reader,converter,writer):
	while True:
		data = reader.read()
		if not data:
			break
		data = converter(data)
		writer.write(data)

通过把各种数据源传入定制了readwrite方法接口的子类实例,我们重用了processor函数来处理我们目前和未来可能需要使用的任何数据源:

class Reader:
	def read(self): ...
	def other(self): ...

class FileReader(Reader):
	def read(self): ...
class SocketReader(Reader):
	de fread(self): ...

processor(FileReader(...), Converter, Filewriter(...))
processor(SocketReader(...), Converter, Tapewriter(...))
processor(FtpReader(...), Converter, Xmlwriter(...))

因为readwrite方法的内部实现已分解至某个独立的位置,修改readwrite的代码并不会影响使用readwrite的代码。实际上,processor函数本身也可以是类,让converter的转换逻辑通过继承添加,并让读取器和写人器以组合的方式嵌入。

通过定制化编程

一旦你习惯了使用这种方式进行程序设计,你就会发现,当要写新程序时,很多工作早已做好。你的任务大部分就是把已实现程序所需行为的现有父类混合起来。例如,某人已写好了这个例子中的EmployeeReaderWriter类,就可以用在完全不同的程序中。如果是这样,你就可以直接地使用那个人的所有代码。

事实上,在很多应用领域中,我们可以获取或购买父类集合,也就是所谓的软件框架。这些框架把常见的程序设计任务实现成类,以便你在应用程序中使用。这些软件框架可能提供一些数据库接口、测试协议、GUI工具包等。有了软件框架,你只需编写子类,填入所需的一两个方法。而树中较高位置的框架类会替你完成绝大多数的工作。在OOP下写程序,所需要做的就是通过编写自己的子类,组合和定制已调试过的代码。

当然,你需要花点时间学习如何充分利用类,从而步入OOP的理想国。实际应用中,面向对象工作也需要实质性的设计工作,来全面实现类的代码重用的优点。为此,程序员开始将常见的OOP结构归类,称为设计模式,来协助解决设计中的问题。不过,在Python中所编写的用于OOP的实际代码是如此简单,以至于在探索OOP时不会增加额外的障碍。

本文对类和OOP进行了宏观的介绍,以在深入学习细节前先一览蓝图。如前所述,OOP基本上就是名为self的属性和在链接对象树中的属性查找。对象树底端的对象会继承树中较高对象的属性。这种特性让我们可以通过定制代码来编写程序,而不是修改原有代码或是从头开始。如果使用得当,这种程序设计模型可以大幅减少开发时间。

后面的文章我们会开始向这里勾勒出的蓝图中填充编码细节。不过,在深人学习Python类时,要记住Python的OOP模型非常简单。就像这里所说的,这其实就是在对象树中搜索属性和一个特殊的函数参数。

猜你喜欢

转载自blog.csdn.net/hy592070616/article/details/126108299