在前面的章节中,我们已经介绍了面向对象编程的许多定义特性。我们现在已经了解了面向对象设计的原则和范例,并且已经介绍了 Python 中面向对象编程的语法。
然而,我们不知道如何以及何时在实践中使用这些原则和语法。在本章中,我们将讨论我们获得的知识的一些有用的应用,并在此过程中选择一些新的主题:
- 如何识别对象
- 数据和行为,再一次
- 使用属性在行为中包装数据
- 限制数据使用行为
- 不要重复自己的原则
- 识别重复代码
这似乎是显而易见的;您通常应该在代码中为问题域中的单独对象指定一个特殊类。我们在前几章的案例研究中已经看到了这方面的例子;首先,我们确定问题中的对象,然后对其数据和行为建模。
识别对象是面向对象分析和编程中一项非常重要的任务。但这并不总是像我们一直在做的那样,像在一小段中数数名词那么容易。记住,对象是既有数据又有行为的东西。如果我们只处理数据,通常最好将其存储在列表、集合、字典或其他一些 Python 数据结构中(我们将在第 6 章、Python 数据结构中详细介绍)。另一方面,如果我们只处理行为,而不处理存储的数据,那么简单的函数更合适。
但是,对象同时具有数据和行为。熟练的 Python 程序员使用内置数据结构,除非(或直到)明显需要定义类。如果不能帮助组织代码,就没有理由添加额外的抽象级别。另一方面,“明显的”需要并不总是不言而喻的。
我们通常可以通过将数据存储在几个变量中来启动 Python 程序。随着程序的扩展,我们稍后会发现我们正在将同一组相关变量传递给一组函数。现在是考虑将变量和函数分组到一个类中的时候了。如果我们正在设计一个在二维空间中对多边形建模的程序,我们可以从每个多边形表示为点列表开始。这些点将被建模为两个元组(x、y),描述该点的位置。这是所有数据,存储在一组嵌套的数据结构(特别是元组列表)中:
square = [(1,1), (1,2), (2,2), (2,1)]现在,如果我们想计算多边形周长周围的距离,我们只需要求两点之间的距离之和。为此,我们还需要一个函数来计算两点之间的距离。这里有两个这样的功能:
import math
def distance(p1, p2):
return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
def perimeter(polygon):
perimeter = 0
points = polygon + [polygon[0]]
for i in range(len(polygon)):
perimeter += distance(points[i], points[i+1])
return perimeter现在,作为面向对象的程序员,我们清楚地认识到polygon类可以封装点列表(数据)和perimeter函数(行为)。此外,一个point类,如我们在第 2 章中定义的,Python 中的对象,可能会封装x和y坐标以及distance方法。问题是:这样做有价值吗?
对于前面的代码,可能是,可能不是。根据我们最近在面向对象原则方面的经验,我们可以在创纪录的时间内编写一个面向对象的版本。让我们比较一下
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, p2):
return math.sqrt((self.x-p2.x)**2 + (self.y-p2.y)**2)
class Polygon:
def __init__(self):
self.vertices = []
def add_point(self, point):
self.vertices.append((point))
def perimeter(self):
perimeter = 0
points = self.vertices + [self.vertices[0]]
for i in range(len(self.vertices)):
perimeter += points[i].distance(points[i+1])
return perimeter正如我们从突出显示的部分所看到的,这里的代码数量是早期版本的两倍,尽管我们可以说,add_point方法并不是严格必要的。
现在,为了更好地理解差异,让我们比较两个正在使用的 API。下面是如何使用面向对象代码计算正方形的周长:
>>> square = Polygon()
>>> square.add_point(Point(1,1))
>>> square.add_point(Point(1,2))
>>> square.add_point(Point(2,2))
>>> square.add_point(Point(2,1))
>>> square.perimeter()
4.0您可能会认为这非常简洁且易于阅读,但让我们将其与基于函数的代码进行比较:
>>> square = [(1,1), (1,2), (2,2), (2,1)]
>>> perimeter(square)
4.0嗯,也许面向对象的 API 不够紧凑!也就是说,我认为阅读比函数式示例更容易:我们如何知道元组列表在第二个版本中应该代表什么?我们如何记住应该传递给perimeter函数的对象类型(两个元组的列表?这不是直观的!)?我们需要大量的文档来解释如何使用这些函数。
相比之下,面向对象的代码相对来说是自文档化的,我们只需查看方法列表及其参数,就可以了解对象的功能以及如何使用它。当我们为功能版本编写所有文档时,它可能比面向对象的代码要长。
最后,代码长度不是代码复杂性的一个好指标。一些程序员会被复杂的“一行程序”所困扰,这些程序在一行代码中完成了难以置信的工作量。这可能是一个有趣的练习,但结果往往是不可读的,甚至对第二天的原作者来说也是如此。最小化代码量通常可以使程序更易于阅读,但不要盲目地假设情况就是这样。
幸运的是,这种权衡是没有必要的。我们可以使面向对象的PolygonAPI 与功能实现一样易于使用。我们所要做的就是改变我们的Polygon类,这样它就可以用多个点构造。让我们给它一个初始值设定项,它接受一个Point对象列表。事实上,让我们允许它也接受元组,如果需要,我们可以自己构造Point对象:
def __init__(self, points=None):
points = points if points else []
self.vertices = []
for point in points:
if isinstance(point, tuple):
point = Point(*point)
self.vertices.append(point)此初始值设定项遍历列表并确保所有元组都转换为点。如果对象不是一个元组,我们将其保持原样,假设它已经是一个Point对象,或者是一个未知的 duck 类型的对象,可以像Point对象一样工作。
尽管如此,这段代码的面向对象版本和更面向数据的版本之间仍然没有明显的赢家。他们都做同样的事情。如果我们有接受多边形参数的新函数,例如area(polygon)或point_in_polygon(polygon, x, y),那么面向对象代码的好处将越来越明显。类似地,如果我们向多边形添加其他属性,例如color或texture,则将该数据封装到单个类中变得越来越有意义。
区别是一个设计决策,但一般来说,一组数据越复杂,就越有可能有多个特定于该数据的函数,使用一个具有属性和方法的类就越有用。
当做出这个决定时,还要考虑如何使用课堂。如果我们只是试图在一个更大的问题的背景下计算一个多边形的周长,那么使用函数可能是编写代码最快的方法,并且更容易使用“一次性”。另一方面,如果我们的程序需要以多种方式操纵许多多边形(计算周长、面积、与其他多边形的交点、移动或缩放它们等等),我们肯定已经识别出一个对象;一个需要非常多才多艺的人。
此外,请注意对象之间的交互。寻找继承关系;没有类,继承是不可能优雅地建模的,所以一定要使用它们。寻找我们在第 1 章、面向对象设计中讨论的其他类型的关系、关联和组合。从技术上讲,合成可以仅使用数据结构建模;例如,我们可以有一个包含元组值的字典列表,但是创建几个对象类通常不那么复杂,特别是当存在与数据相关联的行为时。
不要仅仅因为可以使用对象就急于使用对象,但是在需要使用类时,千万不要忽略创建类。
在本书中,我们一直关注行为和数据的分离。这在面向对象编程中非常重要,但我们即将看到,在 Python 中,这种区别可能会令人难以置信地模糊。Python 非常擅长模糊区别;这并不能帮助我们“跳出框框思考”。相反,它教会我们停止思考盒子。
在我们进入细节之前,让我们先讨论一些糟糕的面向对象理论。许多面向对象语言(Java 是最臭名昭著的)教导我们永远不要直接访问属性。他们坚持我们这样写属性访问:
class Color:
def __init__(self, rgb_value, name):
self._rgb_value = rgb_value
self._name = name
def set_name(self, name):
self._name = name
def get_name(self):
return self._name变量以下划线作为前缀,表示它们是私有的(其他语言实际上会强制它们是私有的)。然后,get 和 set 方法提供对每个变量的访问。这一类将在实践中使用,如下所示:
>>> c = Color("#ff0000", "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red'这远不如 Python 支持的直接访问版本可读:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self.name = name
c = Color("#ff0000", "bright red")
print(c.name)
c.name = "red"那么,为什么会有人坚持使用基于方法的语法呢?他们的理由是,有一天我们可能希望在设置或检索值时添加额外的代码。例如,我们可以决定缓存一个值并返回缓存的值,或者我们可能希望验证该值是否是合适的输入。
在代码中,我们可以决定更改set_name()方法,如下所示:
def set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name现在,在 Java 和类似的语言中,如果我们编写了用于直接访问属性的原始代码,然后将其更改为类似于前面的方法,那么我们就会遇到一个问题:任何编写了直接访问属性的代码的人现在都必须访问该方法。如果他们不将访问样式从属性访问更改为函数调用,他们的代码将被破坏。这些语言的格言是,我们永远不应该让公众成员成为私人。这在 Python 中没有多大意义,因为没有任何真正的私有成员概念!
Python 为我们提供了property关键字,使方法看起来像属性。因此,我们可以编写使用直接成员访问的代码,如果在获取或设置该属性的值时意外地需要更改实现以进行一些计算,那么我们可以在不更改接口的情况下执行此操作。让我们看看它的样子:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self._name = name
def _set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
def _get_name(self):
return self._name
name = property(_get_name, _set_name)如果我们从早期的非基于方法的类开始,该类直接设置了name属性,那么我们可以在以后更改代码,使其与前面的代码类似。我们首先将name属性更改为(半)私有_name属性。然后我们再添加两个(半)私有方法来获取和设置该变量,并在设置时进行验证。
最后,我们在底部有property声明。这就是魔法。它在Color类上创建了一个名为name的新属性,该属性现在取代了以前的name属性。它将该属性设置为属性,每当访问或更改属性时,该属性调用我们刚刚创建的两个方法。这个新版本的Color类可以与上一版本完全相同的方式使用,但现在我们设置name属性时,它会进行验证:
>>> c = Color("#0000ff", "bright red")
>>> print(c.name)
bright red
>>> c.name = "red"
>>> print(c.name)
red
>>> c.name = ""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "setting_name_property.py", line 8, in _set_name
raise Exception("Invalid Name")
Exception: Invalid Name因此,如果我们以前编写代码来访问name属性,然后将其更改为使用property对象,那么以前的代码仍然可以工作,除非它发送空的property值,这是我们首先要禁止的行为。成功
请记住,即使使用name属性,前面的代码也不是 100%安全的。人们仍然可以直接访问_name属性,如果愿意,还可以将其设置为空字符串。但是如果他们访问一个我们用下划线显式标记的变量以表明它是私有的,那么他们是必须处理结果的人,而不是我们。
将property函数视为返回一个对象,该对象通过我们指定的方法代理任何设置或访问属性值的请求。property关键字类似于这样一个对象的构造函数,该对象被设置为给定属性的面向公众的成员。
这个property构造函数实际上可以接受两个附加参数,一个删除函数和属性的 docstring。实际上很少提供delete函数,但它可以用于记录已删除的值,或者如果我们有理由否决删除,则可能否决删除。docstring 只是一个描述属性作用的字符串,与我们在第 2 章、Python 中的对象中讨论的 docstring 没有什么不同。如果我们不提供此参数,则会从 docstring 复制 docstring 作为第一个参数:getter 方法。下面是一个愚蠢的示例,它简单地说明了何时调用任何方法:
class Silly:
def _get_silly(self):
print("You are getting silly")
return self._silly
def _set_silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
def _del_silly(self):
print("Whoah, you killed silly!")
del self._silly
silly = property(_get_silly, _set_silly,
_del_silly, "This is a silly property")如果我们实际使用这个类,当我们要求它执行以下操作时,它确实会打印出正确的字符串:
>>> s = Silly()
>>> s.silly = "funny"
You are making silly funny
>>> s.silly
You are getting silly
'funny'
>>> del s.silly
Whoah, you killed silly!此外,如果我们查看Silly类的帮助文件(通过在解释器提示下发出help(silly),它会向我们显示silly属性的自定义 docstring:
Help on class Silly in module __main__:
class Silly(builtins.object)
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
|
| silly
| This is a silly property再一次,一切都按计划进行。实际上,属性通常只使用前两个参数定义:getter 和 setter 函数。如果我们想为属性提供 docstring,我们可以在 getter 函数中定义它;属性代理将其复制到自己的 docstring 中。删除函数通常为空,因为对象属性很少被删除。如果编码器尝试删除未指定删除函数的属性,则会引发异常。因此,如果有合法理由删除我们的财产,我们应该提供该功能。
如果您以前从未使用过 Python decorators,那么您可能希望跳过本节,在我们讨论了第 10 章、Python 设计模式 I中的 decorator 模式后再回到本节。但是,您不需要了解如何使用 decorator 语法来提高属性方法的可读性。
property 函数可以与 decorator 语法一起使用,将 get 函数转换为属性:
class Foo:
@property
def foo(self):
return "bar"这将应用property函数作为修饰符,与前面的foo = property(foo)语法相同。从可读性的角度来看,主要的区别在于我们可以在方法的顶部将foo函数标记为属性,而不是在它被定义之后,在那里它很容易被忽略。这也意味着我们不必仅仅为了定义属性而创建带有下划线前缀的私有方法。
更进一步,我们可以为新属性指定 setter 函数,如下所示:
class Foo:
@property
def foo(self):
return self._foo
@foo.setter
def foo(self, value):
self._foo = value这种语法看起来很奇怪,尽管意图很明显。首先,我们将foo方法装饰为 getter。然后,我们通过应用最初修饰的foo方法的setter属性来修饰第二个名称完全相同的方法!property函数返回一个对象;此对象始终具有自己的setter属性,然后可以将其作为装饰器应用于其他函数。不需要为 get 和 set 方法使用相同的名称,但它确实有助于将访问一个属性的多个方法组合在一起。
我们也可以用@foo.deleter指定删除函数。我们无法使用property装饰符指定 docstring,因此我们需要依赖从初始 getter 方法复制 docstring 的属性。
这是我们之前重写的Silly类,将property用作装饰器:
class Silly:
@property
def silly(self):
"This is a silly property"
print("You are getting silly")
return self._silly
@silly.setter
def silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
@silly.deleter
def silly(self):
print("Whoah, you killed silly!")
del self._silly这个类的操作与我们的早期版本完全相同,包括帮助文本。您可以使用任何您觉得更可读、更优雅的语法。
由于属性的内置性模糊了行为和数据之间的界限,因此要知道选择哪一个可能会让人困惑。我们前面看到的示例用例是属性最常见的用途之一;我们有一些关于一个类的数据,我们以后要向其中添加行为。在决定使用物业时,还需要考虑其他因素。
从技术上讲,在 Python 中,数据、属性和方法都是类上的属性。方法是可调用的这一事实并不能将其与其他类型的属性区分开来;事实上,我们将在第 7 章、Python 面向对象快捷方式中看到,可以创建可以像函数一样调用的普通对象。我们还将发现函数和方法本身就是普通对象。
方法只是可调用的属性,而属性只是可定制的属性,这一事实可以帮助我们做出这个决定。方法通常应该表示动作;可以对对象执行或由对象执行的操作。当你调用一个方法时,即使只有一个参数,它也应该做一些事情。方法名称通常是动词。
一旦确认属性不是动作,我们就需要在标准数据属性和属性之间做出决定。通常,在需要以某种方式控制对该属性的访问之前,始终使用标准属性。无论哪种情况,你的属性通常是一个名词。属性和属性之间的唯一区别在于,我们可以在检索、设置或删除属性时自动调用自定义操作。
让我们看一个更现实的例子。自定义行为的一个常见需求是缓存难以计算或查找成本高昂的值(例如,需要网络请求或数据库查询)。目标是在本地存储值,以避免重复调用昂贵的计算。
我们可以使用属性上的自定义 getter 来实现这一点。第一次检索值时,我们执行查找或计算。然后,我们可以将该值作为私有属性本地缓存在对象上(或在专用缓存软件中),下次请求该值时,我们将返回存储的数据。以下是缓存网页的方法:
from urllib.request import urlopen
class WebPage:
def __init__(self, url):
self.url = url
self._content = None
@property
def content(self):
if not self._content:
print("Retrieving New Page...")
self._content = urlopen(self.url).read()
return self._content我们可以测试此代码,以查看页面仅检索一次:
>>> import time
>>> webpage = WebPage("http://ccphillips.net/")
>>> now = time.time()
>>> content1 = webpage.content
Retrieving New Page...
>>> time.time() - now
22.43316888809204
>>> now = time.time()
>>> content2 = webpage.content
>>> time.time() - now
1.9266459941864014
>>> content2 == content1
True当我最初测试这段代码时,我正在一个糟糕的卫星连接上,第一次加载内容时花了 20 秒。第二次,我在 2 秒钟内得到了结果(这实际上只是在解释器中键入行所需的时间)。
自定义 getter 对于需要根据其他对象属性动态计算的属性也很有用。例如,我们可能希望计算整数列表的平均值:
class AverageList(list):
@property
def average(self):
return sum(self) / len(self)这个非常简单的类继承自list,因此我们可以免费获得类似列表的行为。我们只需向类中添加一个属性,然后,我们的列表可以有一个平均值:
>>> a = AverageList([1,2,3,4])
>>> a.average
2.5当然,我们可以将其作为一种方法,但是我们应该称之为calculate_average(),因为方法代表动作。但是一个名为average的属性更合适,既易于输入,也易于阅读。
正如我们已经看到的,自定义设置器对于验证非常有用,但它们也可以用于将值代理到另一个位置。例如,我们可以在WebPage类中添加一个内容设置器,该类会自动登录到我们的 web 服务器,并在设置该值时上载新页面。
我们一直关注对象及其属性和方法。现在,我们来看看设计更高级的对象:管理其他对象的对象的种类。把一切联系在一起的对象。
这些对象与我们迄今为止看到的大多数示例之间的区别在于,我们的示例往往代表具体的想法。管理对象更像办公室经理;他们不会在地板上做实际的“可见”工作,但是没有他们,部门之间就没有沟通,没有人知道他们应该做什么(尽管,如果组织管理不善,这也可能是真的!)。类似地,管理类上的属性倾向于引用执行“可见”工作的其他对象;此类类上的行为在正确的时间委托给其他类,并在它们之间传递消息。
例如,我们将编写一个程序,对存储在压缩 ZIP 文件中的文本文件执行查找和替换操作。我们需要对象来表示 ZIP 文件和每个单独的文本文件(幸运的是,我们不必编写这些类,它们可以在 Python 标准库中找到)。manager 对象将负责确保按顺序执行三个步骤:
- 解压缩压缩文件。
- 执行查找和替换操作。
- 压缩新文件。
该类使用.zip文件名初始化,并搜索和替换字符串。我们创建一个临时目录来存储解压后的文件,以便文件夹保持干净。Python 3.4pathlib库帮助进行文件和目录操作。我们将在第 8 章、字符串和序列化中了解更多信息,但在下面的示例中,接口应该非常清晰:
import sys
import shutil
import zipfile
from pathlib import Path
class ZipReplace:
def __init__(self, filename, search_string, replace_string):
self.filename = filename
self.search_string = search_string
self.replace_string = replace_string
self.temp_directory = Path("unzipped-{}".format(
filename))然后,我们为这三个步骤中的每一步创建一个整体的“管理器”方法。此方法将责任委托给其他方法。显然,我们可以在一个方法中完成所有三个步骤,或者实际上,在一个脚本中完成所有步骤,而无需创建对象。分离这三个步骤有几个优点:
- 可读性:每个步骤的代码都是一个独立的单元,易于阅读和理解。方法名称描述了该方法的功能,并且需要较少的额外文档来理解正在发生的事情。
- 可扩展性:如果子类希望使用压缩的 TAR 文件而不是 ZIP 文件,它可以覆盖
zip和unzip方法,而不必复制find_replace方法。 - 分区:外部类可以创建该类的实例,直接在某个文件夹上调用
find_replace方法,而不必zip内容。
委托方法是下面代码中的第一个方法;为确保完整性,包括其他方法:
def zip_find_replace(self):
self.unzip_files()
self.find_replace()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.filename) as zip:
zip.extractall(str(self.temp_directory))
def find_replace(self):
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
def zip_files(self):
with zipfile.ZipFile(self.filename, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).zip_find_replace()为简洁起见,压缩和解压文件的代码很少有文档记录。我们目前的重点是面向对象的设计;如果您对zipfile模块的内部细节感兴趣,请在线或在交互式解释器中键入import zipfile ; help(zipfile)参考标准库中的文档。请注意,此示例仅搜索 ZIP 文件中的顶级文件;如果解压缩内容中有任何文件夹,则不会扫描这些文件夹,也不会扫描这些文件夹中的任何文件。
示例中的最后两行允许我们通过将zip文件名、搜索字符串和替换字符串作为参数传递,从命令行运行程序:
python zipsearch.py hello.zip hello hi当然,这个对象不必从命令行创建;它可以从另一个模块导入(执行批处理 ZIP 文件处理),也可以作为 GUI 界面的一部分进行访问,甚至可以作为知道从何处获取 ZIP 文件(例如,从 FTP 服务器检索 ZIP 文件或将其备份到外部磁盘)的更高级别的管理对象进行访问。
随着程序变得越来越复杂,被建模的对象越来越不像物理对象。属性是其他抽象对象,方法是更改这些抽象对象状态的操作。但是,无论多么复杂,每个对象的核心都是一组具体的属性和定义良好的行为。
通常,管理风格类(如ZipReplace中的代码非常通用,可以以多种方式应用。可以使用组合或继承来帮助将此代码保留在一个位置,从而消除重复代码。在我们看任何这样的例子之前,让我们先讨论一点理论。具体来说,为什么重复代码是件坏事?
有几个原因,但它们都归结为可读性和可维护性。当我们编写一段类似于早期代码的新代码时,最简单的方法是复制旧代码并更改需要更改的内容(变量名、逻辑、注释),使其在新位置工作。或者,如果我们正在编写与项目中其他地方的代码相似但又不完全相同的新代码,那么编写具有类似行为的新代码通常比找出如何提取重叠功能更容易。
但一旦有人必须阅读和理解代码,并且他们遇到重复的块,他们就会面临进退两难的境地。可能突然变得有意义的代码必须被理解。一部分与另一部分有何不同?它们是怎么一样的?在什么条件下称为一节?我们什么时候打电话给对方?你可能会争辩说你是唯一一个阅读代码的人,但是如果你八个月都不接触代码,你会像一个新的程序员一样难以理解。当我们试图阅读两段相似的代码时,我们必须理解它们为什么不同,以及它们如何不同。这浪费了读者的时间;代码应该总是先编写为可读。
我曾经试着去理解某人的代码,这些代码有三个相同的副本,都是相同的 300 行非常糟糕的代码。在我最终了解到这三个“相同”的版本实际上执行的税务计算略有不同之前,我已经使用该代码工作了一个月。有些细微的差异是有意造成的,但也有一些明显的地方,有人在一个函数中更新了计算,而没有更新其他两个函数。代码中微妙的、不可理解的 bug 数量无法统计。我最终用 20 行左右的易读函数替换了所有 900 行。
阅读这样重复的代码可能会让人厌烦,但代码维护更让人痛苦。正如前面的故事所暗示的那样,保持两段相似的代码保持最新可能是一场噩梦。每当我们更新其中一个部分时,我们必须记住更新这两个部分,并且我们必须记住多个部分之间的差异,以便在编辑每个部分时可以修改我们的更改。如果我们忘记更新这两个部分,我们最终会遇到非常恼人的 bug,它们通常表现为“但我已经修复了,为什么它还在发生?”
结果是,阅读或维护我们的代码的人必须花费天文数字的时间来理解和测试它,而与我们最初以非竞争性的方式编写代码相比。当我们在做维护的时候,更令人沮丧;我们发现自己在说,“为什么我第一次没做对?”我们通过复制粘贴现有代码节省的时间在我们第一次维护它时就浪费了。代码被读取和修改的次数和频率都比编写的要多得多。可理解的代码应该始终是最重要的。
这就是为什么程序员,特别是 Python 程序员(他们往往更看重优雅的代码,而不是一般的代码)遵循所谓的不要重复自己(DRY原则。DRY 代码是可维护的代码。我对新手程序员的建议是永远不要使用编辑器的复制粘贴功能。对于中级程序员,我建议他们在点击Ctrl+C之前三思而后行。
但是我们应该做什么来代替代码复制呢?最简单的解决方案通常是将代码移动到一个函数中,该函数接受参数来解释不同的部分。这不是一个非常面向对象的解决方案,但它通常是最优的。
例如,如果我们有两段代码将一个 ZIP 文件解压到两个不同的目录中,那么我们可以很容易地编写一个函数,该函数接受应该解压到的目录的参数。这可能会使函数本身更难阅读,但是一个好的函数名和 docstring 可以很容易地弥补这一点,并且调用函数的任何代码都会更容易阅读。
这当然是足够的理论!这个故事的寓意是:始终努力重构代码,使其更易于阅读,而不是编写更容易编写的糟糕代码。
让我们探索两种重用现有代码的方法。在编写代码来替换一个充满文本文件的 ZIP 文件中的字符串之后,我们随后签订合同,将 ZIP 文件中的所有图像缩放到 640 x 480。看起来我们可以使用一个非常类似于我们在ZipReplace中使用的范例。第一个冲动可能是保存该文件的副本,并将find_replace方法更改为scale_image或类似的方法。
但是,这很不酷。如果有一天我们想要改变unzip和zip方法来同时打开 TAR 文件呢?或者我们想为临时文件使用一个保证唯一的目录名。无论哪种情况,我们都必须在两个不同的地方进行更改!
我们将首先演示一个基于继承的解决方案来解决这个问题。首先,我们将原始的ZipReplace类修改为一个超类,用于处理通用 ZIP 文件:
import os
import shutil
import zipfile
from pathlib import Path
class ZipProcessor:
def __init__(self, zipname):
self.zipname = zipname
self.temp_directory = Path("unzipped-{}".format(
zipname[:-4]))
def process_zip(self):
self.unzip_files()
self.process_files()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.zipname) as zip:
zip.extractall(str(self.temp_directory))
def zip_files(self):
with zipfile.ZipFile(self.zipname, 'w') as file:
for filename in self.temp_directory.iterdir():
file.write(str(filename), filename.name)
shutil.rmtree(str(self.temp_directory))我们将filename属性更改为zipname,以避免与各种方法中的filename局部变量混淆。这有助于使代码更具可读性,即使它实际上不是设计上的更改。
我们还将这两个特定于ZipReplace的参数降低到__init__(search_string和replace_string。然后我们将zip_find_replace方法重命名为process_zip,并使其调用一个(尚未定义的)process_files方法,而不是find_replace;这些名称更改有助于展示新类的更一般化的性质。注意,我们已经完全删除了find_replace方法;该代码是针对ZipReplace的,在这里没有任何业务。
这个新的ZipProcessor类实际上没有定义process_files方法;因此,如果我们直接运行它,它将引发一个异常。因为它不打算直接运行,所以我们删除了原始脚本底部的主调用。
现在,在我们继续讨论图像处理应用程序之前,让我们先修复原始的zipsearch类,以利用这个父类:
from zip_processor import ZipProcessor
import sys
import os
class ZipReplace(ZipProcessor):
def __init__(self, filename, search_string,
replace_string):
super().__init__(filename)
self.search_string = search_string
self.replace_string = replace_string
def process_files(self):
'''perform a search and replace on all files in the
temporary directory'''
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(
self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).process_zip()此代码比原始版本短一点,因为它继承了父类的 ZIP 处理能力。我们首先导入我们刚刚编写的基类,并使ZipReplace扩展该类。然后我们使用super()初始化父类。find_replace方法仍然存在,但我们将其重命名为process_files,以便父类可以从其管理接口调用它。因为这个名称不像旧名称那样具有描述性,所以我们添加了一个 docstring 来描述它在做什么。
考虑到我们现在所拥有的只是一个功能上与我们开始使用的程序没有什么不同的程序,这是一项相当大的工作!但在完成了这项工作之后,我们现在更容易编写对 ZIP 归档文件进行操作的其他类,例如(假设请求的)照片缩放器。此外,如果我们想要改进或修复 zip 功能,我们可以通过只更改一个ZipProcessor基类来对所有类进行改进。维护将更加有效。
看看现在创建一个利用ZipProcessor功能的照片缩放类有多简单。(注意:此类需要第三方pillow库才能获取PIL模块,您可以使用pip install pillow进行安装。)
from zip_processor import ZipProcessor
import sys
from PIL import Image
class ScaleZip(ZipProcessor):
def process_files(self):
'''Scale each image in the directory to 640x480'''
for filename in self.temp_directory.iterdir():
im = Image.open(str(filename))
scaled = im.resize((640, 480))
scaled.save(str(filename))
if __name__ == "__main__":
ScaleZip(*sys.argv[1:4]).process_zip()看这门课多么简单!我们以前做的所有工作都得到了回报。我们所要做的就是打开每个文件(假设它是一个图像;如果无法打开文件,它将意外崩溃)、缩放它并将其保存回去。ZipProcessor课程负责拉链和解扣,我们没有额外的工作。
在本例研究中,我们将尝试深入探讨这个问题,“我应该在什么时候选择对象而不是内置类型?”我们将对一个Document类进行建模,该类可能会在文本编辑器或字处理器中使用。它应该具有哪些对象、函数或属性?
对于Document内容,我们可以用str开头,但在 Python 中,字符串是不可变的(可以更改)。一旦str被定义,它就是永恒。如果不创建一个全新的字符串对象,我们无法在其中插入或删除字符。在 Python 的垃圾收集器认为适合清理之前,这将导致大量str对象占用内存。
因此,我们将使用字符列表,而不是字符串,可以随意修改。此外,Document类需要知道列表中当前光标的位置,并且可能还应该存储文档的文件名。
真实文本编辑器使用一种称为rope的基于二叉树的数据结构来建模文档内容。这本书的标题不是“高级数据结构”,所以如果你有兴趣了解更多关于这个迷人的主题,你可能想在网上搜索 rope 数据结构。
那么应该有什么办法呢??我们可能需要对文本文档执行很多操作,包括插入、删除和选择字符、剪切、复制、粘贴、选择以及保存或关闭文档。看起来有大量的数据和行为,所以把这些东西放到自己的Document类中是有意义的。
一个相关的问题是:这个类是否应该由一堆基本的 Python 对象组成,比如str文件名、int光标位置和list字符?或者这些东西中的一部分或全部应该是专门定义的对象吗?那么单独的行和字符呢,它们需要有自己的类吗?
我们将边走边回答这些问题,但让我们先从最简单的Document类开始,看看它能做什么:
class Document:
def __init__(self):
self.characters = []
self.cursor = 0
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor, character)
self.cursor += 1
def delete(self):
del self.characters[self.cursor]
def save(self):
with open(self.filename, 'w') as f:
f.write(''.join(self.characters))
def forward(self):
self.cursor += 1
def back(self):
self.cursor -= 1这个简单的类允许我们完全控制编辑基本文档。看看它的实际效果:
>>> doc = Document()
>>> doc.filename = "test_document"
>>> doc.insert('h')
>>> doc.insert('e')
>>> doc.insert('l')
>>> doc.insert('l')
>>> doc.insert('o')
>>> "".join(doc.characters)
'hello'
>>> doc.back()
>>> doc.delete()
>>> doc.insert('p')
>>> "".join(doc.characters)
'hellp'看起来它起作用了。我们可以将键盘上的字母键和箭头键连接到这些方法上,文档可以很好地跟踪所有内容。
但如果我们想要连接的不仅仅是箭头键,那该怎么办呢。如果我们想同时连接Home和End键怎么办?我们可以在Document类中添加更多方法,向前或向后搜索字符串中的换行符(在 Python 中,换行符或\n表示一行的结束和一行的开始)并跳转到它们,但是如果我们对每个可能的移动动作(按单词移动、按句子移动)都这样做,向上翻页、向下翻页、行尾、空格开头等等),这门课将会非常庞大。也许最好将这些方法放在一个单独的对象上。因此,让我们将游标属性转换为一个对象,该对象知道其位置并可以操纵该位置。我们可以将前进和后退方法移动到该类,并为Home和End键添加更多的方法:
class Cursor:
def __init__(self, document):
self.document = document
self.position = 0
def forward(self):
self.position += 1
def back(self):
self.position -= 1
def home(self):
while self.document.characters[
self.position-1] != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(self.document.characters
) and self.document.characters[
self.position] != '\n':
self.position += 1此类将文档作为初始化参数,以便方法可以访问文档字符列表的内容。然后,它提供了简单的方法,可以像以前一样前后移动,也可以移动到home和end位置。
这个代码不是很安全。您可以很容易地移动到结束位置,如果您试图返回空文件,它将崩溃。这些例子都很简短,以便于阅读,但这并不意味着它们是防御性的!作为练习,您可以改进此代码的错误检查;这可能是一个扩展异常处理技能的好机会。
Document类本身几乎没有变化,只是删除了移到Cursor类的两个方法:
class Document:
def __init__(self):
self.characters = []
self.cursor = Cursor(self)
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor.position,
character)
self.cursor.forward()
def delete(self):
del self.characters[self.cursor.position]
def save(self):
f = open(self.filename, 'w')
f.write(''.join(self.characters))
f.close()我们只是更新了访问旧游标整数的任何内容,以使用新对象。我们可以测试home方法是否真的移动到了换行符:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert('l')
>>> d.insert('l')
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert('w')
>>> d.insert('o')
>>> d.insert('r')
>>> d.insert('l')
>>> d.insert('d')
>>> d.cursor.home()
>>> d.insert("*")
>>> print("".join(d.characters))
hello
*world现在,由于我们已经大量使用了该字符串join函数(将字符连接起来以便我们可以看到实际的文档内容),我们可以向Document类添加一个属性,以提供完整的字符串:
@property
def string(self):
return "".join(self.characters)这使我们的测试变得更简单:
>>> print(d.string)
hello
world这个框架很简单(尽管可能有点耗时!)可以扩展以创建和编辑完整的纯文本文档。现在,让我们将其扩展到富文本;可以有粗体、下划线或斜体字符的文本。
我们可以用两种方法来处理这个问题;第一种方法是在我们的字符列表中插入类似于指令的“假”字符,例如“在找到停止粗体字符之前使用粗体字符”。第二种方法是向每个字符添加信息,指示其应有的格式。虽然前一种方法可能更常见,但我们将实现后一种解决方案。要做到这一点,我们显然需要一个角色类。此类将具有一个表示字符的属性,以及三个表示字符是粗体、斜体还是带下划线的布尔属性。
嗯,等等!这个Character类会有什么方法吗?如果不是,也许我们应该使用众多 Python 数据结构中的一种;一个元组或命名元组可能就足够了。我们是否希望对角色执行或调用任何操作?
很明显,我们可能想对字符进行处理,比如删除或复制字符,但这些都需要在Document级别进行处理,因为它们实际上是在修改字符列表。是否需要对单个角色执行某些操作?
实际上,现在我们正在思考Character课程实际上是什么。。。它是什么?可以安全地说Character类是字符串吗?也许我们应该在这里使用继承关系?然后我们可以利用str实例附带的众多方法。
我们在谈论什么样的方法?有startswith、strip、find、lower等等。这些方法中的大多数都希望处理包含多个字符的字符串。相反,如果Character是str的子类,我们可能会明智地重写__init__以在提供多字符字符串时引发异常。既然我们免费获得的所有这些方法都不能真正应用于我们的Character类,那么看来我们毕竟不需要使用继承。
这让我们回到我们最初的问题;Character应该是一门课吗?object类中有一个非常重要的特殊方法,我们可以利用它来表示我们的角色。这种方法称为__str__(两个下划线,如__init_\),在print和str构造函数等字符串操作函数中用于将任何类转换为字符串。默认实现会做一些无聊的事情,比如打印模块和类的名称及其在内存中的地址。但是如果我们覆盖它,我们可以让它打印我们喜欢的任何东西。对于我们的实现,我们可以使用特殊字符作为前缀字符,以表示它们是粗体、斜体还是带下划线。因此,我们将创建一个类来表示一个角色,如下所示:
class Character:
def __init__(self, character,
bold=False, italic=False, underline=False):
assert len(character) == 1
self.character = character
self.bold = bold
self.italic = italic
self.underline = underline
def __str__(self):
bold = "*" if self.bold else ''
italic = "/" if self.italic else ''
underline = "_" if self.underline else ''
return bold + italic + underline + self.character这个类允许我们创建字符,并在str()函数应用于字符时使用特殊字符作为前缀。没有什么太令人兴奋的。我们只需要对Document和Cursor类做一些小的修改就可以使用这个类。在Document类中,我们在insert方法的开头添加了这两行:
def insert(self, character):
if not hasattr(character, 'character'):
character = Character(character)这是一段相当奇怪的代码。其基本目的是检查传入的字符是Character还是str。如果它是一个字符串,它被包装在一个Character类中,因此列表中的所有对象都是Character对象。然而,完全有可能使用我们代码的人想要使用一个既不是Character也不是字符串的类,使用 duck 类型。如果对象具有 character 属性,则假定它是一个类似于“Character”的对象。但如果不是,我们假设它是一个类似于“str”的对象,并将其包装在Character中。这有助于程序利用 duck 类型和多态性;只要对象具有 character 属性,它就可以在Document类中使用。
这种通用检查可能非常有用,例如,如果我们想在程序员编辑器中突出显示语法:我们需要有关字符的额外数据,例如字符所属的语法标记类型。请注意,如果我们正在进行大量此类比较,那么最好将Character实现为一个带有适当__subclasshook__的抽象基类,如第 3 章、中所述,当对象类似时。
此外,我们需要修改Document上的 string 属性以接受新的Character值。我们需要做的就是在加入角色之前调用str():
@property
def string(self):
return "".join((str(c) for c in self.characters))这段代码使用了一个生成器表达式,我们将在第 9 章迭代器模式中讨论。它是对序列中的所有对象执行特定操作的快捷方式。
最后,我们还需要在home和end函数中检查Character.character,而不仅仅是我们之前存储的字符串,以查看它是否与换行符匹配:
def home(self):
while self.document.characters[
self.position-1].character != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(
self.document.characters) and \
self.document.characters[
self.position
].character != '\n':
self.position += 1这就完成了字符的格式化。我们可以测试它,看看它是否有效:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert(Character('l', bold=True))
>>> d.insert(Character('l', bold=True))
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert(Character('w', italic=True))
>>> d.insert(Character('o', italic=True))
>>> d.insert(Character('r', underline=True))
>>> d.insert('l')
>>> d.insert('d')
>>> print(d.string)
he*l*lo
/w/o_rld
>>> d.cursor.home()
>>> d.delete()
>>> d.insert('W')
>>> print(d.string)
he*l*lo
W/o_rld
>>> d.characters[0].underline = True
>>> print(d.string)
_he*l*lo
W/o_rld正如所料,每当我们打印字符串时,每个粗体字符前面都有一个*字符,每个斜体字符前面都有一个/字符,每个下划线字符前面都有一个_字符。我们所有的函数似乎都能工作,我们可以在事后修改列表中的字符。我们有一个可以工作的富文本文档对象,它可以插入一个合适的用户界面,并与一个用于输入的键盘和一个用于输出的屏幕连接起来。当然,我们希望在屏幕上显示真正的粗体、斜体和下划线字符,而不是使用我们的__str__方法,但这对于我们要求的基本测试已经足够了。
我们已经研究了对象、数据和方法在面向对象的 Python 程序中相互交互的各种方式。和往常一样,你的第一个想法应该是如何将这些原则应用到你自己的工作中。您是否有任何乱七八糟的脚本可以使用面向对象的管理器重写?浏览一些旧代码,寻找不是动作的方法。如果名称不是动词,请尝试将其改写为属性。
想想你用任何语言编写的代码。它是否违反了干燥原则?有重复的代码吗?你复制并粘贴代码了吗?您是否因为不想理解原始代码而编写了两个版本的类似代码?现在回顾一下您最近的一些代码,看看是否可以使用继承或组合重构重复的代码。试着选择一个你仍然有兴趣维护的项目;代码不会太旧以至于你再也不想碰它了。当你进行改进时,它有助于保持你的兴趣!
现在,回顾一下我们在本章中看到的一些例子。从使用属性缓存检索到的数据的缓存网页示例开始。此示例的一个明显问题是缓存从未刷新。向属性的 getter 添加超时,并且仅当在超时过期之前请求了缓存页面时才返回该页面。您可以使用time模块(time.time() - an_old_time返回自an_old_time以来经过的秒数)来确定缓存是否已过期。
现在看看基于继承的ZipProcessor。在这里使用组合而不是继承可能是合理的。您可以将这些类的实例传递给ZipProcessor构造函数并调用它们来完成处理部分,而不是扩展ZipReplace和ScaleZip类中的类。实现这一点。
你觉得哪个版本更容易使用?哪个更优雅?什么更容易阅读?这些都是主观问题;我们每个人的答案都不尽相同。然而,知道答案很重要;如果您发现您更喜欢继承而不是组合,那么您必须注意在日常编码中不要过度使用继承。如果您更喜欢组合,请确保不要错过创建优雅的基于继承的解决方案的机会。
最后,向案例研究中创建的各种类添加一些错误处理程序。他们应该确保输入单个字符,确保您不试图将光标移过文件的结尾或开头,确保您不删除不存在的字符,确保您不保存没有文件名的文件。试着考虑尽可能多的边缘情况,并考虑它们(想想边缘情况大约是专业程序员工作的 90%)!考虑不同的方法来处理它们。当用户试图移动到文件末尾时,您应该引发异常,还是只保留最后一个字符?
在日常编码中,请注意复制和粘贴命令。每次在编辑器中使用它们时,请考虑改进程序的组织是否是一个好主意,以便只具有要复制的代码的一个版本。
在这一章中,我们着重于识别对象,特别是那些不明显的对象;管理和控制的对象。对象应该同时具有数据和行为,但是属性可以用来模糊两者之间的区别。DRY 原则是代码质量的重要指标,可以应用继承和组合来减少代码重复。
在下一章中,我们将介绍几种内置 Python 数据结构和对象,重点介绍它们的面向对象属性以及如何扩展或调整它们。