30、重构与代码质量:代码坏味道、重构手法、代码审查
说实话,做了十几年嵌入式开发,我见过太多“能跑就行”的代码了。嗯,我自己也写过不少。但后来我发现,那些让我半夜被电话叫醒去修的bug,十有八九都藏在“坏味道”最浓的代码里。
这一章,咱们就聊聊怎么闻出代码里的坏味道,怎么动手重构,以及怎么通过代码审查把问题扼杀在摇篮里。说白了,就是让你写的C代码,不光能跑,还跑得明白、改得动。
核心观点:重构不是重写,是在不改变外部行为的前提下,改善内部结构。代码审查不是找茬,是团队共同提升的捷径。
30.1 代码坏味道:你闻到了吗?
什么是坏味道?不是编译器报错,也不是运行时崩溃。坏味道是那些让你看一眼就觉得“这里不对劲”的代码。我个人的经验是,如果你需要花超过30秒才能看懂一个函数在干什么,那它大概率有坏味道。
30.1.1 常见坏味道清单
| 坏味道 | 典型表现 | C语言中的例子 |
|---|---|---|
| 过长函数 | 一个函数超过50行,甚至上百行 | 一个函数里同时做初始化、数据处理、错误恢复 |
| 过大类(结构体) | 结构体包含20+个字段,职责不单一 | 一个设备结构体里既有硬件寄存器地址,又有日志缓冲区 |
| 重复代码 | 相同逻辑出现在多个地方 | 三个模块各自实现了CRC校验,写法几乎一样 |
| 过长参数列表 | 函数参数超过4个 | void config_uart(int baud, int parity, int stop, int flow, int dma); |
| 全局变量滥用 | 模块间通过全局变量传递状态 | extern int system_state; 被10个文件读写 |
| 散弹式修改 | 改一个需求,要改5个文件 | 修改通信协议,要改发送、接收、解析、日志、配置五个模块 |
我的小技巧:写代码时,如果某个函数开始需要滚动屏幕才能看完,我就知道该拆分了。你想想看,一个屏幕都装不下的函数,别人怎么理解?
30.1.2 嵌入式特有的坏味道
做嵌入式开发,有些坏味道是“行业特色”。我在项目中遇到过最典型的一个:硬编码延时。比如 delay_ms(100) 满天飞,后来换了个主频更高的芯片,所有延时都不准了。这就是坏味道——把硬件依赖和业务逻辑混在一起。
另一个常见的是魔法数字。代码里直接写 if (status == 0x03),没人知道0x03是什么意思。我曾经接手过一个项目,里面有个 #define MAGIC 0x55AA,注释写着“不要改这个值,改了会出问题”。嗯,这种代码我一般直接重构掉。
30.2 重构手法:动手改善代码
重构不是玄学,是有套路可循的。下面这几个手法,是我在项目中用得最多的。每个手法我都配了C语言的例子,方便你直接套用。
30.2.1 提取函数(Extract Function)
这是最基础、最常用的重构手法。把一段逻辑独立的代码,从大函数里抽出来,变成一个小函数。
重构前:
void process_sensor_data(void) {
// 读取原始数据
uint16_t raw = read_adc();
// 计算温度(这里有一段复杂的线性化计算)
float temp = (float)raw * 0.125f - 40.0f;
if (temp < -40.0f) temp = -40.0f;
if (temp > 125.0f) temp = 125.0f;
// 更新显示
update_display(temp);
}
重构后:
static float linearize_temperature(uint16_t raw) {
float temp = (float)raw * 0.125f - 40.0f;
if (temp < -40.0f) temp = -40.0f;
if (temp > 125.0f) temp = 125.0f;
return temp;
}
void process_sensor_data(void) {
uint16_t raw = read_adc();
float temp = linearize_temperature(raw);
update_display(temp);
}
你看,重构后 process_sensor_data 只有三行,一眼就能看明白它在干什么。而且 linearize_temperature 可以单独测试,多好。
30.2.2 引入参数对象(Introduce Parameter Object)
当函数参数超过4个时,我建议你考虑把相关参数打包成一个结构体。我曾经维护过一个串口配置函数,有7个参数,每次调用都要翻文档看参数顺序。后来我改成传结构体指针,世界清净了。
重构前:
void uart_init(int baud, int data_bits, int parity, int stop_bits, int flow_ctrl);
重构后:
typedef struct {
int baud;
int data_bits;
int parity;
int stop_bits;
int flow_ctrl;
} uart_config_t;
void uart_init(const uart_config_t *cfg);
注意:引入参数对象不是让你把所有参数都塞进一个结构体。只有逻辑上相关的参数才应该打包。比如波特率和数据位相关,但和DMA通道就不一定相关。
30.2.3 用状态机替代标志位
嵌入式代码里,经常能看到这样的写法:
int is_initialized = 0;
int is_running = 0;
int is_error = 0;
然后一堆 if (is_initialized && !is_error) 的判断。这种代码很容易出现状态组合爆炸。我建议用状态机来管理。
typedef enum {
STATE_UNINIT,
STATE_IDLE,
STATE_RUNNING,
STATE_ERROR,
STATE_RECOVERY
} system_state_t;
system_state_t current_state = STATE_UNINIT;
void system_run(void) {
switch (current_state) {
case STATE_IDLE:
// 启动运行
current_state = STATE_RUNNING;
break;
case STATE_ERROR:
// 尝试恢复
current_state = STATE_RECOVERY;
break;
default:
// 非法状态
break;
}
}
你想想看,状态机把所有的状态转移都显式地列出来了,比一堆标志位清晰得多。而且状态机容易测试——每个状态、每个转移都可以单独验证。
30.3 代码审查:不只是找bug
代码审查(Code Review)是我认为提升团队代码质量最有效的手段。但很多人把它理解成了“找茬大会”,这就跑偏了。
30.3.1 审查什么?
我个人习惯把审查内容分成三个层次:
- 正确性:逻辑对不对?边界条件处理了吗?有没有内存泄漏?
- 可维护性:命名是否清晰?函数是否过长?有没有重复代码?
- 可扩展性:如果需求变了,改起来方便吗?接口设计是否合理?
我曾经在审查中发现一个同事写的驱动,所有函数都用了 int 作为返回值,0表示成功,-1表示失败。但具体什么错误,完全不知道。我建议他改成返回枚举值,这样调用方就能知道是“超时”还是“校验失败”了。
30.3.2 审查流程建议
| 步骤 | 做什么 | 时间建议 |
|---|---|---|
| 1. 准备 | 作者提供变更说明,标注重点 | 5分钟 |
| 2. 通读 | 快速浏览整体结构,理解意图 | 10分钟 |
| 3. 细审 | 逐行检查逻辑、边界、命名 | 20-30分钟 |
| 4. 反馈 | 给出具体建议,而非笼统批评 | 5分钟 |
避坑指南:我曾经犯过一个错误——在审查时直接说“这段代码写得不好”。后来同事告诉我,他完全不知道哪里不好,也不知道怎么改。从那以后,我每次反馈都带具体建议,比如“这里建议用状态机替代标志位,因为...”。
30.3.3 审查中的常见问题
- 审查范围太大:一次审查超过400行代码,效果会急剧下降。我建议每次审查控制在200-300行。
- 只关注风格:缩进、空格这些应该交给格式化工具,人的精力应该花在逻辑上。
- 忽视测试:审查时一定要看测试代码。没有测试的代码,就像没有说明书的产品。
30.4 本章小结
代码质量不是一蹴而就的。它需要你持续地闻味道、动手重构、互相审查。我做了这么多年嵌入式,最大的体会就是:好代码是改出来的,不是写出来的。
你想想看,那些让你头疼的bug,是不是大多出现在“坏味道”最重的地方?所以,从今天开始,试着用本章的方法去审视你的代码。相信我,你会感谢自己的。
公众号:蓝海资料掘金营,微信deep3321