What Even Is Functional Programming? (And Why It’s Not Just for Math Nerds)
你有没有试过给Roblox游戏写个角色移动的脚本?本来你只是想加个简单的左右移动,结果你改了某个变量的值,角色突然开始往天花板飞,或者你加了二段跳功能之后,所有怪物的血量都变成0了?别慌,这不是你写代码的能力差——这是命令式编程(就是你之前可能一直在用的写法)的锅,而函数式编程(FP)就是来救你于这种“改一行代码,全游戏崩盘”的水火的。
就拿我教过的14岁学生小杰的例子来说吧,他去年写了个Roblox的跑酷游戏,本来一切正常,直到他想加个“吃金币加钱”的功能。他写了个全局变量`player_coins = 0`,然后在游戏每帧刷新的函数里加了逻辑:如果玩家碰到金币,`player_coins += 10`。结果测试的时候他发现,金币吃了,钱没加,找了两个小时,才发现他把金币的碰撞标签写错了,碰到的不是金币,是地图的边界。更坑的是,他后来改碰撞标签的时候,不小心把`player_coins = 0`这行代码从游戏初始化的地方移到了每帧刷新的函数里——也就是说,每帧(1/30秒)都会把金币数重置成0,所以他就算碰对了金币,下一帧马上就被重置了。他盯着代码看了整整一下午,愣是没找到问题,因为那行重置的代码和加金币的代码隔了十几行,他根本没想到会有人(哦不,是会有自己)把初始化代码写在刷新函数里。
这种“变量被偷偷修改、改一个地方全崩”的问题,90%的初学者bug都是这么来的。而FP的核心思想,就是彻底解决这种问题:它不让你随便改变量,不让你写有隐藏小动作的代码,每一段代码都像你数学课上学的函数一样,输入是什么,输出是什么,明明白白,没有任何猫腻。
FP到底是什么?先忘掉你课本里的定义
你课本里可能会说FP是“以数学函数为基础构建程序的范式”,然后甩给你一堆范畴论、柯里化、单子的术语,听着就头大,好像只有数学竞赛的人才能学。别理那些,我们先用人话讲:FP就是一种写代码的规则,规则只有两条核心: 1. 尽量写纯函数:给定相同的输入,永远返回相同的输出,而且除了返回结果之外,不做任何别的事(比如不偷偷改别的变量、不打印日志、不修改玩家血条、不发网络请求,这些偷偷做的事统称
这些偷偷做的事统称副作用(Side Effects),FP的核心规则第二条:尽量把所有副作用都赶到代码的边缘,不要让它混在核心逻辑里。别被“副作用”这个听起来像医学名词的词吓到,说白了就是“任何和函数返回值无关的操作”:改全局变量、打印日志、改数据库、发网络请求、甚至修改游戏里的玩家血条、给用户发弹窗,全都是副作用。
你可能会问:“那程序不就是要和外界交互吗?我写个游戏总不能连血条都不让改吧?” 当然不是,FP从来不是禁止副作用,而是不让副作用偷偷摸摸藏在核心逻辑里。你可以改血条,但你要专门写个函数做这件事,明明白白告诉所有人“这个函数就是改血条的,别的啥也不干”,而不是把它藏在计算伤害的函数里,让人防不胜防。
纯函数的“三大铁律”,帮你避开90%的初学者bug
要判断你写的函数是不是纯函数,只要看它有没有违反下面三条铁律,违反一条都不行: 1. 相同输入永远返回相同输出:你给它传10点基础伤害、有暴击,它永远返回15点伤害,不会有时候返回15,有时候返回10,更不会有时候直接崩。 2. 不能修改任何外部的状态:不能改全局变量、不能改传进来的参数、不能改文件、不能改数据库,除了返回结果之外,啥都不能动。 3. 不能有任何隐藏操作:不能偷偷发网络请求、不能偷偷打印日志、不能偷偷给玩家加buff,所有操作都必须和返回值有关。
你可以把纯函数想象成游戏里的自动售货机:你投进去10块钱(输入),它永远给你出一瓶可乐(输出),不会偷偷拿你的钱去买零食,也不会有时候出可乐有时候出雪碧,更不会突然跳出来给你塞个广告。所有操作都明明白白,没有任何猫腻。
而你现在可能经常写的命令式代码,就像你让一个熊孩子帮你买可乐:“去帮哥哥买瓶可乐,剩下的钱你自己拿去买糖”(输入是10块钱,输出理论上是一瓶可乐+找零,但实际上熊孩子可能把钱全拿去买糖,给你带回来一瓶过期可乐,甚至根本不回来)。你永远不知道他会偷偷做什么,这就是命令式代码的bug来源。
真实案例:小杰的跑酷游戏改bug实录
还记得开头提的14岁学生小杰吗?他后来听了我的建议,把跑酷游戏的核心逻辑全改成了纯函数,我们看看改之前和改之后的区别:
- **改之前的命令式代码**:他写了个`check_collision()`函数,不仅判断玩家有没有碰到障碍物,还偷偷改了玩家的`x`坐标、扣了血量、甚至调了背景音乐,结果后来他加个“碰到弹簧弹起来”的功能,改了`check_collision()`里的逻辑,结果玩家碰到金币的时候也会弹起来,找了两个小时才发现,是碰撞判断的逻辑里偷偷加了弹簧的触发代码,他根本没注意到。
- **改之后的FP代码**:他把碰撞判断写成了纯函数`check_collision(player_pos: tuple, obstacle_pos: tuple) -> bool`,只返回“有没有碰到”,别的啥也不干。然后单独写个`handle_collision(state: dict) -> dict`函数,专门处理碰到障碍物之后的状态更新(扣血、弹开、改音乐),所有逻辑都明明白白,后来他加弹簧功能,只需要改`handle_collision`里碰到弹簧的逻辑,根本不会影响碰撞判断,10分钟就加完了,比之前改碰撞逻辑快了3小时。他后来跟我说:“这比开创造模式还爽,改代码再也不用怕崩了。”
什么是引用透明?FP的“代码可替换”超能力
FP还有个特别爽的特性,叫引用透明(Referential Transparency),这个术语听起来很吓人,其实道理特别简单:任何一个纯函数的调用,你都可以直接用它的返回值替换,程序的行为完全不会变。
比如你写了个`calculate_damage(7, True)`,返回值是10,那你在代码里任何地方看到`calculate_damage(7, True)`,都可以直接换成10,程序一点问题都没有。就像你数学课上做应用题,把x=2代入公式$f(x) = x + 3$,得到$f(2) = 5$,你在整个题目里看到$f(2)$都可以直接换成5,结果不会变。
但如果是命令式的代码就不行了,比如你写了个`player_hp -= 10`,它的返回值是`None`,而且偷偷改了全局的`player_hp`,你要是把它换成10,那全局的`player_hp`就不会被扣了,程序直接崩。就像你让朋友帮你递个水,他不仅递了水,还偷偷把你的钱包拿走了,你不能直接把他替换成一瓶水,不然你的钱包就没了。
引用透明这个特性有多爽?比如你写了个游戏,要算1000个玩家对1000个怪物的伤害,FP的版本你直接写个循环,调用100万次伤害函数就行,因为每个函数调用都是独立的,不会互相影响,速度特别快。而命令式的版本,你每次调用伤害函数都可能改全局的玩家状态、怪物状态,100万次调用下来,你得花半天调试哪个地方的状态被改错了。
我们的两个主角:Haskell vs Clojure,哪个更适合你?
这本书要带你学的两门语言,都是纯FP语言,但是风格完全不一样,就像游戏里的两个不同职业,你可以选自己喜欢的玩。
Haskell:FP界的严格学霸,编译器是你最严的教练
Haskell是1990年出来的纯FP语言,它的核心设计就是“强制你写纯函数”,你敢写一点有副作用的代码,编译器直接给你报错,根本不给你运行的机会。就像你玩《我的世界》的硬核模式,死了就删档,规则严到离谱,但是等你习惯了,你会发现你写的代码几乎不会出bug。
Haskell还有个特别厉害的地方:强静态类型系统。你写变量的时候必须告诉编译器这个变量是数字还是字符串还是列表,编译器会在你运行代码之前就把所有类型错误找出来,比如你把字符串传给了一个要数字的函数,编译器直接报错,不会等你运行的时候才发现崩了。比如你写伤害计算的函数,必须明确告诉编译器“这个函数的输入是数字和布尔值,输出是数字”,你要是传了个字符串进去,编译器直接骂你:“你居然想给字符串乘以1.5?做梦!”
比如我们用Haskell写个伤害计算的纯函数,代码特别简洁: ```haskell -- 第一行是类型签名,告诉编译器这个函数的输入输出是什么 -- Int是整数,Bool是布尔值,->左边是输入,右边是输出 calculateDamage :: Int -> Bool -> Bool -> Float calculateDamage baseDamage hasVulnerability isCrit = let total = baseDamage + (if hasVulnerability