Day 03 - Curry

yo, what's up?

今天要来介绍 Functional Programming 重要的概念,Curry.

Curry 的功用?

我们先来看一个简单的函式 add

const add = (x, y) => x + y;

这应该就是大家所熟悉一般函式的写法,当我们进行两数相加时,只需要放入函式所需的参数,即可进行呼叫。

add(1, 1) // 2

简单吧! 看起来没有什麽好挑剔的,那如果多放或少放参数呢?

add(1) // NaN

add(1, 1, "Naurrr") // 2

多放参数也不是不行,只是会被忽视。 少放的话则会发生问题,变成 1+undefined,则为 NaN.

Curry 是什麽呢?

可以看到上面的范例,如果少放参数,函式也会立即被呼叫,且少放的参数全部会变成 undefined, 那要怎麽让函式收到全部参数再呼叫呢?

相较於一般函式写法不同,Currying 这个概念在於将原本预期是要传入多个参数的函式 转化成 每次只接收一个参数作为输入,并回传新的函式,且等待下一个参数传入,直到函式所需参数皆到齐时,才会呼叫。

所以在 Currying 下的 add 函式写法就会是

const addC = x => y => x + y;

这时 addC 需要这样呼叫 addC(1)(1) ,好处就是我们可以不用一次放入全部参数,可以最大化函式的自由度,也就是说可以在任何时间与任何地点(不同档案路径) 呼叫 currying 的函式。

举个简单的例子:

// utils.js
export const add1 = addC(1);

// example.js
import { add1 } from './utils';

[1, 2, 3, 4, 5].map(add1) // [2, 3, 4, 5, 6]

我们在 utils.js 写了一个 add1 的函式,并在 example.js 引用,这样写大大地增加了写程序的自由度,以及复用性。

实作 Curry

既然知道 currying 这个概念了,直接来实作一个可以将各种函式 currying 的通用函式。

function curry(fn, arity = fn.length) {
    return (function nextCurried(prevArgs) {
        return function curried(nextArgs) {
            const args = [...prevArgs, nextArgs];
            if(args.length >= arity){
                return fn(...args);
            }
            return nextCurried(args)
        }
    })([])
}

现在就来将原本的 add 透过 curry 变成有 currying 特性的 add 吧 !

const add = (x, y) => x + y;

const addC = curry(add);

addC(1)(1) //2

分析一下 curry 这个函式是怎麽将各种函数通用化的,举 add(x, y) 来说,

一开始 curry(add) 传入时会马上呼叫 nextCurried 并且 prevArgs 的初始值为 []

1 被传入时,此时 args 会变成 [1],此时 args.length 还小於 add 传入参数长度(2), 所以会进行递回 nextCurried([1])

当另一个 1 被传入时,此时 args 会变成 [1, 1],而 args.length 也是 2, 故收到所有参数的 add会立即执行,并回传 2

Loose curry

或许大家会觉得 add(1)(1) 有点不方便,那有麽方法可以让更有弹性的,不管写成 addC(1)(1) 或是 addC(1, 1) 都可以执行。好消息是 ramda 或是 lodash/fp 的 curry 函式皆有支援,也有人称此为 loose curry

function looseCurry(fn, arity = fn.length) {
    return (function nextCurried(prevArgs){
        return function curried(nextArgs){
            const args = [...prevArgs, ...nextArgs];
            if(args.length >= artiy){
                return fn(...args);
            }
            return nextCurried(args)
        }
    })([])
}

const add = (x, y) => x + y;

const addC = looseCurry(add);

这样我们就可以同时使用 addC(1)(1)addC(1, 1) 的写法了!

So What...?

看完上面的例子,看不太出来 curry 的好处,心想 so what...? 的读者们,接下笔者来会用实际范例来呈现 curry 的威力!

想像一下,现在 PM 要你处理这支 Users API,需求是

  1. 对使用者居住的纬度由高到低进行排序,
  2. 取出使用者的名字,
  3. 在每位使用者名子後面加 "!"。

所需要的函式会有

// sort
const sort = (fn, data) => [...data].sort(fn)

// get
const get = (key, data) => data[key]

// concat
const concat = (symbol, data) => data.concat(symbol);

// map
const map = (transformer, data) => data.map(transformer)

重点一: 可以看到上面的都有一个共通点,就是 Data Last,这也是 ramda 或是 lodash/fp 的特色,将"需要处理的资料"放在函式的最後一个。晚点也会提到这样有什好处。

由於函式没有进行 currying 之前,函式需要一次传入其所需之参数

fetch('https://jsonplaceholder.typicode.com/users')
  .then(r => r.json())
  .then(data => sort((a, b) => b.address.geo.lat - a.address.geo.lat, data))
  .then(sortedData => map((data) => get('username', data), sortedData))
  .then(usernames => map((username) => concat('!', username), usernames))
  .then(result => console.log(result))
  .catch(err => console.error(err));

接下来,读者可以试着把所有函式进行 currying,并且重构上面的那段程序码

// sort
const sort = curry((fn, data) => [...data].sort(fn));
// get
const get = curry((key, data) => data[key]);
// concat
const concat = curry((symbol, data) => data.concat(symbol));
// map
const map = curry((transformer, data) => data.map(transformer));
fetch('https://jsonplaceholder.typicode.com/users')
  .then(r => r.json())
  .then(xs => sort((a, b) => b.address.geo.lat - a.address.geo.lat)(xs))
  .then(xs => map(get('username'))(xs))
  .then(xs => map(concat('!'))(xs))
  .then(result => console.log(result))
  .catch(err => console.error(err));

可以看到跟上面的写法看似没有什麽差别,但是我们可以将写法进一步改成

fetch('https://jsonplaceholder.typicode.com/users')
  ...
  .then(sort((a, b) => b.address.geo.lat - a.address.geo.lat))
  ...

而这种写法又称为 point-free style. 接下来用 point-free style 重构上面的那段程序码

发现了吗! Isn't that neat!!??

fetch('https://jsonplaceholder.typicode.com/users')
  .then(r => r.json())
  .then(sort((a, b) => b.address.geo.lat - a.address.geo.lat))
  .then(map(get('username')))
  .then(map(concat('!')))
  .then(console.log)
  .catch(console.error)

由上面笔者整理一些重点

1. 易读性增加
有些读者们可能会认为,可以将所有 .then 要做的事,再抽出来变成一个函式,如下面范例

const getNameFromSortedData = sortedData => 
    map(data => get('username', data), sortedData)
fetch('https://jsonplaceholder.typicode.com/users')
   ...
   .then(getNameFromSortedData)
   ...

可以试想一下,这样不是就需要帮每个函式进行命名,然而命名不是一件容易的事情,面对不容易的事情,其中一个方法,就是不要去做这件事。

对! Currying 过後的函式,就已经可以清楚表明程序码到底在干嘛,就不太需要再包成一个函式,并且为函式命名。

fetch('https://jsonplaceholder.typicode.com/users')
    ...
    .then(map(get('username')))
    ...

2. Data Last

其实这点是非常重要的,Data Last 就是将需要处理的资料作为函式的最後一个参数。

function(fn, data)

这样写有什麽好处呢?

其可以最大化 currying 的效果! 再透过 point-free style 可以让阅读程序的人只需要专注於这段是处理了什麽逻辑!

额外读物

  1. Auto-Curried: 在 TypeScript 会喷出错误, TypeScript and currying

参考资源

  1. Functional-Light-JS Ch.3

NEXT: Function Composition


<<:  简单建立一个银行系统

>>:  Day04: 04 - 页面刻划(3) -商品详情、订单详情、个人资料

Fit Leather Jackets

We are making your quest for VIP coats simpler by ...

历行性邮件_范本建立

历行性的邮件报告,主旨和内容,一样的都要再打一次,要找之前那封,不好找.很花时间. 想到一个方法 历...

Unity与Photon的新手相遇旅途 | Day12-血量制作

今天的内容为该如何制作血量,并且在攻击时或受到伤害时改变血量。 ...

Vaadin 工具 / 後记 - day30

Vaadin Start Vaadin 官方网站提供快速产出程序码工具,所见即所得,还可设定权限,分...

VSCode 套件推荐系列 - 下

最後一篇,持续来介绍 VSCode 的套件,让你靠一套文字编辑器在路上横着走! CodeSpellC...