编程范式 | 青训营笔记

543 阅读20分钟

课程介绍

课程背景

  1. 前端的主要编程语言为 JavaScript。
  2. JavaScript 作为一种融合了多种编程范式的语言,灵活性非常高。
  3. 前端开发人员需要根据场景在不同编程范式间自由切换。
  4. 进一步需要创造领域特定语言抽象业务问题。

课程收益

  1. 了解不同编程范式的起源和使用场景。
  2. 掌握 JavaScript 在不同编程范式特别是函数式编程范式的使用。
  3. 掌握创建领域特定语言的相关工具和模式。

编程语言

机器语言

汇编语言

高级语言

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

优势

  • 可缓存
  • 可移植
  • 可测试
  • 可推理
  • 可并行

通过上述两段代码,可以看出代码二的如下优势:

  1. 增加了代码的可测试性:由于代码2中的函数返回了一个新对象,而不是直接修改原对象,因此可以更方便地进行单元测试,避免了测试过程中修改原对象的副作用。
  2. 增加了代码的可维护性:由于代码2中的函数不直接修改原对象(在React中这个称之为不可变的力量),而是返回一个新对象,因此更容易维护和修改。如果要修改函数的行为,只需要修改函数内部的代码即可,不会对其他代码产生影响。
  3. 增加了代码的可读性:由于代码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。 这样有几点好处:

  1. 保证容器内的value一直不会暴露出去,
  2. 对value的操作方法最终会交给容器执行,可以决定何时执行。
  3. 方便链式调用

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
  • 一个组合函数 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 方法对数据流进行加工处理,最终得到我们想要的结果。

15分钟了解 Monad

应用函子 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 方法被调用后,回去获取要观察的对象的最新状态。

观察者模式的优点包括:

  1. 松耦合:观察者模式将主题对象和观察者对象之间解耦,使得它们可以独立地变化和扩展。
  2. 可复用性:由于观察者对象可以动态地添加和删除,因此可以在不修改主题对象的情况下增加新的观察者对象,提高了代码的可复用性。
  3. 扩展性:在观察者模式中,可以灵活地添加和删除观察者对象,因此可以方便地扩展和修改系统的功能。

观察者模式适用于根据对象状态进行相应处理的场景。

迭代器模式 Iterator

迭代器模式(Iterator Pattern)是一种设计模式,它提供了一种顺序访问聚合对象中的元素,而不需要暴露聚合对象的内部表示。迭代器模式可以将遍历聚合对象的过程从聚合对象中分离出来,从而可以简化聚合对象的实现和遍历算法的实现。可以类比为 Promise 和 EventTraget 超集。

在迭代器模式中有以下登场角色:

  • Iterator (迭代器)

    该角色负责定义按顺序逐个遍历元素的接口(API)。

  • Concretelterator (具体的迭代器)

    该角色负责实现 Iterator 角色所定义的接口(API)。

  • Aggregate (集合)

    该角色负责定义创建 Iterator 角色的接口(API)。这个接口是一个方法,会创建出 “按顺序访问保存在我内部元素的人”。

  • ConcreteAggregate (具体的集合)

    该角色负责实现 Aggregate 角色所定义的接口(API)。它会创建出具体的 Iterator 角色,即 ConcreteIerator 角色。

迭代器模式的优点包括:

  1. 简化聚合对象的实现:由于迭代器模式将遍历聚合对象的过程从聚合对象中分离出来,因此可以简化聚合对象的实现,使其只需要关注自己的核心业务逻辑。
  2. 提高聚合对象的访问效率:在迭代器模式中,迭代器对象可以提供不同的遍历算法,从而可以针对不同的应用场景进行优化,提高聚合对象的访问效率。
  3. 提高代码的可复用性:由于迭代器模式将遍历算法从聚合对象中分离出来,因此可以方便地重用遍历算法,提高代码的可复用性。

迭代器模式在实际应用中广泛使用,例如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后生成如下语法树:

总结