Skip to content

Latest commit

 

History

History
892 lines (653 loc) · 52.9 KB

19.md

File metadata and controls

892 lines (653 loc) · 52.9 KB

十九、何时使用面向对象编程

在前面的章节中,我们已经介绍了面向对象编程的许多定义特性。我们现在已经了解了面向对象设计的原则和范例,并且已经介绍了 Python 中面向对象编程的语法。

然而,我们不知道如何,尤其是何时在实践中使用这些原则和语法。在本章中,我们将讨论我们获得的知识的一些有用的应用,同时探讨一些新的主题:

  • 如何识别物体
  • 数据和行为,再一次
  • 使用属性包装数据行为
  • 限制数据使用行为
  • 不要重复自己的原则
  • 识别重复代码

将对象视为对象

这似乎是显而易见的;您通常应该在代码中为问题域中的单独对象指定一个特殊类。我们在前几章的案例研究中已经看到了这样的例子:首先,我们确定问题中的对象,然后对它们的数据和行为建模。

在面向对象的分析和编程中,识别对象是一项非常重要的任务。但这并不总是像在短文中计算名词那么容易,坦白地说,我已经明确为此目的构建了短文。记住,对象是既有数据又有行为的东西。如果我们只处理数据,通常最好将其存储在列表、集合、字典或其他 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类,如我们在第 16 章中定义的,Python 中的对象,可以封装x和y坐标以及distance方法。问题是:这样做有价值吗?

对于前面的代码,可能是,可能不是。根据我们最近在面向对象原则方面的经验,我们可以在创纪录的时间内编写一个面向对象的版本。让我们对它们进行如下比较:

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对象一样工作。

If you are experimenting with the above code, you could subclass Polygon and override the __init__ function instead of replacing the initializer or copying the add_point and perimeter methods.

尽管如此,这段代码的面向对象版本和更面向数据的版本之间仍然没有明显的赢家。他们都做同样的事情。如果我们有接受多边形参数的新函数,例如area(polygon)或point_in_polygon(polygon, x, y),那么面向对象代码的好处将越来越明显。类似地,如果我们向多边形添加其他属性,例如color或texture,则将该数据封装到单个类中变得越来越有意义。

区别在于设计决策,但一般来说,一组数据越重要,就越有可能具有多个特定于该数据的函数,并且使用具有属性和方法的类就越有用。

当做出这个决定时,还要考虑如何使用课堂。如果我们只是在一个更大的问题的背景下计算一个多边形的周长,那么使用一个函数可能是编码最快的,并且更容易使用一次。另一方面,如果我们的程序需要以多种方式操纵许多多边形(计算周长、面积和与其他多边形的交点,移动或缩放它们,等等),我们几乎可以肯定地识别出一个对象;一个需要非常多才多艺的人。

此外,请注意对象之间的交互。寻找继承关系;没有类,继承是不可能优雅地建模的,所以一定要使用它们。寻找我们在第 15 章、面向对象设计中讨论的其他类型的关系、关联和组合。从技术上讲,合成可以仅使用数据结构建模;例如,我们可以有一个包含元组值的字典列表,但有时创建几个对象类就不那么复杂了,特别是当存在与数据相关联的行为时。

Don't rush to use an object just because you can use an object, but don't neglect to create a class when you need to use a class.

向具有属性的类数据添加行为

在本书中,我们一直关注行为和数据的分离。这在面向对象编程中非常重要,但我们即将看到,在 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"
print(c.name)

那么,为什么会有人坚持使用基于方法的语法呢?他们的理由是,有一天,我们可能希望在设置或检索值时添加额外的代码。例如,我们可以决定缓存一个值以避免复杂的计算,或者我们可能希望验证给定的值是否是合适的输入。

例如,在代码中,我们可以决定更改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属性。然后,我们再添加两个(半)私有方法来获取和设置该变量,并在设置时执行验证。

最后,我们在底部有property声明。这就是 Python 的魔力。它在Color类上创建一个名为name的新属性,以替换直接name属性。将该属性设置为属性。在引擎盖下,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值,这是我们首先要禁止的行为。成功

请记住,即使使用name属性,前面的代码也不是 100%安全的。人们仍然可以直接访问_name属性,如果愿意,还可以将其设置为空字符串。但是如果他们访问一个我们用下划线显式标记的变量以表明它是私有的,那么他们是必须处理结果的人,而不是我们。

详细属性

将property函数视为返回一个对象,该对象通过我们指定的方法代理任何设置或访问属性值的请求。内置的property类似于这样一个对象的构造函数,该对象被设置为给定属性的面向公共的成员。

这个property构造函数实际上可以接受两个附加参数,一个delete函数和属性的 docstring。实际上很少提供delete函数,但它可以用于记录某个值已被删除的事实,或者如果我们有理由否决删除,则可能否决删除。docstring 只是一个描述属性作用的字符串,与我们在第 16 章、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 中。delete函数通常为空,因为对象属性很少被删除。如果编码器确实试图删除未指定delete函数的属性,则会引发异常。因此,如果有合法理由删除我们的财产,我们应该提供该功能。

装饰器–创建属性的另一种方法

如果您以前从未使用过 Python decorators,那么您可能希望跳过本节,在我们讨论了第 22 章、Python 设计模式 I中的 decorator 模式之后再回到本节。但是,为了使属性方法更具可读性,您不需要了解如何使用 decorator 语法。

property函数可以与 decorator 语法一起使用,将get函数转换为property函数,如下所示:

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指定一个delete函数。我们无法使用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 中,数据、属性和方法都是类上的属性。方法是可调用的这一事实并不能将其与其他类型的属性区分开来;事实上,我们将在第 20 章、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  

2010 年,当我最初在本书的第一个版本中测试这段代码时,我的卫星连接非常糟糕,第一次加载内容时花了 20 秒。第二次,我在 2 秒钟内得到了结果(这实际上只是在解释器中键入行所需的时间)。在我更现代的连接中,它如下所示:

>>> webpage = WebPage("https://dusty.phillips.codes")
>>> import time
>>> now = time.time() ; content1 = webpage.content ; print(time.time() - now)
Retrieving New Page...
0.6236202716827393
>>> now = time.time() ; content2 = webpage.content ; print(time.time() - now)
1.7881393432617188e-05M

从我的 web 主机检索页面大约需要 620 毫秒。从我的笔记本电脑的内存,它需要 0.018 毫秒!

自定义 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 对象将负责确保按顺序执行以下三个步骤:

  1. 解压缩压缩文件
  2. 执行查找和替换操作
  3. 压缩新文件

该类使用.zip文件名初始化,并搜索和替换字符串。我们创建一个临时目录来存储解压后的文件,以便文件夹保持干净。pathlib库帮助进行文件和目录操作。在以下示例中,界面应该非常清晰:

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(f"unzipped-{filename}")

然后,我们为这三个步骤中的每一个创建一个整体的管理器方法。此方法将责任委托给其他对象:

def zip_find_replace(self): 
    self.unzip_files() 
    self.find_replace() 
    self.zip_files() 

显然,我们可以在一个方法中完成所有三个步骤,或者在一个脚本中完成所有步骤,而无需创建对象。分离这三个步骤有几个优点:

  • 可读性:每个步骤的代码都是一个独立的单元,易于阅读和理解。方法名称描述了该方法的功能,并且需要较少的额外文档来理解正在发生的事情。
  • 可扩展性:如果子类希望使用压缩的 TAR 文件而不是 ZIP 文件,它可以覆盖zip和unzip方法,而不必复制find_replace方法。
  • 分区:外部类可以创建该类的实例,直接在某个文件夹上调用find_replace方法,而不必zip内容。

委托方法是下面代码中的第一个方法;为确保完整性,包括其他方法:

    def unzip_files(self):
        self.temp_directory.mkdir()
        with zipfile.ZipFile(self.filename) as zip:
            zip.extractall(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(filename, filename.name)
        shutil.rmtree(self.temp_directory)

if __name__ == "__main__":
    ZipReplace(*sys.argv[1:4]).zip_find_replace()

为简洁起见,压缩和解压文件的代码很少有文档记录。我们目前的重点是面向对象的设计;如果您对zipfile模块的内部细节感兴趣,请在线或在交互式解释器中键入import zipfile ; help(zipfile)参考标准库中的文档。注意,这个玩具示例只搜索 ZIP 文件中的顶级文件;如果解压缩内容中有任何文件夹,则不会扫描这些文件夹,也不会扫描这些文件夹中的任何文件。

If you are using a Python version older than 3.6, you will need to convert the path objects to strings before calling extractall, rmtree, and file.write on the ZipFile object.

示例中的最后两行允许我们通过将zip文件名、搜索字符串和替换字符串作为参数传递,从命令行运行程序,如下所示:

$python zipsearch.py hello.zip hello hi  

当然,这个对象不必从命令行创建;它可以从另一个模块导入(执行批处理 ZIP 文件处理),也可以作为 GUI 界面的一部分进行访问,甚至可以作为知道从何处获取 ZIP 文件(例如,从 FTP 服务器检索 ZIP 文件或将其备份到外部磁盘)的更高级别的管理对象进行访问。

随着程序变得越来越复杂,被建模的对象越来越不像物理对象。属性是其他抽象对象,方法是更改这些抽象对象状态的操作。但无论多么复杂,每个对象的核心都是一组具体的数据和定义良好的行为。

删除重复代码

通常,管理风格类(如ZipReplace中的代码非常通用,可以以多种方式应用。可以使用组合或继承来帮助将此代码保留在一个位置,从而消除重复代码。在我们看任何这样的例子之前,让我们先讨论一点理论。具体来说,为什么重复代码是件坏事?

有几个原因,但它们都归结为可读性和可维护性。当我们编写一段类似于早期代码的新代码时,最简单的方法是复制旧代码并更改需要更改的内容(变量名、逻辑、注释),使其在新位置工作。或者,如果我们正在编写与项目中其他地方的代码相似但不完全相同的新代码,那么编写具有类似行为的新代码通常比找出如何提取重叠功能更容易。

但是,一旦有人必须阅读和理解代码,并且遇到重复的代码块,他们就会面临进退两难的境地。突然间,似乎有意义的代码必须被理解。一部分与另一部分有何不同?它们是怎么一样的?在什么条件下称为一节?我们什么时候打电话给对方?你可能会争辩说你是唯一一个阅读代码的人,但是如果你八个月都不接触代码,你会像一个新的程序员一样难以理解。当我们试图阅读两段相似的代码时,我们必须理解它们为什么不同,以及它们如何不同。这浪费了读者的时间;代码应该总是先编写为可读。

I once had to try to understand someone's code that had three identical copies of the same 300 lines of very poorly written code. I had been working with the code for a month before I finally comprehended that the three identical versions were actually performing slightly different tax calculations. Some of the subtle differences were intentional, but there were also obvious areas where someone had updated a calculation in one function without updating the other two. The number of subtle, incomprehensible bugs in the code could not be counted. I eventually replaced all 900 lines with an easy-to-read function of 20 lines or so.

阅读这样重复的代码可能会让人厌烦,但代码维护更让人痛苦。正如前面的故事所暗示的那样,保持两段相似的代码保持最新可能是一场噩梦。每当我们更新其中一个部分时,我们必须记住更新这两个部分,并且我们必须记住多个部分之间的差异,以便在编辑每个部分时可以修改我们的更改。如果我们忘记更新所有的部分,我们最终会遇到非常恼人的 bug,通常表现为,但我已经修复了,为什么它仍然在发生*?*

结果是,阅读或维护我们的代码的人必须花费天文数字的时间来理解和测试代码,而不是一开始就以不重复的方式编写代码所需的时间。当我们在做维护的时候,更令人沮丧;我们发现自己在说,为什么我第一次没有做到这一点?我们通过复制和粘贴现有代码节省的时间在我们第一次维护代码时就浪费了。代码被读取和修改的次数和频率都比编写的要多得多。可理解的代码应该始终是优先考虑的。

这就是为什么程序员,特别是 Python 程序员(他们往往比普通开发人员更看重优雅的代码)遵循所谓的不要重复自己(DRY原则。DRY 代码是可维护的代码。我对新手程序员的建议是永远不要使用编辑器的复制粘贴功能。对于中级程序员,我建议他们在点击Ctrl+C之前三思而后行。

但是我们应该做什么来代替代码复制呢?最简单的解决方案通常是将代码移动到一个函数中,该函数接受参数来解释不同的部分。这不是一个非常面向对象的解决方案,但它通常是最优的。

例如,如果我们有两段代码将一个 ZIP 文件解压到两个不同的目录中,我们可以很容易地将其替换为一个函数,该函数接受该文件解压到的目录的参数。这可能会使函数本身更难阅读,但是一个好的函数名和 docstring 可以很容易地弥补这一点,并且调用函数的任何代码都会更容易阅读。

这当然是足够的理论!这个故事的寓意是:始终努力重构代码,使其更易于阅读,而不是编写看似更易于编写的糟糕代码。

实际上

让我们探讨两种重用现有代码的方法。在编写代码来替换一个充满文本文件的 ZIP 文件中的字符串之后,我们随后签订合同,将 ZIP 文件中的所有图像缩放到 640 x 480。看起来我们可以使用一个非常类似于我们在ZipReplace中使用的范例。我们的第一个冲动可能是保存该文件的副本,并将find_replace方法更改为scale_image或类似的方法。

但是,这是次优的。如果有一天我们想要改变unzip和zip方法来同时打开 TAR 文件呢?或者我们可能希望为临时文件使用一个保证唯一的目录名。无论哪种情况,我们都必须在两个不同的地方进行更改!

我们将首先演示一个基于继承的解决方案来解决这个问题。首先,我们将原始的ZipReplace类修改为一个超类,用于处理通用 ZIP 文件:

import sys
import shutil
import zipfile
from pathlib import Path

class ZipProcessor:
    def __init__(self, zipname):
        self.zipname = zipname
        self.temp_directory = Path(f"unzipped-{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(self.temp_directory)

    def zip_files(self):
        with zipfile.ZipFile(self.zipname, "w") as file:
            for filename in self.temp_directory.iterdir():
                file.write(filename, filename.name)
        shutil.rmtree(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类以利用这个父类,如下所示:

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)

此代码比原始版本短,因为它继承了父类的 ZIP 处理能力。我们首先导入我们刚刚编写的基类,并使ZipReplace扩展该类。然后,我们使用super()初始化父类。find_replace方法仍然存在,但我们将其重命名为process_files,以便父类可以从其管理接口调用它。因为这个名称不像旧名称那样具有描述性,所以我们添加了一个 docstring 来描述它在做什么。

考虑到我们现在所拥有的只是一个功能上与我们开始使用的程序没有什么不同的程序,这是一项相当大的工作!但在完成了这项工作之后,我们现在更容易编写对 ZIP 归档文件进行操作的其他类,例如(假设请求的)照片缩放器。此外,如果我们想要改进或修复 zip 功能,我们可以通过只更改一个ZipProcessor基类一次对所有子类进行修改。因此,维护将更加有效。

看看现在创建一个利用ZipProcessor功能的照片缩放类有多简单:

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(filename)

if __name__ == "__main__": 
    ScaleZip(*sys.argv[1:4]).process_zip() 

看这门课多么简单!我们以前做的所有工作都得到了回报。我们所要做的就是打开每个文件(假设它是一个图像;如果文件无法打开或不是图像,它将意外崩溃)、缩放它并将其保存回原处。ZipProcessor课程负责拉链和解扣,我们没有额外的工作。

个案研究

在本案例研究中,我们将尝试深入探讨这个问题,我应该在什么时候选择对象而不是内置类型?我们将建模一个可能在文本编辑器或字处理器中使用的Document类。它应该具有哪些对象、函数或属性?

对于Document内容,我们可以用str开头,但在 Python 中,字符串是不可变的(可以更改)。一旦str被定义,它就是永恒。如果不创建一个全新的字符串对象,我们无法在其中插入或删除字符。在 Python 的垃圾收集器认为适合清理之前,这将导致大量str对象占用内存。

因此,我们将使用字符列表,而不是字符串,可以随意修改。此外,我们需要知道列表中当前光标的位置,并且可能还应该存储文档的文件名。

Real text editors use a binary tree-based data structure called a rope to model their document contents. This book's title isn't Advanced Data Structures, so if you're interested in learning more about this fascinating topic, you may want to search the web for rope data structure.

我们可能需要对文本文档执行很多操作,包括插入、删除和选择字符;剪切、复制和粘贴所选内容;以及保存或关闭文档。看起来有大量的数据和行为,所以把这些东西放到自己的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类添加更多方法,向前或向后搜索字符串中的换行符(换行符,转义为\n,表示一行的结束和一行的开始),然后跳转到它们,但是如果我们对每个可能的移动动作(按单词移动,按句子移动,)都这样做的话翻页、P**年龄下降、行尾、空白处开始等),班级规模会很大。也许最好将这些方法放在一个单独的对象上。那么,让我们将Cursor属性转换为一个对象,该对象可以知道其位置,并可以操纵该位置。我们可以将前进和后退方法移动到该类中,并为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].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] != "\n"
        ):
            self.position += 1

此类将文档作为初始化参数,以便方法可以访问文档字符列表的内容。然后,它提供了简单的方法,可以像以前一样前后移动,也可以移动到home和end位置。

This code is not very safe. You can very easily move past the ending position, and if you try to go home on an empty file, it will crash. These examples are kept short to make them readable, but that doesn't mean they are defensive! You can improve the error checking of this code as an exercise; it might be a great opportunity to expand your exception-handling skills.

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):
        with open(self.filename, "w") as f:
            f.write("".join(self.characters))

我们只是更新了访问旧游标整数的任何内容,以使用新对象。我们现在可以测试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对象。然而,使用我们的代码的人完全可能希望使用 duck 类型使用既不是Character也不是字符串的类。如果对象具有 character 属性,则我们假定它是类似于Character的对象。但如果它不是,我们假设它是一个类似于str的对象,并将其包装在Character中。这有助于程序利用 duck 类型和多态性;只要对象具有 character 属性,它就可以在Document类中使用。

这种通用检查可能非常有用。例如,如果我们想制作一个带有语法高亮显示的程序员编辑器,我们需要关于字符的额外数据,比如字符属于什么类型的语法标记。请注意,如果我们正在进行大量此类比较,那么最好将Character实现为一个带有适当__subclasshook__的抽象基类,如第 17 章中所述,当对象类似时。

此外,我们需要修改Document上的 string 属性以接受新的Character值。我们需要做的就是在加入每个角色之前调用str(),如下所示:

    @property 
    def string(self): 
        return "".join((str(c) for c in self.characters)) 

这段代码使用了一个生成器表达式,我们将在第 21 章迭代器模式中讨论。它是对序列中的所有对象执行特定操作的快捷方式。

最后,我们还需要在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  

正如所料,每当我们打印字符串时,每个粗体字符前面都有一个*字符,每个斜体字符前面都有一个/字符,每个下划线字符前面都有一个_字符。我们所有的函数似乎都能工作,我们可以在事后修改列表中的字符。我们有一个可以工作的富文本文档对象,它可以插入一个合适的图形用户界面,并与一个用于输入的键盘和一个用于输出的屏幕连接起来。当然,我们希望在 UI 中显示真实的粗体、斜体和带下划线的字体,而不是使用我们的__str__方法,但这对于我们要求的基本测试已经足够了。

练习

我们已经研究了对象、数据和方法在面向对象的 Python 程序中相互交互的各种方式。和往常一样,你的第一个想法应该是如何将这些原则应用到你自己的工作中。您是否有任何乱七八糟的脚本可以使用面向对象的管理器重写?浏览一些旧代码,寻找不是动作的方法。如果名称不是动词,请尝试将其改写为属性。

想想你用任何语言编写的代码。它是否违反了干燥原则?有重复的代码吗?你复制并粘贴代码了吗?您是否因为不想理解原始代码而编写了两个版本的类似代码?现在回顾一下您最近的一些代码,看看是否可以使用继承或组合重构重复的代码。试着选择一个你仍然有兴趣维护的项目;代码不会太旧以至于你再也不想碰它了。这将有助于在你进行改进时保持你的兴趣!

现在,回顾一下我们在本章中看到的一些例子。从使用属性缓存检索到的数据的缓存网页示例开始。此示例的一个明显问题是缓存从未刷新。向属性的 getter 添加超时,并且仅当在超时过期之前请求了缓存页面时才返回该页面。您可以使用time模块(time.time() - an_old_time返回自an_old_time以来经过的秒数)来确定缓存是否已过期。

再看看基于继承的ZipProcessor。在这里使用组合而不是继承可能是合理的。您可以将这些类的实例传递给ZipProcessor构造函数并调用它们来完成处理部分,而不是扩展ZipReplace和ScaleZip类中的类。实现这一点。

你觉得哪个版本更容易使用?哪个更优雅?什么更容易阅读?这些都是主观问题;我们每个人的答案都不尽相同。然而,知道答案很重要。如果您发现您更喜欢继承而不是组合,那么您需要注意在日常编码中不要过度使用继承。如果您更喜欢组合,请确保不要错过创建优雅的基于继承的解决方案的机会。

最后,向案例研究中创建的各种类添加一些错误处理程序。他们应该确保输入单个字符,确保您不试图将光标移过文件的结尾或开头,确保您不删除不存在的字符,确保您不保存没有文件名的文件。尽可能多地考虑边缘案例,并对它们进行解释(思考边缘案例大约是专业程序员工作的 90%)。考虑不同的处理方法;当用户试图移动到文件末尾时,您应该引发异常,还是只保留最后一个字符?

在日常编码中,请注意复制和粘贴命令。每次在编辑器中使用它们时,请考虑改进程序的组织是否是一个好主意,以便只具有要复制的代码的一个版本。

总结

在这一章中,我们着重于识别对象,特别是那些不明显的对象;管理和控制的对象。对象应该同时具有数据和行为,但可以使用属性来模糊两者之间的区别。DRY 原则是代码质量的重要指标,可以应用继承和组合来减少代码重复。

在下一章中,我们将讨论如何集成 Python 的面向对象和非面向对象方面。一路上,我们会发现它比乍一看更面向对象!