【笔记】js的函数式编程和aop
Jun 2, 2019笔记软件编码js的函数式编程和aop
- build time: 2018-08-24 13:00:45
- change log:
- 2022-03-06:修改案例代码
- 2019-06-02:增加零开始探索 ES6 和函数式编程
软件开发的本质是组合,FP 和 OOP 搭配会更好哦。
在大概 2006 年以前,JavaScript 被广泛的看作玩具语言和被用制作浏览器中的动画,但是它里面隐藏着一些极其强大的特性。即 lambda 表达式中最重要的特性。伴随的 js 的崛起,人们又开始讨论一个叫做 “函数式编程”的东西。。。
对我来说,重大演变还是向更加函数式的风格的发展,它使得我们放弃很多旧的习惯,并从一些面向对象思想中逐渐退出。 ——John Carmack
1 函数式编程
函数式编程(英语:functional programming,FP)或称函数程序设计,又称泛函数编程,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及易变物件。函数式语言最重要的基础是λ演算(lambda calculus)。而且从λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。 –wiki
阿隆佐·邱奇发明了 lambda 表达式。lambda 表达式是基于函数应用的通用计算模型。艾伦·图灵因为图灵机而知名。图灵机使用定义一个在磁带上操作符号的理论装置来计算的通用模型。
函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段。
函数式编程是声明式编程的一部分。
1.1 廖雪峰《函数式编程》 摘要
地址:https://www.liaoxuefeng.com/wiki/1252599548343744/1255943847278976
我们通过把大段代码拆成函数,通过一层一层的函数调用,就可以把复杂任务分解成简单的任务,这种分解可以称之为面向过程的程序设计。函数就是面向过程的程序设计的基本单元。
在计算机的层次上,CPU执行的是加减乘除的指令代码,以及各种条件判断和跳转指令,所以,汇编语言是最贴近计算机的语言。
而计算则指数学意义上的计算,越是抽象的计算,离计算机硬件越远。
对应到编程语言,就是越低级的语言,越贴近计算机,抽象程度低,执行效率高,比如C语言;越高级的语言,越贴近计算,抽象程度高,执行效率低,比如 Lisp 语言。
函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。
函数式编程的一个特点就是:允许把函数本身作为参数传入另一个函数,还允许返回一个函数!
1.2 阮一峰《函数式编程初探》摘要
地址:https://www.ruanyifeng.com/blog/2012/04/functional_programming.html
简单说,”函数式编程”是一种”编程范式”(programming paradigm),也就是如何编写程序的方法论。
它属于”结构化编程”的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。举例来说,现在有这样一个数学表达式:
1 | (1 + 2) * 3 - 4 |
传统的过程式编程,可能这样写:
1 | const a = 1 + 2; |
函数式编程要求使用函数,我们可以把运算过程定义为不同的函数,然后写成下面这样:
1 | const result = subtract(multiply(add(1,2), 3), 4); |
函数式编程的五个特点
- 函数是“第一等公民”(First Class Citizens):指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
- 只用”表达式”,不用”语句”:”表达式”(expression)是一个单纯的运算过程,总是有返回值;”语句”(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。
- 没有”副作用”:指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。
- 不修改状态:函数式编程只是返回新的值,不修改系统变量。
- 引用透明:指的是函数的运行不依赖于外部变量或”状态”,只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。
1.3 函数式编程核心概念
函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用。函数式编程技术主要基于数学函数和它的思想。
函数的第一条原则是要小,函数的第二条原则是要更小。
函数式编程常用的核心概念:
- 纯函数
- 函数柯里化
- 函数组合(合成)
- Point Free
- 声明式与命令式代码
- 核心概念
函数和js方法
函数是一段可以通过其名称被调用的代码,它可以传递参数并返回值。然而方法是一段必须通过其名称及其关联对象的名称被调用的代码。如:
1 | const simple = a => a; // 函数 |
抽象
在软件工程和计算机科学中,抽象是一种管理计算机系统复杂性的技术。它通过建立一个人与系统进行交互的复杂程度,把更复杂的细节抑制在当前水平之下。程序员应该使用理想的界面(通常定义良好),并且可以添加额外级别的功能,否则处理起来将会很复杂。 ——wiki
1.3.1 纯函数
对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态的函数,叫做纯函数。函数是不同数值之间的特殊关系:每一个输入值返回且只返回一个输出值。纯函数就是数学上的函数,而且是函数式编程的全部。
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。副作用可能包含,但不限于:更改文件系统/往数据库插入记录/发送一个 http 请求/可变数据/打印log/获取用户输入/DOM 查询/访问系统状态。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
如1
2
3
4
5
6
7
8
9const arr = [1,2,3,4,5];
// Array.prototype.slice()是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
arr.slice(0,3); // [1,2,3]
arr.slice(0,3); // [1,2,3]
// Array.prototype.splice()会对原数组造成影响,所以不是纯函数
arr.splice(0,3); // [1,2,3]
arr.splice(0,3); // [4,5]
纯函数的好处
- 可缓存性(Cacheable),利用 memoize 技术
1 | const memoize = function(f) { |
1 | /** |
值得注意的一点是,可以通过延迟执行的方式把不纯的函数转换为纯函数:
1 | const pureHttpCall = memoize(function(url, params){ |
这里有趣的地方在于我们并没有真正发送 http 请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格成为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送 http 请求的函数。
我们的 memoize 函数工作起来没有任何问题,虽然它缓存的并不是 http 请求所返回的结果,而是生成的函数。
2.可移植性/自文档化(Portable / Self-Documenting),依赖明确,自给自足
在 js 的设定中,可移植性可以意味着把函数序列化(serializing)并通过 socket 发送。也可以意味着代码能够在 web workers 中运行。总之,可移植性是一个非常强大的特性。
3.可测试性(Testable)
4.合理性(Reasonable)
很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。
5.可以并行代码
也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
6.管道与组合
使用纯函数,我们只需要在函数中做一件事。纯函数能够自我理解,通过其名称就能知道它所做的事情。纯函数应该被设计为只做一件事。
只做一件事并把它做到完美是UNIX的哲学。
我们也可以通过组合完成复杂的任务。
Linux中的命令实际是一种纯函数,它接受参数并向调用者返回输出,不改变任何外部环境。如
cat
/grep
…
1.3.2 函数柯里化
在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。
如
栗子1:1
2
3
4
5const curry = (fn, arr = []) => (...args) => (
arg => arg.length === fn.length
? fn(...arg)
: curry(fn, arg)
)([...arr, ...args])
栗子2:1
2
3
4// 不纯,因为checkAge()不仅取决于age还有外部依赖变量min
let min = 18;
const checkAge = age => age > min;
1 | // 纯 |
栗子3:1
2
3
4// 未柯里化
function add (x, y) {
return x + y;
}
1 | // 柯里化后 |
事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。
1.3.3 函数组合(合成)
如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做”函数的合成”(compose)。
洋葱代码:h(g(f(x)))
1 | const compost = function (f, g) { |
1 | const compose = (f, g) => (x => f(g(x))); |
1.3.4 Point Free
把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量。
栗子:1
const f = str => str.toUpperCase().split(' ');
函数组合去改造一下:1
2
3
4
5const toUpperCase = word => word.toUpperCase();
const split = x => (str => str.split(x));
const f = compose(split(' '), toUpperCase);
f('abcd efgh');
把一些对象自带的方法转化成纯函数,然后通过函数组合去调用,这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。
1.3.5 声明式与命令式代码
1 | // 命令式 |
数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可。相反,不纯的函数式的代码会产生副作用或者依赖外部系统环境,使用它们的时候总是要考虑这些不干净的副作用。在复杂的系统中,这对于我们的心智来说是极大的负担。
1.3.6 核心概念
FP 和 OOP
OOP 的主要目标是问题分解,将一个问题分解为面向对象的几个部件,同时这些部件/对象可以被聚集在一起并组合成更大的部件。基于这些部件和它们之间的组合关系,我们就可以从部件之间的交互和值来描述一个系统,如:
而用严格的函数式编程的方法来解决问题,也会将一个问题分成几个部分(函数)来解决。如:
与 OOP 将问题分解成多组“名词”或对象不同,函数式方法将相同的问题分解成多组“动词”或函数。与OOP类似的是,FP也通过“黏合”或“组合”其他函数的方式来构建更大的函数,以实现更加抽象的行为。
在一个面向对象系统的内部,我们发现对象间的交互会引起各个对象内部状态的变化,而整个系统的状态转变则是由许许多多小的、细微的状态变化混合来形成的。这些相互关联的状态变化形成了一个概念上的“变化网”,我们时不时会因为它而感到困惑。当需要了解其带来的微妙且广泛的变化时,这种困惑就会成为一个问题。相比之下,函数式系统则努力减少可见的状态修改。因此,向一个遵循函数式原则的系统添加新功能就成了理解如何在存在局限的上下文环境中——无破坏性的数据转换(例如原始数据永不发生变化)——来实现新的函数。
不过一个系统应该由这两种模式共同协作组成。如何平衡函数式风格和面向对象风格是一件需要技巧的事情。
一个没好的基于函数式原则而构建的系统将是一个能够从输入终端接收未加工原料并逐渐从输出终端生产出成品的装配线设备。实践中函数式编程并不是以消除状态改变为主要目的,而是将任何已知系统中突变的出现尽量压缩到最小区域中去。
高阶函数(Higher-Order Function, HOC)
高阶函数,就是把函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。高阶组件常运用于React的高阶组件。
1 | // 命令式 |
递归与尾递归
指函数内部的最后一个动作是函数调用。该调用的返回值,直接返回给函数。函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归需要保存大量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了。通俗点说,尾递归最后一步需要调用自身,并且之后不能有其他额外操作。
1 | // 不是尾递归,无法优化 |
尾递归能有效的防止堆栈溢出。 在 ECMAScript6,我们将迎来尾递归优化,通过尾递归优化,js 代码在解释成机器码的时候,将会向 while 看起,也就是说,同时拥有数学表达能力和 while 的效能。
范畴与容器
- 1.函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
- 2.函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
容器与函子(Functor)
如 jQuery 中$(...)
返回的对象并不是一个原生的 DOM 对象,而是对于原生对象的一种封装,这在某种意义上就是一个“容器”(但它并不函数式)。
Functor(函子)遵守一些特定规则的容器类型。任何具有 map 方法的数据结构,都可以当作函子的实现。
Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。
下面我们看下函子的代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17const Container = function (x) {
this.__value = x;
}
// 函数式编程一般约定,函子有一个of方法
Container.of = x => new Container(x);
// Container.of(‘abcd’);
// 一般约定,函子的标志就是容器具有map方法。该方法将容器
// 里面的每一个值, 映射到另一个容器。
Container.prototype.map = function (f) {
return Container.of(f(this.__value))
}
Container.of(3)
.map(x => x + 1) //=> Container(4)
.map(x => 'Result is ' + x); //=> Container('Result is 4')
1 | class Functor { |
上面代码中,Functor 是一个函子,它的 map 方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val)
)。
一般约定,函子的标志就是容器具有 map 方法。该方法将容器里面的每一个值,映射到另一个容器。上面的例子说明,函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器—-函子。函子本身具有对外接口(map 方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。
因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。
你可能注意到了,上面生成新的函子的时候,用了new命令。这实在太不像函数式编程了,因为new命令是面向对象编程的标志。函数式编程一般约定,函子有一个 of 方法,用来生成新的容器。
1 | Functor.of = function (val) { |
一些常用的函子:
Maybe 函子
函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。
1 | const Maybe = function (x) { |
1 | Functor.of(null).map(function (s) { |
错误处理、Either函子
我们的容器能做的事情太少了,try/catch/throw 并不是“纯”的,因为它从外部接管了我们的函数,并且在这个函数出错时抛弃了它的返回值。Promise 是可以调用 catch 来集中处理错误的。事实上 Either 并不只是用来做错误处理的,它表示了逻辑或。
条件运算if…else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。
1 | class Either extends Functor { |
使用Either函子:1
2
3
4
5
6
7
8
9
10
11const addOne = function (x) {
return x + 1;
};
Either.of(5, 6).map(addOne);
// Either(5, 7);
Either.of(1, null).map(addOne);
// Either(2, null);
Either
.of({ address: 'xxx' }, currentUser.address)
.map(updateField);
AP函子
函子里面包含的值,完全可能是函数。我们可以想象这样一种情况,一个函子的值是数值,另一个函子的值是函数。
1 | class Ap extends Functor { |
1 | function addOne(x) { |
IO函子
IO 跟前面那几个 Functor 不同的地方在于,它的 __value 是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO 包含的是被包裹的操作的返回值。
IO其实也算是惰性求值。
IO负责了调用链积累了很多很多不纯的操作,带来的复杂性和不可维护性。
1 | class IO extends Monad { |
在这里,我们提到了Monad,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。Promise 就是一种 Monad。Monad 让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其它异步任务。
1 | class Monad extends Functor { |
1 | 2018.12.29 未完待续 |
2 零开始探索 ES6 和函数式编程
JavaScript 有函数式编程所需要的最重要的特性。
箭头函数,为了更简单的编写和读取函数、柯里化,和 lambda 语句,它是 JavaScript 函数式编程飞升的燃料。现在很少看见不使用很多函数式编程技术的大型应用了。
JavaScript 有函数式编程所需要的最重要的特性:一级公民函数、匿名函数和简洁的 lambda 语法、闭包
缺少了纯粹性、不可变性、递归(JavaScript 技术上支持递归,但是大多数函数式语言都有尾部调用优化的特性,尾部调用优化是一个允许递归的函数重用堆栈帧来递归调用的特性。没有尾部调用优化,一个调用的栈很可能没有边界导致堆栈溢出。)
在函数式编程中,reduce(也称为:fold,accumulate)允许你在一个序列上迭代,并应用一个函数来处理预先声明的累积值和当前迭代到的元素。当迭代完成时,将返回这个累积值。许多其他有用的功能都可以通过 reduce 实现。多数时候,reduce 可以说是处理集合(collection)最优雅的方式。
1 | 2019.01.28 未完待续 |
3 AOP面向切片
AOP 是 Aspect Oriented Programming 的缩写,译为面向切向编程。用我们最常用的 OOP 来对比理解:
纵向关系 OOP,横向角度 AOP
如,假设设计一个日志模块。按 OOP 思想,我们会设计一个打印日志 LogUtils 类,然后在需要打印的地方引用即可。
1 | // 打印日志类 |
这个类是横跨并嵌入众多模块里的,在各个模块里分散得很厉害,到处都能见到。从对象组织角度来讲,我们一般采用的分类方法都是使用类似生物学分类的方法,以「继承」关系为主线,我们称之为纵向,也就是 OOP。设计时只使用 OOP思想可能会带来两个问题:
- 对象设计的时候一般都是纵向思维,如果这个时候考虑这些不同类对象的共性,不仅会增加设计的难度和复杂性,还会造成类的接口过多而难以维护(共性越多,意味着接口契约越多)。
- 需要对现有的对象 动态增加 某种行为或责任时非常困难。
而AOP就可以很好地解决以上的问题,怎么做到的?除了这种纵向分类之外,我们从横向的角度去观察这些对象,无需再去到处调用 LogUtils 了,声明哪些地方需要打印日志,这个地方就是一个切面,AOP 会在适当的时机为你把打印语句插进切面。
1 | class classA { |
如果说 OOP 是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。利用 AOP 思想,这样对业务逻辑的各个部分进行了隔离,从而降低业务逻辑各部分之间的耦合,提高程序的可重用性,提高开发效率。
OOP 与 AOP 的区别
- 面向目标不同:简单来说 OOP 是面向名词领域,AOP 面向动词领域。
- 思想结构不同:OOP 是纵向结构,AOP 是横向结构。
- 注重方面不同:OOP 注重业务逻辑单元的划分,AOP偏重业务处理过程中的某个步骤或阶段。
OOP 与 AOP 的联系
- 两者之间是一个相互补充和完善的关系。
应用场景
只要系统的业务模块都需要引用通用模块,就可以使用AOP。
以下是一些常用的业务场景:
- 参数校验和判空
- 权限控制
- 埋点
- 日志记录
- 事件防抖
- 异常处理
Decorator装饰器(ES7、TypeScript)
修饰类
1 | @testable |
基本上,修饰器的行为就是下面这样:1
2
3
4
5
6@decorator
class A {}
// 等于
class A {}
A = decorator(A) || A;
也就是说,修饰器是一个对类进行处理的函数。修饰器函数的第一个参数,就是所要修饰的目标类。如果觉得一个参数不够用,可以在修饰器外面再封装一层函数。
1 | function testable(isTestable) { |
注意,修饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。
修饰方法
修饰器不仅可以修饰类,还可以修饰类的属性1
2
3
4class Person {
@test
say () {}
}
修饰器第一个参数是类的原型对象,上例是Person.prototype,修饰器的本意是要“修饰”类的实例,但是这个时候实例还没生成,所以只能去修饰原型(这不同于类的修饰,那种情况时target参数指的是类本身);第二个参数是所要修饰的属性名,第三个参数是该属性的描述对象。
使用
实现mixins:
1 | // mixins.js |
实现readonly属性
1 | class Person { |
Author
My name is Micheal Wayne and this is my blog.
I am a front-end software engineer.
Contact: michealwayne@163.com