原文见 http://bartoszmilewski.com/20...
-> 上一篇:『范畴,可大可小』
日志的复合
你已经见识了如何将类型与纯函数塑造为一个范畴。我还提到过,在范畴论中,有办法构造副作用或非纯函数。现在有一个像这样的例子:具有运行日志的函数。这种东西,用命令式语言可以通过对一些全局状态的修改来实现,像这样:
string logger;
bool negate(bool b) {
logger += "Not so! ";
return !b;
}
这不是一个纯函数,因为它的记忆版本(见『类型与函数』的第 1 个挑战题)无法产生日志。这个函数有副作用。
如果是并发的复杂情况,现代的编程理念建议你尽可能离全局可变的状态远一些。此外,永远不要将这样的代码放在库里。
不过,只要显式的传送日志,就可以将这个函数变成纯函数。现在为它增加一个字符串参数,并将原本的返回值与更新后的日志字符串打包为 pair
类型:
pair negate(bool b, string logger) {
return make_pair(!b, logger + "Not so! ");
}
这个函数是纯的,它没有副作用。只要你给它相同的输入,它就能产生相同的输出。如有必要,它也能被记忆。不过,考虑到日志的累积性,你不得不收集这个函数运行情况的全部历史,每调用它一次,就产生一条备忘,例如:
negate(true, "It was the best of times. ");
与
negate(true, "It was the worst of times. ");
等等。
对于库函数,这不是很好的接口。函数的调用者可以忽略所返回类型中的字符串,因此返回类型不会造成太多大的负担,但是调用者被强迫传递一个字符串作为输入,这可能非常不方便。
有没有办法可以消除这些烦人的东西?有没有办法可以将我们所关心的东西分离出来?在这个简单的示例中,negate
的主要任务是将一个布尔值转换为另一个布尔值。日志是次要的。尽管日志信息对于这个函数而言是特定的,但是将信息汇集到一个连续的日志这一任务是可单独考虑的。我们依然想让这个函数生成日志信息,但是可以减轻一下它的负担。现在有一个折中的解决方案:
pair negate(bool b) {
return make_pair(!b, "Not so! ");
}
这样,日志信息的汇集工作就被转移至函数的当前调用之后且在下一次被调用之前的时机。
为了看看这种方式如何工作,我们用一个更现实一些的示例。我们有一个将小写字符串变成大写字符串的函数,其类型是从字符串到字符串:
string toUpper(string s) {
string result;
int (*toupperp)(int) = &toupper; // toupper is overloaded
transform(begin(s), end(s), back_inserter(result), toupperp);
return result;
}
还有一个函数,可将字符串在空格处断开,将其分割为字符串向量:
vector toWords(string s) {
return words(s);
}
实际上,字符串分割的任务是由一个辅助函数 words
完成的:
vector words(string s) {
vector result{""};
for (auto i = begin(s); i != end(s); ++i)
{
if (isspace(*i))
result.push_back("");
else
result.back() += *i;
}
return result;
}
问题来了,现在我们将函数 toUpper
与 toWords
修改一下,让它们的返回值肩负日志信息。
下面就来『装帧』这些函数的返回值。可以采用泛型方式来做这件事,首先定义一个 Writer
模板,它实际上是一个序对模板,这个序对的第一个元素是类型为 A
的值,第二个元素是字符串:
template
using Writer = pair;
接下来是两个经过装帧的函数:
Writer toUpper(string s) {
string result;
int (*toupperp)(int) = &toupper;
transform(begin(s), end(s), back_inserter(result), toupperp);
return make_pair(result, "toUpper ");
}
Writer> toWords(string s) {
return make_pair(words(s), "toWords ");
}
我们想将这两个函数复合为一个同样经过装帧的函数,这个函数的功能就是将一个小写字串转化为大写字串,然后将其分割为向量,同时产生这些运算的日志。我们的做法是:
Writer> process(string s) {
auto p1 = toUpper(s);
auto p2 = toWords(p1.first);
return make_pair(p2.first, p1.second + p2.second);
}
现在我们已经完成了目标:日志的汇集不再由单个的函数来操心。这些函数各自产生各自的消息,然后在外部汇总为一个更大的日志。
如果整个程序都采用这样的风格来写,那么大量重复性的代码就会变成恶梦。但是我们是程序猿,我们知道如何处理重复的代码:对它进行抽象!然而,这并非是普通的抽象,而是对函数的复合本身进行抽象。由于复合是范畴论的本质,因此在动手之前,我们先从范畴的角度分析一下这个问题。
Writer 范畴
对那几个函数的返回类型进行装帧,其意图是为了让返回类型肩负着一些有用的附加功能。这一策略相当有用,下面将给出更多的示例。起点还是常规的的类型与函数的范畴。我们将类型作为对象,与以前有所不同的是,现在将装帧过的函数作为态射了。
例如,假设我们要装帧从 int
到 bool
的 isEven
函数,然后将装帧后的函数作为态射。尽管装帧后的函数返回了一个序对:
pair isEven(int n) {
return make_pair(n % 2 == 0, "isEven ");
}
但是,我们依然认为它是从 int
到 bool
的态射。
根据范畴法则,可将这种态射与另一种从 bool
到任何类型的态射进行复合。例如,我们应该能够将它与此前定义的 negate
复合:
pair negate(bool b) {
return make_pair(!b, "Not so! ");
}
但是,显然无法像常规的函数那样去复合这样的两个态射,因为它们的输入/输出不匹配。它们的复合只能像下面这样实现:
pair isOdd(int n) {
pair p1 = isEven(n);
pair p2 = negate(p1.first);
return make_pair(p2.first, p1.second + p2.second);
}
我们将这种新的范畴中两个态射的复合法则总结为:
- 执行与第一个态射所对应的装帧函数,得到第一个序对;
- 从第一个序对中取出第一个元素,将这个元素传给与第二态射对应的装帧函数,得到第二个序对;
- 将两个序对中的第二个元素(字符串)连接起来;
- 将计算结果与连接好的字符串捆绑起来作为序对返回。
若想将这种复合抽象为 C++ 中的高阶函数,必须根据与我们的范畴中的三个对象相对应的三种类型构造一个参数化模板。这个函数应该接受能遵守上述复合法则的两个可复合的装帧函数,返回第三个装帧函数:
template
function(A)> compose(function(A)> m1,
function(B)> m2)
{
return [m1, m2](A x) {
auto p1 = m1(x);
auto p2 = m2(p1.first);
return make_pair(p2.first, p1.second + p2.second);
};
}
现在,我们再回到之前的示例,用这个新的模板去实现 toUpper
与 toWords
的复合:
Writer> process(string s) {
return compose>(toUpper, toWords)(s);
}
传递给 compose
模板的类型依然伴随着大量的噪音。对于支持 C++14 的编译器,它支持具有返回类型推导功能的泛型匿名函数(此处代码归功于 Eric Niebler):
auto const compose = [](auto m1, auto m2) {
return [m1, m2](auto x) {
auto p1 = m1(x);
auto p2 = m2(p1.first);
return make_pair(p2.first, p1.second + p2.second);
};
};
利用这个新的 compose
,可将 process
简化为:
Writer> process(string s){
return compose(toUpper, toWords)(s);
}
事情还没完。虽然在这个新的范畴里已经定义了态射的复合,但是恒等态射是什么?这些恒等态射肯定不是常规意义上的恒等态射!它们必须是一个从(装帧之前的)类型 A 到(装帧之后的)类型 A 的的态射,即:
Writer identity(A);
对于复合而言,它们的行为必须像 unit。若要符合上面的态射复合的定义,那么这些恒等态射不应该修改传给它的参数,并且对于日志它们仅贡献一个空的字符串:
template
Writer identity(A x) {
return make_pair(x, "");
}
不难确信,我们所定义的这个范畴是一个合法的范畴。特别是,我们所定义的态射的复合是遵守结合律的,虽然这无关紧要。如果你只关心每个序对的第一个元素,这种复合就是常规的函数复合。第二个元素会被连接起来,而字符串的连接也是遵守结合律的。
敏锐的读者可能会注意到,这种构造适用于任何幺半群,而不仅仅是字符串幺半群。我们可以在 compose
中使用 mappend
,在 identify
中使用 mempty
。这样做,实际上可以将我们从基于字符串的日志中解脱出来。优秀的库级 Writer 应该能够标识让库能够工作的最低限度的约束——在此处,就是一个日志库,只需要日志拥有幺半群般的性质。
Haskell 中的 Writer
同样的事,在 Haskell 中做起来要简约一些,而且也能得到编译器的很多帮助。我们从定义 Writer
类型开始:
type Writer a = (a, String)
这里,我定义了一个类型别名,等价与 C++ 中的 typedef
或 using
。Writer
的类型被类型变量 a
参数化了,它等同于 a
与 String
构成的序对。序对的语法很简单:用逗号隔开两个元素,外围套上括号。
态射就是从任意类型到 Writer
类型的函数:
a -> Writer b
我们将复合声明为一个可爱的中缀运算符,可将其称为『鱼』:
(>=>) :: (a -> Writer b) -> (b -> Writer c) -> (a -> Writer c)
这个函数接受两个自身也是函数的参数,返回一个函数。第一个参数的类型是 (a -> Writer b)
,第二个参数的类型是 (b -> Writer c)
,返回值是 (a -> Writer c)
。
这个中缀运算法的定义如下,m1
与 m2
是它的参数:
m1 >=> m2 = \x ->
let (y, s1) = m1 x
(z, s2) = m2 y
in (z, s1 ++ s2)
返回的是一个具有单参数 x
的匿名函数。在 Haskell 中,匿名函数就是一个反斜杠,一个断了左腿的 λ。
let
表达式可以让你声明辅助变量。在本例中,辅助变量是与 m1
的返回值相匹配的序对变量 (y, s1)
,同理,还有 (z, s2)
。
在 Haskell 中,序对的模式匹配很寻常,它不使用我们在 C++ 中所习惯的访问器(Accesor)。除了这一点,这两种语言所实现的序对基本上是大同小异的。
整个 let
表达式的结果由 in
从句产生,结果就是 (z, s1 ++ s2)
。
还得为这个范畴定义一个恒等态射,我将这个态射命名为 return
,至于为何这样命名,以后你就知道了。
return :: a -> Writer a
return x = (x, "")
为了示例的完整性,我们还得定义 upCase
与 toWords
的 Haskell 版本:
upCase :: String -> Writer String
upCase s = (map toUpper s, "upCase ")
toWords :: String -> Writer [String]
toWords s = (words s, "toWords ")
map
函数相当于 C++ 的 transform
。它将 toUpper
函数作用于 s
中的每个字符。words
是 Haskell 标准库(Prelude library)中已经定义了的函数。
最后,在小鱼运算符的帮助下,给出函数的复合函数:
process :: String -> Writer [String]
process = upCase >=> toWords
Kleisli 范畴
你可能已经猜到了,其实我并非当场就发明了这个范畴。它其实是一个被称为 Kleisli 范畴的示例。Kleisli 范畴是建立于单子之上的范畴。在此,我们依然不讨论单子,我只是想让你看看单子都能干些什么。对于我们有限的目的,一个 Kleisli 范畴拥有编程语言的类型,它们是这个范畴中的对象。从类型 A 到类型 B 的态射是从 A 到由 B 的派生类型(装帧后的 B)的函数。每个 Kleisli 范畴都定义了相应的态射的复合运算,以及能够支持这种复合运算的恒等态射。(『装帧』是个不严谨的说法,它相当于范畴论中的自函子,这一点以后我们就知道了。)
我所用的这个特定的单子是本文中所涉及的范畴的基础,它叫 Writer 单子,专门用于函数执行情况的跟踪记录。它也是副作用被嵌入到纯计算过程这种一般性机制的一个范例。之前你已经见识了,我们可以将编程语言的类型与函数构建为集合的范畴(忽略底的存在)。在本文中,我们将这个模型扩展为一个稍微有些不同的范畴,其态射是经过装帧的函数,态射的复合所做的工作不仅仅是将一个函数的输出作为另一个函数的输入,它做了更多的事。这样,我们就多了一个可以摆弄的自由度:这种复合本身。对于传统上使用命令式语言并且通过副作用实现的程序,这种复合运算能够给出简单的指称语义。
挑战
一个函数,如果它不是为了它的参数的所有可能取值而定义,那么这个函数就叫做偏函数。它不是数学意义上的函数,因此它不适合标准的范畴论模型。不过,它能够被装帧成返回 optional
类型:
template class optional {
bool _isValid;
A _value;
public:
optional() : _isValid(false) {}
optional(A v) : _isValid(true), _value(v) {}
bool isValid() const { return _isValid; }
A value() const { return _value; }
};
作为示例,在此给出经过装帧的函数 safe_root
的实现:
optional safe_root(double x) {
if (x >= 0) return optional{sqrt(x)};
else return optional{};
}
以下是挑战:
- 为偏函数构造 Kleisli 范畴(定义复合与恒等)。
- 实现装帧函数
safe_reciprocal
,如果参数不等于 0,它返回一个参数的倒数。 - 复合
safe_root
与safe_reciprocal
,产生safe_root_reciprocal
,使得后者能够在任何情况下都能计算sqrt(1/x)
。
致谢
感谢 Eric Niebler 阅读了草稿,并利用 C++14 的新功能来驱动类型推导,从而能给出了更好的 compose
实现。得益于此,我砍掉了整整一节的旧式模板的魔幻代码,它们使用类型 trait 做了相同的事。排出毒素,一身轻松!也非常感谢 Gershom Bazerman 有用的评论,帮助我澄清了一些要点。
-> 下一篇:『积与余积』