[译] Clojure 中的设计模式(下)

Clojure 设计模式 Design Patterns


译自 Clojure Design Patterns
Author: Mykhailo Kozik


第十五集:单例

Feverro O'Neal 抱怨我们的 UI 样式太多了。
把每个应用的 UI 配置都统一成一份。

Pedro: 等下,我看这里要求每个用户都可以保存 UI 样式啊。
Eve: 可能需求有变吧。
Pedro: 好吧,那我们应该使用 单例 (Singleton) 保存配置,然后在需要用到的地方调用。

public final class UIConfiguration {
  public static final UIConfiguration INSTANCE = new UIConfiguration("ui.config");

  private String backgroundStyle;
  private String fontStyle;
  /* other UI properties */

  private UIConfiguration(String configFile) {
    loadConfig(configFile);
  }

  private static void loadConfig(String file) {
    // process file and fill UI properties
    INSTANCE.backgroundStyle = "black";
    INSTANCE.fontStyle = "Arial";
  }

  public String getBackgroundStyle() {
    return backgroundStyle;
  }

  public String getFontStyle() {
    return fontStyle;
  }
}

Pedro: 这样就可以在不同的 UI 之间共享配置了。
Eve: 没错是没错,但是为啥写了这么多代码?
Pedro: 因为我们需要保证只会有一个 UIConfiguration 的实例存在。
Eve: 那我问你一个问题:单例和全局变量之间有啥区别。
Pedro: 你说啥?
Eve: ……单例和全局变量之间的区别啊。
Pedro: Java 不支持全局变量。
Eve: 但是 UIConfiguration.INSTANCE 就是全局变量啊。
Pedro: 好吧,就算是吧。
Eve: 单例模式在 Clojure 里面的实现就是最简单的 def

(def ui-config (load-config "ui.config"))

(defn load-config [config-file]
  ;; process config file and return map with configuratios
  {:bg-style "black" :font-style "Arial"})

Pedro: 但是你这样怎么改变样式呢?
Eve: 你怎么在你的代码里改,我就怎么改。
Pedro: 额……好吧,我们增加点难度。把 UIConfiguration.loadConfig 变成公共的,这样当需要改配置的时候就可以调用它来改了。
Eve: 那我就把 ui-config 改成 atom 然后想改配置的时候就调用 swap!
Pedro: 但是 atoms 只在并发环境下才有用啊。
Eve: 第一点,虽然 atoms 在并发环境下有用,但是并不是只能用在并发环境下。第二点,atom 的读操作并不是你想的那么缓慢。第三点,这种改变 UI 配置的方式是原子性 的。
Pedro: 在这个简单例子里需要关心原子性么?
Eve: 需要啊。考虑这种可能性,UI 配置发生了变化,一些渲染器读取到了新的 backgroundStyle,却读取到了老的 fontStyle
Pedro: 好吧,那就给 loadConfig 加上 synchronized 关键字。
Eve: 那你必须还得给 getter 也加上 synchonized,这样会导致运行速度变慢。
Pedro: 我可以用双重检查锁定 习语啊。
Eve: 双重检查锁定是很巧妙,但是并不总是管用。
Pedro: 好吧我认输,你赢了。

第十六集:责任链

纽约营销组织 "A Profit NY" 需要在他们的公共聊天系统上开启敏感词过滤。

Pedro: 卧槽, 他们不喜欢" "这个字儿?
Eve: 他们是营利组织,如果有人在公共聊天室说脏话会造成经济损失的。
Pedro: 那又是谁定义了脏话列表?
Eve: George Carlin 。
(译注:原文给出的链接是 youtube 上 George Carlin 的演讲视频,由于你懂的原因这里替换成一个可以访问的介绍。)

边看边笑

Pedro: 好吧,那就加一个过滤器把这些脏字替换成星号好了。
Eve: 还要确保你的方案是可扩展的,或许还要添加其它的过滤器呢。
Pedro: 使用责任链模式应该是一个不错的候选。首先我们需要搞个抽象的过滤器。

public abstract class Filter {
  protected Filter nextFilter;

  abstract void process(String message);

  public void setNextFilter(Filter nextFilter) {
    this.nextFilter = nextFilter;
  }
}

Pedro: 然后,实现你所需要的具体的过滤器

class LogFilter extends Filter {
  @Override
  void process(String message) {
    Logger.info(message);
    if (nextFilter != null) nextFilter.process(message);
  }
}

class ProfanityFilter extends Filter {
  @Override
  void process(String message) {
    String newMessage = message.replaceAll("fuck", "f*ck");
    if (nextFilter != null) nextFilter.process(newMessage);
  }
}

class RejectFilter extends Filter {
  @Override
  void process(String message) {
    System.out.println("RejectFilter");
    if (message.startsWith("[A PROFIT NY]")) {
      if (nextFilter != null) nextFilter.process(message);
    } else {
      // reject message - do not propagate processing
    }
  }
}

class StatisticsFilter extends Filter {
  @Override
  void process(String message) {
    Statistics.addUsedChars(message.length());
    if (nextFilter != null) nextFilter.process(message);
  }
}

Pedro: 最后,组合成一个过滤器链,传给它需要处理的信息。

Filter rejectFilter = new RejectFilter();
Filter logFilter = new LogFilter();
Filter profanityFilter = new ProfanityFilter();
Filter statsFilter = new StatisticsFilter();

rejectFilter.setNextFilter(logFilter);
logFilter.setNextFilter(profanityFilter);
profanityFilter.setNextFilter(statsFilter);

String message = "[A PROFIT NY] What the fuck?";
rejectFilter.process(message);

Eve: 好的,现在轮到 Clojure了。只需把各种过滤器定义为函数。

;; define filters

(defn log-filter [message]
  (logger/log message)
  message)

(defn stats-filter [message]
  (stats/add-used-chars (count message))
  message)

(defn profanity-filter [message]
  (clojure.string/replace message "fuck" "f*ck"))

(defn reject-filter [message]
  (if (.startsWith message "[A Profit NY]")
    message))

Eve: 然后使用 some-> 宏链接各个过滤器。

(defn chain [message]
  (some-> message
          reject-filter
          log-filter
          stats-filter
          profanity-filter))

Eve: 你看到有多简单了么,不需要每次都调用 if (nextFilter != null) nextFilter.process(),他们就自然地链接在一起。调用链的顺序自然地依照 some-> 里从上到下填写函数的顺序,无需手动使用 setNext
Pedro: 这东西的可组合性真的强啊,但是为啥这里你选择使用 some->,而不是选择用 ->
Eve: 是为了实现 有阻过滤器 (reject-filter)。它可以尽早地停止处理过程,一旦有过滤器返回 nilsome-> 就会直接返回 nil
Pedro: 可以进一步解释一下么?
Eve: 看看实际用法你就懂了

(chain "fuck") => nil
(chain "[A Profit NY] fuck") => "f*ck"

Pedro: 懂了。
Eve: 责任链模式 不过是一种函数组合

第十七集:组合

女演员 Bella Hock 投诉说,在她的电脑上看不到我们社交网站的用户头像。

“看谁都是黑的,这是黑洞么?”

Pedro: 技术上来说是黑色方框。
Eve: 额,在我的电脑上也出现了这个问题。
Pedro: 应该是最近的一次更新把头像显示给搞坏了。
Eve: 奇怪啊,渲染头像的方式和渲染其它节点的方式是一样的啊,但是其它节点的显示都正常啊。
Pedro: 你确定是同一种渲染方式?
Eve: 额……不确定

开始扒拉代码

Pedro: 这里™发生了什么?
Eve: 不知谁从哪复制的代码,粘贴过来之后忘记改头像这部分了。
Pedro: 强烈谴责,开启谴责工具 git-blame
Eve: 谴责 虽然是好东西,但是我们还是得修复这个问题啊。
Pedro: 修复很简单啊,就在这加一行代码。
Eve: 我的意思是,真正解决掉这个问题。为啥我们要使用两段相似的代码来处理同一个模块?
Pedro: 对耶,我觉得我们可以用组合模式来搞定整个界面的渲染问题。我们定义最小的渲染元素是一个块 (Block)。

public interface Block {
  void addBlock(Block b);
  List getChildren();

  void render();
}

Pedro: 很显然一个块里面可以嵌套着其它的块,这是组合模式的核心所在,首先我们可以创造出一些块的实现。

public class Page implements Block { }
public class Header implements Block { }
public class Body implements Block { }
public class HeaderTitle implements Block { }
public class UserAvatar implements Block { }

Pedro: 然后把各种具体实现依然当作 Block 来处理

Block page = new Page();
Block header = new Header();
Block body = new Body();
Block title = new HeaderTitle();
Block avatar = new UserAvatar();

page.addBlock(header);
page.addBlock(body);
header.addBlock(title);
header.addBlock(avatar);

page.render();

Pedro: 这是一种关于组织结构的模式,是一种组合 (compose) 对象的好方式。所以我们叫它组合结构 (composite)
Eve: 喂,组合结构不就是个树形结构么。
Pedro: 是的。
Eve: 这种模式适用于所有的数据结构么?
Pedro: 不,只适用于列表和树形结构。
Eve: 实际上,树形可以用列表来表示。
Pedro: 怎么表示?
Eve: 第一个元素表示父节点,后续元素表示子节点,依次这样……
Pedro: 我懂了。
Eve: 为了更详细地进行说明,假如有这样一棵树

        A
     /  |  \
    B   C   D
    |   |  / \
    E   H J   K
   / \       /|\
  F   G     L M N

Eve: 然后这是这棵树的列表形式表达

(def tree
  '(A (B (E (F) (G))) (C (H)) (D (J) (K (L) (M) (N)))))

Pedro: 这括号数量有点夸张啊!
Eve: 用来明确定义结构,你懂的。
Pedro: 但是这样理解起来很困难啊。
Eve: 但适合机器识别,这里提供了一个十分酷炫的功能 tree-seq,用来解析这颗树。

(map first (tree-seq next rest tree)) => (A B E F G C H D J K L M N)

Eve: 如果你需要更强大的遍历功能,可以试试 clojure.walk
Pedro: 我看不懂,这东西好像有点难。
Eve: 不用全部理解,你就只需了解用一种数据结构就可以表示整棵数,一个函数就可以操作它。
Pedro: 这个函数都会干点啥?
Eve: 它会遍历这颗树,然后把指定的函数作用于所有的节点,在我们的例子里就是渲染每个块。
Pedro: 我还是不懂,可能我还是太年轻了,我们跳过树的这个部分。

第十八集:工厂方法

Sir Dry Bang 提议要给他们热卖的游戏增加新的关卡。关卡多多,圈钱多多。

Pedro: 我们要搞出来一个啥样的新关卡?
Eve: 就简单改一下道具资源然后加一点新的物体材质:纸,木头,铁……
Pedro: 这么做是不是有点脑残?
Eve: 反正本身就是个脑残游戏。如果玩家愿意砸钱给他的游戏角色买个彩色帽子,那肯定也愿意买个木头材质的块块儿。
Pedro: 我也这么觉得,不管咋说,先搞一个通用的 MazeBuilder 然后为每种类型的方块创建具体的 builder。这叫工厂模式。

class Maze { }
class WoodMaze extends Maze { }
class IronMaze extends Maze { }

interface MazeBuilder {
  Maze build();
}

class WoodMazeBuilder {
  @Override
  Maze build() {
    return new WoodMaze();
  }
}

class IronMazeBuilder {
  @Override
  Maze build() {
    return new IronMaze();
  }
}

Eve: 难道 IronMazeBuilder 还能不返回 IronMazes
Pedro: 这不是重点,重点是,如果你想要生产其它材质的方块,只需要改变具体的生产工厂。

MazeBuilder builder = new WoodMazeBuilder();
Maze maze = builder.build();

Eve: 这好像和之前的哪个模式挺像的。
Pedro: 你说哪个?
Eve: 我觉得像策略模式和状态模式。
Pedro: 怎么可能!策略模式是关于选择哪一种合适的操作,而工厂模式是为了生产适合的对象。
Eve: 但是生产同样可以看作一种操作。

(defn maze-builder [maze-fn])

(defn make-wood-maze [])
(defn make-iron-maze [])

(def wood-maze-builder (partial maze-builder make-wood-maze))
(def iron-maze-builder (partial maze-builder make-iron-maze))

Pedro: 嗯,的确看起来很像。
Eve: 对吧。
Pedro: 有什么使用范例没?
Eve: 用不着,按照你的直觉来使用就行,你可以回到上面再看一下 策略、状态 或 模板方法 这些章节。

第十九集:抽象工厂

玩家不愿意购买游戏推出的新关卡。于是 Saimank Gerr 搭了一个反馈云平台供玩家吐槽。根据反馈结果分析,出现最多的负面词汇是:“丑”,“垃圾”,“渣”。

改进一下关卡构建系统。

Pedro: 我就说了吧这是个垃圾游戏。
Eve: 是啊,雪地背景配木墙,太空侵入配木墙,啥都东西都搭配木制墙体是要闹哪样。
Pedro: 所以我们必须得把每关的游戏世界分离出来,然后再给每种世界分配一组具体的对象。
Eve: 解释一下。
Pedro: 我们不用以前构建具体方块的工厂方法了,取而代之的是使用抽象工厂,以创建一组相关对象,这样以来构建关卡的方式看起来就不会那么糟糕了。
Eve: 举个栗子。
Pedro: 看代码。首先我们定义抽象 关卡工厂的行为

public interface LevelFactory {
  Wall buildWall();
  Back buildBack();
  Enemy buildEnemy();
}

Pedro: 然后是关卡元素的层次结构,关卡就是由这些内容组成的

class Wall {}
class PlasmaWall extends Wall {}
class StoneWall extends Wall {}

class Back {}
class StarsBack extends Back {}
class EarthBack extends Back {}

class Enemy {}
class UFOSoldier extends Enemy {}
class WormScout extends Enemy {}

Pedro: 看到没?我们给每个关卡都提供了具体的对象,现在就可以给它们创建工厂了。

class SpaceLevelFactory implements LevelFactory {
  @Override
  public Wall buildWall() {
    return new PlasmaWall();
  }

  @Override
  public Back buildBack() {
    return new StarsBack();
  }

  @Override
  public Enemy buildEnemy() {
    return new UFOSoldier();
  }
}

class UndergroundLevelFactory implements LevelFactory {
  @Override
  public Wall buildWall() {
    return new StoneWall();
  }

  @Override
  public Back buildBack() {
    return new EarthBack();
  }

  @Override
  public Enemy buildEnemy() {
    return new WormScout();
  }
}

Pedro: 关卡工厂的实现类为各个关卡生产出相关的一组对象。这样肯定比以前的关卡好看。
Eve: 让我冷静一下。我真的看不出这和工厂方法有啥区别。
Pedro: 工厂方法把创建对象推迟到子类,抽象工厂也一样,只不过创建的是一组相关对象
Eve: 啊哈,也就是说我需要一组相关的函数来实现抽象工厂。

(defn level-factory [wall-fn back-fn enemy-fn])

(defn make-stone-wall [])
(defn make-plasma-wall [])

(defn make-earth-back [])
(defn make-stars-back [])

(defn make-worm-scout [])
(defn make-ufo-soldier [])

(def underground-level-factory
  (partial level-factory
           make-stone-wall
           make-earth-back
           make-worm-scout))

(def space-level-factory
  (partial level-factory
           make-plasma-wall
           make-stars-back
           make-ufo-soldier))

Pedro: 很眼熟。
Eve: 就是这么直接。你挂在嘴边的“一组相关的东西”,在我看来“东西”就是函数。
Pedro: 是的,很清晰,不过 partial 是干啥的。
Eve: partial 用来向函数提供参数。所以,underground-level-factory 只需考虑构建什么样式的墙体、背景和敌人。其余的功能都是从抽象的 level-factory 方法继承而来的。
Pedro: 很方便。

第二十集:适配

Deam Evil 举办了一场复古风格中世纪骑士对决。奖金高达 $100.000

我分你一半奖金,只要你能黑掉他的系统,允许我的武装突击队加入比赛。

Pedro: 终于,我们接到一个好玩的活了。
Eve: 我非常期待这场比赛啊。尤其是 M16 对阵铁剑的部分。
Pedro: 但是骑士们都穿着良好的盔甲啊。
Eve: F1 手榴弹根本不在乎 什么盔甲。
Pedro: 管他呢,只管干活拿钱。
Eve: 五万大洋,好价钱啊。
Pedro: 可不是嘛,瞅瞅这个,我搞到了他们竞赛系统的源码,虽然我们不大可能直接修改源码吧,但是说不准能找到一些漏洞。
Eve: 我找到漏洞了

public interface Tournament {
  void accept(Knight knight);
}

Pedro: 啊哈!系统只用了 Knight 做传入参数检查。 只需要把突击队员伪造 (to adapt) 成骑士就行了。让我们看看骑士都长什么样子

interface Knight {
  void attackWithSword();
  void attackWithBow();
  void blockWithShield();
}

class Galahad implements Knight {
  @Override
  public void blockWithShield() {
    winkToQueen();
    take(shield);
    block();
  }

  @Override
  public void attackWithBow() {
    winkToQueen();
    take(bow);
    attack();
  }

  @Override
  public void attackWithSword() {
    winkToQueen();
    take(sword);
    attack();
  }
}

Pedro: 为了能传入突击队员,我们先看看突击队员的原始实现

class Commando {
    void throwGrenade(String grenade) { }
    shot(String rifleType) { }
}

Pedro: 开始改造 (adapt)

class Commando implements Knight {
  @Override
  public void blockWithShield() {
    // commando don't block
  }

  @Override
  public void attackWithBow() {
    throwGrenade("F1");
  }

  @Override
  public void attackWithSword() {
    shotWithRifle("M16");
  }
}

Pedro: 这样就搞定了。
Eve: Clojure 里更简单。
Pedro: 真的?
Eve: 我们不喜欢类型,所以根本没有类型检查。
Pedro: 那你是怎么把骑士替换成突击队员的呢?
Eve: 本质上,骑士是什么?就是一个由数据和行为组成的 map 而已。

{:name "Lancelot"
 :speed 1.0
 :attack-bow-fn attack-with-bow
 :attack-sword-fn attack-with-sword
 :block-fn block-with-shield}

Eve: 为了能适配突击队员,只需把原始的函数替换为突击队员的函数

{:name "Commando"
 :speed 5.0
 :attack-bow-fn (partial throw-grenade "F1")
 :attack-sword-fn (partial shot "M16")
 :block-fn nil}

Pedro: 我们怎么分赃分钱?
Eve: 五五开。
Pedro: 我写的代码行多啊,我要七。
Eve: 行,七七开。
Pedro: 成交。

第二十一集:装饰者

Podrea Vesper 抓到我们在比赛上作弊。现在有两条路可以走:要么进局子,要么就帮他的超级骑士加入比赛。

Pedro: 我不想进监狱。
Eve: 我也不想。
Pedro: 那我们就再帮他做一次弊吧。
Eve: 和上一次一样,是吧?
Pedro 不完全是。突击队员是军队的人,本来是不允许参加比赛的。我们适配 (adapted) 了一下。但是骑士本来就允许参加比赛,不需要我们再改造了。我们必须 给现有的对象增加新的行为。
Eve: 继承还是组合?
Pedro: 组合,装饰者模式的主要目的就是要在运行时改变行为。
Eve: 所以,我们要怎么造出一个超级骑士呢?
Pedro: 他们计划派出骑士 Galahad,然后给他装饰 一下,让他拥有超多血量强力盔甲
Eve: 嘿,这个条子竟然还玩儿辐射[1]呢。
Pedro: 嗯哪,让我们先写一个抽象骑士类

public class Knight {
    protected int hp;
    private Knight decorated;

    public Knight() { }

    public Knight(Knight decorated) {
        this.decorated = decorated;
    }

    public void attackWithSword() {
        if (decorated != null) decorated.attackWithSword();
    }

    public void attackWithBow() {
        if (decorated != null) decorated.attackWithBow();
    }

    public void blockWithShield() {
        if (decorated != null) decorated.blockWithShield();
    }
}

Eve: 所以我们改造了哪些功能?
Pedro: 首先我们使用 Knight 类取代原来的接口,增加了血量属性。然后我们提供了两个不同的构造方法,默认无参的是标准行为,decorated 参数表示需要装饰的对象。
Eve: 用类代替接口是不是因为类更直接一些?
Pedro: 不是因为这个,是因为这样可以避免出现两个功能相似的类,同时不必强制对象实现所有的方法,因为我们给每个待装饰对象提供了方法的默认实现。
Eve: 好吧,那强力的盔甲在哪里?
Pedro: 很简单

public class KnightWithPowerArmor extends Knight {
    public KnightWithPowerArmor(Knight decorated) {
        super(decorated);
    }

    @Override
    public void blockWithShield() {
        super.blockWithShield();
        Armor armor = new PowerArmor();
        armor.block();
    }
}

public class KnightWithAdditionalHP extends Knight {
    public KnightWithAdditionalHP(Knight decorated) {
        super(decorated);
        this.hp += 50;
    }
}

Pedro: 两个装饰者就可以满足 FBI 的要求,然后我们就可以着手制造看起来和 Galahad 差不多,但是拥有强力盔甲和额外 50 点血量的超级骑士了。

Knight superKnight =
     new KnightWithAdditionalHP(
     new KnightWithPowerArmor(
     new Galahad()));

Eve: 这个特技加的可以。
Pedro: 接下来有请你来展示一下 Clojure 是怎么实现类似功能的。
Eve: 好的

(def galahad {:name "Galahad"
              :speed 1.0
              :hp 100
              :attack-bow-fn attack-with-bow
              :attack-sword-fn attack-with-sword
              :block-fn block-with-shield})

(defn make-knight-with-more-hp [knight]
  (update-in knight [:hp] + 50))

(defn make-knight-with-power-armor [knight]
  (update-in knight [:block-fn]
             (fn [block-fn]
               (fn []
                 (block-fn)
                 (block-with-power-armor)))))

;; create the knight
(def superknight (-> galahad
                     make-knight-with-power-armor
                     make-knight-with-more-hp)

Pedro: 的确也可以满足要求。
Eve: 是的,这里要提一下,强力盔甲装饰器是个亮点。
(译注:亮点可能是使用了闭包。)

第二十二集:代理

Deren Bart 是一个调酒制造系统的管理员。这个系统非常地死板难用,因为每次调制完毕之后,Bart 都必须手动的从酒吧库存中扣除已使用的原材料。把它改成自动的。

Pedro: 能搞到他代码库的权限么?
Eve: 不能,但是他给了一些 API。

interface IBar {
    void makeDrink(Drink drink);
}

interface Drink {
    List getIngredients();
}

interface Ingredient {
    String getName();
    double getAmount();
}

Pedro: Bart 不想让我们修改源码,所以我们得通过实现 IBar 接口来提供一些额外的功能 --- 自动扣除已用原料。
Eve: 怎么搞啊?
Pedro:代理模式 ,前几天我还看这个模式来着。
Eve: 讲给我听听呗。
Pedro: 基本思路就是,所有已有功能依然调用之前标准的 IBar 实现来执行,然后在 ProxiedBar 里提供新的功能

class ProxiedBar implements IBar {
    BarDatabase bar;
    IBar standardBar;

    public void makeDrink(Drink drink) {
       standardBar.makeDrink(drink);
       for (Ingredient i : drink.getIngredients()) {
           bar.subtract(i);
       }
    }
}
Pedro:

Pedro: 他们只需要把老的 StandardBar 实现类替换成我们的 ProxiedBar
Eve: 看起来超级简单啊。
Pedro: 是的,额外加入的功能并不会破坏已有功能。
Eve: 你确定?我们还没有做回归测试呢。
Pedro: 所有的功能都是委派给已经通过测试的 StandardBar 去执行的啊。
Eve: 但是同时你还调用了 BarDatabase 扣除了已用原材料啊。
Pedro: 我们可以认为他们是解耦的 (decoupled)
Eve: 哦……
Pedro: Clojure 里有什么替代方案么?
Eve: 这个,我也不清楚。在我看来你只是在用函数组合 (function composition)。
Pedro: 怎么说。
Eve: IBar 的实现类是一组函数,其它什么的各种 IBar 都不过是一组函数。你所谓的一切额外加入的功能都可以通过函数组合来实现。就好比在 make-drink 之后对酒吧库存进行 subtract-ingredients 操作不就行了。
Pedro: 可能用代码描述会更清晰一点?
Eve: 嗯,不过我并不觉得这有啥特别的

;; interface
(defprotocol IBar
  (make-drink [this drink]))

;; Bart's implementation
(deftype StandardBar []
  IBar
  (make-drink [this drink]
    (println "Making drink " drink)
    :ok))

;; our implementation
(deftype ProxiedBar [db ibar]
  IBar
  (make-drink [this drink]
    (make-drink ibar drink)
    (subtract-ingredients db drink)))

;; this how it was before
(make-drink (StandardBar.)
    {:name "Manhattan"
     :ingredients [["Bourbon" 75] ["Sweet Vermouth" 25] ["Angostura" 5]]})

;; this how it becomes now
(make-drink (ProxiedBar. {:db 1} (StandardBar.))
    {:name "Manhattan"
     :ingredients [["Bourbon" 75] ["Sweet Vermouth" 25] ["Angostura" 5]]})

Eve: 我们可以利用协议 (protocol) 和类型 (types) 把一组函数聚合在一个对象里。
Pedro: 看起来 Clojure 也有着面向对象的能力啊。
Eve: 没错,不仅如此,我们还可以使用 reify 功能,它可以允许我们在运行时创建代理。
Pedro: 就好比在运行时创建类?
Eve: 差不多。

(reify IBar
  (make-drink [this drink]
    ;; implementation goes here
  ))

Pedro: 感觉挺好用的。
Eve: 是啊,但是我还是没有理解它和装饰者的区别在哪。
Pedro: 完全不一样啊。
Eve: 装饰者给接口增加功能,代理也是给接口增加功能。
Pedro: 好吧,但是代理……
Eve: 甚至,适配器看起来也没啥区别嘛。
Pedro: 适配器用了另一个接口。
Eve: 但是从实现的角度来说,这些模式都是一样的道理,把一些东西包装起来,然后把调用委派给包装者。我觉得叫它们“包装者 (Wrapper)” 模式更好一些。

第二十三集:桥接

一位来自人力资源代理机构 "Hurece's Sour Man" 的女孩需要审核应征者是否满足职位要求。问题在于,一般来说工作岗位是顾客设计的,但是职位要求却是人力部门设计的。给他们提供一个灵活的方式来协调这个问题。

(译注:“工作岗位是顾客设计的”,人力资源代理机构的顾客就是用人单位了。也就是说工作岗位是用人公司设计的,职位要求是人力资源代理机构设计的。)
Eve: 说实话我没看明白这个问题。
Pedro: 我倒是有点这方面背景。他们的系统非常的奇怪,职位要求是用一个接口来描述的。

interface JobRequirement {
    boolean accept(Candidate c);
}

Pedro: 通过实现这个接口,来表示每一个具体的职位要求。

class JavaRequirement implements JobRequirement {
    public boolean accept(Candidate c) {
        return c.hasSkill("Java");
    }
}

class Experience10YearsRequirement implements JobRequirement {
    public boolean accept(Candidate c) {
        return c.getExperience() >= 10;
    }
}

Eve: 我好像明白了点。
Pedro: 你要谅解,毕竟这个层次结构是人力部设计的。
Eve: 好的。
Pedro: 然后还有一个 Job 层级,用来描述岗位,和职位要求一样,每个具体岗位都要实现 Job
Eve: 为啥他们要把每种岗位都用一个类来表示?明明一个对象就可以了啊。
Pedro: 这个系统设计的时候就是类要比对象还多,所以你就先凑合着。
Eve: 类比对象还多?!
Pedro: 是的,好好听别打岔。岗位和职位要求是两个完全分离开的层级,而且岗位是由用人单位设计的。现在我们有请 桥接 (Bridge) 模式来关联这两个分离的层级,并允许两者继续独立运转。

abstract class Job {
    protected List requirements;

    public Job(List requirements) {
        this.requirements = requirements;
    }

    protected boolean accept(Candidate c) {
        for (JobRequirement j : requirements) {
            if (!j.accept(c)) {
                return false;
            }
        }
        return true;
    }
}

class CognitectClojureDeveloper extends Job {
    public CognitectClojureDeveloper() {
        super(Arrays.asList(
                  new ClojureJobRequirement(),
                  new Experience10YearsRequirement()
        ));
    }
}

Eve: 桥呢?
Pedro: JobRequirement, JavaRequirement, ExperienceRequirement 是一个层级,是吧?
Eve: 是啊。
Pedro: Job, CongnitectClojureDeveloperJob, OracleJavaDeveloperJob 是另一个层级。
Eve: 哦,我明白了。职位和职位要求之间的联系就是那个桥。
Pedro: 非常对!这样以来人事部的人员就可以像这样来进行审核了。

Candidate joshuaBloch = new Candidate();
(new CognitectClojureDeveloper()).accept(joshuaBloch);
(new OracleSeniorJavaDeveloper()).accept(joshuaBloch);

Pedro: 总结一下要点。用人单位使用抽象的 Job 以及 JobRequirement 的实现。他们只需要大概描述一下岗位的情况就行了,然后人力资源部门负责把描述转换成一组 JobRequirement 对象。
Eve: 明白了。
Pedro: 据我了解,Clojure 可以用 defprotocoldefrecord 来模拟这个模式?
Eve: 是的,不过我想重温一下这个问题。
Pedro: 为啥啊?
Eve: 我们先整理一下套路:顾客描述岗位,人力资源部把它转换成一组职位要求,然后在求职数据库里跑一段脚本去逐一尝试看没有没有符合要求的人员?
Pedro: 没错。
Eve: 所以这里还是存在依赖关系啊,没有职位空缺的话 HR 啥也干不了。
Pedro: 这个,算是吧。但是他们还是可以在没有职位空缺的情况下设计出一组职位要求。
Eve: 目的何在?
Pedro: 提前搞出来,留着以后碰见一样的要求就可以直接拿来用了啊。
Eve: 行吧,但是这不就是自找麻烦了。本来我们只是想要找到一种在抽象与实现之间协调的方式而已。
Pedro: 也许吧,我想看看你是怎么在 Clojure 里用桥接模式解决这个特定问题的。
Eve: 简单。用专设层级 (adhoc hierarchies)。
Pedro: 要给抽象设置层级?
Eve: 是的,岗位是抽象 层级,然后我们只需要对其进行扩展。

;; abstraction

(derive ::clojure-job ::job)
(derive ::java-job ::job)
(derive ::senior-clojure-job ::clojure-job)
(derive ::senior-java-job    ::java-job)

Eve: HR 部门就好比开发者 , 他们提供抽象的具体实现。

;; implementation
(defmulti accept :job)

(defmethod accept :java [candidate]
  (and (some #{:java} (:skills candidate))
       (> (:experience candidate) 1)))

Eve: 如果以后有新岗位出现,但是岗位需求还没有被确认,当然也没有与之对应的 accept 方法,这个时候就会回退到上个层级。
Pedro: 蛤?
Eve: 假如某人创建了一个新的下属于 ::java 岗位的 ::senior-java 岗位。
Pedro: 哦!如果 HR 没有给委派值 ::senior-java 提供 accept 实现,多重方法就会委派给 ::java 对应的方法,对吧?
Eve: 小伙子学的挺快嘛。
Pedro: 但是这还是桥接模式么?
Eve: 这里本来就没有什么 ,但是同样得以让抽象与实现可以独立地运转。

剧终。

速查表 (代替总结)

模式非常难以理解,关于它们的介绍,通常都是使用面向对象的方式,再配上一堆 UML 图表和花哨的名词,而且还是为了解决特定语言下的问题,所以这里提供了一张迷你复习速查表,希望能用类比的方式帮助你理解模式的本质。

  • 命令 (Command) - 函数
  • 策略 (Strategy) - 接受函数的函数
  • 状态 (State) - 依据状态的策略
  • 访问者 (Visitor) - 多重分派
  • 模板方法 (Template Method) - 默认策略
  • 迭代器 (Iterator) - 序列
  • 备忘录 (Memento) - 保存和恢复
  • 原型 (Prototype) - 不可变值
  • 中介者 (Mediator) - 解耦和
  • 观察者 (Observer) - 在函数后调用函数
  • 解释器 (Interpreter) - 一组解析树形结构的函数
  • 羽量 (Flyweight) - 缓存
  • 建造者 (Builder) - 可选参数列表
  • 外观 (Facade) - 单一访问点
  • 单例 (Singleton) - 全局变量
  • 责任链 (Chain of Responsibility) - 函数组合
  • 组合 (Composite) - 树形结构
  • 工厂方法 (Factory Method) - 制造对象的策略
  • 抽象工厂 (Abstract Factory) - 制造一组相关对象的策略
  • 适配 (Adapter) - 包装,功能相同,类型不同
  • 装饰者 (Decorator) - 包装, 类型相同, 但增加了新功能
  • 代理 (Proxy) - 包装, 函数组合
  • 桥接 (Bridge) - 分离抽象和实现

演员表

很久很久以前,在一个很远很远的星系…… [2]

由于思维匮乏,所有登场的名字都是字母倒置游戏。

Pedro Veel - Developer
Eve Dopler - Developer
Serpent Hill & R.E.E. - Enterprise Hell
Sven Tori - Investor
Karmen Git - Marketing
Natanius S. Selbys - Business Analyst
Mech Dominore Fight Saga - Heroes of Might and Magic
Kent Podiololis - I don't like loops
Chad Bogue - Douchebag
Dex Ringeus - UX Designer
Veerco Wierde - Code Review
Dartee Hebl - Heartbleed
Bertie Prayc - Cyber Pirate
Cristopher, Matton & Pharts - Important Charts & Reports
Tuck Brass - Starbucks
Eugenio Reinn Jr. - Junior Engineer
Feverro O'Neal - Forever Alone
A Profit NY - Profanity
Bella Hock - Black Hole
Sir Dry Bang - Angry Birds
Saimank Gerr - Risk Manager
Deam Evil - Medieval
Podrea Vesper - Eavesdropper
Deren Bart - Bartender
Hurece's Sour Man - Human Resources

P.S. 从刚开始提笔距今早已超过 两年。时间飞逝,物换星移,Java 8 也已经发布了。

clojure programming java story patterns

18 December 2015


  1. 辐射 (Fallout) 是 Bethesda 出品的游戏。在游戏中有一种可驾驶的重型盔甲机器人。 ↩

  2. A long time ago in a galaxy far, far away... 出自电影《星球大战》。 ↩

你可能感兴趣的:([译] Clojure 中的设计模式(下))