8.2 组合成Parts对象
面向对象设计实践指南:Ruby语言描述
很明显,零件列表会包含一长串的单个零件。现在应该添加表示单个零件的类了。单个零件的类名显然应该为Part。不过,当你已拥有一个Parts类时,引入Part类会让交谈变得很困难。当同样的这个名字已经用于指代单个的Parts对象时,使用“parts”一词来指代一堆的Part对象很容易让人感到困惑。不过,前面的措辞说明了一种会顺带引起交流问题的技术。当在讨论Part和Parts时,你可以在类名之后带上“object”一词,如有必要还可以使用复数的“object”。
你可以在一开始就避免出现这种交流问题,方法是选择不同的类名。但其他的名字可能没那么好的表现力,并且很可能引入新的沟通问题。这种“Parts/Part”情形很常见,需要正面对待。选择这些类名称需要一次准确的交流,这才是其自身追求的目标。
因此,有一个Parts对象,它可能包含多个Part对象,就这么简单。
8.2.1 创建Part
图8-4展示了一张新的时序图,它说明的是Bicycle与其Parts对象之间,以及Parts对象同其Part对象之间的会话。Bicycle会将spares发送给Parts,接着Parts对象会将needs_spare发送给每一个Part.
以这种方式对设计进行更改,会要求创建新的Part对象。那个Parts对象现在由Part对象组合而成,如图8-5里的类图所示。在直线上靠近Part的“1..*”所表示的是:一个Parts拥有一个及以上的Part对象。
引入新的Part类,可以大大简化已有的Parts类。它现在已变成了一个简单的包裹器,将一组Part对象包裹在一起。Parts可以过滤Part对象列表,并返回那些需要备件的Part对象。下面的代码展示了三个类:现有的Bicycle类,更新后的Parts类和新引入的Part类。
1 class Bicycle
2 attr_reader :size, :parts
3
4 def initialize(args ={})
5 @size = args[:size]
6 @parts = args[:parts]
7 end
8
9 def spares
10 parts.spares
11 end
12 end
13
14 class Parts
15 attr_reader :parts
16
17 def initialize(parts)
18 @parts = parts
19 end
20
21 def spares
22 parts.select {|part| part.needs_spare}
23 end
24 end
25
26 class Part
27 attr_reader :name, :description, :needs_spare
28
29 def initialize(args)
30 @name = args[:name]
31 @description = args[:description]
32 @needs_spare = args.fetch(:needs_spare, true)
33 end
34 end
有了三个类之后,你便可以创建单个的Part对象。下面的代码创建了多个不一样的零件,并将每一个保存在某个实例变量里。
1 chain =
2 Part.new(name: 'chain', description: '10-speed')
3
4 road_tire =
5 Part.new(name: 'tire_size', description: '23')
6
7 tape =
8 Part.new(name: 'tape_color', description: 'red')
9
10 mountain_tire =
11 Part.new(name: 'tire_size', description: '2.1')
12
13 rear_shock =
14 Part.new(name: 'rear_shock', description: 'Fox')
15
16 front_shock=
17 Part.new(
18 name: 'front_shock',
19 description: 'Manitou',
20 needs_spare: false)
单个的Part对象可以被组合成Parts。下面的代码将公路自行车的Part对象组合成了适合公路自行车的Parts。
1 road_bike_parts =
2 Parts.new([chain, road_tire, tape])
当然,你也可以跳过这个中间步骤,在创建Bicycle时简单、迅速地构建Parts对象,如下面第4~6行和第22~25行所示。
1 road_bike =
2 Bicycle.new(
3 size: 'L',
4 parts: Parts.new([chain,
5 road_tire,
6 tape]))
7
8 road_bike.size # -> 'L'
9
10 road_bike.spares
11 # -> [#<Part:0x00000101036770
12 # @name="chain",
13 # @description="10-speed",
14 # @needs_spare=true>,
15 # #<Part:0x0000010102dc60
16 # @name="tire_size",
17 # etc ...
18
19 mountain_bike =
20 Bicycle.new(
21 size: 'L',
22 parts: Parts.new([chain,
23 mountain_tire,
24 front_shock,
25 rear_shock]))
26
27 mountain_bike.size # -> 'L'
28
29 mountain_bike.spares
30 # -> [#<Part:0x00000101036770
31 # @name="chain",
32 # @description="10-speed",
33 # @needs_spare=true>,
34 # #<Part:0x0000010101b678
35 # @name="tire_size",
36 # etc ...
正如从上面的第8~17行和第27~34行所看到的,这种新的代码编排很有效,并且其行为跟原来的那个Bicycle层次结构几乎完全一样。这里有一点差别,即Bicycle原有的spares方法会返回一个散列表,而新的spares方法返回的是一个Part对象数组。
虽然有也可以把这些对象当作是Part的实例,但是组合是要告诉你把它们当作扮演Part角色的对象。它们不一定是Part类类型,只需表现得像即可。也就是说,它们必须响应name、description和needs_spare。
8.2.2 让Parts对象更像一个数组
这段代码也可以工作,但很明显还有改进的空间。时间倒退片刻,请想想Bicycle里的parts和spares。感觉这些消息应该返回相同的内容,然而回过头来一看,这些对象的表现方式并不相同。当你向每一个零件询问其大小时,会发生什么事情呢?一起来看看。
在下面的第1行,spares开心地报告它的size为3。然而,在向parts问同样的问题时,实际情况却并非如此,如第2~4行所示。
1 mountain_bike.spares.size # -> 3
2 mountain_bike.parts.size
3 # -> NoMethodError:
4 # undefined method 'size' for #<Parts:...>
第1行可以工作,因为spares会返回一个数组(由Part对象组成),且Array能够明白size。第2行失败的因为在于parts会返回Parts实例,而它对size并不理解。
只要你拥有这种代码,类似的失败会不断缠绕着你。这两个事物看起来都很像数组。你不可避免地会把它们当成这个样子,尽管事实上恰好对了一半,但其结果就会像是踩在谚语常说的“院子里的钉耙”上。那个Parts对象并不像数组,所有把它当作数组的尝试都会失败。
往Parts里添加size方法,可以快速地解决眼前这个问题。实现一个方法,将size委托给实际的数组,这是件很简单的事情。如下所示。
1 def size
2 parts.size
3 end
不过,这种更改开始会让Parts类走下坡路。如果这样做,那么过不了多久你就会想要Parts对each做出响应,接着响应sort,然后响应Array里的其他所有事情。永无止境!越让Parts像数组,你会越期望它是一个数组。
也许Parts就是一个数组,虽然它多了一点额外的行为。你可以让它成为一个数组。下面这个示例展示了一个新版的Parts类。现在它是作为Array的一个子类。
1 class Parts < Array
2 def spares
3 select {|part| part.needs_spare}
4 end
5 end
上面这段代码直截了当地表达了这样一个思想,即Parts是Array的特殊化。在完美的面向对象语言里,该解决方案完全正确。不幸的是,Ruby语言还不够完美,并且这个设计隐藏着一个缺陷。
下面这个示例可以说明这一问题。当Parts成为Array的子类时,它继承了Array的所有行为。这种行为包括了像“+”那样的方法,这个方法会将两个数组连接在一起,并且返回第三个。下面的第3、4行展示了这样一个过程:“+”将两个现有的Parts实例结合在一起,并将结果保存到combo_parts变量。
这个似乎可以工作:combo_parts现在会包含正确的零件数量(第7行)。然而,事情明显不正确。如第12行所示,combo_parts无法回答其spares。
这个问题的根源暴露在第15~17行。尽管“+”连接的对象是Parts实例,但“+”所返回的对象即是Array实例,而Array并不明白spares是什么回事。
1 # Parts从Array继承了'+',
2 # 因此你可以将两个Parts相加。
3 combo_parts =
4 (mountain_bike.parts + road_bike.parts)
5
6 # '+'肯定会对Parts进行组合
7 combo_parts.size # -> 7
8
9 # 不过'+'所返回的那个对象
10 # 并不了解'spares'
11 combo_parts.spares
12 # -> NoMethodError: undefined method 'spares'
13 # for #<Array:...>
14
15 mountain_bike.parts.class # -> Parts
16 road_bike.parts.class # -> Parts
17 combo_parts.class # -> Array !!!
结果表明:在Array里,有许多方法都会返回新的数组,并且不幸的是,这些方法会返回新的Array类实例,而不是那个新子类的实例。Parts类仍然会误导人,而你只是将一个问题变换成另外一个。一旦你失望地发现Parts并没有实现size,那么你现在可能会惊讶地发现:将两个Parts加在一起会返回一个让spares无法理解的结果。
你已看过了三种不同的Parts实现。第一种实现只响应了spares和parts消息。它不像数组,它只是包含一个数组。第二种Parts实现添加了size。它只是做了一点细微的改进,并返回了其内部的数组大小。最后那个Parts实现了Array子类,因此其外在表现就像是一个数组,但如上面的示例子所展示的,Parts实例仍然会表现出意想不到的行为。
现在已很明显,并没有完美的解决方案。因此,现在要做一个艰难的决定。尽管它不能响应size,但原来的Parts实现可能已经够好了。如果是这样,那么你可以接受它缺乏类似数组一样的行为,并恢复到该版本。如果你需要size,而size不存在,那么最好是只添加这一个方法。因此,第二个实现可接受。如果你能容忍出现错误混淆的问题,或者你非常确定你永远不会遇到它们,那么成为Array的子类并安静地走开也具有意义。
在复杂性和可用性之间的中间区域的某个地方,会有下面这样的解决方案。下面的Parts类将size和each委托给了它的@parts数组,并包含Enumerable,以获得公共的遍历和检索方法。Parts的这个版本并没有Array的所有行为,但它宣称的所有事情至少都可以工作。
1 require 'forwardable'
2 class Parts
3 extend Forwardable
4 def_delegators :@parts, :size, :each
5 include Enumerable
6
7 def initialize(parts)
8 @parts = parts
9 end
10
11 def spares
12 select {|part| part.needs_spare}
13 end
14 end
将“+”发送给自己的Parts的实例会导致NoMethodError异常。不过,由于Parts现在可以响应size、each以及所有的Enumerable消息,并且当你错误地将它当作是一个实际的数组时会合理地引发错误,所以这段代码已很不错了。下面的示例表明spares和parts现在都可以响应size。
1 mountain_bike =
2 Bicycle.new(
3 size: 'L',
4 parts: Parts.new([chain,
5 mountain_tire,
6 front_shock,
7 rear_shock]))
8
9 mountain_bike.spares.size # -> 3
10 mountain_bike.parts.size # -> 4
又多了一版可工作的Bicycle、Parts和Part类。你现在应该重新考虑一下这个设计。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。