8.1.3 组合与范畴
函数式编程的本质是函数的组合,组合的本质是范畴(Category)。
和搞编程的一样,数学家喜欢将问题不断加以抽象从而将本质问题抽取出来加以论证解决,范畴论就是这样一门以抽象的方法来处理数学概念的学科,主要用于研究一些数学结构之间的映射关系(函数)。
在范畴论里,一个范畴(category)由三部分组成:
- 对象(object)
- 态射(morphism)
- 组合(composition)操作符#### 范畴的对象
这里的对象可以看成是一类东西,例如数学上的群,环,以及有理数,无理数等都可以归为一个对象。对应到编程语言里,可以理解为一个类型,比如说整型,布尔型等。#### 态射
态射指的是一种映射关系,简单理解,态射的作用就是把一个对象 A 里的值 a 映射为 另一个对象 B 里的值 b = f(a),这就是映射的概念。
态射的存在反映了对象内部的结构,这是范畴论用来研究对象的主要手法:对象内部的结构特性是通过与别的对象的映射关系反映出来的,动静是相对的,范畴论通过研究映射关系来达到探知对象的内部结构的目的。#### 组合操作符
组合操作符,用点(.)表示,用于将态射进行组合。组合操作符的作用是将两个态射进行组合,例如,假设存在态射 f: A -> B, g: B -> C, 则 g.f : A -> C.
一个结构要想成为一个范畴, 除了必须包含上述三样东西,它还要满足以下三个限制:
结合律: f.(g.h) = (f.g).h 。
封闭律:如果存在态射 f, g,则必然存在 h = f.g 。
同一律:对结构中的每一个对象 A, 必须存在一个单位态射 Ia: A -> A, 对于单位态射,显然,对任意其它态射 f, 有 f.I = f。
在范畴论里另外研究的重点是范畴与范畴之间的关系,就正如对象与对象之间有态射一样,范畴与范畴之间也存在映射关系,从而可以将一个范畴映射为另一个范畴,这种映射在范畴论中叫作函子(functor),具体来说,对于给定的两个范畴 A 和 B, 函子的作用有两个:
- 将范畴 A 中的对象映射到范畴 B 中的对象。
- 将范畴 A 中的态射映射到范畴 B 中的态射。
显然,函子反映了不同的范畴之间的内在联系。跟函数和泛函数的思想是相同的。
而我们的函数式编程探究的问题与思想理念可以说是跟范畴论完全吻合。如果把函数式编程的整个的世界看做一个对象,那么FP真正搞的事情就是建立通过函数之间的映射关系,来构建这样一个美丽的编程世界。
很多问题的解决(证明)其实都不涉及具体的(数据)结构,而完全可以只依赖映射之间的组合运算(composition)来搞定。这就是函数式编程的核心思想。
如果我们把程序
看做图论里面的一张图G,数据结构
当作是图G的节点Node(数据结构,存储状态), 而算法
逻辑就是这些节点Node之间的Edge (数据映射,Mapping), 那么这整幅图 G(N,E)
就是一幅美妙的抽象逻辑之塔的 映射图
, 也就是我们编程创造的世界:
#### 函数是”第一等公民”
函数式编程(FP)中,函数是”第一等公民”。
所谓”第一等公民”(first class),有时称为 闭包 或者 仿函数(functor)对象,指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。这个以函数为参数的概念,跟C语言中的函数指针类似。
举例来说,下面代码中的print变量就是一个函数(没有函数名),可以作为另一个函数的参数:
>>> val print = fun(x:Any){println(x)}
>>> listOf(1,2,3).forEach(print)
1
2
3
```#### 高阶函数(Higher order Function)
FP 语言支持高阶函数,高阶函数就是多阶映射。高阶函数用另一个函数作为其输入参数,也可以返回一个函数作为输出。
代码示例:
```kotlin
fun isOdd(x: Int) = x % 2 != 0
fun length(s: String) = s.length
fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C {
return { x -> f(g(x)) }
}
测试代码:
fun main(args: Array<String>) {
val oddLength = compose(::isOdd, ::length)
val strings = listOf("a", "ab", "abc")
println(strings.filter(oddLength)) // [a, abc]
}
这个compose函数,其实就是数学中的复合函数的概念,这是一个高阶函数的例子:传入的两个参数f , g都是函数,其返回值也是函数。
图示如下:
这里的
fun <A, B, C> compose(f: (B) -> C, g: (A) -> B): (A) -> C
中类型参数对应:
fun <String, Int, Boolean> compose(f: (Int) -> Boolean, g: (String) -> Int): (String) -> Boolean
这里的(Int) -> Boolean
、(String) -> Int
、 (String) -> Boolean
都是函数类型。
其实,从映射的角度看,就是二阶映射。对[a, ab, abc] 中每个元素 x 先映射成长度g(x) = 1, 2, 3 , 再进行第二次映射:f(g(x)) %2 != 0 , 长度是奇数?返回值是true的被过滤出来。
有了高阶函数,我们可以用优雅的方式进行模块化编程。
另外,高阶函数满足结合律:
#### λ演算 (Lambda calculus 或者 λ-calculus)
? 演算是函数式语言的基础。在λ-演算的基础上,发展起来的π-演算、χ-演算,成为近年来的并发程序的理论工具之一,许多经典的并发程序模型就是以π-演算为框架的。λ 演算神奇之处在于,通过最基本的函数抽象和函数应用法则,配套以适当的技巧,便能够构造出任意复杂的可计算函数。
λ演算是一套用于研究函数定义、函数应用和递归的形式系统。它由 阿隆佐·丘奇(Alonzo Church,1903~1995)和 Stephen Cole Kleene 在 20 世纪三十年代引入。当时的背景是解决函数可计算的本质性问题,初期λ演算成功的解决了在可计算理论中的判定性问题,后来根据Church–Turing thesis,证明了λ演算与图灵机是等价的。
λ 演算可以被称为最小的通用程序设计语言。它包括一条变换规则 (变量替换) 和一条函数定义方式,λ演算之通用在于,任何一个可计算函数都能用这种形式来表达和求值。
λ演算强调的是变换规则的运用,这里的变换规则本质上就是函数映射。
Lambda 表达式(Lambda Expression) 是 λ演算 的一部分。
λ演算中一切皆函数,全体λ表达式构成Λ空间,λ表达式为Λ空间到Λ空间的函数。
例如,在 lambda 演算中有许多方式都可以定义自然数,最常见的是Church 整数,定义如下:
0 = λ f. λ x. x
1 = λ f. λ x. f x
2 = λ f. λ x. f (f x)
3 = λ f. λ x. f (f (f x))
...
数学家们都崇尚简洁,只用一个关键字 ‘λ’ 来表示对函数的抽象。
其中的λ f. λ x.
,λ f
是抽象出来的函数, λ x
是输入参数, .
语法用来分割参数表和函数体。 为了更简洁,我们简记为F, 那么上面的Church 整数定义简写为:
0 = F x
1 = F f x
2 = F f (f x)
3 = F f (f (f x))
...
使用λ演算定义布尔值:
TRUE = λ x. λ y. x
FALSE = λ x. λ y. y
用图示如下:
在λ演算中只有函数,一门编程语言中的数据类型,比如boolean、number、list等,都可以使用纯λ演算来实现。我们不用去关心数据的值是什么,重点是我们能对这个值做什么操作(apply function)。
使用λ演算定义一个恒等函数I :
I = λ x . x
使用Kotlin代码来写,如下:
>>> val I = {x:Int -> x}
>>> I(0)
0
>>> I(1)
1
>>> I(100)
100
对 I 而言任何一个 x 都是它的不动点(即对某个函数 f(x) 存在这样的一个输入 x,使得函数的输出仍旧等于输入的 x 。形式化的表示即为 f(x) = x )。
再例如,下面的 λ 表达式表示将x映射为 x+1 :
λ x . x + 1
测试代码:
( λ x . x + 1) 5
将输出6 。
这样的表达式,在Kotlin中, 如果使用Lambda表达式我们这样写:
>>> val addOneLambda = {
... x: Int ->
... x + 1
... }
>>> addOneLambda(1)
2
如果使用匿名函数,这样写:
>>> val addOneAnonymouse = (fun(x: Int): Int {
... return x + 1
... })
>>> addOneAnonymouse(1)
2
在一些古老的编程语言中,lambda表达式还是比较接近lambda演算的表达式的。在现代程序语言中的lambda表达式,只是取名自lambda演算,已经与原始的lambda演算有很大差别了。例如:
在Javascript里没有任何语法专门代表lambda, 只写成这样的嵌套函数function{ return function{...} }
。#### 函数柯里化(Currying)
很多基于 lambda calculus 的程序语言,比如 ML 和 Haskell,都习惯用currying 的手法来表示函数。比如,如果你在 Haskell 里面这样写一个函数:
f x y = x + y
然后你就可以这样把链表里的每个元素加上 2:
map (f 2) [1, 2, 3]
它会输出 [3, 4, 5]。
Currying 用一元函数,来组合成多元函数。比如,上面的函数 f 的定义在 Scheme 里面相当于:
(define f (lambda (x) (lambda (y) (+ x y))))
它是说,函数 f,接受一个参数 x,返回另一个函数(没有名字)。这个匿名函数,如果再接受一个参数 y,就会返回 x + y。所以上面的例子里面,(f 2) 返回的是一个匿名函数,它会把 2 加到自己的参数上面返回。所以把它 map 到 [1, 2, 3],我们就得到了 [3, 4, 5]。
我们再使用Kotlin中的函数式编程来举例说明。
首先,我们看下普通的二元函数的写法:
fun add(x: Int, y: Int): Int {
return x + y
}
add(1, 2) // 输出3
这种写法最简单,只有一层映射。
柯里化的写法:
fun curryAdd(x: Int): (Int) -> Int {
return { y -> x + y }
}
curryAdd(1)(2)// 输出3
我们先传入参数x = 1, 返回函数 curryAdd(1) = 1 + y;然后传入参数 y = 2, 返回最终的值 curryAdd(1)(2) = 3。
当然,我们也有 λ 表达式的写法:
val lambdaCurryAdd = {
x: Int ->
{
y: Int ->
x + y
}
}
lambdaCurryAdd(1)(2) // 输出 3
这个做法其实来源于最早的 lambda calculus 的设计。因为 lambda calculus 的函数都只有一个参数,所以为了能够表示多参数的函数, Haskell Curry (数学家和逻辑学家),发明了这个方法。
不过在编码实践中,Currying 的工程实用性、简洁性上不是那么的友好。大量使用 Currying,会导致代码可读性降低,复杂性增加,并且还可能因此引起意想不到的错误。 所以在我们的讲求工程实践性能的Kotlin语言中,
古老而美丽的理论,也许能够给我带来思想的启迪,但是在工程实践中未必那么理想。#### 闭包(Closure)
闭包简单讲就是一个代码块,用{ }
包起来。此时,程序代码也就成了数据,可以被一个变量所引用(与C语言的函数指针比较类似)。闭包的最典型的应用是实现回调函数(callback)。
闭包包含以下两个组成部分:
- 要执行的代码块(由于自由变量被包含在代码块中,这些自由变量以及它们引用的对象没有被释放)
- 自由变量的作用域
在PHP、Scala、Scheme、Common Lisp、Smalltalk、Groovy、JavaScript、Ruby、 Python、Go、Lua、objective c、swift 以及Java(Java8及以上)等语言中都能找到对闭包不同程度的支持。
Lambda表达式可以表示闭包。#### 惰性计算
除了高阶函数、闭包、Lambda表达式的概念,FP 还引入了惰性计算的概念。惰性计算(尽可能延迟表达式求值)是许多函数式编程语言的特性。惰性集合在需要时提供其元素,无需预先计算它们,这带来了一些好处。首先,您可以将耗时的计算推迟到绝对需要的时候。其次,您可以创造无限个集合,只要它们继续收到请求,就会继续提供元素。第三,map 和 filter 等函数的惰性使用让您能够得到更高效的代码(请参阅 参考资料 中的链接,加入由 Brian Goetz 组织的相关讨论)。
在惰性计算中,表达式不是在绑定到变量时立即计算,而是在求值程序需要产生表达式的值时进行计算。
一个惰性计算的例子是生成无穷 Fibonacci 列表的函数,但是对 第 n 个Fibonacci 数的计算相当于只是从可能的无穷列表中提取一项。#### 递归函数
递归指的是一个函数在其定义中直接或间接调用自身的一种方法, 它通常把一个大型的复杂的问题转化为一个与原问题相似的规模较小的问题来解决(复用函数自身), 这样可以极大的减少代码量。递归分为两个阶段:
- 递推:把复杂的问题的求解推到比原问题简单一些的问题的求解;
- 回归:当获得最简单的情况后,逐步返回,依次得到复杂的解。
递归的能力在于用有限的语句来定义对象的无限集合。
使用递归要注意的有两点:
- 递归就是在过程或函数里面调用自身;
- 在使用递归时,必须有一个明确的递归结束条件,称为递归出口。
下面我们举例说明。
阶乘函数 fact(n) 一般这样递归地定义:
fact(n) = if n=0 then 1 else n * fact(n-1)
我们使用Kotlin代码实现这个函数如下:
fun factorial(n: Int): Int {
println("factorial() called! n=$n")
if (n == 0) return 1;
return n * factorial(n - 1);
}
测试代码:
@Test
fun testFactorial() {
Assert.assertTrue(factorial(0) == 1)
Assert.assertTrue(factorial(1) == 1)
Assert.assertTrue(factorial(3) == 6)
Assert.assertTrue(factorial(10) == 3628800)
}
输出:
factorial() called! n=0
factorial() called! n=1
factorial() called! n=0
factorial() called! n=3
factorial() called! n=2
factorial() called! n=1
factorial() called! n=0
factorial() called! n=10
factorial() called! n=9
factorial() called! n=8
factorial() called! n=7
factorial() called! n=6
factorial() called! n=5
factorial() called! n=4
factorial() called! n=3
factorial() called! n=2
factorial() called! n=1
factorial() called! n=0
BUILD SUCCESSFUL in 24s
6 actionable tasks: 5 executed, 1 up-to-date
我们可以看到在factorial计算的过程中,函数不断的调用自身,然后不断的展开,直到最后到达了终止的n==0,这是递归的原则之一,就是在递归的过程中,传递的参数一定要不断的接近终止条件,在上面的例子中就是n的值不断减少,直至最后为0。
再举个Fibonacci数列的例子。
Fibonacci数列用数学中的数列的递归表达式定义如下:
fibonacci (0) = 0
fibonacci (1) = 1
fibonacci (n) = fibonacci (n - 1) + fibonacci (n - 2)
我们使用Kotlin代码实现它:
fun fibonacci(n: Int): Int {
if (n == 1 || n == 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
测试代码:
@Test
fun testFibonacci() {
Assert.assertTrue(fibonacci(1) == 1)
Assert.assertTrue(fibonacci(2) == 1)
Assert.assertTrue(fibonacci(3) == 2)
Assert.assertTrue(fibonacci(4) == 3)
Assert.assertTrue(fibonacci(5) == 5)
Assert.assertTrue(fibonacci(6) == 8)
}
外篇: Scheme中的递归写法
因为Scheme 程序中充满了一对对嵌套的小括号,这些嵌套的符号体现了最基本的数学思想——递归。所以,为了多维度的来理解递归,我们给出Scheme中的递归写法:
(define factorial
(lambda (n)
(if (= n 0)
1
(* n (factorial (- n 1))))))
(define fibonacci
(lambda (n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))))
其中关键字lambda, 表明我们定义的(即任何封闭的开括号立即离开λ及其相应的关闭括号)是一个函数。
Lambda演算和函数式语言的计算模型天生较为接近,Lambda表达式一般是这些语言必备的基本特性。
Scheme是Lisp方言,遵循极简主义哲学,有着独特的魅力。Scheme的一个主要特性是可以像操作数据一样操作函数调用。#### Y组合子(Y - Combinator)
在现代编程语言中,函数都是具名的,而在传统的Lambda Calculus中,函数都是没有名字的。这样就出现了一个问题 —— 如何在Lambda Calculus中实现递归函数,即匿名递归函数。Haskell B. Curry (编程语言 Haskell 就是以此人命名的)发现了一种不动点组合子 —— Y Combinator,用于解决匿名递归函数实现的问题。Y 组合子(Y Combinator),其定义是:
Y = λf.(λx.f (x x)) (λx.f (x x))
对于任意函数 g,可以通过推导得到Y g = g (Y g)
((高阶)函数的不动点 ),从而证明 λ演算 是 图灵完备 的。 Y 组合子 的重要性由此可见一斑。
她让人绞尽脑汁,也琢磨不定!她让人心力憔悴,又百般回味!
她,看似平淡,却深藏玄机!她,貌不惊人,却天下无敌!
她是谁?她就是 Y 组合子:Y = λf.(λx.f (x x)) (λx.f (x x)),不动点组合子中最著名的一个。
Y 组合子让我们可以定义匿名的递归函数。Y组合子是Lambda演算的一部分,也是函数式编程的理论基础。仅仅通过Lambda表达式这个最基本的 原子 实现循环迭代。Y 组合子本身是函数,其输入也是函数(在 Lisp 中连程序都是函数)。
颇有道生一、一生二、二生三、三生万物的韵味。
举个例子说明: 我们先使用类C语言中较为熟悉的JavaScript来实现一个Y组合子函数, 因为JavaScript语言的动态特性,使得该实现相比许多需要声明各种类型的语言要简洁许多:
function Y(f) {
return (function (g) {
return g(g);
})(function (g) {
return f(function (x) {
return g(g)(x);
});
});
}
var fact = Y(function (rec) {
return function (n) {
return n == 0 ? 1 : n * rec(n - 1);
};
});
我们使用了Y函数组合一段匿名函数代码,实现了一个匿名的递归阶乘函数。
直接将这两个函数放到浏览器的Console中去执行,我们将看到如下输出:
fact(10)
3628800
这个Y函数相当绕脑。要是在Clojure(JVM上的Lisp方言)中,这个Y函数实现如下:
(defn Y [r]
((fn [f] (f f))
(fn [f]
(r (fn [x] ((f f) x))))))
使用Scheme语言来表达:
(define Y
(lambda (f)
((lambda (x) (f (lambda (y) ((x x) y))))
(lambda (x) (f (lambda (y) ((x x) y)))))))
我们可以看出,使用Scheme语言表达的Y组合子跟 原生的 λ演算 表达式基本一样。
用CoffeeScript实现一个 Y combinator就长这样:
coffee> Y = (f) -> ((x) -> (x x)) ((x) -> (f ((y) -> ((x x) y))))
[Function]
这个看起就相当简洁优雅了。我们使用这个 Y combinator 实现一个匿名递归的Fibonacci函数:
coffee> fib = Y (f) -> (n) -> if n < 2 then n else f(n-1) + f(n-2)
[Function]
coffee> index = [0..10]
[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
coffee> index.map(fib)
[ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 ]
实现一个匿名递归阶乘函数:
coffee> fact = Y (f) ->(n) -> if n==0 then 1 else n*f(n-1)
[Function]
coffee> fact(10)
3628800
上面的Coffee代码的命令行REPL运行环境搭建非常简单:
$ npm install -g coffee-script
$ coffee
coffee>
对CoffeeScript感兴趣的读者,可以参考:http://coffee-script.org/。
但是,这个Y组合子 要是 使用 OOP 语言编程范式, 就要显得复杂许多。为了更加深刻地认识OOP 与 FP编程范式,我们使用Java 8 以及 Kotlin 的实例来说明。这里使用Java给出示例的原因,是为了给出Kotlin与Java语言上的对比,在下一章节中,我们将要学习Kotlin与Java的互操作。
首先我们使用Java的匿名内部类实现Y组合子 :
package com.easy.kotlin;
public class YCombinator {
public static Lambda<Lambda> yCombinator(final Lambda<Lambda> f) {
return new Lambda<Lambda>() {
@Override
public Lambda call(Object input) {
final Lambda<Lambda> u = (Lambda<Lambda>)input;
return u.call(u);
}
}.call(new Lambda<Lambda>() {
@Override
public Lambda call(Object input) {
final Lambda<Lambda> x = (Lambda<Lambda>)input;
return f.call(new Lambda<Object>() {
@Override
public Object call(Object input) {
return x.call(x).call(input);
}
});
}
});
}
public static void main(String[] args) {
Lambda<Lambda> y = yCombinator(new Lambda<Lambda>() {
@Override
public Lambda call(Object input) {
final Lambda<Integer> fab = (Lambda<Integer>)input;
return new Lambda<Integer>() {
@Override
public Integer call(Object input) {
Integer n = Integer.parseInt(input.toString());
if (n < 2) {
return Integer.valueOf(1);
} else {
return n * fab.call(n - 1);
}
}
};
}
});
System.out.println(y.call(10));//输出: 3628800
}
interface Lambda<E> {
E call(Object input);
}
}
这里定义了一个Lambda<E>
类型, 然后通过E call(Object input)
方法实现自调用,方法实现里有多处转型以及嵌套调用。逻辑比较绕,代码可读性也比较差。当然,这个问题本身也比较复杂。
我们使用Java 8的Lambda表达式来改写下匿名内部类:
package com.easy.kotlin;
public class YCombinator2 {
public static Lambda<Lambda> yCombinator2(final Lambda<Lambda> f) {
return ((Lambda<Lambda>)(Object input) -> {
final Lambda<Lambda> u = (Lambda<Lambda>)input;
return u.call(u);
}).call(
((Lambda<Lambda>)(Object input) -> {
final Lambda<Lambda> v = (Lambda<Lambda>)input;
return f.call((Lambda<Object>)(Object p) -> {
return v.call(v).call(p);
});
})
);
}
public static void main(String[] args) {
Lambda<Lambda> y2 = yCombinator2(
(Lambda<Lambda>)(Object input) -> {
Lambda<Integer> fab = (Lambda<Integer>)input;
return (Lambda<Integer>)(Object p) -> {
Integer n = Integer.parseInt(p.toString());
if (n < 2) {
return Integer.valueOf(1);
} else {
return n * fab.call(n - 1);
}
};
});
System.out.println(y2.call(10));//输出: 3628800
}
interface Lambda<E> {
E call(Object input);
}
}
最后,我们使用Kotlin的对象表达式(顺便复习回顾一下上一章节的相关内容)实现Y组合子:
package com.easy.kotlin
// lambda f. (lambda x. (f(x x)) lambda x. (f(x x)))
object YCombinatorKt {
fun yCombinator(f: Lambda<Lambda<*>>): Lambda<Lambda<*>> {
return object : Lambda<Lambda<*>> {
override fun call(n: Any): Lambda<*> {
val u = n as Lambda<Lambda<*>>
return u.call(u)
}
}.call(object : Lambda<Lambda<*>> {
override fun call(n: Any): Lambda<*> {
val x = n as Lambda<Lambda<*>>
return f.call(object : Lambda<Any> {
override fun call(n: Any): Any {
return x.call(x).call(n)!!
}
})
}
}) as Lambda<Lambda<*>>
}
@JvmStatic fun main(args: Array<String>) {
val y = yCombinator(object : Lambda<Lambda<*>> {
override fun call(n: Any): Lambda<*> {
val fab = n as Lambda<Int>
return object : Lambda<Int> {
override fun call(n: Any): Int {
val n = Integer.parseInt(n.toString())
if (n < 2) {
return Integer.valueOf(1)
} else {
return n * fab.call(n - 1)
}
}
}
}
})
println(y.call(10)) //输出: 3628800
}
interface Lambda<E> {
fun call(n: Any): E
}
}
关于Y combinator的更多实现,可以参考:https://gist.github.com/Jason-Chen-2017/88e13b63fa5b7c612fddf999739964b0 ; 另外,关于Y combinator的原理介绍,推荐看《The Little Schemer 》这本书。
从上面的例子,我们可以看出OOP中的对接口以及多态类型,跟FP中的函数的思想表达的,本质上是一个东西,这个东西到底是什么呢?我们姑且称之为“编程之道”罢!
Y combinator 给我们提供了一种方法,让我们在一个只支持first-class函数,但是没有内建递归的编程语言里完成递归。所以Y combinator给我们展示了一个语言完全可以定义递归函数,即使这个语言的定义一点也没提到递归。它给我们展示了一件美妙的事:仅仅函数式编程自己,就可以让我们做到我们从来不认为可以做到的事(而且还不止这一个例子)。
严谨而精巧的lambda演算体系,从最基本的概念“函数”入手,创造出一个绚烂而宏伟的世界,这不能不说是人类思维的骄傲。#### 没有”副作用”
所谓”副作用”(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。
函数式编程强调没有”副作用”,意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。
函数式编程的动机,一开始就是为了处理运算(computation),不考虑系统的读写(I/O)。”语句”属于对系统的读写操作,所以就被排斥在外。
当然,实际应用中,不做I/O是不可能的。因此,编程过程中,函数式编程只要求把I/O限制到最小,不要有不必要的读写行为,保持计算过程的单纯性。
函数式编程只是返回新的值,不修改系统变量。因此,不修改变量,也是它的一个重要特点。
在其他类型的语言中,变量往往用来保存”状态”(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。#### 引用透明性
函数程序通常还加强引用透明性,即如果提供同样的输入,那么函数总是返回同样的结果。就是说,表达式的值不依赖于可以改变值的全局状态。这样我们就可以从形式上逻辑推断程序行为。因为表达式的意义只取决于其子表达式而不是计算顺序或者其他表达式的副作用。这有助于我们来验证代码正确性、简化算法,有助于找出优化它的方法。