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