30、函数设计最佳实践:函数单一职责原则,函数命名规范,参数个数控制,代码复用与重构。
好,咱们今天聊点实在的。函数怎么写才算「好」?
我见过太多代码,一个函数动辄几百行,变量命名全靠拼音首字母,参数列表长得像购物清单。这种代码,写的时候很爽,改的时候想哭。你想想看,是不是这个理?
这一讲,我就把我在嵌入式项目里摸爬滚打总结出来的几条铁律,掰开了揉碎了讲给你听。说白了,就是四个字:整洁、克制。
核心观点:函数是代码的最小组织单元。它的质量,直接决定了整个系统的可维护性和可测试性。别小看一个函数,它背后藏着工程师的全部修养。
30.1 单一职责原则:一个函数,只做一件事
这是最基础,也最容易被打破的原则。什么叫「一件事」?我举个例子。
你写了一个函数,叫 process_sensor_data()。它干了什么?读取传感器、滤波、计算平均值、更新全局状态、还要往串口打印日志。这算几件事?至少五件。
我个人习惯是:如果一个函数,你没办法用一句话说清楚它干了什么,那它一定干了太多事。
来看个反面教材:
// 反面示例:一个函数干了太多事
void handle_temperature(void) {
uint32_t raw = read_adc(ADC_CH_TEMP); // 读ADC
float temp = (float)raw * 0.125f - 50.0f; // 转换公式
if (temp > 85.0f) {
set_alarm(ALARM_OVERHEAT); // 触发报警
fan_on(); // 开风扇
}
printf("Temp: %.2f\n", temp); // 打印日志
update_display(temp); // 更新屏幕
}
这个函数,读数据、算温度、判断阈值、控制风扇、打印日志、更新显示——全搅在一起。如果哪天你想把打印日志去掉,或者换一种报警方式,你都得动这个函数。牵一发动全身。
我建议改成这样:
// 推荐:每个函数只做一件事
float read_temperature(void) {
uint32_t raw = read_adc(ADC_CH_TEMP);
return (float)raw * 0.125f - 50.0f;
}
bool is_overheat(float temp) {
return temp > 85.0f;
}
void handle_overheat(void) {
set_alarm(ALARM_OVERHEAT);
fan_on();
}
void log_temperature(float temp) {
printf("Temp: %.2f\n", temp);
}
// 主控逻辑:只负责编排,不负责实现细节
void temperature_task(void) {
float temp = read_temperature();
log_temperature(temp);
if (is_overheat(temp)) {
handle_overheat();
}
update_display(temp);
}
你看,每个函数都只做一件事。名字就是它的说明书。测试也好,修改也好,都变得非常清晰。
我的经验:在嵌入式系统里,单一职责还有一个隐藏好处——方便做单元测试。每个小函数都可以单独拿出来验证,不用搭整个硬件环境。我曾经在一个电机控制项目里,把一个大函数拆成7个小函数,测试覆盖率从30%直接干到95%。
30.2 函数命名规范:名字就是最好的注释
我见过最离谱的命名是什么?func1()、deal()、handle()。你问我这函数干嘛的?我只能去看代码。
命名这件事,其实有套路。我总结了三句话:
- 动词开头,表明动作。比如
read_sensor()、set_speed()、enable_interrupt()。 - 返回值类型暗示在名字里。如果是判断,用
is_、has_、should_开头。比如is_buffer_full()、has_data()。 - 不要缩写,除非是行业共识。
tmp可以,tmprtr不行。cnt可以,cou不行。
来看个对比:
| 不好的命名 | 好的命名 | 说明 |
|---|---|---|
deal() |
process_uart_rx() |
明确处理的是UART接收 |
check() |
is_checksum_valid() |
明确检查的是校验和 |
set() |
set_pwm_duty_cycle() |
明确设置的是PWM占空比 |
get() |
get_system_tick_ms() |
明确获取的是系统滴答值 |
注意:我曾经在一个项目里看到有人用 aaa()、bbb() 来命名函数,说是「临时用一下,后面再改」。结果这个项目维护了三年,这些名字一直没改。嗯,后来接手的人骂了三年。
30.3 参数个数控制:超过3个,就该想想了
函数参数越多,调用者越容易犯错。你想想看,一个函数有6个参数,调用的时候你得一个一个对位置,少传一个、传错一个,编译器都不一定报错。
我给自己定了个规矩:参数超过3个,就用结构体。
来看个例子:
// 坏味道:参数太多
void config_timer(uint32_t period, uint32_t prescaler, uint8_t mode,
bool enable_interrupt, void (*callback)(void), uint8_t priority);
// 好做法:用结构体封装
typedef struct {
uint32_t period;
uint32_t prescaler;
uint8_t mode;
bool enable_interrupt;
void (*callback)(void);
uint8_t priority;
} timer_config_t;
void config_timer(timer_config_t *cfg);
这样做的好处是什么?
- 调用的时候,可以指定成员名赋值,不容易搞混。
- 以后要加参数,只需要在结构体里加字段,函数接口不用变。
- 结构体可以复用,比如多个定时器共用一套配置模板。
我的习惯:如果参数是0个或1个,直接用。2到3个,看情况。超过3个,强制用结构体。这个习惯帮我避免了很多低级bug。有一次我传参顺序搞反了,结构体方式直接让编译器帮我查出来了。
30.4 代码复用与重构:别重复你自己
DRY 原则——Don't Repeat Yourself。听起来简单,做起来难。
我见过最夸张的代码,一个项目里同样的 crc16 计算函数写了5遍,每遍还略有不同。为什么?因为不同的人在不同的模块里各自实现了一遍。
重构的核心思路是什么?我总结了三步:
- 识别重复。看到两段代码长得像,先别急着复制粘贴。停下来想一想,能不能抽象成一个公共函数?
- 提取共性。把重复的部分抽出来,不同的部分用参数控制。
- 测试验证。重构完一定要跑一遍测试,确保行为没变。
来看一个实际例子。假设你有两个函数,一个读温度,一个读湿度:
// 重复代码
float read_temperature(void) {
uint32_t raw = read_adc(ADC_CH_TEMP);
return (float)raw * 0.125f - 50.0f;
}
float read_humidity(void) {
uint32_t raw = read_adc(ADC_CH_HUM);
return (float)raw * 0.1f + 20.0f;
}
重构之后:
// 提取公共逻辑
typedef struct {
uint8_t adc_channel;
float scale;
float offset;
} sensor_calib_t;
float read_sensor(sensor_calib_t *calib) {
uint32_t raw = read_adc(calib->adc_channel);
return (float)raw * calib->scale + calib->offset;
}
// 使用时
static sensor_calib_t temp_calib = {ADC_CH_TEMP, 0.125f, -50.0f};
static sensor_calib_t hum_calib = {ADC_CH_HUM, 0.1f, 20.0f};
float temp = read_sensor(&temp_calib);
float hum = read_sensor(&hum_calib);
你看,代码量没少多少,但逻辑清晰了。以后要加新的传感器,只需要定义一个新的校准结构体,不用再写一遍读取逻辑。
避坑指南:重构的时候,千万别想着「顺便改点功能」。重构只改变结构,不改变行为。我曾经有一次重构时顺手改了一个阈值,结果整个系统的行为都变了,排查了两天才找到原因。记住:重构和加功能,分开做。
30.5 知识体系总览
下面这张图,我把这一讲的核心逻辑串起来了。你可以把它当作函数设计的「检查清单」。
这张图把四个核心原则串在了一起。你写每个函数之前,都可以拿这张图过一遍:这个函数是不是只做一件事?名字能不能让人一眼看懂?参数是不是太多了?有没有重复的代码可以抽出来?
养成这个习惯,你的代码质量会有质的提升。
总结一句话:好的函数设计,不是为了炫技,而是为了让你自己——以及以后接手你代码的人——少掉几根头发。