面向对象基础

第一部分:基本概念

什么是面向对象编程

通过编程解决问题的过程就是使用代码对现实问题建模的过程,“面向”是说我们选择某一个视角来建模复杂的业务问题。

  • 面向过程编程

关注"做什么"—— 把问题分解为一系列步骤或操作

// 面向过程的思维  
function createUser(name: string, age: number) {  
  validateName(name);  
  validateAge(age);  
  saveToDatabase(name, age);  
  sendWelcomeEmail(name);  
}

什么是面向对象编程

  • 面向对象编程

关注"谁来做"—— 把问题分解为一组对象,让对象之间协作

// 面向对象的思维  
class User {  
  constructor(name: string, age: number) {  
    this.name = name;  
    this.age = age;  
  }  
  
  validate() {  
    this.validateName();  
    this.validateAge();  
  }  
  
  save() {  
    // 保存到数据库  
  }  
  
  sendWelcomeEmail() {  
    // 发送欢迎邮件  
  }  
}

数据与对其进行的操作集中在一起,更有利于抽象和复用

对象是什么

就像现实世界中的事物一样,对象包含两个基本要素:

以汽车为例:

  • 属性(特征):描述对象是什么或处于什么状态
  • 方法(行为):描述对象能做什么,可能会随状态变化,也可能修改状态
  • 属性:品牌、颜色、速度、油量等
  • 方法:启动、加速、刹车、加油等

在编程中,可以使用类(class)来描述并重复生成同一种对象。

类是什么

在编程中:

  • 类定义了某一类事物应该具有的属性(特征)和方法行为
  • 对象是类的具体实例,拥有类定义的所有特征,但具有自己的独特状态

 

类比汽车制造过程:

  • 类(class)就像汽车的设计图纸
  • 对象(object)则是根据这份图纸制造出来的具体汽车
  • 同一份图纸可以制造出多辆外观、颜色不同,但基本结构相同的汽车

类的结构

  • 属性 property

属性是类中用来存储数据的变量,描述了对象的特征或状态

class Car {  
    brand: string;  
    speed: number;  
}

类的结构

  • 方法 method

方法描述了对象可以执行的操作

class Car {  
    accelerate() {  
        // 加速逻辑  
    }  
    brake() {  
        // 刹车逻辑  
    }  
}

“函数”与“方法”

  • “函数”是通用的概念:一段独立的代码块接受输入,执行特定任务并返回;
  • “方法”是 OOP 里的概念,必须通过对象调用;

类的结构

  • 构造函数
class Car {  
    brand: string;  
    speed: number;  

    constructor(brand: string) {  
        this.brand = brand;  
        this.speed = 0;  
    }  
}

初始化对象的特殊方法,创建对象时自动调用

除了少数特殊情况,所有属性必须在构造函数中初始化完成,否则创建出的对象将处于非法状态

类的结构

  • 构造函数
class Plugin { 
    // 使用 ! 声明该属性会延迟初始化,而不是在构造函数里初始化
    private config!: Config; 
  
    initialize(config: Config) {
        this.config = config; //... 初始化 config
    }
  
    loadDocument() {
       this.config.getSomeValue();
       // ...
    }
}

在某些具备特殊生命周期的类里,属性只在特定特定生命周期后初始化和访问。这种情况通过声明延迟初始化该属性,可以延后初始化。以 TS 为例:

使用该特性时,必须严格确保该属性只会在初始化后访问

类的结构

  • 构造函数
class User {  
    // 参数属性语法:自动创建并初始化实例属性  
    constructor(  
        private name: string,      // 自动创建私有属性  
        public age: number = 20,   // 可以设置默认值  
        readonly id: string        // 只读属性  
    ) {}  
}

TS 里,构造函数支持参数属性语法

添加任一下列修饰符,可以自动创建并初始化实例属性:public, protected, private, readonly

// 传统写法  
class User {  
    private name: string;  
    public age: number;  
    readonly id: string;  

    constructor(name: string, age: number, id: string) {  
        this.name = name;  
        this.age = age;  
        this.id = id;  
    }  
}  

类的结构

  • 访问器方法 getter & setter

getter 和 setter 是两种特殊方法,主要用来封装访问属性和修改对象属性的行为。

class Person {  
    private _age: number;  // 私有属性  

    // getter  
    get age(): number {  
        return this._age;  
    }  

    // setter with validation  
    set age(value: number) {  
        if (value >= 0 && value <= 150) {  
            this._age = value;  
        } else {  
            throw new Error("年龄必须在0-150之间");  
        }  
    }  
}

类的结构

  • 访问器方法 getter & setter

getter 和 setter 是两种特殊方法,主要用来封装访问属性和修改对象属性的行为。

class Person {  
    private _age: number;  // 私有属性  

    // getter  
    get age(): number {  
        return this._age;  
    }  

    // setter with validation  
    set age(value: number) {  
        if (value >= 0 && value <= 150) {  
            this._age = value;  
        } else {  
            throw new Error("年龄必须在0-150之间");  
        }  
    }  
}
const person = new Person();
// 调用 setter 
person.age = 25;        
// 调用 getter,输出25
console.log(person.age); 

类的结构

  • 访问器方法 getter & setter
    • 使用场景1:保护内部数据,控制访问方式
class User {  
    private _password: string;  

    get password(): string {  
        return "********";  // 永远不返回真实密码  
    }  

    set password(value: string) {  
        this._password = value;  // 密码只能设置,不能查看  
    }  
}  
const user = new User();  
user.password = "123456";  
console.log(user.password);  // 输出: ********  

只提供 getter 可以实现只读属性,也是类似的使用场景

类的结构

  • 访问器方法 getter & setter
    • 使用场景2:校验输入数据,确保符合业务规则
class Person {  
    private _age: number;  

    get age(): number {  
        return this._age;  
    }  

    set age(value: number) {  
        if (value < 0 || value > 150) {  
            throw new Error("年龄必须在0-150之间");  
        }  
        this._age = value;  
    }  
}  
const person = new Person();  
person.age = 20;  // 正常  
person.age = -1;  // 抛出错误:年龄必须在0-150之间 

类的结构

  • 访问器方法 getter & setter
    • 使用场景3:基于其他属性动态计算的计算属性(computed)
class Rectangle {  
    private _width: number;  
    private _height: number;  

    constructor(width: number, height: number) {  
        this._width = width;  
        this._height = height;  
    }  

    // 计算属性:面积  
    get area(): number {  
        return this._width * this._height;  
    }
}  
const rect = new Rectangle(10, 5);  
console.log(rect.area);      // 输出: 50  

类的结构

  • 访问器方法 getter & setter

相比于提供 setWidth, getWidth, getArea 等显式的方法,优势在于:

  • 在具备上述能力的同时提供更自然的属性读写的语法​
  • 某些情况下,调用方无法将属性读写修改为显式的方法调用

类的结构

  • 静态属性
class Car {  
    static readonly MAX_SPEED = 200;  
    static readonly DEFAULT_COLOR = 'black';  

    private speed: number;  
    private color: string;  

    constructor(color?: string) {  
        this.speed = 0;  
        this.color = color ?? Car.DEFAULT_COLOR;  
    }  
}  

属于类本身而不是实例的属性,一般用于定义与该类相关的常量或配置。

编程语言不限制定义可变的静态属性,但这种用法与全局变量近似,会带来很多问题,因此推荐只定义只读的静态属性。

// 使用示例  
console.log(Car.MAX_SPEED); // 200  
console.log(Car.DEFAULT_COLOR); // 'black'  

类的结构

  • 静态方法
class User {  
    constructor(private name: string) {}  

    // 工厂方法:创建特定对象  
    static createGuest(): User {  
        return new User("访客");  
    }  

    // 工具方法:提供通用功能  
    static formatName(name: string): string {  
        return `用户: ${name}`;  
    }  
}  

属于类本身而不是实例的方法,一般用于定义与该类相关的工具函数,工厂方法等。

// 使用示例  
const guest = User.createGuest();  
console.log(User.formatName("张三"));  // "用户: 张三"  

类的结构

  • 访问控制修饰符

访问控制修饰符用于限定类成员(属性和方法)的访问范围,是实现封装的关键工具。

  • public (TS 的默认行为)
    • 对外公开的属性或方法
    • 可以在任何地方访问
  • protected
    • 需要在继承链中共享但不想公开的属性或方法
    • 只能在类内部和子类中访问
  • private
    • 类内部使用的属性或方法
    • 只能在类内部访问
  • package-private(​Java 特有,且是其默认行为)
    • ​同一个包 (package) 内的类可以访问的属性或方法

OOP 的三大特点:

封装、继承、多态

类的结构

  • 访问控制修饰符​

​先给所有属性和方法加上 private,直到有明确使用场景时再扩大权限

最大程度保护类的实现细节,只开放真正需要的访问权限

  • 接口一旦暴露出去,就会增加维护成本
  • 优先使用最严格的访问级别 private
  • 谨慎使用 protected 和 public

类的结构

  • 访问控制修饰符​

私有构造函数:TS 和 Java 都支持将构造函数声明为 private,从而限制外部创建实例。常见的使用场景包括:

  • 单例模式
  • 工厂模式
  • 纯静态工具类
  • 防止继承

类的结构

  • 访问控制修饰符​

私有构造函数:单例模式

class Singleton {  
    private static instance: Singleton;  

    // 私有构造函数,防止外部实例化  
    private constructor() { }  

    // 静态方法,返回唯一实例  
    public static getInstance(): Singleton {  
        if (!Singleton.instance) {  
            Singleton.instance = new Singleton();  
        }  
        return Singleton.instance;  
    }  

    public sayHello() {  
        console.log("Hello from Singleton!");  
    }  
}  

类的结构

  • 访问控制修饰符​

私有构造函数:工厂模式

class Product {  
  private name: string;  

  // 私有构造函数,外部无法直接实例化  
  private constructor(name: string) {  
    this.name = name;  
  }  

  // 静态工厂方法,用于创建类的实例  
  public static createProduct(name: string): Product {  
    return new Product(name);  
  }  

  // 获取产品名称的方法  
  public getName(): string {  
    return this.name;  
  }  
}

类的结构

  • 访问控制修饰符​

私有构造函数:纯静态工具类

class Utility {  
    private constructor() {} // 私有构造函数  

    static add(a: number, b: number): number {  
        return a + b;  
    }  
} 

在 TS 里,我们不必要完全抛弃非 OOP 写法,像这种纯静态类其实也可以定义为导出多个常规函数的 module

类的结构

  • 访问控制修饰符​

私有构造函数:防止继承

class Final {  
    private constructor() {} // 私有构造函数,禁止继承或实例化  
}  

class Derived extends Final {} // 错误:无法继承

在 Java 里可以通过 final class 实现类似的效果

类的结构

  • 继承:允许一个类扩展另一个类并继承其属性和方法。子类可以重写父类的方法,或者调用父类的构造函数和方法。
class Animal {  
    public name: string;  
    private age: number;  
    protected type: string;  

    constructor(name: string, age: number) {  
        this.name = name;  
        this.age = age;  
        this.type = 'animal';  
    }  
    public makeSound(): void {  
        console.log('Some sound');  
    } 
    private getAge(): number {  
        return this.age;  
    }  
    protected getInfo(): string {  
        return `${this.name} is ${this.age} years old`;  
    }  
}  
class Dog extends Animal {  
    constructor(name: string, age: number) {  
        // 调用父类构造函数  
        super(name, age);
        // 可以访问 protected 属性  
        this.type = 'dog'; 
    }  
    // 重写父类方法  
    public makeSound(): void {  
        console.log('Woof!');  
    }  
    // 新增子类方法  
    public showInfo(): void {  
        // console.log(this.age); // 错误 
        console.log(this.type);   
        console.log(this.name);  
        console.log(this.getInfo()); 
    }  
}  

OOP 的三大特点:

封装、继承、多态

类的结构

  • 抽象类:使用 abstract class 关键字定义抽象类,用于表达不完整但具有共同特征的概念
// 抽象类  
abstract class Shape {  
    // 普通属性  
    protected color: string;  

    // 构造函数  
    constructor(color: string) {  
        this.color = color;  
    }  

    // 抽象方法:子类必须实现  
    abstract calculateArea(): number;  
    abstract calculatePerimeter(): number;  

    // 具体方法:子类可以直接使用  
    public getInfo(): string {  
        return `这是一个${this.color}的图形,面积为${this.calculateArea()}`;  
    }  
}  
// 具体类:圆形  
class Circle extends Shape {  
    private radius: number;  

    constructor(color: string, radius: number) {  
        super(color);  // 调用父类构造函数  
        this.radius = radius;  
    }  

    // 实现抽象方法  
    calculateArea(): number {  
        return Math.PI * this.radius * this.radius;  
    }  

    calculatePerimeter(): number {  
        return 2 * Math.PI * this.radius;  
    }  
}  

类的结构

  • 抽象类
  • 抽象类不能被直接实例化,只能通过具体子类实例化
  • 抽象类要至少包含一个 abstract 的属性或方法
  • 具体类是继承或实现了所有 abstract 属性和方法的子类,对应一种确实存在,功能完整的对象

典型例子:

  • 生物分类:动物、植物(抽象) → 狗、猫、树(具体)
  • 食物种类:主食、零食(抽象) → 米饭、面包、薯片(具体)
  • 职业类型:工人、艺术家(抽象) → 木工、画家(具体)

这种区分不是绝对的,取决于设计的抽象程度

与抽象类对应的是具体类

类的结构

  • 抽象类

常见的抽象类的使用场景:

  • 泛化的事物类别

    • 比如"动物":动物会吃东西、运动,但"动物"本身是个抽象概念,现实中不存在"一般的动物",必须是具体的狗、猫等才能存在
  • 包含部分已知特征的模板

    • 比如"交通工具":它一定能运输、有速度,但具体如何运输(飞、开、游)需要具体类型来定义,汽车、飞机等才是完整的实现
  • 需要统一规范的基础设施

    • 比如"数据库连接":知道它需要连接、查询、关闭,但具体的连接方式取决于数据库类型,MySQL、MongoDB 等实现具体细节

继承和抽象类是为了代码重用,但要谨慎使用,避免不合理的抽象或过深的继承带来的维护性问题。后面会展开介绍。

继承的问题

  • is-a 关系脆弱

继承表达的是“子类”是“父类”特例的情况,即“子类对象 is a 父类对象”,但真实世界里很少存在这样稳定的 is-a 关系。

abstract class Bird {  
    fly(): void {  
        console.log("我在飞!");  
    }  
    
    walk(): void {  
        console.log("我在走路");  
    }  
    
    makeSound(): void {  
        console.log("我在叫");  
    }  
}  
// 鸭子继承鸟类  
class Duck extends Bird {  
    makeSound(): void {  
        console.log("嘎嘎嘎");  
    }  
}  

// 鸡继承鸟类  
class Chicken extends Bird {  
    makeSound(): void {  
        console.log("咯咯咯");  
    }  
}

// 看起来工作得很好  
const duck = new Duck();  
duck.fly();  // 输出:我在飞!  
duck.makeSound();  // 输出:嘎嘎嘎  

继承的问题

  • is-a 关系脆弱

继承表达的是“子类”是“父类”特例的情况,即“子类对象 is a 父类对象”,但真实世界里很少存在这样稳定的 is-a 关系。

abstract class Bird {  
    fly(): void {  
        console.log("我在飞!");  
    }  
    
    walk(): void {  
        console.log("我在走路");  
    }  
    
    makeSound(): void {  
        console.log("我在叫");  
    }  
}  
// 企鹅出现了...  
class Penguin extends Bird {  
    // 企鹅不会飞,但继承了 fly 方法  
    fly(): void {  
        // 这很尴尬 
        throw new Error("我不会飞!");  
    }  
    
    swim(): void {  
        console.log("我在游泳!");  
    }  
}

继承的问题

  • is-a 关系脆弱
abstract class Bird {  
    fly(): void {  
        console.log("我在飞!");  
    }  
    
    walk(): void {  
        console.log("我在走路");  
    }  
    
    makeSound(): void {  
        console.log("我在叫");  
    }  
}  
  • 它会叫(发出声音),但声音是电子的
  • 它能飘在水上,但不是游泳

生活中没有鸟,鸟只是一个抽象, 我们生活中有鸡,有鸭。

我们认为它们有一些相同的地方,于是把拥有这些相同点的东西叫做鸟,但永远不确定下一个遇见的能不能算鸟, 鸟的定义要不要修改。

如果新来了橡皮鸭子,它是鸟吗?

继承的问题

  • is-a 关系脆弱
class Rectangle {  
    private width: number;  
    private height: number;  

	// ...省略构造函数

    public setWidth(width: number) {  
        this.width = width;  
    }  

    public setHeight(height: number) {  
        this.height = height;  
    }  

    public getArea() {  
        return this.width * this.height;  
    }  
}
class Square extends Rectangle {  
    // ...省略构造函数

    public setWidth(width: number) {  
        // 修改宽度时,高度也要跟着改变  
        super.setWidth(width);
      	super.setHeight(width);
    }  
 
    public setHeight(height: number) {  
        // 修改高度时,宽度也要跟着改变  
        super.setWidth(width);
      	super.setHeight(width); 
    }  
}

一个正方形是一个矩形吗?

继承的问题

  • 子类与父类之间耦合

子类在重写父类属性或方法时,常常需要了解其实现细节才能不破坏其原有行为。父类为了让子类通过重写扩展其能力,不得不向其暴露更多内部细节。

// 基础订单类  
class Order {  
    protected price: number;  
    protected quantity: number;  

    constructor(price: number, quantity: number) {  
        this.price = price;  
        this.quantity = quantity;  
    }  

    getTotal(): number {  
        return this.price * this.quantity;  
    }  
}
// 折扣订单继承自基础订单  
class DiscountOrder extends Order {  
    private discount: number;  

    constructor(
       price: number, 
       quantity: number, 
       discount: number
    ) {  
        super(price, quantity);  
        this.discount = discount;  
    }  

    getTotal(): number {  
        // 依赖了父类的实现细节:
        // 假设父类就是简单的 price * quantity  
        return super.getTotal() * (1 - this.discount);  
    }  
}  

继承的问题

  • 单继承无法组合多个功能

在 TS 和 Java 里,子类同时只能继承一个父类,要用复用不同功能必须通过多层继承来叠加功能,这非常不灵活且难以维护。

class Animal {  
  eat() {  
    console.log("This animal is eating.");  
  }  
}  

class FlyingAnimal extends Animal {  
  fly() {  
    console.log("This animal can fly.");  
  }  
}  
  
class SwimmingAnimal extends Animal {  
  swim() {  
    console.log("This animal can swim.");  
  }  
}  
// 子类:鸟类(只能飞)  
class Bird extends FlyingAnimal {}  

// 子类:鱼类(只能游泳)  
class Fish extends SwimmingAnimal {}  

// 如果需要一个既能飞又能游泳的动物
// 需要通过多层继承  
class Penguin extends SwimmingAnimal {  
  fly() {  
    console.log(
      "Penguins can't fly, but let's pretend they can."
    );  
  }  
}

interface

  • interface 只定义契约,不提供实现
  • 实现 interface 的类必须遵守接口契约
  • 一个类可以同时实现多个 interface,从而灵活组合功能
// 定义接口:飞行能力  
interface Flyable {  
  fly(): void;  
}  

// 定义接口:游泳能力  
interface Swimmable {  
  swim(): void;  
}  

// 基础类:动物  
class Animal {  
  eat(): void {  
    console.log("This animal is eating.");  
  }  
}  
class Bird extends Animal implements Flyable {  
  fly(): void {  
    console.log("This bird can fly.");  
  }  
}  
class Fish extends Animal implements Swimmable {  
  swim(): void {  
    console.log("This fish can swim.");  
  }  
}   
class Penguin extends Animal implements Flyable, Swimmable {  
  fly(): void {  
    console.log("This penguin can fly");  
  }  

  swim(): void {  
    console.log("This penguin can swim.");  
  }  
}

 interface

// 定义接口:飞行能力  
type Flyable = {  
  fly(): void;  
}  

// 基础类:动物  
class Animal {  
  eat(): void {  
    console.log("This animal is eating.");  
  }  
}  

class Bird extends Animal implements Flyable {  
  fly(): void {  
    console.log("This bird can fly.");  
  }  
}  
  • 在 TS 里,所有类型都可以当作 interface 使用
  • 一种是通用概念:OOP 里可以被 implements 的接口
  • 另一种是 TS 里的关键字 interface

注意区分两种 interface 的概念:

 interface

  • TS 里无法在运行时判断一个实例是否实现了某个 interface
    • ​不能使用 object instanceof FooInterface
    • 可以基于 duck type 的特性使用自定义 type guard 代替
  • Java 可以使用 object instanceof FooInterface
interface Person {  
    name: string;  
    age: number;  
}  

function isPerson(value: unknown): value is Person {  
    return (  
        typeof value === "object" &&  
        value !== null &&  
        "name" in value &&  
        "age" in value &&  
        typeof (value as Person).name === "string" &&  
        typeof (value as Person).age === "number"  
    );  
}

接口与实现分离

  •  将接口与实现分开,使程序的使用者只需要了解接口的定义,而不需要关心具体的实现细节。
    • 注意:这里所说的接口是指抽象的概念,与前面的 interface 不同
    • 在实际编程中,接口可以是抽象类或 interface
// 定义接口(描述行为)  
interface Logger {  
  log(message: string): void;  
  error(message: string): void;  
} 

// 使用接口而非具体实现  
function doSomething(logger: Logger) {  
  logger.log("a log message.");  
  logger.error("an error message.");  
}
// 实现接口的具体类  
class ConsoleLogger implements Logger {  
  log(message: string): void {  
    console.log(`Log: ${message}`);  
  }  

  error(message: string): void {  
    console.error(`Error: ${message}`);  
  }  
}

// 创建具体实现并传递给函数  
const logger = new ConsoleLogger();  
doSomething(logger);  

多态

  • 多态是指同一个接口在不同的类中具有不同的实现

OOP 的三大特点:

封装、继承、多态

多态允许我们通过统一的方式调用不同对象的行为,而不需要关心这些对象的具体类型。具体来说,它有两种表现形式:

  • 子类继承父类,重写其方法,提供不同行为
  • 不同类实现同一个接口,并提供不同行为
// 定义一个父类  
class Animal {  
  speak(): void {  
    console.log("Animal makes a sound");  
  }  
}  

// 使用多态  
function makeAnimalSpeak(animal: Animal) {  
  animal.speak(); // 调用的是具体对象的实现  
}  
class Dog extends Animal {  
  speak(): void {  
    console.log("Dog barks");  
  }  
}  
class Cat extends Animal {  
  speak(): void {  
    console.log("Cat meows");  
  }  
}  

// 调用统一的方法  
makeAnimalSpeak(new Dog());// Dog barks  
makeAnimalSpeak(new Cat());// Cat meows  

组合 Composition

  • 将对象分解为更小的、可复用的组件,并通过组合这些组件来构建复杂的功能
class Car {  
  startEngine(): void {  
    console.log("Engine starts.");  
  }  
  stopEngine(): void {  
    console.log("Engine stops.");  
  }
} 

使用组合改造该实现:

// 子类:电动车  
class ElectricCar extends Car {  
  chargeBattery(): void {  
    console.log("Battery is charging.");  
  }  
  // 重写引擎启动方法,适配电动车的行为  
  startEngine(): void {  
    console.log("Electric motor starts silently.");  
  }  
}  
// 子类:燃油车  
class GasolineCar extends Car {  
  refuel(): void {  
    console.log("Refueling the car.");  
  }  
  // 重写引擎启动方法,适配燃油车的行为  
  startEngine(): void {  
    console.log("Gasoline engine starts with a roar.");  
  }  
}  

组合

// 引擎接口  
interface Engine {  
  start(): void;  
  stop(): void;  
}
// 汽车类,通过组合引擎实现功能  
class Car {  
  private engine: Engine;  

  constructor(engine: Engine) {  
    this.engine = engine; // 通过依赖注入传入引擎  
  }  
  startEngine(): void {  
    this.engine.start();  
  }  
  stopEngine(): void {  
    this.engine.stop();  
  }
}  
  • 使用组合实现
// 燃油引擎实现  
class GasolineEngine implements Engine {  
  start(): void {  
    console.log("Gasoline engine starts with a roar.");  
  }  
  stop(): void {  
    console.log("Gasoline engine stops.");  
  }  
}  
// 电动引擎实现  
class ElectricEngine implements Engine {  
  start(): void {  
    console.log("Electric motor starts silently.");  
  }  
  stop(): void {  
    console.log("Electric motor stops.");  
  }  
}  
const gasolineCar = new Car(new GasolineEngine());
const electricCar = new Car(new ElectricEngine());

组合

  • 组合的优势​
    • 低耦合:通过接口或对象引用来实现功能,组件之间的耦合较低
    • 更灵活:可以动态地组合不同的组件来实现不同的功能,而不需要依赖固定的继承关系
    • 更好的复用性:组件独立于类的层次结构,方便在不同的上下文中复用
  • 继承:类之间存在 “is-a” 关系
  • 组合:类之间存在 “has-a” 关系

聚合 Aggregation

  • 组合:部分不能脱离整体存在,整体被销毁时部分也会被销毁
    • 引擎是汽车不可分割的一部分
    • 汽车报废时,引擎作为其组成部分也会一同报废
    • 引擎的生命周期完全依附于汽车
  • 聚合:部分可以独立于整体而存在。即使整体被销毁,部分仍可以存在
    • 图书可以在不同图书馆之间转移
    • 即使图书馆关闭,图书依然可以存在
    • 一本书可以被多个图书馆收藏

不同类别的对象

  • 在经典的面向对象系统中,根据职责可以将对象分为以下几类:
  • 实体(Entity)​
    • 具有唯一标识符(ID)的业务对象
    • 在整个生命周期中保持 ID 不变
    • 通常映射到数据库中的记录
    • 包含业务逻辑和行为
    • 例如: User, Order, Furniture
  • 值对象(Value Object)​
    • 没有唯一标识符
    • 通过其属性值来判断相等性
    • 一般不可变(Immutable)
    • 用于描述、计量或度量
    • 例如: Point2d, Matrix4, Date, Color

不同类别的对象

  • 在经典的面向对象系统中,根据职责可以将对象分为以下几类:
  • 服务(Service)​
    • 封装特定的业务逻辑
    • 一般无状态从而可以重复执行操作得到相同结果
    • 提供操作或计算功能,处理跨实体的业务逻辑
    • 例如: PaymentService, RenderService

对象的生命周期

  • 创建(Creation)
    • 调用构造函数
    • 初始化属性
  • 使用(Usage)
    • 访问属性
    • 调用方法
  • 销毁(Destruction)
    • 调用析构函数(如果有)
    • 释放资源
      • 断开数据库/文件读写
      • 取消异步任务
      • 移除事件监听

系统设计时要考虑清楚:对象会被谁创建和使用,会在什么时机创建,什么时机销毁。

良好的生命周期管理可以帮助我们避免资源泄露,确保对象正确初始化。

对象可变性

  • Mutable 对象:创建后状态(属性)可以被改变
  • Immutable 对象:创建后状态(属性)不能被改变,任何修改都会返回新对象
class Vector2 {  
  constructor(  
    private readonly x: number,  
    private readonly y: number  
  ) {}  
  
  // 过去时态暗示返回新实例  
  added(other: Vector2): Vector2 {  
    return new Vector2(this.x + other.x, this.y + other.y);  
  }  
  
  subtracted(other: Vector2): Vector2 {  
    return new Vector2(this.x - other.x, this.y - other.y);  
  }  
  
  equals(other: Vector2): boolean {  
    return this.x === other.x && this.y === other.y;  
  }  
} 

对象可变性

  • Immutable 对象的使用场景​
    • 值对象(Value Objects)
      • 代表一个确定的值或状态,如金额、坐标、时间点,业务规则要求数据一旦创建就不应被修改
    • 缓存场景:
      • 可以安全地被多处代码共享和缓存,无需担心缓存失效问题
    • ​Java 里部分场景:
      • HashMap、HashSet 等集合中作为键 
      • 线程安全要求
  • ​​代价​
    • 每次修改都会创建新对象,可能带来性能开销
    • 某些场景下导致代码冗长

对象序列化

  • 序列化:将对象转换为可传输或存储的格式
  • 反序列化:将数据重新转换为可用的对象实例
  • 常见的序列化格式:JSON、ProtocolBuffer 等

最基础的序列化和反序列化:手动转换

class User {  
  constructor(  
    public name: string,  
    public age: number,  
  ) {}  
  // 添加序列化方法  
  toJSON() {  
    return {  
      name: this.name,  
      age: this.age  
    };  
  }  
  // 添加静态反序列化方法  
  static fromJSON(json: string): User {  
    const data = JSON.parse(json);  
    return new User(data.name, data.age);  
  }  
}

对象序列化

更进一步地,可以使用序列化库来简化操作:

  • Java 里的 Gson 库
  • TS 里的 @qunhe/json-mapper
//json object
const j =  {
	a: 1,
	b: "",
	c: { d: 3, e: -1 },
	cs: [{ d: 4 }, { d: 5 }],
	d: 33
}
import { JsonProperty, deserialize, serialize } from '@qunhe/json-mapper';
////对应类定义:在需要进行序列化和反序列化的字段上加上注解。
class A {
	@JsonProperty()
	a: number;
	@JsonProperty()
	b: string;
	@JsonProperty({ excludeToJson: true }) //这个字段不会被序列化到json中
	c: B;
	@JsonProperty({ class: B }) // 必须写明类型,
	cs: B[]
	@JsonProperty('d') //将json的映射为 md
	private md: number;
}

//反序列化
const a = deserialize(A, j); //a is A
//序列化
const aj = serialize(a); // aj is an object

UML 类图

package

interface

抽象类

B 继承 A

B 实现 A

A 聚合 B

A 组合 B

第二部分:设计原则

依赖管理

  • 一个模块应该依赖于比它更稳定的模块(Depend in the direction of stability)
    • 稳定性指的是模块在系统中发生变化的可能性。
    • 一个模块越稳定,意味着它越不容易被修改或破坏,通常是因为它被广泛使用或具有较高的抽象性。

Model

UI

框架/通用模块

业务模块

小心循环依赖,它暗示了模块设计可能有问题

依赖管理

  • 什么是依赖
    • 如果对象 Foo 需要对象 Bar 来完成其工作的一部分,那么 Bar 就被称为 Foo 的依赖(dependency)。
class DashboardController {  
  public execute(): void {  
    const recentPosts = Cache.has('recent_posts') ? 
          Cache.get('recent_posts') : 
          [];  
    // ...  
  }  
}

避免隐式依赖

class DashboardController {  
  public execute(): void {  
    const recentPosts = Cache.has('recent_posts') ? Cache.get('recent_posts') : [];  
    // ...  
  }  
}
class DashboardController {  
  private cache: Cache;  

  constructor(cache: Cache) {  
    this.cache = cache;  
  }  

  public execute(): void {  
    const recentPosts = this.cache.has('recent_posts') ? this.cache.get('recent_posts') : [];  
    // ...  
  }  
}
  • 静态依赖(如调用静态方法或全局变量)会隐藏实际依赖,导致代码难以理解和测试。
    • 解决方法是将这些依赖封装为接口,通过构造函数注入。

抽象程度要随稳定性要求增加

  • 一个模块如果需要非常稳定(被许多其他模块依赖),那么它应该是抽象的,而不是具体的
    • ​避免因为实现细节的变化而导致依赖它的模块受到影响
// 抽象接口  
interface Vehicle {  
  start(): void; // 启动交通工具  
  stop(): void;  // 停止交通工具  
}  

// 具体类:飞机  
class Airplane implements Vehicle {  
  start(): void {  
    console.log("powering up the engines.");  
  }  

  stop(): void {  
    console.log("deploying the landing gear.");  
  }  
}  

抽象程度要随稳定性要求增加

  • 通过类来抽象和封装有特殊规则的领域对象或多个相关联的属性
    • 使用两个 number 表示精度和维度.
      • ​缺乏语义:单独的两个浮点数无法表达其业务含义
      • 规则分散:经纬度可能有特定的范围限制,如果不封装规则可能会散布在代码的多个地方。
    • 定义 Location 类表达位置
class Location {  
  constructor(private latitude: number, private longitude: number) {  
    // 根据业务规则,验证合法性
  }  
  // 获取纬度  
  getLatitude(): number {  
    return this.latitude;  
  }  
  // 获取经度  
  getLongitude(): number {  
    return this.longitude;  
  }  
}  

模块要易于扩展

  • 软件模块(类、函数等)应该对扩展开放,对修改关闭。
    • 通过扩展功能而不是修改已有代码来适应需求变化,从而减少对现有代码的影响,降低引入新错误的风险。
    • 主要描述的是设计目标而不是一种设计方法
    • 例如:通过继承或实现接口来扩展功能,而不是直接修改类的代码来添加新功能

SOLID -

Open/Closed Principle (开放封闭原则)

SOLID 其他几条原则说明了如何保证扩展性...

接口职责要单一且明确

  • 一个类应该只有一个职责
  • 将不同的功能分离到不同的类中,避免一个类承担过多的职责,从而降低耦合性。

SOLID -

Single Responsibility Principle (单一职责原则)

class UserService {  
  private users: string[] = [];  
  
  addUser(user: string): void {  
    this.users.push(user);  
    this.log(`User added: ${user}`);  
  }  
  
  removeUser(user: string): void {  
    this.users = this.users.filter(u => u !== user);  
    this.log(`User removed: ${user}`);  
  } 
  
  private log(message: string): void {  
    console.log(`[LOG]: ${message}`);  
  }  
}

接口职责要单一且明确

SOLID -

Interface Segregation Principle (接口隔离原则​)

  •  interface 应该尽量小而专一
  • 对 interface 实现者:一个类不会被迫实现它不支持的接口
  • 对 interface 调用者:不需要依赖它用不到的接口
// 定义多个小接口  
interface Printer {  
  print(document: string): void;  
}  

interface Scanner {  
  scan(): string;  
}  

interface Fax {  
  sendFax(document: string): void;  
}
// 多功能设备实现所有接口  
class MultiFunctionDevice implements Printer, Scanner, Fax {  
	// ...
}  

// 单功能设备只实现需要的接口  
class SimplePrinter implements Printer {  
  ///
} 
  • 区分查询方法和命令方法 (CQS: Command Query Separation)

    • 查询(Query):返回某些信息,但不会改变对象的状态
    • 命令(Command):执行某种操作,会改变对象的状态,但不返回任何值。

一般对于类的一个方法来说:

应该要么是命令,要么是查询,不应该同时是两者。

接口职责要单一且明确

public class BankAccount {  
    private double balance;  

    // 这个方法既修改了状态(存款),又返回了余额  
    public double deposit(double amount) {  
        balance += amount;  
        return balance;  
    }  
}

接口与实现分离

SOLID -

Dependency Inversion Principle (依赖倒置原则​)

低层模块

高层模块

前面介绍了依赖的方向应该是:高层模块(不稳定,具体)依赖低层模块(稳定,抽象)

框架/通用模块

业务模块

Model

UI

依赖倒置原则进一步要求:不要依赖具体实现,而是依赖于中间接口定义。

低层模块

高层模块

接口

接口与实现分离

class EmailService {  
    public void sendEmail(String message) {  
        System.out.println("Sending email: " + message);  
    }  
}  

class Notification {  
    private EmailService emailService;  
    public Notification() {  
        this.emailService = new EmailService(); // 高层模块直接依赖低层模块  
    }  
    public void notifyUser(String message) {  
        emailService.sendEmail(message);  
    }  
} 
  • 高层模块受制于低层模块:Notification类(高层模块)必须知道EmailService类(低层模块)的具体实现。
  • 低层模块的变化会影响高层模块:如果需要更换通知方式(比如改用短信服务),就需要修改Notification类的代码。
  • 耦合性高:高层模块和低层模块紧密耦合,难以扩展和维护。

接口与实现分离

// 抽象接口  
interface MessageService {  
    void sendMessage(String message);  
}  
// 低层模块实现接口  
class EmailService implements MessageService {  
    public void sendMessage(String message) {  
        System.out.println("Sending email: " + message);  
    }  
}  
// 高层模块依赖接口  
class Notification {  
    private MessageService messageService;  

    public Notification(MessageService messageService) {  
        this.messageService = messageService; // 通过依赖注入传入具体实现  
    }  

    public void notifyUser(String message) {  
        messageService.sendMessage(message);  
    }  
}  

依赖注入

低层模块与高层模块互不依赖,那么谁来将其串连起来呢?

低层模块

高层模块

接口

通过依赖注入

依赖注入

interface Logger {  
  log(message: string): void;  
}  

class ConsoleLogger implements Logger {  
	// ...
}  
  
class UserService {  
  private logger: Logger;  
  constructor(logger: Logger) {  
    this.logger = logger; // 通过构造函数注入依赖  
  }
}
// 使用时注入依赖  
const logger = new ConsoleLogger();  
const userService = new UserService(logger);  
userService.createUser("Alice");  
  • 构造函数注入:通过构造函数将依赖项传递给类

依赖注入

class UserService {  
  createUser(name: string, logger: Logger): void {  
    logger.log(`User ${name} has been created.`);  
  }  
}  
// 使用时注入依赖  
const logger = new ConsoleLogger();  
const userService = new UserService();  
userService.createUser("Charlie", logger); // 方法注入  
  • 方法注入:通过方法参数将依赖项传递给类的方法

依赖注入

  • 使用依赖注入框架:解决复杂项目里的依赖注入
    • TS:InversifyJS
      • Angular 框架里也广泛使用了依赖注入
    • Java:Spring, Dagger

继承或实现的基本原则

SOLID -

Liskov Substitution Principle (里氏替换原则​)

  • 子类必须能够替换其基类,并且保证程序的行为不变
    • ​接口的实现与接口之间也应遵守该原则
  • ​确保继承关系的正确性,避免因为子类(实现)的行为与基类(接口)不一致而导致程序出错
    • 正确性体现在两方面:
      • ​接口类型上要兼容
      • 接口语义和功能要一致
  • ​​实践方法:
    • ​避免不恰当的继承(前文提到过继承的问题和解法)
    • 理解并遵守接口行为

设计模式 Design patterns

设计模式是一套被反复使用的、经过验证的解决特定问题的最佳实践和经验总结。

  • 设计模式本身是个通用概念,每个人都可以总结出新的模式。
  • 在 OOP 里,设计模式一般是指 GOF 提出的针对面向对象范式提出的 23 种模式,例如:
    • 单例模式
    • 工厂模式
    • 适配器模式
    • 组合模式
    • 装饰器模式
    • 代理模式
    • 责任链模式
    • 命令模式
    • ...

设计模式 Design patterns

设计模式通常包括以下四个基本元素:

  • 模式名称
    • 一个简洁的名称,用于描述设计问题及其解决方案,方便沟通
  • 问题
    • 描述何时应用该模式,解释问题的背景和内容
  • 解决方案
    • 提供一个抽象的设计描述,说明如何通过 OOP 里的机制来解决问题
  • 后果
    • 描述应用该模式的优势和劣势,包括空间和时间的权衡、语言和实现问题等

If you’re a beginning programmer you won’t understand a lot of the material, and if you are experienced, the book will only confirm what you already know.

如果你是一名初级程序员,你将无法理解很多内容,而如果你很有经验,这本书只会确认已经知道的内容。

不要过度设计,过度抽象

系统设计练习题

  • 网上的题目,自己设计,与他人对比
  • 与 AI 对话练习

Q&A

谢谢

面向对象基础

By yiliang_wang

面向对象基础

  • 11