《函数式编程思维》笔记

函数式编程中粒度最小的重用单元是函数(一等公民),并具备值不可变性,带给我的感受是通过一系列基本数据结构方法的复用,配合高阶函数,用最基本的方法叠加出复杂的解法。在用Haskell解决问题总能体会到逆向思维,从目标出发,一步步推到初始条件。函数式的模式匹配、柯里化和部分施用都很有特色,在这种思维下思考是一个很享受的过程。下面是阅读《函数式编程思维》时做摘录的整理。

思维转变

  • 命令式编程风格通常 迫使我们出于性能考虑,把不同的任务交织起来,以便能够用一次循环来完成多个任务 。而函数式编程用map、filter这些高阶函数把我们解放出来,让我们 站在更高的抽象层次上去考虑问题 ,把问题看得更清楚。

  • 把控制权让渡给语言(运行时)。“人生苦短,远离malloc”。函数式编程语言让我们用高阶抽象从容取代基本的控制结构,将琐碎的细节(如垃圾处理)交托给运行时。 面向对象编程通过封装不确定因素来使代码能被人理解;函数式编程通过尽量减少不确定因素来使代码能被人理解。——Michael Feathers 。与其建立种种机制来控制可变的状态,不如尽可能消灭可变的状态这个不确定因素。

  • 函数式语言提倡 在有限的几种关键数据结构(list、set、map)上运用针对这些数据结构高度优化过的操作 ,以此形成基本的运转架构;面向对象程序员喜欢不断的创建新的数据结构和附属的操作,因为OOP范式就是建立新的类和类间的消息。比起一味创建新的类结构体系,把封装的单元降低到函数级别,更有利于达到细颗粒度的、基础层面的重用。
  • 换用函数式语言不是关键,转变看待问题的角度才是必不可少的。命令式编程是按照“程序是一系列改变状态的命令”来建模的一种编程风格,鼓励程序员将操作安排在循环内部执行。 函数式语言希望尽可能减少可变的状态 ,因此更多发展了通用性的计算设施。
  • 高阶函数消除了摩擦。语法上的便利是非常重要的方面, 在语法处处掣肘下塑造出的抽象,很难配合我们的思维过程而不产生所谓的摩擦 。迭代需要让位于高阶函数,如果能用高阶函数把希望执行的操作表达出来,语言将会把操作安排的更高效。

权责让渡

  • 理解掌握的抽象层次永远要比日常使用的抽象层次更深一层

  • 闭包 (closure)实际上是一种特殊的函数,在暗地里绑定了函数内部引用的所有变量,换句话说,这种函数把它所引用的所有东西都放在一个上下文里包了起来。下面的代码先定义了一个Employee类,其中带有name和salary字段,接着定义带有amount参数的paidMore函数,其返回值是一个以Employee实例为参数的 代码块 ,或者叫闭包。数值100000随着isHighPaid = paidMore(100000)这一步操作永久的和代码块绑定在一起。第二部分代码执行闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Employee{
def name, salary
}
def paidMore(amount) {
return {Employee e -> e.salary > amount }
}
isHighPaid = paidMore(100000)


def Smithers = new Employee(name:"Fred", salary:120000)
def Homer = new Employee(name:"Homer", salary:80000)
println isHighPaid(Smithers)
println isHighPaid(Homer)
  • 闭包经常被函数式语言和框架当作一种 异地执行的机制 ,用来传递待执行的变换代码,如map之类的高阶函数。注意闭包是代码块,而不是一个值,各个闭包内部状态都是独立的,尽管局部变量不在代码块内定义,但只要代码块引用了该变量,两者就被绑定在一起,这种联系在代码块实例的全部生命期内一直保持着。从实现的角度说, 代码块实例从它被创建的一刻起,就持有其作用域内一切事物的封闭副本 。下面的代码展示了闭包的异地执行。闭包所表现出来的函数式思维就是,“让运行时去管理状态”。
1
2
3
4
5
6
7
8
9
10
11
def Closure makeCounter(){
def local_variable = 0
return { return local_variable += 1}
}
c1 = makeCounter()
c1()
c1()
c1()
c2 = makeCounter()
println "C1 = ${c1()}, C2 = ${c2()}"
// output: C1 = 4, C2 = 1 //
  • 柯里化 指的是从一个多参数函数变成 一连串单参数函数 的变换。它描述的是 变换的过程 ,不涉及变换之后对函数的调用。调用者可以决定对多少个参数实施变换,余下的部分将衍生成一个参数数目较少的新函数。举例来说,函数process(x, y, z)完全柯里化之后变成process(x)(y)(z)的性质,其中process(x)和process(x)(y)都是单参数的函数。如果只对第一个参数柯里化,那么process(x)的返回值将是一个单参数的函数,而这个唯一的参数又接受另一个参数的输入。 函数柯里化的结果是返回链条中的下一个函数
  • 部分施用 指提前代入一部分参数值,使一个多参数得以省略部分参数,从而转化为一个参数数目较少的函数。 部分施用是把参数的取值绑定到用户在操作中提供的具体值上

  • 递归 的核心在于对一个不断变短的列表反复做同一件事,利用递归,将状态的管理责任推给运行时。递归没有成为一种平常的操作,一个主要原因是栈的增长。使用尾调用优化的写法来帮助运行时科夫栈的增长问题。当递归调用是函数执行的最后一个调用时,运行时往往可以在栈里就地更新,而不需要增加新的栈空间。因此 尽可能多的使用尾递归的写法

记忆和缓求值

  • 只有对纯函数才能放心地使用函数缓存的结果,这刚好符合函数式特性。对于Groovy,可以先将记忆的函数定义为闭包,再对闭包使用memoize()方法获得一个新函数,这个新函数调用的时候结果就会被缓存起来。在Haskell中好像下面的链接可以实现。我们写出来的缓存决不可能比语言开发者设计的更高效,因为语言设计者可以无视他们给语言设定的规定。 语言设计者实现出来的机制总是比开发者自己做的效率高Haskell-Wiki上的Memoization

  • 缓求值的好处:昂贵的运算只有到了绝对必要的时候才执行;可以建立无限大的集合,只要一接到请求就一直送出元素;按缓求值的方式使用map、filter等,可以产生更高效的代码。特别适合于资源生产成本较高的情况。Haskell的惰性求值就是这样的特性,是非严格求值的。在严格求值的语言运行下面代码会报错,而非严格求值的语言会得出4。

1
print length([2+1, 3*2, 1/0, 5-4])

语言演化

  • 少量的数据结构搭配大量的操作。函数式语言有很多操作,但对应的数据结构很少。面向对象语言鼓励建立专门针对某个类的方法,我们从类的关系中发现重复出现的模式并加以重用。 函数式语言的重用表现在函数的通用性上,他们鼓励在数据结构上使用各种共通的变换,并通过高阶函数来调整操作以满足具体事项的要求100个函数操作一种数据结构的组合,要好过10个函数操作10种数据结构的组合。——Alan Perlis

  • 让语言去迎合问题 ,不要拿问题硬套语言,而是想法揉捏手中的语言来迎合问题。Lisp家族的语言传承了无可比拟的灵活性,对DSL的支持比主流语言要强得多。

  • 函数式偏好没有副作用的纯函数,“异常”违背了这个条件。因此函数式语言通过Either类这种不相交联合体,返回左值表示错误信息,右值表示正常结果。函数式语言关注 引用的透明性 ,发出调用的例程不必关心他的访问对象真的是一个值,还是一个返回值的函数。
  • 现代语言大多数是多范式的,支持多种多样的编程范式,如OOP,元编程、函数式、过程式等等。这些范式在语言中相互正交(没有任何影响),不会相互干扰。
  • 设计模式的变化,模式已被函数式语言吸收成为了语言的一部分,语言特性简化了实现细节。OOP模式和FP模式已经具备了不同的意义。面向对象倾向于封装对象的重用,在不同的结构之间 耦合 。而函数式编程则依靠零件之间的 复合 来组织抽象,以达到减少不确定因素的目的。

参考文献: 《函数式编程思维 - 美 Neal Ford》

原创作品,允许转载,转载时无需告知,但请务必以超链接形式标明文章原始出处(https://forec.github.io/2016/02/13/functional-thinking/) 、作者信息(Forec)和本声明。

分享到