设计模式——创建型设计模式:阅读笔记与个人思考

创建型设计模式

1: Abstract Factory

一句话总结

  • **本质:**对于本质相同,但表现形式不同的事物,采用同一个接口间接创建,而不是直接在代码中创建。

  • 优点: 动态切换不同表现的时候,不需要改动创建时候的代码。

OOP写法

第一步:定义抽象接口(对应于上文的本质)

ts 复制代码
// 抽象产品 A
interface Button {
    paint(): void;
}

// 抽象产品 B
interface TextField {
    display(): void;
}

第二步:定义不同的实现(对应于上文的表现)

ts 复制代码
// Windows 家族的具体产品
class WindowsButton implements Button {
    public paint(): void { 
        console.log("渲染 Windows 风格按钮"); 
    }
}

class WindowsTextField implements TextField {
    public display(): void { 
        console.log("显示 Windows 风格文本框"); 
    }
}

// Mac 家族的具体产品
class MacButton implements Button {
    public paint(): void { 
        console.log("渲染 Mac 风格按钮"); 
    }
}

class MacTextField implements TextField {
    public display(): void { 
        console.log("显示 Mac 风格文本框"); 
    }
}

第三步:定义同一个创建接口

ts 复制代码
// 统一的工厂契约
interface GUIFactory {
    createButton(): Button;       // 工厂方法 A
    createTextField(): TextField; // 工厂方法 B
}

// Windows 工厂:只打包生产 Windows 系列组件
class WindowsFactory implements GUIFactory {
    public createButton(): Button { 
        return new WindowsButton(); 
    }
    public createTextField(): TextField { 
        return new WindowsTextField(); 
    }
}

// Mac 工厂:只打包生产 Mac 系列组件
class MacFactory implements GUIFactory {
    public createButton(): Button { 
        return new MacButton(); 
    }
    public createTextField(): TextField { 
        return new MacTextField(); 
    }
}

使用方法:

ts 复制代码
class Application {
    private button: Button;
    private textField: TextField;

    // 客户端只接受抽象的 GUIFactory,具体传入什么工厂它不关心
    constructor(factory: GUIFactory) {
        this.button = factory.createButton();
        this.textField = factory.createTextField();
    }

    public run(): void {
        this.button.paint();
        this.textField.display();
    }
}

// === 测试运行 ===

// 想要 Windows 风格,就注入 Windows 工厂
const winApp = new Application(new WindowsFactory());
winApp.run(); 
// 输出: 
// 渲染 Windows 风格按钮
// 显示 Windows 风格文本框

// 想要切换表现时,客户端(Application)核心代码完全不需要改动,只需要改动注入的具体工厂
const macApp = new Application(new MacFactory());
macApp.run();
// 输出:
// 渲染 Mac 风格按钮
// 显示 Mac 风格文本框

现代写法

非OOP语言中,根本不需要这么麻烦,也不需要利用OOP的多态,使用泛型约束能够更好的控制类型。

第一步:定义抽象接口

rust 复制代码
// 抽象产品 A
pub trait Button {
    fn paint(&self);
}

// 抽象产品 B
pub trait TextField {
    fn display(&self);
}

第二步:定义不同的实现

rust 复制代码
// --- Windows 家族的具体产品 ---
pub struct WindowsButton;
impl Button for WindowsButton {
    fn paint(&self) {
        println!("渲染 Windows 风格按钮");
    }
}

pub struct WindowsTextField;
impl TextField for WindowsTextField {
    fn display(&self) {
        println!("显示 Windows 风格文本框");
    }
}

// --- Mac 家族的具体产品 ---
pub struct MacButton;
impl Button for MacButton {
    fn paint(&self) {
        println!("渲染 Mac 风格按钮");
    }
}

pub struct MacTextField;
impl TextField for MacTextField {
    fn display(&self) {
        println!("显示 Mac 风格文本框");
    }
}

第三步:定义同一个创建接口

rust 复制代码
// 统一的工厂契约
pub trait GUIFactory {
    // 关联类型:规定了该工厂生产的具体产品类型,它们必须实现对应的 Trait
    type B: Button;
    type T: TextField;

    fn create_button(&self) -> Self::B;
    fn create_text_field(&self) -> Self::T;
}

// --- Windows 工厂 ---
pub struct WindowsFactory;
impl GUIFactory for WindowsFactory {
    type B = WindowsButton; // 锁定产品类型
    type T = WindowsTextField;

    fn create_button(&self) -> Self::B { WindowsButton }
    fn create_text_field(&self) -> Self::T { WindowsTextField }
}

// --- Mac 工厂 ---
pub struct MacFactory;
impl GUIFactory for MacFactory {
    type B = MacButton;    // 锁定产品类型
    type T = MacTextField;

    fn create_button(&self) -> Self::B { MacButton }
    fn create_text_field(&self) -> Self::T { MacTextField }
}

使用方法:

rust 复制代码
// 客户端只需要一个泛型 F,只要 F 实现了 GUIFactory 契约就行
pub struct Application<F: GUIFactory> {
    button: F::B,
    text_field: F::T,
}

impl<F: GUIFactory> Application<F> {
    // 客户端只接受抽象的工厂,具体传入什么它不关心
    pub fn new(factory: F) -> Self {
        Self {
            button: factory.create_button(),
            text_field: factory.create_text_field(),
        }
    }

    pub fn run(&self) {
        self.button.paint();
        self.text_field.display();
    }
}

// === 测试运行 ===
fn main() {
    // 1. 想要 Windows 风格,就注入 Windows 工厂
    let win_factory = WindowsFactory;
    let win_app = Application::new(win_factory);
    win_app.run();

    // 2. 想要切换表现时,Application 的内部代码一行都不用改
    let mac_factory = MacFactory;
    let mac_app = Application::new(mac_factory);
    mac_app.run();
}

回顾思考

本质其实非常简单,不直接创建对象,而是通过一个类型相同的工厂来创建。

为什么叫做抽象工厂?一个工厂生产多种产品,这正好对应上面的各种create方法。

2: Factory Method

一句话总结

  • 本质:其实和抽象工厂本质一模一样,采用同一个接口间接创建,而不是直接在代码中创建。
  • 优点动态切换不同表现的时候,不需要改动创建时候的代码。

回顾思考

其实本质上和抽象工厂没有区别,上面的GUIFactory是抽象工厂,而这个GUIFactory调用的create_buttoncreate_text_field就是工厂方法。

换句话说,一个抽象工厂,通过调用每种产品的工厂方法,生成了一个产品

不过设计模式把这两种方式给区分开来了,大抵是认为工厂方法侧重于单一产品的创建,核心是通过定义一个创建对象的接口,让子类决定实例化哪一个类(特点是利用继承 )。而抽象工厂侧重于产品族,它关注的是对象的组合 。但是,由于现代语言甚至都不再有OOP了,还需要强调这一点吗?在非 OOP 语言里,工厂方法和抽象工厂在语法结构上彻底合二为一了。不需要再去纠结是用继承还是用组合。从这个角度看,现代开发中确实完全没必要死扣这两个概念的字面区别。

3: Builder

一句话总结

  • 本质 :把一个复杂对象的创建拆成独立的几部分,每一部分包装成独立的函数。Builder 模式最标志性的特征就是**链式调用,通过每个配置方法返回 this/Self 来实现。
  • 优点:使得用户可以独立控制创建过程,根据自己的需要来创建对象。

现代写法

第一步:定义产品

rust 复制代码
#[derive(Debug)]
pub struct Computer {
    cpu: String,
    ram: String,
    storage: String,
    gpu: Option<String>, // 可选属性
}

第二步:写一个Builder

rust 复制代码
pub struct ComputerBuilder {
    cpu: Option<String>,
    ram: Option<String>,
    storage: Option<String>,
    gpu: Option<String>,
}

impl ComputerBuilder {
    // 初始化空的建造者
    pub fn new() -> Self {
        Self {
            cpu: None,
            ram: None,
            storage: None,
            gpu: None,
        }
    }

    // 独立部件包装成独立的函数,利用消费消费并返回 self 的链式调用
    pub fn cpu(mut self, cpu: &str) -> Self {
        self.cpu = Some(cpu.to_string());
        self
    }

    pub fn ram(mut self, ram: &str) -> Self {
        self.ram = Some(ram.to_string());
        self
    }

    pub fn storage(mut self, storage: &str) -> Self {
        self.storage = Some(storage.to_string());
        self
    }

    pub fn gpu(mut self, gpu: &str) -> Self {
        self.gpu = Some(gpu.to_string());
        self
    }

    // 最终构建
    pub fn build(self) -> Result<Computer, String> {
        let cpu = self.cpu.ok_or("缺少 CPU 配置")?;
        let ram = self.ram.ok_or("缺少 RAM 配置")?;
        let storage = self.storage.ok_or("缺少存储配置")?;

        Ok(Computer {
            cpu,
            ram,
            storage,
            gpu: self.gpu,
        })
    }
}

使用方法:

rust 复制代码
fn main() {
    // 根据需要独立控制创建过程
    let my_computer = ComputerBuilder::new()
        .cpu("AMD R9")
        .ram("64GB")
        .storage("4TB")
        .gpu("RX 7900XTX")
        .build()
        .unwrap();

    println!("创建成功: {:?}", my_computer);
}

回顾思考

在TS开发中,其实很少写 Builder 了,一般都直接利用字面量对象:createComputer({ cpu: 'i9', ram: '32G' })

在 Rust 或 Java中,Builder 模式存在的唯一价值,其实是弥补语言本身缺乏"具名参数"和"参数默认值"的缺陷。

这个模式典型的特点就是每个Builder的方法应该是独立的,这意味着要修改的那些参数,应该要么都有默认要么是可选的。

4: Prototype

一句话总结

  • 本质:不通过构造函数从头创建对象,而是以一个已有对象为"原型",通过内存复制拷贝出一个一模一样的新对象,然后在基础上修改。

  • 优点:绕过了复杂的构造函数初始化逻辑,大幅提升重量级对象的创建效率(这是一个极其肤浅的优点)。可以实现类型的动态注册与实例化(这才是真正的威力)。

回顾思考

这个特别需要注意深浅拷贝的问题,一不留神就会导致出问题。

利用**这种模式可以实现在程序运行中动态的添加类型和创建对应的实例。**这是什么意思?程序的类型都是编译器写死的,但如果你有一个原型管理器,每个类型使用字符串来标识 ,如果利用现有的类型组合出新的对象,然后添加到管理器中,那么你就相当于有了一个新的类型。之后你要创建一个这个新类型的实例,不是采用构造函数的方式,而是采用clone的方式。

这种在游戏里非常常见,而且和游戏里的依赖注入使用的一部分技术很像?都是使用一个HashMap来关联字符串和类型。不过DI手里有真正的构造函数和类名,但是这个只是一个实例。

在现代开发中,我们不再叫它原型模式,也不再直接拷贝,而是为这种结构生成一个配置文件,我们叫它 "配置驱动""模板"

5: Singleton

一句话总结

  • **本质:**保证拿到的对象是单例,不管怎么创建都应该只有一个。

回顾思考

单例控制是否属于创建本身?个人认为这个不应该属于设计模式中的内容。这种需求纯属是面向特定的业务或者任务需求来的。在一般情况下,控制是否是单例不应该由类型本身来决定,这个权限应该属于创建容器来决定


6 补充:对象池

一句话总结

  • 本质: 预先创建并回收一组相同类型的对象,用借与还代替新建与销毁。
  • 优点: 干掉频繁创建/销毁重量级资源带来的内存抖动、高昂耗时与GC压力。

现代写法

第一步:定义基础产品与对象池结构

rust 复制代码
use std::sync::{Arc, Mutex};

// 模拟重量级资源
pub struct RawConnection {
    id: usize,
}

impl RawConnection {
    pub fn send(&self, msg: &str) {
        println!("连接 #{} 发送: {}", self.id, msg);
    }
}

// 对象池核心:用 Mutex 保护空闲连接队列
pub struct ConnectionPool {
    available: Mutex<Vec<RawConnection>>,
}

第二步:利用智能指针和 Drop 契约实现自动归还

关键在于不直接返回资源本身,而是返回一个包装了池子引用的代理对象(智能指针)。

rust 复制代码
// 借出去的"代理连接",用户拿到的其实是这个
pub struct PooledConnection {
    // 实际的连接(用 Option 包装,方便拿出来归还)
    conn: Option<RawConnection>,
    // 保持对所属对象池的引用,知道该还给谁
    pool: Arc<ConnectionPool>,
}

// 为代理连接实现 Deref,让用户用起来和用原始连接一模一样
impl std::ops::Deref for PooledConnection {
    type Target = RawConnection;
    fn deref(&self) -> &Self::Target {
        self.conn.as_ref().unwrap()
    }
}

// 核心:实现 Drop Trait。当这个变量离开作用域时,自动触发归还逻辑!
impl Drop for PooledConnection {
    fn drop(&mut self) {
        if let Some(raw_conn) = self.conn.take() {
            println!("--- [Modern] 变量离开作用域,底层自动将其还回池中 ---");
            let mut pool_conns = self.pool.available.lock().unwrap();
            pool_conns.push(raw_conn);
        }
    }
}

第三步:完善池子的借出方法

rust 复制代码
impl ConnectionPool {
    pub fn new(size: usize) -> Arc<Self> {
        let mut conns = Vec::new();
        for i in 1..=size {
            println!("[Modern] 初始化底层资源 #{}", i);
            conns.push(RawConnection { id: i });
        }
        Arc::new(Self {
            available: Mutex::new(conns),
        })
    }

    pub fn acquire(self: &Arc<Self>) -> PooledConnection {
        let mut pool_conns = self.available.lock().unwrap();
        let raw_conn = pool_conns.pop().expect("没有可用连接了!");
        
        PooledConnection {
            conn: Some(raw_conn),
            pool: self.clone(),
        }
    }
}

使用方法:

rust 复制代码
fn main() {
    let pool = ConnectionPool::new(2);

    {
        // 借出连接 1
        let conn1 = pool.acquire();
        conn1.send("Hello"); // 借由 Deref 直接调用底层方法
        
        let conn2 = pool.acquire();
        conn2.send("World");
        
        // 到了大括号结尾,conn1 和 conn2 离开作用域
        // 它们身上的 Drop 会自动被调用,悄悄把连接还给 pool
    } 

    // 此时再借,借到的就是刚才自动归还的连接
    let conn3 = pool.acquire();
    conn3.send("New Data");
}

回顾思考

传统的 OOP 对象池有一个致命痛点:信任客户端 。如果在 try-catch 里漏掉了 pool.release(conn),这个连接就会永久泄漏,池子越用越小,直到系统崩溃。

而在现代开发中,无论是 Rust 的 Drop、C++ 的智能指针析构函数,还是 Go 语言的 defer pool.Put(conn),都在做同一件事:利用语言的结构特性,把"归还"这个动作从用户的视线里抹去。

在TS里虽然没有原生析构函数,但现代写法通常会采用闭包回调来收拢生命周期,例如:

ts 复制代码
// 现代 TS 做法:进池子、干活、出池子,一条龙封装在函数内部,用户碰不到 release
await pool.useConnection(async (conn) => {
    await conn.send("Hello");
}); // 函数执行完,连接在内部自动还回去

对象池在设计分类上虽然被归为创建型,但它干的活其实是反创建。它的核心思维在于用空间的固化来换取时间的稳定。

7 补充:依赖注入

一句话总结

  • 本质: 对象的依赖项不再由自己内部创建出来,函数的参数不再手动传递,而是通过声明"我需要什么",由一个独立的第三方(DI 容器)在运行时负责创建并塞给它。
  • 优点: 彻底干掉了类与类之间,函数与参数之间的硬编码耦合。不再需要手动传递参数来调用一个方法,干掉了依赖层级维护的问题。

类注入

运行时需要引入:import "reflect-metadata";这个非常重要,因为如果运行时没有类型信息,DI容器根本不知道应该把什么东西塞给对象或者函数。

第一步:定义装饰器和容器基底

ts 复制代码
// 模拟全局 IoC 容器,用 Map 建立类名到实例的映射
class IoCContainer {
    private services = new Map<string, any>();

    // 注册服务
    public register(target: any) {
        // 获取该类的构造函数所需要的参数类型列表(由 TS 的 reflect-metadata 自动生成)
        const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
        
        // 递归解析并实例化该类所依赖的所有子组件
        const dependencies = paramTypes.map(paramType => {
            // 如果容器里还没初始化过这个依赖,就先初始化它
            if (!this.services.has(paramType.name)) {
                this.register(paramType);
            }
            return this.services.get(paramType.name);
        });

        // 将依赖项注入构造函数,完成当前类的实例化
        const instance = new target(...dependencies);
        this.services.set(target.name, instance);
    }

    // 获取服务
    public get<T>(target: new (...args: any[]) => T): T {
        return this.services.get(target.name);
    }
}

const globalContainer = new IoCContainer();

// 声明装饰器:只要贴上这个标签,就自动把自己注册到容器里
function Injectable() {
    return function(target: any) {
        globalContainer.register(target);
    };
}

第二步:用声明式的方式编写业务组件

ts 复制代码
// 注册底层组件
@Injectable()
class FancyLogger {
    public log(msg: string) {
        console.log(`[Fancy Log] ${msg}`);
    }
}

// 注册高层业务
@Injectable()
class OrderService {
    // 只需要在构造函数里写出类型声明
    // 至于什么时候 new、怎么 new、谁把它传进来,OrderService 完全不操心
    constructor(private logger: FancyLogger) {}

    public placeOrder(item: string) {
        this.logger.log(`下单成功: ${item}`);
    }
}

使用方法:

ts 复制代码
// 业务方甚至看不见任何依赖拼装的过程,直接从容器提取顶层业务组件即可
const orderService = globalContainer.get(OrderService);

orderService.placeOrder("MacBook Pro"); 
// 输出: [Fancy Log] 下单成功: MacBook Pro

函数注入

函数式写法的核心在于:在函数执行的瞬间 ,通过反射动态解析该函数声明的参数类型,从容器中捞出对应的单例,然后通过 apply 动态注入并完成调用。

第一步:创建极简的 IoC 容器与调度核心

ts 复制代码
// 1. 全局数据容器(只存 @Component 的单例)
const componentContainer = new Map<any, any>();

// 2. @Component 装饰器:把类实例化并丢进容器
export function Component() {
  return function (target: any) {
    // 实例化该组件并存入容器(以类本身作为 Key)
    const instance = new target();
    componentContainer.set(target, instance);
  };
}

// 3. 核心魔术:动态装配参数并执行函数
export function executeSystem(staticMethod: Function, contextMessage?: any) {
  // 核心:利用 reflect-metadata 提取该函数在编译期保留的参数类型数组
  const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", staticMethod) || [];

  // 根据参数类型,动态去容器里抓取对应的实例
  const args = paramTypes.map((type) => {
    // 如果当前参数类型正好是触发的消息类型,就把消息实体塞进去
    if (contextMessage && type === contextMessage.constructor) {
      return contextMessage;
    }
    
    // 否则,从组件容器里捞出对应的单例数据
    const componentInstance = componentContainer.get(type);
    if (!componentInstance) {
      throw new Error(`找不到类型为 ${type.name} 的组件,请确认是否注册!`);
    }
    return componentInstance;
  });

  // 终极一步:使用 apply(或展开运算符)将拼装好的参数数组塞给函数并执行
  return staticMethod(...args);
  // 或者写成:return staticMethod.apply(null, args);
}

第二步:定义数据与指令

ts 复制代码
// 定义指令消息
class IncrementMessage {
  constructor(public amount: number) {}
}

// 定义纯数据组件(贴上标签后自动进入 componentContainer)
@Component()
class CounterComponent {
  public count = 0;
}

第三步:编写纯静态逻辑系统

ts 复制代码
class CounterSystem {
  // 注意:这里不需要写任何装饰器,只要参数类型写对了就行
  static onIncrement(
    msg: IncrementMessage,
    counter: CounterComponent
  ) {
    // 打印看看,验证是不是真的拿到了正确的实例
    console.log(`[System 内部] 收到消息,增加额度: ${msg.amount}`);
    
    // 直接修改动态注入进来的数据
    counter.count += msg.amount;
  }
}

模拟引擎调度执行

ts 复制代码
// 模拟业务中产生了一个消息指令
const mockMsg = new IncrementMessage(5);

// 引擎底层调度:把"函数"和"触发的消息"丢进执行器
executeSystem(CounterSystem.onIncrement, mockMsg);

// 验证结果:去容器里捞出组件,看看数据有没有被静态方法修改成功
const globalCounter = componentContainer.get(CounterComponent);
console.log(`[验证结果] 全局计数器的值现在是: ${globalCounter.count}`); 
// 输出: 5

!IMPORTANT

在游戏领域,往往不使用上面那种动态注入的方式,这样的性能达不到要求。对于静态类型如rust或者c++这种编译之后类型就被抹除的语言,必须依赖更强有力的宏甚至定制编译器才能做到真正的依赖注入,这样直接为每个函数生成固定的调用而不是动态注入,消除运行时开销。

回顾思考

IoC(控制反转)与 DI(依赖注入)的关系

它们的本质关系可以用一句话概括:IoC 是哲学指导思想(目的),而 DI 是具体的工程实现手段(手段)。

IoC (Inversion of Control - 控制反转) 是一个宏观的架构设计原则。 在传统开发中,代码的控制权在业务逻辑手里:业务函数决定什么时候调用外部库、什么时候创建依赖对象。而控制反转,就是把这个"创建对象、控制执行流程"的权力从你的业务代码里剥夺掉,上缴给外部的框架/引擎。

DI (Dependency Injection - 依赖注入) 是实现 IoC 最主流、最优雅的技术方案。 当大管家把控制权拿走后,业务代码怎么获取需要的资源呢?业务代码不再主动去"找"资源,而是采取"声明"的方式(在方法参数或构造函数里写上类型),等大管家在运行时动态地把资源"注入"进来。

为什么依赖注入能成为工业级标配?

无论是微服务、企业级 Web 还是复杂的游戏架构,DI 解决的不是表面上的懒得写 new的问题,归根到底下面的三个优点,导致了所有的复杂系统里,依赖注入都是最终手段。

  • 极致的业务解耦与全生命周期隔离 :在没有 DI 之前,为了实现单例,必须在类里写死 getInstance()。一旦用了 DI,你的类可以保持百分之百的纯净。它到底是全局单例、还是每次调用都新建、亦或是跟某些环境绑定的生命周期,业务代码不需要改动哪怕一个字,全部由IoC容器决定。

  • **可测试性:**有了 DI,函数所有的外部依赖都变成了参数。在测试环境中,直接传进一个假的 MockDatabase 或者是假的 mockLogger,使用 apply 一塞,就能完成对核心业务逻辑的纯净验证。

  • **摆脱依赖混乱,获得确定性:**普通的事件驱动或者直接调用,很容易陷入"A 调 B,B 调 C,C 又触发 A"的混乱黑洞中。 当引入了基于 IoC 容器的方法级注入时,控制权彻底反转了。引擎的中央调度器手握所有的数据和消息,统一负责传递参数、触发函数。


再论:本质

一个对象真正被创建到使用,需要几个步骤?很简单,首先提出一个请求,指明你要创建什么样的对象,然后真正的对象将被创建并交付给程序,最后再将对象传递到真正需要被使用程序代码中。

创建型模式的本质,是把对象的请求与创建两个动作完全解耦。不管是抽象工厂、抽象方法、Builder、原型,都是不会再直接调用创建对象的真正方法。

为何依赖注入要更进一步?因为依赖注入不仅解耦了请求与创建两个部分,还解耦了创建与传递两个部分,并且还将其完全的自动化了。因此可以说,依赖注入是解决创建问题的终极答案......吗?

举一个生动的例子,如果一个人饿了,怎么才能填饱肚子?这不是废话,当然要吃饭。但是怎么才能吃上饭?

第一种方法,是自己做饭。自己买菜自己烹饪,这对应了最原始的创建对象方法,直接调用构造函数传参。

第二种方法,去食堂买饭。这时候就不需要自己做饭了,去食堂点餐即可,省去了很多麻烦,但是还是要跑腿去食堂才行,这对应了各种创建型模式。

第三种方法,点外卖。现在连跑腿都省了,下单之后,剩下的唯一要做的事情就是等着饭来然后吃,这对应的正是DI。

那么?还存在第四种方法吗?显然,很难,因为你已经减少到只剩下最终的目的"吃饭"了。那么,其实第四种方法也很明显了。

第四种方法,不吃饭。没错!解决麻烦的最好的方法就是不吃饭!如果不需要吃饭(不需要创建对象),就能解决问题。那么为何还要自己花钱找麻烦呢?


让我们把第四种方法展开再说说吧。不创建对象?如果不创建对象该怎么解决问题呢?

要解决这个棘手的问题,得先让我们看看对象到底是什么?

不管是OOP语言,还是函数式语言。本质上对象,只是一些基础数据单元的集合,在OOP中这是一些属性,而函数式语言中这是struct中的各个字段。我们从OOP上讲,这个对象本质上包含了两个内容:

一个对象,包含了属性/字段本身之外,还隐含了这些属性/字段的组成关系。(暂不考虑对象方法)

现在应该可以发现一些端倪了。在一个对象中,属性/字段的关系是显式用代码写死的。你必须去写这样的代码。声明了 struct A { a: string, b: number },就意味着在内存里,数据 a 的屁股后面必须紧跟着数据 b。这种"关系"是用内存偏移量在编译期死死固化的。要用它们,就必须实例化这个组合体。

rust 复制代码
struct A {
	a:string,
	b:number
}

这太死板了!原子的属性/字段我们没办法再解耦了,但如果我们能够把关系都给解耦呢?如果程序自己能够知道那些零散的属性组成了一个对象,我们就可以根本不需要实例对象了。这就是------ECS模式(这是一个很长的破折号)。ECS 模式彻底干掉了 struct A 这个概念。

  • 属性 a 堆在 Vec<String> 数组里。

  • 属性 b 堆在 Vec<i32> 数组里。

所谓的关系,已经退化为一个纯粹的整数ID,用来标识这种特殊的对象组成关系。引擎在运行时,通过检查哪个组件数组里包含相同的ID,动态地把 ab 关联起来。这直接带来了两个颠覆性的结果:

  1. 动态组合: 运行时想给一个临时加个属性?传统语言得重构类继承或加字段。ECS 里只需要把这个ID扔进新属性的数组里即可。
  2. 零创建成本: 没有任何复杂的构造函数,只有数组的插入操作,而且数组的内存是连续的,你可以享受到CPU缓存带来的极大红利,因为你根本没有创建任何对象!

那么?到这里就结束了吗?至此对象已经被拆得七零八落已经拆无可拆。

一路走来可以发现,相比最初,我们的现在的对象构成方式已经发生了巨变,ECS好像前面的所有方式都完全不同,完全是两条进化线上的物种。为什么是这样?

**可以说,是本体论上的本质分歧,导致了这种结果。**二者的区别,正是下面两种完全不同的本体论在软件工程里的落地。

传统 OOP 与创建型模式:实体论

  • 哲学主张: 世界是由一个个具体的、有边界的"实体"组成的。属性和行为必须依附于实体。
  • 代码映射: structclass 是第一等公民。数据(属性)在内存里是绑死在一起的(通过内存偏移量固化关系)。创建型模式折腾来折腾去,都是在研究如何优雅地把这一团绑死的数据实例化出来。

ECS 模式:关系/流本体论

  • 哲学主张: 实体并不存在,它只是属性在某一瞬间的"关系组合",行为也不是实体的附属,而是数据在时间维度上的流动。
  • 代码映射: 彻底干掉了作为复合结构的类。
    • Identity(唯一标识): 退化为一个纯整数 Entity ID
    • Attributes(属性): 拍扁成一维的、连续的底层数组(Component)。
    • Behavior(行为): 升级为纯粹的、无状态的遍历系统(System)。

结论:历史与反思

为何OOP会成为主流?如果OOP从一开始的对世界的本体论就是错的,为何到今天人们才意识到?

OOP的坏永远也说不完,CPU缓存不友好、变更对象困难。但不得不承认,OOP是符合人类对世界的认知思维的。上世纪 80-90 年代,随着微机普及,软件规模呈指数级爆炸,摩尔定律疯狂吃红利,CPU 主频从几百 MHz 飙升到几个 GHz。硬件的暴涨完美掩盖了 OOP 的低效,让大家坚信"万物皆对象"就是真理。

直到 2000 年后,OOP 的遮羞布被扯掉了。摩尔定律已死,单核CPU主频达到了极限,这时候,CPU 想要跑得快,极其依赖 CPU缓存,在越来越复杂的软件面前,OOP 傲慢的"继承"和"封装"在频繁变动面前彻底崩盘,变成了阻碍维护的罪魁祸首。


然而遗憾的是,其实这种关系思想在几十年前早就有了,只是人们一直都在错误的路上一直狂奔。

这就是,关系数据库!

为何已经过去了几十年,关系数据库仍然是主流?关系数据的空前成功,本质上就是"关系/数据导向本体论"对"对象本体论"的一次全面降维打击。因为关系是外在且动态的,当你的业务增加新需求时,你只需要增加一张表 或者加一个字段,原有的表结构完全不需要重构。这种极致的灵活性,让它统治了工业界半个世纪。

然而讽刺的是,随着 OOP 的大流行,那段时间学术界和部分工业界开始鼓吹对象数据库。不过在实际应用中遭到了惨败。原因就在于它犯了和 OOP 相同的本体论错误:**把数据之间的关系硬编码锁死了。**如今除了极少数特定领域,主流市场根本见不到它的身影。


组合优于继承,不是一句空话。这背后的本质,是两种截然不同的本体论分歧。


但是事实是,继承就真的一无是处吗?

现在让我们来开始考虑一个情况,假设你需要一个抽象的"消息"类型,他还需要携带一些数据,用来丢入调度中心,让调度中心根据消息的类型触发某些操作。如何使用继承?

很简单,定义一个BaseMessage,然后从其中派生各种各种各样的例如表示系统开始的StartMessage,表示终止的EndMessage,携带数据的IncreaseDataMessage等等。

这很自然,然后利用多态,只需要声明调度器接受一个BaseMessage就可以工作了。

可如果你使用ECS,事情会变成什么样?

你将被迫将一个完整的消息,拆分成独立的实体,获取你可能拆成一个实体上,拥有MessageIncreaseData两个抽象的含义,然后把数据作为其他组件也一同传递。当使用时,你必须去判断这些组合到底是什么含义,还必须判断该组合是不是非法的,比如如果一个只有IncreaseData的实体是什么意思呢?显然这是荒谬的。

结果你的调度中心变成了一个巨大的switch语句,这正是OOP出现之前,使用C语言没有继承与多态时的情况。

以上的根本原因在于:

ECS只适合用于描述具体的、携带数据的、持久的、类型无关的数据。而这个任务需要的是一个抽象的、可能不含数据的、瞬时的、类型本身就携带了信息的信号。

当你使用组合去表达一个抽象的事物,你必须面对的问题:一个抽象的事物如何组合?。抽象与抽象之间不是通过组合得来的,你不能说IncreaseDataMessage二者组合成了一个IncreaseDataMessage消息。抽象的事物产生了组合,并不仅仅是放在一起,抽象之间会发生"纠缠"从而诞生新的抽象。而ECS,恰恰有一个极其重要的前提条件,你不能假设任何组件只有当其他组件存在时实体才有意义,不然ECS就会发生退化。

因为我们可以说,组合优于继承,并不是绝对的,当一个系统越来越以数据为中心驱动,那么组合出现的情况就越多。当一个系统越来越以语义为中心驱动,那么继承与多态才能发挥自己的真正优势。


最重要的不是知道什么时候该使用哪些设计模式,而是不该用那些设计模式。我想这才是真正的精髓。


那么,Rust是如何解决没有继承问题的呢?很显然,这样说其实很肤浅。

问题根本不在于有没有继承本身,而是当抽象发生纠缠成了新的抽象时候,如何表达这种抽象上的层级关系?

在Rust中,我们使用代数类型,即enum来表达这种事物,因此我们可以写成下面这样。

rust 复制代码
enum Message {
    Start, // 纯粹的语义信号,不带数据
    End,   // 纯粹的语义信号,不带数据
    Increase(IncreaseData), // 语义与数据的完美纠缠
}
struct IncreaseData {
    amount: u32,
}

表面上看这只不过是把很多类型当作一个类型来使用,然而这,其实正是继承和多态的核心。本质上来讲,这表达了一种抽象的层级。即is-a关系,而组合,是has-a关系。

因此,Rust虽然没有继承,但是Rust并不是缺乏is-a关系的表达能力喔!OOP 的继承是一种开放的多态 (你不知道未来还会有多少个子类继承 BaseMessage,必须靠虚表动态分发);而 Rust 的 enum 是一种闭合的多态(编译期你就明确知道只有这几种消息类型)。它更完美表达了抽象的层级和纠缠。

OOP不是死了,他的核心是以一种更完善的姿态,以一种新的名字------代数类型,重新回到了现代语言中。


最终我们终于到了这里,世界的终点。

  • 看到大量、流动、属性高度正交的业务数据(如游戏实体、大批量订单流水批处理、高性能报表数据),关系型/ECS/组合路线(has-a)。

  • 看到有限、闭合、带有强内聚语义、决定控制流走向的逻辑信号(如网络协议包、状态机事件、编译器 AST 语法树节点),走代数数据类型/多态/模式匹配路线(is-a)。

相关推荐
用户65868180338406 小时前
业务系统集成 OpenClaw 多 Agent 方案:从架构到落地的完整指南
架构
小码哥0686 小时前
一套可复用的打车系统模板,微服务版网约车系统|类似滴滴的打车平台
微服务·云原生·架构·滴滴·打车
元智启6 小时前
企业AI如何开发:智能体时代的安全治理架构与合规管控实践
人工智能·安全·架构
老毛肚7 小时前
微服务网关整合授权中心实现单点登录
运维·微服务·架构
沪漂阿龙7 小时前
Dify 面试题详解:开源 LLM 应用开发平台、RAG 知识库、Workflow 工作流、Agent 智能体一文讲透
人工智能·架构
一枝小雨7 小时前
RISC-V架构的中断与异常处理机制学习笔记
单片机·架构·嵌入式·risc-v·内核原理·中断与异常
沪漂阿龙8 小时前
面试题详解:多模态大模型全攻略——ViT 架构、扩散模型、U-Net、VAE、CLIP、Prompt 图像对齐一次讲透
人工智能·架构·prompt
REDcker8 小时前
Playwright详解 Web自动化与E2E测试 架构原理与实战入门
前端·架构·自动化
phltxy8 小时前
Redis Sentinel:主从架构的自动保镖详解
redis·架构·sentinel