番茄炒蛋 🍅🥚

数据结构课程 Project 3 重写记

日期 2026-06-27    标签 programming、cpp、notes

事情是这样的:我这学期在上一门数据结构的课程,虽然感觉难度不大,但是它的 Project 3 一堆条条框框,让我写得非常不爽;于是在按照要求做完提交之后,我又抽时间自己从零搭了一个能实现一模一样功能的程序,但是除了使用 C++17 标准之外完全不设任何限制。最后我做出了一个用回调函数实现解耦的架构。写这篇帖子是简要介绍一下我是如何搓出这个架构的。

重写过的项目源码在这里,实现的功能也写在 readme 里面了,这里不做过多赘述。简而言之,就是一个高度可扩展的物种演化模拟程序

You can view the English translation of the post here, but it is only for reference.

痛点:模拟器层次的复杂度

由于项目要求限制1,在交上去的代码里,模拟器层次有一个巨大的 switch 语句块,用来根据物种程序的指令类型来让底下的引擎执行不同的物理操作。下面是代码片段 (不完全是提交上去的代码,但是功能和结构一致):

void Simulator::SimulateCreature(unsigned int creature_index) {
  ...
  current_instruction = engine_.get_instruction(creature_index);  // obtain current instruction
  ...
  switch (current_instruction.opcode) {
    case Opcode::kHop:  // hop forward
      engine_.MoveCreature(creature_index);
      break;
    case Opcode::kLeft:  // turn left
      engine_.TurnCreatureLeft(creature_index);
      break;
    ...
  }
  ...
}

功能上虽然没有问题,但是这种做法的缺点有不少:

事实上我本地的 code quality 工具直接警告这个 switch 语句块所在的函数复杂度过高,所幸评测机没卡这个点扣我分。

但为什么一开始会把指令执行放在模拟器里面呢?因为引擎归模拟器管理,而指令的执行又依赖于引擎提供的各种物理操作(例如前进、左转、感染前方生物等等)。

联想:嵌入式开发中学到的回调函数

在当初写完这个函数之后,我也一直在想能不能把指令执行从模拟器里面解耦出去。

某天洗澡的时候,我忽然回忆起嵌入式开发中学到的回调函数 (callback function)。回调函数一般是和中断 (interrupt) 搭配使用,用来在某个特定的事件发生时对其进行处理。老师教的基本架构是这样:

💡 在嵌入式领域,中断是实现事件处理的方式之一。当某个事件发生的时候,对应的设备会让单片机 (MCU) 知晓,这个时候单片机会中断正在执行的其他程序,转而处理这个刚刚发生的事件。

这种架构能让回调函数层完全不用知晓应用层要干什么,它只机械地执行应用层传过来的回调函数,因而在需求发生变更时也不用改动。这就是一种解耦的方式。

解决:依赖倒置

在上述的架构中,回调函数层不依赖于应用层的具体实现,而它俩都依赖于回调函数的函数类型。这就是依赖倒置原理 (Dependency Inversion Principle)。它要求:

高层次不应依赖于低层次;两者都应依赖于某个抽象 (abstraction)

抽象不应依赖于实现细节;相反,实现细节应依赖于这个抽象

在嵌入式的例子里,回调函数的函数类型就是这个抽象。而稍微迁移一下,依赖倒置原理也能应用在这个项目中。我是这样做的:

这个回调函数列表就是统一的抽象,模拟器层次和引擎层次都依赖于它。现在,在模拟器的执行逻辑里,只需要根据指令名称定位到对应的执行方法,然后调用其括号运算符,传入准备好的回调函数列表和其他相关参数即可。逻辑简单很多,如果要扩展的话也可以直接继承出一个新的执行类。

void Simulator::SimulateCreature(unsigned int creature_index) {
  ...
  current_instruction = engine_.get_instruction(creature_index);  // obtain current instruction
  ...
  std::shared_ptr<IMethod> executor = get_executor(current_instruction.opcode);  // obtain executor
  ...
  (*executor)(world_callbacks, creature_index, ...);  // dispatch the executor
  ...
}

重写过的这个 SimulateCreature 函数虽然看上去还会很复杂,但是其核心就是上面的这三行代码:获取指令、找到执行方法,传入参数执行。其它的都是一些检查判断之类的操作,是为了满足杂七杂八的其他需求。如果后续还要加新的功能的话,是完全不用动它的。

脚注

  1. 连头文件都要用白名单限制是何意味呢我请问了?连 stdexceptmemory 都不让引,害得我抛个异常都得用字符串字面量 (string literal),智能指针也用不了,相当的不优雅……