你也许不应该在工作中用一门纯函数式编程语言(那样对大脑太不友好),但你应当学习这样一门语言,因为这将会改变你的思维。 即使你平常使用的是一些常见的语言,如 C++/Java(8+)/Python/JS,你还是能从这些语言中找到函数式编程的影子。 另外,函数式编程思想在整体架构的设计上也有很大的作用。
What’s the difference?
(像Haskell这样的)纯函数式编程语言和我们平常使用比较多的命令式编程语言有极大的不同。
- 没有变量,一切量都是不变的
- 因此也没有循环了
- 也没有传统意义上的逻辑判断
- 因此函数都是“纯函数”,因为没有变量,所以除非使用特殊结构,否则不可能有副作用
- 函数是”一等公民”,每个函数都是一个变量,有其类型,能作为其他函数的参数
看到这里你就意识到了,你需要把你在命令式编程语言界学到的大部分知识全部忘掉。
确实是这样,因为这完全是两种不同的思维体系。
你也许会以为没有变量是没有办法编程的,但这是一种常见的误解,你将在下面看到,变量不仅没那么重要,而且有时“可变”只会带来麻烦。
Why Haskell?
Haskell除了是一门纯函数式编程语言外,还有如下特点:
- 惰性求值,所有的值不被用到就不会被计算。
- 静态强类型,并有着我所见过的,在“能用的”语言中最好的类型系统。
Haskell 里的基本操作
基本数据类型
从表面上看,Haskell中的数据类型和C中的大致相同,整数字符浮点数应有尽有。
此外,Haskell中比较厉害的一个类型是列表类型,下面会说。
调用函数
Haskell调用函数的方式和C语言不太一样:
-- 假设有函数 f, g
f x -- 这就是计算f(x)了
g x y -- g(x,y)
Haskell省去了括号和逗号,这虽然让用惯了 C 系语言的人不太适应,但也部分避免了像 Lisp 那样一屏幕的括号的尴尬。
基本运算
+
、-
、*
、/
、&&
、||
、==
这些运算符的作用都和C语言一样。
特别的,取反要用 not
(实际上这是个函数,在Haskell中这些运算符本质都是函数),C 中的不等于符号 !=
,在这里是 /=
。
求余要用mod函数:
mod 3 2
如果你想要像 C 语言中的 % 那样中缀调用 mod
的话,你可以使用 ``` 将函数包裹:
3 `mod` 2
列表
首先要提一句,Haskell 中所谓的字符串还是字符列表的一个语法糖。
Haskell 的列表是一个非常强大的玩意,我们可以用和 Python 中制作列表相似的方法做一个 Haskell 列表:
-- 直接使用字面量
[1,2,3,4,5,6]
-- 使用区间
[1..5]
-- [1,2,3,4,5]
-- 一般的等差数列都能推出来
[1,3..42]
-- [1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41]
-- 使用列表推导式
[x*2 | x <- [1..10], x*2 >= 12]
-- 意为:对于在 [1..10] 之中的x,若有 x*2 >= 12,则将x*2放入列表
Haskell列表可以被拼接:
[1,2]++[3,4]++[5,6]
-- [1,2,3,4,5,6]
也可以拼一个元素上去:
1:[1,2,3]
-- [1,1,2,3]
-- 注意只能拼在前面
-- [1,2,3]:1 会报错的
可以各种取元素:
take 3 [1,2,3,4,5,6,7]
-- 取前三个,[1,2,3]
drop 3 [1,2,3,4,5,6,7]
-- 取前三个之外的元素,[4,5,6,7]
head [1,2,3,4,5,6,7]
-- 取第一个,1
tail [1,2,3,4,5,6,7]
-- 取除第一个之外的元素,[2,3,4,5,6,7]
init [1,2,3,4,5,6,7]
-- 取最后一个之外的元素,[1,2,3,4,5,6]
last [1,2,3,4,5,6,7]
-- 最后一个,7
1 `elem` [1,2,3]
elem 1 [1,2,3]
-- 判断元素是否在列表中,两句等价,都为True
[1,2,3,4] !! 2
-- 取列表的第二个元素,3
-- 用!!有点奇怪不是吗😓
由于Haskell的惰性求值特性,你可以构造一个无穷的列表:
[1..]
-- 从1开始,到无穷大为止
运用这个特点我们能做一些很酷的事情,比如求前10个7的倍数或末尾含7的数:
take 10 [x | x <- [1..], x `mod` 7 == 0 || x `mod` 10 == 7]
就只有一行代码,简单方便,可读性好。
你拿命令式语言写,估计得絮絮叨叨写一大坨了吧。
自己写函数
Haskell的函数语法非常直白,很像数学中的函数:
triple x = 3 * x
就定义好了一个函数triple,它的作用就是返回输入参数的三倍。
很像数学里的函数的写法:
$$ triple(x) = 3*x $$
多个参数:
length x y = sqrt (x *x + y* y)
也很像数学中的函数:
$$ length(x, y) = \sqrt{x * x + y * y} $$
如果函数要分类讨论,可以使用“模式匹配”等技巧,它们在函数式编程中替代了逻辑判断:
-- 当参数恰好匹配的时候会返回对应的值
sillyFunction 0 = 0
sillyFunction -1 = 2
sillyFunction 2 = 3
-- 都没有匹配到,会进入这个默认的匹配
sillyFunction x = x/2+1
就像是:
$$ sillyFunction(x) = \begin{equation} \begin{cases} 0 & x=0 \\ -1 & x=2 \\ 2 & x=3 \\ x/2+1 & otherwise \end{cases} \end{equation} $$
记得匹配的顺序是从上到下,因此如果参数为x的匹配放到第一个那么就会GG。
这个行为和很多现代 web 框架匹配 URL 的方式很像。
如果要匹配的是一个范围,那么应当使用“哨卫”语法:
f x
| x <= 10 = x
| x <= 20 = x/2
| x <= 30 = x*x
| otherwise = x*x*x
就像是:
$$ f(x) = \begin{equation} \begin{cases} x & x < 10 \\ x/2 & 10 \le x < 20 \\ x \times x & 20 \le x < 30 \\ x \times x \times x & otherwise \end{cases} \end{equation} $$
如果在列表上进行模式匹配,可以这样写:
listFunction [] = "Empty!"
listFunction (x:[]) = "Only One!"
listFunction (x:y:[]) = "There are two!"
-- 这里要提一下,show 函数返回输入值的字符串表示
listFunction (x:y:_) = "More than 2! First two are " ++ show x ++ " and " ++ show y
递归
想要理解递归,你要先理解递归。
有了上面那一堆东西,大部分命令式语言能实现的东西就能被实现了,但是我们还没讲到一样重要的东西:递归。
我们来考察“最大值”函数,它应该接受一个列表,返回列表中的最大值。
在命令式编程中,我们会使用一个循环来实现这一点。
然而我们没有循环了,该怎么办?
用递归啊。
我们可以这样想,如果一个列表里只有一个元素,那么这个元素就是最大的:
maxInList [x] = x
否则,就应该是这个列表第一个值和其余部分中的最大值中比较大的那个
maxInList (x:xs) = max x (maxInList xs)
于是就这么愉快地写好了,这个函数只有两行,如果使用命令式语言,恐怕很难用两行完成(当然你要是硬是不换行把代码都挤在一行上我也没话说)。
为了进一步展示函数式编程的美,我们来看看函数式的快速排序:
quicksort [] = []
quicksort (x:xs) = (quicksort [y | y <- xs, y < x]) ++ [x] ++ (quicksort [y | y <- xs, y >= x])
还是只有两行,爽!
进阶
curry 化函数与 Hindley-Milner 类型签名
我们前面说过Haskell中的函数可以带多个参数:
length x y = sqrt (x * x + y * y)
但是我实际上,我要说,所有的Haskell函数都只接受一个参数,返回一个值。
那上面那个玩意是怎么弄出来的呢?
我们先看看这个函数的类型(在GHCI中使用 :t length
):
length :: Floating a => a -> a -> a
WTF?这是啥神仙玩意?
实际上这是一个叫 Hindley-Milner 类型签名的东西,Haskell 主要使用这种东西来标记一个函数的类型。
这个东西这样读:
Length is a fuction which takes an argument of type “a” and returns a function which (
takes an argument of type “a” and returns a value of type “a”
) where “a” is a type of typeclass Floating
用中文:
Length函数接受一个”a”类型的值作为参数,返回一个(接受一个”a”类型值作为参数,返回一个”a”类型值的函数),其中”a”是 Floating 类型类下的类型。
如果你还是觉得有点晕的话,我们给上面的类型打上括号:
length :: Floating a => (a -> (a -> (a)))
可以理解为,这里一共有两个函数,一个是 `length`` 本身,它接受一个”a”类型参数,返回一个类型为
Floating a => a -> a
的函数。
这个新的函数接受一个”a”类型参数,返回一个”a”类型的值。
所以一个 Haskell 函数只接受一个参数,然后要么返回一个函数,负责“吃掉”剩下的参数,要么返回一个值,就是函数运行的结果。
那么这样有什么好处呢?
一个好处是容易创建偏函数:
length x y = sqrt (x *x + y* y)
f = length 2
f 3 -- 即length 2 3
f 4 -- 即length 2 4
很多语言中要依赖框架才能做出来的依赖注入功能,Haskell 里很多时候直接用偏函数就解决了。
typeclass
看到 typeclass
不要想到某些面向对象语言中的 class
,相比之下,typeclass
更像 interface
或 trait
,也就是表达“一种类型的能力”( interface
大概是这个意思)而非“一个对象的能力”( class
大概是这个意思)。
有以下一些常见的 typeclass
:
-
Eq 类型类
可判断相等性的类型,要求类型实现了 == 和 /= 两个函数。
-
Ord 类型类
可比较大小的类型,要求类型实现了 compare。
-
Show 类型类
可以转成字符串,也就可以被显示出来的类型,实现show。
-
Read 类型类
可以从字符转出来的类型,实现 read。
-
Enum 类型类
可以求其前驱和后继的类型,实现 pred 和 succ。
-
Bounded 类型类
有界的类型,实现 minBound 和 maxBound。
-
Num 类型类
表示数值的类型类,基本上就是 Int、Integer、Float、Double。
-
Floating 类型类
表示浮点数的类型类,基本上就是 Float 和 Double。
-
Integeral 类型类
表示整数的类型类,基本上就是 Int(会溢出的整数)和 Integer(大整数)。
这些就是一些基本的类型类。
当然你可以自己做一些类型,并让它们实现某个类型类。
不过在此还是留下例子:
data TrafficLight = Red | Yellow | Green
instance Eq TrafficLight where
Red == Red = True
Green == Green = True
Yellow == Yellow = True
_==_ = False
如果你学过 Rust, 这就和 impl Eq for TrafficLight
基本上是一个意思。
数学家的呓语
在 Haskell 中有几个 臭名昭著 特殊的 typeclass。
函子
设 $C$ 和 $D$ 为范畴,从 $C$ 至 $D$ 的函子为一映射F:
- 将每个对象 $X \in C$ 映射至一对象 $F(X) \in D$ 上,
- 将每个态射 $f:X\rightarrow Y \in C$ 映射至一态射 $F(f):F(X) \rightarrow F(Y) \in D$ 上,使之满足下列条件:
- 对任何对象 $X \in C$ ,恒有 ${\displaystyle F(\mathbf {id} _{X})=\mathbf {id} _{F(X)}}$ 。
- 对任何态射 $f: X \to Y; g: Y \to Z$,恒有 $F(g \circ f) = F(g) \circ F(f)$。换言之,函子会保持单位态射与态射的复合。
上面这坨都是啥神仙玩意。
数学家就喜欢把其实很简单事情搞得看上去超级复杂,美其名曰“严密”,以凸显其远超常人的智商,实际上我们都知道……好吧他们是真的很聪明TAT。
在实际应用中,函子的概念可以被简单地描述为:
函子(Functor)就是可以被 map-over(即通过 map 向对象中的子对象应用一个函数)的对象。
或者,一码胜千言:
class Functor f where
fmap :: (a -> b) -> f a -> f b
Functor
是一个类型类,它要求实现了它的类型实现 fmap
函数,它取一个 (a -> b)
和一个 f a
(即 f
类型里面的 a
类型)值作为参数,返回一个 f b
的值。
比如列表就是一个Functor:
map :: (a -> b) -> [a] -> [b]
instance Functor [] where
fmap = map
另外,Haskell 中的 Set 和 Maybe(可空值)也是Functor。
Maybe是个好东西,下面就用它讲解了:
instance Functor Maybe where
fmap func (Just x) = Just (func x) -- 有东西写作 Just xxx, map上去就是Just f(xxx)
fmap func Nothing = Nothing -- 没东西写作 Nothing, map上去还是Nothing
总的来说,Functor就是表现的像是容器或者上下文的一个类型,你可以通过fmap向容器中的元素应用一个操作。
Applicative
Applicative
是 Functor
的升级版本(也称 Applicative Functor),这是个啥呢?
我们已经知道了我们可以将一个函数 map
到一个Functor
上,但是如果我们要应用的函数6也在上下文中呢?
例如 Just (+3)
这种?
在已经能完成Functor所有功能的基础上,Applicative也会帮我们解开函数的上下文,然后应用:
class Functor f => Applicative f where -- Applicative一定是Fuctor
pure :: a -> f a -- 返回一个包裹在上下文中的值
(<*>) :: f (a -> b) -> f a -> f b -- 应用上下文中的函数
例如 Maybe
:
instance Applicative Maybe where
pure = Just
Nothing <*> _ = Nothing -- 应用 Nothing 得 Nothing
(Just func) <*> something = fmap func something
Monad (Ah! Finally!)
Monad有啥难的,不过是自函子范畴上的一个幺半群罢了。
这么说的都给我拖出去毙了。就你懂群论代数系统范畴论。😠
Monad
是 Applicative
的升级版本,它在 Applicative
的基础上,添加了一个“接受一个上下文中的值和一个(接受普通值返回上下文中的值的函数),返回一个上下文中的值”的功能。
(简化过的)Monad是这样的:
class Applicative m => Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
>>=
的人话名字叫 flatMap。
同样看Maybe:
class Applicative Maybe => Monad Maybe where
return :: a -> Maybe a
(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
用起来是这种感觉的:
half x = if even x
then Just (x `div` 2)
else Nothing
Prelude> Just 20 >>= half -- 把Just 20塞进half函数里
Just 10
Prelude> Just 20 >>= half >>= half -- 把Just 20的结果塞进half函数里
Just 5
Prelude> Just 20 >>= half >>= half >>= half
Nothing
链式调用,管道操作,酷毙了。
真实世界中的 Functor/Applicative/Monad 实例
Future/Promise
(尤其是写 JS 的)程序员们常用的 Promise/Future 其实是一个 Monad,flatMap
其实就是 then
。
Option/Maybe/Nullable
Scala, Rust 等语言中常见,同 Haskell 的 Maybe
。
Result
同 Option
, 不过是把 None
换成了具体的错误类型。
Array/Iterator/Stream
数组或者任何在空间/时间上构成一个序列的东西也是 Monad, 比如 Rust 的 Iterator
trait 就包含了 flat_map
方法。
为什么要进一步抽象出这些类型类
这样,一些函数就可以更好容易地被复用1,比如:
double x = 2 * x
Without concept of Functor
:
doubleArray [] = []
doubleArray (x:xs) = double x : doubleArray xs
doubleMaybe None = None
doubleMaybe Just x = double x
With concept of Functor
:
doubleArray = fmap double
doubleMaybe = fmap double
你甚至不再需要分别定义 doubleArray
和 doubleMaybe
,直接用 fmap double
就行。
从函数式编程到函数式架构
上面这些东西都很不错,但我觉得你很难在“搬砖”的时候用到他们。
搬砖的时候最多用一下 map、reduce、filter 和少量递归的想法,几乎没有可能会(显式地)用到 Applicative 和 Monad 本身,原因很简单:他们太难了,很多人理解不能。
但是这些都是微观的函数式编程,我认为函数式思想另外一个用途在于架构上,也就是“宏观的函数式编程”。
我们想一想我们在函数式编程里学到了什么。
- “可变的”、“副作用”是不好的
- 数据流>>函数>>函数>>函数 = 程序的结果
这些想法在架构中也能用到。
- “可变”、“副作用”会带来管理上的复杂性,每个函数或方法必须明确“调用前要满足的条件”和“调用后会导致的副作用”,而这很可能会导致“认知超载”,故应当限制可变的东西。
或者说,可以将系统设计为:
- 系统状态=f(系统状态,用户行为)
这样系统状态的改变的唯一原因就是“用户行为”8。
- 领域层(这是从 DDD 里借来的词)>> 渲染函数 =表现层
将他们结合起来,我们可以得到这样一个架构:
- 有一个“领域层”
- 领域层 = applyAction(领域层,用户行为)
- 表现层 = render(领域层)
那么这样一个架构像什么呢?
你可以说它像 MVVM:
- “领域层” —— VM层
- render函数——由MVVM框架负责提供。
- 用户行为——是指用户修改了VM层的数据
但我认为它更像是类似 Flux 的状态管理方案:
- “领域层”——Store
- render函数——自己提供
- Action——就是Action
相比 MVVM,Dispatcher 为 Flux 提供了一个应用 Action 的统一入口,引起 Store 变化的原因被放在 Dispatcher 里面统一管理了起来,显得更加清晰。
这些类型呈现出的共有数学结构提供给了我们用相同的方式将函数运用于其上的机会。