课程介绍
课程背景:
- 前端的主要编程语言为 JavaScript。
- JavaScript 作为一种融合了多种编程范式的语言,灵活性非常高。
- 前端开发人员需要根据场景在不同编程范式间自由切换。
- 进一步需要创造领域特定语言抽象业务问题。
课程收益:
- 了解不同编程范式的起源和使用场景。
- 掌握 JavaScript 在不同编程范式特别是函数式编程范式的使用。
- 掌握创建领域特定语言的相关工具和模式。
编程语言
机器语言
汇编语言
高级语言
C / C++
C:“中级语言” 过程式语言代表
- 可对位,字节,地址直接操作
- 代码和数据分离倡导结构化编程
- 功能齐全:数据类型和控制逻辑多样化
- 可移植能力强
C++:面向对象语言代表
- C with Classes
- 继承
- 权限控制
- 虚函数
- 多态
Lisp
Lisp:函数式语言代表
- 与机器无关
- 列表:代码即数据
- 闭包
JavaScript
基于原型和头等函数的多范式语言
- 过程式
- 面向对象
- 函数式
- 响应式*
编程范式
什么是编程范式
常见编程范式
过程式编程
- 自顶向下
- 结构化编程
自顶向下
结构化编程
JS 中的面向过程
面向过程
面向过程式编程有什么缺点?
- 数据与算法关联弱
- 不利于修改和扩充
- 可维护性差:面向过程式编程缺乏封装性和抽象性,代码的耦合度高,修改代码时容易影响其他部分的代码,导致维护性差
- 不利于代码重用
- 可扩展性差:面向过程式编程很难对程序进行扩展,因为程序的逻辑分散在各个函数或过程中,很难进行整体性的扩展
为什么后面会出现面向对象
面向对象解决了这几个问题:
- 可读性好:面向对象编程将数据和函数封装在一起,代码的可读性好,易于理解整个程序的逻辑。
- 可维护性好:面向对象编程具有封装性和抽象性,代码的耦合度低,修改代码时只需要修改对象的内部实现,不会影响其他部分的代码,导致维护性好。
- 可扩展性好:面向对象编程将数据和函数封装在一起,对象之间通过接口进行交互,易于对程序进行扩展。
面向对象编程
- 封装
- 继承
- 多态
- 依赖注入*
封装
- 关联数据与算法
继承
- 无需重写的情况下进行功能扩充
多态
- 不同的结构可以进行接口共享,进而达到函数复用
依赖注入
- 去除代码耦合
面向对象编程五大原则
-
单一职责原则SRP (Single Responsibility Principle)
一个类只负责一个功能领域中的相应职责,或者可以定义为一个类只有一个引起它变化的原因。这个原则的目的是将职责分离,提高类的内聚性,降低类的耦合性,使得代码更加灵活、可维护和可扩展。
-
开放封闭原则OCP (Open-Close Principle)
一个软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这个原则的目的是使得代码更加灵活、可扩展和可维护,同时也能降低代码的风险和复杂度。通过使用抽象化和多态等技术,使得代码能够适应不同的需求和变化。
-
里式替换原则LSP (the Liskov Substitution Principle LSP)
所有引用基类(父类)的地方必须能透明地使用其子类的对象。这个原则的目的是保证代码的正确性和可靠性,避免在子类中破坏父类的行为和逻辑。通过遵循这个原则,可以使得代码更加灵活、可扩展和可维护。
-
依赖倒置原则DIP (the Dependency Inversion Principle DIP)
高层模块不应该依赖于底层模块,两者都应该依赖于抽象;抽象不应该依赖于具体实现,具体实现应该依赖于抽象。这个原则的目的是降低代码的耦合性,提高代码的灵活性和可扩展性。通过使用接口和抽象类等技术,使得代码能够适应不同的需求和变化。
-
接口分离原则ISP (the Interface Segregation Principle ISP)
一个类不应该依赖于它不需要的接口,一个类应该只依赖于它需要的接口。这个原则的目的是降低代码的耦合性,提高代码的灵活性和可扩展性。通过将接口进行分离,使得代码更加灵活、可维护和可扩展。
面向对象编程有什么缺点? 为什么我们推荐函数式编程
函数式编程
- 函数是 “第一等公民”
- 纯函数 / 无副作用
- 高阶函数 / 闭包
“第一等公民” First Class Function
所谓 "第一等公民"(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。
纯函数 Pure Function
优势
- 可缓存
- 可移植
- 可测试
- 可推理
- 可并行
通过上述两段代码,可以看出代码二的如下优势:
- 增加了代码的可测试性:由于代码2中的函数返回了一个新对象,而不是直接修改原对象,因此可以更方便地进行单元测试,避免了测试过程中修改原对象的副作用。
- 增加了代码的可维护性:由于代码2中的函数不直接修改原对象(在React中这个称之为不可变的力量),而是返回一个新对象,因此更容易维护和修改。如果要修改函数的行为,只需要修改函数内部的代码即可,不会对其他代码产生影响。
- 增加了代码的可读性:由于代码2中的函数返回了一个新对象,而不是直接修改原对象,因此代码的含义更加清晰明确。同时,代码2中的函数使用了解构赋值和对象展开运算符,使得代码更加简洁、易读。
柯里化 Currying
什么是柯里化?
柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数 (最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。
柯里化的结构
// 一般结构
// function add(x, y, z) {
// return x + y + z
// }
// var result = add(10, 20, 30)
// console.log(result)
// 柯里化
function sum1(x) {
return function(y) {
return function(z) {
return x + y + z
}
}
}
var result1 = sum1(10)(20)(30)
console.log(result1)
// 简化柯里化的代码
var sum2 = x => y => z => {
return x + y + z
}
console.log(sum2(10)(20)(30))
// 再次简化
var sum3 = x => y => z => x + y + z
console.log(sum3(10)(20)(30))
柯里化的优点
让函数职责单一
在函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理;
那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完后在下一个函数中再使用处理后的结果; 比如上面的案例我们进行一个修改,传入的函数需要分别被进行如下处理:
第一个参数 + 2 、第二个参数 * 2、 第三个参数 ** 2;
// function add(x, y, z) {
// x = x + 2
// y = y * 2
// z = z * z
// return x + y + z
// }
// console.log(add(10, 20, 30))
// 柯里化处理
function sum(x) {
x = x + 2
return function(y) {
y = y * 2
return function(z) {
z = z * z
return x + y + z
}
}
}
console.log(sum(10)(20)(30))
柯里化的复用
另外一个使用柯里化的场景是可以帮助我们可以复用参数逻辑: makeAdder 函数要求我们传入一个 num(并且如果我们需要的话,可以在这里对 num 进行一些修改);在之后使用返回的函数时,我们不需要再继续传入num了;
// function sum(m, n) {
// return m + n
// }
// // 假如在程序中,我们经常需要把5和另外一个数字进行相加
// console.log(sum(5, 10))
// console.log(sum(5, 14))
// console.log(sum(5, 1100))
// console.log(sum(5, 555))
function makeAdder(count) {
count = count * count
return function(num) {
return count + num
}
}
// var result = makeAdder(5)(10)
// console.log(result)
var adder5 = makeAdder(5)
adder5(10)
adder5(14)
adder5(1100)
adder5(555)
自动柯里化函数
//柯里化的函数实现
function lyCurrying(fn){
function curried(...args){
// 判断当前已经接收的参数的个数, 接受的参数本身需要接受的参数是否已经一致了
// 1.当已经传入的参数 大于等于 需要的参数时, 就执行函数
if(args.length>=fn.length){
return fn.apply(this,args)
}else{
// 没有达到个数时, 需要返回一个新的函数, 继续来接收的参数
function curried2(...args2){
// 接收到参数后, 需要递归调用curried来检查函数的个数是否达到
return curried.apply(this, args.concat(args2))
}
return curried2
}
}
return curried
}
const curryAdd = lyCurrying(add1)
console.log(curryAdd(10, 20, 30))
console.log(curryAdd(10, 20)(30))
console.log(curryAdd(10)(20)(30))
案例
//打印日志时间
function log(date,type,message){
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`)
}
log(new Date(),'DEBUG','查找到轮播图的bug')//[22:24][DEBUG]:[查找到轮播图的bug]
log(new Date(),'DEBUG','查询菜单的bug')//[22:24][DEBUG]:[查询菜单的bug]
log(new Date(),'DEBUG','查询数据的bug')//[22:24][DEBUG]:[查询数据的bug]
---------------------------------------------------------------------------------------------
//柯里化优化
var log = date => type => message =>{
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`)
}
//如果我打印的都是当前的时间,我们就可以将时间复用
var nowLog = log(new Date());
nowLog("DEBUG")("查找小满去哪了")//[22:32][DEBUG]:[查找小满去哪了]
//或者时间+类型都全部复用
var nowLog1 = log(new Date())("小满系列查找");
nowLog1("查找小满人去哪了")//[22:34][小满系列查找]:[查找小满人去哪了]
nowLog1("查找小满的黑丝去哪了")//[22:34][小满系列查找]:[查找小满的黑丝去哪了]
nowLog1("查找小满的裤衩子被谁拿走了")//[22:34][小满系列查找]:[查找小满的裤衩子被谁拿走了]
nowLog1("查找小满有没有去按摩店找小姐姐")//[22:34][小满系列查找]:[查找小满有没有去按摩店找小姐姐]
组合函数 Composition
组合函数 是在 JavaScript 开发过程中一种对函数的使用技巧、模式:
- 比如我们现在需要对某一个数据进行函数的调用,执行两个函数fn1和fn2,这两个函数是依次执行的;
- 那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复
- 那么是否可以将这两个函数组合起来,自动依次调用呢?
- 这个过程就是对函数的组合,我们称之为 组合函数(Compose Function);
function double(num){
return num*2
}
function square(num){
return num ** 2 //平方
}
var count = 10
var result = square(double(count))
console.log(result);
//如何将double和square结合起来,实现简单的组合函数
function composeFn(m,n){
return function(count){
return n(m(count))
}
}
var newFn = composeFn(double,square)
console.log(newFn(10));
容器 Container
容器可以想象成一个瓶子,也就是一个对象,里面可以放各种不同类型的值。想想,瓶子还有一个特点,跟外界隔开,只有从瓶口才能拿到里面的东西;类比看看, container 回暴露出接口供外界操作内部的值。
可以当做容器的类型,类型支付对容器内元素进行操作,常见的 functor:
Array(Iterable).map, Promise.then
一个典型的容器示例:
var Container = function(x) {
this.__value = x;
}
Container.of = function(x) {
return new Container(x);
}
Container.of("test")
// 在chrome下会打印出
// Container {__value: "test"}
我们已经实现了一个容器,并且实现了一个把值放到容器里面的 Container.of方法,简单看,它像是一个利用工厂模式创建特定对象的方法。of方法正是返回一个container。
函子 functor
上面容器上定义了of方法,functor的定义也类似
Functor 是实现了
map函数并遵守一些特定规则的容器类型。
把值留在容器中,只能暴露出map接口处理它。函子是非常重要的数据类型,后面会讲到各种不同功能的函子,对应处理各种依赖外部变量状态的问题。
Container.prototype.map = function(f) {
return Container.of(f(this.__value))
}
把即将处理容器内变量的函数,包裹在map方法里面,返回的执行结果也会是一个Container。
这样有几点好处:
- 保证容器内的value一直不会暴露出去,
- 对value的操作方法最终会交给容器执行,可以决定何时执行。
- 方便链式调用
Monad
一个functor, 只要他定义了一个join 方法和一个of 方法,那么它就是一个monad。 它可以将多层相同类型的嵌套扁平化,像剥洋葱一样。关键在于它比一般functor 多了一个join 方法。
这里的 unit 有时候也叫 of,return 等名字,bind 有时候叫 chain,map/join 等名字。
可以去除嵌套容器的容器类型
常见 monad:Array.flatMap, Promise.then
根据维基百科的定义,Monad 由以下三个部分组成:
-
一个类型构造函数(M),可以构建出一元类型
M<T>。 -
一个类型转换函数(return or unit),能够把一个原始值装进 M 中。
-
- unit(x) :
T -> M T
- unit(x) :
-
一个组合函数 bind,能够把 M 实例中的值取出来,放入一个函数中去执行,最终得到一个新的 M 实例。
-
M<T>执行T-> M<U>生成M<U>
除此之外,它还遵守一些规则:
- 单位元规则,通常由 unit 函数去实现。
- 结合律规则,通常由 bind 函数去实现。
单位元:是**集合里的一种特别的元素,与该集合里的二元运算**有关。当单位元和其他元素结合时,并不会改变那些元素。
乘法的单位元就是 1,任何数 x 1 = 任何数本身、1 x 任何数 = 任何数本身。
加法的单位元就是 0,任何数 + 0 = 任何数本身、0 + 任何数 = 任何数本身。
以下用 js 来模拟:
class Monad {
value = "";
// 构造函数
constructor(value) {
this.value = value;
}
// unit,把值装入 Monad 构造函数中
unit(value) {
this.value = value;
}
// bind,把值转换成一个新的 Monad
bind(fn) {
return fn(this.value);
}
}
// 满足 x-> M(x) 格式的函数
function add1(x) {
return new Monad(x + 1);
}
// 满足 x-> M(x) 格式的函数
function square(x) {
return new Monad(x * x);
}
// 接下来,我们就能进行链式调用了
const a = new Monad(2)
.bind(square)
.bind(add1);
//...
console.log(a.value === 5); // true
上述代码就是一个最基本的 Monad,它将程序的多个步骤抽离成线性的流,通过 bind 方法对数据流进行加工处理,最终得到我们想要的结果。
应用函子 Applicative
直接对两个容器操作
函子的值可以是函数,我们把value值是函数的函子称之为可应用的函子。但是这样理解不完整,因为我们看不到它的”可应用性“体现在哪里?函数(function)可以被应用,除了可以直接调用,它还有一个apply方法,可以把函数应用于其它对象。
[].map.apply([1,2,3],[n=>n*2]) // [2,4,6]
Applicative 函子 也应该具备一个apply方法,使它可以应用于另外一个函子上面。事实也正如此,按照规则Applicative应该具备一个 ap方法,用途类似于函数的 apply方法。
class Applicative<T> extends Pointed<T> {
public static of<T>(val:T){
return new Applicative(val)
}
isNothing(){
return this.value === null || this.value === undefined
}
public map<U>(fn:(val:T)=>U){
if (this.isNothing()) return Applicative.of(null)
let rst = fn(this.value)
return Applicative.of(rst)
}
public ap(O)){
return O.map(this.value)
}
}
做为可应用的函子,Applicative适用的场景有很多。比如Currying函数多次传参数链式调用。
import { curry } from 'ramda'
const add = curry((a,b,c)=> a + b + c)
// Applicative { value: 10 }
Applicative.of(add(2)).ap(Applicative.of(3)).ap(Applicative.of(5))
这里还有一个很有意思的特性可以了解一下,有助于我们更好理解和掌握Applicative。把一个值封装成函子,然后map到一个函数,跟把函数封装成函子,然后ap到一个封装值的函子,是等价的。用表达式表示就是:
F.of(x).map(f) == F.of(f).ap(F.of(x))
一句话总结:Applicative 函子就是 value 值是函数,且实现了 ap 方法的 Pointed 函子。
函数式编程--Functor、Applicative、Monad
响应式编程
异步 / 离散的函数式编程
- 数据流
- 操作符
- 过滤
- 合并
- 转化
- 高阶
维基百科定义:在计算中,响应式编程或反应式编程(英语:Reactive programming)是一种面向数据流和变化传播的 声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。
- 通俗来说,响应式编程就是一种处理数据流的编程方式。我们可以把数据流看成一条河流,数据就像是水流一样从上游流向下游。在响应式编程中,我们可以方便地定义这条河流,并在河流中处理数据的变化,就像是在河流中处理水流一样。这样,我们就可以很方便地处理数据的变化,而不需要手动追踪和处理每一个数据变化的位置。
没有纯粹的响应式编程语言,我们需要借助工具库的帮忙,例如 RxJS
观察者模式 Observable
观察者模式(Observer Pattern)是一种设计模式,它定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象,当主题对象发生变化时,它的所有观察者都会收到通知并自动更新。
在观察者模式中有以下登场角色:
-
Subject (观察对象)
Subject 角色表示观察对象。Subject 角色定义了注册观察者和删除观察者的方法。此外,它还声明了 “获取现在状态” 的方法。
-
ConcreteSubject (具体的观察对象)
ConcreteSubject 角色表示具体的被观察对象。当自身状态发生变化后,它会通知所有已经注册的 Observer 角色。
-
Observer (观察对象)
Observer 角色负责接收来自 Subject 角色的状态变化的通知。为此,它声明了
update方法。 -
ConcreteObserver (具体的观察者)
ConcreteObserver 角色表示具体的 Observer。当它的
update方法被调用后,回去获取要观察的对象的最新状态。
观察者模式的优点包括:
- 松耦合:观察者模式将主题对象和观察者对象之间解耦,使得它们可以独立地变化和扩展。
- 可复用性:由于观察者对象可以动态地添加和删除,因此可以在不修改主题对象的情况下增加新的观察者对象,提高了代码的可复用性。
- 扩展性:在观察者模式中,可以灵活地添加和删除观察者对象,因此可以方便地扩展和修改系统的功能。
观察者模式适用于根据对象状态进行相应处理的场景。
迭代器模式 Iterator
迭代器模式(Iterator Pattern)是一种设计模式,它提供了一种顺序访问聚合对象中的元素,而不需要暴露聚合对象的内部表示。迭代器模式可以将遍历聚合对象的过程从聚合对象中分离出来,从而可以简化聚合对象的实现和遍历算法的实现。可以类比为 Promise 和 EventTraget 超集。
在迭代器模式中有以下登场角色:
-
Iterator (迭代器)
该角色负责定义按顺序逐个遍历元素的接口(API)。
-
Concretelterator (具体的迭代器)
该角色负责实现 Iterator 角色所定义的接口(API)。
-
Aggregate (集合)
该角色负责定义创建 Iterator 角色的接口(API)。这个接口是一个方法,会创建出 “按顺序访问保存在我内部元素的人”。
-
ConcreteAggregate (具体的集合)
该角色负责实现 Aggregate 角色所定义的接口(API)。它会创建出具体的 Iterator 角色,即 ConcreteIerator 角色。
迭代器模式的优点包括:
- 简化聚合对象的实现:由于迭代器模式将遍历聚合对象的过程从聚合对象中分离出来,因此可以简化聚合对象的实现,使其只需要关注自己的核心业务逻辑。
- 提高聚合对象的访问效率:在迭代器模式中,迭代器对象可以提供不同的遍历算法,从而可以针对不同的应用场景进行优化,提高聚合对象的访问效率。
- 提高代码的可复用性:由于迭代器模式将遍历算法从聚合对象中分离出来,因此可以方便地重用遍历算法,提高代码的可复用性。
迭代器模式在实际应用中广泛使用,例如Java中的Iterator接口、C++中的STL迭代器等等。它可以帮助我们更加方便地遍历聚合对象中的元素,提高代码的可读性和可维护性。
操作符
响应式编程的 “compose”
- 合并
- 过滤
- 转化
- 异常处理
- 多播
去除嵌套的 Observable
领域特定语言
什么是领域特定语言
领域特定语言(Domain-Specific Language,简称DSL)是一种 专门用于解决特定领域问题的编程语言。与通用编程语言相比,DSL 更加关注于特定领域的问题,使得针对该领域的编程变得更加高效、简单和直观。
DSL 的设计是为了解决特定领域的问题,因此它可以更加贴近领域的需求和特点,提供更加便捷和高效的解决方案。DSL通常具有简单的语法和丰富的领域专业术语,使得开发人员可以更加专注于解决领域问题,而无需关注底层技术实现。
DSL 的应用场景包括但不限于:配置文件、工作流程、数据分析、模型定义等。在这些领域中,DSL 可以提供更加高效、直观和易于维护的解决方案,提升开发效率和代码质量。
- HTML
- SQL
与之相对应的是 General-purpose language(通用语言)
- C/C++
- JavaScript
- ....
特定语言需要由通用语言实现,通用语言无法由特定语言实现。
创造 DSL
语言运行
SQL Token 分类
- 注释
- 关键字
- 操作符
- 空格
- 字符串
- 变量
lexer
parser
上下文无关语法规则
- 推导式:表示非终结符到(非终结符或终结符)的关系。
- 终结符:构成句子的实际内容。可以简单理解为词法分析中的token.
- 非终结符:符号或变量的有限集合。它们表示在句子中不同类型的短语或子句。
Parser_LL
LL:从左到右检查,从左到右构建语法树
Parser_LR
LR:从左到右检查,从右到左构建语法树
LL(K) > LR(1) > LL(1),括号里的内容为构建语法树需要向下看的数量
tools
利用工具让我们只需要关注语法方面的问题,语法分析则交给工具来做。
visitor
在运行parser.parse后生成如下语法树: