yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
253 stars 12 forks source link

JavaScript 面向对象编程 #62

Open yinguangyao opened 3 years ago

yinguangyao commented 3 years ago

前言

面向对象是软件开发中的一种方法,现在被广泛使用。面向对象是使用一种抽象的方式来模拟现实世界的编程模式。在面向对象中,我们可以将程序看做一个个对象互相协作的结果。 面向对象具有三大特性和六大原则。三大特性是继承、多态和封装。六大原则是单一职责原则(SRP)、开放封闭原则(OCP)、里氏替换原则(LSP)、依赖倒置原则(DIP)、接口分离原则(ISP)、最少知识原则(LKP)。

image_1dml1scvj1o4830c1cnd1ucj1pmg9.png-55.5kB

面向对象的好处在于提高了程序开发的灵活性和可维护性,尤其是有利于模块化。

1. 鸭子类型

传统的静态类型语言中,在定义变量的时候就必须声明类型,这样有利于在编译阶段就能避免不必要的错误,但也会增加一些不必要的代码。 而在JS这种静态语言,只有在运行的时候才能确定真正的类型,虽然降低了后期的维护性,但也让我们可以更加专注于业务逻辑的编写。 所以我们在JS中使用一个对象的时候,完全不必考虑这个对象是否被设计为有这个方法。我们称之为「鸭子类型」。 如果一个动物走起路来像鸭子,叫起来也像鸭子,不管它本来是什么,都可以被认为是鸭子。 鸭子类型让我们更关注对象的行为和表现,而并非它自身的类型,我们应该更关注接口,而非实现。 用代码举个例子可能会更加直观:

class Duck {
    walk() {
        console.log("walk!");
    }
    voice() {
        console.log("voice!");
    }
}
class Chicken {
    walk() {
        console.log("walk!");
    }
    voice() {
        console.log("voice!");
    }
}
const duckWalk = (duck) => {
    duck.walk();
}
const duckVoice = (duck) => {
    duck.voice();
}
duckWalk(new Duck);
duckWalk(new Chicken);

我们在 duckWalk 方法中,不需要考虑传入的参数 duck 是什么类型,如果它有 方法,那么就可以被当做鸭子。 同理,如果一个对象拥有 pushpop 等方法,那么就可以被当做栈来使用。 如果是静态类型的语言中,和动态语言实现有什么区别呢?我这里用 TypeScript 来实现一下(没有 TypeScript 经验的童鞋可以跳过)。

// interface Performs
interface Performs {
    void walk();
    void voice();
}

// class Duck
class Duck implements Performs {
    public walk() {
        console.log("walk!");
    }

   public voice() {
        console.log("voice!");
    }
}

// class Chicken
class Chicken implements Performs {
    public walk() {
        console.log("walk!");
    }

    public voice() {
        console.log("voice!");
    }
}

class MainClass {
    public static inTheForest<T extends Performs> (duck: T) : void {
        duck.walk();
        duck.voice();
    }

    constructor() {
         let d: Duck = new Duck();
         let p: Person = new Person();
         MainClass.inTheForest(d);
         MainClass.inTheForest(p);
    }
}

其实可以看得出来,在 TypeScript 中必须将类型向上进行继承 `Performs 才能实现鸭子类型,这种实现方式比用 JavaScript 会麻烦很多。

2. 接口

上面我们提到了「接口」的概念,那么接口是什么意思呢? 接口一般是静态类型语言中的一个概念,比如上面 TypeScript 中的 interface Performs 就是一个接口,但是在 JavaScript 中并没有存在相关实现。

举个例子,就像我们平时使用的插座一样,插座会提供插座孔给你,你可以根据自己使用的电器的实际情况来选择插到哪个里面。这里的插座孔就是插座暴露给你的接口,具体插到插座孔里面做了什么事情,我们也不知道,也不必关心。 当然,我们可以通过 ES6 的继承来模拟接口,接口中定义抽象方法,子类继承后再做具体实现。

class Animal {
    walk() {}
    voice() {}
}
class Chicken extends Animal {
    public walk() {
        console.log("walk!");
    }

    public voice() {
        console.log("voice!");
    }
}
class Duck extends Animal {
    public walk() {
        console.log("walk!");
    }

   public voice() {
        console.log("voice!");
    }
}

因此,我们在编写一个程序的时候,应该将定义和实现进行分离,这样两者互不影响。只要实现了接口,那么不管是怎么实现的,我们都不用关心。

3. 三大特性

3.1 封装

前面我们说过面向对象有三大特性,封装就是其中一种特性。 顾名思义,封装就是指隐藏实现的细节,将一些具体的事物封装成抽象的事物。比如我们有一台电脑,我不用知道这台电脑内部的工作原理,但我依然可以用这台电脑访问网页、打游戏等等。 如果你想通过 id 来从数据库中查询一条 user 的信息,又不想每次查询都要写一串代码,这个时候就可以封装一个 findUserById 的方法。至于这个 findUserById 是怎么实现的,调用的时候并不用关心,你只需要关心输入和输出就行了。 我们可以用代码举个简单例子:

const findUserById = (id) => {
    // ...
}

封装的意义在于防止内部数据被篡改或者被破坏。我们可以将数据进行封装,只暴露出方法来对数据进行访问,避免类以外的操作修改数据。我们也可以将方法进行封装,对于使用者来说,不必关心方法实现的细节。只要提供的接口不变,就不会对使用者造成影响。 但是在 JavaScript 中不管是 Object 还是 Class 都还没有实现私有属性,只要是其中的属性,就一定能在类外被访问到,比如:

// Object
const student = {
    _age: 20,
    _sex: 'female',
    setAge(newAge) {
        this._age = newAge;
    },
    getAge() {
        return this._age;
    }
}
console.log(student._age); // _age虽然是私有变量,但依然能被外界访问。

// Class
class Student {
    constructor(age, sex) {
        this._age = age;
        this._sex = sex;
    }
    setAge(newAge) {
        this._age = newAge;
    },
    getAge() {
        return this._age;
    }
}
const student = new Student(20, 'female');
console.log(student._age); // _age虽然是私有变量,但依然能被外界访问。

因此我们可以利用闭包来对这个封装进行改良。

// 闭包版
const Student = function(age, sex) {
    let _age = age, _sex = sex;
    const setAge = (newAge) => {
        _age = 20;
    }
    const getAge = () => {
        return _age;
    }
    return {
        setAge,
        getAge
    }
}
const student = new Student(20, 'female');
console.log(student._age); // undefined

3.2 继承

继承是指可以在不编写更多代码的情况下,一个类可以使用另一个类上的属性或者方法。甚至父类可以只提供接口,让子类去实现。 一般来说,继承的类叫做「子类」或者「派生类」,而被继承的类叫做「父类」或者「超类」。 继承的好处在于可以将复用代码,无需将相同的功能写多次。 我们以 ES6 的 extends 关键字来一窥究竟。

class Animal {
    walk() {
        console.log(`the ${this.name} walks`);
    }
    eat() {}
}
class Duck extends Animal {
    name = 'duck'
    eat() {
        console.log("duck eats fish")
    }
}
class Cat extends Animal {
    name = 'cat'
    eat() {
        console.log("cat eats mice")
    }
}
const duck = new Duck();
duck.eat(); // 父类只提供了eat的接口,需要子类自己去实现
duck.walk(); // 即使Duck类上没有walk方法,依然可以从父类中调用。

在上篇《深入理解类与继承》中,我们已经对继承进行了比较详细的论述,需要进一步了解的可以移步上篇文章。

3.3 多态

按照字面意思来理解,多态就是「多种状态」。在 OOP 语言中,接口的不同实现即为多态。 也许上面的解释让你还不容易理解,我们可以举个例子。

从前有个动物王国,每年都会举行一次赛跑,所有小动物都可以参加。当裁判大喊开始之后,小动物们纷纷开始跑向终点。兔子用矫捷的四肢飞快地跑去;袋鼠用健壮的两条腿向终点跳去;鸭子也用两条短腿踉踉跄跄地往前跑。

对于裁判来说,他只下达了「跑」的命令,但是不同小动物有自己的奔跑方式。 因此,我们上面关于继承的 eat 方法的实现也是多态的一种。 多态的实现一般是重载和重写两种方式。 重载的意思就是利用传给函数的参数类型、个数等来进行判断加载不同的函数。但是在 JavaScript 中,并没有对函数参数进行约束,因此不管传多少个参数都不会有区别,因此这里不会对重载进行讨论。 这里主要讨论的是重写。重写的意思就是对父类中的同名方法进行重写,以实现覆盖的功能。由于 JavaScript 中会通过原型链一层层往上查找属性和方法,因此完全可以做到重写。

class Animal {
    eat() {}
}
class Duck extends Animal {
    eat() {
        console.log("duck eats fish")
    }
}
class Cat extends Animal {
    eat() {
        console.log("cat eats mice")
    }
}
const duck = new Duck();
duck.eat(); // Duck类中对eat方法进行了重写

4. 六大原则

4.1 单一职责原则

单一职责的描述如下:

A class should have only one reason to change 类发生更改的原因应该只有一个

引申一下,不仅仅是类,一个对象、一个方法也只应该做一件事。 *问题由来:* 如果有个类 A,负责两个不同职责 P1,P2,一旦其中一个职责 P1 因需求发生变动需要修改,就有可能会导致原本运行正常的职责 P2 功能发生故障。 解决方式:** 最好的方式就是对每个职责建立一个类,这样修改 P1 就不会影响到原来的 P2 。

*单一职责的优点: 1.降低类的复杂度,一个类只负责一个职责。

2.提高类的可读性,提高系统的可维护性。

3.降低变更引起的风险。

*单一职责的缺点: 一个职责对应一个类,可能会给系统带来不必要的负担。 因此,可以在适当的场景下违反单一职责原则。

4.2 开放封闭原则

*定义:* 一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。 问题由来: 在维护项目的时候,经常会出现需求让我们在原有功能的基础上增加新功能,如果在直接在原来的代码中直接修改,可能会不小心给原有代码引入错误,也可能会让我们不得不对整个功能重构,也有可能会造成原有代码需要重新测试。 解决方式: 尽量使用扩展的方式,而不是直接在原有的代码中修改。 例子:** 假如有一批书,在双十一这天要打折,金额大于40块的打9折,金额大于50块的打8折,其他的不打折。 也许你会想到,可以直接修改书的价格啊?可如果双十一过去后,书全部恢复原价呢? 对于这种情况,可以在 Book 类的基础之上,增加了一个子类 OffNovelBook,重写了 getPrice 方法,相当于对 getPrice 方法进行了扩展。

class Book {
    getPrice() {
    }
}
class OffNovelBook extends Book {
    getPrice() {
        const price = super.getPrice();
        if (price >= 500) {
            return price * 0.8;
        }
        if (price >= 400) {
            return price * 0.9;
        }
        return price;
    }
}

4.3 里氏替换原则

*定义:* 所有引用基类的地方必须能透明地使用其子类的对象。 通俗点讲,只要父类出现的地方子类都能出现,且用子类替换父类也不会出现错误,对于使用者来说不需要关心是子类还是父类。但是反过来就不行了,父类未必能替换子类。 规范:** 里氏替换原则一般是为继承定义规范,使用继承的时候需要遵守下面几个规范:

  1. 子类需要实现父类的方法
  2. 子类可以有自己的特性
  3. 覆盖或实现父类的方法时输入参数可以被放大
  4. 覆写或实现父类的方法时输出结果可以被缩小

    如果有一个禽类的类,派生出来鸡和麻雀两个类。大家都知道禽类会下蛋,把这个禽类换成鸡和麻雀也没问题。可你说麻雀会飞,能换成禽类会飞吗?事实上鸵鸟和鸡都不会飞。

    4.4 依赖倒置原则

    *定义:* 依赖倒置原则(Dependence Inversion Principle)是程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。 更简单地说,就是面向接口编程。 规范:**

  5. 每个类尽量提供接口或抽象类,或者两者都具备。
  6. 变量的声明类型尽量是接口或者是抽象类。
  7. 任何类都不应该从具体类派生。
  8. 使用继承时尽量遵循里氏替换原则。

由于 JavaScript 中没有接口的概念,这里会用 TypeScript 进行讲解,如果 TypeScript 经验的同学可以跳过这段。

class Taobao {
    sell() {
        console.log('淘宝购物');
    }
}
class Customer {
    shopping(shop: Taobao) {
        shop.sell();
    }
}
const customer = new Customer();
customer.shopping(new Taobao());

如果顾客想要从京东购买呢?你会想到直接修改 shopping 函数。

class JD {
    sell() {
        console.log('京东购物');
    }
}
class Customer {
    shopping(shop: JD) {
        shop.sell();
    }
}
const customer = new Customer();
customer.shopping(new JD());

可是这样不是违反了前面讲过的开闭原则吗?可再到 Customer 类上增加一个方法也会显得多余。 因此这里的 shop 参数应该更加抽象,而不是依赖具体实现。TaobaoJD 应该基于接口 IShop 来创建。

interface IShop {
    sell(): void
}
class Taobao implements IShop {
    sell() {
        console.log('淘宝购物');
    }
}
class JD implements IShop {
    sell() {
        console.log('京东购物');
    }
}
class Customer {
    shopping(shop: IShop) {
        shop.sell();
    }
}
const customer = new Customer();
customer.shopping(new Taobao());
customer.shopping(new JD());

4.5 接口分离原则

*定义: 接口分离原则指在设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。即,一个类要给多个客户使用,那么可以为每个客户创建一个接口,然后这个类实现所有的接口;而不要只创建一个接口,其中包含所有客户类需要的方法,然后这个类实现这个接口。 简单来说就是一个类不应该依赖于它不需要的方法,比如我们有个 IPerson 接口,它拥有这么几个方法:

interface IPerson {
    walk(): void;
    say(): void;
    look(): void;
}
class Person implements IPerson {
    walk() {}
    say() {}
    look() {}
}

可是对于残疾人来说,盲人不能看、下肢瘫痪的不能走、哑巴不能说话,如果他们还拥有这些方法,似乎就很奇怪。 因此,应该将接口隔离,使用多个类

interface IWalk {
    walk(): void;
}
interface ILook {
    look(): void;
}
interface ISay {
    say(): void;
}
interface IPerson extends IWalk, ILook, ISay {}

*接口隔离和单一职责的区别: 接口隔离原则强调的是设计时的架构分离,把不同功能分给不同的接口,让实现类避免少了解与己无关的方法、通过实现不同接口保证与外部的耦合; 单一职责原则强调的是 实现时的职责分离,具体功能下的不同实现要封装在不同的模块,尽量避免牵一发而动全身。

总结

这节课介绍了面向对象的相关概念,尤其是继承和封装在开发中使用的比例非常大。 通过面向对象的形式来实现业务功能的模块化,这样有利于提高系统的可维护性和复用性。 但也不是所有场景都适合使用面向对象,我们应该根据场景来合理使用。在一些简单的场景下,如果还使用面向对象来创造很多类,无疑也会加大内存开销,这种情况下用面向过程已经足够了。