欢迎来到函数式魔法世界
现在请试着写下你做三明治的「魔法咒语」步骤吧!写完可以对照下面的魔法小裁判来看看你的咒语符不符合函数式的规则哦~
魔法小裁判:三明治咒语大评比
我们先看看魔法学院里三位小朋友写的咒语,找一找哪个最符合函数式的规则:
- 小明的咒语:①拿2片生面包放在干净的盘子上 ②用干净的小勺取1勺花生酱,均匀抹在2片面包的一面 ③取1片新鲜的火腿放在其中1片抹了酱的面包上 ④盖上另一片抹了酱的面包 ⑤用儿童安全刀把三明治切成2个三角形 ⑥输出:2个三明治半块,盘子里的所有食材都没有被额外修改
- 小红的咒语:①拿2片面包 ②如果我现在特别开心就多抹1层果酱,不开心就只抹花生酱 ③如果冰箱里有火腿就放火腿,没有就放芝士 ④盖上另一片面包 ⑤如果妈妈喊我吃饭就把做了一半的三明治扔到垃圾桶 ⑥输出:可能有完整三明治,可能没有
- 小刚的咒语:①拿2片面包,把其中1片的状态改成「烤过的」 ②把2片面包的状态都改成「抹了花生酱的」 ③把一片火腿的状态改成「放在面包上的」 ④把整个三明治的状态改成「完成的」 ⑤输出:1个三明治,原来的面包、火腿都已经被改得面目全非,没法再做第二个
你看,小明的咒语就是最标准的函数式做法:所有的输入都是明确的,没有隐藏的条件,也没有偷偷修改输入的东西,每次用同样的食材做,出来的三明治都是一模一样的。而小红的咒语有隐藏的输入(心情、冰箱里有没有火腿、妈妈喊不喊吃饭),输出不确定;小刚的咒语直接修改了输入食材的「状态」,原来的材料没法重复用,这就是典型的命令式做法。
---
1.1 为什么函数式编程像搭积木一样好玩?
小朋友平时搭积木、玩手工、买零食的时候,其实早就接触过函数式的思路啦!我们先回忆几个熟悉的场景,就能明白函数式编程到底是什么意思。
场景1:搭积木的两种方法
假设你要搭一个红色的城堡,有两种搭法:
- **命令式搭法(像玩橡皮泥)**:你先拿一个白色积木块当底座,直接把它涂成红色,然后往上叠黄色积木,再把黄色积木涂成绿色做城墙。你想再搭一个一模一样的红色城堡时,发现你之前涂成红色的底座已经用过了,你完全忘了自己涂过这个积木,结果第二个城堡的底座是白色的,和第一个不一样,你困惑极了:为什么我步骤一模一样,搭出来的东西却不一样?
- **函数式搭法(像用乐高)**:你有一个「涂色魔法咒语」,输入是「任意颜色的积木块+红色颜料」,输出是「新的红色积木块」,原来的积木块颜色完全不会变。你每次要红色积木块,就念一遍这个咒语,每次都能得到全新的红色积木块,所以每次搭出来的城堡都一模一样,永远不会乱。
你看,函数式的核心就是不让原来的东西偷偷变来变去——你拿到一块白色积木,它永远是白色的,除非你明确用「涂色魔法」生成一个新的红色积木,原来的白色积木还会留在桌子上,想用多少次都可以。这就是函数式编程第一个核心规则:不可变数据。
场景2:买零食的两种售货机
你去学校门口的自动售货机买薯片,也有两种售货机:
- **命令式售货机(像人工小卖部)**:你给老板5块钱说买一包薯片,老板今天心情好就多给你一包辣条,心情不好就只给你半包薯片,要是老板今天没睡醒,可能还会给你拿成饼干。你每次投5块钱,拿到的零食可能都不一样,完全没法预测。
- **函数式售货机(像标准自动售货机)**:你投5块钱,按下薯片的按钮,每次出来的都是重量、口味、包装完全一样的薯片,不会因为老板的心情、今天星期几而变化。你每次投同样的钱按同样的按钮,拿到的永远是一样的薯片,完全可预测。
这个自动售货机就是函数式编程的第二个核心规则:纯函数——就像数学里的 $f(x) = x + 2$,你输入 $x=3$,永远得到 $5$,不会有时候是5有时候是6,没有任何隐藏的变量偷偷影响结果。
场景3:包装礼物的两种方法
你要给朋友包装生日礼物,也有两种方法:
- **命令式包装法**:你先拿一张包装纸,把书包起来,然后剪一段胶带粘好,再贴一个贴纸。如果你要包装10个礼物,你得重复做10次「拿纸、包、剪胶带、贴贴纸」的步骤,特别麻烦。
- **函数式包装法**:你有一个「万能包装魔法」,不管你拿的是书、玩具还是零食,只要把礼物和包装纸交给这个魔法,它就会帮你包好。你甚至还可以做一个「贴贴纸魔法」,不管是什么包装好的礼物,只要交给这个魔法,它就会给你贴一个你喜欢的贴纸。你包装10个礼物,只要说10次「用万能包装魔法包装礼物,再用贴贴纸魔法贴贴纸」就行,不用重复做每一步。
这个「万能包装魔法」和「贴贴纸魔法」就是函数式编程的第三个核心规则:高阶函数——它可以接收别的函数或者动作作为输入,也可以输出新的函数,就像你可以把任何步骤打包成一个魔法,随便复用。
---
1.2 函数式编程的三个核心魔法道具
现在我们把刚才场景里提到的三个规则,变成可以随身携带的「魔法道具」,不管什么时候用都不会错。
道具1:纯函数咒语——输入一样,输出永远一样
我们先给纯函数下个小朋友能懂的定義:纯函数就是一个没有任何小动作的魔法咒语,只要你给它的输入一模一样,它给你的输出永远一模一样,而且绝对不会偷偷修改别的东西,也不会做咒语要求之外的事情。
我们拿大家最熟悉的乘法来举例:$3 \times 4 = 12$,这个乘法就是纯函数,只要你输入3和4,永远得到12,不会有时候是11有时候是13,也不会偷偷把你桌子上的橡皮擦吃掉,更不会把你作业本上的数字改了,它只会返回计算结果。
那什么是「不纯的函数」呢?我们看几个例子:
- 非纯函数1:「咬一口苹果魔法」:输入是一个完整的苹果,输出是带咬痕的苹果,但是这个魔法偷偷修改了输入的那个苹果,原来的完整苹果没了,下次再咬这个苹果就不一样了,这就是有「副作用」的魔法。
- 非纯函数2:「根据心情变颜色的魔法」:输入是一个白色积木块,如果你开心就输出红色积木,不开心就输出蓝色积木,输出不固定,因为有个隐藏的输入「你的心情」,你没法预测输出是什么。
- 非纯函数3:「读妈妈手机消息魔法」:输入是什么都没,输出是妈妈手机里的消息,但是这个魔法偷偷读了妈妈的手机,属于做了咒语要求之外的事情,有副作用。
纯函数的好处是什么?就像你做数学题,只要你公式用对了,数字没抄错,答案永远是对的,不会出现「昨天这道题我做对了,今天怎么错了」的情况,因为纯函数没有任何隐藏的小动作,完全可预测。后面我们学Haskell和Clojure的时候会发现,这两种语言里的函数默认都是纯函数,编译器会帮你检查有没有偷偷写小动作,非常省心。
> 🧙♂️ 电子版侧边栏:纯函数小挑战 > 试试判断下面哪个是纯函数: > A. 输入一个橙子,输出橙子的重量 > B. 输入一个橙子,剥了皮输出橙子肉 > C. 输入一个数字,如果现在是下午就输出它的平方,是上午就输出它的立方 > 答案在 chapter1_practice.md 里哦~(Markdown格式便于程序提取)
道具2:不可变积木——拿到是什么样,永远是什么样
不可变数据就是函数式编程里的「乐高积木」:你拿到一块红色的2×2积木,它永远是红色的2×2,不会突然自己变成蓝色的1×1,也不会你刚搭完城堡就自己散架。如果你想要一块蓝色的1×1积木,你不能把红色的2×2积木强行改成蓝色的1×1,只能用「改造魔法」生成一块新的蓝色1×1积木,原来的红色2×2积木还会好好待在盒子里。
我们还是用做三明治的例子来对比命令式的「可变数据」和函数式的「不可变数据」:
- 命令式的做法:你有一块生面包,你把它放到烤面包机里烤,这时候原来的生面包就变成了烤面包,如果你忘了自己烤过这块面包,下次拿的时候它就是烤的,没法再做生三明治了。你有个变量`bread = 生`,然后执行`bread = 烤(bread)`,变量`bread`就从「生」变成了「烤」,原来的「生」状态已经没了。
- 函数式的做法:你有一块生面包,你用「烤面包魔法」,输入是生面包,输出是烤面包,原来的生面包还是生的,放在盘子里,你可以用这个生面包做生三明治,也可以用「烤面包魔法」生成新的烤面包做烤三明治,永远不会乱。这里的`bread`永远是「生」的状态,烤面包是一个新的值,不会修改原来的`bread`。
不可变数据最大的好处就是不会出现「数据被偷偷改了」的bug。比如你写了一个游戏,游戏里的角色有100点血,命令式编程里你可能不小心写了`hp = hp - 200`,角色血量就变成-100了,出bug了;函数式编程里你原来的100点血永远不会变,你生成一个新的角色血量是-100,原来的100点血的角色还在,你随时可以回到之前的状态,不会乱。
> 📖 纸书脚注1:不可变数据也是解决并发编程bug的终极武器哦!如果多个小朋友同时搭积木,命令式的积木可能被别的小朋友偷偷改了颜色,搭出来的城堡就歪了;函数式的积木没人能改,每个小朋友拿到的都是原来的样子,搭出来的城堡永远不会歪。这就是为什么现在很多高并发的服务器程序都用函数式语言写,特别稳定。
道具3:高阶魔法袋——可以装任何魔法的魔法
高阶函数是很多小朋友刚接触的时候觉得最难懂的概念,但其实你早就用过它了!高阶函数就是一个「能装别的魔法的魔法袋」,你可以把任何小的魔法咒语装到这个袋子里,高阶函数会对这些咒语做统一的操作,不用你重复写步骤。
我们拿买零食的例子来理解:你去超市买零食,老板有个「打包魔法」,不管你买的是薯片、巧克力还是饼干,只要把你要买的东西交给这个魔法,它就会帮你装到袋子里,还会帮你算总价。这个「打包魔法」就是高阶函数:它接收的是「买薯片的动作」「买巧克力的动作」这些不同的动作(在编程里动作就是函数),输出的是打包好的零食,你不用自己分别学怎么打包薯片、怎么打包巧克力,只要用这个打包魔法就行。
再举个更具体的例子:假设你有三个小魔法咒语: 1. 「变红魔法」:把任何东西变成红色 2. 「变大魔法」:把任何东西放大2倍 3. 「贴星星魔法」:给任何东西贴一个星星
现在你有一个高阶魔法叫「组合魔法」,它可以接收两个小魔法,输出一个把这两个小魔法按顺序用一次的新魔法。比如你把「变红魔法」和「变大魔法」交给「组合魔法」,就会得到一个新的「先变红再变大魔法」,你只要念这个新魔法的咒语,就能得到先变红再变大的东西。你还可以把「先变红再变大魔法」和「贴星星魔法」再交给「组合魔法」,得到更厉害的新魔法。
你看,高阶函数就像你玩的「技能组合系统」,你可以把小的技能随便组合成新的技能,不用每次都从头写一遍,特别方便。后面我们学Haskell和Clojure的时候会发现,高阶函数是函数式编程里最强大的工具,很多复杂的逻辑用几行高阶函数就能写出来,不用写一大堆重复的代码。
---
1.
1.3 函数式编程 vs 命令式编程:魔法规则大对决
我们已经知道了函数式编程的三个核心魔法道具,现在来玩一场「魔法规则大对决」,看看函数式魔法和传统命令式魔法到底有什么不一样。我们还是用小朋友最爱的做果汁场景来对比: 你要做一杯橙汁,命令式魔法师会这么做:①拿一个完整的橙子,把它放到榨汁机里,榨完把橙子皮扔进垃圾桶(偷偷修改了原来的橙子,原来的完整橙子没了)②如果榨汁机是上次用完之后没洗的,榨出来的果汁会有一股橘子皮的味道(依赖隐藏状态:榨汁机有没有洗过)③如果你要榨10杯果汁,每次用完榨汁机都要记得洗,要是上次忘了洗,这杯果汁就难喝到没法喝(结果不可预测,上次的行为影响了这次的结果)。 而函数式魔法师会这么做:①有一个「榨汁魔法咒语」,输入是「完整的橙子+干净的榨汁机」,输出是「一杯橙汁+完整的橙子+干净的榨汁机」(所有输入都不被修改,原来的橙子还是完整的,榨汁机还是干净的)②每次用同样的橙子和同样的榨汁机,榨出来的果汁味道、分量、颜色完全一模一样(结果可预测,没有任何隐藏变量影响)③你要10杯果汁,只要念10次同样的咒语,每次都能得到一模一样的果汁,完全不会出问题。 我们把这两种魔法的流程画成对比图,就能更清楚地看到区别: ```mermaid graph TD subgraph 命令式魔法流程(会变形的橡皮泥) A[拿一块白色积木] --> B[把积木涂成红色<br/>(直接修改了原来的积木)] B --> C[用涂好的积木搭城堡] C --> D[要搭第二个城堡?] D --> E[原来的红色积木已经用过了<br/>只能拿新的白色积木重涂] E --> F[结果可能和第一个城堡不一样<br/>因为涂的颜色深浅不一样] end subgraph 函数式魔法流程(不会变形的乐高) F1[拿一块白色积木] --> G[用「涂色魔法」生成新的红色积木<br/>(原来的白色积木完全不变)] G --> H[用新的红色积木搭城堡] H --> I[要搭第二个城堡?] I --> J[再用一次「涂色魔法」生成新的红色积木] J --> K[结果和第一个城堡一模一样<br/>因为魔法规则是固定的] end ``` 你可以看到,命令式魔法的核心是「修改状态」:你拿到一个东西,直接把它改成你想要的样子,原来的东西就没了;而函数式魔法的核心是「生成新东西」:你拿到一个东西,用魔法生成你想要的新东西,原来的东西永远不变,想用多少次都可以。 <!-- 标签: 命令式编程 函数式编程 对比分析 --> > 📖 纸书脚注2:其实命令式编程并不是「不好的魔法」,只是它更适合给计算机下非常细的指令,比如控制机器人走路、控制屏幕显示像素,这些场景需要直接修改硬件状态。函数式编程更适合处理复杂的逻辑,比如做游戏规则、做数学计算、做数据分析,这些场景需要结果可预测、不容易出bug。两种魔法没有高低之分,只是适用的场景不一样哦。
---
1.4 函数式编程的正式魔法定义
前面我们用搭积木、买零食、做果汁的例子类比了函数式编程,现在我们来给函数式编程一个准确的、数学家也能认可的正式定义,这样你以后看别的函数式编程的书,也不会 confused 啦。 函数式编程的起源可以追溯到1936年,一位叫阿隆佐·邱奇的数学家爷爷发明了一种叫「λ演算(lambda演算)」的数学体系,这个体系里所有的计算都是通过「函数应用」来完成的,没有变量修改,没有隐藏状态,完全符合我们之前说的纯函数、不可变数据的规则。我们现在用的Haskell、Clojure这些函数式编程语言,都是基于λ演算的规则设计的。
1.4.1 函数式编程的学术定义
函数式编程(Functional Programming,简称FP)是一种声明式编程范式,它将计算过程视为数学函数的求值,避免使用程序状态以及易变的变量。它的核心规则可以总结为三点: 1. 函数是「一等公民」:你可以把函数当成普通的数据来用,比如把函数存在变量里、把函数当成参数传给别的函数、把函数当成返回值返回,这就是我们之前说的高阶函数的基础。 2. 数据是不可变的:一旦你创建了一个数据,就不能再修改它,如果你需要修改后的数据,就创建一个新的数据,原来的数据永远保持不变。 3. 没有副作用:函数的执行不会修改函数外部的状态,比如不会修改全局变量、不会读文件、不会改屏幕上的内容,只会根据输入返回输出,就像数学里的函数一样。
1.4.2 核心概念的数学表达
我们之前说的纯函数、不可变数据、高阶函数,都可以用数学公式准确表达,小朋友不用怕,我们一句一句解释: 首先是纯函数的数学定义: $$f: A \rightarrow B, \quad \forall a_1, a_2 \in A, \quad a_1 = a_2 \implies f(a_1) = f(a_2)$$ 这句话翻译成小朋友能懂的话就是:有一个函数$f$,它的输入是$A$类型的东西,输出是$B$类型的东西。对于$A$里的任意两个输入$a_1$和$a_2$,如果$a_1$和$a_2$是一样的,那么$f$处理$a_1$的结果和处理$a_2$的结果永远是一样的。比如$f(x) = x + 2$,输入3永远得到5,输入4永远得到6,不会有例外。 然后是不可变数据的数学定义:对于任意数据$d$,不存在操作$op$,使得$op(d)$会修改$d$本身的值,所有对$d$的操作都会返回新的数据$d'$,而$d$保持不变。 最后是高阶函数的数学定义: $$h: (A \rightarrow B) \rightarrow C$$ 这句话的意思是:高阶函数$h$的输入是一个「从A到B的函数」,输出是C类型的结果。比如我们之前说的「组合魔法」,输入是两个小魔法(两个函数),输出是把这两个小魔法组合起来的新魔法(新的函数),这就是高阶函数。 <!-- 标签: 学术定义 数学表达 lambda演算 --> > 📱 电子版侧边栏:数学家小故事 > 阿隆佐·丘奇发明λ演算的时候,本来是想解决数学里的「可判定性问题」,结果没想到这个理论后来成了函数式编程的基础。他有个学生叫阿兰·图灵,就是发明图灵机的那个人,师生俩一起奠定了现代计算机的理论基础哦~点击这里可以看丘奇爷爷的简介[链接]。
---
1.5 三明治咒语练习进阶
之前我们看了小明、小红、小刚的三明治咒语,现在你可以自己写一个属于你的「做三明治」函数式咒语啦!写完之后可以对照下面的规则检查,看看符不符合函数式的要求:
1.5.1 咒语写作规则
1. 所有输入必须明确:不能有「如果开心就多放酱」这种隐藏的输入,所有的条件都要写在输入里。 2. 不能修改输入的东西:比如你不能写「把面包烤了」,因为这会修改原来的面包,要写「用烤面包魔法生成新的烤面包」。 3. 同样的输入永远得到同样的输出:你用同样的食材、同样的步骤,做出来的三明治永远一模一样。
1.5.2 咒语示例与代码对应
我们之前小明写的咒语,对应到Haskell和Clojure的代码里,就是这样的: 首先是Haskell版本的做三明治函数: ```haskell -- 定义食材类型,就像给魔法材料分类 data Ingredient = Bread | RoastedBread | PeanutButter | Ham | Cheese deriving (Show, Eq) -- 定义三明治类型 data Sandwich = Sandwich Ingredient Ingredient Ingredient deriving (Show, Eq)
-- 纯函数:烤面包魔法 -- 输入是生面包,输出是烤面包,不修改输入的面包 toastBread :: Ingredient -> Ingredient toastBread Bread = RoastedBread
-- 纯函数:抹酱魔法 -- 输入是面包和酱,输出是抹了酱的面包,不修改输入 applySauce :: Ingredient -> Ingre