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 我的选择策略
你想想看,实际项目中这三种方式往往混着用。我个人有个简单的选择策略:
- 模块内部的核心依赖 —— 构造函数注入。比如一个传感器驱动依赖 I2C 总线,必须通过 init 函数传进来。
- 一次性的、临时性的依赖 —— 参数传递注入。比如计算校验和时需要的查表地址,每次调用传一下就好。
- 调试和日志 —— 全局变量注入。但我会加个条件编译,产品代码里把全局依赖的赋值入口封死。
特别注意: 不要在中断服务函数里用全局变量注入的依赖。我遇到过,ISR 里访问了一个全局指针,结果主线程刚好在改它……嗯,那种随机死机的 bug,查起来能让人崩溃。
3.6 知识结构图
下面这张图总结了三种手动依赖注入方式的核心区别和选择依据:
3.7 实际项目中的混合策略
说实话,现实项目里很少只用一种方式。我最近做的一个 BLE 协议栈,就是三种混着用的:
- 构造函数注入:HCI 层依赖 UART 驱动,在初始化时传入。这样换 UART 实现时,HCI 层代码一行都不用改。
- 参数传递注入:数据加密函数,每次调用传入不同的密钥上下文。同一个函数处理不同连接的数据包。
- 全局变量注入:调试日志输出接口。只在调试版本里启用,产品版本直接编译掉。
关键原则: 让依赖关系尽可能显式,但别为了“纯”依赖注入把代码搞复杂。嵌入式开发讲究实用,不是设计模式竞赛。
手动依赖注入没有银弹。理解每种方式的利弊,根据场景做选择,比死守某一种“最佳实践”重要得多。我见过太多人为了“优雅”把简单事情搞复杂,也见过太多人为了“省事”埋下一堆坑。
嗯,这一章就到这里。记住:依赖注入的本质是控制反转,不是代码炫技。选对方式,比选“纯”的方式更重要。