23 种设计模式详解 - 7 种结构型设计模式

接手一个老项目,新换的国产传感器寄存器接口和原框架对不上,改一处牵动半个驱动层;多任务抢同一根 I2C 总线......

这些场景的共同特征是:代码本身能跑,但"结构"扛不住了。功能没问题,可扩展性、可维护性已经透支。

GoF 把这类"如何把类/对象组合成更大的结构"的问题,归到结构型模式(Structural Patterns,共 7 种) 。核心就是:在不改原有代码的前提下,搭出更灵活的骨架

做嵌入式 C 的同学常觉得"设计模式是 Java/C++ 的玩意儿"。其实不然------Linux 内核的 device/driver 模型、FreeRTOS 的设备 API、RT-Thread 的 rt_device 框架,全是结构型模式的教科书级实现。区别只在于:C 语言用结构体 + 函数指针来模拟"接口与多态"。


一、结构型模式总览

结构型模式表如下:

模式 一句话本质 嵌入式典型场景 解决的痛点
适配器 Adapter 转换接口,让不兼容的能合作 新传感器适配旧框架、第三方 SDK 封装 接口不匹配
桥接 Bridge 抽象与实现两个维度各自独立变化 LCD 屏幕 × 总线、图形 API × 底层总线 组合爆炸、类爆炸
组合 Composite 树形"部分-整体",统一处理叶子与分支 多级菜单树、文件系统 节点类型多、遍历逻辑分支
装饰器 Decorator 动态套娃式叠加职责 数据流加 CRC/加密/日志 继承爆炸、功能要灵活拼装
外观 Facade 给复杂子系统一个简单入口 BSP 对外 API、AT 指令模组 子系统太复杂、调用方耦合
享元 Flyweight 共享细粒度对象省内存 字模共享、棋盘格、图标缓存 RAM 吃紧、对象海量重复
代理 Proxy 替身控制对原对象的访问 I2C 互斥、Flash 写保护、懒加载 访问控制、线程安全

选择:接口对不上→适配器;两维组合爆炸→桥接;树形统一处理→组合;功能要拼装→装饰器;子系统要简化→外观;海量对象要省内存→享元;访问要管控→代理。


二、统一代码风格:C 语言怎么做"面向对象"

7 段代码都遵循同一种风格------这也是 Linux 驱动模型、RT-Thread 设备框架的核心套路。

c 复制代码
/* "接口"= 函数指针虚表(ops);"对象"= 结构体,持有 ops + 私有上下文 ctx */
typedef struct device device_t;
typedef struct {
    int (*read)(device_t *self, uint8_t *buf, uint16_t len);   /* 虚函数 */
    int (*ioctl)(device_t *self, uint32_t cmd, void *arg);
} device_ops_t;

struct device {
    const device_ops_t *ops;   /* 虚函数表(指向 const,常驻 Flash) */
    void               *ctx;   /* 具体实现的私有数据(适配器/装饰器/代理各塞各的) */
};

/* 多态调用入口:上层只认 device_t,不关心背后是谁 */
static inline int dev_read(device_t *d, uint8_t *buf, uint16_t len)
{
    return d->ops->read(d, buf);   /* 通过 ops 分发到具体实现 */
}

记住:

  1. ops 指向 const 函数指针表,多个同类对象共享一份表,省 RAM(这本身就是享元思想)。
  2. ctx 装私有数据,适配器装被适配者的参数、装饰器装内层对象、代理装真实对象+锁。
  3. self 作为第一个参数传回 ,相当于 C++ 的 this,让实现能拿到自己的上下文。

三、适配器模式(Adapter Pattern)

说明

把一个类的接口转换成客户端期望的另一个接口,让原本不兼容的类能一起工作。只做接口转换,不掺业务逻辑。

用途

  • 新换的传感器芯片是"读寄存器"式接口,旧框架期望的是"直接读温度值"------中间加个适配器。
  • 第三方 SDK 风格各异,统一封装成自家接口。

C 代码

c 复制代码
#include <stdint.h>
#include <stdbool.h>

/* ====== 目标接口(Target):上层期望的统一传感器抽象 ====== */
typedef struct sensor_dev sensor_dev_t;
typedef struct {
    bool (*read_temp)(sensor_dev_t *self, float *out_c);
    bool (*read_humi)(sensor_dev_t *self, float *out_rh);
} sensor_ops_t;

struct sensor_dev {
    const sensor_ops_t *ops;
    void               *ctx;
};

/* ====== 被适配者(Adaptee):新芯片 BSP 自带的"寄存器式"接口 ====== */
/* 这俩是新芯片厂商给的,接口和统一抽象对不上 */
extern uint16_t NewChip_ReadReg(uint8_t dev_addr, uint8_t reg);

#define NEWCHIP_REG_TEMP   0x00U
#define NEWCHIP_REG_HUMI   0x02U
#define NEWCHIP_TEMP_SCALE 0.01f
#define NEWCHIP_TEMP_BIAS  40.0f

/* 适配器的私有上下文:记录被适配者需要的参数 */
typedef struct {
    uint8_t dev_addr;
} newchip_ctx_t;

/* ====== 适配器(Adapter):把"读寄存器"翻译成"读温湿度值" ====== */
static bool newchip_read_temp(sensor_dev_t *self, float *out_c)
{
    newchip_ctx_t *ctx = (newchip_ctx_t *)self->ctx;   /* 取回自己的上下文 */
    uint16_t raw = NewChip_ReadReg(ctx->dev_addr, NEWCHIP_REG_TEMP);
    *out_c = (float)raw * NEWCHIP_TEMP_SCALE - NEWCHIP_TEMP_BIAS;   /* 原始值 → 摄氏度 */
    return true;
}

static bool newchip_read_humi(sensor_dev_t *self, float *out_rh)
{
    newchip_ctx_t *ctx = (newchip_ctx_t *)self->ctx;
    uint16_t raw = NewChip_ReadReg(ctx->dev_addr, NEWCHIP_REG_HUMI);
    *out_rh = (float)raw * NEWCHIP_TEMP_SCALE;
    return true;
}

/* 虚表:常量,全局唯一一份 */
static const sensor_ops_t g_newchip_ops = {
    .read_temp = newchip_read_temp,
    .read_humi = newchip_read_humi,
};

/* 适配器构造函数:返回一个"看起来像统一传感器"的设备 */
sensor_dev_t NewChip_Adapt(uint8_t dev_addr)
{
    static newchip_ctx_t ctx;       /* 实际项目按需静态分配或动态分配 */
    ctx.dev_addr = dev_addr;
    sensor_dev_t dev = { .ops = &g_newchip_ops, .ctx = &ctx };
    return dev;
}

/* ====== 上层使用:完全不知道背后是新芯片 ====== */
void app_poll_sensor(void)
{
    sensor_dev_t sensor = NewChip_Adapt(0x48U);
    float t;
    if (sensor.ops->read_temp(&sensor, &t)) {
        /* 上报温度 t,无需关心是哪个芯片、读的哪个寄存器 */
    }
}

容易踩的坑

  • ⚠️ 适配器只做"接口转换",别把业务逻辑(滤波、标定、上报)塞进来。发现适配器里写了几百行,说明越界了------业务逻辑抽到上层。
  • ⚠️ 数值换算的精度raw * 0.01f 在低端 MCU(无 FPU)上会触发软浮点库,几十倍慢。资源紧张时改用定点数(如 0.01℃)。
  • ⚠️ 对象适配器 vs 类适配器:C 没有"多继承",所以 C 里基本只能写"对象适配器"(持有一个被适配者对象的引用),这反而是更推荐的方式。

四、桥接模式(Bridge Pattern)

说明

把"抽象"和"实现"两个维度分开,让它们各自独立变化,用组合替代多层继承。

用途

  • LCD 驱动:4 种屏幕 × 3 种总线(SPI / 8080 并口 / I2C)。用继承要写 12 个类,桥接只要 4+3=7 个,且加一种总线不用动屏幕代码。
  • 通信协议栈:协议层(Modbus/自定义)× 传输层(UART/CAN/TCP)。

C 代码

c 复制代码
#include <stdint.h>

/* ====== 实现端(Implementor):底层总线抽象 ====== */
typedef struct lcd_bus lcd_bus_t;
typedef struct {
    void (*write_cmd)  (lcd_bus_t *self, uint8_t cmd);
    void (*write_data) (lcd_bus_t *self, uint8_t data);
    void (*write_block)(lcd_bus_t *self, const uint8_t *buf, uint16_t len);
} lcd_bus_ops_t;

struct lcd_bus {
    const lcd_bus_ops_t *ops;
    void                *ctx;   /* 总线私有参数:SPI 句柄、并口端口组等 */
};

/* 具体实现 A:SPI 总线 */
typedef struct { void *spi_handle; } spi_ctx_t;
static void spi_write_cmd  (lcd_bus_t *self, uint8_t cmd)  { /* HAL_SPI_Send(self->ctx, &cmd, 1); */ }
static void spi_write_data (lcd_bus_t *self, uint8_t data) { /* ... */ }
static void spi_write_block(lcd_bus_t *self, const uint8_t *buf, uint16_t len) { /* DMA 发送 */ }
static const lcd_bus_ops_t g_spi_bus_ops = {
    .write_cmd = spi_write_cmd, .write_data = spi_write_data, .write_block = spi_write_block,
};

/* 具体实现 B:8080 并口(实现省略,结构同上) */
static const lcd_bus_ops_t g_i80_bus_ops = { /* ... */ };

/* ====== 抽象端(Abstraction):图形 API,通过"桥"持有总线 ====== */
typedef struct {
    lcd_bus_t *bus;      /* ← 这就是"桥":抽象端持有实现端的引用 */
    uint16_t   width;
    uint16_t   height;
} lcd_dev_t;

/* 设置显存写入窗口,桥接到具体总线发指令 */
static void lcd_set_window(lcd_dev_t *lcd, uint16_t x1, uint16_t y1,
                           uint16_t x2, uint16_t y2)
{
    lcd->bus->ops->write_cmd(lcd->bus, 0x2AU);               /* 0x2A: 列地址设置 */
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(x1 >> 8));
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(x1 & 0xFFU));
    lcd->bus->ops->write_cmd(lcd->bus, 0x2BU);               /* 0x2B: 行地址设置 */
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(y1 >> 8));
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(y1 & 0xFFU));
}

/* 画点:抽象端 API,完全不关心底层是 SPI 还是并口 */
void Lcd_DrawPoint(lcd_dev_t *lcd, uint16_t x, uint16_t y, uint16_t color)
{
    lcd_set_window(lcd, x, y, x, y);
    lcd->bus->ops->write_cmd(lcd->bus, 0x2CU);               /* 0x2C: 写 GRAM */
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(color >> 8));
    lcd->bus->ops->write_data(lcd->bus, (uint8_t)(color & 0xFFU));
}

/* ====== 使用:同一段图形代码,换总线只改 bus 指针 ====== */
void app_lcd_demo(void)
{
    lcd_bus_t spi_bus = { .ops = &g_spi_bus_ops, .ctx = /*&hspi1*/ NULL };
    lcd_dev_t lcd    = { .bus = &spi_bus, .width = 240, .height = 320 };
    Lcd_DrawPoint(&lcd, 10, 10, 0xF800U);   /* 画个红点,背后走 SPI */

    /* 换成并口屏?只改 bus,图形 API 一行不动 */
    lcd_bus_t i80_bus = { .ops = &g_i80_bus_ops, .ctx = /*&gpio*/ NULL };
    lcd.bus = &i80_bus;
    Lcd_DrawPoint(&lcd, 10, 10, 0xF800U);
}

容易踩的坑

  • ⚠️ 判断是否该上桥接:只有"两个维度真的会各自独立扩展"才值得。屏幕就一种、总线就一种,别上桥接,直接函数调用。
  • ⚠️ 桥接 vs 策略:策略模式是"运行时换算法",桥接是"两个维度都要独立扩展"。区别在于桥接的两侧都是长期存在、各自演进的体系,不是临时切换。
  • 💡 新手提示:这个模式最抽象,不用强求一上来就懂。等真被"组合爆炸"折磨过,回来再看。

五、组合模式(Composite Pattern)

说明

把对象组织成树形结构,让"单个对象"(叶子)和"组合对象"(容器)用同一套接口处理。客户端遍历时不用区分是叶子还是分支。

用途

  • 多级菜单系统:菜单项(叶子,点了执行动作)、子菜单(容器,点了展开下一级)、分隔线------都是"节点",遍历渲染统一处理。
  • 文件系统(文件 + 文件夹)、UI 控件树、配置树。

C 代码

c 复制代码
#include <stdint.h>

#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))

/* ====== 统一组件接口(Component):叶子与容器共用一套操作 ====== */
typedef struct menu_node menu_node_t;
typedef struct {
    void     (*render)(menu_node_t *self, uint8_t depth);   /* 渲染自身 */
    void     (*on_enter)(menu_node_t *self);                /* 选中/进入回调 */
    uint16_t (*count)(menu_node_t *self);                   /* 统计节点总数(叶子=1)*/
} menu_ops_t;

typedef enum {
    MENU_LEAF,        /* 叶子:可执行菜单项 */
    MENU_CONTAINER,   /* 容器:子菜单 */
} menu_type_t;

struct menu_node {
    const menu_ops_t *ops;
    const char       *name;
    menu_type_t       type;
    menu_node_t     **children;   /* 容器才有,叶子为 NULL */
    uint8_t           child_cnt;
};

/* ====== 叶子节点实现 ====== */
static void leaf_render(menu_node_t *self, uint8_t depth)
{
    /* depth 控制缩进,实际项目画到 LCD 对应位置 */
    for (uint8_t i = 0; i < depth; i++) { /* 打印缩进 */ }
    /* LCD_DrawString(indent_x, line_y, self->name); */
}
static void leaf_on_enter(menu_node_t *self)
{
    /* 叶子节点:执行具体动作,如跳转设置页、切换开关 */
}
static uint16_t leaf_count(menu_node_t *self) { (void)self; return 1U; }

static const menu_ops_t g_leaf_ops = {
    .render = leaf_render, .on_enter = leaf_on_enter, .count = leaf_count,
};

/* ====== 容器节点实现(核心:递归处理子节点)====== */
static void container_render(menu_node_t *self, uint8_t depth)
{
    leaf_render(self, depth);   /* 先渲染自己(复用叶子逻辑) */
    for (uint8_t i = 0; i < self->child_cnt; i++) {
        /* 递归渲染每个子节点,深度+1 ------ 不用管子节点是叶子还是容器 */
        self->children[i]->ops->render(self->children[i], depth + 1U);
    }
}
static void container_on_enter(menu_node_t *self)
{
    /* 容器:展开/折叠子菜单,或进入下一级 */
}
static uint16_t container_count(menu_node_t *self)
{
    uint16_t n = 1U;   /* 算上自己 */
    for (uint8_t i = 0; i < self->child_cnt; i++) {
        n += self->children[i]->ops->count(self->children[i]);   /* 递归统计 */
    }
    return n;
}
static const menu_ops_t g_container_ops = {
    .render = container_render, .on_enter = container_on_enter, .count = container_count,
};

/* ====== 构建一棵菜单树 ====== */
/* 叶子:"日期时间""系统信息""恢复出厂" */
static menu_node_t node_datetime  = { .ops = &g_leaf_ops,      .name = "日期时间", .type = MENU_LEAF };
static menu_node_t node_sysinfo   = { .ops = &g_leaf_ops,      .name = "系统信息", .type = MENU_LEAF };
static menu_node_t node_factory   = { .ops = &g_leaf_ops,      .name = "恢复出厂", .type = MENU_LEAF };
static menu_node_t node_bright    = { .ops = &g_leaf_ops,      .name = "背光亮度", .type = MENU_LEAF };

/* 容器:"设置"包含上面几个叶子 */
static menu_node_t *settings_children[] = { &node_bright, &node_datetime, &node_sysinfo, &node_factory };
static menu_node_t node_settings = {
    .ops = &g_container_ops, .name = "设置",
    .type = MENU_CONTAINER, .children = settings_children, .child_cnt = ARRAY_SIZE(settings_children),
};

/* ====== 客户端:统一遍历,完全不区分叶子/容器 ====== */
void app_menu_demo(void)
{
    /* 从根节点"设置"开始渲染整棵树,递归细节被封装在 ops 里 */
    node_settings.ops->render(&node_settings, 0U);

    /* 统计整棵树有多少个节点(含根),同样一个调用搞定 */
    uint16_t total = node_settings.ops->count(&node_settings);   /* 结果:5 */
}

容易踩的坑

  • ⚠️ 树只有两层、节点类型就一种,别上组合模式 。一个结构体数组 + 一个 for 循环就够了,强行上模式是过度设计。
  • ⚠️ 递归深度:菜单理论上能无限嵌套,但 MCU 栈有限。超过 4-5 层的菜单本身就该重新设计(用户也找不到东西了)。深度可控时递归没问题;不可控时改用迭代 + 显式栈。
  • ⚠️ 父子回指 :如果要做"返回上级"导航,节点要加 parent 指针,注意别在静态初始化时形成环形引用导致初始化顺序坑。

六、装饰器模式(Decorator Pattern)

说明

动态地给对象套上一层职责,可以层层嵌套("套娃"),比继承更灵活地扩展功能。

用途

  • 串口发送:原始发送 → 套 CRC → 套加密 → 套日志,按需拼装。
  • 数据采集:原始读数 → 套滑动平均滤波 → 量化处理。
  • Java IO 流体系(BufferedInputStream(FileInputStream(...)))是经典范例。

C 代码

c 复制代码
#include <stdint.h>
#include <stdbool.h>
#include <string.h>

/* ====== 统一接口(Component):数据发送者 ====== */
typedef struct data_sender data_sender_t;
typedef struct {
    bool (*send)(data_sender_t *self, const uint8_t *data, uint16_t len);
} sender_ops_t;

struct data_sender {
    const sender_ops_t *ops;
    void               *ctx;
};

/* ====== 具体组件:原始串口发送(最内层)====== */
extern bool Uart_RawSend(const uint8_t *data, uint16_t len);
static bool raw_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
    (void)self;
    return Uart_RawSend(data, len);
}
static const sender_ops_t g_raw_ops = { .send = raw_send };

/* ====== 装饰器基类:持有一个被包裹的内层对象 ====== */
typedef struct {
    data_sender_t *inner;   /* 被装饰的内层对象,这是"套娃"的关键 */
} decorator_ctx_t;

extern void  AppendCrc(uint8_t *buf, uint16_t *len);
extern void  Log_Debug(const char *fmt, ...);

/* 装饰器 A:CRC 校验 ------ 在数据尾部追加 CRC,再交给内层发送 */
static bool crc_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
    decorator_ctx_t *ctx = (decorator_ctx_t *)self->ctx;
    uint8_t  buf[256];
    uint16_t n = len;
    memcpy(buf, data, len);
    AppendCrc(buf, &n);                               /* 叠加 CRC 职责 */
    return ctx->inner->ops->send(ctx->inner, buf, n); /* 交给下一层(内层)发送 */
}
static const sender_ops_t g_crc_ops = { .send = crc_send };

/* 装饰器 B:日志 ------ 打日志后原样交给内层 */
static bool log_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
    decorator_ctx_t *ctx = (decorator_ctx_t *)self->ctx;
    Log_Debug("send %u bytes", len);                  /* 叠加日志职责 */
    return ctx->inner->ops->send(ctx->inner, data, len);
}
static const sender_ops_t g_log_ops = { .send = log_send };

/* ====== 使用:套娃式组装 raw → crc → log ====== */
void app_send_demo(void)
{
    /* 内层:原始发送 */
    static data_sender_t raw = { .ops = &g_raw_ops };

    /* 中层:CRC 装饰器,包裹 raw */
    static decorator_ctx_t crc_ctx;
    static data_sender_t   crc = { .ops = &g_crc_ops, .ctx = &crc_ctx };
    crc_ctx.inner = &raw;

    /* 外层:日志装饰器,包裹 crc */
    static decorator_ctx_t log_ctx;
    static data_sender_t   log = { .ops = &g_log_ops, .ctx = &log_ctx };
    log_ctx.inner = &crc;

    /* 对外只暴露最外层 log;调用 send 时:
       先打日志 → 再加 CRC → 最后真正发出去,三层职责层层透传 */
    uint8_t payload[] = { 0x01U, 0x02U, 0x03U };
    log.ops->send(&log, payload, sizeof(payload));
}

容易踩的坑

  • ⚠️ 套的层数一多,调试时根本不知道数据被哪层改坏了。建议每层都打日志,或尽量限制最多套 2-3 层。
  • ⚠️ 缓冲区拷贝开销 :每层 memcpy 一次,对高吞吐场景(几 Mbps)是负担。可让装饰器传偏移 + 原地修改,避免拷贝。
  • ⚠️ 顺序敏感log(crc(raw))crc(log(raw)) 行为不同------前者日志记录的是不含 CRC 的长度,后者记录含 CRC 的长度。组装时想清楚"谁包谁"。

七、外观模式(Facade Pattern)

说明

给复杂的子系统提供一个统一、简单的高层入口,屏蔽子系统内部复杂度,降低调用方与子系统的耦合。

用途

  • BSP(板级支持包)就是最经典的外观LED_On() 背后是一堆 RCC 时钟使能、GPIO 模式配置、速率设置,但业务层只看到一个函数。
  • AT 指令模组:背后是几十条 AT 指令 + 状态机 + 超时重传,对外只给 Wifi_Connect(ssid, pwd)

C 代码

c 复制代码
#include <stdint.h>

#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))

/* ====== 复杂子系统(被封装的部分):一堆底层寄存器操作 ====== */
extern void RCC_EnableClock(uint32_t port);
extern void GPIO_ConfigOut(uint32_t port, uint8_t pin, uint8_t speed, uint8_t pull);
extern void GPIO_SetHigh(uint32_t port, uint8_t pin);
extern void GPIO_SetLow (uint32_t port, uint8_t pin);
/* ... 实际项目里还有中断配置、复用映射、防抖初始化等几十个细节 ... */

#define PORT_A      0x40020000UL
#define PORT_B      0x40020400UL
#define GPIO_SPEED_HIGH  2U
#define GPIO_PULL_NONE   0U

/* LED 引脚定义表:板上低电平点亮 */
typedef struct { uint32_t port; uint8_t pin; } led_pin_t;
static const led_pin_t g_leds[] = {
    { PORT_A, 5U },   /* LED0 */
    { PORT_B, 0U },   /* LED1 */
};

/* ====== 外观(Facade):极简对外 API ====== */
void Board_Init(void)
{
    /* 把"使能时钟 + 配置 GPIO + 默认熄灭"这套繁琐流程藏起来 */
    for (uint8_t i = 0; i < ARRAY_SIZE(g_leds); i++) {
        RCC_EnableClock(g_leds[i].port);
        GPIO_ConfigOut(g_leds[i].port, g_leds[i].pin, GPIO_SPEED_HIGH, GPIO_PULL_NONE);
        GPIO_SetHigh(g_leds[i].port, g_leds[i].pin);   /* 高电平 = 熄灭 */
    }
}

void LED_On(uint8_t idx)
{
    if (idx < ARRAY_SIZE(g_leds)) {
        GPIO_SetLow(g_leds[idx].port, g_leds[idx].pin);   /* 低电平点亮 */
    }
}

void LED_Off(uint8_t idx)
{
    if (idx < ARRAY_SIZE(g_leds)) {
        GPIO_SetHigh(g_leds[idx].port, g_leds[idx].pin);
    }
}

void LED_Toggle(uint8_t idx)
{
    /* 实际项目里读回 ODR 寄存器取反,这里示意 */
    static uint8_t state[2] = {0};
    if (idx < ARRAY_SIZE(g_leds)) {
        state[idx] ^= 1U;
        (state[idx] ? LED_On(idx) : LED_Off(idx));
    }
}

/* ====== 上层使用:完全看不到任何寄存器 ====== */
void app_blink_demo(void)
{
    Board_Init();
    while (1) {
        LED_Toggle(0);
        /* delay 500ms */
    }
}

容易踩的坑

  • ⚠️ 外观层别变成"上帝对象" 。什么都往 Board 里塞(LED、按键、串口、SPI、网络......)会膨胀成几千行的怪物。按功能拆几个外观:led_facadekey_facadenet_facade
  • ⚠️ 外观不是"禁止直接访问子系统"。需要精细控制时,允许调用方绕过外观直接调底层;外观只是"常用快捷方式",不是牢笼。
  • 💡 这个模式几乎零缺点,新手最该多用 。把一堆零散的初始化函数包成一个 Board_Init(),立刻就能感受到分层的好处。

八、享元模式(Flyweight Pattern)

说明

通过共享技术复用大量细粒度对象,区分内部状态 (可共享,如字模数据本身)和外部状态(不共享,如显示坐标),大幅降低内存占用。

用途

  • 字模存储:128×64 LCD 要显示一堆汉字,每个字模几十字节。把字模做成共享享元,按编码查表复用,而不是每个字、每帧都拷一份。
  • 图标缓存、棋盘格子、GUI 控件样式表。

C 代码

c 复制代码
#include <stdint.h>

#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))

/* ====== 享元对象(Flyweight):字模,可共享 ====== */
typedef struct {
    uint16_t       encoding;   /* 字符编码(内部状态:决定字模内容)*/
    uint8_t        width;      /* 字宽 */
    uint8_t        height;     /* 字高 */
    const uint8_t *bitmap;     /* 指向 Flash 中的字模数据,只读、天然共享 */
} glyph_t;

/* ====== 享元工厂:维护共享池 ====== */
/* 字模表存于 Flash(const),所有字符共用这一份,绝不拷贝到 RAM */
static const uint8_t font_H[] = { 0x18, 0x24, 0x42, 0x7E, 0x42, 0x42, 0x00 }; /* 'H' 7 行 */
static const uint8_t font_e[] = { 0x00, 0x42, 0x7E, 0x40, 0x40, 0x40, 0x00 }; /* 'e' */
static const uint8_t font_l[] = { 0x7E, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00 }; /* 'l' */
static const uint8_t font_o[] = { 0x00, 0x3C, 0x42, 0x42, 0x42, 0x3C, 0x00 }; /* 'o' */

static const glyph_t g_glyph_pool[] = {
    { 'H', 7U, 7U, font_H },
    { 'e', 7U, 7U, font_e },
    { 'l', 7U, 7U, font_l },
    { 'o', 7U, 7U, font_o },
    /* ... 其余字模,整张表常驻 Flash,零 RAM 占用 ... */
};

/* 查表返回共享字模指针(找不到返回 NULL)*/
const glyph_t *Glyph_Get(uint16_t encoding)
{
    for (uint16_t i = 0; i < ARRAY_SIZE(g_glyph_pool); i++) {
        if (g_glyph_pool[i].encoding == encoding) {
            return &g_glyph_pool[i];   /* 共享返回,不拷贝 */
        }
    }
    return NULL;
}

/* ====== 外部状态:显示坐标,每次绘制时传入 ====== */
/* 注意:这里用独立的像素绘制原语,与桥接模式的 Lcd_DrawPoint(lcd_dev_t*, ...) 区分,
   实际项目里通常由 GUI 上下文统一管理当前 LCD 设备。 */
extern void Lcd_PutPixel(int x, int y, uint16_t color);

void Font_DrawChar(uint16_t encoding, uint16_t x, uint16_t y)
{
    const glyph_t *g = Glyph_Get(encoding);   /* 取共享字模(内部状态)*/
    if (g == NULL) {
        return;                               /* 无此字模,跳过 */
    }
    /* x, y 是外部状态------同一份字模可画在屏幕任意位置,互不影响 */
    for (uint8_t row = 0; row < g->height; row++) {
        uint8_t bits = g->bitmap[row];
        for (uint8_t col = 0; col < g->width; col++) {
            if ((bits & (0x80U >> col)) != 0U) {
                Lcd_PutPixel((int)(x + col), (int)(y + row), 0xFFFFU);   /* 画前景 */
            }
        }
    }
}

/* ====== 使用:画 "Hello",无论画多少遍,字模内存始终一份 ====== */
void app_font_demo(void)
{
    const char *str = "Hello";
    uint16_t x = 0;
    for (uint8_t i = 0; str[i] != '\0'; i++) {
        Font_DrawChar((uint16_t)str[i], x, 0);   /* 同一份字模,不同坐标 */
        x += 7U;                                  /* 字宽 7,横移 */
    }
    /* 关键收益:l 出现了两次,但 font_l 在 Flash 里只有一份,RAM 零增长 */
}

容易踩的坑

  • ⚠️ 享元对象必须是只读/不可变的。一旦共享,任何一方修改都会影响所有引用方。字模天然只读没问题;如果享元带可变状态,务必只把不可变部分共享,可变部分作为外部状态传入。
  • ⚠️ 查表方式:示例用线性查找便于理解,实际汉字库用 GB2312 编码做偏移直接定位,O(1)。别在循环里用线性查找几千个字的字模。
  • ⚠️ 别为了享元而享元:对象总共就几十个、内存够用,硬上享元工厂反而增加复杂度。享元的价值在"海量重复对象 + 内存紧张"时才凸显。

九、代理模式(Proxy Pattern)

说明

给目标对象套一个"替身",由替身控制对原对象的访问,可在访问前后附加额外逻辑(加锁、校验、缓存、懒加载)。

用途

  • I2C 总线互斥代理:多任务共用一根 I2C,直接调底层会打架,代理自动加互斥量。
  • Flash 写保护代理:写前校验权限/解锁,防止误擦关键区。
  • 懒加载代理:大资源(字库、图片)用到才初始化。

C 代码

c 复制代码
#include <stdint.h>

/* ====== 目标接口(Subject):I2C 读写 ====== */
typedef struct i2c_dev i2c_dev_t;
typedef struct {
    int8_t (*write)(i2c_dev_t *self, uint8_t addr, const uint8_t *data, uint16_t len);
    int8_t (*read) (i2c_dev_t *self, uint8_t addr, uint8_t *buf, uint16_t len);
} i2c_ops_t;

struct i2c_dev {
    const i2c_ops_t *ops;
    void            *ctx;
};

/* ====== 真实对象(RealSubject):裸 HAL 调用,非线程安全 ====== */
extern int8_t HAL_I2C_Write(uint8_t addr, const uint8_t *data, uint16_t len);
extern int8_t HAL_I2C_Read (uint8_t addr, uint8_t *buf, uint16_t len);

static int8_t raw_i2c_write(i2c_dev_t *self, uint8_t addr,
                            const uint8_t *data, uint16_t len)
{
    (void)self;
    return HAL_I2C_Write(addr, data, len);
}
static int8_t raw_i2c_read(i2c_dev_t *self, uint8_t addr,
                           uint8_t *buf, uint16_t len)
{
    (void)self;
    return HAL_I2C_Read(addr, buf, len);
}
static const i2c_ops_t g_raw_i2c_ops = {
    .write = raw_i2c_write, .read = raw_i2c_read,
};

/* ====== 代理(Proxy):加互斥锁,控制对真实对象的访问 ====== */
/* 假设在 FreeRTOS 环境 */
extern void *xSemaphoreCreateMutex(void);
extern int   xSemaphoreTake(void *mux, uint32_t ticks);
extern void  xSemaphoreGive(void *mux);
#define portMAX_DELAY 0xFFFFFFFFUL

typedef struct {
    i2c_dev_t *real;    /* 持有真实对象 */
    void      *mutex;   /* 互斥量,保证总线独占 */
} i2c_proxy_ctx_t;

static int8_t proxy_i2c_write(i2c_dev_t *self, uint8_t addr,
                              const uint8_t *data, uint16_t len)
{
    i2c_proxy_ctx_t *ctx = (i2c_proxy_ctx_t *)self->ctx;
    int8_t ret;
    xSemaphoreTake(ctx->mutex, portMAX_DELAY);   /* 进临界区:拿锁 */
    ret = ctx->real->ops->write(ctx->real, addr, data, len);   /* 委托真实对象 */
    xSemaphoreGive(ctx->mutex);                  /* 出临界区:放锁 */
    return ret;
}
static int8_t proxy_i2c_read(i2c_dev_t *self, uint8_t addr,
                             uint8_t *buf, uint16_t len)
{
    i2c_proxy_ctx_t *ctx = (i2c_proxy_ctx_t *)self->ctx;
    int8_t ret;
    xSemaphoreTake(ctx->mutex, portMAX_DELAY);
    ret = ctx->real->ops->read(ctx->real, addr, buf, len);
    xSemaphoreGive(ctx->mutex);
    return ret;
}
static const i2c_ops_t g_proxy_i2c_ops = {
    .write = proxy_i2c_write, .read = proxy_i2c_read,
};

/* ====== 构造:真实对象套上代理,上层拿到的是代理 ====== */
i2c_dev_t I2C_CreateProxy(void)
{
    static i2c_dev_t        real = { .ops = &g_raw_i2c_ops };
    static i2c_proxy_ctx_t  ctx;
    static i2c_dev_t        proxy;
    ctx.real   = &real;
    ctx.mutex  = xSemaphoreCreateMutex();
    proxy.ops  = &g_proxy_i2c_ops;
    proxy.ctx  = &ctx;
    return proxy;   /* 返回给上层的是 proxy,调用方对"自动加锁"无感 */
}

/* ====== 变体:Flash 写保护代理(示意)====== */
/* 写前校验调用方权限/地址范围,防止误擦 Bootloader 区 */
typedef struct { uint32_t base; uint32_t size; } flash_region_t;
extern bool Auth_CheckWritePermission(uint32_t addr, uint16_t len);
extern int8_t Flash_Unlock(void);
extern int8_t Flash_EraseWrite(uint32_t addr, const uint8_t *data, uint16_t len);
extern void   Flash_Lock(void);

int8_t FlashProxy_Write(uint32_t addr, const uint8_t *data, uint16_t len)
{
    /* 保护代理:先校验权限和地址范围 */
    if (!Auth_CheckWritePermission(addr, len)) {
        return -1;   /* 拒绝越权写 */
    }
    Flash_Unlock();                       /* 解锁 */
    int8_t ret = Flash_EraseWrite(addr, data, len);
    Flash_Lock();                         /* 重新上锁,防止误写 */
    return ret;
}

容易踩的坑

  • ⚠️ 代理 vs 装饰器 :两者结构几乎一样(都持有内层对象并转发),但意图不同------代理强调"控制访问"(加锁、校验、懒加载),装饰器强调"叠加职责"(加 CRC、加日志)。别混用语义。
  • ⚠️ 加锁代理的死锁:代理内拿锁后,被代理的回调又回头调代理,会重复拿锁死锁。递归互斥量或重新设计调用链可解。
  • ⚠️ 所有访问入口都要走代理:留一个直接调底层的口子,代理的锁就形同虚设。

十、总结:7 种模式怎么选?

讲完 7 种,回到工程现实------怎么选型。给一张决策表,遇到问题对号入座:

遇到的问题 优先考虑 不要混淆于
新旧接口对不上 适配器 外观(外观是简化入口,不是接口转换)
两个维度组合爆炸(N×M) 桥接 策略(策略是临时换算法,不是两维扩展)
树形结构要统一遍历 组合 装饰器(装饰器是链式,不是树形)
功能要灵活拼装(CRC/加密/日志) 装饰器 代理(代理是控制访问,不是叠加职责)
子系统太复杂,想要简单入口 外观 适配器
海量相似对象,RAM 不够 享元 单例
要加锁/校验/懒加载,控制访问 代理 装饰器
  1. 新手重点掌握:外观、适配器、代理。这三个在嵌入式驱动里几乎天天见,用熟了代码分层就立起来了。
  2. 模式不是越多越好 。一个三层菜单给它套"组合 + 装饰器 + 访问者",那不是工程,是炫技。简单优先,等真感觉耦合痛了再上模式
  3. C 语言的"面向对象"有代价:函数指针虚表比直接调用慢一点(多一次间接跳转)、Flash 多占一点(虚表本身)、可读性下降一点(追代码要跳 ops 表)。在中断高频路径或极致性能场景,直接函数调用更合适。
  4. 好的代码不是用满了 7 种模式,而是看起来"平平无奇" ------每层各管各的,改一个功能不用满世界找。模式只是工具,不是目标本身

参考链接

  • GoF 原书 :Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software . Addison-Wesley, 1994.(中文版:《设计模式:可复用面向对象软件的基础》,机械工业出版社)。维基百科条目:Design Patterns (book)
  • refactoring.guru 设计模式中文站 (图文并茂,强烈推荐入门):https://refactoringguru.cn/design-patterns
  • 维基百科:软件设计模式总览Software design pattern
  • RT-Thread 源码仓库rt_device 设备框架是外观 + 策略 + 适配器的典范,rt_i2c 总线驱动可对照桥接/适配器阅读):https://github.com/RT-Thread/rt-thread
  • FreeRTOS 源码仓库Queue / StreamBuffer / MessageBuffer API 是对链表 + 临界区细节的外观封装;MessageBufferStreamBuffer 上的封装接近装饰器思路):https://github.com/FreeRTOS/FreeRTOS
  • SourceMaking 设计模式参考 (含大量结构图与代码):https://sourcemaking.com/design_patterns