JS函数式 整合笔记一

306 阅读16分钟

一 简介

1.1 什么是函数式编程?为何它重要

先来了解数学中的函数

  • 函数必需总是接受一个参数
  • 函数必须总是返回一个值
  • 函数应该依据接收到的参数而不是外部环境运行
  • 对于一个给定的x,只会输出唯一的Y
var percentValue = 5;
var calculateTax = value => value/100 * (100 + percentValue);

在calculateTax函数中,函数依赖了全局变量,percentValue。因此该函数在数学意义上就不能被称为一个真正的函数。

修复方法

var calculateTax = (value, percentValue) => value/100 * (100 + percentValue)

现在calculateTax函数可被称为一个真正的函数了。

我们只是在内部消除了对全局变量的访问。这使得测试更容易

函数与JS方法的区别

函数:是一段可通过其名称被调用的代码,可传参并返回值。

方法:是一段必须通过其名称及其关联对象的名称被调用的代码。

函数

var simple = a => a;
simple(5);

方法

var obj = {simple: a => a }
obj.simple(5)

1.2 引用透明性

根据函数定义,我们可得出:所有的函数对于相同的输入都将返回相同的输出。这一属性被称为引用透明性 (Referential Transparency)

sum(4,5) + identity(1)

根据引用透明性的定义,我们可把上面的语句转为:

sum(4,5) + 1

该过程被称为替换模型(Substitution Model),因为你可直接替换函数的结果(主要因为函数的逻辑不依赖其他全局变量)。这使并发代码和缓存成为可能。根据该模型想象一下,你可轻松使用多线程运行上面的代码,甚至不需要同步!为什么?同步的问题在于线程不应该在并发运行的时候依赖全局数据。遵循引用透明性的函数只能依赖来自参数的输入。因此,线程可自由地运行,没有任何锁机制!

由于函数会为给定的输入返回相同的值,实际上我们就可缓存它了!

引用透明性在并发代码和可缓存代码中发挥着重要作用。

1.3 命令式、声明式与抽象

函数式主张声明式编程和编写抽象的代码。

先看看如何用命令式方法遍历数组

var array = [1,2,3]
for(i=0;i<array.length;i++){
    console.log(array[i])
}

命令式编程主张告诉编译器“如何”做

而声明式告诉编译器做“什么”。“如何”做部分将被抽象到普通函数中(这被称为高阶函数)

在看看用声明式方法遍历数组

var array = [1,2,3];
array.forEach((element)= console.log(element))

上例中我们移除了“如何”做部分,比如 "获得数组长度,循环数组,用索引获取每个元素,等等"。我们使用了一个处理“如何”的抽象函数。

1.4 函数式编成的好处

大多数函数式编程的好处来自于纯函数,所有我们先看看什么是纯函数

1.5 纯函数

对于相同的输入,永远会得到相同的输出

var double = value => value * 2;

这就是纯函数,所有它能带给我们什么好处?

1.5.1 纯函数产生可测试的代码

不纯的函数具有副作用:

var percentValue = 5;
var calculateTax = value => value /100 * (100 + percentValue)

percentValue可能会被其他意外或无意的修改,这导致calculateTax非常难于测试!但我们可容易地修复这个问题

var calculateTax = (value, percentValue) => value /100 * (100 + percentValue)

纯函数的一个重要属性,即“纯函数不应改变任何环境的变量”。换言之,纯函数不应依赖任何外部变量,也不应改变任何外部变量。

1.5.2 合理的代码

包含纯函数的代码库会易于阅读,理解和测试。记住,函数必须总是具有一个有意义的名称。

给定函数调用

Math.max(3,4,5,6)

结果是什么?你看了max的实现了吗?没有,对不对?为什么?

答案是Math.max是纯函数。

1.5并发代码

纯函数总是允许我们并发执行代码。因为纯函数不会改变它的环境,这意味着我们根本不需要担心同步问题!

如果你使用WebWorker执行多任务或Node代码需要并发执行函数:

let global = "something"
let function1 = input => {
	// 处理input
    // 改变global
    global = 'somethingElse"
}
let function2 = input => {
	if(global == "something"){
    	// 业务逻辑
    }
}

如果需要并发执行function1和function2,由于两个函数都依赖全局变量glboal,并发执行将会引起不良影响。现在改为纯函数:

let function1 = (input, global) => {
	// 处理input
    // 改变global
    global = 'somethingElse"
}
let function2 = glboal => {
	if(global == "something"){
    	// 业务逻辑
    }
}

如此,不必担心任何问题。

1.7 可缓存

既然函数总是为给定的输入返回相同的输出,那么我们就能够缓存函数的输出。

var longRunningFnBookKeeper = {2:3, 4:5}
longRunningFnBookKeeper.hasOwnProperty(ip) ?
    longRunningFnBookKeeper[ip] :
    longRunningFnBookKeeper[ip] = longRuningFunction(ip)

上面代码相当直观。在真正调用函数前,我们用ip检查函数的结果是否存在。看到了吗?用更少的代码很容易使函数调用可缓存。

在看下lodash的 memorize

// 纯
import _ fromt 'lodash'
var sin = _.memorize(x=>{
Math.sin(x))

// 第一次计算会稍慢
var a = sin(1)

// 第二次有了缓存,速度极快
var b = sin(1)

1.8 管道与组合

使用纯函数,我们只需要在函数中做一件事。纯函数能够自我理解,通过其名称就能知道它所载的事情。纯函数应该被设计为只做一件事。只做一件事并把它做到完美是UNIX的哲学,我们在实现纯函数时也将遵循这一原则。

二 高阶函数

JS将函数视为数据。允许以函数代替数据传递是一个非常强大的概念。接受另一个函数作为其参数的函数称为高阶函数Higher-Order Function 简称HOC。

2.1 理解数据

JS支持下面数据类型

Number / String / Boolean / Object / null / undefined

重要的是函数也可作为JS的一种数据类型。当一门语言允许函数作为任何其他数据类型使用时,函数被称为一等公民First Class Citizens。也就是说,函数可被赋值给变量,作为参数传递,也可被其他函数返回。

2.1.1 存储函数

	let fn = () => {}

上面代码中,fn就是一个指向函数数据类型的变量。

2.1.2 传递函数

var tellType = arg => {
	console.log(typeof arg)
}

let data = 1;
tellType(data)
=> number

没有特别之处。那么传递一个引用函数的变量会如何?

var dataFn = ()=>{
	console.log("I'm a function")
}
tellType(dataFn)
// => function

太棒了!如果传入参数的类型是function,我们就使tellType执行它

var tellType = arg => {
	if(typeof arg === 'function'){
    	arg()
    } else {
    	console.log(typeof arg)
    }
}

tellType(dataFn);
// => "I'm a function"

我们成功地把函数dataFn传递给另一个函数 tellType,而tellType执行了传入的函数,非常简单

2.1.3 返回函数

既然函数是JS中的简单数据,就能把它从其他函数中返回(就像其他数据类型)

下面函数返回了另一个函数

let crazy = () => { return String }

String是JS内置函数, 我们可使用该函数创建新的字符串

String("HOC")
// => HOC

下面调用crazy函数

crazy()
// => String(){ [native code] }

如你所见,调用crazy函数返回了一个String函数引用,但并没有执行函数。因此,可暂存返回的函数引用:

let fn = crazy();
fn("HOC")
// HOC

或者下面方式会更好

crazy()("HOC")

高阶函数是接受函数作为参数并且/或者返回函数作为输出的函数

2.2 抽象和高阶函数

一般而言,高阶函数通常用于抽象通用的问题。即定义抽象

在软件工程和计算机科学中,抽象是一种管理计算机系统复杂性的技术。它通过建立一个人与系统进行交互的复杂程度,把更复杂的细节抑制在当前水平之下。程序员应该使用理想的界面,并可添加额外级别的功能,否则处理起来将会很复杂

一个编写涉及数值操作代码的程序员不应对底层硬件中的数字表示方式感兴趣(不在乎它们是16位还是32位),可以说它们被抽象出来了,只留下简单的数字给程序员处理。

2.2.1 通过高阶函数实现抽象

const forEach = (array,fn) => {
    for(let i=0; array.length; i++){
        fn(array[i)
    }
}

forEach函数抽象出了遍历数组的问题。使用API forEach的用户不需要理解forEach是如何实现遍历的,如此问题就被抽象出来了。

forEach 本质上是遍历数组,那如何遍历对象呢?

  1. 遍历给定对象的所有key
  2. 识别key是否属于该对象本身
  3. 如果步骤2为true,则获取key的值

下面把这些步骤抽象到一个名为forEachObject的高阶函数中

const forEachObject = (obj, fn) => {
    for(var property in obj){
        if (obj.hasOwnProperty(property)) {
            // 以 key 和 value 为参,调用fn
            fn(property, obj[property])
        }
    }
}

下面是运行结果

let object = {a:1, b:2}
forEachObject(object, (k,v)=> console.log(`${k}:${v}`))
// => a:1
// => b:1

forEach和forEachObject都是高阶函数,抽象出遍历的部分,所以能彻底地测试它们,于是产生了简洁的代码库。有了高阶函数我们就会变得更加函数式。下面以抽象的方式实现对控制流程的处理

const unless = (predicate, fn) =>{
	if(!predicate){
    	fn()
    }
}

有了unless函数,就可编写一段简洁的代码来查找一个列表中的偶数

forEach([1,2,3,4,5,6,7,8,9], number => {
	unless( (number % 2), () => {
    	console.log(number, "is even")
    })
})

在看一个简单的高阶函数,times可接受一个数字次数,根据次数调用传入的函数

const times = (times, fn) => {
	for(var i=0; i<times; i++){
    	fn(i)
    }
}

使用times

times(100, function(n){
	unless(n%2, function(){
    	console.log(n, 'is even")
    })
})

我们用上面代码抽象出循环,条件判断被放在一个简明的高阶函数中。

2.2.2 真实的高阶函数

大多数高阶函数都会与闭包一起使用。

every函数

我们经常需要检查数组的内容是否为一个数字、自定义多选题或其他类型。下面将这些抽象到一个名为every的函数中

const every = (arr, fn) =>{
    let result = true;
    for(let i=0; i<arr.length; i++){
        result = result && fn(arr[i])
    }
    return result;
}

使用

every([NaN,NaN,NaN], isNaN)
// true
every([NaN,NaN,4], isNaN)
// false

every函数是一个典型的高阶函数,实现简单且非常有用!然后用for-of重写every函数

const every = (arr, fn) =>{
    let result = true;
    for(const value of arr){
        result = result && fn(arr[i])
    }
    return result;
}

for...of只是for的抽象。隐藏了索引变量移除了对数组的遍历等等。我们使用every抽象出了for...of。这就是抽象

some 函数

const some = (arr, fn) => {
	let result = false;
    for(const value of arr){
    	result = result || fn(value)
    }
    return result;
}

使用

some ([NaN, NaN, 4], isNaN)
// => true

下面来看sort函数以及高阶函数如何在其扮演重要的角色

sort函数

sort是JS的Array原型的内置函数。

假设我们需要给一个人员列表排序

var people = [
    {firstname: 'aaFirstname', lastname: 'ccLastName'},
    {firstname: 'bbFirstname', lastname: 'aaLastName'},
    {firstname: 'ccFirstname', lastname: 'bbLastName'},
]
// 根据firstname排序
people.sort((a,b)=>{
    return (a.firstname < b.firstname) ? -1 : 
            (a.firstname > b.firstname) ? 1 : 0
})
// 根据lastname排序
people.sort((a,b)=>{
    return (a.lastname < b.lastname) ? -1 : 
            (a.lastname > b.lastname) ? 1 : 0
})

我们能做得更好吗?不必每次编写compareFunction,把compare逻辑抽象到一个函数中吗?

const sortBy = (property)=>{
    return (a,b) => {
        var result =  (a[property] < b[property]) ? -1 : 
            (a[property] > b[property]) ? 1 : 0;
        return result;    
    }
}
people.sort(sortBy("firstname"));
people.sort(sortBy("lastname"))

我们在次抽象出了compareFunction背后的逻辑,使用户得以专注于真正的需求。毕竟,高阶函数就是抽象!

三 闭包与高阶函数

3.1 理解闭包

闭包的强大在于它对作用域链(或作用域层级)的访问。 从技术上讲,闭包有3个可访问的作用域

  1. 在它自身声明之内声明的变量
  2. 对全局变量的访问
  3. 对外部函数变量的访问**
let global = "global";
function outer(){
    let outer = "outer";
    function inner(){
        let a = 5;
        console.log(outer)
    }
    inner() //调用inner函数
}

上面代码将打印出outer,这看起来是合理的,但却是一个非常重要的闭包属性。闭包能访问外部函数的变量,该属性使闭包变得非常强大!

3.1.1 闭包可记住它的上下文

上面部分说明了闭包中的重要概念 —— 闭包可记住它的上下文!

var fn = arg => {
	let outer = "Visible"
    let unnerFn = () => {
    	console.log(outer)
        console.log(arg)
    }
    return innerFn
}

运行一下

var closureFn = fn(5)
closureFn()
// => Visible
// => 5

在该例子中发生了两件事

  1. 当下面一行代码被调用时 var closureFn = fn(5), fn被参数5调用了,它返回了innerFn函数

  2. 此处有趣的事发生了。当innerFn被返回时,JS引擎视innerFn为一个闭包,并相应地设置了它的作用域。如上节所见,闭包有3个作用域层级,这3个作用域层级在innerFn返回时被设置了!返回函数的引用存储在closureFn中。如此,当closureFn通过作用域被调用时就记住了arg、outer值!

  3. 当我们最后调用closureFn时clusureFn()打印出

Visible
5

现在你可猜到,closureFn是在第2步被创建的时候记住它的上下文的!

那么 闭包的应用场景是什么?我们在sortBy函数中已经实战过了。下面来回顾下

3.1.2 回顾sortBy函数

const sortBy = (property)=>{
    return (a,b) => {
        var result =  (a[property] < b[property]) ? -1 : 
            (a[property] > b[property]) ? 1 : 0;
        return result;    
    }
}

当我们以如下方式调用sortBy函数时sortBy("firstname")发生了下面的事情:

sortBy函数返回了一个接受两个参数的新函数:(a,b)=>{/*...*/} 我们已熟悉了闭包且知道返回函数能访问sortBy函数的参数property。由于该函数只有在sortBy被调用时才会返回,而这时property参数会被替换为一个值;因此,返回函数将在其生命周期中持有该上下文:

property = "PassedValue"
(a,b) => {/*...*/}

有了这些说明,我们就可理解高阶函数了,它让我们能够编写像sortBy这样的函数,抽象出内部的细节。

3.2 真实的高阶函数(续)

有了对闭包的理解,我们将实现一些真实有用的高阶函数

3.2.1 tap函数

const tap = (value) => 
    (fn) => (
        typeof(fn) === 'function' && fn(value),
        console.log(value)
    )

此处函数接受一个value,并返回一个包含value的闭包函数,该函数将被执行。

tap('fun')((it)=>console.log('value is ', it))
// => value is fun
// => fun

假设你在遍历来自服务器的数组,并发现数据错了。因此你想调试一下,看看数组究竟包含了什么。不用命令式、用函数式的方法,这正是使用tap函数的地方

forEach([1,2,3], (a)=>
    tap(a)(()=>
        {
            console.log(a)
        }
    )
)

3.2.2 unary函数

考虑下面代码

[1,2,3].map((a)=>{return a * a})
// [1, 4, 9]

map有3个参数,分别是element/index/arr。 假设我们要把字符串数组解析为整数数组 parseInt,两个参数parse/radix,如果可能,该函数会把传入的parse转换为数字。 如果把parseInt传给map函数,map会把index传给parseInt的radix参数,这会产生意想不到的行为。

(曾有网友被问到此恶心的面试题)

['1', '2', '3'].map(parseInt)
// [1, NaN, NaN]
['1', '2', '3'].map(parseInt(v, index))

我们用unary函数来解决这个问题

const unary = fn => arg => fn(arg)

unary返回一个新函数,它只接受一个参数arg,并用该参数调用fn

['1', '2', '3'].map(unary(parseInt))
// [1,2,3]

3.2.3 once 函数

很多时候,我们只需要运行一次给定函数,如只想设置一次三方库、或初始化一次支付设置,或发起一次银行支付请求等。

const once = fn => {
    let done = false;
    return function (){
        return done ? undefined: ((done=true), fn.apply(this.arguments))
    }
}
var doPayment = once(()=>{
    console.log('Payment is done')
})
doPayment()
//Payment is done
doPayment()
// undefined

3.2.4 memoized 函数

假设我们有factorial,计算阶乘

var factorial = (n) => {
    if(n===0){
        return 1;
    }
    return n * factorial(n-1);
}
factorial(2)
//2
factorial(3)
//6

此处没什么特别的。但为什么不能为每一个输入存储结果(就像某种对象)呢?如果输入在对象中存在,为什么不能直接给出结果而不必在次计算呢?

这就是moemoized函数要做的事情。 moemoized函数是一个特别的高阶函数,它使函数能记住计算结果。

const memoized = (fn) =>{
    const lookupTable = {}
    return (arg) => lookupTable[arg] || (lookupTable[arg] = fn(arg))
}

现在把factorial函数包裹进一个memoized函数来保留它的输出了:

let fastFactorial = memoized((n)=>{
    if(n === 0){
        return 1;
    }
    return n * fastFactorial(n - 1)
})

// 现在调用fastFactorial:
fastFactorial(5)
// 120
// lookupTable将为:{0:1, 1:1, 2:2, 3:6, 4:24, 5:120}
fastFactorial(3)
// 6   从lookupTable中返回
fastFactorial(7)
// 5040
// lookupTable将为: {0:1, .... 6:720, 7:5040}

它以同样的方式运行,但比之前快得多。

四 数组的函数式编程

4.1 数组的函数式方法

4.1.1 map

map的实现与forEach非常相似,区别只是用一个新数组捕获了结果 map函数是一个投影函数,因为map返回了给定函数转换后的值。 一个例子,假设我们有如下图书列表

let apressBooks = [
    {
        "id": 111,
        "title": 'C#',
        "author": "ANDREW TROELSEN",
        "rating": [4.7],
        "reviews":[{good:4, excellent: 12}]
    }
    //...
]

假设你需要获取它,只需要title和author字段

map(apressBooks, (book)=>{
    return {title: book.title, author:book.author}
})

这将返回期望的结果。

02 filter

假设我们需要评级高于4.5的图书列表

const filter = (array, fn) => {
    let results = []
    for(const value of array){
        (fn(value)) ? results.push(value) : undefined
    }
    return results;
}

有了filter就可如下操作

filter(apressBooks, (book)=>book.rating[0] > 4.5)

这将返回期望结果

连接操作

为了达成目标,我们经常需要连接很多函数。如从apressBooks中获取含有title和author对象且评级高于4.5的对象

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

这将返回期望结果 要注意:map和filter都是投影函数。因此它们总是对数组应用转换操作(通过传入高级函数)后在返回数据。于是我们能够连接filter和map

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

concatAll

对apressBooks稍做修改

let apressBooks = [
    {
        name: "beginners",
        bookDetails: [
            {
                "id": 111,
                "title": 'C#',
                "author": "ANDREW TROELSEN",
                "rating": [4.7],
                "reviews":[{good:4, excellent: 12}]
            }
            //...
        ]
    },
    {
        name: "pro",
        bookDetails: [
            {
                "id": 333,
                "title": 'Pro AngularJS',
                "author": "Adam Freeman",
                "rating": [4.0],
                "reviews":[]
            }
            //...
        ]
    },
]

现在让们回顾上节的问题 -- 获取含有title 和 author字段且评级高于4.5的图书 首先

map(apressBooks, (book) => {
    return book.bookDetails
})

这将返回

[
    [
        {
            "id": 111,
            "title": 'C#',
            "author": "ANDREW TROELSEN",
            "rating": [4.7],
            "reviews":[{good:4, excellent: 12}]
        }
        //...
    ],
    [
        {
            "id": 333,
            "title": 'Pro AngularJS',
            "author": "Adam Freeman",
            "rating": [4.0],
            "reviews":[]
        }
        //...
    ],
    //...
]

map返回的数据包含了数组中的数组。因为bookDetails本身就是数组。但是filter会有问题。因为filter不能在嵌套函数上运行 此处就是concatAll函数发挥作用的地方

const concatAll = (array, fn) => {
    let results = []
    for(const value of array){
        results.push.apply(results, value)
    }
    return results;
}

concatAll主要目的是将嵌套数组转换为非嵌套的单一数组

concatAll(
    map(apressBooks, (book) => {
        return book.bookDetails
    })
)

这将返回期望结果

reduce 函数

let useless = [2,5,6,1,10]

我们需要对上面的数组求和,如何实现呢?

let result = 0;
forEach(useless, (value) => {
    result = result + value;
})
console.log(result)
// 24

既然我们要对所有数组重复上面的过程--归约操作,那么为什么不把它抽象到一个函数中呢?

const reduce = (array,fn, initialValue) => {
    let accumlator;
    if(initialValue != undefined){
        accumlator = initialValue
    }else{
        accumlator = array[0]
    }
    if(initialValue === undefined){
        for(let i=1; i<array.length; i++){
            accumlator = fn(accumlator, array[i])
        }
    }else {
        for(const value of array){
            accumlator = fn(accumlator, value)
        }
    }
    return [accumlator]
}

有了reduce函数,我们就能通过它解决求和问题

reduce(cseless, (acc,val) => acc + val)
// 24

现在我们要在apressBooks中使用reduce 有一天老板让你实现此逻辑:从apressBooks中统计评价为good和excellent的数量。 首先取出bookDetails并拉平数组

let bookDetails = concatAll(
    map(apressBooks, (book) => {
        return book.bookDetails
    })
)

现在用reduce解决问题

reduce(bookDetails, (acc, bookDetail) => {
    let goodReviews = bookDetail.reviews[0] != undefined ? bookDetail.reviews[0].good : 0
    let excellentReviews = bookDetail.reviews[0] != unfefined ? bookDetail.reviews[0].excellent : 0
    return {
        good: acc.good + goodReviews, 
        excellent: acc.excellent + excellentReviews
    }
}, {good:0, excellent:0})

如你所见,我们把内部的细节抽象到高阶函数中,产生了优雅的代码

zip 数组

事情并非总是如你所愿。我们在apressBooks,的bookDetails中获取了reviews,并能轻松地操作它。但是apressBooks可能来自服务器,而reviews被作为单独数组返回,并不是嵌入的数据

let apressBooks = [
    {
        name: "beginners",
        bookDetails: [
            {
                "id": 111,
                "title": 'C#',
                "author": "ANDREW TROELSEN",
                "rating": [4.7],
            },
            //...
        ]
    },
    {
        name: "pro",
        bookDetails: [
            {
                "id": 333,
                "title": 'Pro AngularJS',
                "author": "Adam Freeman",
                "rating": [4.0],
            },
            //...
        ]
    }
]

reviewDetails对象包含了图书的评价详情

let reviewDetails = [
    {
        'id': 111,
        'reviews': [{good:4, excellent: 12}]
    },
    // ...
]

reviews被填充到一个单独的数组中,它与书的id匹配。这是数据被分离到不同部分的典型例子 但是如何处理这些数据呢? zip函数的任务是合并两个给定的数组

const zip = (leftArr, rightArr, fn) => {
 let index, results = [];
 for(index = 0; index < Math.min(leftArr.length, rightArr.length); index++){
    results.push(fn(leftArr[index], right[index]))
 }
 return results;
}

我们需要遍历两个给定的数组,首先获得最小长度,然后调用传入的高阶函数fn

zip([1,2,3], [4,5,6], (x,y)=> x+y)
// [5,7,9]

现在让我们用zip解决apressBooks和reviewDetails问题

let bookDetails = concatAll(
    map(apressBooks, (book) => {
        return book.bookDetails
    })
)
let mergedBookDetails = zip(bookDetails, reviewDetails, (book, review) => {
    if(book.id === review.id){
        let clone = Object.assign({}, book)
        clone.retings = review
        return clone
    }
})