引言
简介:什么是面向对象编程?
面向对象编程将一个系统抽象为许多对象的集合,每一个对象代表了这个系统的特定方面。对象包括函数(方法)和数据。一个对象可以向其他部分的代码提供一个公共接口,而其他部分的代码可以通过公共接口执行该对象的特定操作,系统的其他部分不需要关心对象内部是如何完成任务的,这样保持了对象自己内部状态的私有性。
概括:面向对象是一种思想,一种编程模式:先创造一个实现功能的对象,再使用这个对象实现功能。
面向对象编程与过程式编程的比较。
面向过程:就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候再一个一个的依次调用就可以了。
面向过程 | 面向对象 | |
优点 | 性能比面向对象高,适合跟硬件联系很紧密的东西,例如单片机就采用的面向过程编程。 | 易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护 |
缺点 | 不易维护、不易复用、不易扩展 | 性能比面向过程低 |
对象和类
类
类:是一个模板,描述一类对象的行为和状态。
使用函数创建类:
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()
时,浏览器做了这些事情:
-
在
dog
中寻找toString
属性。 -
dog
中找不到toString
属性,故在dog
的原型对象中寻找toString
。 -
其原型对象也没有这个属性,然后在
dog
的原型对象的原型对象即:Object.prototype
中找toString
。 -
在
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
操作符来创建一个对象的过程涉及到几个关键步骤。这个过程不仅涉及到实例化一个新对象,还包括将构造函数的原型赋给新对象的原型链,并执行构造函数以初始化新对象。以下是这个过程的详细步骤:
-
创建一个空对象:使用
new
操作符时,JavaScript首先创建一个空的JavaScript对象(即{}
)。 -
设置原型链:新创建的空对象的
__proto__
属性(即原型链)被赋值为构造函数的prototype
属性。这意味着新对象可以访问构造函数原型上的属性和方法。 -
执行构造函数:新创建的对象作为
this
传入构造函数中执行。这一步允许在构造函数中为新对象定义属性和方法。 -
返回新对象:如果构造函数中有返回值且返回值是一个对象,那么这个对象会成为
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
指向新创建的对象,给这个新对象添加了name
、age
属性和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
),而数据加载和显示的职责被分离到了UserDataLoader
和UserDisplayer
两个类中。这样做不仅使每个类更加专注和独立,也使得未来对加载或显示逻辑的修改更加容易和安全。
开放封闭原则(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
的一个实例,且T
是S
的子类型,那么在程序中使用父类S
的对象的地方都可以使用子类T
的对象。
换句话说,子类在继承父类的时候,除了增加新的方法完成新增功能外,还要确保通过父类实例可以访问到的所有方法,在子类实例中仍然可以使用。
优点
-
提高代码的可复用性:通过继承和多态性减少重复代码。
-
提高代码的可维护性:当修改父类时,不需要修改到使用父类的地方。
示例
考虑一个简单的几何形状处理系统,我们有一个基类Shape
和它的两个子类Rectangle
和Square
。
不遵循里氏替换原则的例子:
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
类违反了里氏替换原则,因为它改变了Rectangle
类setWidth
和setHeight
方法的行为。这导致那些期望使用Rectangle
行为的代码在使用Square
时可能不会按预期工作。
遵循里氏替换原则的改进:
为了遵循LSP,我们应该重新设计这些类的结构,使得每个类都符合它们应有的行为。例如,可以抽象出一个更通用的基类Shape
,然后让Rectangle
和Square
分别实现它,而不是让Square
继承Rectangle
。这样,Square
和Rectangle
就不再有"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)是面向对象设计原则之一,强调了一种特定的模块依赖关系。依赖倒置原则指出:
-
高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
-
抽象不应该依赖于细节。细节应该依赖于抽象。
换句话说,这个原则鼓励我们优先使用抽象(接口或抽象类)而不是具体实现,从而使得高层模块和低层模块之间的依赖关系更加灵活和可维护。
优点
-
提高模块间的解耦性:模块之间不再直接依赖具体实现,而是依赖抽象,使得改变一个模块的实现不会影响到依赖它的其他模块。
-
增加代码的可复用性:通过面向接口编程,可以使得代码更容易在不同场景下复用。
-
提高代码的可维护性:降低了模块间的耦合度,使得系统更容易理解和维护。
示例
假设我们有一个应用程序,其中包含用户界面(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
直接访问了Customer
的Wallet
来进行支付,违反了迪米特法则。
遵循迪米特法则的改进:
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
来完成支付操作,这样就减少了类之间的直接耦合,更好地遵循了迪米特法则。