函数式组件与函数式编程

函数式编程和函数式组件是现在开发的一个趋势,过去一直在用,但是没有从概念上理解,本文就把二者放在一起总结一下并进行一定的对比。

函数式编程是一种编程范式,而函数式组件是这种范式下的一种产物。

函数式编程

编程范式

编程范型、编程范式或程序设计法(英语:Programming paradigm),是指软件工程中的一类典型的编程风格。常见的编程范型有:函数式编程、指令式编程、过程式编程、面向对象编程等等。

编程范型提供并决定了程序员对程序执行的看法。例如,在面向对象编程中,程序员认为程序是一系列相互作用的对象,由于方法论的不同,面向对象编程范型又分为基于类编程和基于原型编程,而在函数式编程中一个程序会被看作是一个无状态的函数计算的序列。

编程范式与编程语言不同,比如面向对象语言,只是说该语言提供了比较方便的封装,继承,多态的语法,不代表你只能写出面向对象的代码,也不代表你写出来的代码符合面向对象的范式

编程范式还有很多,这里贴一下维基百科的目录:

  • 指令式
    • 过程式
    • 块结构
    • 结构化
    • 模块化
  • 函数式
    • 头等函数
    • 纯函数式
    • 隐式
    • 模式匹配
    • 推导式
  • 面向对象
    • 基于类
    • 基于原型
    • 契约式
    • 面向切面
    • 面向代理
  • 阵列式
  • 数据流程
    • 同步式
    • 响应式
    • 流处理
    • 基于流程

函数式编程是一种编程范式

函数式编程,或称函数程序设计、泛函编程(英语:Functional programming),是一种编程范型,它将电脑运算视为函数运算,并且避免使用程序状态以及可变对象。

在函数式编程中,函数是头等对象即头等函数,这意味着一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。λ演算是这种范型最重要的基础,λ演算的函数可以接受函数作为输入参数和输出返回值。

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

什么叫函数式编程

从上面那段定义可以提取出这几个关键词:

  1. 避免状态变更
  2. 函数作为输入输出
  3. 和λ演算有关

关于这段定义,如果只是想简单理解函数式编程的表现形式是怎样的,可以看一下阮一峰的这篇博客

如果想深入理解,也建议先读读上面那个博客,很短,但是有助于理解下面的某些公式的形式。

避免状态变更和函数作为输入输出都比较好理解,,那什么叫做lambda演算呢?要理解lambda演算,就会扯出图灵完备,图灵机。接下来我简单和大家解释一下。

什么叫表达式

λ演算(英语:lambda calculus,λ-calculus)是一套从数学逻辑中发展,以变量绑定和替换的规则,来研究函数如何抽象化定义、函数如何被应用以及递归的形式系统。它由数学家阿隆佐·邱奇在20世纪30年代首次发表。lambda演算作为一种广泛用途的计算模型,可以清晰地定义什么是一个可计算函数,而任何可计算函数都能以这种形式表达和求值,它能模拟单一磁带图灵机的计算过程;尽管如此,lambda演算强调的是变换规则的运用,而非实现它们的具体机器。

lambda演算可比拟是最根本的编程语言,它包括了一条变换规则(变量替换)和一条将函数抽象化定义的方式。因此普遍公认是一种更接近软件而非硬件的方式。对函数式编程语言造成很大影响,比如Lisp、ML语言和Haskell语言。在1936年邱奇利用λ演算给出了对于判定性问题(Entscheidungsproblem)的否定:关于两个lambda表达式是否等价的命题,无法由一个“通用的算法”判断,这是不可判定性能够证明的头一个问题,甚至还在停机问题之先。

lambda演算包括了建构lambda项,和对lambda项执行归约的操作。在最简单的lambda演算中,只使用以下的规则来建构lambda项:

语法名称描述
x变量用字符或字符串来表示参数或者数学上的值或者表示逻辑上的值
(λx.M)抽象化一个完整的函数定义(M是一个 lambda 项),在表达式中的 x 都会绑定为变量 x。
(M N)应用将函数M作用于参数N。 M 和 N 是 lambda 项。

产生了诸如:(λx.λy.(λz.(λx.zx)(λy.zy))(x y))的表达式。如果表达式是明确而没有歧义的,则括号可以省略。对于某些应用,其中可能包括了逻辑和数学的常量以及相关操作。

λ演算是图灵完备的,也就是说,这是一个可以用于模拟任何图灵机的通用模型。λ也被用在λ表达式和λ项中,用来表示将一个变量绑定在一个函数上。

λ演算可以是有类型或者无类型的,在有类型λ演算中(上文所述是无类型的),函数只能在参数类型和输入类型符合时被应用。有类型λ演算比无类型λ演算要弱——后者是这个条目的主要部分——因为有类型的λ运算能表达的比无类型λ演算少;与此同时,前者使得更多定理能被证明。例如,在简单类型λ演算中,运算总是能够停止,然而无类型λ演算中这是不一定的(因为停机问题)。目前有许多种有类型λ演算的一个原因是它们被期望能做到更多(做到某些以前的有类型λ演算做不到的)的同时又希望能用以证明更多定理。

λ演算在数学、哲学、语言学和计算机科学中都有许多应用。它在编程语言理论中占有重要地位,函数式编程实现了λ演算支持。λ演算在范畴论中也是一个研究热点。

λ演算式就三个要点:

  • 绑定关系。变量任意性,x、y和z都行,它仅仅是具体数据的代称。
  • 递归定义。λ项递归定义,M可以是一个λ项。
  • 替换归约。λ项可应用,空格分隔表示对M应用N,N可以是一个λ项。

通过变量代换(substitution)和归约(reduction),我们可以像化简方程一样处理我们的演算。

举个例子,刚才我们说的(λx.λy.(λz.(λx.zx)(λy.zy))(x y)),首先(λx.zx)表示f(x) = zx,那么 (λx.zx) 3 就是3z

演算:变量的含义

在λ演算中我们的表达式只有一个参数,那它怎么实现两个数字的二元操作呢?比如加法a + b,需要两个参数。

这时,我们要把函数本身也视为值,可以通过把一个变量绑定到上下文,然后返回一个新的函数,来实现数据(或者说是状态)的保存和传递,被绑定的变量可以在需要实际使用的时候从上下文中引用到。

比如:λm.λn.m + n 5 = λn.5 + n,第一次函数调用传入m=5,返回一个新函数,这个新函数接收一个参数n,并返回m + n的结果。像这种情况产生的上下文,就是Closure(闭包,函数式编程常用的状态保存和引用手段),我们称变量m是被绑定(binding)到了第二个函数的上下文。

除了绑定的变量,λ演算也支持自由的变量,比如这个y:λm.λn.m + n + y,这里的y是一个没有绑定到参数位置的变量,被称为一个自由变量。

绑定变量和自由变量是函数的两种状态来源,一个可以被代换,另一个不能。实际程序中,通常把绑定变量实现为局部变量或者参数,自由变量实现为全局变量或者环境变量

演算:代换和归约

演算分为Alpha代换和Beta归约。 前面章节我们实际上已经涉及这两个概念,下面来介绍一下它们。

Alpha代换指的是变量的名称是不重要的,你可以写λm.λn.m + n,也可以写λx.λy.x + y,在演算过程中它们表示同一个函数。也就是说我们只关心计算的形式,而不关心细节用什么变量去实现。这方便我们不改变运算结果的前提下去修改变量命名,以方便在函数比较复杂的情况下进行化简操作。实际上,连整个lambda演算式的名字也是不重要的,我们只需要这种形式的计算,而不在乎这个形式的命名。

Beta归约指的是如果你有一个函数应用(函数调用),那么你可以对这个函数体中与标识符对应的部分做代换(substitution),方式为使用参数(可能是另一个演算式)去替换标识符。听起来有点绕口,但是它实际上就是函数调用的参数替换。比如:(λm.λn.m + n) 1 3 = (λn.1 + n) 3 = 1 + 3 = 4

可以使用1替换m,3替换n,那么整个表达式可以化简为4。这也是函数式编程里面的引用透明性的由来。需要注意的是,这里的1和3表示表达式运算值,可以替换为其他表达式。比如把1替换为(λm.λn.m + n 1 3),这里就需要做两次归约

JavaScript中的λ表达式:箭头函数

ECMAScript 2015规范引入了箭头函数,它没有this,没有arguments。只能作为一个表达式(expression)而不能作为一个声明式(statement),表达式产生一个箭头函数引用,该箭头函数引用仍然有name和length属性,分别表示箭头函数的名字、形参(parameters)长度。一个箭头函数就是一个单纯的运算式,箭头函数我们也可以称为lambda函数,它在书写形式上就像是一个λ演算式。

可以利用箭头函数做一些简单的运算,下例比较了四种箭头函数的使用方式:

1
2
3
4
const add_1 = (x, y) => x + y; // 全部为局部变量
const add_2 = x => x + y; // y为全局变量
const add_3 = x => y => x + y; // 闭包串联参数,柯里化
const add_4 = b => a => a + b; // 参数命名和表达式结果无关

这是直接针对数字(基本数据类型)的情况,如果是针对函数做运算(引用数据类型),事情就变得有趣起来了。

1
2
3
4
5
const fn_1 = x => y => x(y);
const fn_2 = f => x => f(x);
const add_1 = (f => f(5))(x => x + 2);
const add_2 = (x => y => x + y)(2)(5);
const add_3 = (x => x + 2)(5);

fn_x类型,表明我们可以利用函数内的函数,当函数被当作数据传递的时候,就可以对函数进行应用(apply),生成更高阶的操作。 并且x => y => x(y)可以有两种理解,一种是x => y函数传入X => x(y),另一种是x传入y => x(y)。

add_x类型表明,一个运算式可以有很多不同的路径来实现。

函数式编程基础:函数的元、柯里化和Point-Free

回到JavaScript本身,我们要探究函数本身能不能带给我们更多的东西?我们在JavaScript中有很多创建函数的方式:

可以通过声明式,表达式,箭头函数,new Function等方式

虽然函数有这么多定义,但function关键字声明的函数带有arguments和this关键字,这让他们看起来更像是对象方法(method),而不是函数(function) 。

况且function定义的函数大多数还能被构造(比如new Array)。

接下来我们将只研究箭头函数,因为它更像是数学意义上的函数(仅执行计算过程)。

  • 没有arguments和this。
  • 不可以被构造new。

函数的元

不论使用何种方式去构造一个函数,这个函数都有两个固定的信息可以获取:

  • name 表示当前标识符指向的函数的名字。
  • length 表示当前标识符指向的函数定义时的参数列表长度。

在数学上,我们定义f(x) = x是一个一元函数,而f(x, y) = x + y是一个二元函数。在JavaScript中我们可以使用函数定义时的length来定义它的元。

1
2
3
const one = a => a;
const two = (a, b) => a + b;
const three = (a, b, c) => a + b + c;

定义函数的元的意义在于,我们可以对函数进行归类,并且可以明确一个函数需要的确切参数个数。函数的元在编译期(类型检查、重载)和运行时(异常处理、动态生成代码)都有重要作用。

如果我给你一个二元函数,你就知道需要传递两个参数。比如+就可以看成是一个二元函数,它的左边接受一个参数,右边接受一个参数,返回它们的和(或字符串连接)。

在一些其他语言中,+确实也是由抽象类来定义实现的,比如Rust语言的trait Add。

但我们上面看到的λ演算,每个函数都只有一个元。为什么呢?

只有一个元的函数方便我们进行代数运算。λ演算的参数列表使用λx.λy.λz的格式进行分割,返回值一般都是函数,如果一个二元函数,调用时只使用了一个参数,则返回一个「不完全调用函数」。这里用三个例子解释“不完全调用”。

柯里化函数:函数元降维技术

柯里化(curry)函数是一种把函数的元降维的技术,这个名词是为了纪念我们上文提到的数学家阿隆佐·邱奇。

柯里化函数帮助我们把一个多元函数变成一个不完全调用,利用Closure的魔法,把函数调用变成延迟的偏函数(不完全调用函数)调用。这在函数组合、复用等场景非常有用

Point-Free|无参风格:函数的高阶组合

函数式编程中有一种Point-Free风格,中文语境大概可以把point认为是参数点,对应λ演算中的函数应用(Function Apply),或者JavaScript中的函数调用(Function Call),所以可以理解Point-Free就指的是无参调用。

来看一个日常的例子,把二进制数据转换为八进制数据:

1
2
var strNums = ['01', '10', '11', '1110'];
strNums.map(x => parseInt(x, 2)).map(x => x.toString(8));

这段代码运行起来没有问题,但我们为了处理这个转换,需要了解 parseInt(x, 2) 和 toString(8) 这两个函数(为什么有魔法数字2和魔法数字8),并且要关心数据(函数类型a -> b)在每个节点的形态(关心数据的流向)。有没有一种方式,可以让我们只关心入参和出参,不关心数据流动过程呢?

1
2
3
4
5
6
const toBinary = x => parseInt(x,  2);
const toString0x => x => x.toString(8);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);

var strNums = ['01', '10', '11', '1110'];
strNums.map(pipe(toBinary, toString0x));

函数式组件

什么叫函数式组件

函数式组件就是函数是组件,组件是函数,它的特征是没有内部状态、没有生命周期钩子函数、没有this(不需要实例化的组件)。

在日常开发中,我们经常会开发一些纯展示性的业务组件,比如一些详情页面,列表界面等,它们有一个共同的特点是:

只要你传入数据,我就进行展现。

不需要有内部状态,不需要在生命周期钩子函数里面做处理。

这时候你就可以用函数式组件。

为什么要用函数式组件

函数式组件不需要实例化,无状态,没有生命周期,所以渲染性能要好于普通组件

函数式组件结构更简单,代码结构更清晰

Vue2 的函数式组件

  1. 函数式组件需要在声明组件是指定functional。

  2. 函数式组件不需要实例化,所以没有this,this通过render函数的第二个参数来代替。

  3. 函数式组件没有生命周期钩子函数,不能使用计算属性,watch等等。

  4. 函数式组件不能通过$emit对外暴露事件,调用事件只能通过context.listeners.click的方式调用外部传入的事件。

  5. 因为函数式组件是没有实例化的,所以在外部通过ref去引用组件时,实际引用的是HTMLElement。

  6. 函数式组件的props可以只声明一部分或者全都不声明,所有没有在props里面声明的属性都会被自动隐式解析为prop,而普通组件所有未声明的属性都被解析到$attrs里面,并自动挂载到组件根元素上面(可以通过inheritAttrs属性禁止)。

上面已经反复强调,凡是不需要实例化,无状态,没有生命周期的组件,除了props之外没有别的配置项,都可以改写成函数式组件。

语法

模版语法

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<func text="aaaaaaaa" />
</div>
</template>
<script>
import func from '@/components/func.vue';
export default {
components: {
func
}
};
</script>
1
2
3
<template functional>
<p>{{props.text ? props.text : '哈哈'}}</p>
</template>

注意,没有<script>...</script>部分。

JSX语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
export default {
functional: true,
props: {
text: {
type: String
}
},
/**
* 渲染函数
* @param {*} h
* @param {*} context 函数式组件没有this, props, slots等都在context上面挂着
*/
render(h, context) {
console.log(context);
const { props } = context
if (props.text) {
return <p>{props.text}</p>
}
return <p>哈哈嗝</p>
}
}
</script>

Vue3 的函数式组件

Vue3 函数式组件

React 的函数式组件

React 函数式组件

参考文章:

https://tech.meituan.com/2022/10/13/dive-into-functional-programming-01.html

https://www.ruanyifeng.com/blog/2012/04/functional_programming.html