第三天:从 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
行吗?
方:不行,因为这样写的意思是把 show
、doubleMe
、100
作为参数传给 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
方:那,明天再见吧。