JavaScript中的函数式编程

937 阅读23分钟

前言

为什么要学习函数式编程

解释之前,我们先介绍下常见的编程范式:

  1. 面向过程编程(Procedural Programming):将程序分解为一系列过程、方法或函数,强调通过修改和操作全局变量来控制程序的执行流程。
let x = 5;
let y = 10;

let result = x + y;
console.log(result); // 输出 15
  1. 面向对象编程(Object-Oriented Programming,OOP):将程序组织为对象的集合,通过封装、继承和多态等概念来实现代码的可重用性和模块化。
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

let rectangle = new Rectangle(5, 10);
let area = rectangle.calculateArea();
console.log(area); // 输出 50
  1. 函数式编程(Functional Programming,FP):将计算视为数学函数的求值过程,避免共享状态和可变数据,强调不可变性和纯函数的使用。
const numbers = [1, 2, 3, 4, 5];

// 使用高阶函数 map 对数组中的每个元素进行平方操作
const squaredNumbers = numbers.map(x => x * x);
console.log(squaredNumbers); // 输出 [1, 4, 9, 16, 25]

// 使用高阶函数 reduce 计算数组中所有元素的和
const sum = numbers.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
console.log(sum); // 输出 15
  1. 声明式编程(Declarative Programming):以描述问题的方式来表达程序的逻辑,而非指定如何解决问题。包括逻辑编程、约束编程和数据库查询语言等。
const numbers = [1, 2, 3, 4, 5];

// 使用 filter 声明式地筛选出偶数
const evenNumbers = numbers.filter(x => x % 2 === 0);
console.log(evenNumbers); // 输出 [2, 4]

// 使用 reduce 声明式地计算所有偶数的和
const sumOfEvenNumbers = numbers.filter(x => x % 2 === 0).reduce((a, b) => a + b, 0);
console.log(sumOfEvenNumbers); // 输出 6

与其他编程范式相比的优点是:

  1. 简洁性:函数式编程强调使用纯函数,这些函数没有副作用且只依赖于输入。
  2. 不可变性:函数式编程鼓励使用不可变数据结构,即不能被修改的数据结构。这种方式可以避免意外的状态变化,减少了错误发生的可能性。
  3. 高阶函数:函数式编程支持高阶函数,即函数可以作为参数传递给其他函数,或者函数可以返回另一个函数作为结果。这种能力可以提高代码的模块化和复用性,并且可以实现一些强大的编程技巧,如函数组合和柯里化等。
  4. 并行和异步编程:函数式编程天然适合并行和异步编程,因为纯函数没有副作用且不依赖于共享状态。这种方式使得并行执行函数成为可能,提高程序的性能和可扩展性。
  5. 易于测试和调试:由于函数式编程强调不可变性、纯函数和模块化,代码的测试和调试变得更加容易。因为函数之间没有副作用和相互依赖,我们可以更轻松地测试和验证函数的行为。

总的来说,学习函数式编程可以帮助你提高代码质量、可读性和可维护性,所以函数式编程都是值得学习的一种编程范式。

1. 什么是函数式编程

1.1 概念

函数式编程是一种编程范式,是一种以函数为基本构建块,并将计算视为函数求值的方式。在函数式编程中,函数是一等公民,程序被看作是一系列函数的组合,通过对输入的处理和转换来生成输出结果,而不是通过修改共享状态。

1.2 特点

  1. 纯函数(Pure Functions): 函数式编程鼓励使用纯函数,即没有副作用(side effects)的函数。纯函数的输出只依赖于输入,不会改变外部状态,也不会对系统环境造成影响。纯函数易于理解、测试和推理,并且能够更好地支持并行计算和代码优化。

  2. 不可变性(Immutability): 函数式编程强调数据的不可变性,即创建后不可修改。当需要修改数据时,函数式编程倾向于创建新的数据副本来代替直接修改原始数据。这种方式可以避免共享状态和意外副作用,使得代码更加可靠、简单和易于推理。

  3. 高阶函数(Higher-Order Functions): 函数式编程支持将函数作为值传递给其他函数,或者将函数作为返回值。高阶函数能够接受一个或多个函数作为输入参数,也能够返回一个函数作为输出结果。这种能力使得函数能够被抽象、组合和扩展,更灵活地处理复杂的逻辑和操作。

  4. 函数组合(Function Composition): 函数式编程鼓励使用函数组合来构建复杂的功能。函数组合是将多个函数按照一定的规则组合在一起,生成一个新的函数。这种方式可以将复杂的问题拆解成简单的函数,每个函数专注于特定的功能,易于理解和调试。

  5. 惰性计算(Lazy Evaluation): 函数式编程支持惰性计算,即在需要的时候才计算表达式的值,而不是立即计算。这种方式可以提高性能,避免不必要的计算和资源浪费。

2. 函数式编程基础

2.1 理解函数式编程

函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y=sin(x),x和y的关系

  • 相同的输入始终要得到相同的输出(纯函数)
  • 函数式编程用来描述数据(函数)之间的映射
// 非函数式
let num1 = 1;
let num2 = 2;
let sum = num1 + num2;

// 函数式 (纯函数)
function sum(n1, n2) {
  return n1 + n2;
}

// 非纯函数 n1不可控
let n1 = 10
function sum(n2) {
  return n1 + n2;
}

2.2 函数是一等公民(First-class Function)

当一门编程语言的函数可以被当作变量一样使用。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。

函数是一等公民有以下特点:

  • 高阶函数
    • 函数作为参数
    • 函数作为返回值
  • 函数可以赋值给变量

在JavaScript中函数就是一个普通的对象,可以通过new Function(),我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过new Function(alert(1)")来构造一个新的函数。

案例:

// 优化前
const BlogController = {
    index(posts){ return Views.index(posts)},
    show(post){ return Views.show(post)},
}
// 优化后
const BlogController = {
    index:Views.index,
    show:Views.show,
}

2.3 高阶函数(Higher-order function)

2.3.1 函数作为参数传递给另一个函数

我们平常使用的forEach、filter方法就是高阶函数,我们可以手写一个forEach、filter方法。

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

const arr = [1, 2, 3]
forEach(arr, (item) => {
    console.log('打印***item',item)
})

function filter(array, fn) {
    let result = []
    for (let i = 0; i < array.length; i++) {
        if (fn(array[i])) {
            result.push(array[i])
        }		
    }
    return result
}

console.log(filter(arr, (item) => item % 2 === 0));

2.3.2 函数作为另一个函数的返回结果

看一个场景:比如支付时限制只能支付一次,我们需要写一个once函数。

// once函数 只会执行一次 带参数
function once(fn) {
    let done = false
    return function () {
        if (!done) {
            done = true
            fn.apply(this,arguments)
        }
    }
}


const pay = once(function (money) {
    console.log(`支付:${money} RMB`);
})

pay(10)
pay(10)
pay(10)

多次执行,只会触发一次,就是利用函数作为返回值。

2.3.3 高阶函数的意义

  • 抽象可以帮我们屏蔽细节,只需要关注与我们的目标
  • 高阶函数是用来抽象通用的问题

常用的高阶函数

数组的方法forEach、map、filter、every、some、find/findIndex、reduce、sort等...

模拟实现三个函数实现 map、every、some

const map = (array, fn) => {
    let result = []
    for (const value of array) {
        result.push(fn(value))
    }
    return result
}
let arr = [1, 2, 3, 4]
arr = map(arr, (v) => v * v)
console.log('打印***arr',arr)


const every = (array, fn) => {
    let result = true
    for (let i = 0; i < array.length; i++) {
        result = fn(array[i])
        if (!result) {
            break;
        }		
    }
    return result
}
const flag = every(arr, v => v > 0)
console.log('打印***flag', flag)


const some = (array, fn) => {
    let result = false
    for (let value of array) {
        result = fn(value)
        if (result) {
            break
        }
    }
    return result
}
const hasEven = some(arr, v => v % 2 === 0)
console.log('打印***hasEven',hasEven)

2.4 闭包 (Closure)

简单来说一个函数的返回值是一个函数,返回的函数引用了外部函数的变量,就产生了闭包。

闭包的本质:函数在执行的时候会放到一个执行栈上当函数执行完毕之后会从执行栈上移除,但是堆上的作用域成员。因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

案例

函数调用栈 默认放在一个匿名函数中执行 顶层作用域是window对象

// 求某个数的二次方,始终要指定第二个参数
function makePower(power){
    return function(number){
        return Math.pow(number,power)
    }
}


let power2 = makePower(2)
let power3 = makePower(3)

console.log(power2(4));
console.log(power2(3));
console.log(power3(2));

用匿名函数执行,再执行makePower

image.png

运行power2时会产生闭包 image.png

整体过程如下:

image.png

可以通过闭包的特性实现惰性计算。

例如:计算员工的工资,基本工资有相同的,绩效不同,可对基本工资的进行缓存,返回新的函数。

基本工资绩效
120002000
150003000
150004000
// 如果将基本工资固定,只写绩效工资
function makeSalary(base){
    return function(bonus){
        return base + bonus
    }
}

let salaryLevel1 = makeSalary(12000)
let salaryLevel2 = makeSalary(15000)

console.log(salaryLevel1(2000));
console.log(salaryLevel2(3000));
console.log(salaryLevel2(4000));

2.5 纯函数 (pure functions)

相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。类似数学中的函数(用来描述输入和输出之间的关系),y=(x)

image.png

lodash是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法

  • 数组的slice和splice分别是:纯函数和不纯的函数
    • slice返回数组中的指定部分,不会改变原数组
    • splice对数组进行操作返回该数组,会改变原数组
const arr = [1, 2, 3, 4, 5];

// 纯函数
console.log(arr.slice(0, 3));
console.log(arr.slice(0, 3));
console.log(arr.slice(0, 3));

// 不纯的函数
console.log(arr.splice(0, 3));
console.log(arr.splice(0, 3));
console.log(arr.splice(0, 3));

// 一个纯函数
function sum(n1, n2) {
  return n1 + n2;
}
console.log(sum(1, 2));
console.log(sum(1, 2));
console.log(sum(1, 2));

image.png

  • 函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的)

  • 我们可以把一个函数的执行结果交给另一个函数去处理

Lodash - 纯函数的代表

lodash中的一些函数

const _ = require("lodash")

const array = ['jack', 'tom', 'lucy', 'kate']

console.log(_.first(array),_.head(array));
console.log(_.last(array));
console.log(_.toUpper(_.first(array)));
console.log(_.reverse(array));


const r = _.each(array, (item, index) => {
    console.log(item,index);
})

console.log(r);


console.log(_.includes(array,"jack"));
console.log(_.find(array,name=>name==="kate"));
console.log(_.findIndex(array,name=>name==="lucy"));

image.png

纯函数的优势

  1. 可缓存
  • 因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来

lodash中:

const _ = require("lodash");

function getArea(r) {
  console.log(r);
  return Math.PI * r * r;
}

const getAreaWithMemory = _.memoize(getArea);
console.log(getAreaWithMemory(4)); //4 50.26548245743669
console.log(getAreaWithMemory(4)); // 50.26548245743669
console.log(getAreaWithMemory(4)); // 50.26548245743669

自己模拟一个memoize函数

function getArea(r) {
  console.log(r);
  return Math.PI * r * r;
}
// 模拟memoize方法实现
function memoize(fn) {
let cache = {}
return function () {
    let key = JSON.stringify(arguments) // key使用输入
    cache[key] = cache[key] || fn.apply(this, arguments)
    return cache[key]
    }
}


const getAreaWithMemory = memoize(getArea);
console.log(getAreaWithMemory(4)); //4 50.26548245743669
console.log(getAreaWithMemory(4)); // 50.26548245743669
console.log(getAreaWithMemory(4)); // 50.26548245743669

  1. 可测试

纯函数让测试更方便

  1. 并行处理
  • 在多线程环境下并行操作共享的内存数据很可能会出现意外情况
  • 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数(Web Worker)

2.6 副作用

相同的输入永远会得到相同的输出,而且没有任何可观察的副作用

// 有副作用的
let mini = 10
function checkAge(age) {
    return age>=min
}


// 纯函数(存在硬编码,可用柯里化解决)
function checkAge1(age) {
    let mini = 18
    return age>=mini
}

副作用让一个函数变的不纯(如上例),纯函数是根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。

副作用来源:

  • 配置文件
  • 数据库
  • 获取用户的输入
  • ...

所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。

2.7 柯里化(Haskell Brooks Curry)

2.7.1 柯里化解决硬编码问题

使用柯里化解决上一个案例中硬编码的问题

// 硬编码问题
function checkAge(age) {
    let mini = 18
    return age>=mini
}
console.log(checkAge(20))

// mini 提取到参数的位置 变成纯函数
function checkAge(min, age) {
    return age>=min
}

console.log(checkAge(18,20));
console.log(checkAge(18,21));

// 固定基准值 函数柯里化
function checkAge(min) {
    return function(age){
        return age>=min
    }
}


let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)

console.log(checkAge18(21));
console.log(checkAge20(18));

柯里化(Currying):

  • 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变)
  • 然后返回一个新的函数接收剩余的参数,返回结果

2.7.2 lodash中的柯里化

  • _.curry(fun c)
    • 功能:创建一个函数,该函数接收一个或多个fun c的参数,如果fun c所需要的参数都被提供则执行fun c并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
    • 参数:需要柯里化的函数
    • 返回值:柯里化后的函数
const _ = require('lodash')

// 需要柯里化的函数
function sum(a, b, c) {
	return a + b + c
}

// 柯里化后的函数

const curried = _.curry(sum)

console.log(curried(1)(2)(3));
console.log(curried(1,2)(3));
console.log(curried(1)(2,3));
console.log(curried(1,2,3));

案例: 提取所有的空白字符

// 希望写一个函数,根据规则匹配
function match(reg,str) {
	return str.match(reg)
}

// 柯里化处理
const match = _.curry(function (reg, str) {
  return str.match(reg);
});

const haveSpace = match(/\s+/g);
console.log(haveSpace("hello world!"));

const haveNumber = match(/\d+/g);
console.log(haveNumber("asdf 23423"));

// 实现一个过滤函数
const filter = _.curry(function (fn, array) {
  return array.filter(fn);
});

const arr = ["kate", " jack", "haha haha"];
console.log(filter(haveSpace, arr));
// 使用柯里化的方式
const findSpace = filter(haveSpace);
console.log(findSpace(arr));

2.7.3 柯里化实现原理

const _ = require("lodash");

// 需要柯里化的函数
function sum(a, b, c) {
  return a + b + c;
}

// 柯里化后的函数

const curried = _.curry(sum);

console.log(curried(1)(2)(3));
console.log(curried(1, 2)(3));
console.log(curried(1)(2, 3));
console.log(curried(1, 2, 3));

// 实现柯里化
function curry(fn) {
  return function curriedFn(...args) {
    // 判断实参和形参的个数 函数的参数数量可以通过length属性获取
    if (args.length < fn.length) {
        // 参数不一致需要返回一个新的函数
    return function () {
        // 新的函数下次调用传入的参数,需要递归调用返回
        return curriedFn(
          ...args.concat(Array.from(arguments))
        );
      };
    }
    return fn(...args);
  };
}

const curriedSelf = curry(sum);

console.log(curriedSelf(1)(2)(3));
console.log(curriedSelf(1, 2)(3));
console.log(curriedSelf(1)(2, 3));
console.log(curriedSelf(1, 2, 3));


2.7.4 小结

  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数
  • 这是一种对函数参数的缓存
  • 让函数变的更灵活,让函数的粒度更小
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

3. 函数式编程进阶

3.1 函数组合

  • 纯函数和柯里化很容易写出洋葱代码h(s(f(x)))

  • 获取数组的最后一个元素再转换成大写字母,

    • .to upper(.first(_.reverse(array)))
  • 函数组合可以让我们把细粒度的函数重新组合生成一个新的函数

image.png

过程可以表示为:b = fn(a)

image.png

// 先组合
fn = compose(f1,f2,f3)
// 再使用
b= fn(a)
 

函数组合

  • 函数组合(compose):如果一个函数要经过多个函数处理才能得到晨终值,这个时候可以把中间过程的函数合并成一 个函数

  • 函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果

  • 函数组合默认是从右到左执行

案例: 求数组最后一个元素,先翻转 取第一个元素

function reverse(array) {
  return array.reverse();
}

function first(array) {
  return array[0];
}

// 不使用函数组合
let r = first(reverse(array))


// 函数组合使用
// 定义组合函数 暂时只支持2个函数
function compose(f, g) {
  return function (value) {
    return f(g(value));
  };
}

const last = compose(first, reverse);
console.log(last([1,2,3]));

3.1.1 lodash中的组合函数

  • lodash中的组合函数
    • lodash中组合函数flow()或者flow Right(),他们都可以组合多个函数
    • flow()是从左到右运行
    • flowRight()是从右到左运行,使用的更多一些
const _ = require("lodash");

const reverse = (arr) => arr.reverse();
const first = (arr) => arr[0];
const toUpper = (s) => s.toUpperCase();

const f = _.flowRight(toUpper, first, reverse);

const arr = ["one", "two", "three"];
console.log(f(arr));

3.1.2 组合函数实现原理

const reverse = (arr) => arr.reverse();
const first = (arr) => arr[0];
const toUpper = (s) => s.toUpperCase();

const arr = ["one", "two", "three"];

function compose(...args) {
	return function (value) {
		return args.reverse().reduce(function (acc, fn) {
			return fn(acc)
		},value)
	}
}

const f = compose(toUpper, first, reverse);
console.log(f(arr));

3.1.3 函数组合的结合律(associativity)

  • 我们既可以把g和h组合,还可以把f和g组合,结果都是一样的
const f = compose(f,g,h)
const associative = compose(compose(f,g),h) == compose(f,compose(g,h)) // true

例如:全部使用lodash中的方法

const _ = require("lodash");

const f = _.flowRight(_.toUpper, _.first, _.reverse);

const f1 = _.flowRight(_.flowRight(_.toUpper,_.first),_.reverse)
const f2 = _.flowRight(_.toUpper,_.flowRight(_.first,_.reverse))

const arr = ["one", "two", "three"];
console.log(f(arr));
console.log(f1(arr));
console.log(f2(arr));

3.1.4 如何调试函数组合

案例:将字符转换 NEVER SAY DIE -> never-say-die

// 先空格split切割成数组,调用toLower变成小写,然后进行join分隔

const _ = require('lodash')
// 因为使用组合函数,数据是最后进行传入,所以需要柯里化进行数据位置变化
const split = _.curry((sep,str)=>_.split(str,sep))
const join = _.curry((sep,arr)=>_.join(arr,sep))
const compose = _.flowRight( join('-'),_.toLower,split(' '))
console.log(compose("NEVER SAY DIE"));//n-e-v-e-r-,-s-a-y-,-d-i-e

输出有问题,无法定位问题的发生的位置

  • 可在组合函数中加入打印的日志
const log = (v) => {
  console.log(v);
  return v;
};
const composeLog = _.flowRight(join("-"),log,_.toLower,log,split(" "));
console.log(composeLog("NEVER SAY DIE"));//n-e-v-e-r-,-s-a-y-,-d-i-e
  • 尽管可以打印,但是位置不清晰,可以使用柯里化进行包装
const trace = _.curry((tag, v) => {
    console.log(tag, v);
    return v
})

const f = _.flowRight(
  join("-"),
  trace('map 之后'),
  _.toLower,
  trace('split 之后'),
  split(" ")
);
console.log(f("NEVER SAY DIE"));//n-e-v-e-r-,-s-a-y-,-d-i-e

打印结果发现是toLower位置出现问题,使用map进行柯里化

const _ = require('lodash')
// 因为使用组合函数,数据是最后进行传入,所以需要柯里化进行数据位置变化
const split = _.curry((sep,str)=>_.split(str,sep))
const join = _.curry((sep,arr)=>_.join(arr,sep))
const map = _.curry((fn, array) => _.map(array, fn))

const trace = _.curry((tag, v) => {
    console.log(tag, v);
    return v
})

const f = _.flowRight(
  join("-"),
  trace('map 之后'),
  map(_.toLower),
  trace('split 之后'),
  split(" ")
);
console.log(f("NEVER SAY DIE"))

结果返回正常。

通过上面的案例,我们会发现函数的入参位置不同,每次都需要柯里化,那么有没有现成的方式解决。

3.2 lodash的fp模块

3.2.1 fp介绍

  • lodash的fp模块提供了实用的对函数式编程友好的方法
  • 提供了不可变auto-curried iterate-first data-last的方法即自动柯里化,函数优先,数据最后原则。

看一个案例:不使用fp模块是数据优先,使用fp模块是函数优先

// 数据优先
const _ = require("lodash");
const arr = ["a", "b", "c"];
console.log(_.map(["a", "b", "c"], _.toUpper));// 数组每一项转换成大写

const str = "hello world";
console.log(_.split(str, " "));

// 数据最后
const fp = require("lodash/fp");
console.log(fp.map(_.toUpper, arr));
console.log(fp.map(arr, _.toUpper));
console.log(fp.split(" ")(str));
console.log(fp.split(" ", str));

改造之前的组合函数的案例,因为fp模块自带柯里化所以可以简化:

const _ = require("lodash/fp");
// 因为使用组合函数,数据是最后进行传入,所以需要柯里化进行数据位置变化   进行改造
const split = _.curry((sep, str) => _.split(str, sep));
const join = _.curry((sep, arr) => _.join(arr, sep));
const map = _.curry((fn, array) => _.map(array, fn));

const trace = _.curry((tag, v) => {
  console.log(tag, v);
  return v;
});

const f = _.flowRight(
  join("-"),
  trace("map 之后"),
  map(_.toLower),
  trace("split 之后"),
  split(" ")
);

// 改造后 不需要自己柯里化 ,自动柯里化,函数优先,数据最后
const trace = _.curry((tag, v) => {
  console.log(tag, v);
  return v;
});

const ffp = _.flowRight(
  fp.join("-"),
  trace("map 之后"),
  fp.map(fp.toLower),
  trace("split 之后"),
  fp.split(" ")
);

3.2.2 lodash和lodash/fp模块方法的区别

将数组的每一项转换成整数

// 原始
const _ = require("lodash");
console.log(_.map(["23", "8", "10"], parseInt)); // [ 23, NaN, 2 ]


// fp模块 中map只有一个参数
const fp = require("lodash/fp");
console.log(fp.map(parseInt, ["23", "8", "10"])); // [ 23, 8, 10 ]

解析:

  • _.map(value, index|key, collection)三个参数
parseInt('23',0,array) // 默认十进制
parseInt('8',1,array) // 没有那个
parseInt('10',2,array) // 有二进制
parseInt(str,2-36进制)
所以出现NaN情况
  • fp.map中parseInt只接受一个参数 */

3.2 PointFree

是一种编程风格,它指的是一种不直接引用操作对象的方式来定义函数。换句话说,就是通过组合已经存在的函数来定义新的函数,而不使用命名的中间变量。

PointFree风格的代码更加简洁、封装和可读性强,它将函数的实现与输入输出的具体类型分离开来,使得代码更具有通用性和灵活性。在PointFree编程中,函数只关注输入和输出之间的映射关系,而不涉及具体的数据操作。

以下是一个示例以演示PointFree风格的函数定义:

// 非PointFree风格的函数定义
function addOne(n) {
  return n + 1;
}

// PointFree风格的函数定义
const addOne = (n) => n + 1;

在上述示例中,非PointFree风格的函数addOne使用了中间变量来存储操作结果,而PointFree风格的函数addOne直接定义了对输入参数的操作。

案例演示:

// 案例: 把一个字符串中的首字母提取,转换成大写,使用.作为分隔符
// hello world web => W. W. W.
const str = "hello world web";

const f2 = fp.flowRight(
  fp.join("_ "),
  fp.map(fp.toUpper),
  fp.map(fp.first),
  fp.split(" ")
);
console.time('f1')
console.log(f2(str));
console.timeEnd('f1')

// 两个map可以进行合并,只用flowRight进行封装成新的函数
console.time('f2')
const f3 = fp.flowRight(fp.join("_ "),fp.map(fp.flowRight(fp.toUpper,fp.first)),fp.split(" "))
console.timeEnd('f2')
console.log(f3(str));

image.png

我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 定义一些辅助的基本运算函数

3.4 函子 (Functor)

3.4.1 什么是函子

到目前为止已经已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控的范围内、异常处理、异步操作等。

什么是函子:包含值和值的变形关系(这个变形关系就是函数),一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)。

在函数式编程中,函子(Functor)是一个特殊的对象(或数据结构),它封装了对值的处理过程。函子允许我们在不直接操作值的情况下对其进行转换、映射和组合。

函子通常被定义为包含以下两个重要方法的对象:

  1. map:将一个函数应用于函子中的值,并返回一个新的函子,该函子包含了经过函数处理后的新值。
  2. of(有时也称为pure):将一个普通的值放入到函子中。

通过使用map方法,我们可以对函子中的值进行变换,并得到一个新的函子。这种方式确保了我们可以避免直接操作函子中的值,从而提高代码的可维护性和可重复使用性。

pointed函子

  • Pointed函子是实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用map来处理值)
class Container {
	static of(value) {
		return new Container(value);
	}

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return new Container(fn(this._value));
  }
}

const f = Container.of(3)
  .map((v) => v * v)

案例:

// Functor 函子
class Container {
  constructor(value) {
    this._value = value;
  }

  /** 创立新的函子 处理的结果给新的函子 */
  map(fn) {
    return new Container(fn(this._value));
  }
}

const c = new Container(5)
  .map((v) => v * 2)
  .map((v) => v + 1)
  .map((v) => v - 10);
console.log(c);

// 函数式编程 避免new
class Container {
  constructor(value) {
    this._value = value;
  }

  static of(value) {
      return new Container(value);
  }

  map(fn) {
    return new Container(fn(this._value));
  }
}

const f = Container.of(3)
  .map((v) => v * v)
  .map((v) => v - 10)
	.map((v) => v + 1);
	
	console.log(f);
  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了map契约的对象
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数),由这个函数来对值进行处 理
  • 最终map方法返回一个包含新值的盒子(函子)

存在的问题:

先看一个问题,如果传入的值是null或者是undefined时,会出现报错

class Container {
  constructor(value) {
    this._value = value;
  }

  static of(value) {
    return new Container(value);
  }

  map(fn) {
    return new Container(fn(this._value));
  }
}

// 演示null undefined的问题
const c1 = Container.of(null).map((v => v.toUpperCase()));

console.log(c1);

引入MayBe函子。

MayBe函子

MayBe函子:

  • 我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理
  • MayBe函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)

案例:

// 传递null 和 undefined
class MayBe {
  static of(value) {
    return new MayBe(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return this.isNothing()
      ? MayBe.of(null)
      : MayBe.of(fn(this._value));
  }

  isNothing() {
    return (
      this._value === null || this._value === undefined
    );
  }
}

const r = MayBe.of("hello world").map((v) => v.toUpperCase());
console.log(r);
const r1 = MayBe.of(null).map((v) => v.toUpperCase());
console.log(r1);

也存在问题:哪一次出现空值是不清楚的

const r2 = MayBe.of("hello world")
  .map((v) => v.toUpperCase())
  .map((v) => null)
  .map((v) => v.split(" "));
console.log(r2);

引入Either函子。

Either函子

  • Ether两者中的任何一个,类似于if..else...的处理
  • 异常会让函数变的不纯,Either函子可以用来做异常处理
// 需要定义两种类型
// 失败处理
class Left {
  static of(value) {
    return new Left(value);
  }

  constructor(value) {
    this._value = value;
  }
  // 嵌入错误消息
  map(fn) {
    return this;
  }
}

// 成功处理
class Right {
  static of(value) {
    return new Right(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return Right.of(fn(this._value));
  }
}

let r1 = Right.of(12).map((v) => v + 2);
let l1 = Left.of(12).map((v) => v + 2);

console.log(l1); // Left { _value: 12 }
console.log(r1); // Right { _value: 14 }

function parseJSON(str) {
  try {
    return Right.of(JSON.parse(str));
  } catch (err) {
    return Left.of({ err: err.message });
  }
}

console.log(parseJSON(`{"name":'jack'}`)); // Left { _value: { err: "Unexpected token ' in JSON at position 8" } }
console.log(parseJSON(`{"name":"jack"}`)); // Right { _value: { name: 'jack' } }
console.log(parseJSON(`{"name":"jack"}`).map((v) => v.name.toUpperCase())); // Right { _value: 'JACK' }

IO 函子

  • lO函子中的_value是一个函数,这里是把函数作为值来处理
  • IO函子可以把不纯的动作存储到_value中,延迟执行这个不纯的操作(情性执行),包装当前的操作纯函数
  • 把不不纯的操作交给调用者来处理
const fp = require('lodash/fp')

class IO {
  // 传入value值使用纯函数进行包裹 返回IO函子
  static of(value) {
    return new IO(function () {
      return value;
    });
  }

  constructor(fn) {
    this._value = fn;
  }

  // map作用是将每次map传入的fn,与之前的函子中保存的_value进行组合,形成一个新的IO函子 类似fp.flowRight(fp.flowRight(fn,this._value),this._value)
  map(fn) {
    return new IO(fp.flowRight(fn, this._value));
  }
}


// 始终是纯函数操作
let r = IO.of(process).map(p=>p.execPath)
// 不纯的操作延迟到调用时执行
console.log(r._value());

Task异步执行

  • 异步任务的实现过于复杂,我们使用folktale中的Task来演示
  • folktale一个标准的函数式编程库
    • 和lodash、ramda不同的是,他没有提供很多功能函数
    • 只提供了一些函数式处理的操作,例如:compose、curry等,一些函子Task、Either、MayBe等

folktale中的curry和compose的使用

const { compose, curry } = require("folktale/core/lambda");
const {first,toUpper} = require('lodash/fp')

// 柯里化几个参数
const f = curry(2, (x, y) => x + y);
console.log(f(1, 2), f(1)(2));

// 组合函数
const c = compose(toUpper,first)

console.log(c(['one','two']));

Task案例

读取package.json中的version

// Task处理异步任务


const { task } = require("folktale/concurrency/task");
const fs = require("fs");
const { split, find } = require("lodash/fp");

// 读取文件
function readFile(filename) {
  return task((resolver) => {
    fs.readFile(filename, "utf-8", (err, data) => {
      if (err) resolver.reject(err);
      resolver.resolve(data);
    });
  });
}

// 通过run进行调用,listen监听,onResolved接受返回的数据,此处只接受数据,task函子提供map方法进行处理数据
let r = readFile("package.json")
	.map(split('\n'))
	.map(find(v=>v.includes('version')))
  .run()
  .listen({
    onRejected: (err) => {
      console.log("打印***err", err);
    },
		onResolved: (value) => {
			// 如果这里处理数据,就失去了函数式编程的意义
      console.log("打印***value", value);
    },
  });

console.log("打印***r", r);

Monad 单子

解决IO函子的问题

const fp = require('lodash/fp')
const fs = require('fs')

class IO {
  static of(value) {
    return new IO(function () {
      return value;
    });
  }

  constructor(fn) {
    this._value = fn;
  }

  map(fn) {
    return new IO(fp.flowRight(fn, this._value));
  }
}


// 读取文件的函数 然后打印 cat
const readFile = function (filename) {
	return new IO(function () {
		return fs.readFileSync(filename,'utf-8')
	})
}

const print = function (s) {
	return new IO(function () {
		console.log('s',s);
		return s
	})
}


const cat = fp.flowRight(print, readFile)
// 嵌套的IO    printIO(readFileIO(s)) 外层是print返回的函子 内层是readFile的函子
let r = cat('package.json')

console.log(r._value()._value());


image.png

嵌套关系 image.png

问题: 函子有嵌套 调用非常不方便

Monad

  • Monad函子是可以变扁的Pointed函子,IO(IO(x)
  • 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
const fp = require("lodash/fp");
const fs = require("fs");

class IO {
  static of(value) {
    return new IO(function () {
      return value;
    });
  }

  constructor(fn) {
    this._value = fn;
  }

  map(fn) {
    return new IO(fp.flowRight(fn, this._value));
  }
  // 解决嵌套调用
  join() {
    return this._value();
  }
  // 就是将map和join进行结合,将上一次返回的IO函数自己调用
  flatMap(fn) {
    return this.map(fn).join();
  }
}

// 读取文件的函数 然后打印 cat
const readFile = function (filename) {
  return new IO(function () {
    return fs.readFileSync(filename, "utf-8");
  });
};

const print = function (s) {
  return new IO(function () {
    console.log('s',s);
    return s;
  });
};

const cat = fp.flowRight(print, readFile)
// 嵌套的IO    printIO(readFileIO(s)) 外层是print返回的函子 内层是readFile的函子
let r = cat('package.json')
// console.log(r._value()._value());
// 函子有嵌套 调用非常不方便

// 当返回的是函子使用flatMap 否则使用map
/**
	执行过程:
	readFile返回IO函子
	使用flatMap:此时this._value是readFile生成的函子,fn是将print生成的IO函子 返回两个组合后的
	new IO(fp.flowRight(fn, this._value))._value
 */
const res = readFile("package.json")
  // .map((x) => x.toUpperCase()) //处理数据
  // .map(fp.toUpper) // 使用模块化工具
  .flatMap(print)
  .join();
// 结果处理
console.log(res);



4. 总结

最后总结一波,本文主要讲解实践了函数式编程中的常见用法。如图所示:

image.png

希望对大家有一点帮助,如有错误,请指正。^O^