最近在写代码的时候常常感觉迷茫,到底函数式语言应该如何写代码。immutable 数据结构的好处到底在哪里。为什么我写出来的代码总感觉像是命令式、过程式的。
带着这些疑惑,我又重新开始学习了史上最成功的函数式语言 ---- SQL。是的你没看错,SQL是一门函数式语言。
在 https://www.sql-ex.ru/ 这个网站上,你可以做很多免费的 SQL 习题,还有免费的排名,非常爽。
而 SQL 核心的语句是很简单的,select
, from
, where
三板斧,轻轻松松地就能将需求变成查询语句。
例如这样一个问题:Find the model number, speed and hard drive capacity of PCs cheaper than $600 having a 12x or a 24x CD drive.
写出来的 SQL 答案就是:
select model, speed, hd
from PC
where price < 600 and (cd = '12x' or cd = '24x')
简直就是大白话直译,依然信达雅齐全。所以我们总结出来,要写出好的函数式代码,你得设计出好的 DSL,或者是好的抽象,好坏的标准就是能否够直接翻译产品的需求。而能否用自然语言描述出需求,也是很考验能力的。所以我们进一步推出:
要成为一个好的函数式程序员,你需要成为一个好的产品经理。
匿名函数
匿名函数又被称作 lambda,可以说是函数式语言的灵魂存在。比如这一段 SQL 语句:
join (...) as t on Product.model = t.model
其实就暗藏了一个匿名函数, 用 JS ES6 来写, 大概就就是
(Product, t) => Product.model === t.model
而 join
函数则被称为高阶函数,因为它以匿名函数为参数。
使用匿名函数和高阶函数的组合可以增加 DSL 使用者的自由度。
描述一件事而不是做一件事
函数式语言区别于过程式语言的一点是:描述一件事而非去做一件事。有时候你会发现其实很难区分这种区别,因为严格意义上来说,语言其实永远都是在描述一件事。俗话说 “光说不练”,意味着“说” 和 “做” 是相互对立的,而语言并不能实际上去 “做” 。
之所以在一些语言里,会给我们感觉是 “做” 了什么,是因为使用了可变数据结构。例如:
let a = 1;
// a = 1
a++;
// a = 2
a *= 2;
// a = 4
可以看到在语言被”说“出来的过程中,变量 a 的值就发生了变化,语言实实在在地 ”做“ 了一些事情 ———— 改变了变量的值。可以这样读上面的代码:令a为1,令a加1,令a乘以2.
所以,在”只说不做“的函数式语言里,通常使用的是不可变数据结构。即在语言描述的过程中,不会改变任何的值。例如:
A0 = 1,
A1 = A0 + 1,
A2 = A1 * 2.
% A2 = 4
这样读上面的代码会更合适:有A0等于1,有A1等于A0加1,有A2等于A1乘以2.
从这个角度来说,我们就更能说明 SQL 是函数式语言,因为它也没有变量。另外,我们甚至可以说 SVG 也是函数式语言。例如这样一段 SVG 代码:
<polygon points="60,30 90,90 30,90">
<animateTransform attributeName="transform"
attributeType="XML"
type="rotate"
from="0 60 70"
to="360 60 70"
dur="10s"
repeatCount="indefinite"/>
</polygon>
描述了一个旋转的多边形动画,在其中你看不到任何变量,只有定义。函数式语言可真是一个懒汉,总是光说不做,所以,要实际做出可以用的东西,一般我们需要底层用命令式的语言去实现,例如将 SQL 解析后转化成实际的查询,将 SVG 定义绘制成真正的动画。所以我们使用函数式语言的时候不要有优越感,因为如果没有过程式的底层去“做事”,上层的函数式代码只能沦为空中楼阁。
把做事的过程隐藏起来
既然函数式语言是 “光说不做”的,那么要显得更加函数式,一个方法就是要把做事的过程藏起来。例如看下面这段 React Hooks 的代码:
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
对于每点击一次按钮都把 count 的值加1,最容易想到的“做事”方法就是改变 count 的值,即 count++
,这函数式吗?这不函数式。所以仔细看上面的代码,从第一行读到最后一行,count的值变了吗?没有。做事的过程被隐藏到了 setCount
里面,所以这很函数式。
命令的多个出口
曾经有一段时间我以为函数式语言的特征是函数到最后只有一个输出。后来我发现其实所谓的一个输出,是从编译器的角度去看的。在写代码的时候,每一个带有副作用的命令,都可以看作是一个出口。
例如这样一段 React Hooks 代码:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
就能看到 useEffect
这个函数是没有返回值的,因为它实际上的作用是给 DOM 的观察者绑定了一个句柄,是一个有副作用的函数。我喜欢这样理解:函数式的世界是一个纯洁的没有副作用的世界,这些有副作用的函数就是联通函数式世界和外部世界的大门。门太少拥堵,门太多漏风。
老资历的函数式语言
严格意义上的函数式语言是怎么写的,我看了一下这些语言的官网,一般都会有一段最有代表性的代码。比如, Haskell:
primes = filterPrime [2..]
where filterPrime (p:xs) =
p : filterPrime [x | x <- xs, x `mod` p /= 0]
读出来就是:“【质数(集合)】等于 【对 2 到正无穷的整数列表 进行 filterPrime
操作】”。至于 filterPrime
操作怎么读,就比较拗口了:“【filterPrime
操作】 等于 【列表的开头 p】加上 【对列表的 tail 中不能被 p 整除的数做 filterPrime
操作得到的结果】”。
可以赞叹这种写法具有数学定义一般的优美准确,也可以对这种因为数据结构不可变而采取的用递归替代循环,看似不得以而为之方式表示鄙夷。
一般在这些语言里,类型定义也是可以递归的,例如 Ocaml:
type tree = Leaf of int | Node of tree * tree
这里的 tree * tree
是 {tree, tree}
元祖类型的意思。读出来就是: “什么是树?要么是一个叶子,要么是一个节点!什么是叶子?整数!什么是节点?树和树组成的元祖!”
乍看上去好像循环定义了,并没有。任意一个有限的数据,都可以通过这个类型定义来判断其是否是一个 tree。因为一直推到到叶子,就可以通过基础的类型 int
来判断了。 而不符合这一特性,即“在实际使用时不能最终推导到基本的类型” 的类型定义,即是非法的定义。例如: type a = a
.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。