第312篇 模块化设计——高内聚低耦合的实践
上篇聊了软件架构的整体设计。这篇聚焦到一个更具体的话题:模块化设计。怎么把一个大系统拆分成合理的模块?模块之间的接口怎么设计?这是每个机器人软件工程师每天都要面对的问题。
模块化设计的好坏直接影响开发效率、代码质量和系统可维护性。面试中,面试官经常通过你的模块化设计能力来判断你的工程水平。
什么是好的模块化
好的模块化有两个核心特征:高内聚、低耦合。
高内聚指的是一个模块内部的元素紧密相关。一个负责路径规划的模块,里面所有的类和函数都应该跟路径规划有关。如果你发现一个模块里既有路径规划又有传感器驱动的代码,那就是内聚性差的表现。
低耦合指的是模块之间的依赖关系尽量少、尽量简单。两个模块之间如果只通过一个简单的数据结构通信,耦合度很低。如果它们互相调用对方的内部函数、共享全局变量、依赖对方的执行时序,耦合度就很高。
低耦合的直接好处是:修改一个模块时,不需要改动其他模块。这在多人协作的项目中尤其重要——你改你的路径规划算法,不需要担心影响别人的感知模块。
SOLID原则是模块化设计的理论基础。单一职责原则(SRP):一个模块只做一件事。开闭原则(OCP):对扩展开放,对修改关闭——新增功能通过添加新代码而不是修改旧代码。里氏替换原则(LSP):子类可以替换父类而不影响正确性。接口隔离原则(ISP):不要强迫使用者依赖它们不需要的接口。依赖反转原则(DIP):高层模块不依赖低层模块,两者都依赖抽象。这五个原则在面试中经常被问到,要能结合项目经验来解释。
接口设计的原则
模块之间的接口是模块化设计中最关键的部分。接口设计得好,模块可以独立开发、独立测试、独立替换。接口设计得差,改一个地方到处都要改。
接口设计的几个原则。
最小知识原则。模块只暴露它必须暴露的东西,其余全部隐藏。C++中用private和public来控制,Python中用下划线前缀表示私有。接口越简洁,使用者的认知负担越低,出错的可能性越小。
面向接口编程。模块之间通过抽象接口(abstract class或interface)通信,而不是具体实现。这样你可以随时替换实现——比如把A*路径规划换成RRT,只要它们实现了同一个接口,调用方不需要改动。
数据契约。模块之间传递的数据结构要有明确的定义(通常用protobuf、flatbuffers或者简单的struct)。数据结构的变更要向后兼容——新增字段可以,删除或修改已有字段要谨慎。
// 好的接口设计示例
class PathPlanner {
public:
virtual ~PathPlanner() = default;
// 统一的接口,不暴露内部实现
virtual Path plan(const Pose& start, const Pose& goal,
const OccupancyGrid& map) = 0;
// 查询算法状态
virtual bool isReady() const = 0;
};
// AStarPlanner和RRTPlanner都实现这个接口
// 调用方只需要知道PathPlanner接口
避免使用全局变量来传递模块间的数据。全局变量会让模块之间产生隐式依赖,很难追踪和调试。如果确实需要共享大量数据,用一个显式的数据管理器模块,其他模块通过它来读写数据。
接口的版本管理也很重要。当接口需要变更时,不要直接修改旧接口,而是创建一个新版本。旧接口标记为deprecated,给下游模块时间迁移。直接修改接口是最容易导致线上事故的做法——你以为只是改了一个字段名,结果下游有五个模块都在用这个字段。
机器人中的模块化实践
在机器人软件中,模块化有一些特定的模式。
传感器驱动封装。每种传感器(激光雷达、相机、IMU)都有一个驱动模块,负责跟硬件通信、解析数据。驱动模块向上提供统一的数据接口——不管是什么品牌的激光雷达,输出的点云格式都是一样的。这样上层的感知模块不需要关心底层硬件的差异。
算法模块的参数化。同一个算法模块(比如目标检测)可能有多种实现(YOLO、CenterPoint、PointPillars)。通过配置参数来切换实现,而不是写if-else。这样新增一个算法只需要实现同一个接口,注册到工厂类中。
状态机的模块化。机器人的行为逻辑通常用状态机来管理(空闲、导航中、充电中、故障中)。每个状态是一个独立的模块,状态之间的转换由状态机框架管理。新增一种行为模式就是新增一个状态模块。行为树(Behavior Tree)是状态机的升级版,更适合复杂的决策逻辑。
配置驱动的模块化。很多模块的行为需要通过参数来配置(比如路径规划器的最大速度、安全距离)。把这些参数放到配置文件中(YAML或JSON),而不是硬编码在代码里。这样改参数不需要重新编译,不同机器人可以有不同的配置。ROS的参数服务器就是做这个的。
常见的模块化反模式也要避免。上帝模块(God Module)——一个模块承担了太多职责,代码量巨大,谁都不敢改。面条式依赖——模块A调B、B调C、C又调A,形成循环依赖。隐式接口——模块之间的通信不通过显式的接口定义,而是通过全局变量、文件、或者约定俗成的数据格式。这些反模式在快速迭代的项目中很常见,但如果不及时治理,系统会变得越来越难以维护。
面试中的模块化设计题
面试官可能给你一个场景,让你现场设计模块划分。比如:"设计一个送餐机器人的软件系统"。
你要能从功能角度拆分模块:感知模块(检测障碍物、识别地标)、定位模块(确定自身位置)、导航模块(路径规划和跟踪)、控制模块(电机控制)、交互模块(语音提示、屏幕显示)、调度模块(接收订单、分配任务)。
然后要讲清楚模块之间的接口。感知模块输出障碍物列表给导航模块,定位模块输出位姿给导航模块和控制模块,导航模块输出目标速度给控制模块。每个接口的数据格式、更新频率、可靠性要求都要说清楚。
面试官可能还会追问:"如果导航模块要换一种算法,需要改哪些模块?"如果你之前的接口设计得好,答案是"只需要改导航模块内部,其他模块不受影响"。这就是模块化设计的价值。
还可能问:"你怎么测试一个模块?"好的模块化设计让单元测试变得容易——你可以用mock对象替代依赖模块,单独测试目标模块。比如测试路径规划器时,用一张固定的地图数据,检查输出的路径是否避开了障碍物、是否满足运动学约束。不需要启动整个系统,也不需要真实的传感器数据。
模块化设计还有一个好处:方便做性能优化。如果你发现系统太慢,可以先profile每个模块的耗时,找到瓶颈模块,集中优化它。如果模块之间耦合度高,你连单独测量一个模块的耗时都做不到。
关于模块的粒度。太粗(一个模块太大)失去模块化的好处,太细(模块太多太小)增加了通信开销和管理复杂度。经验法则是:一个模块的代码量控制在2000到5000行以内,一个人能在两周内完全理解它的逻辑。如果超过了,考虑拆分。
给你的建议
模块化设计是一种实践能力,光看书不够,要多写代码、多做项目。每次写代码时,停下来想一想:这个函数放在这个类里合适吗?这个模块的职责是不是太多了?这个接口是不是太复杂了?
读优秀的开源项目也是好方法。看看MoveIt2、NAV2这些项目是怎么组织代码的,模块之间怎么通信的。然后思考:如果让你重新设计,你会怎么做?
面试前,准备好你项目中的模块化设计案例。能画出模块图,讲清楚每个模块的职责和接口。能解释为什么这样划分,以及这种划分带来的好处和代价。
代码review是检验模块化设计的好方法。如果你的代码别人一看就懂、改起来不需要看其他模块的代码,说明模块化做得好。如果review时经常需要解释"这个模块为什么要调用那个模块的内部函数",说明接口设计有问题。
重构是模块化设计的日常维护。项目初期为了赶进度,可能会写一些"脏"代码——模块职责不清、接口混乱、全局变量满天飞。这些技术债不会自己消失,要定期安排重构。每次迭代结束留20%的时间做重构,清理那些"先这样凑合,以后再改"的代码。Boy Scout Rule是个好原则:每次改代码,让代码比你看到时更好一点。
推荐读两本书:Robert Martin的"Clean Architecture"和"Clean Code"。前者讲架构层面的模块化原则(依赖反转、组件内聚等),后者讲代码层面的模块化实践(函数设计、命名规范、错误处理)。这两本书的知识在面试中经常被考到。
下一篇预告:第313篇 中间件设计——模块之间的桥梁
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)