《面向对象设计实践指南:Ruby语言描述》—第8章 8.2节组合成Parts对象

    xiaoxiao2024-05-21  95

    本节书摘来自异步社区《面向对象设计实践指南:Ruby语言描述》一书中的第8章,第8.2节组合成Parts对象,作者【美】Sandi Metz,更多章节内容可以访问云栖社区“异步社区”公众号查看。

    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类。你现在应该重新考虑一下这个设计。

    本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

    相关资源:七夕情人节表白HTML源码(两款)
    最新回复(0)