且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

更新时间:2021-09-06 07:59:14

本节书摘来自华章出版社《Python编程实战:运用设计模式、并发和程序库创建高质量程序》一 书中的第2章,第2.3节,作者:(美) Mark Summerfield,更多章节内容可以访问云栖社区“华章计算机”公众号查看。

2.3 组合模式

“组合模式”(Composite Pattern)可用来统合类体系中的两种对象:一种对象能够包含体系中的其他对象,另一种不能。前者叫做“组合体”(composite),后者叫做“非组合体”(noncomposite),两者统称“组件”(component)。按照传统的实现方式,这两种组件(一种是单个对象,一种是对象群集)所对应的类都继承自同一个基类。组合体与非组合体对象都具备同一套“核心方法”(core method),此外,组合体对象还有用于增加、移除、遍历子对象的其他方法。
该模式常用于实现Inkscape等绘图程序,这种程序需要有“群组”(group)与“解除群组”(ungroup)功能。用户可选取一批组件,并对其执行群组或解除群组操作,而这些组件中,有的是单个元素(比如矩形),有的是组合体(比如由各种图形所构成的脸谱)。
现在就来看个实际的例子。我们在main()函数里创建一些对象,有单个元素,也有组合体,然后,把它们全都打印出来。下面这段代码选自stationery1.py,代码后面是程序所输出的信息。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

每个SimpleItem对象都有名称及价格,CompositeItem对象也有名称,而且可以包含任意数量的SimpleItem或CompositeItem,也就是说,组合体可以无限嵌套。组合体的价格是其全部元素的价格之和。
在本例中,“铅笔套件”(pencil set)包含一只“铅笔”(pencil)、一把“尺子”(ruler)、一块“橡皮”(eraser)。而“盒装铅笔套件”(boxed pencil set)则包含“文具盒”(box)、铅笔套件及另一只铅笔。图2.4演示了盒装铅笔套件与其元素之间的关系。

《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

接下来,我们要以两种方法实现组合模式,第一种是传统做法,第二种是用一个类来表示组合体与非组合体。

2.3.1 常规的“组合体/非组合体”式层级

在常规的实现方式中,所有组件(无论是组合体还是非组合体)都具有相同的抽象基类AbstractItem,而且组合体要直接继承自另外一个抽象基类AbstractCompositeItem。整个类体系如图2.5所示。我们先看AbstractItem这个基类。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

我们要求所有子类的对象都能向用户汇报自己是不是组合体,同时还要求子类对象必须可以迭代,__iter__()方法的默认行为是返回一个“迭代器”(iterator),该迭代器会在空序列上迭代。
由于AbstractItem类至少有一个抽象方法或抽象属性,所以我们无法创建此类的对象。(从Python 3.3版本开始,也可以把@abstractproperty def method(...): ...写成@property @abstractmethod def method(...): ...。)
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

SimpleItem类用来表示非组合体。在本例中,每个SimpleItem对象都有name及price属性。
由于SimpleItem继承了AbstractItem,所以它必须重新实现基类的全部抽象属性及抽象方法,具体到本例,也就是要实现composite属性。由于AbstractItem类的__iter__()方法不是抽象的,所以无须重新实现,基类的代码会返回指向空序列的迭代器,子类沿用这个实现即可。这样做是合理的,因为SimpleItem对象不是组合体,所以返回空迭代器可以令我们把SimpleItem与CompositeItem对象统合起来(至少在迭代时是如此)。比方说,可以把由这两种对象混合而成的对象交给itertools.chain()来迭代。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

为了便于打印信息,我们给组合体及非组合体都定义了print()方法,打印时,缩进宽度会随着嵌套深度而加大。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

上面这个类是CompositeItem的基类,它实现了组合体所需的添加、移除和迭代等功能。由于该类从AbstractItem中继承了抽象的composite属性但却没提供实现,所以无法实例化。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

上述方法接受若干个item(既可以是SimpleItem,也可以是CompositeItem),并将其加入本组合体的子对象列表中。编写该方法时,不能去掉first参数而只保留items,因为假如那样做的话,items所捕获的元素数量就可能为0,虽然无害,但却会把用户在代码中所犯的逻辑错误掩盖掉。(item的用法请参阅1.2节的补充知识中所讲的解包操作。)另外,此函数没有禁止“循环引用”(circular reference),比方说,用户可通过add()方法把某个组合体对象设置成其自身的子对象。
在下一小节中,我们将看到另一种方法:只需一行代码即可实现add()方法。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

我们用了个简单的办法来实现remove()方法,一次只移除一个item。如果要移除的item是组合体,那么其下各个层级中的子对象也会一并从体系里移除。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

实现了__iter__()这个特殊方法之后,就可以在for循环、“列表推导”(comprehension)及生成器里遍历组合体对象的子对象了。本来也可以把方法体写成for item in self.children: yield item,但由于self.children是个序列(也就是列表),因此直接用Python内置的iter()函数来实现更为简单。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

上面这个CompositeItem类用来表示具体的组合体对象,它有自己的name属性,但与组合体相关的其他任务(也就是子对象的增加、移除、迭代操作)都交由基类处理。由于本类已经实现了抽象的composite属性,而且并未留下其他尚待实现的抽象属性或抽象方法,所以CompositeItem可以实例化。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

price是个“只读属性”(read-only property),其代码稍微有点难懂。这行代码构建了一条“生成器表达式”(generator expression),并用内置的sum()函数来计算组合体中所有子对象的价格,如果子对象也是组合体,那就递归计算下去。
for item in self表达式使得Python调用iter(self)来获取针对self的迭代器,而这又会调用__iter__()特殊方法,该方法返回指向self.children的迭代器。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

为了便于打印信息,本类也提供了print()方法,其首行代码与SimpleItem类的print()方法重复了。
本例中的SimpleItem与CompositeItem能够应对绝大多数情况。但若要构建更为精细的层次结构,则可以从这两个类或其抽象基类中继承专门的子类。
AbstractItem、SimpleItem、AbstractCompositeItem、CompositeItem这四个类确实搭配得很好,但代码稍显冗长,而且接口也不统一:组合体有add()及remove()方法,非组合体却没有。下一小节我们就来解决这些问题。

2.3.2 只用一个类来表示组合体与非组合体

上一小节的四个类(两个抽象类,两个具体类)似乎有些多了,而且接口也没有完全统一:只有组合体才支持add()及remove()方法。如果能忍受少许额外开销的话,我们可以只用一个类来表示组合体与非组合体:给这两种对象都配备一份列表及一个float型属性,非组合体对象里的列表是空的,而组合体对象里的float型属性并不使用。此方案所设计出来的对象其行为更加合理,因为两种对象的接口完全一致:非组合体与组合体一样,也有add()及remove()方法。
本节将新建Item类,组合体与非组合体都可以用这个类表示,无须再借助其他类。本节的范例代码摘录自stationery2.py文件。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

__init__()方法的参数不太整齐,但是没关系,我们稍后就会看到,用户实际上无须手工调用Item()来创建对象。
每个对象都必须有名字,而且还必须有价格,构建对象的时候,若未指定价格,则会使用默认值。此外,构建对象时还可以通过*items参数放入零个或多个子对象,这些子对象将保存在self.children里面。非组合体对象的children是个空列表。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

上面这两个工厂方法都是类方法,它们的参数也都比Item.__init__()整齐,二者均可非常方便地创建Item对象。有了这两个方法之后,SimpleItem("Ruler", 1.60)与CompositeItem("Pencil Set", pencil, ruler, eraser)可分别改写为Item.create("Ruler", 1.60)及Item.compose("Pencil Set", pencil, ruler, eraser)。而且上一小节的四个类现在都合并成Item类型了。当然,用户如果愿意,也可以直接用Item()来创建对象,比如:Item("Ruler", price=1.60)、Item("Pencil Set", pencil, ruler, eraser)。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

我们还提供了上面这两个工厂函数,其作用与刚才提到的那两个工厂方法相同。在使用模块时,这种工厂函数更为便利。例如,如果Item类在Item.py模块中,那么有了这两个工厂函数之后,我们就不用再写Item.Item.create("Ruler", 1.60)了,而是可以写成Item.make_item("Ruler", 1.60)。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

composite属性的实现方式与原来不同,因为有些Item对象是组合体,有些则不是。如果Item的self.children列表非空,那么我们就认定此对象是组合体。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

add()方法的实现代码与上一小节稍有不同,这次用的办法应该会更加高效一些。itertools.chain()函数接受若干个iterable,并返回一个iterable,在返回的iterable上面迭代,其效果就等于依次在参数里的各个iterable上面迭代。
无论对象是不是组合体,都可以在它上面调用add()方法。若在非组合体上调用add()方法,则会令其变为组合体。
把非组合体变为组合体时,会产生一个小问题:由于price属性现在表示所有子对象的总价格,所以该对象本身的价格反而看不到了。若想保留自身价格,当然也有其他办法可循。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

如果把组合体最后一个元素移除,那么它就变成了非组合体。这样做的效果是:本对象的价格不再是其所有子对象的价格总和了(这些子对象现在没有了),而会等于其私有的self.__price属性。为了确保相关逻辑正确,我们在__init__()方法里为所有对象都设置了初始价格。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

在组合体上调用__iter__()方法会返回其子对象列表,在非组合体上调用,会返回空序列。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

price属性必须同时适用于组合体及非组合体。对于前者来说,它表示其子对象的价格总和,对于后者来说,它表示本对象的价格。
《Python编程实战:运用设计模式、并发和程序库创建高质量程序》—— 2.3 组合模式

上面这个方法和price属性一样,也必须对组合体及非组合体都适用才行,此方法的代码与上一小节的CompositeItem.print()方法相同。如果在非组合体上执行print()方法,那么执行到for语句时,该对象就会返回指向空序列的迭代器,这样的话,遍历时就不用担心“无限递归”(infinite recursion)问题了。
由于Python语言很灵活,所以用它来创建组合体与非组合体是件很简单的事:想缩减存储开销时,可以分别建立两个类,而若要提供完全统一的接口,则可以合并成一个类。
3.2节讲述“命令模式”(Command Pattern)时,将会谈到组合模式的另一种变化形式。