PSJay Blog

#FIXME, seriously

控制流的抽象

| Comments

本文翻译自 Abstracting Control Flow


所有的程序员都在持续不断地创造抽象,尽管有时候连他们自己也意识不到。我们平常最常抽象的是运算(写成函数)或者行为(子程序或者类),但其实我们的工作中还有一些其他的重复的模式,特别是异常处理、资源管理与优化。

这些重复的模式通常会引入一些规则,例如「关闭所有你打开的东西」,「释放资源然后抛出异常」,「如果成功了则继续,否则……」,这些代码通常都是重复的 if ... else 或者 try ... catch。不如把这些控制流也抽象出来?

在那些没人秀技巧的常规代码里,通常使用控制结构来控制流程。但有时候它们并不能完成得太好,于是我们就只能靠自己动手了。这在 Lisp、Ruby 或者 Perl 里面很容易做到,在所有支持高阶函数的语言中也有办法可以做到。

抽象

让我们从头开始吧。创建一个新的抽象我们需要做些什么呢?

  1. 选择一个功能或者行为。
  2. 给它命名。
  3. 实现它。
  4. 把我们的实现细节隐藏在这个命名之后。

第三点和第四点不一定总是可以做到的。这与你试图抽象的东西和你所使用的语言的灵活性有非常大的关系。

如果你所使用的语言做不到这些,那就忽略实现这一步,仅仅描述实现的方法就好了,然后想办法让它流行起来,从而创造一个新的设计模式。这样你就不会对你将要写的那些重复代码感到糟糕了。

回到现实

这是一段普通的 Python 代码,它是从真实的项目中拿出来的,只做了很少的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
urls = ...
photos = []

for url in urls:
    for attempt in range(DOWNLOAD_TRIES):
        try:
            photos.append(download_image(url))
            break
        except ImageTooSmall:
            pass # skip small images
        except (urllib2.URLError, httplib.BadStatusLine, socket.error), e:
            if attempt + 1 == DOWNLOAD_TRIES:
                raise

这段代码实现了好几个功能:迭代 urls、下载图片、把图片收集到 photos 里、忽略小图片和在下载失败时重试。这一大堆功能全都塞进了这段单独的代码中,尽管它们在这段代码之外也可能被用到。

这其中的有些功能其实已经存在了。比如,迭代后的结果聚合其实就是 map

1
photos = map(download_image, urls)

让我们来实现其他的功能。先从忽略小图片开始,它可以写成:

1
2
3
4
5
6
7
8
9
10
11
@contextmanager
def ignore(error):
    try:
        yield
    except error:
        pass

photos = []
for url in urls:
    with ignore(ImageTooSmall):
        photos.append(download_image(url))

看起来不错的样子。但这样写不能轻易地与 map 组合起来使用。让我们暂时忽略它,先来解决网络错误问题。我们可以试着用我们刚刚处理 ignore 的方法来抽象它:

1
2
with retry(DOWNLOAD_TRIES, (urllib2.URLError, httplib.BadStatusLine, socket.error)):
    # ... do stuff

只有这个是不能被正确实现的。Python 的 with 语句不能重复地运行它包含的语句块。我们碰到了语言的限制。如果你想要在语法层面之上理解不同语言之间的差别,那么注意到这样类似的情况是很重要的。在 Ruby 和 Perl 的小扩展版本中,我们可以继续操作语句块,在 Lisp 中我们甚至可以操作代码(这可能有点杀伤力过猛了),但是这些特性在 Python 中全失去了,我们应该转而使用高阶函数和它们的简化形式——装饰器:

1
2
3
4
5
6
7
8
9
10
11
@decorator
def retry(call, tries, errors=Exception):
    for attempt in range(tries):
        try:
            return call()
        except errors:
            if attempt + 1 == tries:
                raise

http_retry = retry(DOWNLOAD_TRIES, (urllib2.URLError, httplib.BadStatusLine, socket.error))
photos = map(http_retry(download_image), urls)

我们可以看到,它甚至自然地就可以和 map 同时工作了。另外,我们还得到了一对非常有用的可复用的工具:retryhttp_retry。不幸的是我们的 ignore contextmanager 不能轻松地在这里添加。它是不可被组合的。让我们来把它也重写成装饰器:

1
2
3
4
5
6
7
8
9
10
11
@decorator
def ignore(call, errors=Exception):
    try:
        return call()
    except errors:
        return None

ignore_small = ignore(ImageTooSmall)
http_retry = retry(DOWNLOAD_TRIES, (urllib2.URLError, httplib.BadStatusLine, socket.error))
download = http_retry(ignore_small(download_image))
photos = filter(None, map(download, urls))

这么做好在哪儿?

似乎我们现在为实现同等的功能写了更多的代码。不同的地方就在于这些功能现在没有混乱地耦合在一起了,并且它们是组合起来的,这意味着这几件事情:

  • 每个单独的功能都是可见的,
  • 它被命名了,
  • 它可以很容易地被使用和输出,
  • 它是可复用的。

在我们使用函数式的流程控制之后只用了最后 4 行基础代码来实现这些功能,这也许使代码变得更可读了。或者也没有,毕竟这是一种主观判断。我仍然希望这篇文章能帮到一些人写出更好的代码。

P.S. 我把 @decoratorignoreretry 打包到了一个实际的项目中

P.P.S 其他控制流程抽象的例子有:underscore.js 中的函数操作,列表推导式和生成器表达式,模式匹配函数重载,装饰器缓存等等。

Comments