王垠:编程的宗派( 五 )


与fold类似的白象 , 还有currying , Hindley-Milner类型推导等特性 。 看似很酷 , 但等你仔细推敲才发现 , 它们带来的麻烦 , 比它们解决的问题其实还要多 。 有些特性声称解决的问题 , 其实根本就不存在 。 现在我把一些函数式语言的特性 , 以及它们包含的陷阱简要列举一下:

  1. fold 。 fold等“递归模板” , 相当于把递归函数定义插入到调用的地方 , 而不给它们名字 。 这样导致每次读代码都需要理解几乎整个递归函数的定义 。
  2. currying 。 貌似很酷 , 可是被部分调用的参数只能从左到右 , 依次进行 。 如何安排参数的顺序成了问题 。 大部分时候还不如直接制造一个新的lambda , 在内部调用旧的函数 , 这样可以任意的安排参数顺序 。
  3. Hindley-Milner类型推导 。 为了避免写参数和返回值的类型 , 结果给程序员写代码增加了很多的限制 。 为了让类型推导引擎开心 , 导致了很多完全合法合理优雅的代码无法写出来 。 其实还不如直接要程序员写出参数和返回值的类型 , 这工作量真的不多 , 而且可以准确的帮助阅读者理解参数的范围 。 HM类型推导的根本问题其实在于它使用unification算法 。 Unification其实只能表示数学里的“等价关系”(equivalence relation) , 而程序语言最重要的关系 , subtyping , 并不是一个等价关系 , 因为它不具有对称性(symmetry) 。
  4. 代数数据类型(algebraic data type) 。 所谓“代数数据类型” , 其实并不如普通的类型系统(比如Java的)通用 。 很多代数数据类型系统具有所谓sum type , 这种类型其实带来过多的类型嵌套 , 不如通用的union type 。 盲目崇拜代数数据类型的人 , 往往是因为盲目的相信“数学是优美的语言” 。 而其实事实是 , 数学是一种历史遗留的 , 毛病很多的语言 。 数学的语言根本没有经过系统的 , 全球协作的设计 。 往往是数学家在黑板上随便写个符号 , 说这个表示XX概念 , 然后就定下来了 。
  5. Tuple 。 有代数数据类型的的语言里面经常有一种构造叫做Tuple , 比如Haskell里面可以写(1, "hello") , 表示一个类型为(Int, String)的结构 。 这种构造经常被人看得过于高尚 , 以至于用在超越它能力的地方 。 其实Tuple就是一个没有名字的结构(类似C的structure) , 而且结构里面的域也没有名字 。 临时使用Tuple貌似很方便 , 因为不需要定义一个结构类型 。 然而因为Tuple没有名字 , 而且里面的域没法用名字访问 , 一旦里面的数据多一点就发现很麻烦了 。 Tuple往往只能通过模式匹配来获得里面的域 , 一旦你增加了新的域进去 , 所有含有这个Tuple的模式匹配代码都需要改 。 所以Tuple一般只能用在大小不超过3的情况下 , 而且必须确信以后不会增加新的域进去 。
  6. 惰性求值(lazy evaluation) 。 貌似数学上很优雅 , 但其实有严重的逻辑漏洞 。 因为bottom(死循环)成为了任何类型的一个元素 , 所以取每一个值 , 都可能导致死循环 。 同时导致代码性能难以预测 , 因为求值太懒 , 所以可能临时抱佛脚做太多工作 , 而平时浪费CPU的时间 。 由于到需要的时候才求值 , 所以在有多个处理器的时候无法有效地利用它们的计算能力 。
  7. 尾递归 。 大部分尾递归都相当于循环语句 , 然而却不像循环语句一样具有一目了然的意图 。 你需要仔细看代码的各个分支的返回条件 , 判断是否有分支是尾递归 , 然后才能判断这代码是个循环 。 而循环语句从关键字(for , while)就知道是一个循环 。 所以等价于循环的尾递归 , 其实最好还是写成特殊的循环语句 。 当然 , 尾递归在另一些情况下是有用的 , 这些情况不等价于循环 。 在这种情况下使用循环 , 经常需要复杂的break或者continue条件 , 导致循环不易理解 。 所以循环和尾递归 , 其实都是有必要的 。


    推荐阅读