js的函数式编程和aop

  • build time: 2018-08-24 13:00:45
  • update time: 2019-06-02 21:35:10

软件开发的本质是组合,下雨天FP和OOP搭配会更好哦。

在大概 2006 年以前,JavaScript 被广泛的看作玩具语言和被用制作浏览器中的动画,但是它里面隐藏着一些极其强大的特性。即 lambda 表达式中最重要的特性。伴随的JavaScript的崛起,人们开始暗中讨论一个叫做 “函数式编程”的东西。。。

1 函数式编程

函数式编程(英语:functional programming)或称函数程序设计,又称泛函数编程,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及易变物件。函数式语言最重要的基础是λ演算(lambda calculus)。而且从λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。 –wiki

阿隆佐·邱奇发明了 lambda 表达式。lambda 表达式是基于函数应用的通用计算模型。艾伦·图灵因为图灵机而知名。图灵机使用定义一个在磁带上操作符号的理论装置来计算的通用模型。
函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段。

函数式编程是声明式编程的一部分。

1.1 廖雪峰《函数式编程》 我们通过把大段代码拆成函数,通过一层一层的函数调用,就可以把复杂任务分解成简单的任务,这种分解可以称之为面向过程的程序设计。函数就是面向过程的程序设计的基本单元。

在计算机的层次上,CPU执行的是加减乘除的指令代码,以及各种条件判断和跳转指令,所以,汇编语言是最贴近计算机的语言。
而计算则指数学意义上的计算,越是抽象的计算,离计算机硬件越远。
对应到编程语言,就是越低级的语言,越贴近计算机,抽象程度低,执行效率高,比如C语言;越高级的语言,越贴近计算,抽象程度高,执行效率低,比如Lisp语言。
函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。

函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数!

1.2 阮一峰《函数式编程初探》

简单说,”函数式编程”是一种”编程范式”(programming paradigm),也就是如何编写程序的方法论。

它属于”结构化编程”的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。举例来说,现在有这样一个数学表达式:

1
(1 + 2) * 3 - 4

传统的过程式编程,可能这样写:

1
2
3
4
5
var a = 1 + 2;

  var b = a * 3;

  var c = b - 4;

函数式编程要求使用函数,我们可以把运算过程定义为不同的函数,然后写成下面这样:

1
var result = subtract(multiply(add(1,2), 3), 4);

函数式编程的五个特点

  • 函数是“第一等公民”:指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
  • 只用”表达式”,不用”语句”:”表达式”(expression)是一个单纯的运算过程,总是有返回值;”语句”(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。
  • 没有”副作用”:指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。
  • 不修改状态:函数式编程只是返回新的值,不修改系统变量。
  • 引用透明:指的是函数的运行不依赖于外部变量或”状态”,只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

1.3 函数式编程核心概念

函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用。

函数式编程常用的核心概念:

  • 纯函数
  • 函数柯里化
  • 函数组合(合成)
  • Point Free
  • 声明式与命令式代码
  • 核心概念

1.3.1 纯函数

对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态的函数,叫做纯函数。


1
2
3
4
5
6
7
8
9
var 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]

1.3.2 函数柯里化

在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,尽管它是 Moses Schnfinkel 和 Gottlob Frege 发明的。

栗子1:

1
2
3
4
5
const 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
2
3
// 纯
const checkAge = min => (age => age > min);
const checkAge18 = checkAge(18);

栗子3:

1
2
3
4
// 未柯里化
function add (x, y) {
return x + y;
}

1
2
3
4
5
6
// 柯里化后
function add (x) {
return function (x) {
x + y;
}
}

事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。

1.3.3 函数组合(合成)

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做”函数的合成”(compose)。
洋葱代码:h(g(f(x)))

1
2
3
4
5
const compost = function (f, g) {
return function (x) {
return f(g(x))
}
};

1
2
3
4
5
const compose = (f, g) => (x => f(g(x)));
let first = arr => arr[0];
let reverse = arr => arr.reverse();
let last = compose(first, reverse);
last([1, 2, 3, 4, 5]); // 5

1.3.4 Point Free

把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量。

栗子:

1
const f = str => str.toUpperCase().split(' ');

函数组合去改造一下:

1
2
3
4
let toUpperCase = word => word.toUpperCase();
let split = x => (str => str.split(x));
let f = compose(split(' '), toUpperCase);
f('abcd efgh');

把一些对象自带的方法转化成纯函数,然后通过函数组合去调用,这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。

1.3.5 声明式与命令式代码

1
2
3
4
5
6
7
//命令式
let CEOs = [];
for (let i = 0; i < companies.length; i++) {
CEOs.push(companies[i].CEO)
}
//声明式
let CEOs = companies.map(c => c.CEO);

数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可。相反,不纯的函数式的代码会产生副作用或者依赖外部系统环境,使用它们的时候总是要考虑这些不干净的副作用。在复杂的系统中,这对于我们的心智来说是极大的负担。

1.3.6 核心概念

高阶函数

高阶函数,就是把函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。高阶组件常运用于React的高阶组件。

1
2
3
4
5
6
7
8
9
//命令式
let add = function (a, b) {
return a + b;
};

function math(func, array) {
return func(array[0], array[1]);
}
math(add, [1, 2]); // 3
递归与尾递归

指函数内部的最后一个动作是函数调用。该调用的返回值,直接返回给函数。函数调用自身,称为递归。如果尾调用自身,就称为尾递归。递归需要保存大量的调用记录,很容易发生栈溢出错误,如果使用尾递归优化,将递归变为循环,那么只需要保存一个调用记录,这样就不会发生栈溢出错误了。通俗点说,尾递归最后一步需要调用自身,并且之后不能有其他额外操作。

1
2
3
4
5
6
7
8
9
10
// 不是尾递归,无法优化
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
} //ES6强制使用尾递归

尾递归能有效的防止堆栈溢出。 在ECMAScript6,我们将迎来尾递归优化,通过尾递归优化,javascript代码在解释成机器码的时候,将会向while看起,也就是说,同时拥有数学表达能力和while的效能。

范畴与容器
  • 1.函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
  • 2.函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
容器与函子(Functor)

$(…) 返回的对象并不是一个原生的 DOM 对象,而是对于原生对象的一种封装,这在某种意义上就是一个“容器”(但它并不函数式)。

Functor(函子)遵守一些特定规则的容器类型。任何具有map方法的数据结构,都可以当作函子的实现。
Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。

下面我们看下函子的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var 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
2
3
4
5
6
7
8
9
10
11
12
class Functor {
constructor(val) {
this.val = val;
}
map(f) {
return new Functor(f(this.val));
}
}
(new Functor(2)).map(function (two) {
return two + 2;
});
// Functor(4)

上面代码中,Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val))。
一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。上面的例子说明,函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器—-函子。函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。
因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。

你可能注意到了,上面生成新的函子的时候,用了new命令。这实在太不像函数式编程了,因为new命令是面向对象编程的标志。函数式编程一般约定,函子有一个of方法,用来生成新的容器。

1
2
3
4
5
6
7
Functor.of = function (val) {
return new Functor(val);
};
Functor.of(2).map(function (two) {
return two + 2;
});
// Functor(4)

一些常用的函子:

Maybe 函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
var Maybe = function (x) {
this.__value = x;
}
Maybe.of = function (x) {
return new Maybe(x);
}
Maybe.prototype.map = function (f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
Maybe.prototype.isNothing = function () {
return (this.__value === null || this.__value === undefined);
}
//新的容器我们称之为 Maybe(原型来自于Haskell,Haskell是通用函数式编程语言)

1
2
3
4
5
6
7
8
9
Functor.of(null).map(function (s) {
return s.toUpperCase();
});
// TypeError

Maybe.of(null).map(function (s) {
return s.toUpperCase();
});
// Maybe(null)
错误处理、Either函子

我们的容器能做的事情太少了,try/catch/throw 并不是“纯”的,因为它从外部接管了我们的函数,并且在这个函数出错时抛弃了它的返回值。Promise 是可以调用 catch 来集中处理错误的。事实上 Either 并不只是用来做错误处理的,它表示了逻辑或。

条件运算if…else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Either extends Functor {
constructor(left, right) {
this.left = left;
this.right = right;
}
map(f) {
return this.right ?
Either.of(this.left, f(this.right)) :
Either.of(f(this.left), this.right);
}
}
Either.of = function (left, right) {
return new Either(left, right);
};

使用Either函子:

1
2
3
4
5
6
7
8
9
10
11
12
var 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
2
3
4
5
class Ap extends Functor {
ap(F) {
return Ap.of(this.val(F.val));
}
}

1
2
3
function addOne(x) {
return x + 1;
}Ap.of(addOne).ap(Functor.of(1)) // ap函子,让addOne可以用后面函子中的val运算 结果为Ap(2)
IO函子

IO 跟前面那几个 Functor 不同的地方在于,它的 __value 是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO 包含的是被包裹的操作的返回值。
IO其实也算是惰性求值。
IO负责了调用链积累了很多很多不纯的操作,带来的复杂性和不可维护性

1
2
3
4
5
class IO extends Monad {
map(f) {
return IO.of(compose(f, this.__value))
}
}

在这里,我们提到了Monad,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。Promise 就是一种 Monad。Monad 让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其它异步任务。

1
2
3
4
5
6
7
8
class Monad extends Functor {
join() {
return this.val;
}
flatMap(f) {
return this.map(f).join();
}
}

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

AOP 是 Aspect Oriented Programming 的缩写,译为面向切向编程。用我们最常用的 OOP 来对比理解:
纵向关系 OOP,横向角度 AOP

如,假设设计一个日志模块。按 OOP 思想,我们会设计一个打印日志 LogUtils 类,然后在需要打印的地方引用即可。

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
// 打印日志类
class LogUtils {
clickStat(id, name) {
console.log(`Click Stat. Log id is: ${id}, name is : ${name}`);
}

jumpStat(id, name) {
console.log(`Jump Stat. Log id is: ${id}, name is : ${name}`);
}
}

// 使用

// A类中
class classA {
init () {
LogUtils.clickStat('a', 'classA');
}
}

// B类中
class classB {
initStat () {
LogUtils.jumpStat('b', 'classB');
}
}

这个类是横跨并嵌入众多模块里的,在各个模块里分散得很厉害,到处都能见到。从对象组织角度来讲,我们一般采用的分类方法都是使用类似生物学分类的方法,以「继承」关系为主线,我们称之为纵向,也就是 OOP。设计时只使用 OOP思想可能会带来两个问题:

  • 对象设计的时候一般都是纵向思维,如果这个时候考虑这些不同类对象的共性,不仅会增加设计的难度和复杂性,还会造成类的接口过多而难以维护(共性越多,意味着接口契约越多)。
  • 需要对现有的对象 动态增加 某种行为或责任时非常困难。

而AOP就可以很好地解决以上的问题,怎么做到的?除了这种纵向分类之外,我们从横向的角度去观察这些对象,无需再去到处调用 LogUtils 了,声明哪些地方需要打印日志,这个地方就是一个切面,AOP 会在适当的时机为你把打印语句插进切面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


class classA {
@Log({
type: 'click',
id: 'a',
name: 'classA'
})
init () {}
}

class classB {
@Log({
type: 'jump',
id: 'a',
name: 'classA'
})
init () {}
}

如果说 OOP 是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。利用 AOP 思想,这样对业务逻辑的各个部分进行了隔离,从而降低业务逻辑各部分之间的耦合,提高程序的可重用性,提高开发效率。
aop001

OOP 与 AOP 的区别

  • 面向目标不同:简单来说 OOP 是面向名词领域,AOP 面向动词领域。
  • 思想结构不同:OOP 是纵向结构,AOP 是横向结构。
  • 注重方面不同:OOP 注重业务逻辑单元的划分,AOP偏重业务处理过程中的某个步骤或阶段。

OOP 与 AOP 的联系

  • 两者之间是一个相互补充和完善的关系。

应用场景

只要系统的业务模块都需要引用通用模块,就可以使用AOP。

以下是一些常用的业务场景:

  • 参数校验和判空
  • 权限控制
  • 埋点
  • 日志记录
  • 事件防抖
  • 异常处理

Decorator装饰器(ES7、TypeScript)

文档

修饰类

1
2
3
4
5
6
7
8
9
10
@testable
class MyTestableClass {
// ...
}

function testable(target) {
target.isTestable = true;
}

MyTestableClass.isTestable // true

基本上,修饰器的行为就是下面这样:

1
2
3
4
5
6
@decorator
class A {}

// 等于
class A {}
A = decorator(A) || A;

也就是说,修饰器是一个对类进行处理的函数。修饰器函数的第一个参数,就是所要修饰的目标类。如果觉得一个参数不够用,可以在修饰器外面再封装一层函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}

@testable(true)
class MyTestableClass {}
MyTestableClass.isTestable // true

@testable(false)
class MyClass {}
MyClass.isTestable // false

注意,修饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。

修饰方法

修饰器不仅可以修饰类,还可以修饰类的属性

1
2
3
4
class Person {
@test
say () {}
}

修饰器第一个参数是类的原型对象,上例是Person.prototype,修饰器的本意是要“修饰”类的实例,但是这个时候实例还没生成,所以只能去修饰原型(这不同于类的修饰,那种情况时target参数指的是类本身);第二个参数是所要修饰的属性名,第三个参数是该属性的描述对象。

使用

实现mixins:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mixins.js
export function mixins(...list) {
return function (target) {
Object.assign(target.prototype, ...list)
}
}

// main.js
import { mixins } from './mixins'

const Foo = {
foo() { console.log('foo') }
};

@mixins(Foo)
class MyClass {}

let obj = new MyClass();
obj.foo() // 'foo'

实现readonly属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14

class Person {
this.name = 'Wayne';

@readonly
name () {
return this.name
}
}

function readonly (target, name, descriptor) {
descriptor.writable = false;
return descriptor;
}

相关链接