无知的 tonyseek

Yet Another Seeker

先让简单的命令模式消失吧

最近读《Ruby 设计模式》 [1] (第 n 次重读),对其中的一个观点特别认同:设计模式终将随着语言抽象能力的强化而消失在代码中。正如作者曾经在 C 语言中用函数指针结构体管理操作特定结构体的“面向对象设计模式”,随着面向对象融入语言而消失。GoF 的设计模式很经典,但是在 Ruby、Python 等表达力强大的语言火热之后,部分原来可以称之为“模式”的做法已经开始有了融入语言的趋势。

虽然这篇博客讨论的是命令模式,但我不会再絮叨一边命令模式是什么,已经有非常详细的资料 [2] 在先了。我想讨论的是关于在相对传统高级语言更“高级”的语言中,命令模式更佳的实现方式。这类语言包括但不限于:Python、Ruby、Scala、JavaScript。

代码块与函数对象

JavaScript、Ruby、Python 等动态语言对比 Java 有个很大的不同,就是让“过程”成了一种可以传递、修改甚至可以向它发送消息的对象。在 Ruby 中,这种对象称之为 Proc(本文称为“代码块”);Python 和 JavaScript 中,任何一个函数和 lambda 表达式都是这样的对象。这种对象其实就是许多动态语言都有的“匿名函数” [3]

这种特性给了我们将过程保存为对象的可能性。在 C/C++ 里面也有函数指针的概念,但 C/C++ 并不支持随处定义过程,只能把全局定义好的函数用函数指针引用,所以相比之下 Ruby&Python 的这种特性能带来更多的便捷。这种便捷带给我们的,是可以让一部分 GoF 设计模式实现简化,因为 GoF 模式中有大量的行为模式,是和保存过程密切相关的,例如命令模式(Command)、策略模式(Strategy)。

这种“函数对象”和 GoF 命令模式本质上有相同点:

  • 函数(Command 对象)的定义初始化了一个将被执行的过程;
  • 对这个函数(Command 对象)调用一定的方法(圆括号调用或者发送 execute 消息)会执行预定义好的过程。

这么一来,我们就有可能用这种“函数对象”替代部分“命令对象”。举个简单的例子,点击一个按钮,触发删除文件的动作。假设 Java 中实现这点所使用的 GUI 框架依赖一个 ActionCommand 接口:

public interface ActionCommand {
    public void execute();
}

那么我们要做的就是将删除文件的操作封装为实现了 ActionCommand 接口的命令对象。

public class DeleteFileCommand implements ActionCommand {
    public void execute() {
        File file = new File("/tmp/somefile");
        if(file.isFile() && file.exists()) { file.delete(); }
    }
}

最终使用的时候,讲这个命令对象实例化并传递给 GUI 框架即可。

// omit...
button.setClickAction(new DeleteFileCommand());
// omit...

// 我们知道 GUI 框架内部会在按钮被点击的时候执行这个命令:
ActionCommand clickAction = this.clickAction;
clickAction.execute();

实现有些繁琐,但是我们在 Java 中似乎也较难找到更好的实现方式,毕竟我们要做到的是“保存一个过程,需要的时候才执行”。但是如果在 Python 中呢?我想大多数 Pythoner 不会选择去这么繁琐的方式,因为这样不 Pythonic。事实上,这样不仅不 Pythonic,也不 Ruby Style、JavaScript Style。在支持函数对象(或者代码块、匿名函数,随便怎么叫)的语言中,我们都可以选择更简单、更直观的方式。

import os
import ttk  # ``from tkinter import ttk`` for py3k

def deletefile():
    path = "/tmp/somefile"
    if os.path.exists(path) and os.path.isfile(path):
        os.remove(path)

root = ttk.Frame()
ttk.Button(root, command=deletefile).pack()
root.pack()
root.mainloop()

这也是我认为简单的命令模式将会融入语言的依据,因为从本质上来看,命令即过程。

当然,这种用函数对象定义命令的方式也有缺陷。第一个缺陷是对有接口强迫症的同学来说,不定义个接口去约束 Command 会很难受,这点不予评论,我坚持认为会游泳的就是鸭子 [4] ,干嘛非得有鸭子的 DNA。第二个缺陷是如果不能硬编码被删除文件的路径,需要通过参数传入怎么办?或者说的更通用一些,如果命令对象需要传入参数怎么办?在 Java 里面,我们可以:

public class DeleteFileCommand implements ActionCommand {
    private String path;
    public DeleteFileCommand(String path) { this.path = path; }
    public void execute() { /* omit */ }
}
button.setClickAction(new DeleteFileCommand("/tmp/somefile"));

似乎对于这点函数对象就无能为力了,所以我们这时候需要借助另一个有力的工具——词法闭包。

高阶函数与闭包

高阶函数似乎是来自函数式编程 [5] 领域的一个概念,简单来说就是调用一个函数,这个函数返回一个函数。这点在 JavaScript 里面实现的最优雅,也最常用:

var pow = function (base) {
  return function(num) {
      var result = num;
      for(var i=1; i<base; i++) {
        result = result*num;
      }
      return result;
    };
}

var pow2 = pow(2);
console.log(pow2(8)); // output: 64

这种特性其实就是“函数对象”的一种体现,它给了我们另一种便捷:通过函数生产函数,也就是通过一个函数来定制 过程 。这种定制本质上和 GoF 设计模式中的许多行为模式是一致的,上面一段计算次方的 JavaScript 可以等价于下面通过类来实现的 Java 代码:

public class Pow {
    private int base;
    public Pow(int base) { this.base = base; }
    public int getValueOf(int num) {
        int result = num;
        for (int i=1; i<this.base; i++) { result = result * num; }
        return result;
    }
}
Pow pow2 = new Pow(2); // use it
System.out.println(pow2.getValueOf(8));

这段代码和“函数对象”中的范例有微小的不同——JavaScript 版的 pow 函数成功地等价了 Java 版“带参数构造”的实现。仔细观察 pow 中返回的匿名函数对象,就会发现其中引用了一个属于外部作用域(也就是该匿名函数自身所在作用域)的变量 base 。这样乍看没问题,但是仔细推敲不合逻辑:在内部匿名函数被返回之后, pow 函数的一次调用应该已经结束了,其作用域中的变量应该被回收,为何还在匿名函数中可用呢?这就归结于[词法闭包][4]。在 Python、Ruby、Scala、JavaScript 等语言中,词法闭包一般就称呼为“闭包”,我使用全称是因为我曾被严重困扰过——这里的闭包(Closure) [6] 和数学中的闭包(也是 Closure) [7] 没有任何关系。

维基百科对于这里的闭包定义是这样的:

在计算机科学中,闭包(Closure)是词法闭包(Lexical Closure)的简称,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。

也就是说,一个高阶函数内部的局部变量,被更深一层作用域的函数(也就是被返回的函数)内部的作用域引用之后,其生命期就会被绑定到内部的函数,只要该内部函数生命期未终结,即使高阶函数的生命期已经终结,该变量仍然活着。换言之,闭包让该过程附带上了额外的数据,这样就让过程在一定的情况下可等同于一个类的实例。关于闭包的这一特性,我听过一个最好的描述,可惜不知道出自谁口:

类实例是绑定了行为的数据,闭包是绑定了数据的行为

也就是说,对于只有一个行为的类实例(例如简单的 Command 对象),闭包是完全可以做到等价的。这么一来,就弥补了我们上面用“函数对象”代替类定义实现命令模式的不足,因为我们可以通过闭包的方式让行为附带外部数据。上述“删除文件”的例子完全可以在 Python 中实现如下:

import os
import ttk

def delete_file(path):
    def execute():
        if os.path.exists(path) and os.path.isfile(path):
            os.remove(path)
    return execute

root = ttk.Frame()
ttk.Button(root, text="Remove",
           command=delete_file("/tmp/myfile")).pack()
root.pack()
root.mainloop()

又或者是 Ruby 中,实现如下:

require 'tk'

def delete_file(path)
  return lambda do
    File.delete(path) if File.exists?(path) and File.file?(path)
  end
end

root = TkRoot.new
TkButton.new(root) do
  text "Remove File"
  command delete_file("e:/test.txt")
  pack
end
root.mainloop

至此,我们已经可以完美地用函数对象(代码块)和闭包,代替只有一个 execute 方法的命令对象。

结语

无论是函数对象还是词法闭包,我相信都已经是高表达力语言中的常用工具。尤其是上述“简单命令模式”的实现,我相信很多 Pythoner 在使用这种做法的时候根本不会想到这个是“命令模式”。随着语言抽象表达能力越来越高,这些模式终将融于平平常常的代码中。类似的例子还有很多,Python 中大家惯用的装饰器(Decorator) [8] 语法、还有简单的策略模式、观察者模式,都是此列。

所以对于设计模式,我觉得 GoF 固然经典,但新近发展中的变化却不容忽视。大家惯用的手法融于语言,而新的设计模式又随之出现。而比起传统模式,新的模式往往抽象程度更高,更贴近“设计”而不是“编码”。例如来自 Ruby 的“惯例优于配置”模式、《Ruby 设计模式》一书作者提出的“你不会用到它”模式都是很好的思想指引。

[1]豆瓣的《Ruby 设计模式》书籍介绍
[2]命令模式的 Google 搜索结果
[3]匿名函数
[4]鸭子型别
[5]函数式编程
[6]计算机科学中的“闭包”
[7]数学的“闭包”
[8]Python Decorators

Comments