3. 手动依赖注入:构造函数注入、参数传递注入、全局变量注入的利弊

依赖注入听起来像是个高大上的设计模式,其实说白了,就是把一个对象需要的“零件”从外面塞给它,而不是让它自己 new 出来。在嵌入式世界里,我们没那么多框架可用,大部分时候得靠手动注入。

手动注入有三种常见姿势:构造函数注入、参数传递注入、全局变量注入。每种都有它的脾气。我这些年踩过的坑,多半跟选错了注入方式有关。

3.1 构造函数注入

这是我最喜欢的方式。在对象创建的时候,把依赖通过构造函数传进去。干净、明确、跑不掉。

/* 构造函数注入示例 */
typedef struct {
    spi_t *spi;
    gpio_t *cs_pin;
} flash_driver_t;

bool flash_driver_init(flash_driver_t *drv, spi_t *spi, gpio_t *cs) {
    if (!drv || !spi || !cs) return false;
    drv->spi = spi;
    drv->cs_pin = cs;
    return true;
}

/* 使用 */
spi_t spi2 = { .base = SPI2_BASE, .speed = 1000000 };
gpio_t cs = { .port = GPIOA, .pin = 4 };
flash_driver_t flash;
flash_driver_init(&flash, &spi2, &cs);

优点:

  • 依赖关系一目了然——看构造函数就知道这个对象需要什么
  • 对象创建后,依赖就固定了,不会中途被换掉
  • 单元测试友好——传个 mock 进去就行

缺点:

  • 构造函数参数多了会很难看(我见过传 7 个参数的)
  • 如果依赖是可选的,构造函数注入就有点尴尬
  • 在 C 语言里,没有真正的构造函数,得靠 init 函数模拟

我的习惯: 核心模块、驱动层、需要单元测试的代码,一律用构造函数注入。参数超过 4 个时,我会考虑把参数打包成一个配置结构体。

3.2 参数传递注入

这种方式更灵活——每次调用函数时,把依赖作为参数传进去。有点像“用完即走”的感觉。

/* 参数传递注入示例 */
typedef struct {
    uint8_t *buffer;
    uint32_t size;
} data_packet_t;

/* 依赖通过参数传递 */
bool process_packet(data_packet_t *pkt, crypto_engine_t *crypto) {
    if (!pkt || !crypto) return false;
    return crypto->encrypt(crypto, pkt->buffer, pkt->size);
}

/* 使用 */
crypto_engine_t aes = { .type = CRYPTO_AES, .key = my_key };
data_packet_t pkt = { .buffer = data, .size = len };
process_packet(&pkt, &aes);

优点:

  • 灵活——同一个函数可以用不同的依赖实例
  • 适合无状态函数或工具类函数
  • 依赖的生命周期由调用方管理,不容易泄漏

缺点:

  • 调用方每次都要准备依赖,调用链长了很烦人
  • 如果多个函数都需要同一个依赖,代码会重复
  • 容易遗漏——少传一个参数,编译器可能只给个 warning

我曾经踩过的坑: 在一个协议栈里,把硬件抽象层通过参数层层传递。结果一个回调函数忘了传,导致底层直接操作了寄存器。调试了两天才发现是参数传递链断了。从那以后,我规定:超过 3 层传递的依赖,必须用构造函数注入。

3.3 全局变量注入

嗯,这个方式……怎么说呢,方便是真方便,坑也是真坑。

/* 全局变量注入示例 */
/* global_deps.h */
extern spi_t *g_system_spi;
extern gpio_t *g_led_pin;

/* global_deps.c */
spi_t *g_system_spi = NULL;
gpio_t *g_led_pin = NULL;

/* 某个模块直接使用 */
void led_on(void) {
    if (g_led_pin) {
        gpio_write(g_led_pin, 1);
    }
}

优点:

  • 简单粗暴——任何地方都能访问,不用传来传去
  • 适合系统级的单例资源(比如系统时钟、调试串口)
  • 代码改动最小——老项目重构时,这是最快的方式

缺点:

  • 依赖关系隐式——看代码根本不知道这个模块依赖什么
  • 单元测试困难——想换一个 mock,得改全局变量
  • 多实例场景直接崩溃——两个同类型设备怎么办?
  • 初始化顺序问题——全局变量还没初始化就被用了,经典段错误

我的建议: 全局变量注入只适合两种情况:一是调试用的日志输出,二是系统级的、确定只有一个实例的硬件资源。其他情况,尽量别用。

3.4 三种方式对比

维度 构造函数注入 参数传递注入 全局变量注入
依赖可见性 显式 显式 隐式
单元测试友好度 高 高 低
多实例支持 好 好 差
代码侵入性 中 低 低
调用复杂度 低(一次传入) 高(每次传入) 无
重构难度 低 中 高
适用场景 核心模块、驱动 工具函数、回调 调试、单例资源

3.5 我的选择策略

你想想看,实际项目中这三种方式往往混着用。我个人有个简单的选择策略:

  1. 模块内部的核心依赖 —— 构造函数注入。比如一个传感器驱动依赖 I2C 总线,必须通过 init 函数传进来。
  2. 一次性的、临时性的依赖 —— 参数传递注入。比如计算校验和时需要的查表地址,每次调用传一下就好。
  3. 调试和日志 —— 全局变量注入。但我会加个条件编译,产品代码里把全局依赖的赋值入口封死。

特别注意: 不要在中断服务函数里用全局变量注入的依赖。我遇到过,ISR 里访问了一个全局指针,结果主线程刚好在改它……嗯,那种随机死机的 bug,查起来能让人崩溃。

3.6 知识结构图

下面这张图总结了三种手动依赖注入方式的核心区别和选择依据:

手动依赖注入三种方式对比 构造函数注入 创建时传入,一次绑定 参数传递注入 调用时传入,用完即走 全局变量注入 全局访问,隐式依赖 依赖显式,测试友好 灵活,适合无状态函数 简单,但隐式耦合 多实例支持好 调用链长时繁琐 多实例困难 推荐:核心模块 驱动层、单元测试 推荐:工具函数 回调、临时依赖 推荐:调试日志 单例硬件资源 选择建议 核心逻辑用构造函数注入,临时功能用参数传递, 调试手段用全局变量——但别让全局变量进入产品核心路径

3.7 实际项目中的混合策略

说实话,现实项目里很少只用一种方式。我最近做的一个 BLE 协议栈,就是三种混着用的:

  • 构造函数注入:HCI 层依赖 UART 驱动,在初始化时传入。这样换 UART 实现时,HCI 层代码一行都不用改。
  • 参数传递注入:数据加密函数,每次调用传入不同的密钥上下文。同一个函数处理不同连接的数据包。
  • 全局变量注入:调试日志输出接口。只在调试版本里启用,产品版本直接编译掉。

关键原则: 让依赖关系尽可能显式,但别为了“纯”依赖注入把代码搞复杂。嵌入式开发讲究实用,不是设计模式竞赛。

手动依赖注入没有银弹。理解每种方式的利弊,根据场景做选择,比死守某一种“最佳实践”重要得多。我见过太多人为了“优雅”把简单事情搞复杂,也见过太多人为了“省事”埋下一堆坑。

嗯,这一章就到这里。记住:依赖注入的本质是控制反转,不是代码炫技。选对方式,比选“纯”的方式更重要。