Skip to main content

面向对象编程

管理大型程序

在本书的开头,我们提出了构建程序的四种基本编程模式:

  • 顺序代码
  • 条件代码(if 语句)
  • 重复代码(循环)
  • 存储和重用(函数)

在后面的章节中,我们探讨了简单变量以及像列表、元组和字典这样的集合数据结构。

在构建程序时,我们设计数据结构并编写代码来操作这些数据结构。编写程序的方式有很多种,到目前为止,你可能已经编写了一些“不那么优雅”的程序和一些“更优雅”的程序。即使你的程序可能很小,你已经开始看到编写代码存在一点艺术性和美感。

当程序达到数百万行之长时,编写易于理解的代码就变得越来越重要。如果你正在处理一个百万行的程序,你永远无法同时将整个程序记在脑子里。我们需要方法将大型程序分解成多个较小的部分,这样在解决问题、修复错误或添加新功能时,我们需要查看的内容就更少了。

在某种程度上,面向对象编程是一种组织代码的方式,使你可以专注于其中的 50 行代码并理解它,而暂时忽略其余的 999,950 行代码。

开始

像编程的许多方面一样,在能够有效使用面向对象编程之前,有必要先学习其概念。你应该将本章视为学习一些术语和概念,并通过一些简单的例子为未来的学习奠定基础。

本章的关键成果是对对象的构造方式、它们如何运作,以及最重要的是,我们如何利用 Python 和 Python 库提供给我们的对象功能有一个基本的理解。

使用对象

事实证明,我们在这本书中一直都在使用对象。Python 为我们提供了许多内置对象。下面是一些简单的代码,其前几行对你来说应该感觉非常简单和自然。

stuff = list()
stuff.append('python')
stuff.append('chuck')
stuff.sort()
print (stuff[0])
print (stuff.__getitem__(0))
print (list.__getitem__(stuff,0))

# 代码: https://www.py4e.com/code3/objects.py

我们不关注这些代码行完成了什么,而是从面向对象编程的角度来看实际发生了什么。如果下面的段落在你第一次阅读时没有任何意义,请不要担心,因为我们尚未定义所有这些术语。

第一行构造 (constructs) 了一个 list 类型的对象,第二行和第三行调用 (call) 了 append() 方法 (method),第四行调用了 sort() 方法,第五行检索 (retrieves) 了位置 0 处的项。

第六行调用了 stuff 列表中的 __getitem__() 方法,参数为零。

print (stuff.__getitem__(0))

第七行是检索列表中第 0 个项的一种更冗长的方式。

print (list.__getitem__(stuff,0))

在这段代码中,我们调用了 list (class) 中的 __getitem__ 方法,并将列表以及我们想从列表中检索的项作为参数传递 (pass) 进去。

程序的最后三行是等效的,但简单地使用方括号语法来查找列表中特定位置的项更方便。

我们可以通过查看 dir() 函数的输出来了解一个对象的功能:

>>> stuff = list()
>>> dir(stuff)
['__add__', '__class__', '__contains__', '__delattr__',\
'__delitem__', '__dir__', '__doc__', '__eq__',\
'__format__', '__ge__', '__getattribute__', '__getitem__',\
'__gt__', '__hash__', '__iadd__', '__imul__', '__init__',\
'__iter__', '__le__', '__len__', '__lt__', '__mul__',\
'__ne__', '__new__', '__reduce__', '__reduce_ex__',\
'__repr__', '__reversed__', '__rmul__', '__setattr__',\
'__setitem__', '__sizeof__', '__str__', '__subclasshook__',\
'append', 'clear', 'copy', 'count', 'extend', 'index',\
'insert', 'pop', 'remove', 'reverse', 'sort']
>>>

本章的其余部分将定义以上所有术语,所以请确保在完成本章后回来重新阅读以上段落以检查你的理解。

从程序开始

一个程序最基本的形式是接受一些输入,进行一些处理,并产生一些输出。我们的电梯转换程序演示了一个非常简短但完整的程序,展示了所有这三个步骤。

usf = input('Enter the US Floor Number: ')
wf = int(usf) - 1
print('Non-US Floor Number is',wf)

# 代码: https://www.py4e.com/code3/elev.py

如果我们更深入地思考这个程序,会发现有“外部世界”和程序本身。输入和输出方面是程序与外部世界交互的地方。在程序内部,我们有代码和数据来完成程序设计要解决的任务。

一个程序 一个程序

思考面向对象编程的一种方式是它将我们的程序分成多个“区域”。每个区域包含一些代码和数据(就像一个程序),并与外部世界以及程序内的其他区域有明确定义的交互。

如果我们回顾一下我们使用 BeautifulSoup 库的链接提取应用程序,我们可以看到一个通过连接不同的对象来完成任务的程序:

# 要运行此程序,请下载 BeautifulSoup zip 文件
# http://www.py4e.com/code3/bs4.zip
# 并在与此文件相同的目录中解压缩

import urllib.request, urllib.parse, urllib.error
from bs4 import BeautifulSoup
import ssl

# 忽略 SSL 证书错误
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE

url = input('Enter - ')
html = urllib.request.urlopen(url, context=ctx).read()
soup = BeautifulSoup(html, 'html.parser')

# 检索所有锚点标签
tags = soup('a')
for tag in tags:
print(tag.get('href', None))

# 代码: https://www.py4e.com/code3/urllinks.py

我们将 URL 读入一个字符串,然后将其传递给 urllib 以从网络检索数据。urllib 库使用 socket 库来建立实际的网络连接以检索数据。我们将 urllib 返回的字符串交给 BeautifulSoup 进行解析。BeautifulSoup 利用 html.parser 对象 1 并返回一个对象。我们对返回的对象调用 tags() 方法,该方法返回一个标签对象的字典。我们遍历标签并为每个标签调用 get() 方法以打印出 href 属性。

我们可以画出这个程序的图示以及对象如何协同工作。

作为对象网络的程序 作为对象网络的程序

这里的关键不是要完美理解这个程序是如何工作的,而是要看到我们如何构建一个交互对象的网络,并协调信息在对象之间的流动来创建一个程序。同样重要的是要注意,当你在几章前回顾那个程序时,你可能完全理解程序中发生的事情,甚至没有意识到该程序是在“协调对象之间的数据流动”。它只是完成工作的代码行。

细分问题

面向对象方法的一个优点是它可以隐藏复杂性。例如,虽然我们需要知道如何使用 urllib 和 BeautifulSoup 代码,但我们不需要知道这些库内部是如何工作的。这使我们能够专注于我们需要解决的那部分问题,而忽略程序的其他部分。

使用对象时忽略细节 使用对象时忽略细节

这种只关注我们关心的程序部分并忽略其余部分的能力,对我们使用的对象的开发者也很有帮助。例如,开发 BeautifulSoup 的程序员不需要知道或关心我们如何检索 HTML 页面,我们想读取哪些部分,或者我们计划如何处理从网页中提取的数据。

构建对象时忽略细节 构建对象时忽略细节

我们的第一个 Python 对象

从基本层面来看,对象只是比整个程序更小的一些代码加上数据结构。定义一个函数允许我们存储一小段代码并给它一个名字,然后稍后使用函数名调用该代码。

一个对象可以包含许多函数(我们称之为方法 (methods))以及这些函数使用的数据。我们将作为对象一部分的数据项称为属性 (attributes)。

我们使用 class 关键字来定义将构成每个对象的数据和代码。class 关键字包含类的名称,并开始一个缩进的代码块,我们在其中包含属性(数据)和方法(代码)。

class PartyAnimal:

def __init__(self):
self.x = 0

def party(self) :
self.x = self.x + 1
print("So far",self.x)

an = PartyAnimal()
an.party()
an.party()
an.party()

# 代码: https://www.py4e.com/code3/party2.py

每个方法看起来像一个函数,以 def 关键字开始,并包含一个缩进的代码块。

第一个方法是一个特殊命名的方法,称为 __init__()。这个方法被调用来对我们想要存储在对象中的数据进行任何初始设置。在这个类中,我们使用点表示法分配 x 属性并将其初始化为零。

    self.x = 0

另一个方法名为 party。所有方法都有一个特殊的第一个参数,我们按惯例命名为 self。第一个参数使我们能够访问对象实例,以便我们可以使用点表示法设置属性和调用方法。

正如 def 关键字不会导致函数代码被执行一样,class 关键字也不会创建对象。相反,class 关键字定义了一个模板,指示了 PartyAnimal 类型的每个对象将包含哪些数据和代码。类就像一个饼干模具,而使用类创建的对象就是饼干 2。你不会在饼干模具上放糖霜;你会在饼干上放糖霜,并且你可以给每个饼干放上不同的糖霜。

一个类和两个对象 一个类和两个对象

如果我们继续看这个示例程序,我们会看到第一行可执行代码:

an = PartyAnimal()

这就是我们指示 Python 构造(即创建)PartyAnimal 类的对象 (object) 或实例 (instance) 的地方。它看起来像是对类本身的函数调用。Python 用正确的数据和方法构造对象,并返回该对象,然后将其赋给变量 an。在某种程度上,这与我们一直使用的下面这行代码非常相似:

counts = dict()

这里我们指示 Python 使用 dict 模板(Python 中已存在)构造一个对象,返回字典的实例,并将其赋给变量 counts

当使用 PartyAnimal 类构造对象时,变量 an 用于指向该对象。我们使用 an 来访问 PartyAnimal 类特定实例的代码和数据。

每个 PartyAnimal 对象/实例内部都包含一个变量 x 和一个名为 party 的方法/函数。我们在这行代码中调用 party 方法:

an.party()

当调用 party 方法时,第一个参数(我们按惯例称为 self)指向调用 party 的 PartyAnimal 对象的特定实例。在 party 方法内部,我们看到这行代码:

self.x = self.x + 1

这种使用运算符的语法表示“self 内部的 x”。每次调用 party() 时,内部的 x 值增加 1,并打印出该值。

下面这行是调用 an 对象内部 party 方法的另一种方式:

PartyAnimal.party(an)

在这个变体中,我们从类内部访问代码,并显式地将对象指针 an 作为第一个参数传递(即方法内部的 self)。你可以将 an.party() 看作是上面这行的简写。

当程序执行时,它产生以下输出:

So far 1
So far 2
So far 3
So far 4

对象被构造出来,party 方法被调用了四次,每次都增加并打印出 an 对象内部 x 的值。

类作为类型

正如我们所见,在 Python 中所有变量都有一个类型。我们可以使用内置的 dir 函数来检查变量的功能。我们也可以对我们创建的类使用 typedir

class PartyAnimal:

def __init__(self):
self.x = 0

def party(self) :
self.x = self.x + 1
print("So far",self.x)

an = PartyAnimal()
print ("Type", type(an))
print ("Dir ", dir(an))
print ("Type", type(an.x))
print ("Type", type(an.party))

# 代码: https://www.py4e.com/code3/party3.py

当这个程序执行时,它产生以下输出:

Type <class '__main__.PartyAnimal'>
Dir ['__class__', '__delattr__', ...\
'__sizeof__', '__str__', '__subclasshook__',\
'__weakref__', 'party', 'x']
Type <class 'int'>
Type <class 'method'>

你可以看到使用 class 关键字,我们创建了一个新类型。从 dir 输出中,你可以看到 x 整数属性和 party 方法在对象中都可用。

对象生命周期

在前面的例子中,我们定义了一个类(模板),使用该类创建了该类的一个实例(对象),然后使用了该实例。当程序结束时,所有的变量都被丢弃了。通常,我们不太考虑变量的创建和销毁,但往往随着我们的对象变得越来越复杂,我们需要在对象构造时在对象内部采取一些行动来进行设置,并在对象被丢弃时可能进行清理。

如果我们希望我们的对象能够意识到这些构造和销毁的时刻,我们向对象添加特殊命名的方法:

class PartyAnimal:

def __init__(self):
self.x = 0
print('I am constructed')

def party(self) :
self.x = self.x + 1
print('So far',self.x)

def __del__(self):
print('I am destructed', self.x)

an = PartyAnimal()
an.party()
an.party()
an = 42
print('an contains',an)

# 代码: https://www.py4e.com/code3/party4.py

当这个程序执行时,它产生以下输出:

I am constructed
So far 1
So far 2
I am destructed 2
an contains 42

当 Python 构造我们的对象时,它调用我们的 __init__ 方法,让我们有机会为对象设置一些默认或初始值。当 Python 遇到这行代码:

an = 42

它实际上“丢弃了我们的对象”,以便它可以重用 an 变量来存储值 42。就在我们的 an 对象被“销毁”的那一刻,我们的析构方法代码 (__del__) 被调用。我们无法阻止我们的变量被销毁,但我们可以在我们的对象不再存在之前进行任何必要的清理工作。

在开发对象时,向对象添加构造方法以设置对象的初始值是很常见的。相对而言,很少需要为对象添加析构方法。

多个实例

到目前为止,我们已经定义了一个类,构造了一个单一对象,使用了该对象,然后丢弃了该对象。然而,面向对象编程的真正威力在于我们构造我们类的多个实例时。

当我们从我们的类构造多个对象时,我们可能想为每个对象设置不同的初始值。我们可以向构造方法传递数据,为每个对象赋予不同的初始值:

class PartyAnimal:

def __init__(self, nam):
self.x = 0
self.name = nam
print(self.name,'constructed')

def party(self) :
self.x = self.x + 1
print(self.name,'party count',self.x)

s = PartyAnimal('Sally')
s.party()
j = PartyAnimal('Jim')

j.party()
s.party()

# 代码: https://www.py4e.com/code3/party5.py

构造方法既有一个指向对象实例的 self 参数,也有额外的参数,这些参数在对象构造时被传递到构造方法中:

s = PartyAnimal('Sally')

在构造方法内部,第二行将传递进来的参数 (nam) 复制到对象实例内部的 name 属性中。

self.name = nam

程序的输出显示每个对象(sj)都包含它们自己独立的 xnam 副本:

Sally constructed
Jim constructed
Sally party count 1
Jim party count 1
Sally party count 2

继承

面向对象编程的另一个强大特性是通过扩展现有类来创建新类的能力。在扩展类时,我们将原始类称为父类 (parent class),新类称为子类 (child class)。

对于这个例子,我们将我们的 PartyAnimal 类移动到它自己的文件中。然后,我们可以在一个新文件中“导入”PartyAnimal 类并扩展它,如下所示:

from party import PartyAnimal

class CricketFan(PartyAnimal):

def __init__(self, nam) :
super().__init__(nam)
self.points = 0

def six(self):
self.points = self.points + 6
self.party()
print(self.name,"points",self.points)

s = PartyAnimal("Sally")
s.party()
j = CricketFan("Jim")
j.party()
j.six()
print(dir(j))

# 代码: https://www.py4e.com/code3/party6.py

当我们定义 CricketFan 类时,我们表明我们正在扩展 PartyAnimal 类。这意味着来自 PartyAnimal 类的所有变量 (x) 和方法 (party) 都被 CricketFan继承 (inherited) 了。例如,在 CricketFan 类的 six 方法内部,我们调用了来自 PartyAnimal 类的 party 方法。

我们在 CricketFan 类的 __init__() 方法中使用了一种特殊的语法,以确保我们也调用 PartyAnimal 中的 __init__() 方法,这样 PartyAnimal 需要的任何设置都会在 CricketFan 扩展所需的设置之外完成。

   def __init__(self, nam) :
super().__init__(nam)
self.points = 0

super() 语法告诉 Python 调用我们正在扩展的类中的 __init__ 方法。PartyAnimal 是超类(或父类),CricketFan 是子类。

程序执行时,我们创建 sj 作为 PartyAnimalCricketFan 的独立实例。j 对象具有超出 s 对象的额外功能。

Sally constructed
Sally party count 1
Jim constructed
Jim party count 1
Jim party count 2
Jim points 6
['__class__', '__delattr__', ... '__weakref__',\
'name', 'party', 'points', 'six', 'x']

j 对象(CricketFan 类的实例)的 dir 输出中,我们看到它具有父类的属性和方法,以及在扩展类以创建 CricketFan 类时添加的属性和方法。

总结

这是对面向对象编程的一个非常快速的介绍,主要侧重于术语以及定义和使用对象的语法。让我们快速回顾一下我们在本章开头看到的代码。此时你应该完全理解发生了什么。

stuff = list()
stuff.append('python')
stuff.append('chuck')
stuff.sort()
print (stuff[0])
print (stuff.__getitem__(0))
print (list.__getitem__(stuff,0))

# 代码: https://www.py4e.com/code3/objects.py

第一行构造了一个 list 对象。当 Python 创建 list 对象时,它调用构造方法(名为 __init__)来设置将用于存储列表数据的内部数据属性。我们没有向构造方法传递任何参数。当构造方法返回时,我们使用变量 stuff 来指向返回的 list 类的实例。

第二行和第三行调用了 append 方法,带有一个参数,通过更新 stuff 内部的属性在列表末尾添加一个新项。然后在第四行,我们调用了不带参数的 sort 方法来对 stuff 对象内部的数据进行排序。

然后我们使用方括号打印出列表中的第一项,这是调用 stuff 内部 __getitem__ 方法的快捷方式。这等同于调用 list 中的 __getitem__ 方法,并将 stuff 对象作为第一个参数传递,将我们正在查找的位置作为第二个参数传递。

在程序结束时,stuff 对象被丢弃,但在此之前会调用析构方法(名为 __del__),以便对象可以在必要时清理任何收尾工作。

这些是面向对象编程的基础知识。关于在开发大型应用程序和库时如何最好地使用面向对象方法,还有许多额外的细节,这些超出了本章的范围。3

术语表

属性 (attribute) 作为类一部分的变量。 类 (class) 可用于构造对象的模板。定义了将构成对象的属性和方法。 子类 (child class) 通过扩展父类创建的新类。子类继承父类的所有属性和方法。 构造方法 (constructor) 一个可选的特殊命名方法 (__init__),在使用类构造对象时被调用。通常用于设置对象的初始值。 析构方法 (destructor) 一个可选的特殊命名方法 (__del__),在对象即将被销毁时被调用。析构方法很少使用。 继承 (inheritance) 当我们通过扩展现有类(父类)来创建新类(子类)时。子类拥有父类的所有属性和方法,再加上子类定义的额外属性和方法。 方法 (method) 包含在类以及从该类构造的对象中的函数。一些面向对象的模式使用“消息”(message) 而不是“方法”来描述这个概念。 对象 (object) 类的构造实例。对象包含类定义的所有属性和方法。一些面向对象的文档交替使用术语“实例”(instance) 和“对象”。 父类 (parent class) 被扩展以创建新子类的类。父类将其所有的方法和属性贡献给新的子类。


  1. https://docs.python.org/zh-cn/3/library/html.parser.html ↩︎
  2. 饼干图片版权 CC-BY https://www.flickr.com/photos/dinnerseries/23570475099 ↩︎
  3. 如果你对 list 类的定义位置感到好奇,可以看看(希望 URL 不会改变)https://github.com/python/cpython/blob/master/Objects/listobject.c - list 类是用一种叫做“C”的语言编写的。如果你看了那个源代码并觉得好奇,你可能想在 https://www.cc4e.com/ 探索 C 编程,着眼于构建对象。↩︎

如果你在本书中发现错误,欢迎使用 Github 给我发送修正。