R 中的设计模式
本文翻译自 Design Patterns in R(By Sebastian Warnholz)。
本文的灵感来源于:
- Stuart Sierra 的演讲,关于函数式编程中的设计模式;以及
- 我从 F# for fun an profit 想到的想法;以及
- 我在使用 R 的过程中用不同方法解决问题获得的反馈。
设计模式似乎是一个很大的词,特别是因为它在面向对象编程中的使用。但最终我认为它只不过是软件设计中的可重复策略。
不动点算法
下面,我使用 R 并且以计算正数平方根的不动点算法为例。算法定义如下:
\[ x_{n+1} = f(x_n) \]
用于寻找平方根的不动点函数如下:
\[ f(x \mid p) = \frac{p}{x} \]
要计算的正是 p
的平方根。用 R 代码描述算法:
fp <- function(f,
x,
converged,
...)
{
value <- f(x, ...)
if (converged(x, value)) value
else Recall(f, value, converged, ...)
}
x
是初始值或最后一次迭代得到的值。converged
是有 arguments
和 ...
两个参数的函数,...
用于 R 的“函数柯里化”1。不动点函数如下:
fpsqrt <- function(x, p) p / x
并且
converged <- function(x, y) all(abs(x - y) < 0.001)
开始计算:
fp(fpsqrt, 2, converged, p = 2)
## Error: eva luation nested too deeply: infinite recursion / options(expressions=)?
第一次运行完全不起作用。在目前的代码实现中很难找出哪里出了问题,但我们会找到的。下面,我将应用不同的模式来修改上述框架以获得解决方案。
包装器模式
这个模式是我从 Stuart Sierra 的演讲中得到的。通过包装器模式我可以向一个函数增加新功能,却不改变原先的函数。我所要做的事就是给函数添加日志,或者记录函数的保留属性,R 中的许多函数并不会这样做。有时候要尝试调用一个函数并每两分钟重试一次,因为连接数据库失败或文件系统没有响应。
一个函数要有单一、明确的用途,日志和写入数据库是两件事。计算下一次迭代以及记录迭代次数也是两件事。这一个功能,以及用于记录是/否的额外参数,不会长期独立存在。
在我的上个例子中,遇到的问题是不动点函数在两个值之间振荡,而不是收敛于平方根。解决这个问题的一个技巧是使用平均阻尼。这就是说我们用 \(\frac{x_{n-1}+x_n}{2}\),而不是 \(x_n\) 来计算 \(x_{n+1}\)。这实际上不是不动点函数逻辑的一部分,所以逻辑不应该被它污染:
averageDamp <- function(fun)
{
function(x, ...) (x + fun(x, ...)) / 2
}
fp(averageDamp(fpsqrt), 2, converged, p = 2)
## [1] 1.414214
# and to compare:
sqrt(2)
## [1] 1.414214
OK,看起来能跑通了!我需要一个额外的包装器来打印每一次迭代的值:
printValue <- function(fun)
{
function(x,
...)
{
cat(x, "\n")
fun(x, ...)
}
}
fp(printValue(averageDamp(fpsqrt)), 2, converged, p = 2)
## 2
## 1.5
## 1.416667
## 1.414216
## [1] 1.414214
现在的问题是,如果我们添加太多的包装器,计算就会变得复杂。事实上试图找出哪个包装器首先被调用,也许这对你来说并不明显。
包装器模式可以在原函数之前或之后(或前后同时)增加新功能。printValue
在原函数之前添加打印功能,averageDamp
在原函数之后作修正。如果看到 averageDamp
的另一种实现,模式将会显得更加清晰:
averageDamp <- function(fun)
{
function(x, ...)
{
value <- fun(x, ...)
(x + value) / 2
}
}
接口模式
柯里化(Currying)
从我的角度来看,这项技术的价值在于你可以更轻松地构建接口(在语言能够充分支持的情况下)。例如,平方根的不动点函数需要两个参数。然而,该算法实际上只知道一个参数。在这种情况下柯里化只是意味着将双参数函数 fpsqrt
变成单参数函数。我们可以通过设置 p = 2
来实现这一点,这是我借助 ...
实现的。
在 R 中,有两个原生选项来模拟柯里化。通常看到的是使用点参数(...
)来允许将额外的参数传递给该函数。然而,这给我的框架中的每个实现都带来了额外的负担,因为我需要让我定义的每个包装器函数使用点参数。另一种选择是使用匿名函数将原始版本封装在单参数函数中,如下所示:
fp(averageDamp(function(x) fpsqrt(x, p = 2)), 2, converged)
## [1] 1.414214
如果我依靠这个接口(单参数函数),我可以不是用点参数。然而,这种技术的语法支持在 R 中受到限制,这就是为什么会出现像 purrr 和 rlist 这样的软件包来试图改善这种情况;包 functional 和 pryr 提供专门的功能用于实现柯里化。
闭包(Closures)
R 中的每个函数都是一个闭包(除了原生函数)。闭包是一个具有与之相关环境的函数。例如,R 包中的函数可以访问包的名称空间,或在面向对象时类中的方法可以访问整个类。但是通常这个术语是在从其他函数中返回函数时使用的(每当你试图对闭包进行子集化时,除了 R 的错误消息外)。如果你对此还不了解,你可以阅读这篇文章或《Advanced R》的相关章节。
我的例子中,我使用闭包来为给定的 p
值重新定义平方根的不动点函数。我认为只有通过以下实施才能强调给定 p
值:
fpsqrt <- function(p)
{
function(x) p / x
}
这实际上使算法的调用更加简洁:
fp(averageDamp(fpsqrt(2)), 2, converged)
## [1] 1.414214
缓存模式
在各种情况下,我都想缓存一些结果而不是重新计算它们。这是因为性能方面的考虑,因为无论使用哪个库,计算矩阵逆的时间关于样本大小都不是线性的。如果你有 10000 个观察值,并且要计算在蒙特卡罗模拟中得到的协方差矩阵(\(10000 \times 10000\))的逆,你有得好等了。为了说明这一点,我设想计算一个线性估计量。尽管估计量可以通过解析方法来得到,但我使用了不动点算法。这种情况下,不动点函数中我使用 Newton-Raphson 算法,定义如下:
\[ \beta_{n+1} = \beta_n - (f'' (\beta_n))^{-1} f'(\beta_n) \]
其中,
\[ f'(\beta) = X^\to