javascript函数式编程入门

361 阅读12分钟

前言

在学习函数式编程之前,我们先来说说为什么要学习它,主要有以下几方面原因:

  • 提升代码质量,让别人对你刮目相看
  • 让你可以读得懂更加高级的代码

函数式编程定义

在计算机科学中, 函数式编程是一种编程范式,一种构建计算机程序结构和元素的方式 ,将计算视为数学函数。 它是一种声明性编程范例,这意味着使用表达式或声明而不是语句来完成编程。 在函数代码中,函数的输出值仅取决于传递给函数的参数,因此,对于参数x ,使用相同的值调用函数f两次会产生相同的结果f(x)。

image-20210331205301651.png

函数式编程必须满足2个特性:

1.纯函数

å即对于函数相同的输入都将返回其相同的输出,意思就是函数传入一个参数必须返回经过计算后得到的数据,且不依赖外部环境,例:

const percentValue = 5
// 非纯函数,依赖外部变量percentValue
const calculateTax = (val) => val / 100 * (100 + percentValue)

calculateTax不是纯函数,依赖外部变量percentValue,这样使用的坏处是在测试calculateTax时具有不确定性,万一percentValue在外部变化了,就会导致测试结果与预期的不一致。

let global = 'globalValue'
// 非纯函数,改变了外部变量
const badFunction = (val) => {
	global = 'changed'
	return val * 2
}

badFunction不是纯函数,因为它改变了外部变量global,有极强的副作用,万一其他函数正在使用global,就会改变其他函数的计算结果。

// 是纯函数
const double = (i) => 2*i
// 返回4
double(2)

2.用声明式代码不用命令式代码

// 命令式代码
const arr = [1, 2, 3]
for (let i = 0; i< arr.length; i++) {
    console.log(arr[i])
}

// 声明式代码
const arr = [1, 2, 3]
arr.forEach(value => {
    console.log(value)
})

意思就是声明式代码是命令式代码的能力的封装,使用起来更简单优雅一些,命令式代码关心如何做,而声明式代码只关心做什么,下面我们来看下forEach是如何实现的,利用声明式的思想去封装forEach

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

const arr = [1, 2, 3]
forEach(arr, value => {
    console.log(value)
})

与面向对象编程的区别

面向对象编程关注于定义事物(对象)和可以对它们执行的操作(方法)。它把对象当作主要的抽象概念,所以你会发现自己用名词和动词来思考你可以对它们做什么。对象倾向于保持可变状态,并且操作该状态的函数在样式上通常是命令式的。

函数式编程将函数视为主要的抽象。函数是独立的,而不是任意地为每个操作都需要一个对象。重点是编写可重用的、可组合的函数,并将底层数据结构的细节作为同等或次要的。纯函数样式将函数视为数学函数,避免了可变状态和命令式代码带来的副作用。

详细了解请移步blog.csdn.net/hujutaoseu/…

高阶函数

高阶函数是接收另一个函数作为参数或返回一个函数的函数,注意这里是或,不是且,也就是说只要函数作为参数传入就是高阶函数,或直接返回一个函数也是高阶函数。下面我们来看一个高阶函数的例子:

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

const people = [
    {firstname: "aaFirstName", lastname: "cclastName"},
    {firstname: "ccFirstName", lastname: "aalastName"},
    {firstname:"bbFirstName", lastname:"bblastName"}
];

console.log("通过firstname排序", people.sort(sortBy("firstname")))
console.log("通过lastname排序", people.sort(sortBy("lastname")))

sortBy直接返回一个函数,因此sortBy是高阶函数,sort以一个函数作为参数,因此sort是高阶函数。如果没有sortBy函数,每次都要重新定义比较函数,这就降低了代码重用性。

闭包与高阶函数

首先我们来回顾一下什么是闭包,简单来说就是函数里边嵌套函数,请看以下代码:

    function outer() {
        function inner(){
        }
    }

这就是闭包,其中inner称为闭包函数,那闭包的作用是什么呢?请看以下代码:

const outer = (arg) => {
    const hello = 'hello';
    return () => {
        console.log(`${hello} ${arg}`)
    }
}

const print = outer('dengshangli')
// 输出 hello dengshangli
print();

pirnt就是outer返回的函数,调用后发现它缓存了第一次传入的参数arg以及在outer里边定义的变量hello,说明闭包函数具有缓存传入参数以及外部函数变量的功能。outer函数返回了一个函数,根据我们前面的定义,说明它除了是闭包以外还是一个高阶函数,接下来我们来看2个闭包的例子:

1.once函数

once函数的作用是让函数只执行一次,我们来看看是如何实现的:

const once = (fn) => {
  let done = false;

  return function () {
    return done ? undefined : ((done = true), fn.apply(this, arguments))
  }
}

const testOnce = once((arg) => {
    console.log(arg)
})
// 打印出dengshangli
testOnce('dengshangli');
// 再执行一次,返回undefined
testOnce('dengshangli');

once里边定义了变量done,用于判断函数是否执行过,由于once是一个闭包,因此done可以被缓存,当第二次testOnce时判断已被执行过,一次不会再执行。·闭包函数使用apply的原因是恢复函数执行环境。

2.memoized函数

memoized函数的功能是让函数带有记忆功能,举一个例子,我们要做是计算一个数的阶乘,没有memoized的时候,每次都要重新计算,有了memoized,就可以缓存上次计算的结果,下次计算直接使用,从而减少了计算量,具体实现如下:

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

//递归计算阶乘
const factorial = (n) => {
    if(n === 0) {
        return 1
    }
    
    return n*factorial(n-1)
}

const fastFactorial = memoized(factorial)
// 第一次计算输出120
fastFactorial(5)
// 第二次计算输出720
fastFactorial(6)

memoized函数是一个闭包,里边定义了变量lookupTable,用于缓存上一次计算的结果,下次调用时如果存在直接从里边取,从而节省了计算的时间。

数组的函数式编程

原生数组支持已经支持forEach、map、filter等函数,都是通过函数式编程的思想去实现的,这里我们没有必要再实现一遍,这里我们重点介绍zip函数,这个函数的作用是用来连接2个数组,例:

    const arr1 = [
        {id: 1, name:'1'},
        {id: 2, name:'2'},
    ]
    const arr2 = [
        {id: 1, age:'1'},
        {id: 2, age:'2'},
    ]
    
    //我们想要得到以下数组
    const arr3 = [
        {id: 1, name:'1', age:'1'},
        {id: 2, name:'2', age:'2'},
    ]

这种情况可能会出现在后端返回给我们的数据可能是2个接口得到的,我们想要得到形如arr3的数据,就可以通过zip函数实现。

const zip = (leftArr,rightArr,fn) => {
  let results = []

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

const arr1 = [
    {id: 1, name: '1'},
    {id: 2, name: '2'},
]
const arr2 = [
    {id: 1, age: '1'},
    {id: 2, age: '2'},
]

const arr3 = zip(arr1, arr2, (arr1Item, arr2Item) => {
    if (arr1Item.id === arr2Item.id) {
        return { ...arr1Item,  ...arr2Item }
    }
})
console.log(arr3)

通过zip函数成功连接了2个数组,Math.min(leftArr.length, rightArr.length)表示只遍历长度最小的那个,因为长度长的那个剩余部分没有连接的必要。

柯里化与偏应用

1.柯里化

柯里化是把多元函数转换成一个嵌套的一元函数的过程,简单的说就是把带有多个参数的函数,通过闭包缓存,转换成只有一个参数的嵌套函数,请看以下代码:

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

const add = (x, y) => x + y
// 将二元函数转为一元函数
const addCurried = curry(add)
// 输出8
console.log(addCurried(4)(4))

curry就是柯里化函数,通过闭包缓存第一个参数及第二个参数,然后执行真正执行逻辑的函数,然后我们通过一个加法函数去测试,最后成功输出了结果。但是我们发现curry只能处理2个参数的情况,那多个参数的情况该怎么办呢?请看以下代码:

const curryN =(fn) => {
    if(typeof fn!=='function'){
        throw Error('No function provided');
    }

    return function curriedFn(...args){
    // 传入参数长度小于传入函数参数长度时才执行
      if(args.length < fn.length){
        return function(...restArgs){
          return curriedFn(...args, ...restArgs);
        };
      }

      return fn.apply(null, args);
    };
};

const add = (x, y, z) => x + y + z
// 将多元函数转为一元函数
const addCurried = curryN(add)
// 输出12
console.log(addCurried(4)(4)(4))
// 输出12
console.log(addCurried(4, 4)(4))

我们来分析以下代码,curryN内部递归执行了curriedFn,当我们执行addCurried(4)时,就等于执行curriedFn(4),返回一个function,执行addCurried(4)(4)等于执行function(4),此时arguments{0: 4}args[4],执行curriedFn(4, 4),再返回一个function,以此类推,直到执行完毕为止,最后将累加得到的arg传给最初包装的add函数,得到执行结果。返回curryN要比curry强大得多,它不仅可以将多元函数转换为一元函数,如果中间出现二元的情况情况它也是可以处理的,这点体现在addCurried(4, 4)(4)。再来看一个柯里化的实例:

const loggerHelper = (mode,initialMessage,errorMessage,lineNo) => {
  if(mode === "DEBUG")
    console.debug(initialMessage,errorMessage + "at line: " + lineNo)
  else if(mode === "ERROR")
    console.error(initialMessage,errorMessage + "at line: " + lineNo)
  else if(mode === "WARN")
    console.warn(initialMessage,errorMessage + "at line: " + lineNo)
  else 
    throw "Wrong mode"
}

let errorLogger = curryN(loggerHelper)("ERROR")("Error At Stats.js");
let debugLogger = curryN(loggerHelper)("DEBUG")("Debug At Stats.js");
let warnLogger = curryN(loggerHelper)("WARN")("Warn At Stats.js");


//for error
errorLogger("Error message",21)
//for debug
debugLogger("Debug message",233)
//for warn
warnLogger("Warn message",34)

curryN将各种错误类型缓存起来,用的时候直接调用errorLogger,是不是比之前传入多个参数简洁多了。

2.偏应用

偏应用技术是指利用闭包缓存函数的部分参数,将某个可变的参数保留到函数真正调用时传入,请看以下代码:

const partial = function (fn,...partialArgs){
  let args = partialArgs.slice(0);
  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(this, args);
  };
};

const obj = { foo: 'foo', bar: 'bar' }
JSON.stringify(obj, null, 2)
const prettyPrintJson = partial(JSON.stringify, undefind, null, 2)
prettyPrintJson(obj)

JSON.stringify(obj, null, 2)的意思是返回格式化后的JSON字符串,但参数null, 2是不变的,每次都这么调用显得不够优雅,我们希望借助偏函数缓存这2个参数,同时让obj在真正执行函数时传入。于是我们在用偏函数包裹时JSON.stringify传入undefind,在partial中发现最终undefind被替换成了obj。在每次希望执行格式化是执需prettyPrintJson(obj),非常优雅。

组合与管道

1.管道的概念

管道的概念来自Unix,通过|来连接前后内容,|被称为管道符号,前面的输出将作为后边的输入,请看以下代码:

    cat test.txt | grep 'world' | wc

cat用于读取test.txt文本内容,假如test.txt里边是hello world,控制台将输出hello wroldgrep接收cat的输出,并且返回与world关联的内容,wc计算给定单词在文本中的数量。可以看出,通过管道符号会把前一次计算的结果通过参数传给下一个命令。

2.compose函数

compose函数就是函数组合函数,借助管道的概念,接收多个函数作为参数,从最右边的参数开始执行,把执行的结果返回给下一个函数,作为下一个函数的参数,直到执行到最左边的函数为止,请看以下代码:


const compose = (...fns) => (value) => {
    return fns.reverse().reduce((acc, fn) => fn(acc), value); 
}


// 将字符串通过空格转换为数组
let splitIntoSpaces = (str) => str.split(" ");
// 统计数组的长度
let count = (array) => array.length;
// 判断数字是基数还是偶数
let oddOrEven = (ip) => ip % 2 == 0 ? "even" : "odd"
// 组合三个函数, 并且让最后一个函数暂时不执行
const oddOrEvenWords = compose(oddOrEven,count,splitIntoSpaces);
// compose满足结合律,我们还可以这么写
// var oddOrEvenWords = compose(oddOrEven,compose(count,splitIntoSpaces));
// 输出odd
console.log(oddOrEvenWords("hello your reading about composition"))

compose函数非常简单,只有一行代码,借助reduce函数,把上一个函数执行的结果传给下一个函数。与之对应的pipe,它的功能与compose一模一样,只不过它的参数执行顺序是从左向右,而不是从右向左,我们只需把compose中的fns.reverse()变为fns即可。

3.compose函数调试技巧

const showParams = (params) => {
    console.log(params)
    return params
}

const oddOrEvenWords = compose(oddOrEven,showParams,count,splitIntoSpaces);

通过在中间传入showParams函数,就可以在控制台打印出前一次执行的结果,以便于快速定位错误。

4.compose的应用

compose可以用来连接redux中间件,可以在redux源码地址中找到它,还可以用来增强store,下面示例演示了如何使用 compose 增强 store,这个 storeapplyMiddlewareredux-devtools 一起使用。

import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import DevTools from './containers/DevTools'
import reducer from '../reducers'

const store = createStore(
  reducer,
  compose(
    applyMiddleware(thunk),
    DevTools.instrument()
  )
)

函子

1.函子的概念

函子是一个普通对象(在其它语言中,可能是一个类),它实现了map函数,在遍历每个对象值的时候生成一个新对象。

const Container = function (value) {
  this.value = value
}

Container.of = function (value) {
  return new Container(value)
}

Container.prototype.map = function (fn) {
  return Container.of(fn(this.value))
}

const double = (x) => 2*x
// 返回Container {value: 12}
Container.of(3).map(double).map(double)

具有以上结构的函数被称为函子,其中map是可以通过链式调用的。

2. 函子的应用

函子的作用不仅仅是链式调用这么简单,下面我们先看一个例子,要判断一个变量是否为空,不为空的话就变量转变为大写,用命令式的写法如下:

let value = 'string'
if (value !== undfind && value !== null) {
	return value.toUpperCase()
}

有了函子之后,我们不必去判断变量是否为空,可以把这些细节影藏起来,通过使用MayBe函子,我们就不用去关心传入的变量是不是空,下面看一下MayBe函子是如何实现的。

const MayBe = function(val) {
    this.value = val
}

MayBe.of = function(val) {
    return new MayBe(val)
}

MayBe.prototype.isNothing = function() {
    return this.value === null || this.value === undefined
}

MayBe.prototype.map = function(fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this.value))
}

// 将字符串变为大写并且新增一些内容
const toUpperCase = (value) => {
  // 返回MayBe {value: '我是新增内容 STRING'}
  return MayBe.of(value).map((v) => v.toUpperCase()).map((v) => `我是新增内容 ${v}`)
}
toUpperCase('string')

我们在MayBe函数的原型链上新增一个isNothing函数,用于判断当前值是否为nullundefined,如果是就返回true,并且在map里边调用了它,如果返回true就调用MayBe.of(null)将当前值设为null,以后无论调用多少次map,所传的函数都不会执行了值到最终都是null。由此可见MayBe可以用来处理在编码中遇到的传值错误的情况,而不是将错误直接抛给浏览器。

函数式编程库—Ramda

Why Ramda?

目前已经存在许多优秀的函数式的库。通常它们作为通用工具包,可以用于多种编程范式。Ramda 的目标更为专注:专门为函数式编程风格而设计,更容易创建函数式 pipeline、且从不改变用户已有数据。

What's Different?

Ramda 主要特性如下:

  • Ramda 强调更加纯粹的函数式风格。数据不变性和函数无副作用是其核心设计理念。这可以帮助你使用简洁、优雅的代码来完成工作。
  • Ramda 函数本身都是自动柯里化的。这可以让你在只提供部分参数的情况下,轻松地在已有函数的基础上创建新函数。
  • Ramda 函数参数的排列顺序更便于柯里化。要操作的数据通常在最后面。

最后两点一起,使得将多个函数构建为简单的函数序列变得非常容易,每个函数对数据进行变换并将结果传递给下一个函数。Ramda 的设计能很好地支持这种风格的编程。

官网入口

ramda.cn/docs/

参考书籍