写给前端工程师看的函数式编程对话 - 3

701 阅读7分钟

前篇:juejin.cn/post/693919…

第三天:从 JS 函数到 Haskell 函数

方:黑眼圈这么重,你昨晚是不是开黑到很晚?

学生: 别取笑我了,今天讲什么呢?

方:今天我教你写函数,先聊聊函数参数吧。

学生:参数有什么好讲的,就是把数据传给函数吧。对了,根据你昨天讲的,函数也可以被当作参数传给另一个函数。

方:先看代码吧:

add = (a, b) => a + b
add(1,2) // 得到 3

add 可以求两数之和。要调用 add,只需要把 1 和 2 传给 add 即可得到 3

学生:嗯,接下来你肯定要整点花里胡哨的写法。

方:那是,继续看代码:

c_add = (a) => (b) => a + b

c_add 也可以求两数之和,要调用 c_add,你需要先传入 1,得到一个函数(记为 temp),然后把 2 传给 temp,得到 3:

temp = c_add(1)
temp(2) // 得到 3

你也可以连续使用两对括号:

c_add(1)(2) // 得到 3

学生:呃……说实话,我觉得这种写法又难看,又多余。我只在面试题里见过类似的考题,工作中从来不用。

方:确实没有什么用,不过这种写法并没有 bug 对吗?

学生:bug 倒是没有

方:我想告诉你的是,这两种参数写法是等价的:

add = (a, b) => a + b
c_add = (a) => (b) => a+b

学生:等价的?

方:嗯,我说的「等价」是指,两个函数,只要得到相同的输入,就一定得到相同的输出。

学生:哦,你的意思是把函数当成黑盒是吧?

方:对,那现在你看 add 和 c_add,除了传参的「形式」不同,是不是等价的?

add(1,2) // 3
c_add(1)(2) // 3

add(18, 24) // 42
c_add(18)(24) // 42

学生:按你的说法,确实等价。毕竟他们的核心代码都是 a + b 而已。

方:好,接下来我们举更复杂的例子:

multi = (a, b, c) => a * b * c
c_multi = a => b => c => a * b * c

这两个函数,等价吗?

学生:也等价

方:继续,更复杂的例子,我将参数个数拓展到 n 个:

anyFn = (p1, p2, ..., pn) => doSomethingWith(p1, p2, ..., pn)
c_anyFn = p1 => p2 => ... => pn => doSomethingWith(p1, p2, ..., pn)

任何一个「接受 n 个参数的函数」,是否与「分 n 次接受 1 个参数的函数」等价呢?

学生:让我想想哈……按你的说法,看起来是等价的

方:所以,我们能不能说 「多参数函数」一定能改写为「单参数函数」,且其输入输出保持不变

学生:好像可以……但我从没想过这件事,这样改写有什么意义吗?

方:我们后面才会用到这个定理,我称之为「单参定理」,先记下来。

学生:好,我先记在小本本上。

方:其实,你在做前端开发时,用过单参函数。

学生:哦?我怎么不记得?

方:你用过前端模板吧?比如 Handlebars.js:

template = Handlebars.compile("<div>{{user.name}}</div>"); // 传第一个参数

html1 = template({user:{name:'frank'}}) // 传第二个参数
// html1 为 <div>frank</div>
html2 = template({user:{name:'jack'}}) // 传第二个参数
// html2 为 <div>jack</div>

学生:嗯,用过,看来这个 compile 就是一个单参数函数。

方:没错,其用法是 compile(string)(data)。一般来说,一个函数只会选择一种传参形式,要么是 (string, data),要么是 (string)(data)。不过如果想要同时支持两种形式,也是可以做到的。

学生:那单参函数有什么优点吗?

方:你如果在网上搜,会看到有人说优点是延迟计算啊、代码复用啊之类的,其实吧,多参数同样可以做到这些。

学生:那就是没什么优点咯?

方:后面用到函数组合的时候就能看出优点,目前讲了你也听不懂。

学生:好吧,那我还是先记在本本上吧,没有优点的东西我大脑记不住

方:我们继续。如果一个函数是多参数形式,比如 add,但是我们需要单参数形式,比如 c_add,该怎么办?

学生:虽然我从来都没有这种需求,不过我听过可以通过柯里化(currify)做到?

方:你懂得挺多,将 add 柯里化确实可以得到 c_add,但是我现在不打算讲柯里化,为了简单起见,我们直接改代码就行了,声明函数的时候手动写成 xxx = a => b => c => ... 的形式即可。

学生:方啊,我总是猜不透你……我还等着你讲柯里化呢

方: 柯里化以后讲啦。好了,今天的第一个内容讲完了,那就是「单参定理」,请你把内容复述一遍。

学生:(拿起本本照着读)「多参数函数」一定能改写为「单参数函数」,且其输入输出保持不变

方:不错

学生:那,第二个内容是?

方:接下来讲函数的类型。直接上 TypeScript 代码:

type Add = (a: number, b: number) => number;
const add: Add = (a, b) => a + b;
// 改成单参
type Add = (a: number) => (b: number) => number;
const add: Add = a => b => a + b;

鉴于浏览器不能直接运行 TS,你可以把代码复制到 CodeSandbox.io 上运行

学生:看起来挺简单,就是给函数、参数和返回值加上类型

方:没错,接下来来个复杂点的,这是一个 CPS 形式的 add,我们来给它加上类型:

const add = a => b => fn => fn(a+b)
add(1)(2)( (result)=> result*2 ) // 得到 6

学生:这个 add 接受三个参数 a、b、fn,然后把 a + b 传给 fn,并返回 fn 的返回值,是吧?

方:嗯,要给 add 添加类型,我们可以先声明一个 Add

type Add = ...
const add: Add = ...

学生:嗯,目前很简单

方:然后添加参数 a、b 的类型

type Add = (a:number) => (b:number) => ...
const add: Add = (a) => (b) => ...

学生:还要加 fn 吧?

方:fn 的类型是个函数: (c:number) => number,加进代码里就是这样:

type Add = (a:number) => (b:number) => (fn: (c:number) => number ) =>
const add: Add = (a) => (b) => (fn) => ...

学生:复杂起来了呢

方:由于 add 的返回值,就是 fn(a+b) 的返回值,所以我们最后可以加上返回值的类型:

type Add = (a:number) => (b:number) => (fn: (c:number) => number) => number
const add: Add = (a) => (b) => (fn) => fn(a+b)

学生:看起来好复杂,加类型有什么意义吗?不写类型代码也能运行不是吗?

方:类型系统是程序员的好帮手,只是需要一点时间来适应而已。现在,我们的第二个知识也讲完了:可以给函数加类型声明。

学生:第三个知识点呢?

方:接下来会讲一点点 Haskell 的语法,因为 JS 在后面的文章中会显得不够用,所以我必须逐渐教你一点 Haskell 了。

学生:新语言啊,来吧。

方:这是一个 Haskell 函数,你应该能看懂:

doubleMe :: Int -> Int 
doubleMe x = x * 2

第一句话表示 doubleMe 的参数是 Int 类型,返回值也是 Int 类型; 第二句话表示 doubleMe x 等价于 x * 2,即,若输入 x ,则输出 x 乘以 2。

学生:看是能看懂,那怎么调用 doubleMe 呢?

方:Haskell 中的函数,与其说是「调用」,不如说是「代入」。比如你想调用 doubleMe(100),只需要把 100 代入到 doubleMe x = x * 2 等式左边的 x,就能得到等式右边的 100 * 2,也就是 200

学生:倒是挺直白啊。Haskell 中的函数调用不需要括号吗?

方:不需要。括号一般用来表示优先级。比如

  putStrLn (show (doubleMe 100))

就是先执行 doubleMe 100,得到 200;然后执行 show 200,得到字符串 "200";最后执行 putStrLn "200" 打印出字符串

学生:我用 JS 表示就是 putStrLn(show(doubleMe(100))) 咯?

方:嗯

学生:这样写不觉得傻吗?一层括号套一层括号,Haskell 里直接写 putStrLn show doubleMe 100 行吗?

方:不行,因为这样写的意思是把 showdoubleMe100 作为参数传给 putStrLn

学生:是哦,那就必须写这么多括号了吗?

方:Hashell 给出了一个 $ 符号,方便我们把上面的代码写成这样

putStrLn  (show  (doubleMe 100))
写为
putStrLn $ show $ doubleMe 100

学生:这么写还行。但是看代码得从右往左看,好麻烦啊

方:就这还嫌麻烦啊,那你可以从上往下写,多加几个中间变量即可

let n = doubleMe 100
let string = show n
putStrLn string

这种从上到下的写法是专门给脑容量较小的新手准备的,因为易于理解

学生:这说得不就是我么……

方:嗯,就是你。对了,如果你学过面向对象,可以写成 n.doubleMe().toString().print(),这叫「链式调用」

学生:这种写法我会!给 n 所属的类添加成员方法就行。

方:你觉得链式调用是不是比 putStrLn $ show $ doubleMe 100 看着舒服

学生:(点头)嗯嗯嗯

方:但是是有代价的哦

学生:什么代价

方:链式调用需要引入 this 或 self 关键字,因为 doubleMe、toString、print 都需要用 this 来获取 n

学生:你不说我都忘了,确实要用到 this

方:顺便提一下,你不觉得 class 那一套面向对象的语法引入的关键字、特殊属性和概念太多了吗?比如 class、extends、interface、this、super、constructor、public、private、继承、实例、成员属性、类属性、静态方法……

学生:用多了还好吧,我基本都背下来了

方:好,那就拿出你背这些概念的精神头来背背函数式的知识,目前只引入了一个 $,不要嫌烦。按你的话说,用多了就好了。

学生:好的好的,我会耐心的

方:最后,把我完整的 Haskell 代码给你,方便你在这个网站上面运行:

doubleMe :: Int -> Int
doubleMe x = x * 2

main :: IO ()
main = putStrLn $ show $ doubleMe 100

学生:咦,这个 main = 我能理解,但是这个 main :: IO() 是指定 main 的类型为 IO() 吗? IO() 是什么?

方:这个以后再说,看不懂的代码照抄就行。

学生:(打开网站点击Run)我运行成功了!输出 200

方:行,今天我们就从 JS 慢慢往 Haskell 过渡了,以后能用 JS 表示的代码我会用 JS,不能用 JS 表示的我就得用 Haskell 来写了哦。

学生:OK

方:那,明天再见吧。