前面聊了不少设计模式——工厂、单例、观察者、策略、状态模式。但设计模式是"术",SOLID原则才是"道"。

面试官有时候会问一个很开放的问题:"你是怎么设计类的?"

这个问题没有标准答案,但如果你能提到SOLID五个原则,再结合项目里的例子讲清楚,面试官基本会点头。因为这五个字母背后,是几十年软件工程实践总结出来的面向对象设计的基本功。

S——单一职责原则

一个类只应该有一个引起它变化的原因。

听着很抽象,翻译成人话就是:一个类别干太多事。

之前在项目里见过一个SensorManager类,干了什么呢?读取传感器数据、做数据滤波、写日志、发ROS2话题、还要处理串口通信。五个功能塞在一个类里,改任何一个功能都可能影响其他功能。

// 反面教材:一个类干了太多事
class SensorManager {
    void readData();        // 读传感器
    void filterData();      // 滤波
    void writeLog();        // 写日志
    void publishTopic();    // 发话题
    void handleSerial();    // 串口通信
};

拆开来看就舒服多了:

class SensorReader { void read(); };
class DataFilter { void filter(); };
class DataPublisher { void publish(); };
class Logger { void log(); };

每个类只管一件事。改滤波算法不影响读取逻辑,改日志格式不影响发布话题。

O——开闭原则

对扩展开放,对修改关闭。

前面讲的设计模式,工厂模式、策略模式、状态模式,本质上都在实现开闭原则——加新功能的时候加代码,别改老代码。

举个机器人的例子。你的机器人要支持多种传感器,最初只有激光雷达和IMU。你写了个if-else:

if (type == "lidar") { initLidar(); }
else if (type == "imu") { initIMU(); }

后来要加相机、超声波、GPS……每次加新传感器都要改这段代码。用开闭原则的做法是定义一个Sensor接口,每种传感器实现自己的初始化逻辑。加新传感器只需要加一个新类,不用动已有的代码。

L——里氏替换原则

子类应该能替换父类而不影响程序的正确性。

这个原则说白了就是:继承关系要合理。子类不能偷偷改变父类的行为。

经典反面案例:正方形继承矩形。矩形的setWidth和setHeight,正方形继承之后重写了setWidth——setWidth同时改了宽和高。结果呢?用矩形的代码去操作正方形,行为就错了。

在机器人开发里,这个坑也常见。比如你有一个BaseController,有个move(double speed)方法。子类FourWheelController正常实现,但子类HoverController重写了move——它要求speed必须大于某个阈值才能悬浮。传一个小的speed进去,HoverController直接忽略了。这就是违反里氏替换。

class BaseController {
public:
    virtual void move(double speed) = 0;
};

// 违反里氏替换:子类偷偷改变了move的语义
class HoverController : public BaseController {
public:
    void move(double speed) override {
        if (speed < 0.5) return;  // 静默忽略!调用者不知道
        // 真正的移动逻辑
    }
};

正确做法是什么?如果HoverController有特殊要求,应该在接口层面就表达出来,而不是偷偷加限制。

I——接口隔离原则

客户端不应该被迫依赖它不使用的接口。

翻译一下:接口别太大。如果你的接口有20个方法,但某个实现类只需要其中3个,那这个接口就该拆。

// 反面:一个巨大的接口
class RobotInterface {
    virtual void move() = 0;
    virtual void sense() = 0;
    virtual void plan() = 0;
    virtual void communicate() = 0;
    virtual void selfDiagnose() = 0;
};

// 拆分后:每个接口职责明确
class Movable { virtual void move() = 0; };
class Sensable { virtual void sense() = 0; };
class Plannable { virtual void plan() = 0; };

这样,一个只需要移动功能的模块只要实现Movable就行了,不用被迫实现一堆用不到的方法。

D——依赖倒置原则

高层模块不应该依赖低层模块,两者都应该依赖抽象。

这条原则是解耦的核心。你的导航模块不应该直接依赖具体的激光雷达驱动,而应该依赖一个抽象的传感器接口。

// 反面:高层直接依赖低层具体实现
class Navigator {
    RPLidarA1 lidar_;  // 绑死了具体型号
    void navigate() {
        auto scan = lidar_.getScan();
        // ...
    }
};

// 正面:依赖抽象接口
class Navigator {
    LidarInterface& lidar_;  // 依赖抽象
    void navigate() {
        auto scan = lidar_.getScan();
        // ...
    }
};

这样换激光雷达型号的时候,Navigator一行代码都不用改。写个新的RPLidarA1的驱动类实现LidarInterface接口就行。

五个原则在面试中的实战

面试官问"你怎么设计类的",不是让你背概念。他想听的是你怎么把这五个原则用到实际项目里的。

我的经验是这么组织回答:先说"我会用单一职责来拆分模块,每个类只管一件事"。然后说"接口设计要遵循接口隔离原则,不要把接口搞得太大太臃肿"。接着提"模块之间尽量通过抽象接口来交互,不要直接依赖具体实现"。如果面试官继续追问,再展开讲开闭原则和里氏替换原则。

关键是结合项目实例来说明。比如你可以说:"在我的SLAM项目里,前端里程计和后端优化之间通过抽象接口通信,这样换前端算法不影响后端。"这比背十遍SOLID的定义都管用得多。

给正在准备面试的你

SOLID原则是面向对象设计的底线要求。面试里不一定会直接问你"SOLID是什么",但会通过各种问题间接考察——"你怎么设计这个模块""你这个类为什么这么拆""如果换一种传感器你的代码要怎么改"。

前面讲的设计模式,每一个都在实践SOLID的某个原则。工厂模式实现开闭原则,策略模式实现依赖倒置,单一职责原则更是几乎所有模式的前提。理解了SOLID,设计模式就不再是死记硬背的套路,而是自然而然的设计选择。

分享一个面试经验:面试时被问到系统设计类问题,可以先用SOLID原则分析现有方案的不足,再提出改进方案。比如"原来的设计违反了单一职责原则,一个类管太多了,我建议拆成三个类,各自负责不同的功能"。这种有分析过程的回答比直接给答案更能体现设计能力,面试官看重的不只是结果,更是你的思考过程和表达能力。

再补充一个面试中经常被追问的话题:里氏替换原则(LSP)在实际项目中怎么验证。里氏替换原则说的是子类必须能替换父类而不破坏程序的正确性。在机器人开发里,如果你有一个Sensor基类和一个Lidar子类,那所有使用Sensor的地方换成Lidar都应该正常工作。验证LSP的一个实用方法是写单元测试——用父类接口写一套测试用例,然后让所有子类都跑一遍。如果某个子类跑不过,说明它违反了里氏替换原则,需要重新审视继承关系。面试时如果你能提到用单元测试来验证LSP,面试官会觉得你不只是理解理论,还有工程化的验证手段。另外在实际项目中,违反LSP最常见的表现就是子类重写了父类方法但改变了语义——比如父类的move是向前移动,子类重写后变成了旋转,这种设计上的不一致在面试中是很好的反面教材。

下篇聊C++内存模型——栈、堆、全局区、代码区的分布。这个知识点是理解变量生命周期和内存管理的基础,面试里经常和指针、引用一起考。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。

「机器人软件开发面试·从入门到精通」连载系列 上一篇:第48篇 设计模式之状态模式——机器人行为管理的优雅方案 下一篇预告:第50篇 C++内存模型详解——栈/堆/全局区/代码区的分布

有任何问题欢迎评论区留言,我会尽量回复。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐