▲点击“RingCentral铃盛软件”并设为【星标】,及时获取RingCentral的资讯
这篇文章的主要与大家分享一个来自于我日常 code review 的感想:虽然我们都喜欢自己编写的函数不要过长(这条经验也被囊括在了 Martin Fowler 的《重构》一书中), 但是太短的函数也不一定是更好的。当我们编写一个函数时, 函数的可读性、可维护性和鲁棒性要比其长短和抽象程度更重要。 R 一个简单的例子考虑以下场景: 你希望实现一个简单的算法来从一系列进账和消费中算出并以外汇的形式来展示一个银行账户的余额。为了简单起见, 接下来我将用Javascript
来实现这一简单的需求, 并且
忽略ECMAScript中的
浮点问题
以及参数的
类型和值的合法性校验
。这个算法显然需要3个参数: 账户余额、一个元素类型为Number的列表其中收入以正数表示且开销以负数表示、以及一个汇率, 返回值是一个Number类型的值。
R
简单粗暴的解法
function getBalanceInFC ( balance = 0, costs = [], exchangeRate = 1) { return costs.reduce((acc, flow) => acc + flow, balance) / exchangeRate;}
R
利用可复合性
上述代码实际上是由两种数学运算构成: 累加和相除。两种运算在会计领域都有其对应的意义。
所以, 鉴于单一职责原则、可重用性和可维护性出发, 可以将其解构:
const getBalance = (balance = 0, costs = []) => costs.reduce((acc, flow) => acc + flow, balance);const getFC = (balance = 0, exchangeRate = 1) => balance / exchangeRate;const getBalanceInFC = ( balance = 0, costs = [], exchangeRate = 1) => getFC(getBalance(balance, costs), exchangeRate);
R
Point-free 风格以上的代码简单易懂且容易维护, 因为每个函数都只对应一个职责且均为纯函数。但抽象层及还能更高: 我们可以不写
getBalanceInFC
的参数, 这种编程风格被称为
point-free 风格
。
point-free 风格
来自于数学。就像几何更关注面和空间而胜过单个的点一样, 在编程中这种风格可以简单地视作是泛指那些不用提及其具体形参的函数。到达这种目的的途径之一是利用
eta-conversion[1]:
假设
z
单参数函数, 则:x => z(x)
等同于:
z
自身.
但是我们还缺少一个材料, 因为 javascript 缺乏一个内建的combinator
, 在Haskell
中我们称它为 after
:
给定任意两个函数:
f
和g
, 则表达式x => f(g(x))
在 Haskell 中可以写作:
f.g
读作
f 在 g 之后
(f after g)它也可以实现为前缀运算符:
after(f)(g)
注意这里的 after
是经过 柯里化
的.
我在这里提供一种 curry
和 after
的简单实现:
const curry = f => { // Need to remove the default value for `f` // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length#Description const subCurry = (args) => args.length === f.length ? f.apply(null, args) : (x) => subCurry([...args, x]); return x => subCurry([x]);}const after = f => g => x => f(g(x));
则为了能做 eta-conversion, 我们还需要柯里化后的 getBalance
和 getFC
:
// the prefix `c` stands for `curry`const cGetBalance = curry(getBalance);const cGetFC = curry(getFC);
现在我们做好了朝 point-free 出发的准备, 我会用等式推导的的方式完成之后的内容:
1.编写柯里化的 getBalanceInFC
:
// the prefix `pf` stands for `point-free` const pfGetBalanceInFC = (balance = 0) => (costs = []) => (exchangeRate = 1) => getFC(getBalance(balance, costs), exchangeRate)
2.将 getFC
及其调用方式替换为它柯里化后的版本:
const pfGetBalanceInFC = (balance = 0) => (costs = []) => (exchangeRate = 1) => cGetFC(getBalance(balance, costs))(exchangeRate);
3.用 eta-conversion 消除实参exchangeRate
:
const pfGetBalanceInFC = (balance = 0) => (costs = []) => cGetFC(getBalance(balance, costs));
4.getBalance
及其调用方式替换为它柯里化后的版本:
const pfGetBalanceInFC = (balance = 0) => (costs = []) => cGetFC(cGetBalance(balance)(costs));
5.接下来我们利用柯里化的优势 —— cGetBalance(balance)
是一个函数! 我们可以假设 cGetBalance(balance)
等效于某个函数 f
, 则 cGetFC(cGetBalance(balance)(costs))
可以读作 cGetFC
在 f
之后 然后用 costs
来调用 (cGetFC
after f
then applied with costs
)。然后我们再利用函数式编程在 声明式编程
(declarative) 上的优势来将自然语言翻译成代码:
const pfGetBalanceInFC // = (balance = 0) => (costs = []) => after(cGetFC)(cGetBalance(balance))(costs); = (balance = 0) => after(cGetFC)(cGetBalance(balance));
6.
对上一步得出的表达式, 我们可以应用一条规则:
f.g ≅ \x->(f.g)(x) ≅ \x->f(g(x))
(为了简单起见, 我在这里混用了
Haskell
语法和范畴论中的同构运算符≅)。之后让我们再一次应用这条规则: 把
after(cGetFC)
看作是某一个函数表达式
f
并且把
cGetBalance
看作是某个函数
g
后我们就能看清所得到的结果恰好匹配上述的规则:
const pfGetBalanceInFC // = (balance = 0) => after(after(cGetFC))(cGetBalance)(balance); = after(after(cGetFC))(cGetBalance);
所以 point-free 风格的 getBalanceInFC
就是 after(after(cGetFC))(cGetBalance)
。
它很简短又很抽象但是让人完全摸不着头脑!即使point-free 风格有它自己的优势 —— 如果我们把上述的模式抽出一个 combinator 的话:
const owl // = f => g => after(after(f))(g); = after(after)(after); // applying same rules above
值得一提的是它的 Haskell 版本看起来更"可怕":
-- because it looks like an owlowl = (.).(.)
则这个 猫头鹰
(owl) 对任意参数数量的函数f
都适用。给与任意像 getBalance
那样拥有2个形参的函数:
const f = (x, y, z) => x + y + z;const g = (x, y) => x / y;const p = (w, x, y, z) => f((g(w, x)), y, z);const owlP = owl(curry(f))(curry(g));console.log(p(1, 2, 3, 4) === owlP(1)(2)(3)(4)) //true
这意味着给定同样的参数, 则p
和owlP
的执行结果始终一致(可以利用我们的 after
combinator 的类型注解来得以证明这一断言)。无可辩驳的是猫头鹰
的确提供了鲁棒性, 但是也极大增加了理解代码的负担。
编程实践是一种社交活动, 因为其中涉及到了合作和交流。而交流则需要参与者拥有一定的背景知识。举个例子, 每当人们向我询问 monad
的概念时, 最精炼的答案总是来自于 James Iry
的文章 Brief, Incomplete and Mostly Wrong History of Programming Languages[2] 中的一段话:
我相信大多数人听到这个答案的时候, 对话就结束了。因为为了能够理解这个答案的意思, 听者需要理解一大堆数学概念: 像A monad is just a monoid in the category of endofunctors
前序集
,
幺半群(monoid)
,
范畴(category)
, 以及
自函子(endofunctors)
;在此之外还得再了解
Galois connection
,
adjoint situation
,
函子(functor)
,
natural transformations
。但是如果我从一个简单的
commutative diagram
开始画起(我最喜欢范畴论的一点就在于可以用画图来证明东西), 我就能用点和线来让提问者理解范畴论的概念, 进而再解释
Kleisli Categories
, 也许最后他们就能对这个复杂的概念有比较直观的认识。程序中抽象的优势在于对于特定的问题域上能够在提供特定的功能的同时又给与鲁棒性。但是程序员应该给予其合作者的知识背景来选择其代码的抽象程度, 尤其是在企业规模的实践中。所以我认为一个拥有好味道的的函数代码永远是那些对于团队成员来说易读、易懂又不失鲁棒性的函数, 而其长短则没那么重要。
R
参考
1.
关于
owl
的更多信息可以从这本逻辑猜谜书中找到
To Mock a Mockingbird[3]
2.这里提供一个 Haskell 的版本:
-- because it looks like an owl owl = (.).(.) getBalance:: Float -> [Float] -> Float getBalance original costs = foldl (+) original costs getFC:: Float -> Float -> Float getFC balance exchangeRate = balance / exchangeRate getBalanceInFC = getFC `owl` getBalance main = print $ getBalanceInFC 1 [1, 2] 2
3.这里用近似 Haskell 的语法给出一个owl结合子的通用性的证明:
根据after
的类型注解:
(.):: (b → C) → (a → b) → (a → c)
我们想得出一下结合子的类型注解:
(.).(.)
∵ 让我们将上式子的左右2个 (.)
分别表上序号为 #1, #2; 并且将中间的.
标注为 #3, 则:
#1:: (b→c)→(a→b)→(a→c) #2:: (b1→c1)→(a1→b1)→(a1→c1) #3:: (b2→c2)→(a2→b2)→(a2→c2)
∴ 我们有:
#1:: b2→c2 #2:: a2→b2
∴ 进而推导出:
b2:: b→c c2:: (a→b)→(a→c)
以及:
a2:: b1→c1 b2:: (a1→b1)→(a1→c1)
这意味着:
b2:: b→c :: (a1→b1)→(a1→c1)
∴ 得:
b::c1→b1 c::a1→c1
因此最终结果是:
(.).(.):: a2→c2 :: (b1 → c1)→[a→(a1→b1)]->[a→(a1→c1)]
这和你在 GHC 中输入
:type (.).(.)
得到的结果一模一样(除了类型的命名和方括号外)。因此一旦你“喂给”
owl
两个函数以及用于第二个函数的两个参数后, 你将得到类型为
c1
的结果, 它就是你给的第一个函数进行(部分)评估后的结果的类型注解。这也是为什么第一个函数可以获得任意数量的参数的原因。
访问原文请点击文末
![1f657283d77daf20966ad8d7f8445ae4.gif](https://img-blog.csdnimg.cn/img_convert/1f657283d77daf20966ad8d7f8445ae4.gif)
点击阅读原文
即可查看原文
点个在看
少个Bug
![0ce1ada5a3e1e731b01ec27ff7658dfd.gif](https://img-blog.csdnimg.cn/img_convert/0ce1ada5a3e1e731b01ec27ff7658dfd.gif)