In-depth: Functional programming in C++ - 在C++上面使用函数式编程

原文作者为游戏界的传奇人物John Carmack


本译文张贴已经过作者同意
原文网址:
http://gamasutra.com/view/news/169296/Indepth_Functional_programming_in_C.php


译者:
网路上有很多关于用FP风格写C++的文章,其中John Carmack所写的这一篇Functional programming in C++是最让我起共鸣的,原文在2012年已经有人翻译过了,不过因为我很喜欢这篇文章,所以用个人方式理解再翻译一遍。

原谅我为了语意通顺,并没有逐句翻译,但不用担心,技术内容并没有打折扣,这篇文章真的很赞。

因为我的翻译能力有限,所以在这里先做一些阅读前的说明,会让你读起来更舒服点。

1.Functional Programming在文中都用FP来简称。
2.纯函式是FP的中心思想,遵守纯函式的规则就可以得到FP带来的好处了。
3."纯"指的是没有副作用,副作用越少的函式就越"纯"。
4.副作用是指函式执行时对外部造成的影响,只有回传值的影响不算副作用。
5.纯函式的行为跟数学上的函式是一样的。



本文:

也许各位早已听过一种名为FP的程式编写思维,它被视为软体开发者的福音,甚至有人认为FP就是银弹(注1)。不过查了一下维基上的资料却让我倒尽胃口,网页上劈头就讲了λ演算法(注2)跟形式系统(注3),当下的确看不出来这些理论对软体开发有什么帮助。


个人站在实务面所归纳的结论是:软体开发过程产生的疑难杂症,大多是因为工程师没有掌握好程式执行过程里各时期的状态所导致的。在多执行绪环境下这情况会变的更严重(假如你曾留意过这问题)。使用FP思维来编写程式会让程式码的流程状态变得清楚明确,程式码也变得更有条理,而且完全遵守FP规则的程式是不可能发生执行绪冲突的。


我认为FP确实有它存在的价值,但是光凭这点就去呼吁大家放弃C++然后改用Lisp、Haskell这些语言是不负责任的发言。


程式语言的设计者总是担心语法的优点会被外在因素抹灭,而这种事情在游戏界特别容易发生,我们在专案开发上必须面对跨平台问题、被特定的工具库、认证门槛、授权技术绑死以及严苛的性能要求等等外在因素,再加上还要用有限的人手去维护前人遗留下的程式码。


如果你的工作环境条件允许你使用非主流语言的话,那么恭喜你,但也要做好挨骂的心理准备,罪名是拖慢开发进度之类的。


不管你用的是哪一种语言,使用FP风格来撰写程式都能带来好处,只要觉得情况允许使用FP就该用,如果觉得情况不适合也要再好好想想为何不用。如果打算使用FP的话你可以去查一下λ演算法、单子(注4)、柯里化(注5)、在无穷集合上组合惰性求值函式(注6)、以及学习其他FP导向的语言。


C++并不鼓励你采用FP,但也不阻止你使用,而且C++允许你深入底层使用平行处理的指令集来操作记忆体上的资料,还允许你使用其他你需要的强大功能。


注1:银弹是传说中可以有效伤害吸血鬼的武器,在IT界则用银弹比喻对软体开发有卓越效果的技巧。
注2:λ演算法(lambdas)思维就是将function当参数或回传值使用。
注3:形式系统(formal system),用于逻辑推导的一种主义思想,直接想成是数学上的逻辑推导就行了。
注4:单子(monad)是FP里面提到的一种抽象型别,用来表示一段计算而非数据资料。
注5:柯里化(Currying)就是把一个多参数的函式包装成一个只接受一个参数的新函式,举例就是把 f(x,y,z)=x+y+z 包成 g(x)=f(x,3,5)
注6:惰性求值的意思是算式执行的当下没有真的去计算答案,不储存结果只储存算式,然后等到该答案真的需要使用时才开始做计算。(这么说我以前就会惰性求学跟惰性写作业了)



纯函式

一个纯函式只注意外部传进来的参数,它唯一的工作就是依据输入参数求出回传值,逻辑上不会有副作用,当然我指的是抽象概念上的副作用,以硬体角度来看,任何函式都有副作用,但在抽象角度上它的确没有副作用。

纯函式不会去读写全域变数,内部不保存静态变数,不进行IO操作,不修改传进来的参数,最理想的状况是连那种有可能跟外部连动的参数都不要传进来,像是传递全域变数的指标进来就偏离纯函式的宗旨了。

纯函式有以下优良特质:

“执行绪安全性”,一个使用实体参数的纯函式是完全没有执行绪问题的,但如果使用指标或参考作为参数的话,你有必要注意一下其他执行绪有可能也引用一样的指标来操作同一块资料,甚至释放掉记忆体。即使避不开这样的风险,纯函式依然是能让多执行绪更加安全的有效技巧之一。

你可以简单的把这些函式拿来平行执行或者个别执行比较结果,这样测试跟展开函式会安全很多。


“重覆使用性”,要将纯函式移植到其他环境非常容易,虽然还是要处理参数型别问题以及转接内部呼叫的其他纯函式,但是至少不会牵一发而动全身。你不知道有多少程式码从旧架构环境中抽出来时,所花费的时间比重写一个新的还要久。


“可测试性”,纯函式具有引用公开化的特质,意思是每次输入同一组参数都会得到相同的结果,这使得纯函式比纠结的程式更容易做调试。


我向来都尽写些不负责任的测试程式,程式中有太多地方跟系统相连了,需要搭配复杂的配套措施进行测试修改,我总是认为这不值得花时间去写(也许我这想法也不对)。

纯函式可以方便你做细部测试,测试码看起来就跟教科书上写的一样漂亮。每当遇到结构刁钻的程式时,我会切割成一个个纯函式来分别做测试,惊人的是,常常还是能测出小毛病来,这意味着我布下的安全网还不够周全。


“可读性”与“可维护性”,因为参数输入跟输出的直接影响范围很有限,你可以很容易搞懂以前写的纯函式,跟函式外部有关的潜规则也变的更少。


形式系统跟程式自我推理(注7:)在未来会越来越重要,静态程式分析在现今就已经很重要了,程式写得越符合FP规范,程式分析工具会运作的更好,不然至少也会让速度快的局部分析工具的分析范围变得更大,分担更多全域分析工具的工作。
我觉得对于Eclipse之类的工具而言,OO跟FP都能分析的很好,差别在于OO的写法如果封装不好的话会很难阅读的,当你要追查一个变数的影响范围时,OO会追的比较辛苦。


我们这领域重视的是"完成品",架构正确性的形式证明(注8)还没被列为开发重点,但是去证明有哪些还没浮现的潜在危险仍然是值得的。我们可以在开发过程中应用更多的学术理论。


正在修计算机概论的同学可能会一边抓着头一边想:"程式不都是这么写的吗?",现实中却是搞成"大泥球"(注9)的专案比较多,传统的指令式程式语言提供了紧急应变手段,结果大家没事就拿来用。如果你写的是免洗程式,那倒无所谓,一直用全域变数也没差。


如果你写的是过了一年都还会用到的程式码,那就要衡量一下是现在方便重要,还是避免日后一定会发生的问题重要。大部分软体开发者都没有用长远的眼光去预测修改程式引发的麻烦。



注7:自我推理(automated reasoning)是AI领域的名词,指的是电脑能自己进行逻辑推理。
注8:形式证明(formal proof),就是高等数学里的论证方式,这没什么好翻的。
注9:大泥球(Big Balls of Mud)是一种反面模式,指的是程式结构混乱不清晰这种常犯的错误。



纯度的实现

并不是每个地方都可以纯函式化,除非整个程式都是自己亲手写的,不然总有些地方必须跟外界交流。尽力去提升程式的纯度(注10)是很有趣没错,但是在实作上必须承认在某些情况下,最低限度的副作用是必要的。


即使针对单一函式而言,纯度的实现也不是那种会功亏一篑的工作。纯度的价值随着纯度的提升只会越来越高,而且从"一团乱"提升到"大致上纯化"所得到的好处比从"几乎纯化"提升到"完全纯化"还要高。即使无法达到完全的纯化,也应当尽可能提高纯度。使用全域计数器或全域旗帜是拉低纯度的行为,不过如果除此之外都已纯化,那么还是能得到纯函式化所带来的好处。


修正大范围中最糟糕的缺点通常会比雕琢几个完美的小区块还要重要。回想一下你碰过最棘手的功能面问题或者系统架构问题,我几乎能笃定问题是起源于复杂的状态沟通网络,也许程式的错误行为是受到这些状态的牵动,而且影响到的不止是参数而已。在发生问题的区块加强管制,或至少拼命防止更多程式陷入类似的麻烦,做这种事比你花时间去最佳化底层的那些数学函式库还要有意义。


朝着纯化进行重构的过程里通常会出现将一段算式做分割的行为,这十之八九会产生更多负责参数传递的程式码,字数冗长的程式可是公认的差劲写法,但FP的结果却常常反而是减少了程式的字数,我知道这听起来有点吊诡。

FP写法之所以在某些情况下比指令式语言更加精简的原因跟这些有关:纯函式的使用、垃圾回收机制、强大的内建资料型态、模式匹配、条列式推导(注11)、函式编成、多种语法糖(注12)诸如此类。其实这些缩短程式码的手段大多在指令式语言也找的到,并非FP特有。


如果只是呼叫个函式也要你填十几个参数,不爽是正常的。可以试着重构程式来减少参数。


C++对纯度维护没有提供任何支援,这点不大理想,假如有人污染一个被大量呼叫的纯函式,那么所有呼叫该函式的纯函式也会失去纯度,这问题对形式系统来说很严重,但还是那句话,维护纯度不是那种会功亏一篑的工作,破坏纯度也不是说罪无可赦,对整体的程式开发而言,失去纯度就只是有点可惜而已。


听起来C/C++应该在新的标准里增加pure这个关键字,目前已经有一个类似的关键字叫做const,一个用来让编译器帮忙监督、保护工程师的选项,而且这招常常是管用的,D语言已经有pure这个关键字了,请注意其中弱纯度跟强纯度的之间的差异,强纯度的指标参数也需要加const关键字。


从某些角度来看,程式语言的关键字是很狭隘的设计,纯函式即使呼叫了不纯的函式,只要没有带给外部副作用就还是很纯。只接受命令列参数不读取其他档案的程式也可以视为一种纯函式。


注10:纯度(purity),我想不到什么好的翻译,因为原文名词一样令人费解,总之就是纯函式化的程度。
注11:条列式推导(list comprehension),好的,这也是我乱翻的,这种写法的程式看起来就像数学算式一样全挤到同一行,根本是FP作风。
注12:语法糖(syntactic sugar)又称糖衣语法,是程式语言设计者提供给你的一些偷懒语法。



物件导向设计

OO将经常变动的部份封装起来以提高可读性,FP则是缩短经常变动的部份以提高可读性
- Michael Feathers(@mfeathers)


所谓"变动的部份"其实是指"变化的状态",物件导向入门书籍最先教的就是操作物件改变自己的状态,这对大部分软体工程师也是非常根深蒂固的概念,但这行为跟FP是背道而驰的,显而易见的,OO将函式跟变数封装在一起是有它的价值的,但是要让一段程式采用FP设计就必须舍弃某些OO特征了。
这里并不是说采用FP之后就无法贯彻OO了,这两者其实是可以一起使用的武器,没有人会嫌自己武器太多的


不能宣告为const的成员函式在FP定义上就已经不纯了,因为这种函式会改变物件的状态,也不是执行绪安全的,这种让物件状态渐渐失控的设计显然是bug的主要来源。


如果被C++隐藏的物件指标"this"不算参数的话,宣告为const的成员函式在技术上也算是纯函式。但是当物件规模大到让成员变数用起来像全域变数时,纯成员函式的好处会被埋没。建构子也可以写成纯函式,而且最好都这么写,写成一种输入参数然后回传物件的纯函式。


就策略上,你可以用FP的思维来使用物件,也许还需要改一下介面,在id Software工作的时候,我们有个使用长达十年的向量类别叫做idVec3,它有个能将自己标准化的成员函式( void idVec3::Normalize(); ),但却没有一个同功能的全域函式( idVec3 Normalized(); )来产生标准化的向量类别。很多字串容器也是用类似的设计,它们修改自己的内容,而不是回传一个修改过的副本,例如ToLowerCase()、StripFileExtension()等等。



性能影响

绝大部分情况下,直接修改记忆体的资料是程式执行效率最高的做法,而且可以避免浪费效能。但是这只不过是理论上的空想罢了,现实中我们总是选择牺牲效能来换取开发效率。


使用FP会导致更多资料复制行为,在一些情况下,基于性能考量FP反而是错误的策略。举个极端的例子,你如果写个用来改图的纯函式,它接受整张图片作为参数复制进来,然后改好之后回传改好的全新图片,千万别这么做。


回传实体变数是FP的编程风格,但是依赖编译器做回传值最佳化会有性能上的疑虑,改成传递参考来输出复杂的资料结构可以避开这问题。但是这又带来新的问题,你就算加上const关键字也无法让回传值遵守单赋值(注13)。
这里我不懂为何参考加上const关键字无法遵守单赋值,另外C++11新增了std::move()这样的工具,有机会降低传递变数时的开销


在很多情况下会倾向更改结构变数中的某个成员而不是写一份新的结构,但这会失去执行绪安全性,不该轻易这么做。但列表这么做倒是挺合理的,遵守FP规则的列表用法一样是复制一份新列表回传,原本的列表则原封不动。真正的FP语言会有特别的实作来实现这功能,所以没有听起来那么严重,但是C++的容器也这么做可就惨了。



这里有个可以缓颊的好理由,那就是如今追求执行效率都是针对平行运算的程式设计,相较于单执行绪程式,多执行绪就算是最佳化也通常需要更多的复制、合并行为,所以相较之下代价变小了,换来的好处是复杂度的降低、正确性的提升。


当你开始思考如何让游戏世界的所有角色同时动作时,你会马上陷入因为采用物件导向而产生的多执行绪难题。也许可以规定所有物件都只能读取全域状态不能进行写入,然后在绘图回圈跑完一圈的时候去读取更新过的全域状态,嘿!给我等一下...
因为大家都唯读的话就没有人可以去更新状态了



能做的事

调查你程式中比较有规模的函式,追踪所有跟它们有关的外部状态以及所能造成的影响,即使你根本没用这函式做什么事情都还是会写出一份庞大的注解。如果最后发现这函式的副作用居然还能影响萤幕画面,那你可以举双手投降然后宣称这函式已经算超自然现象了。


你的下一个任务是从源头开始思考程式真正的运算结果,整理输入的参数资料然后传给一个纯函式处理,接下来用这纯函式的回传值做点什么。

当你除错的时候,多留意背地里变化的状态跟隐藏参数在做什么。

修改你的类别实作,让它可以回传一份新的复制品而不是修改自己的内容,试着把每个非累加变数都宣告为const。

其他参考资料:

http://www.haskell.org/haskellwiki/Introduction

http://lisperati.com/

http://www.johndcook.com/blog/tag/functional-programming/

http://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf

http://channel9.msdn.com/Shows/Going+Deep/Lecture-Series-Erik-Meijer-Functional-Programming-Fundamentals-Chapter-1

http://www.cs.utah.edu/~hal/docs/daume02yaht.pdf

http://www.cs.cmu.edu/~crary/819-f09/Backus78.pdf

http://fpcomplete.com/the-downfall-of-imperative-programming/


注13:单赋值(single assignment),把变数宣告成const就能符合单赋值,因为能防止不小心又对变数赋值。


读后感:

靠近底层的程式会跟装置紧密结合,而装置就是一个会记住设定的状态了,互动程式也一定需要留住许多外部输入的状态,所以游戏程式有很多地方不能实施FP,但如同John Carmack所言,能用FP就尽量采用。

FP其实并不是用来批评、取代OO的,但我觉得玩OO玩到走火入魔的人刚好可以学FP来均衡一下,而且藉此检讨一下自己的OO有哪里写的不好,例如物件内部保存的状态应该越少越好,method也是越少越好,这样变化较少,也比较容易维护测试。物件跟物件之间的瓜葛应该越少越好,互动上越清晰越好,别让物件背着你做出超出预期的行为。懂OO的缺点就会懂FP的优点。

别认为C语言会比物件导向的C++更适合实现FP,纯函式会将其他函式做包装再回传,这种事要用boost::function才做得到,C++其实比C更适合FP。

你可能感兴趣的:(翻译)