Understanding the 4 rules of simple design 读书笔记

这本书通过很多例子来解释了 Kent Beck 提出的四个简单设计的规则

  1. Tests Pass
  2. Expresses Intent
  3. No Duplicatoin (DRY)
  4. Small

书中的例子都来自于一个叫 "生命游戏(Game of life)" 的程序,因为一千个人对同一个游戏有一千种写法,所以书中例子前后不一致甚至出现矛盾都是正常的。

强烈建议看书前先自己实现一遍 GOL, 否则看书里代码片断就会云里雾里。 下面就开始看实例吧。

测试名称应该影响对象的 API

看一下下面两个测试:

def test_a_new_world_is_empty
  world = World.new
  assert_equal 0, world.living_cells.count
end
def test_a_cell_can_be_added_to_the_world
  world = World.new world.set_living_at(1, 1)
  assert_equal 1, world.living_cells.count
end

看起来很正常,两个测试说明了一个新的世界在刚开始的时候,活着的细胞数量是 0, 再加入一个活细胞后数量是 1。 不过从 test should express intent 的角度来看,这样的写法明显不妥。

先看第一个测试,测试的方法名中提到了 empty 这个关键词,但是在测试代码中,并没有表达出这个关键词。 目前我们表达 empty 的方式是,计算 world 内部的一个集合的总数,如果是 0, 那么可以认为是 empty. 这样显然太麻烦了。 你也不希望在系统的其他地方,每次要判断 world 是否是 empty 都要写这么麻烦的一段代码吧,更何况这样把内部细节暴露出来的写法肯定是有问题的。 那么我们可以改成这样:

def test_a_new_world_is_empty
  world = World.new
  assert_true world.empty?
end

这样不仅表达了意图,也隐藏了 world 的那内部细节,以及提供了更友好的 API 给系统的其他地方调用。

再看看第二个测试,测试的方法名表示要测试的是一个被添加进去的细胞,但是在验证时仅仅验证了活细胞的总数,并没有提及那个被进入的细胞。 我们可以这样改:

def test_a_cell_can_be_added_to_the_world
  world = World.new
  world.set_living_at(1, 1)
  assert_true world.alive_at?(1, 1)
end

除了例子1中提到的好处外,我们可以发现现在我们已经在开始构建系统的 API 了(这里我们添加了一个 alive_at? 的 API)。 我们可以利用这个 API (以及之前的 empty 方法)构建另一个测试了:

def test_after_adding_a_cell_the_world_is_not_empty
  world = World.new
  world.set_living_at(1, 1)
  assert_false world.empty?
end

下次当你开始 TDD 时,要注意一下你所测试的东西到底是不是你想要测试的东西。

知识的重复

看看下面的 WordCell 类的代码:

class World
  def set_living_at(x, y)
    #...
  end
  def alive_at?(x, y)
    #...
  end
end

class LivingCell
  attr_reader :x, :y
end
class DeadCell
  attr_reader :x, :y
end

这里有一个不易察觉的重复(真的吗= =!),一个探测重复的方法是问问自己如果要修改一个地方的话,哪些地方也会受到影响。 那么,这里的例子的问题就是,当我们要把细胞的坐标从二维改成三维的话,要改哪些地方呢? 答案很明显,这里我们犯的错误是把二维坐标这个知识散布在系统的各个地方。 所以我们应该抽象出一个 Location 类:

class Location
  attr_reader :x, :y
end

那么,之前的类就会变成:

class World
  def set_living_at(location)
    #...
  end
  def alive_at?(location)
    #...
  end
end

class LivingCell
  attr_reader :location
end

class DeadCell
  attr_reader :location
end

上面这个问题我们也可以把它看成一个命名的问题,我们缺乏有效的表达意图的手段。 xy 这样的命名无疑是很糟糕的。如果出现糟糕的命名,通常是因为没有正确的抽象出一个概念。

行为吸引者

假设我们目前的程序有如下两个类:

class World
  def set_living_at(x, y)
    #...
  end
  def alive_at?(x, y)
    #...
  end
end
class Cell
  attr_reader :x, :y
  def alive_in_next_generation?
    # run rules
  end
end

下面我们需要一个方法来得到一个坐标的相邻坐标:

def neighbors_of(x, y)
  # calculate the coordinates of neighbors
end

那么问题来了,这个方法应该放在哪里呢?放在 Cell 中似乎很合理,但是它已经有了判断下一代是否存活的职责了, 再强行加入这个放方法就不符合但单一职责员原则了。 那么放 World 类呢,好像也很合理,闭经毕竟它张掌握着“世界”嘛,不过很明显,这样的话它就真的是”上帝类“ 了。

其实这种情况我们会经常碰到,我们需要一个行为,但是不知道把它放在哪里。这个例子中,我们可以(像上一节一样), 创建一个 Location 类,并负责这个行为,获取相邻坐标这样的新行为放在这里再适合不过了。 这个就是标题所说的,作者称之为行为吸引者的例子。 我们会为了一个新的行行为而创建新的类,不是新的类接受了这个行为,而是新的类吸引了这个习行为。

通常当我们出现很难把一个行为放到已有的类中时,就说明有一个概念没有在系统中被很好的表达。

测试状态还是测试行为

一般来说,由外到内的设计,我们都会先涉及最外层的类。 比如下面这个测试,一个新的世界是没有任何活细胞的:

def test_a_world_starts_out_empty
  world = World.new
  assert_true world.empty?
end

那么接下来, 理所当然的,下一个测试就是看看放入一个活细胞后系统的状态:

def test_world_is_not_empty_after_setting_a_living_cell
  world = World.new
  location = Location.random
  world.set_living_at(location)
  assert_false world.empty?
end

这样的步骤很自然,我们先进行一通操作,然后检查系统的状态。 下面我们来看看另一种方式,关注系统的行为而不是状态。 想想你什么样的行为是你期望的,然后让测试基于它来构建。

基于行为来构建系统的意思就是在需求必要的时候,只构建绝对需要的东西。 这样,我们就会得到刚刚好可以支持用例的代码。

当我要构建一个东西时,我会问自己:我的系统需要什么样的行为? 在这个例子中,问题有两个:

一般这样自问自答得到的答案会是类似 "因为xx行为需要它" 这样的句式。 那么我们来试试吧:

为什么我们要设置一个独立的细胞呢?

因为我们要设置一个初始化的状态呀,那为什么我们需要一个初始化的状态?

因为游戏需要计算下一个回合。

这样我们就得到一个基本的行为:计算下一回合。

在我们的系统中,这个基本行为发生在嘀嗒(tick)中,进入下一回合。

下面就可以写测试了,一个空的世界,下一个回合也还是空的。

def test_an_empty_world_stays_empty_after_a_tick
  world = World.new
  next_world = world.tick
  assert_true next_world.empty?
end

这样就是更加基于行为的测试方法。

不要让测试依赖别的测试

看看之前提到的一个例子:

def test_an_empty_world_stays_empty_after_a_tick
  world = World.new
  next_world = world.tick
  assert_true next_world.empty?
end

这个例子有点问题:我们怎么知道 new 出来的 World 就是空的呢? 当然之前有测试会覆盖这个问题,但现在的问题不是测试是否覆盖,而是测试之间产生了依赖。

如果 World 的初始化方法修改了,改成会设置一些 seed 细胞,那么这个测试也会连带着失败。 失败不可怕,问题的重点是,空的世界的下一个回合依然是空世界这一逻辑并没有修改,也没有错误。 但是这个测试却因为一个不相关的修改而出错,这样的问题 debug 起来也会非常麻烦。

我们不应该让这个测试是基于 new World 一定是空的这一假设为前提的。 我们应该明确指定需要一个空的 World:

def test_an_empty_world_stays_empty_after_a_tick
  world = World.empty
  next_world = world.tick
  assert_true next_world.empty?
end

这样一来,初始化的代码修改就不会影响这个测试了。

破坏抽象层次

脆弱的测试套件在面对修改时很容易出现大量的错误,为此甚至有人会倾向于少写单元测试而只写集成测试, 当然这是不对的,出现这种情况的话,我们应该去调查为什么会出现这种脆弱性并修正它。

看看下面的例子:

def test_world_is_not_empty_after_adding_a_cell
  world = World.empty
  world.set_living_at(Location.new(1,1))
  assert_false world.empty?
end

这里的问题是,在这个测试中,我们暗示了 world 的 empty 与否是与坐标 (1, 1) 相关的,这让这个测试的抽象没有处于同一个层次, 和之前提到过的一样,如果把二维坐标改成三维坐标的话,可以想象会有大量的单元测试报错。

我们可以使用 Mock 对象或者制造一个随机的 Location 来解决这个问题:

def test_world_is_not_empty_after_adding_a_cell
  world = World.empty
  world.set_living_at(Location.random)
  assert_false world.empty?
end

注意,这里的 Location.random 和 Location.new(1,1) 的区别并不是 (1,1) 是具体的位置, 而是抽象层次的统一,(1,1) 是具体的 x, y 坐标, 而 world 是否 empty 只和 location 有关, x, y 是 location 所关心的问题,不应该与 world 出现在一起。

天真的重复

对于细胞下一回合是生存还是死亡,开头的游戏规则已经说明了,这段逻辑并不难

class Cell
  attr_reader :alive # true | false
  def alive_in_next_generation?
    if alive
      number_of_neighbors == 2 || number_of_neighbors == 3
    else
      number_of_neighbors == 3
    end
  end
end

肯定有同学会发现这里有一个重复, 那就重构一下试试:

class Cell
  # ...
  def alive_in_next_generation?
    (alive && number_of_neighbors == 2) || number_of_neighbors == 3
  end
end

代码看起来简介多了~, 但是 这是一个典型的对于 DRY 原则的错误理解 !!

DRY 原则的定义是:

Every piece of knowledge has one and only one representation

事实上,这句话中没有任何提到代码的地方。出现重复的代码和 DRY 有必然联系吗? 回过头再看看我们的重构有没有问题,为了让问题看起来更清晰,不妨抽象出两个方法:

class Cell
  # ...
  def alive_in_next_generation?
    if alive
      stable_neighborhood?
    else
      genetically_fertile_neighborhood?
    end
  end

  private
  def stable_neighborhood
    number_of_neighbors == 2 || number_of_neighbors == 3
  end

  def genetically_fertile_neighborhood?
    number_of_neighbors == 3
  end
end

现在你还觉得第一次的重构是正确的吗? DRY 原则应该从意图上去理解,而不是单纯的代码的字面重复。

程序上的多态

继续上面提到的代码:

class Cell
  # ...
  def alive_in_next_generation?
    if alive
      stable_neighborhood?
    else
      genetically_fertile_neighborhood?
    end
  end
end

首先 alive_in_next_generation? 是一个状态查询式的命名,而不是一个行为。 我们为什么对于是否 alive 感兴趣呢? 对于当前已经活着的细胞,它就是 alive 的, 而对于死掉的细胞,它下一回合可能会“复活”,看来很难取一个更好的名字来统一这两种(以后可能更多)的情况。

说到多种情况,这种充斥 if else 的代码,稍有经验的同学都知道可以用多态来解耦。

class LivingCell
  def alive_in_next_generation?
    # neighbor_count == 2 || neighbor_count == 3
    stable_neighborhood?
  end
end
class DeadCell
  def alive_in_next_generation?
    # neighbor_count == 3
    genetically_fertile_neighborhood?
  end

这样,就算以后有了生死以外的状态,也可以通过新增一个类来处理了,比如:

class ZombieCell
  def alive_in_next_generation?
    # new, possibly more complex rules
  end
end

不过,为了多态, alive_in_next_generation? 这个名字还是不能修改,如果为每种情况设置一个更好的名字, 比如 stays_alive?comes_to_life :

class LivingCell
  def stays_alive?
    neighbor_count == 2 || neighbor_count == 3
  end
end
class DeadCell
  def comes_to_life?
    neighbor_count == 3
  end
end

这样就失去了多态的好处了,当然这取决于你是如果调用这些类的,不过至少,提取出多态类给了你重新命名的机会。

对用例做出假设

看看这个例子:

class LivingCell
  def stays_alive?(number_of_neighbors)
    number_of_neighbors == 2 || number_of_neighbors == 3
  end
end
class DeadCell
  def comes_to_life?(number_of_neighbors)
    number_of_neighbors == 3
  end
end

对于一个 entity, 当一个查询方法需要外部参数(而不是内部状态)来实现时,就要注意了,可能有职责上的设计问题。 那么一个细胞的下一回合状态如何,是否可以用一个 Rule 类来处理呢,这样的职责划分就更清晰了。 注意,这里的关键来了,在这个例子中(代码没有省略的部分),我们可以直接把两个类的名字改掉:

class LivingCellRules
  def stays_alive?(number_of_neighbors)
    number_of_neighbors == 2 || number_of_neighbors == 3
  end
end
class DeadCellRules
  def comes_to_life?(number_of_neighbors)
    number_of_neighbors == 3
  end
end

注意上面的变化是改类名哦,那意味着现在不存在 LivingCell 或者 DeadCell 了。 感觉不可思议吗?一个 Game of Life 的游戏,居然没有 Cell 类。 但仔细想想,Location 类可以直接和 Rule 类关联吗?或者说,我们的世界是一个无尽的世界, 我们需要跟踪每一个 cell 吗?又或者 Location 类已经包含了 Rule 的职责。。。 "我们需要这个抽象吗?" 这个问题很常见, 我们常常假设在未来某个时候我们会用到它, 但真正的答案只有在实际存在的用例面前才会出现,不要过早抽象。

展开一个对象

类似 Ruby 这样的语言,都有一个设计就是方法的最后一个表达式会被作为返回值。

不过现在我们要讨论的是编写 没有返回值 的代码。

这样的写法会让你不再能获取对象的属性(没有查询方法),这样有助于构建高内聚的对象(只借助对象自己来管理内部状态)。 不过问题来了,那我们怎么测试呢?比如我想知道两个 location 对象是不是同一个坐标。 看看下面的例子:

class Location
  attr_reader :x, :y
end

location1 = Location.new(1, 1)
location2 = Location.new(1, 2)

if location1.equals?(location2)
  # Do something interesting
end

你可以这样写:

class Location
  attr_reader :x, :y
  def equals?(other_location)
    self.x == other_location.x && self.y == other_location.y
  end
end
location1.equals?(location2)

不过这样犯规了,你向 otherlocation 询问了它的 x 和 y 属性。

来看看作者使用的 ”展开(unwrapping)“ 技巧吧:

class Location
  attr_reader :x, :y
  def equals?(other_location)
    other_location.equals_coordinate?(self.x, self.y)
  end
  def equals_coordinate?(other_x, other_y)
    self.x == other_x && self.y == other_y
  end
end

通过把对象自己的属性传递给 otherlocation 对象,巧妙的避开了规则的限制。 等等,这样真的就 OK 了吗? equals? 方法可是返回了一个布尔值啊~ 好吧,因为之前提到的 Ruby 的特性,我们先把返回值手动清除:

class Location
  attr_reader :x, :y
  def equals?(other_location)
    other_location.equals_coordinate?(self.x, self.y)
    nil
  end
  def equals_coordinate?(other_x, other_y)
    self.x == other_x && self.y == other_y
    nil
  end
end

深入想想,我们为什么要知道两个 location 是相等的? 肯定是因为我们要做一些操作,前提条件是它们相等。 那么, 一般的写法是:

count_of_locations = 0
if location1.equals?(location2)
  count_of_locations++
end

不过我们的 equals? 方法已经没有返回值了,所以我们要用一些技巧来实现上面的功能。

count_of_locations = 0
location1.equals?(location2, -> { count_of_locations++ })

没想到吧,这里使用 lambda 表达式巧妙的实现了同样的功能,具体的 Location 类内部实现如下:

class Location
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def equals?(other_location, if_equal)
    other_location.equals_coordinate?(self.x, self.y, if_equal)
    nil
  end
  def equals_coordinate?(other_x, other_y, if_equal)
    if self.x == other_x && self.y == other_y
      if_equal.()
    end
    nil
  end
end

ifeuqal 函数就是当两个 location 相当时要做的事情。

倒转组合替代继承

看看下面的 Cell 类:

class LivingCell
  attr_reader :location
end
class DeadCell
  attr_reader :location
end

我们已经抽象出了 location, 不过可以看到, location 本身还是出现了两次。 不过正如之前在 DRY 原则中提到的,我们要确定,这真的是同一个知识(knowledge)的重复吗? 可以说是的,因为两种 Cell 都通过 location 关联到了世界的一个位置。

那么如何消除这个重复呢?最容易想到的就是继承了:

class Cell
  attr_reader :location
end

class LivingCell < Cell
end

class DeadCell < Cell
end

但是这样真的好吗?新增了一个类,但是没有任何新的领域概念,这不符合 "small" 这个简单设计原则。 继承应该是被用来创建可重用的代码而不是消除重复(两者还是有区别的)。 不用继承的话,我们试试 Ruby 中的 module 吧:

class LivingCell
  include HasLocation
end

class DeadCell
  include HasLocation
end

感觉也差不多, module 算是一种多重继承的实现方式,它的问题和上面单继承提到的一样。

好好看看这个问题,两个类型需要连接同一个类型,那么不妨试试把依赖倒转过来,让一个类型去连接两个类型。

class Location
  attr_reader :x, :y
  attr_reader :cell
end
class LivingCell
  def stays_alive?(number_of_neighbors)
    number_of_neighbors == 2 || number_of_neighbors == 3
  end
end
class DeadCell
  def comes_to_life?(number_of_neighbors)
    number_of_neighbors == 3
  end
end

这样还有一个小问题, 就是 cell 有一个 location, 而不是 location 有一个 cell, 只要把 Location 类改名为 Coordinate 就行了。

class Coordinate
  attr_reader :x, :y
  attr_reader :cell
end