模块化编程中的算法模块化
各位同学,今天我们来聊聊算法模块化。说白了,就是把那些常用的排序、查找、字符串处理、数学计算,统统封装成独立的模块。我做了十几年嵌入式开发,发现很多新手喜欢把算法代码散落在各个.c文件里,结果项目一大了,改个排序算法得翻遍整个工程。嗯,这其实是个坑。
为什么要把算法模块化?
你想想看,一个嵌入式项目里,排序可能用在数据采集、日志整理、菜单排序等多个地方。如果你在每个地方都写一遍冒泡排序,那代码维护起来有多痛苦?我曾在某个工控项目中,就因为三处排序代码不一致,导致数据对不上,查了整整两天。
模块化之后,好处很明显:
- 复用性:写一次,到处用
- 可测试性:单独测试算法模块,不用跑整个系统
- 可维护性:想换算法?只改一个文件
- 代码整洁:业务逻辑和算法实现分离
核心原则:算法模块应该是"无状态"的——不依赖全局变量,只通过参数输入输出。这样在任何上下文都能直接调用。
排序算法封装
排序是算法模块里最基础的一块。我个人习惯把排序接口设计成统一的函数指针形式,这样上层调用时,可以动态选择用冒泡、快速还是插入排序。
// sort.h
#ifndef __SORT_H__
#define __SORT_H__
#include <stdint.h>
/* 排序方向 */
typedef enum {
SORT_ASCENDING,
SORT_DESCENDING
} sort_direction_t;
/* 比较函数指针类型 */
typedef int (*cmp_func_t)(const void *a, const void *b);
/* 排序函数指针类型 */
typedef void (*sort_func_t)(void *base, size_t nmemb, size_t size,
cmp_func_t cmp, sort_direction_t dir);
/* 对外接口 */
void sort_bubble(void *base, size_t nmemb, size_t size,
cmp_func_t cmp, sort_direction_t dir);
void sort_quick(void *base, size_t nmemb, size_t size,
cmp_func_t cmp, sort_direction_t dir);
void sort_insertion(void *base, size_t nmemb, size_t size,
cmp_func_t cmp, sort_direction_t dir);
#endif
你看,接口统一了,调用方只需要传入数据指针、元素个数、元素大小、比较函数和排序方向。我曾经在一个数据采集项目中,用这种设计让客户可以在运行时切换排序算法,调试起来特别方便。
小技巧:比较函数用 int (*)(const void*, const void*) 这种通用形式,可以支持任意数据类型。你只需要在回调里做类型转换即可。
查找算法封装
查找算法和排序算法是孪生兄弟。二分查找要求数据已排序,而线性查找则没这个限制。我建议把它们放在同一个模块里,方便管理。
// search.h
#ifndef __SEARCH_H__
#define __SEARCH_H__
#include <stdint.h>
/* 查找结果 */
typedef struct {
void *data; /* 找到的数据指针,NULL表示未找到 */
int index; /* 在数组中的索引,-1表示未找到 */
} search_result_t;
/* 线性查找 */
search_result_t search_linear(void *base, size_t nmemb, size_t size,
const void *key, cmp_func_t cmp);
/* 二分查找(要求数据已排序) */
search_result_t search_binary(void *base, size_t nmemb, size_t size,
const void *key, cmp_func_t cmp);
#endif
这里有个坑,我曾经踩过:二分查找的数组必须是升序排列的,而且比较函数要和排序时用的完全一致。否则结果会莫名其妙。嗯,这一点要特别注意。
注意:二分查找的底层实现中,计算中间索引时不要用 (low + high) / 2,而要用 low + (high - low) / 2。前者在 low+high 很大时可能溢出。这是经典bug,我在面试中经常考这个点。
字符串处理模块
字符串处理在嵌入式里其实很常见——解析协议、格式化日志、处理配置参数。标准库的 string.h 功能有限,而且有些实现有安全风险。我习惯自己封装一套。
// string_util.h
#ifndef __STRING_UTIL_H__
#define __STRING_UTIL_H__
#include <stdint.h>
/* 安全字符串拷贝,返回实际拷贝长度 */
int32_t str_safe_copy(char *dst, const char *src, size_t dst_size);
/* 去除字符串首尾空白字符,返回新字符串长度 */
int32_t str_trim(char *str);
/* 分割字符串,返回分割出的字段数量 */
int32_t str_split(const char *str, char delimiter,
char *tokens[], int32_t max_tokens);
/* 字符串转大写/小写 */
void str_to_upper(char *str);
void str_to_lower(char *str);
/* 检查字符串是否以指定前缀/后缀开头/结尾 */
bool str_starts_with(const char *str, const char *prefix);
bool str_ends_with(const char *str, const char *suffix);
#endif
我在做物联网网关项目时,需要解析MQTT的Topic字符串,就用这套工具做了分割和匹配。你想想看,如果没有模块化,每个协议解析文件里都写一遍 str_split,那得多乱。
建议:所有字符串操作函数,都要传入目标缓冲区大小。不要相信外部输入的长度。这是嵌入式安全编程的基本素养。
数学工具模块
数学工具模块主要封装一些常用的数学计算,比如平均值、中位数、标准差、限幅、映射等。这些在传感器数据处理中特别常用。
// math_util.h
#ifndef __MATH_UTIL_H__
#define __MATH_UTIL_H__
#include <stdint.h>
/* 计算数组平均值 */
float math_mean(const float *data, size_t len);
/* 计算数组标准差 */
float math_stddev(const float *data, size_t len);
/* 限幅:将value限制在[min, max]之间 */
float math_clamp(float value, float min, float max);
/* 线性映射:将x从[in_min, in_max]映射到[out_min, out_max] */
float math_map(float x, float in_min, float in_max,
float out_min, float out_max);
/* 滑动平均滤波器 */
typedef struct {
float *buffer;
size_t size;
size_t index;
float sum;
int filled;
} sliding_avg_t;
void sliding_avg_init(sliding_avg_t *ctx, float *buf, size_t size);
float sliding_avg_update(sliding_avg_t *ctx, float value);
#endif
滑动平均滤波器这个结构体,我当年在电机控制项目里用过。它本质上是一个环形缓冲区,每次新数据进来,替换最旧的数据,然后重新计算平均值。这样做的好处是内存占用固定,计算速度快。
经验之谈:数学工具模块里,尽量避免使用动态内存分配(malloc)。嵌入式环境里,内存碎片是个大问题。我一般让调用方自己提供缓冲区,就像上面的 sliding_avg_init 那样。
模块之间的依赖关系
这几个模块之间其实有依赖关系。比如二分查找依赖排序模块,而排序模块又依赖比较函数。我建议用一张图来理清关系:
从这张图可以看出,四个模块都依赖公共基础层(比较函数、错误码等),而查找模块又依赖排序模块。应用层则直接调用各个模块的接口。这种分层设计,让代码的耦合度降到最低。
模块化设计的几个要点
- 头文件自包含:每个模块的.h文件应该能独立编译,不要依赖外部头文件顺序。
- 内部函数加static:模块内部使用的辅助函数,一定要加static,避免污染全局命名空间。
- 错误处理统一:我习惯定义一套错误码,所有模块返回统一的错误类型,方便上层统一处理。
- 避免全局变量:算法模块应该是纯函数式的,输入输出都通过参数传递。
曾经踩过的坑:我在一个项目中,把排序算法的比较函数定义成了全局变量,结果多线程调用时数据全乱了。后来改成每次调用都传入比较函数指针,问题才解决。记住:算法模块不要保存任何状态。
总结
算法模块化,说白了就是把"怎么算"和"用来干什么"分开。排序、查找、字符串处理、数学工具,这四个模块是嵌入式开发中最常用的。把它们封装好,以后每个项目都能直接复用。
我个人习惯在工程里建一个 modules/ 目录,下面按功能分文件夹。每个模块都有独立的 .h 和 .c 文件,单元测试也单独写。这样不管是做新产品还是维护老项目,心里都有底。
好了,这一章的内容就到这里。记住:好的模块化设计,能让你的代码活得更久。
公众号:蓝海资料掘金营,微信deep3321