js 面向对象编程

引言

简介:什么是面向对象编程?

面向对象编程将一个系统抽象为许多对象的集合,每一个对象代表了这个系统的特定方面。对象包括函数(方法)和数据。一个对象可以向其他部分的代码提供一个公共接口,而其他部分的代码可以通过公共接口执行该对象的特定操作,系统的其他部分不需要关心对象内部是如何完成任务的,这样保持了对象自己内部状态的私有性。

概括:面向对象是一种思想,一种编程模式:先创造一个实现功能的对象,再使用这个对象实现功能。

面向对象编程与过程式编程的比较。

面向过程:就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候再一个一个的依次调用就可以了。

面向过程

面向对象

优点

性能比面向对象高,适合跟硬件联系很紧密的东西,例如单片机就采用的面向过程编程。

易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护

缺点

不易维护、不易复用、不易扩展

性能比面向过程低

对象和类

类:是一个模板,描述一类对象的行为和状态。

使用函数创建类:

function Person(name,age,sex){
    this.name = name;
    this.age = age;
    this.sex = sex;
    this.getMoney = function(){
        return '$123';
    }
}

使用 class 创建类,class是 es6 的语法,是函数创建类的语法糖:

class Person {
  constructor(name, age, sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
  }

  getMoney() {
    return '$123';
  }
}

对象

对象:是类的一个实例,有状态和行为。

  • 使用类创建对象:

    // 定义一个名为Person的类
    class Person {
       // 定义属性
       name = '';
       age = null;
      // 类的构造函数,用于初始化对象的属性
      constructor(name, age) {
        this.name = name;
        this.age = age;
      }
    
      // 类的方法,用于描述对象的行为
      greet() {
        return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
      }
    }
    
    // 创建一个Person类的实例
    const person2 = new Person('Bob', 25);

  • 使用函数创建对象:

    // 定义一个函数来创建对象
    function Person(name, age) {
      this.name = name;
      this.age = age;
      
      // 添加一个方法到对象的原型中
      Person.prototype.greet = function() {
        return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
      };
    }
    
    // 创建一个Person对象的实例
    const person1 = new Person('Alice', 30);

  • 使用大括号创建对象:

    let obj = { 
        name: '小丽',
        age: 18,
        sex: '女',
        height: 180,
        eat : function(){ return;} 
    }

构造函数

JS 中构造函数有两种不同的定义:

  • 首先是指一种特殊的函数,主要用来创建和初始化对象。当你使用new关键字调用一个函数时,该函数就作为构造函数来使用。构造函数通常首字母大写,以区别于普通函数

function Person(name,age,sex){
    this.name = name;
    this.age = age;
    this.sex = sex;
    this.getMoney = function(){
        return '$123';
    }
}
let obj = new Person('小美女',16,'女');
  • 在ES6中的类(class)中,构造函数(constructor)是一个特殊的方法,用于在创建类的实例时进行初始化操作。构造函数在类实例化时自动调用,用于设置对象的初始属性值和执行任何必要的初始化操作。

    class Person {
      // 构造函数,用于初始化对象的属性
      constructor(name, age) {
        this.name = name;
        this.age = age;
      }
    
      // 方法定义
      greet() {
        return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
      }
    }
    
    // 创建一个Person类的实例
    const person1 = new Person('Alice', 30);

原型与原型链

JS声明构造函数(用来实例化对象的函数)时,会在内存中创建一个对应的对象,这个对象就是原函数的原型。构造函数默认有一个prototype属性,prototype的值指向函数的原型。同时原型中也有一个constructor属性constructor的值指向构造函数。

// 定义一个构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.greet = function() {
  return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
}

// 访问构造函数的原型和原型对象的constructor属性
console.log(Person.prototype); // 输出:Person { greet: [Function] }
console.log(Person.prototype.constructor); // 输出:[Function: Person]

每个JavaScript对象都有一个属性,叫做__proto__,这个属性指向其构造函数的原型对象。这个原型对象包含了可以由该类型的所有实例共享的属性和方法。这意味着你可以将那些不需要在每个实例中单独存储的方法,添加到原型对象上,以减少内存的占用。

原型链是JavaScript实现继承的一种机制。每个对象都有一个原型对象,每个原型对象也有自己的原型,这样一层层向上直到一个对象的原型为null

当你试图访问一个对象的属性时:如果在对象本身中找不到该属性,就会在原型中搜索该属性。如果仍然找不到该属性,那么就搜索原型的原型,以此类推,直到找到该属性,或者到达链的末端,在这种情况下,返回 undefined

function Dog() {
    this.name='dog';
}
const dog = new Dog();
dog.toString()   //输出'[object Object]'

  在上述例子中,调用 dog.toString() 时,浏览器做了这些事情:

  1. dog中寻找 toString 属性。

  2. dog中找不到 toString 属性,故在 dog的原型对象中寻找 toString

  3. 其原型对象也没有这个属性,然后在dog的原型对象的原型对象即:Object.prototype 中找 toString

  4. Object.prototype 中找到 toString调用,等价于 Object.prototype.toString.call(dog)

  从深入到通俗:Object.prototype.toString.call()

Function与原型链

  • 在JavaScript中,几乎所有的东西都是对象,包括函数。Function本身既是一个函数,同时也是一个对象。

  • Function对象是由构造函数Function创建的。

    Function.constructor === Function // 即 Function.__proto__.constructor === Function

  • 函数Function的原型是一个特殊的空函数(即没有执行体的函数)。Function对象的原型也是同一个空函数。

    Function.__proto__ === Function.prototype

  • 于是所有函数的构造函数为 Function。所有函数的原型为空函数。唯一例外就是上述特殊的空函数 Function.prototype

    a = function() {} a.__proto__.constructor === Function a.__proto__ === Function.prototype

  • Function.prototype 的构造函数为 Object,原型为 Object.prototype

    Function.prototype.__proto__.constructor === Object Function.prototype.__proto__ === Object.prototype

Object与原型链

  • Object是JavaScript中所有对象的根基。几乎所有的JavaScript对象都是由Object函数创建的或者在原型链上最终回溯到Object。

  • Object 对象的原型为 Function.prototype,Object 对象的构造函数为 Function。

    Object.__proto__ === Function.prototype Object.__proto__.constructor === Function

  • Object.prototype是一个特殊的对象,也是原型链的顶端。即 Object.prototype 的原型为 null。而Object.prototype 的构造函数为 Object

    Object.prototype.__proto__ === null Object.prototype.constructor === Object

Function, Object, 和 Prototype总结

  • Function.prototype和Object.prototype是特殊的原型对象。所有函数都继承自Function.prototype,所有普通对象都继承自Object.prototype。

  • Function.prototype自身是一个特殊的空函数(即没有执行体的函数),而Object.prototype是一个特殊的对象。

  • Function.prototype的原型是Object.prototype。这意味着所有的函数都间接继承自Object.prototype,从而可以访问诸如toString()这样的通用方法。

属性和方法

属性是与对象相关联的值。你可以把属性看作是定义对象特征的变量。属性可以是基本数据类型(如字符串、数字等),也可以是对象,甚至是函数。通过点符号(.)或方括号([])来访问对象的属性。

let person = {
  name: 'Alice',
  age: 30
};

console.log(person.name); // 输出: Alice
console.log(person['age']); // 输出: 30

方法是分配给对象属性的函数。当一个函数作为一个对象的属性存储时,我们称之为该对象的方法。方法可以通过点符号(.)调用,并且它们通常用于定义对象的行为。

let person = {
  name: 'Alice',
  age: 30,
  greet: function() {
    console.log('Hello, my name is ' + this.name);
  }
};

person.greet(); // 输出: Hello, my name is Alice

静态属性和方法

在JavaScript中,静态属性和方法是通过类的构造函数直接定义的属性和方法,而不是在类的实例上定义的。

function Circle() {
  // 静态属性
  Circle.PI = 3.14;
  
    // 静态方法
  Circle.add = function(x, y) {
    return x + y;
  };
}
console.log(Circle.PI); // 输出: 3.14

ES6 引入了 static关键字来定义静态属性和方法:

  class MyClass {
    static staticProperty = '静态属性';
    static staticMethod() {
      return '这是一个静态方法';
    }
  }

  // 访问静态属性
  console.log(MyClass.staticProperty); // 输出: 静态属性

  // 调用静态方法
  console.log(MyClass.staticMethod()); // 输出: 这是一个静态方法

在这个示例中,staticProperty是一个静态属性,可以通过类名直接访问。staticMethod()是一个静态方法,可以通过类名直接调用。

私有属性和方法

在JavaScript中,私有属性和方法是ES2020标准引入的新特性,允许开发者在类中定义私有成员。私有属性和方法只能在类的内部访问,这提供了更强的封装性。

私有属性通过在属性名前加上#符号来定义。这样定义的属性只能在类的内部被访问和修改,尝试在类的外部访问这些属性会导致语法错误。

class MyClass {
  #privateProperty = '私有属性';

  getPrivateProperty() {
    return this.#privateProperty;
  }
}

const myInstance = new MyClass();
console.log(myInstance.getPrivateProperty()); // 正确访问
console.log(myInstance.#privateProperty); // 语法错误

私有方法也是通过在方法名前加上#符号来定义的。这些方法只能在类的内部被调用。

class MyClass {
  #privateMethod() {
    return '这是一个私有方法';
  }

  callPrivateMethod() {
    return this.#privateMethod();
  }
}

const myInstance = new MyClass();
console.log(myInstance.callPrivateMethod()); // 正确调用

访问器属性

JavaScript中,get和set是特殊类型的方法,用于定义对象的访问器属性(accessors)。访问器属性不包含实际的值,而是当访问属性时调用的函数(get)和当属性值被修改时调用的函数(set)。

get方法用于访问对象的属性。当读取对象的属性时,会自动调用相应的get方法,并返回这个方法的返回值。

let person = {
  firstName: 'Alice',
  lastName: 'Doe',
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
};

console.log(person.fullName); // 输出: Alice Doe

set方法用于修改对象的属性。当为对象的属性赋值时,会自动调用相应的set方法。

let person = {
  firstName: 'Alice',
  lastName: 'Doe',
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  },
  set fullName(name) {
    [this.firstName, this.lastName] = name.split(' ');
  }
};

person.fullName = 'Bob Smith';
console.log(person.firstName); // 输出: Bob
console.log(person.lastName); // 输出: Smith

在类定义中定义访问器属性:

class Person {
  constructor(firstName, lastName) {
    this._firstName = firstName;
    this._lastName = lastName;
  }

  // get访问器用于获取fullName
  get fullName() {
    return `${this._firstName} ${this._lastName}`;
  }

  // set访问器用于设置fullName
  set fullName(name) {
    [this._firstName, this._lastName] = name.split(' ');
  }
}

const person = new Person('Alice', 'Doe');
console.log(person.fullName); // 输出: Alice Doe

// 使用set访问器修改fullName
person.fullName = 'Bob Smith';
console.log(person.fullName); // 输出: Bob Smith

this关键字

  • 在构造函数中的this指向new出来的实例对象。

  • 在方法内部,this关键字用于访问调用该方法的对象。它是对包含它的对象的引用。

使用 new 创建对象的过程

在JavaScript中,使用new操作符来创建一个对象的过程涉及到几个关键步骤。这个过程不仅涉及到实例化一个新对象,还包括将构造函数的原型赋给新对象的原型链,并执行构造函数以初始化新对象。以下是这个过程的详细步骤:

  1. 创建一个空对象:使用new操作符时,JavaScript首先创建一个空的JavaScript对象(即{})。

  2. 设置原型链:新创建的空对象的__proto__属性(即原型链)被赋值为构造函数的prototype属性。这意味着新对象可以访问构造函数原型上的属性和方法。

  3. 执行构造函数:新创建的对象作为this传入构造函数中执行。这一步允许在构造函数中为新对象定义属性和方法。

  4. 返回新对象:如果构造函数中有返回值且返回值是一个对象,那么这个对象会成为new表达式的结果;如果返回值不是一个对象(包括null),则忽略该返回值,返回新创建的对象。如果构造函数没有显式返回其他对象,则new操作符会自动返回新创建的对象。

下面是一个具体的例子来说明这个过程:

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.greet = function() {
    return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
  };
}

// 使用new操作符创建Person实例
const person1 = new Person('Alice', 30);
console.log(person1.greet()); // 输出: Hello, my name is Alice and I am 30 years old.

在这个例子中:

  • 使用new Person('Alice', 30)创建了一个新对象。

  • 这个新对象的原型链被设置为Person.prototype

  • 构造函数Person被执行,this指向新创建的对象,给这个新对象添加了nameage属性和greet方法。

  • 由于构造函数没有返回其他对象,因此新创建的对象被返回并赋值给变量person1

通过这个过程,使用new操作符可以方便地基于构造函数创建并初始化新对象。

继承

继承是 基于类创建其他类的能力。通过继承,我们可以先定义父类 (包含一些属性和方法), 然后再定义子类,子类继承父类的所有属性和方法。

原型继承

原型继承是基于原型链来完成的。

function superf() {
    this.name = "小行星";
}

function subf() {
    this.age = "21"; 
}
subf.prototype = new superf();//subf继承了superf,通过原型,形成链条
const test = new subf();
console.log(test.name) //小行星

类继承:使用extends和super关键字

在 ES6 中,我们使用 extends 关键字来声明我们需要继承的父类。在 constructor 方法中,使用super函数来表示父元素的构造函数。

class Character {
    constructor (speed) {
        this.speed = speed
    }
    move = () => console.log(`I'm moving at the speed of ${this.speed}!`)
}
class Enemy extends Character {
    constructor(power, speed) {
        super(speed)
        this.power = power
    }
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(power, speed)
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)

alien1.move() // 输出:"I'm moving at the speed of 50!"
console.log(alien2.speed) // 输出:60

注意事项

  • JS 中一个子类只能继承一个父类,不可以继承多个父类。

  • 如果子类从父类继承一些属性,必须首先使用super()函数并将父类属性传参,然后再设定子类自己的属性。

  • 在继承的时候,所有父类的方法和属性都会被子类继承,我们并不能决定继承哪些,不继承哪些。

  • 子类可以覆盖掉父类的属性和方法。

组合与继承的比较

组合是指一个类包含另一个类作为其成员变量,通过这种方式实现类之间的关联。

// Engine 类
class Engine {
    constructor(horsepower) {
        this.horsepower = horsepower;
    }

    start() {
        console.log("Engine started");
    }
}

// Car 类
class Car {
    constructor(make, model, engine) {
        this.make = make;
        this.model = model;
        this.engine = engine;
    }

    start() {
        console.log(`Starting the ${this.make} ${this.model}`);
        this.engine.start();
    }
}

// 创建一个 Engine 实例
const engine = new Engine(200);

// 创建一个 Car 实例,将 Engine 实例传入
const myCar = new Car("Toyota", "Corolla", engine);

// 启动汽车
myCar.start();

继承是一种"is-a"关系,表示一种类是另一种类的一种特殊类型;而组合是一种"has-a"关系,表示一个类具有另一个类的对象。

组合适用于需要灵活性和模块化的情况,可以避免继承的一些问题,但需要编写更多的连接代码。

继承适用于建立层次结构和减少重复代码的情况,但可能会导致耦合性强和难以修改的问题。

组合

继承

优点

灵活性高:通过组合,可以在不同的类之间共享功能,提高代码的复用性。

易于维护:组合可以帮助将功能模块化,使得代码更易于理解和维护。

可以避免多重继承的问题:在JavaScript中,多重继承可能导致混乱和冲突,而组合可以避免这些问题。

简洁:继承可以通过创建子类来重用父类的属性和方法,使代码更简洁。

层次清晰:通过继承可以建立层次结构,使得对象之间的关系更清晰。

可以减少重复代码:避免在不同类中重复编写相同的代码。

缺点

需要更多的代码:使用组合通常需要编写更多的代码来连接不同的类和功能。

可能会导致过度设计:如果设计不当,可能会出现过度组合功能,增加复杂性。

约束性强:继承会创建类之间的强耦合关系,子类依赖于父类的实现细节。

可能导致继承层次过深:多层次的继承结构可能导致代码难以理解和维护。

难以修改:一旦建立了继承关系,改变父类可能会影响到所有子类。

封装

基本概念

封装指的是将对象的数据(属性)和行为(方法)组合到一个单独的单元中,并对对象的信息进行隐藏和保护。在实践中,封装不仅仅是将数据和方法组合在一起,它还涉及到限制对某些组件的直接访问,这通常是通过使用访问修饰符(如 public、private 和 protected)来实现的。

封装的主要目的:

  • 隐藏实现细节:封装允许开发者隐藏功能的具体实现细节,只提供一个公开的接口与外界交互。这样,使用者不需要了解内部的实现逻辑,只需要知道如何使用即可。

  • 减少耦合:通过封装,可以减少系统中各个部分之间的依赖关系,使得修改一个部分的实现对其他部分影响最小。

  • 增强安全性:封装可以限制对对象内部数据的直接访问,只允许通过特定的方法来修改或获取这些数据。这有助于防止外部代码随意修改对象内部的状态,从而可能导致错误或不一致。

  • 提高可维护性:封装使得代码更加模块化,每个模块都有明确的职责。当需要修改或扩展功能时,可以更容易地定位到需要改动的部分,而不必深入了解整个系统。

实现方式

使用私有字段

通过使用私有字段,类的实例不能直接访问这些字段,只能通过公开的方法来访问,这样就实现了封装。

class Alien {
    #birthYear // 首先我们要声明一个私有属性,通常是用 “#” 打头
    constructor (name, birthYear) {
        this.name = name
        this.#birthYear = birthYear // 然后将它赋值到 constructor 函数
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    howOld = () => console.log(`I was born in ${this.#birthYear}`) // 在对应的方法中使用
}
const alien1 = new Alien("Ali", 1989)
alien1.howOld() // 输出:"I was born in 1989"
console.log(alien1.#birthYear) // 报错

在这个示例中,#birthYear 是一个私有属性,只能在Alien类的内部访问。通过定义公共方法howOld()来间接访问私有属性,从而实现封装的概念

使用访问器属性(getter和setter方法)

在JavaScript中,通过定义getters和setters方法来控制对对象内部属性的访问和修改。这种方式可以在不暴露对象内部属性的情况下,对外提供属性的访问。

下面是一个使用访问器属性来实现封装的例子:

class Person {
    constructor(name) {
        // 私有属性
        let _name = name;

        // 访问器属性
        Object.defineProperty(this, 'name', {
            // 提供读取属性值的方法
            get: function() {
                return _name;
            },
            // 提供设置属性值的方法
            set: function(value) {
                if (value === '') {
                    console.error('Name cannot be empty.');
                } else {
                    _name = value;
                }
            },
            enumerable: true,
            configurable: true
        });
    }
}

const person = new Person('John Doe');
console.log(person.name); // John Doe

person.name = 'Jane Doe';
console.log(person.name); // Jane Doe

person.name = ''; // 尝试设置一个无效值
// 输出: Name cannot be empty.

在这个例子中,Person类有一个私有属性_name,通过定义访问器属性name来控制对_name的访问。这样,即使是私有属性,外部代码也可以通过访问器方法安全地访问和修改它,同时保留了对输入验证等逻辑的控制权。

多态

基本概念

多态意味着“多种形态”。它允许我们以统一的接口处理不同类型的对象,而具体调用哪个对象的哪个方法,则是在运行时(而非编译时)决定的。这增加了程序的灵活性和可扩展性。

实现方式

  • 方法重载(Overloading):同一个类中的同名方法,根据传入参数的不同调用不同的实现。这种方式是编译时多态。

  • 方法覆盖(Overriding):子类重新定义父类中已有的方法。当通过父类引用调用该方法时,实际执行的是子类的版本。这是实现运行时多态的关键。

  • 接口(Interface):通过接口定义一个标准模板,不同的类实现同一个接口,然后通过接口引用调用实现了该接口的类的对象。这也是一种运行时多态。

下面是方法覆盖的例子:

// 1.创建一个父类并定义一个方法:
class Animal {
    sound() {
        console.log("Animal makes a sound");
    }
}

// 2.创建子类并重写父类的方法:
class Dog extends Animal {
    sound() {
        console.log("Dog barks");
    }
}

class Cat extends Animal {
    sound() {
        console.log("Cat meows");
    }
}

// 3.创建对象并调用方法:
const dog = new Dog();
const cat = new Cat();

dog.sound(); // 输出 "Dog barks"
cat.sound(); // 输出 "Cat meows"

在这个例子中,父类 Animal 有一个 sound() 方法,而子类 Dog 和 Cat 分别重写了 sound() 方法以展示不同的行为。当调用 sound() 方法时,根据对象的实际类型执行相应的方法,实现了多态性的效果。

面向对象设计原则

面向对象设计原则是在无数先辈的理论与实践中产生的。 身为一名主要使用面向对象编程软件从业员,这六大原则是必须要掌握的,它就是各种设计模式的理论,设计模式是它的实践。前五个原则简称SOLID原则。

单一职责原则(SRP:Single Responsibility Principle)

单一职责原则(Single Responsibility Principle, SRP)是面向对象设计原则之一,它指一个类应该只有一个引起它变化的原因。换句话说,这个原则主张一个类应该仅负责一项职责。如果一个类承担了过多的职责,那么这些职责之间的耦合度增加,当修改一个职责时,可能会影响到其他职责的功能,这会降低代码的可维护性和可扩展性。

优点

  • 提高类的可读性和可维护性:每个类都有清晰定义的职责,使得代码更容易理解和修改。

  • 降低修改程序造成的风险:修改一个类的行为不会影响到其他的职责。

  • 提高代码的可复用性:职责分离使得各个类可以被独立地复用。

示例

假设我们有一个处理用户信息的类,包括用户数据的加载和用户数据的显示两种职责。按照单一职责原则,我们应该将这两种职责分离到不同的类中。

不遵循单一职责原则的例子:

class User {
    constructor(name) {
        this.name = name;
    }

    // 加载用户数据
    loadUserData() {
        console.log(`Loading data for ${this.name}`);
        // 假设这里是从数据库加载用户数据的逻辑
    }

    // 显示用户数据
    displayUser() {
        console.log(`Displaying user: ${this.name}`);
        // 假设这里是显示用户数据的逻辑
    }
}

const user = new User("John Doe");
user.loadUserData();
user.displayUser();

遵循单一职责原则的例子:

class User {
    constructor(name) {
        this.name = name;
    }
}

class UserDataLoader {
    static loadUserData(user) {
        console.log(`Loading data for ${user.name}`);
        // 假设这里是从数据库加载用户数据的逻辑
    }
}

class UserDisplayer {
    static displayUser(user) {
        console.log(`Displaying user: ${user.name}`);
        // 假设这里是显示用户数据的逻辑
    }
}

const user = new User("John Doe");
UserDataLoader.loadUserData(user);
UserDisplayer.displayUser(user);

在遵循单一职责原则的例子中,User类仅保留了用户数据(name),而数据加载和显示的职责被分离到了UserDataLoaderUserDisplayer两个类中。这样做不仅使每个类更加专注和独立,也使得未来对加载或显示逻辑的修改更加容易和安全。

开放封闭原则(OCP:Open-Closed Principle)

开放封闭原则(Open-Closed Principle, OCP)是面向对象设计原则之一,由 Bertrand Meyer 提出。它指出软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着软件应该在不修改现有代码的情况下,允许新增功能。

优点

  • 提高软件系统的可复用性和可维护性:通过扩展来添加新功能,减少对既有代码的修改,可以降低修改带来的风险。

  • 促进了灵活性和可扩展性:设计时考虑到未来的变化,使得系统更容易应对未来的需求变化。

示例

假设我们有一个简单的图形绘制系统,现在只能绘制圆形,但我们希望系统能够扩展以支持更多种类的图形,而不需要修改现有代码。

不遵循开放封闭原则的例子

class Circle {
    draw() {
        console.log("Drawing a circle");
    }
}

// 如果需要添加新的图形类型,比如矩形,我们需要修改这个函数
function drawShape(shape) {
    if (shape instanceof Circle) {
        shape.draw();
    } 
    // 添加新的判断逻辑来处理矩形
}

const circle = new Circle();
drawShape(circle);

遵循开放封闭原则的例子

class Shape {
    draw() {
        // 默认实现,可以被子类重写
    }
}

class Circle extends Shape {
    draw() {
        console.log("Drawing a circle");
    }
}

class Rectangle extends Shape {
    draw() {
        console.log("Drawing a rectangle");
    }
}

// 这个函数现在对于新增的图形类型是开放的,不需要修改就可以处理新类型
function drawShape(shape) {
    shape.draw();
}

const circle = new Circle();
const rectangle = new Rectangle();
drawShape(circle);
drawShape(rectangle);

在遵循开放封闭原则的例子中,通过引入一个抽象的Shape类,并让所有具体图形类继承自Shape,我们使得drawShape函数能够处理任何继承自Shape的图形对象。这样,当引入新的图形类时,只需让新类继承自Shape并实现draw方法,无需修改drawShape函数。这正体现了对扩展开放、对修改封闭的原则。

里氏替换原则(LSP:Liskov Substitution Principle)

里氏替换原则(Liskov Substitution Principle, LSP)是由Barbara Liskov在1987年提出的一个面向对象设计原则。它是SOLID原则中的第三个原则,指出如果程序中的对象o1是类型S的一个实例,o2是类型T的一个实例,且TS的子类型,那么在程序中使用父类S的对象的地方都可以使用子类T的对象。

换句话说,子类在继承父类的时候,除了增加新的方法完成新增功能外,还要确保通过父类实例可以访问到的所有方法,在子类实例中仍然可以使用。

优点

  • 提高代码的可复用性:通过继承和多态性减少重复代码。

  • 提高代码的可维护性:当修改父类时,不需要修改到使用父类的地方。

示例

考虑一个简单的几何形状处理系统,我们有一个基类Shape和它的两个子类RectangleSquare

不遵循里氏替换原则的例子

class Rectangle {
    setWidth(width) {
        this.width = width;
    }

    setHeight(height) {
        this.height = height;
    }

    getArea() {
        return this.width * this.height;
    }
}

class Square extends Rectangle {
    setWidth(width) {
        this.width = this.height = width;
    }

    setHeight(height) {
        this.width = this.height = height;
    }
}

function increaseRectangleWidth(rectangle) {
    rectangle.setWidth(rectangle.width + 1);
}

const rectangle = new Rectangle();
rectangle.setWidth(10);
rectangle.setHeight(2);

increaseRectangleWidth(rectangle);
console.log(rectangle.getArea()); // 22

const square = new Square();
square.setWidth(5);

increaseRectangleWidth(square);
console.log(square.getArea()); // 36, 不符合预期,因为期望是30

在这个例子中,Square类违反了里氏替换原则,因为它改变了RectanglesetWidthsetHeight方法的行为。这导致那些期望使用Rectangle行为的代码在使用Square时可能不会按预期工作。

遵循里氏替换原则的改进

为了遵循LSP,我们应该重新设计这些类的结构,使得每个类都符合它们应有的行为。例如,可以抽象出一个更通用的基类Shape,然后让RectangleSquare分别实现它,而不是让Square继承Rectangle。这样,SquareRectangle就不再有"is-a"(是一个)的关系,而是各自独立地实现了Shape接口。

class Shape {
    getArea() {}
}

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }

    getArea() {
        return this.width * this.height;
    }
}

class Square extends Shape {
    constructor(side) {
        super();
        this.side = side;
    }

    getArea() {
        return this.side * this.side;
    }
}

function displayArea(shape) {
    console.log(`The area is: ${shape.getArea()}`);
}

const rectangle = new Rectangle(10, 5);
const square = new Square(5);

displayArea(rectangle); // 输出: The area is: 50
displayArea(square); // 输出: The area is: 25

接口隔离原则(ISP:Interface Segregation Principle)

接口隔离原则(Interface Segregation Principle, ISP)是面向对象设计原则之一,强调“不应该强迫客户程序依赖它们不需要的接口”。简而言之,这个原则建议将大的接口拆分成更小且更具体的接口,这样客户端将只需要关心它们真正需要的接口。通过这种方式,系统内部的依赖关系变得更加清晰,增加了代码的可维护性和可扩展性。

优点

  • 减少依赖:客户端不再依赖于它们不需要的方法,减少了系统间的耦合。

  • 增强模块性:通过定义精细化的接口,系统的模块性增强,更易于理解和维护。

  • 提高灵活性:接口的精细化提高了系统各部分的替换和升级的灵活性。

示例

考虑一个系统,其中包含一个多功能打印机,可以进行打印、扫描和复印操作。按照接口隔离原则,我们不应该只定义一个统一的接口给所有设备,而是应该为每种操作定义一个接口。

未遵循接口隔离原则的例子

// 一个大而全的接口
class IMultiFunctionDevice {
    print() {}
    scan() {}
    copy() {}
}

// 多功能打印机实现了所有功能
class MultiFunctionPrinter extends IMultiFunctionDevice {
    print() {
        // 实现打印
    }
    scan() {
        // 实现扫描
    }
    copy() {
        // 实现复印
    }
}

// 假设有一个只需要打印功能的客户端
class SimplePrinter extends IMultiFunctionDevice {
    print() {
        // 实现打印
    }
    scan() {
        throw new Error("Not supported");
    }
    copy() {
        throw new Error("Not supported");
    }
}

遵循接口隔离原则的改进

// 分离接口
class IPrinter {
    print() {}
}

class IScanner {
    scan() {}
}

class ICopier {
    copy() {}
}

// 多功能打印机可以实现所有接口
class MultiFunctionPrinter extends IPrinter, IScanner, ICopier {
    print() {
        // 实现打印
    }
    scan() {
        // 实现扫描
    }
    copy() {
        // 实现复印
    }
}


// 现在,对于只需要打印功能的客户端,我们只需实现IPrinter接口
class SimplePrinter extends IPrinter {
    print() {
        // 实现打印
    }
}

通过这种方式,我们确保了每个类只依赖于它真正需要的接口,提高了代码的清晰度和可维护性。

依赖倒置原则(DIP:Dependency Inversion Principle)

依赖倒置原则(Dependency Inversion Principle, DIP)是面向对象设计原则之一,强调了一种特定的模块依赖关系。依赖倒置原则指出:

  1. 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。

  2. 抽象不应该依赖于细节。细节应该依赖于抽象。

换句话说,这个原则鼓励我们优先使用抽象(接口或抽象类)而不是具体实现,从而使得高层模块和低层模块之间的依赖关系更加灵活和可维护。

优点

  • 提高模块间的解耦:模块之间不再直接依赖具体实现,而是依赖抽象,使得改变一个模块的实现不会影响到依赖它的其他模块。

  • 增加代码的可复用性:通过面向接口编程,可以使得代码更容易在不同场景下复用。

  • 提高代码的可维护性:降低了模块间的耦合度,使得系统更容易理解和维护。

示例

假设我们有一个应用程序,其中包含用户界面(UI)层和数据访问层(DAL)。按照依赖倒置原则,UI层不应直接依赖于具体的数据访问实现,而是应依赖于一个抽象。

首先,定义一个数据访问接口:

// 数据访问接口
class UserRepository {
    getUser() {
        throw new Error("This method should be implemented");
    }
}

然后,实现这个接口:

// 数据访问实现
class SqlUserRepository extends UserRepository {
    getUser() {
        // 假设这里是从SQL数据库获取用户数据
        console.log("Getting user from SQL database");
    }
}

class ApiUserRepository extends UserRepository {
    getUser() {
        // 假设这里是通过API获取用户数据
        console.log("Getting user from API");
    }
}

最后,UI层通过传递使用这个接口,而不是直接依赖具体的实现:

class UserInterface {
    constructor(userRepository) {
        this.userRepository = userRepository;
    }

    showUser() {
        this.userRepository.getUser();
    }
}

// 使用SQL仓库
const sqlUserRepo = new SqlUserRepository();
const ui1 = new UserInterface(sqlUserRepo);
ui1.showUser(); // 输出: Getting user from SQL database

// 使用API仓库
const apiUserRepo = new ApiUserRepository();
const ui2 = new UserInterface(apiUserRepo);
ui2.showUser(); // 输出: Getting user from API

通过这种方式,UserInterface类依赖于UserRepository接口而不是具体的实现类,使得切换数据源或修改数据访问逻辑时,UI层代码无需修改,从而遵循了依赖倒置原则。

迪米特法则(Law of Demeter)

迪米特法则(Law of Demeter, LoD),也被称为最少知识原则(Principle of Least Knowledge),是一种软件开发的设计原则,用于降低系统中各部分之间的耦合。该原则指出一个对象应该对其他对象有尽可能少的了解。

迪米特法则建议:

  • 每个单元应该只与其直接的朋友通信,而不与陌生人通信。这里的“朋友”是指直接的成员变量。

  • 具体来说,一个对象A不应该使用另一个对象B的属性来调用C的方法。相反,对象B应该提供一个方法来封装对对象C的操作。

优点

  • 减少耦合:遵循迪米特法则可以减少类与类之间的耦合。

  • 增加模块的独立性:每个模块对其他模块的了解越少,修改一个模块对其他模块的影响也就越小,从而提高模块的独立性。

示例

假设有一个简单的在线购物系统,其中包含Customer(顾客)、Wallet(钱包)和PaymentService(支付服务)三个类。顾客通过支付服务进行支付,支付时需要使用顾客的钱包中的金额。

不遵循迪米特法则的例子

class Wallet {
    constructor(amount) {
        this.amount = amount;
    }
}

class Customer {
    constructor(wallet) {
        this.wallet = wallet;
    }
}

class PaymentService {
    static pay(customer, amount) {
        if (customer.wallet.amount >= amount) {
            customer.wallet.amount -= amount;
            console.log('Payment successful');
        } else {
            console.log('Insufficient funds');
        }
    }
}

const myWallet = new Wallet(100);
const me = new Customer(myWallet);
PaymentService.pay(me, 50); // Payment successful

在这个例子中,PaymentService直接访问了CustomerWallet来进行支付,违反了迪米特法则。

遵循迪米特法则的改进

class Wallet {
    constructor(amount) {
        this.amount = amount;
    }

    pay(amount) {
        if (this.amount >= amount) {
            this.amount -= amount;
            console.log('Payment successful');
            return true;
        } else {
            console.log('Insufficient funds');
            return false;
        }
    }
}

class Customer {
    constructor(wallet) {
        this.wallet = wallet;
    }

    makePayment(amount) {
        return this.wallet.pay(amount);
    }
}

class PaymentService {
    static pay(customer, amount) {
        return customer.makePayment(amount);
    }
}

const myWallet = new Wallet(100);
const me = new Customer(myWallet);
PaymentService.pay(me, 50); // Payment successful

在改进后的例子中,PaymentService不再直接与Wallet交互,而是通过Customer来完成支付操作,这样就减少了类之间的直接耦合,更好地遵循了迪米特法则。

参考资料

  • 14
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值