JS 函数式整合笔记 二

227 阅读12分钟

一 函数柯里化与偏应用

一些术语

01 一元函数

const identity = (x) => x;

02 二元函数

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

03 变参函数

function variadic(a, ...variadic){
    console.log(a)
    console.log(variadic)
}
variadic(1,2,3);
// 1
// [2,3]

柯里化

柯里化是把一个多参数函数转换为一个嵌套的一元函数的过程

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

柯里化板本

const addCurried = x => y => x + y;
addCurried(4)(4)
// 8

此处手动把两个参数转换为含有嵌套的一元函数的addCurried函数。下面把该处理过程转换为一个名为curry的方法

const curry = (binaryFn) => {
    return function (firstArg){
        return function (secondArg){
            return binaryFn(firstArg, secondArg)
        }
    }
}

现在通过curry函数把add函数转换为一个柯里化版本 let autoCurrieAdd = curry(add) autoCurriedAdd(2)(2)

01 柯里化用例

001 一个创建列表的函数

const tableOf2 = (y) => 2 * y
const tableOf3 = (y) => 3 * y
const tableOf4 = (y) => 4 * y
tableOf2(4)
//8
tableOf3(4)
//12
tableOf4(4)
//16

柯里化的表格函数

const genericTable = (x,y) => x * y;

const tableOf2 = curry(genericTable)(2) 
const tableOf3 = curry(genericTable)(3) 
const tableOf4 = curry(genericTable)(4) 

tableOf2(2)
//4
tableOf3(2)
//6
tableOf4(2)
//8

002 回顾curry

我们知道只能把一个函数柯里化,那么多个函数会如何呢?下面来添加规则

let curry = (fn) => {
    if(typeof fn !== 'function'){
        throw Error('No function provided')
    }
}

有了这层检查,如果其他人用一个整数(如2)调用curry函数,他们会得到错误! 下一个柯里化的要求是,如果有人为柯里化函数提供了所有的参数,需要通过传递这些参数执行真正的函数

let curry = (fn) => {
    // No function provided
    return function curriedFn(...args){
        return fn.apply(null, args)
    }
}

curriedFn是变参函数,它返回了传入args并通过apply调用函数的结果

const multiply = (x,y,z) => x * y * z
curry(multiply)(1,2,3)  // 等价于 multiply(1,2,3)
//6
curry(multiply)(1,2,0)
//0

003 下面把多参数函数转换为嵌套一元函数(这就是柯里化的定义)的问题。

let curry = (fn) => {
    if(typeof fn !== 'function'){
        throw Error('No function provided')
    }
    return function curriedFn(...args){
        if(args.length < fn.length){
            return function(){
                return curriedFn.apply(null, args.concat([].slice.call(arguments)))
            }
        }
        return fn.apply(null, args)
    }
}

让我们逐句理解新增代码args.length<fn.length检查...args参数长度是否小于函数参数列表长度。

一旦满足进入if,就使用apply递归调用curriedFn:return curriedFn.apply(null, args.concat([].slice.call(arguments)))

此片断:args.concat([].slice.call(arguments))非常重要。我们一次传入一个参数,并递归调用curriedFn,把参数暂存到args中。

当某个时刻,参数足够时jf(args.length<fn.length)条件失败了。

程序将调用return fn.apply(null, args)

这将产生函数的完整结果

curry(multipiy)(3)(2)(1)
// 6

004 日志函数

const loggerHelper = (mode, initialMessage, errorMessage, lineNo) => {
    if(mode === 'DEBUG'){
        console.debug(initialMessage, errorMessage, lineNo)
    }else if(mode === 'ERROR'){
        onsole.error(initialMessage, errorMessage, lineNo)
    }else if(mode === 'WARN'){
        onsole.warn(initialMessage, errorMessage, lineNo)
    }else{
        throw "Wrong node"
    }
}

开发者通常如下方式使用函数

loggerHelper("ERROR", "Error at stats.js", "Invalid argument passed", 23)

通过curry解决重复使用前两个参数的问题:

let errorLogger = curry(loggerHelper)('ERROR')("Error at Stats.js")
let debugLogger = curry(loggerHelper)('DEBUG')("Debug at Stats.js")
let warnLogger = curry(loggerHelper)('WARN')("Warn at Stats.js")

现在可轻松地引用上面的柯里化函数并在各自上下文中使用

// ...
errorLogger("Error message", 21)
// ...
debugLogger("Debug message", 45)
// ...
warnLogger("Warn message", 33)

005 lodash curry

import {curry} from 'lodash'

var match = curry((reg, str) => str.match(reg))
var filter = curry((f, arr) => arr.filter(f))
var haveSpace = match(/\s+/g);
// haveSpace('ffffffff')
// haveSpace('a b')

// filter(haveSpace, ["abced", "Hello world"])
filter(haveSpace)(["abocd","Hello world"])

事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上,这是一种对参数的缓存,是一种非常高效的编写函数的方法

02 柯里化 在数组中查找数字

let match = curry(function(expr, str) {
    return str.match(expr)
})
let hasNumber = match(/[0-9]+/)
// 然后创建一个filter函数
let filter = curry(function(f, ary){
    return ary.filter(f)
})
// 通过hasNumber和filter,我们就可创建一个新的名为findNumbersInArray函数
let findNumbersInArray = filter(hasNumber)
// 现在测试它
findNumbersInArray(['js', 'number1'])
// ["number1"]

数据流

柯里化总是在最后接受数组。这个有意为之!

偏应用

setTimeout(()=> console.log("Do X tast"), 10)
setTimeout(()=> console.log("Do Y tast"), 10)

每个setTimeout都传入了10.能在代码中把它隐藏吗?能使用curry函数解决吗?答案是否定的。原因在于curry函数应用参数列表的顺序是从最左到最右!一个变通的方案是把setTimeout函数封装一下

const setTimeoutWrapper = (time, fn) => {
    setTimeout(fn, time)
}

const delayTenMs = curry(setTimeoutWrapper)(10)
delayTenMs(() => console.log("Do X task"))
delayTenMs(() => console.log("Do Y task"))

但问题是我们不得不创建如setTimeoutWrapper一样的封装器,这个一种开销!而此处就是可使用偏应用的地方!

实现偏应用

偏应用函数定义

const partial = function (fn, ...partialArgs){
    let args = partialArgs;
    return function(...fullArguments){
        let arg = 0;
        for(let i=0; i<args.length && arg < fullArguments.length; i++){
            if(args[i] === undefined){
                args[i] = fullArguments[arg++];
            }
        }
        return fn.apply(null, args); 
    }
}
let delayTanMs = partial(setTimeout, undefined, 10);
delayTenMs(()=> console.log("Do Y task"));

这将输出期望结果,让我们浏览一遍偏函数细节,第一次执行是我们捕获了传入函数的参数:

partial(setTimeout, undefined, 10)
// 这将产生
let args = partialArgs
// args = [undefined, 10]

返回的函数将记住args的值(没错,这里是闭包)。返回的函数接受一个名为fullArguments的参数。所以,可像delayTenMs那样通过传入参数调用:

delayTenMs(()=> console.log("Do Y task"))
// fullArguments 指向
// [()=> console.log("Do Y task")]

// 使用闭包的args将包含
// args = [undefined, 10]

在for循环中我们执行遍历并为函数创建必需的参数数组:

if(args[i] === undefined){
    args[i] = fullArguments[arg++];
}

下面从i为0时开始

// args = [undefined, 10]
// fullArguments = [()=> console.log("Do Y task")]
args[0] => undefined === undefined // true

// 在if循环内
args[0] = fullArguments[0]
// args[0] = ()=> console.log("Do Y task")

// 如此args将变为
// [()=> console.log("Do Y task"), 10]

args指向我们期望的setTimeout函数调用所需的数组。一旦在args中有了必需的参数,我们就能通过fn.apply(null,args)调用函数了。

我们可将partial应用于任何含有多个参数的函数。

let obj = {foo:"bar", bar:"foo"}
JSON.stringify(obj, null, 2)

stringify调用的最后两个参数总是相同的"null,2".可用partial移除样板代码

let prettyPrintJson = partial(JSON.stringify, undefined, null, 2)
prettyPrintJson({foo:"bar", bar: "foo"})

我们了解了两种技术。那么问题是什么时候该用哪一个?答案取决于API是如何定义的。如果API如map/filter一样定义,我们就可轻松使用curry函数解决问题。但是往往事与愿违。如setTimeout、这时最合适的选择是偏函数!

二 函数组合与管道

符号 | 被称为管道符号。它允许我们通过组合一些函数去创建一个能够解决问题的新函数!大致来说 | 将最左侧的函数输出作为输入发送给最右侧的函数!从技术上讲,该处理过程称为管道

一句话:每个程序的输出应该是另一个未知程序的输入 我们通过基础函数组合出一个新函数:每个基础函数都需要接受一个参数并返回数据

回顾前面的apressBooks,我们要从apressBooks中获取含有title和author字段且评级高于4.5的对象,我们的解决方案如下

map(filter(apressBooks, (book) => book.rating[0] > 4.5), (book) => {
    return {title: book.title, auhor: book.author}
})

我们获得了如下结果

[
    {
        title: 'c#',
        author: "ANDREW TROELSEN"
    }
]

下面通过把一个函数的输出作为输入发送给另一个函数的方式把两个函数组合起来

compose 函数

const compose = (a,b) => (c) => a(b(c))

compose函数会首先执行b,并将b的返回值作为参数传递给a。该函数调用的方向是从右至左。

应用 compose函数

例1 对一个给定的数字四舍五入求值

不使用组合方式

let data = parseFloat("3.56")
let number = Math.round(data)
//4

下面通过compose函数解决问题

let number = compose(Math.round, parseFloat)
// 等价于 number = (c) => Math.round(parseFloat(c))
number(3.67)
//4

这就是函数组合!

例2 计算一个字符串中单词数量

let splitIntoSpaces = (str) => str.split(" ");
let count = (array) => array.length;

const countWords = compose(count, splitIntoSpaces)
countWords("hello your reading about composition")
// 5

例3 引入curry与partial

仅当函数接受一个参数时,我们才能将两个函数组合。但还存在多参数函数。接下来通过curry或partial函数来实现。

回顾map与filter

map(filter(apressBooks, (book) => book.rating[0] > 4.5), (book) => {
    return {title: book.title, auhor: book.author}
})

map与filter函数都接受两个参数:第一个是数组,第二个是操作数组的函数。因此不能直接将它们组合。

但是可求助于partial函数,假设我们根据不同评级在代码库中定义了很多小函数用于过滤图书

let filterOutStandingBooks = (book) => book.rating[0] === 5;
let filterGoodBooks = (book) => book.rating[0] > 4.5;
let filterBadBooks = (book) => book.rating[0] < 3.5;

还有很多投影函数

let projectTitleAndAuthor = (book) => {
    return {
        title: book.title,
        author: book.author
    }
}
let projectAuthor = (book) => {
    return { author: book.author }
}
let projectTitle = (book) => {
    return {title: book.title
}

组合函数的思想就是把小函数组合成一个大函数。简单的函数容易阅读、测试和维护。

let queryGoodBooks = partial(filter, undefined, filterGoodBooks);
let mapTitleAndAuthor = partial(map, undefined, projectTitleAndAuthor);
let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor, queryGoodBooks)

下面看看compose如何组合两个参数的map和filter

partial(filter, undefined, filterGoodBooks);
partial(map, undefined, projectTitleAndAuthor);

这两个偏应用都只接收一个数组参数!有了这两个偏函数,就可通过compose将它们组合起来

let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor, queryGoodBooks)

现在函数titleAndAuthorForGoodBooks只接受一个参数,下面把apressbooks对象数组传给它:

titleAndAuthorForGoodBooks(apressBooks)

例4 组合多个函数

当前版本的compose函数只能组合两个给定函数。如何组合三个、四个或更多函数呢? 下面来重写compose函数

记住:我们需要把每个函数的输出作为输入发送给另一个函数(通过递归地存储上一次执行的函数的输出)。可使用reduce函数

const compose = (...fns) => 
    (value) => 
        reduce(fns.reverse(), (acc, fn) => fn(acc), value)

通过fns.reverse()反转函数数组,并传入了函数(acc,fn)=>fn(acc),它会以传入的acc作为其参数依次调用每一个函数。显然:累加器的初始值是value变量,它将作为函数的第一个输入

下面用计算给定字符串的单词数测试下

let splitIntoSpaces = (str) => str.split(" ");
let count = (array) => array.length;
const countWords = compose(count, splitIntoSpaces);

// 计算单词数
countWords("Hello your reading about composition")
// 5

假设我们想之敌给定字符串的单词数是奇数还是偶数。

let oddOrEven = (ip) => ip % 2 == 0 ? "even" : "odd"

通过新的compose函数来组合这三个函数

const oddOrEvenWords = compose(oddOrEven, count, splitIntoSpaces);
oddOrEvenWords("Hello your reading about composition")
// ['odd']

五 Point Free

  • 把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量。
  • 这个函数中,我们使用了str作为我们的中间变量,但这个中间变量除了让代码变得长了一点以外是毫无意义的
const f = str => str.toUpperCase().split(' ')
var toUpperCase = word => word.toUpperCase()
var split = x => (str => str.split(x))

var f = compose(split(' '), toUpperCase)

f("abcd efgh")
  • 这种风格能帮助我们减少不必要的命名,让代码保持简洁和通用。

惰性求值,惰性函数

  • 在指令式语言中以下代码会按腮顺序执行,由于们个函数的可能改动或依赖外部的状态,因此必须顺序执行
function ajax(){
    var xhr = null;
    if(widow.XMLHttpRequrest){
        xhr = new XMLHttpRequrest()
    }else{
        xhr = new ActiveXObject('Microsoft.XMLHTTP')
    }
    ajax = xhr;
}

尾调用优化

指函数内部的最后一个动作是函数调用。该调用的返回值,直接返回给函数。 函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归需要保存大量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了。

传统递归,不是递归无法优化

function factorial(n){
    if(n === 1) return 1;
    return n * factorial(n -1)
}

ES6强制尾递归

function factorial(n, total){
    if(n === 1) return total;
    return factoria(n -1, n * total)
}

浏览器不支持,可使用while

function fn()
var i = 10;
while(i--){
    fn()
}

范畴与容器

  1. 我们可把"范畴" 想象成一个容器,里面包含两样东西。值value、值的变形关系,也就是函数。
  2. 范畴论使用函数,表达范畴之间的关系。
  3. 伴随着范畴论的发展,出现一整套函数的运算方法。这套方法起初只用于数学运算,后来在计算机生实现了,就变成了今天的“函数式编程”
  4. 本质上,函数式编程在量范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法。为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是取值,不做其他事情,否则就无法满足函数运算法则了。
  5. 函数不仅可用于同一个范畴之中值的转换,还可用于将一个范畴转成另一个范畴。这就涉及了函子Functor
  6. 函子是函数式编程里最重要数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可依次作用于每一个值,将当前容器形成另一个容器。

  • $(..)返回的对象并不是一个原生的DOM对象,而是对于原生对象的一种封装,这在某种意义上就是一个“容器”(但不是函数式)
  • functor函子遵守一些特定规则的容器类型
  • functor是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口map给容器外的函数,map一个函数时,我们让容器自己来运行这个函数,这样容器就可自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛的特性。