模块化编程中的算法模块化

各位同学,今天我们来聊聊算法模块化。说白了,就是把那些常用的排序、查找、字符串处理、数学计算,统统封装成独立的模块。我做了十几年嵌入式开发,发现很多新手喜欢把算法代码散落在各个.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 那样。

模块之间的依赖关系

这几个模块之间其实有依赖关系。比如二分查找依赖排序模块,而排序模块又依赖比较函数。我建议用一张图来理清关系:

算法模块依赖关系图 排序算法模块 查找算法模块 字符串处理模块 数学工具模块 依赖 公共基础层 比较函数指针类型、错误码定义、基础数据类型 应用层(业务代码) 排序模块 查找模块 字符串模块 数学模块

从这张图可以看出,四个模块都依赖公共基础层(比较函数、错误码等),而查找模块又依赖排序模块。应用层则直接调用各个模块的接口。这种分层设计,让代码的耦合度降到最低。

模块化设计的几个要点

  1. 头文件自包含:每个模块的.h文件应该能独立编译,不要依赖外部头文件顺序。
  2. 内部函数加static:模块内部使用的辅助函数,一定要加static,避免污染全局命名空间。
  3. 错误处理统一:我习惯定义一套错误码,所有模块返回统一的错误类型,方便上层统一处理。
  4. 避免全局变量:算法模块应该是纯函数式的,输入输出都通过参数传递。

曾经踩过的坑:我在一个项目中,把排序算法的比较函数定义成了全局变量,结果多线程调用时数据全乱了。后来改成每次调用都传入比较函数指针,问题才解决。记住:算法模块不要保存任何状态。

总结

算法模块化,说白了就是把"怎么算"和"用来干什么"分开。排序、查找、字符串处理、数学工具,这四个模块是嵌入式开发中最常用的。把它们封装好,以后每个项目都能直接复用。

我个人习惯在工程里建一个 modules/ 目录,下面按功能分文件夹。每个模块都有独立的 .h 和 .c 文件,单元测试也单独写。这样不管是做新产品还是维护老项目,心里都有底。

好了,这一章的内容就到这里。记住:好的模块化设计,能让你的代码活得更久。


公众号:蓝海资料掘金营,微信deep3321