ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[TOC] 在前几章中,我们已经讨论了面向对象编程的许多定义特性。我们现在知道了面向对象设计的原则和范例,我们已经介绍了Python中面向对象编程的语法。 </b> 然而,我们不知道在实践中如何以及何时在中使用这些原则和语法。在本章中,我们将用我们学到的知识讨论一些有用的应用。我们会讨论一些新的话题: * 如何识别对象 * 再一次讨论数据和行为 * 使用`property`包装行为中的数据 * 使用行为限制数据 * 不要重复你自己的原则 * 识别重复代码 ## 将对象看作对象 这似乎是显而易见的;在你的问题中,你通常应该在你的代码里给不同的对象一个特殊类。我们在在前几章中的案例研究中看到了这方面的例子;首先,我们识别问题中的对象,然后对它们的数据和行为进行建模。 </b> 识别对象是面向对象分析和编程中非常重要的任务。但是并不总是像计算一小段中的名词数量那么简单,就像我们一直在做的那样。记住,对象是既有数据又有行为的东西。如果我们只处理数据,我们通常最好将其存储在一个列表、集合、字典,或者其他一些Python数据结构(我们将会在第6章“Python数据结构”详细介绍)。另一方面,如果我们只处理行为,但没有存储数据,简单的函数更合适。 </b> 然而,对象既有数据又有行为。高效的Python程序员只使用内置数据结构,除非(或直到)有明显定义一个类的需要。如果类并没有帮助我们更好的组织代码,就没有理由增加额外的抽象层次。另一方面,“明显的”需求并不总是不言自明的。 </b> 我们通常可以通过将数据存储在几个变量中来开始我们的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`函数(行为)。此外,像我们在第2章“Python中的对象”中定义的点`point`类,可以封装x和y坐标以及距离`distance`方法。问题是:这样做有价值吗? </b> 对于之前的代码,可能是,也可能不是。使用我们刚刚学会面向对象原则的那点儿经验,我们可以编写一个面向对象的版本。让我们比较一下: ``` 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`方法不是绝对必要的。 </b> 现在,为了更好地理解差异,让我们比较一下这两个版本的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`函数?我们需要大量的文档来解释应该如何使用这些函数。 </b> 相比之下,面向对象的代码相对来说是自我说明的,我们只需要查看方法列表及其参数,就可以了解对象作用和如何使用它们。当我们为函数版本编写所有文档时,它可能比面向对象的代码长。 </b> 最后,代码长度不是代码复杂性的好指标。一些程序员沉迷于复杂的“一句话”代码,希望一句话就能完成惊人的工作量。这可能是一个有趣的练习,但结果往往是不可读的,甚至原作者第二天就不记得昨天写的是什么了。最小化代码量通常可以使程序更容易阅读,但不要盲目地滥用这种情况。 </b> 幸运的是,这种权衡是没有必要的。我们可以制作与函数实现一样易于使用的面向对象的多边形`Polygon`API。我们所要做的就是改变我们的多边形`Polygon`类,以便可以用多个点构造它。让我们给它一个接受点`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) ``` 该初始化函数遍历列表,并确保任何元组都被转换为点。如果对象不是元组,我们就保持不变,假设它是点对象,或者可以像点对象一样工作的未知鸭子类型的对象。 </b> 然而,在这个代码上,面向对象和更面向数据之间没有明显的赢家。他们都做同样的事情。如果我们有新的接受多边形参数的函数,如面积`area(polygon)`或多边形中心`point_in_polygon(polygon, x, y)`,面向对象代码的好处变得越来越明显。同样地,如果我们向多边形添加其他属性,如颜色`color`或纹理`texture`,将数据封装到一个类中更有意义。 </b> 区别是设计决策,但一般来说,越复杂的数据越有可能具有针对该数据的多种函数,使用带有属性和方法的类将变得更有用。 </b> 做出这个决定时,花点时间考虑如何使用这个类。如果我们只是在一个更大问题里试图计算一个多边形的周长,使用一个函数编写代码可能是最快和更容易使用的。另一方面,如果我们的程序需要以很多种方式处理大量多边形(计算周长、面积、与其他多边形的交点,移动或缩放它们,等等),最好选择多才多艺的对象。 </b> 此外,注意对象之间的交互。寻找继承关系;没有类,继承是不可能优雅建模的,所以一定要使用它们。寻找我们在第1章“面向对象的设计”中讨论过的关联和组合。从技术上讲,组合可仅使用数据结构建模;例如,我们可以有一个字典列表保存元组值,但是创建几类对象通常不太复杂,尤其是当存在与数据相关联的行为时。 > 不要仅仅因为你可以使用一个对象就急于使用它,但是当你需要使用一个类时,千万不要忘记创建一个类。 ## 使用property给类中的数据添加行为 在这本书里,我们一直关注行为和数据的分离。这在面向对象编程中非常重要,但是我们即将看到,python这种区别可能非常模糊。python非常擅长模糊区别;这并不能帮助我们“跳出框框思考”。相反,它教导我们不要再想盒子了。 </b> 在我们进入细节之前,让我们讨论一些不好的面向对象理论。许多面向对象语言(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 ``` 变量用下划线作为前缀,表明它们是私有的(其他语言实际上会迫使它们是私有的)。然后对每个变量的访问提供获取和设置方法。实际使用该类的方法如下: ``` >>> 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" ``` 那么为什么会有人坚持基于方法的语法呢?他们的推理是,有一天,当一个值被设置或检索时,我们可能想要添加额外的代码。例如,我们可以决定缓存一个值并返回缓存的值,或者我们可能想要验证该值是否是合适的输入。 </b> 在代码中,我们可以决定如下更改`set_name()`方法: ``` def set_name(self, name): if not name: raise Exception("Invalid Name") self._name = name ``` 现在,在Java和类似语言中,如果我们编写了原始代码来直接属性访问,然后将其更改为类似于前面的方法访问,我们会有一个问题:任何编写了直接访问属性的代码的人现在必须访问该方法。如果他们坚持属性访问的风格,而不改变到函数调用时,他们的代码将崩溃。这些语言的准则是我们永远不应该让公共成员私有化(译注:有点小疑问,感觉应该是私有成员不应该编程公共成员)。但这不代表在Python中很有意义,因为Python没有任何私有成员的真正概念! </b> 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`属性。然后我们添加了另外两个私有方法来获取和设置变量,我们在设置方法中添加了验证。 </b> 最后,我们在底部使用了`property`声明。这就是魔力。它创造了`Color`类中一个名为`name`的新属性,它现在取代了以前的`name`属性。它将这个属性设置为一个`property`,每当访问或更改`property`时,它将调用我们刚刚创建的两个方法。新版本的`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`值,这是我们现在希望禁止的行为。成功! </b> 请记住,即使使用`name property`,以前的代码也不是100%安全。人们仍然可以直接访问`_name`属性并将它设置为空字符串,如果他们想这样做的话。但是如果他们访问一个我们已经明确标记变量是私有的(下划线表示),他们必须自己处理因此造成的后果,而不是我们。 ### property细节 我们可以将`property`函数视为一个代理任何设置请求或者通过我们指定的方法访问属性值的返回对象。`property`关键字就像这样一个对象的构造函数,该对象被设置为给定属性的公共成员。 </b> 这个`property`构造函数实际上可以接受两个额外的参数,一个删除函数和一个`docstring`。`delete`函数很少在实际中使用,但是记录一个值已经被删除或者如果我们有理由可能否决删除时,有一些作用。`docstring`只是一个描述该`property`的作用,与我们在第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 ``` 再一次,一切都按照我们的计划进行。实际上,`property`通常仅使用前两个参数:`getter`和`setter`函数。如果我们想要为`property`提供`docstring`,我们可以在`getter`函数上定义它;`property`代理将它复制到自己的`docstring`中。删除功能通常是留空,因为对象属性很少被删除。如果程序员试图删除没有指定删除函数的`property`,会引发异常。因此,如果有正当理由删除我们的`property`,我们应该提供删除功能。 ### 装饰器——创建property的另外一种方法 如果你以前从未使用过Python装饰器,你可能想跳过这一部分,我们在第10章“Python设计模式I”还将讨论装饰器。然而,你现在不需要理解什么是`decorator`语法,它只是使`property`方法更可读。 </b> `property`函数可以与`decorator`语法一起使用,将`get`函数变成`property`: ``` class Foo: @property def foo(self): return "bar" ``` 这使得`property`函数成为修饰器,并等同于前面的`foo = property(foo)`。从可读性的角度来看,主要区别在于,我们可以将`foo`函数的顶部将其标记为一个`property`,而不是在其被定义之后标记为`property`(我们很容易忘记这样做)。这也意味着我们不必为了定义一个`property`,去创建带有下划线前缀的私有方法。 </b> 更进一步,我们可以为新`property`指定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`方法使用相同的名称不是必需的,但它确实有助于对多个方法进行分组一起访问一个`property`方法。 </b> 我们也可以用`@foo.deleter`指定删除函数。我们不能使用`property`修饰器指定`docstring`,所以我们需要依赖`property`复制来自初始`getter`方法的`docstring`。 </b> 下面是我们之前重写的`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 ``` 这个类的操作与我们的早期版本完全相同,包括帮助文本。你可以使用任何你认为更易读、更优雅的语法。 ### 决定何时使用property 由于`property`属性模糊了行为和数据之间的界限,我们可能会很困惑,不知该选择哪一个。我们前面看到的示例是`property`最常见的用途之一;我们的一个类有一些数据,我们希望以后在上面添加一些行为。在决定使用`property`,还有其他因素需要考虑。 </b> 技术上,在Python中,数据、`property`和方法都是类的属性。一个方法可调用的这一事实并不是它与其他类型属性的区别;事实上,我们将在第7章“Python面向对象的快捷方式”中看到,我们可以创建像函数一样调用的普通对象。我们也会发现函数和方法本身就是正常的对象。 </b> 事实上,方法只是可调用的属性,`property`只是可定制的、可以帮助我们做出决定的属性。方法通常应该表示动作;要做的事情用对象表示。当你调用一个方法时,甚至只有一个参数,它都会*做些*什么。方法的名子通常是动词。 </b> 一旦确认属性不是动作,我们需要在标准数据属性和`property`之间进行选择。通常,总是优先使用标准数据属性,直到你需要以某种方式控制对该属性的访问,正如`property`所做的。无论哪种情况,属性通常是名词。属性和`property`之间唯一的区别是当需要检索、设置,或者删除时,我们需要的是`property`。 </b> 让我们看一个更现实的例子。自定义行为的一个常见需求是缓存难以计算或查找成本高的值(例如,网络请求或数据库查询)。目标是将值存储在本地以避免反复调用昂贵的计算。 </b> 我们可以使用`property`上的自定义`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秒钟内得到了结果(这实际上就是输入行所花费的时间)。 </b> 自定义`getter`对于需要计算的属性也很有用,这些对象属性基于其他对象属性。例如,我们可能想要计算整数列表的平均值: ``` class AverageList(list): @property def average(self): return sum(self) / len(self) ``` 这个非常简单的、继承自列表`list`的类,所以我们可以免费获得类似列表的行为。我们只需向类中添加一个`property`,很快,我们的列表就会有一个平均值: ``` >>> a = AverageList([1,2,3,4]) >>> a.average 2.5 ``` 当然,我们可以把它变成一种方法,但是我们应该称之为`calculate_average()`,因为方法代表动作。但是一个叫做`average`更合适,更容易打字,也更容易阅读。 </b> 正如我们已经看到的,自定义`setter`对于验证很有用,但是它们也可以用于将值代理到另一个位置。例如,我们可以为`WebPage`添加一个内容`setter`,自动登录到我们的web服务器,只要设置了值,就上传一个新页面。 ## 管理对象 我们一直关注对象及其属性和方法。现在,我们要看看设计更高级别的对象:管理其他对象的对象,用于将对象联系在一起。 </b> 这些对象和我们迄今为止看到的大多数例子之间的区别是,我们的例子倾向于代表具体的想法。管理对象更像办公室经理;他们不在地板上做真正的“可见”工作,但是没有他们,部门之间就不会有交流,没有人知道他们应该做什么(如果组织管理不善,就可能成为现实!译注:职场中人看此评论一声长叹)。类似地,管理类的属性倾向于指挥其他对象做“可见”的工作;这种类的行为时在合适的时间把任务委托给其他类,并在它们之间传递消息。 </b> 例如,我们将编写一个程序,为存储在压缩的ZIP文件中的文本文件执行查找和替换操作。我们需要定义一个对象来表示ZIP文件和单个文本文件(幸运的是,我们不必编写这些类,它们存在于Python标准库)。经理对象将负责确保依次进行三个步骤: 1. 解压缩压缩文件 2. 执行查找和替换操作 3. 重新压缩文件 类的初始化参数包括`.zip`文件名、搜索字符串和替换字符串。我们创建一个临时目录来存储解压缩后的文件,所以文件夹要干净。Python 3.4`pathlib`库有助于文件和目录操作。我们将在第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`方法,而不必压缩内容。 委托方法是下面代码中的第一个;为完整起见,其余的方法也包括了进来: ``` 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文件中的第一级文件;如果解压缩文件夹还有其他文件夹,里面的任何文件不会被扫描。 </b> 示例中的最后两行允许我们从命令运行程序,传入zip文件、搜索字符串和替换字符串参数: ``` python zipsearch.py hello.zip hello hi ``` 当然,这个对象不必从命令行创建;有可能从另一个模块导入(执行批处理ZIP文件处理)或作为图形用户界面的一部分,甚至更高级别的管理对象,知道在哪里获取压缩文件(例如,从文件传输协议服务器检索文件或将它们备份到外部磁盘)。 </b> 随着程序变得越来越复杂,被建模的对象变得越来越不太像实物。`property`是其他抽象对象,方法是改变这些抽象对象状态的动作。但是在每个对象的核心,不管有多复杂,都是一套具体的性质和明确的行为。 ### 移除重复的代码 像`ZipReplace`这样的管理风格类中的代码通常是非常通用的,并且可以以多种方式应用。可以使用组合或继承将代码保存在一个地方,从而消除重复代码。在我们看这些例子之前,让我们先讨论一点理论。具体来说,为什么重复代码是件坏事? </b> 有几个原因,但都归结为可读性和可维护性。当我们在写一段与早期代码相似的新代码时,最简单的要做的事情是复制旧代码,并更改需要更改的内容(变量名称、逻辑、注释)以使其在新的位置工作。或者,如果我们编写看似相似但与项目中其他地方的代码不同的新代码,编写具有类似行为的新代码通常比弄清楚如何提取重叠功能更容易。 </b> 但是一旦有人必须阅读和理解代码,他们就会发现重复的代码块,他们面临着两难的境地。可能有意义的代码突然必须得搞清楚是怎么回事。这一段代码和另一段代码有什么不同?怎么它们是一样的呢?在什么情况下这一段代码应该被调用?什么时候又该调用另一个呢?你可能会说你是唯一一个阅读代码的人,但是如果你八个月内不碰那个代码,它对你来说将是不可理解的,就好像给一个新程序员看一样。当我们试图读取两条相似的代码时,我们必须理解它们为什么不同,以及它们是如何不同的。这浪费了读者时间;代码应该总是先被编写成可读的。 > 我曾经不得不试着去理解一个人的三段相同的同样300行写得很差的代码副本。我用了一个月才明白三个“相同”的代码版本实际上只是表现略有不同税收计算。一些细微的差别是有意的,但是在一些明显的地方有人更新了一个函数中的计算,而没有更新另外两个函数。代码中微妙的、不可理解的错误无法计数。我最终用20行左右的易读函数替换了所有900行。 读取这样的重复代码可能很烦人,但是代码维护更麻烦折磨。正如前面的故事所暗示的,保留两个相似的代码片段,可能将是一场噩梦。无论何时,只要我们更新其中一个,我们都必须记住更新另一个函数,我们必须记住多个部分是如何不同的,这样我们才可以在编辑每个变更时修改它们。如果我们忘记同时更新这两段代码,我们最终都会发现非常烦人的bug,就像我们一直常说的,“明明我已经解决了,为什么它还在发生?” </b> 结果是,阅读或维护我们代码的人不得不花费大量的时间,而如果我们从一开始以非重复的方式编写代码,情况就好很多。甚至当我们做维护的时候,我们很沮丧;我们发现自己在说,"为什么我第一次做得不对?"我们通过复制粘贴现有代码所节省的时间,在我们第一次维护时就丢失了。代码被读取和修改比它写的次数和频率多得多。可理解的代码应该永远是最重要的。 </b> 这就是为什么程序员,尤其是Python程序员(他们倾向于重视优雅代码,超过平均水平),遵循所谓的“不要重复你自己”**DIY**原则。DIY代码是可维护的代码。我对初级程序员的建议是永远不要使用编辑器的复制和粘贴功能。对于中级程序员,我建议他们在点击Ctrl + C之前三思而后行。 </b> 但是我们应该做什么来代替代码复制呢?最简单的解决方案通常是将代码移动到一个函数中,该函数通过接受参数来说明任何部分的不同点。这不是一个非常面向对象的解决方案,但是它通常是最佳的。 </b> 例如,如果我们需要写两段代码将一个压缩文件解压到两个不同的文件目录,我们可以很容易地编写一个接受目录参数的函数,这个目录参数表示被解压到的位置。这可能会使函数本身稍微更难读,但是一个好的函数名和docstring可以很容易地解决这个问题。调用该函数的任何代码都将更容易阅读。 </b> 这当然是足够的理论!这个故事的寓意是:永远努力重构代码,使其更容易阅读,而不是编写更容易的糟糕代码。 ### 实践 让我们探索两种重用现有代码的方法。我们已经编写了替换ZIP文件中的文本文件的字符串的代码,现在我们被要求缩放ZIP文件中的所有的图像的尺寸到640 x 480。看起来我们可以使用一个非常类似的`ZipReplace`范例。第一个冲动可能是保存该文件的副本并将`find_replace`方法更改成`scale_image`方法,或类似的方法。 </b> 但是,那不酷。如果有一天我们想把`unzip`和`zip`方法改为也可以打开TAR文件呢?或者,我们可能希望为临时文件提供一个有保证的唯一目录。无论哪种情况,我们都必须在两个不同的地方改变它! </b> 我们将从演示这个问题的基于继承的解决方案开始。首先我们将把原始的`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`局部变量混淆。这有助于代码更易读,即使它实际上不是设计上的改变。 </b> 我们还删除了`__init__`初始化方法的两个参数(`search_string`和`replace_ string`),这两个参数被指定用于`ZipReplace`类。然后我们将`zip_find_replace`方法重命名为`process_zip` ,使它能够调用一个(尚未确定的)`process_files`方法(替代`find_replace`方法)。这些名称更改有助于演示新类的普遍性。请注意,我们已经删除了`find_replace`方法;该代码是专门用于`ZipReplace`的,在这里没有任何意义。 </b> 这个新的`ZipProcessor`类实际上并没有定义`process_files`方法;所以如果我们直接运行它,它会引发一个异常。因为它不是用来运行,我们直接删除了原始脚本底部的主调用。 </b> 现在,在我们继续我们的图像处理应用程序之前,让我们修复一下我们`zipsearch`类(译注:感觉应该是` ZipReplace`)的原始版本,以使用这个父类: ``` 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): '''对临时目录中的所有文件执行搜索和替换''' 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`来描述它在做什么。 </b> 现在,考虑到我们现在只有一个项目,这是相当多的工作,但在功能上与我们开始时没有什么不同!但是做了这些之后,现在对我们来说,编写在ZIP中对文件进行操作的其他类要容易得多,例如(假设请求的)照片缩放。此外,如果我们想改进或修复zip功能,我们可以通过改变`ZipProcessor`基类即可。这样维护会更加有效。 </b> 看看现在创建一个利用`ZipProcessor`功能的照片缩放类。(注意:本课程需要第三方`pilleow`库去使用`PIL`模块。你可以用`pip install pillow`安装它) ``` from zip_processor import ZipProcessor import sys from PIL import Image class ScaleZip(ZipProcessor): def process_files(self): '''把文件夹中图片缩放至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`类,它可能用于文本编辑器或文字处理器。这个类应该具有哪些对象、函数或`property`呢? </b> 我们可以从代表`Document`内容的`str`开始,但是在Python中,字符串不是可变的(但可以改变)。一旦定义了一个字符串`str`,它就是永远。我们不能对这个`str`插入或者删除一个字符,除非我们创建一个全新的字符串对象。那会留下大量字符串对象`str`占用内存,直到Python垃圾回收器清理掉它们。 </b> 因此,我们将使用一个字符列表替换字符串,这样我们可以随意修改字符串。此外,Document类需要知道当前指针在列表中的位置,可能还应该存储文档的文件名。 </b> > 真实文本编辑器使用基于二叉树的数据结构,称为绳子来模拟他们的文档内容。这本书的标题不是“高级数据结构”,所以如果您有兴趣了解更多关于这个迷人的话题,你可在网上搜索绳索数据结构。 现在,它应该有什么方法呢?我们可能想做很多事情,对文本文档执行操作,包括插入、删除和选择字符,剪切、复制、粘贴、选择以及保存或关闭文档。看起来有大量的数据和行为,所以把这些放在`Document`类中是有意义的。 </b> 一个相关的问题是:这个类应该由一堆基本的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' ``` 看起来可以用。我们可以把键盘上的字母和箭头键和这些上面方法联系起来,文档将更好地跟踪一切。 </b> 但是如果我们想连接的不仅仅是箭头键呢?如果我们还想连接呢`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`位置的方法。 > 这个代码不太安全。你很容易地越过结尾,如果你试图在一个空文件上回到`Home`,它会崩溃。这些例子保持简短,以使它们可读,但并不意味着它们是无敌的!你可以改进作为练习这方面的错误检查;这可能扩展你的异常处理技能。 `Document`类本身几乎没有变化,除了删除两种方法,将它们移动到了`Cursor`类(译注:注意`save`方法,原本是用`open`的,可以省下`close`方法): ``` 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 `,以便我们看到完整的字符串: ``` @property def string(self): return "".join(self.characters) ``` 这让我们的测试变得简单了一点: ``` >>> print(d.string) hello world ``` 这个框架很简单(虽然可能有点耗时!),延伸到创建和编辑完整的明文文档。现在,让我们把它扩展到为富文本;可以有粗体、下划线或斜体字符的文本。 </b> 我们有两种方法可以处理这个问题;第一种是将“假”字符插入我们的字符列表,就像指令一样,比如“将字符变成粗体字,直到遇到停止粗体字符”。第二种方法是给每个字符添加它应该有什么格式的信息。虽然前一种方法可能更常见,我们将实现后一种解决方案。为此,我们显然需要一个字符类。这个类将有一个表示字符的属性,以及三个布尔属性,表示它是否是粗体、斜体还是下划线。 </b> 嗯,等等!这个`Character`类会有什么方法吗?如果没有,也许我们应该使用Python众多数据结构中的一种;元组或命名元组可能就足够了。有什么我们想做的事吗?或者对一个字符进行调用? </b> 很明显,我们可能想用字符做一些事情,比如删除或复制,但是这些都是需要在文档`Document`级别处理的事情,因为它们正在修改字符列表。有什么需要对单个字符做的事情吗? </b> 事实上,现在我们正在思考什么是字符`Character`类...它到底是什么呢?可以说`Character`类是字符串吗?也许我们应该在这里使用继承关系?然后我们可以利用字符串`str`实例附带的众多方法。 </b> 我们在谈论什么样的方法?开始于`startswith`,剥去`strip`,找到`find`,小写`lower`,还有更多其他方法。这些方法中的大多数都期望在包含多个字符的字符串上工作。相反,如果`Character`是`str`子类,如果是对多字符操作,我们最好重写`__init__`来引发异常。既然我们免费获得的所有这些方法对我们的`Character`类来说并不真正适用,似乎我们根本不需要使用继承。 </b> 这让我们回到了最初的问题;`Character`应该是一个类吗?我们可以使用`object`类一种非常重要的特殊方法,代表我们`Character`的优势。这个方法叫做`__str__`(两个下划线,如`__init__`),用于字符串操作功能(如打印),`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()`函数应用于它们时,将在字符前添加一个特殊字符前缀。这没什么太令人兴奋的。如果我们想让`Character`和`Document`、`Cursor`类一起工作,只须做一些小的修改。在文档`Document`类中,我们在插入`insert`方法的开始位置插入两行代码: ``` def insert(self, character): if not hasattr(character, 'character'): character = Character(character) ``` 这看起来相当奇怪。它的基本目的是检查传入的字符是否是字符`Character`或字符串`str`。如果它是一个字符串,它将被包装在一个字符`Character`类中(译注:可是如果是字符串,也过不了断言检查啊!),这样列表中的所有对象都将是字符`Character`对象。然而,完全有可能的是,那些使用我们代码的人想使用的类,既不是字符`Character`类也不是字符串类,他们想用鸭子类型。如果对象有字符属性,我们假设它是一个“类似字符”的对象。如果不是,我们就假设它是一个用`Character`类包装的“类似字符串”对象。这也有助于程序利用鸭子类型和多态的优势;只要对象具有字符属性,就可以用在`Document`类中。 </b> 这个通用检查其实很有用。例如,如果我们想创建一个语法突出的编辑器:我们可能需要关于字符的额外数据,例如字符属于哪种类型的语法标记。请注意,如果我们正在做很多类似的比较,将角色实现为带有适当`__subclasshook__`的抽象基类,可能会更好一些。正如第3章“当对象是相似的”所讨论的那样。 </b> 此外,我们需要修改`Document`类上的字符串`property`来接受新的字符值。我们所需要做的就是在加入每个字符之前调用`str()`。 ``` @property def string(self): return "".join((str(c) for c in self.characters)) ``` 这段代码使用生成器表达式,我们将在第9章“迭代器模式”中讨论。这是对序列中的所有对象执行特定操作的快捷方式。 </b> 最后,我们还需要检查`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__`方法,对于我们要求的基本测试,这已经足够了。 ## 总结 在本章中,我们将重点放在识别对象上,尤其是那些不是显而易见的对象;以及用于管理和控制的对象。对象应该都有数据和行为,但是`property`可以用来模糊两者之间的区别。DRY原则是代码质量的重要指标,并且可以应用组合和继承来减少代码重复。 </b> 在下一章中,我们将介绍几个内置的Python数据结构对象,关注它们面向对象的属性以及如何扩展或者改编它们。