题目虽然是函数式编程,但是这里并不会介绍函数式编程,Google 一下,全世界都是函数式编程的入门文章。
说起函数式编程的时候,大多数人都知道 Erlang/Haskell/Lisp 是函数式编程语言,并且常见的命令式编程语言如 C/Java/Python 也支持一些函数式的特性。但是,到底什么是函数式编程呢?
跟面向对象编程一样,函数式编程只是一些列的思想,也就是如何撸代码的方法论,而不是一套严苛的规定。它的思想源自于 阿隆佐·邱奇 的 lambda演算,与之对应的是 艾伦·图灵 发明的 图灵机,他们实际上是基友,只是阿隆佐运气没那么好,我们所使用的计算机都是 冯·诺伊曼设计架构。一直到 Lisp 语言的发明,函数式编程才慢慢的被世人认可。
跟面向对象所谓的一切皆对象相对应,函数式编程有个一切皆表达式的说法。函数是由更小的函数组成的,函数的参数是函数,函数的返回值也是函数。
Erlang 在这方面就十分严格的遵循了变量不变这一教条,但实际来看,这也带来了不少麻烦。比如一个作用域里的一个名字只能指向一个特定值/地址。比如说要实现斐波那契数列,python 可以这样写
def fib(n):
if n == 1 or n == 2:
return 1
a, b = 1, 1
for i in range(3, n+1):
a, b = b, a+b
return b
使用 Erlang 就只能使用尾递归实现,很少写 Erlang 也不知道是否写对了
fib(1) -> 1;
fib(2) -> 1;
fib(N) -> fib_compute(1, 2, 3, N).
fib_compute(A, B, N, N) -> B;
fib_compute(A, B, I, N) when I < N -> fib_compute(B, A+B, I+1, N).
但是千万别写成了这样
fib(1) -> 1;
fib(2) -> 1;
fib(N) -> fib(N-1) + fib(N-2).
这样的递归从形式上来看,就知道会消耗大量的栈空间,Erlang 编译器应该不会聪明到这种递归都为你优化好。
但是 Elixir 突破了这层障碍,它的变量是可变的。于是,我们的斐波那契数列也可以写成和 python 类似
def fib(1), do: 1
def fib(2), do: 1
def fib(n) do
Enum.reduce(3..n+1, {1, 1}, fn _x, {a, b} -> {b, a+b} end)
|> elem(1)
end
这里我们没有使用 for 循环,是因为函数式编程的每个表达式/函数都会返回一个值。而 for 循环作用域中的 a/b 改变,并不会影响到作用域之外的 a/b,也就是说,如果在 for 循环中写了 {a, b} = {b, a+b}
,每次循环结束之后并不会影响到 for 之外初始化的 a/b,它们的值永远不变。
模式匹配本身也不是函数式编程的固有特征,不过加入这个特性,可以让代码简洁很多,省去了一大部分 if-else/switch 之类的分支判断。原理其实也很简单,运用模式匹配的函数,在运行时,编译器会对传入的参数和函数定义的参数进行比较,并选择最为接近的那个。
比如对普通函数的一个多层if语句
def func(a, b, c, d):
if a == 0:
if b == 1 and c == 2:
return d
else:
return a
return None
换成 Elixir 的模式匹配之后,会变成这样
def func(0, 1, 1, d), do: d
def func(0, _, _, _), do: nil
def func(a, _, _, _), do: a
后者明显比前者更加清晰易懂,不过需要注意的是,函数定义的顺序一定要对,模式匹配是从上往下一个一个来的。
以前只在 linux 中见识过管道,概念简单但却威力惊人。管道使得代码的易读性大大增高,曾经的一层一层的函数包裹,只需要简单 |>
即可实现参数传递
惰性求值是函数式的一个基本特征。一般情况下,我们再写代码的时候并不关心编译器会如何执行、何时执行我们的语句。指令式编程语言的所有代码都是顺序执行的,这让我们很容易调试比如说一些 print 语句就可以打印出当前上下文的一些信息。可喜的是,大多数函数式在执行的时候也是顺序执行的,我也不知道编译器是如何优化成顺序执行的。但是既然是函数式编程语言,惰性求值自然也不能少。
比如,我们会熟练的使用 Enum 枚举类型
Enum.map(1..n, fn x -> x*x end)
|> Enum.filter(fn x -> rem(x, 2) end)
|> Enum.sum
在 n 不是很大的时候并不会有任何问题,但在 n 十分大的时候(比如 1000,000,000) 这里就会生成两个 十分大的列表,因为函数是即时执行的,每个管道都会生成一堆中间变量,造成了大量的内存消耗。
Elixir 提供了流 Stream 类型来实现惰性求值,可以将上面写成
Strea.map(1..n, fn x -> x*x end)
|> Stream.filter(fn x -> rem(x, 2) end)
|> Enum.sum
只有在最后碰到 Enum.sum 的时候,前面才会进行求值。
实际上,大多数编程语言都会不自觉的提到闭包,似乎闭包已经是现代编程语言一项不可缺少的特性。同样,Elixir 也支持闭包,并且是真正的闭包,对作用域外的变量无副作用,也就是仅仅包含指向数据的指针。比如
>> a = 5
>> f = fn x -> x+a end
>> f.(10)
15
>> a = 100
>> f.(10)
15
可以看到,闭包只捕获了在他之前存在的变量 a 的值,之后 a 改变,但是并没有影响到函数 f 中的 a。由此可见,f 中的 a 已经是一个独立于外部 a 的单独的内存区域(fh在 heap 中)。这一特性,实际上就是变量不变的特性,全局的 a 值发生了变化,仅仅是因为它指向的地址发生了变化而已。所以,并不是说它不修改外部数据,而是它根本没办法修改。
相较与其他语言中的闭包,也不能说哪个更好,比如 Python 的装饰器,可以为我们实现强大的函数缓存
def deco(func):
instence = {}
def wrap(*args, **kwargs):
if func.__name__ in instence:
instence[func.__name__] += 1
else
instence[func.__name__] += 1
func(*args, **kwargs)
这才是最令人兴奋的地方,为元编程提供了极大的便利。Python 也提供源编程,并且也有提供访问 AST 的库,比如 ast 和 pythoscope,但好像作用并不是很大,因为是解释型语言并且已经有了各种好用的语法糖。虽然还有如 __metaclass__
等更高级的特性来实现元编程,可是在实际生产中却鲜有使用,除非要写一个 ORM 库之类的,少用的另一个原因是难学,要理解 Python 元变成的概念都要花大把时间。
Elixir 完全破解了这种魔咒,在 元编程 里,我们可以轻轻松松的实现各种奇葩的功能,甚至是直接编写自己的 DSL。后面我想尝试用简单易懂的方法来实现一个 mongodb 的 orm 库,并跟 python 实现相比较,感受一下是否能甩几条街。
在把函数当成参数之后,被调用的函数需要在后面加个.
号,略感奇葩。比如
def call(func) do
func.()
end
原因很简单,Elixir 的函数调用 func
和 func()
是等价的。把函数当成参数的时候,实际上是传的函数的定义/地址,后面加个点号告诉了编译器这样转换之后才是函数本身。
在 1.5 版本之后,函数调用不加括号会报 warning,看来消除加点号的操作还是指日可待的。
比如说对一个以 atom 为 key 的 map 里面,%{a: 1, b: 2}
冒号后面必须有空格,不然就会报错。虽然 Python 也很奇葩的用缩进来表示代码块,但至少还是说得过去。这个冒号必须要空格就不知道是为什么了,难道是因为 Erlang 的函数调用的形式是 erlang:some_func
?
最近又看到了 这篇文章,是 Elixir 作者写的。开篇就点明:函数式不是 Erlang VM 的目标(goal),而是达到目标的手段(end)
I would like to add a slightly different perspective to functional programming in the Erlang VM: functional programming is not a goal in the Erlang VM. It is a means to an end.