🌒

Hi Folks.

函数式编程

引言:函数式编程(Functional Programming),简称 FP,是一种编程范式。

它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,lambda演算为该语言最重要的基础。而且,lambda演算的函数可以接受函数当作输入(引数)和输出(传出值)。

比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。


纯函数

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

所谓纯函数有两个重要概念:

  • 无副作用

    纯函数在执行过程中不会对除函数返回值以外的任何东西产生影响。这意味着它不会改变任何全局变量、不会修改传入的参数,也不会产生除了返回值以外的其他可观察到的影响。

  • 相同输入产生相同输出

    给定相同的输入,纯函数总是返回相同的输出,不受任何外部因素影响。这使得纯函数更容易进行测试和推理,因为其行为是可预测和确定的。


例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//* 非纯函数
// 污染外部变量
const baseNum = 10
const add = (num) => {
return baseNum + num
}

//* 纯函数
// 没有污染外部变量
const add = (num) => {
const baseNum = 10
return baseNum + num
}

const arr = [1, 2, 3, 4, 5, 6]
//* 非纯函数
arr.splice(0, 3)
// => [1, 2, 3]
// 改变原了数组

//* 纯函数
arr.slice(0, 3)
// => [1, 2, 3]
// 返回新数组,不改变原数组

好处

  1. 可预测性: 纯函数的输出只依赖于输入参数,因此对于相同的输入始终产生相同的输出。这种确定性使代码更易于测试和推理。
  2. 可缓存性: 纯函数对于相同的输入始终返回相同的结果,因此可以利用缓存来提高性能,避免重复计算。
  3. 可并行性: 由于纯函数不涉及共享状态,因此可以更容易地并行执行,无需担心竞争条件或锁的问题。
  4. 更容易推理: 纯函数不依赖于外部状态,使得代码更容易推理和理解,减少了出错的可能性

闭包

闭包是一类特殊的高阶函数,它是由函数和其周围状态的引用捆绑在一起形成的,它的表现就是可以在一个作用域中调用一个函数内部的函数并访问到该函数的作用域中的成员

闭包形成的原因是,当一个函数执行完成以后,其内部的成员就会被释放掉。如果这个函数返回了一个函数,并且在这个返回的函数内部又访问了其外部函数中的成员变量,这就形成了闭包。即外部对内部成员有引用,就造成该成员不能被释放掉

特点

  • 在一个作用域中可以去调用另一个函数的内部函数

  • 调用这个内部函数的时候可以访问到这个内部函数外部函数的内部成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 计算工资函数
const calcSalary = base => {
return (/* 表现工资 */ perf) => {
// 内部函数可以访问到外部函数(calcSalary)的base变量
return base + perf
}
}

const getSalaryOfDeveloper = calcSalary(12000)
const getSalaryOfManager = calcSalary(15000)

// 在一个作用域中调用函数的内部函数
const mikeSalary = getSalaryOfDeveloper(2000)
// ==> 14000
const amySalary = getSalaryOfDeveloper(3000)
// ==> 15000
const jackSalary = getSalaryOfManager(3000)
// =>> 18000

惰性调用

概念:延迟表达式的求值直到实际需要的时候。这意味着表达式不会在定义时立即被计算,而是在需要其值的时候才被求值和执行。

在函数式编程中,惰性计算经常用于创建无限序列或避免不必要的计算。这种方法允许程序在必要的时候才执行计算,以节省资源并提高效率。

例子

1
2
3
4
5
6
7
8
9
10
// 计算工资函数
const calcSalary = base => {
return perf => base + perf
}

// 给予参数,但不立即执行
const getSalaryOfDeveloper = calcSalary(12000)

// 当需要结果时,才调用函数进行计算
const mikeSalary = getSalaryOfDeveloper(2000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 惰性调用在正则中的例子
const createRegExpTemplate = (regStr) => {
const reg = new RegExp(regStr)
return (testValue) => reg.test(testValue)
}
const emailRegExp = createRegExpTemplate('^\\w+@[a-z0-9]+\\.[a-z]+$')
const phoneRegExp = createRegExpTemplate('^1[34578]\\d{9}$')

console.log(emailRegExp('111.com'))
// ==> false
console.log(emailRegExp('helloword123@qq.com'))
// ==> true
console.log(phoneRegExp('17312341234'))
// ==> true

柯理化

柯里化是一种将使用多个参数的函数转换成一系列使用一个参数的函数的技术。它的核心思想是将一个接受多个参数的函数转换为一系列嵌套的单参数函数。

展现形式

柯理化函数是建立在闭包和惰性调用的基础之上的一个概念,有了这两个理念的支持才得以实现函数柯理化。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 普通函数
const add = (a, b, c) => a + b + c
//* 柯理化函数
// 一些列一个参数的函数
const add = a => b => c => a + b + c

console.log(add(1)(2)(3))
// => 6
const addBase = add(100)(10)
console.log(addBase(5))
// => 115
console.log(addBase(8))
// => 118

作用

多个参数的函数转换为一系列接受单个参数的函数。这种转换使得函数更加灵活、更易于复用,并且支持更为简洁的函数组合。

参数复用与部分应用

柯里化允许您先传递一部分参数,然后在后续调用中提供剩余参数。这对于创建更多抽象的函数非常有用。

1
2
3
4
5
6
7
8
9
10
11
12
function calculateTax(taxRate) {
return function(amount) {
return amount * taxRate
};
}

// 创建一个计算5%税率的函数
const apply5PercentTax = calculateTax(0.05)

const res = apply5PercentTax(100)
const res2 = apply5PercentTax(200)

由于闭包和惰性执行的特性,柯理化函数把一个业务流程颗粒化成一系列的小粒度业务。可以随着流程的变化惰性的赋予其他参数,让这个业务可以随着代码的执行流程变得非常可控,是函数式编程中非常重要的组成。

柯理化工厂

1
2
3
4
5
6
7
8
9
10
const curryFactory = (fn) => {
const curried = (...args) => {
if (args.length >= fn.length) {
return fn(...args)
} else {
return (...moreArgs) => curried(...args, ...moreArgs)
}
};
return curried
}

我们并不可能对多参数函数手动的实现它的柯理化,但是我们可以借助柯理化工厂来实现。

1
2
3
4
5
6
7
8
9
10
11
// 示例函数
const add = (a, b, c) => a + b + c;

// 创建柯里化版本
const curriedAdd = curryFactory(add);

// 使用柯里化函数
console.log(curriedAdd(2)(3)(4)) // 输出: 9
console.log(curriedAdd(2, 3)(4)) // 输出: 9
console.log(curriedAdd(2)(3, 4)) // 输出: 9
console.log(curriedAdd(2, 3, 4)) // 输出: 9

组合(Compose)

数组合是函数式编程中的一个重要概念,它涉及将多个函数结合在一起,形成一个新的函数。这种组合允许你以一种清晰、模块化的方式构建复杂的操作。

展现形式

在函数组合中,两个或多个函数按照一定的顺序合并在一起,使得每个函数的输出成为下一个函数的输入,从而形成一个新的函数。这种组合可以简化代码、提高可读性,并且可以减少中间变量的使用。一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
// 简单的组合函数工厂
const compose = (f, g) => x => f(g(x))

// 两个纯函数
const add5 = x => x + 5
const multiply = (x) => x * 2

// 组合函数并按顺序执行
const res = compose(add5, multiply)(10)
console.log(res)
// => 25

从这个简单的例子可以发现,函数组合其实就是字面意思,将多个函数组合在一起形成一个新的函数,当组合多的时候,纯函数的特点就体现出来的。这就是为什么函数式编程提倡纯函数,提倡无作用函数的重要原因。

多函数组合

往往我们需要组合多个纯函数,那上面的简单组合工厂就不适用了,稍作调整:

1
2
3
4
5
6
7
8
9
10
11
const compose = (...fns) => (x) => {
return fns.reduceRight((acc, fn) => fn(acc), x);
}

const add5 = x => x + 5
const multiply = x => x * 2
const desc10 = x => x - 10

const res = compose(add5, multiply, desc10)(10)
console.log(res)
// => 5

管道(Pipe)

管道(Pipe),它很类似于组合。但更强调数据的流动和函数的串联,不会创建新的函数。

展现形式

函数管道强调数据流向的方向,一个函数的输出成为下一个函数的输入,从而形成一个数据流水线。一个简单的例子:

1
2
3
4
5
6
const add5 = (x) => x + 5
const multiply = (x) => x * 2

const res = multiply(add5(3))
console.log(res)
// => 16

可以看到它和组合的展现形式有所不同,它是从前往后,而非从而往前。和组合比调换了执行顺序,也就是说:

1
2
3
4
// 从
const compose = (f, g) => x => f(g(x))
// 变为
const pipe = (f, g) => x => g(f(x))

管道和组合的区别

在广义上,管道和组合其实是同一个作用,它们都是把多个函数组合在一起来执行一系列的操作。它们的实现方式可能有些微小的差别,主要在于函数执行顺序上,组合通常从右到左执行函数,而管道通常从左到右执行函数。很多时候甚至都不对它们不做区分,只不过从语义上对它们的职责进行了分化,从而导致叫法不同。例如,通常组合不会组合很多的函数,当组合的函数多了,我们可以称之为管道。


函子(Functor)

在函数式编程中,函子(Functor)是一种特殊的对象或数据结构,它实现了 map 函数,并遵循一些特定规则。函子可被视为一种抽象,它提供了一种包装值的机制,并允许对这些值进行变换和操作,而无需直接暴露内部实现。

特点

函子的主要特点是它具有一个 map 方法,这个方法能够将函子中的值映射到另一个函子中。当对函子调用 map 方法时,它会返回一个新的函子,而不是直接操作原始值。

函子的实现可以是各种类型的数据结构,例如数组、对象、甚至 Promise。这种抽象允许我们对值进行变换、映射或其他操作,而无需直接访问或修改值。

规则

  • 对值进行包装
  • 提供map方法: 函子必须提供 map 方法,这个方法能够对函子内的值进行操作并返回一个新的函子。
  • 保持对原值的不可变性:map 方法对值进行操作时,不会直接修改原始值,而是返回一个新的函子包含了新值。
  • 遵循同态性: map 方法保持了函子对值的变换,并且可以链接多次 map 调用而不改变函子的结构。

具体形式

Pointed函子

实现了of静态方法的函子被称为Pointed函子,未实现的则为普通函子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Pointed {
constructor(value) {
// 私有
this._value = value
}

// 契约的map方法
map(fn) {
// fn: 传入的纯函数
return Pointed.of(fn(this._value))
}

static of(value) {
return new Pointed(value)
}
}

let num = Pointed.of(10).map(x => x + 1).map(x => x * 2)

Maybe函子

Maybe 会先检查自己的值是否为空,然后才调用传进来的函数,这样处理空值就不会出错了。Maybe 常用在那些可能无法成功返回结果的函数中;可以避免使用命令式的 if…else 语句,可以用 Maybe(null) 来表示失败,但却不能告诉我们太多有效信息,譬如:失败的原因是什么?是哪儿造成失败的?Either 函子能帮助我们解决这样的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Maybe {
static of (value) {
return new Maybe(value)
}

constructor (value) {
this._value = value
}

map (fn) {
// 检查自己的值是否为空
return this._value ? Maybe.of(fn(this._value)) : Maybe.of(null)
}
}

let toUpper = Maybe.of(null).map(x => x.toUpperCase())

// 输出:Maybe { _value: null }
console.log(toUpper)

// 用在可能会无法成功返回结果的函数中
function getFirst (arr) {
return Maybe.of(arr[0])
}
let firstElement = getFirst([]).map(x => x + 3)
// 输出:Maybe { _value: null }
console.log(firstElement)

Either函子

函数式编程里面,使用 Either 函子代替条件运算(if…else);另一个用途是代替 try…catch,使用左值表示错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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))
}
}

上面所述就是Either函子:Left表示错误函子,Right表示正确函子。Either函子通常用于函数的返回值例如:

1
2
3
4
5
6
7
8
9
const isEven = x => x % 2 === 0
? Right.of(x)
: Left.of('not even')

isEven(2).map(console.log)
// ==> 输出:2

isEven(3).map(console.log)
// ==> 不输出

如上例子,将函数的返回值改为Either函子,函数满足您的逻辑正确,则执行Right函子的map反之则执行Left函子的map语句(直接返回当前实力,也就是说被截断。)

那么,又如何判断函数返回的函子是Right还是Left呢?两种方法:

  • 标记函子

    这个方法简单粗暴,给对应Either函子一个标识即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    class Left {

    isLeft() { return true }
    isRight() { return false }

    static of(value) {
    return new Left(value)
    }
    constructor (value) {
    this._value = value
    }
    map (fn) {
    return this
    }
    }

    class Right {
    isLeft() { return false }
    isRight() { return true }

    static of (value) {
    return new Right(value)
    }
    constructor (value) {
    this._value = value
    }
    map (fn) {
    return Right.of(fn(this._value))
    }
    }

    const isEven = x => x % 2 === 0
    ? Right.of(x)
    : Left.of('not even')

    const resFunctor = isEven(2)
    if (resFunctor.isRight()) {
    resFunctor.map(console.log)
    } else {
    // 执行错误逻辑
    }

    这种传统方式,不够灵活,可以试试使用fold方法来解决问题

  • fold方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    class Left {
    static of(value) {
    return new Left(value);
    }

    constructor(value) {
    this._value = value
    }

    map(fn) {
    return this
    }

    fold(leftFn, rightFn) {
    return leftFn(this._value)
    }
    }

    class Right {
    static of(value) {
    return new Right(value)
    }

    constructor(value) {
    this._value = value
    }

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

    fold(leftFn, rightFn) {
    return rightFn(this._value);
    }
    }

    const isEven = x => x % 2 === 0
    ? Right.of(x)
    : Left.of(`error: ${x} is not even`)

    isEven(2).fold(
    // 直接输出错误信息
    console.log,
    x => {
    console.log(`${x} 为偶数,做一些额外处理`)
    // ......
    }
    )
    // => 2 为偶数,做一些额外处理
    // => .....其他额外处理输出

    如果对错误事件想要进行额外的特殊处理,改怎么办?

    无需变动上方代码,将特殊处理的逻辑传给leftFn即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    isEven(3).fold(
    // 直接输出错误信息
    () => {
    console.log(`${x} 不为偶数,做一些额外特殊处理`)
    // ......
    },
    x => {
    console.log(`${x} 为偶数,做一些额外处理`)
    // ......
    }
    )
    // => 3 不为偶数,做一些额外特殊处理
    // => .....其他额外处理输出

    这样就可以很优雅的处理分支和异常

函子的使用

通过函子实现链式编程,以及异常处理

  • 所用函子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const Either = {
    Left: (value) => ({
    map: (fn) => Either.Left(value),
    fold: (fn, _) => fn(value),
    chain: (fn) => Either.Left(value)
    }),
    Right: (value) => ({
    map: (fn) => Either.Right(fn(value)),
    fold: (_, fn) => fn(value),
    //+ 用于保持函子的结构
    chain: (fn) => fn(value)
    })
    }
  • 业务

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // 是否为偶数
    const isEven = (n) => n % 2 === 0
    ? Either.Right(n)
    : Either.Left(n)
    // 是否大于2
    const isBiggerThan2 = (n) => n > 2
    ? Either.Right(n)
    : Either.Left(n)
    // 减半
    const half = (n) => n / 2

    const result = isEven(10)
    .chain(isBiggerThan2)
    .map(half)
    .map((n) => n + 2)
    .fold(() => 0, n => n)
    console.log(result)
    // => 7

    const result2 = isEven(2)
    .chain(isBiggerThan2)
    .map(half)
    .map((n) => n + 2)
    .fold(() => 0, n => n)
    console.log(result2)
    // => 0

    const result3 = isEven(1)
    .chain(isBiggerThan2)
    .map(half)
    .map((n) => n + 2)
    .fold(() => 0, n => n)
    console.log(result3)
    // => 0

    可以发现,当中途出现,Either.Left时,就会将Either.Right.map()更改为Either.Left.map(),也就是说不对值执行map中传递的纯函数返回函子本身(等价于跳过),最终被Either.Left.fold()劫持到。

另外,类似这样的函子还有一些,例如:Ap函子、IO函子、Monad函子。通过上面所述的函子,可以得出,函子它具有一个 map 方法,这个方法能够将函子中的值映射到另一个函子中。当对函子调用 map 方法时,它会返回一个新的函子,而不是直接操作原始值。


👋 保重;

— 2023年12月15日