跳转至

实验反馈

回过头来看 lab2,我们主要是想让大家通过这个实验锻炼如下能力:

  • 学习使用「非学院派」的工具链,诸如 GitHub,GNU Make,CMake
  • 学习命令行工具的开发,特别是如何优雅地处理参数
  • 通过必做和选做的小节式编写,鼓励大家在实验部分考虑 implementation 的可扩展性
  • 最重要的是,掌握课上讲解的 syscall 设计思路和应用,理解 Unix 系统对进程/文件/IO 等功能的抽象

写在前面

在实验过程中,有不少同学向助教私信提问,这些提问有时会出乎我们的意料,有些同学似乎很难和人描述清楚自己的问题。针对这种情况,助教的建议是:

  • 本节所用到的系统调用都是非常经典的接口,通过命令行 man <command> 就可以查询接口的文档。除此之外,还有搜索引擎/Creference/Stackoverflow/ChatGPT 等可以助你搞清楚这些接口的使用方法、参数意义、返回值含义等
  • 避免陷入「XY 问题」:在寻求解决问题的过程中,提出一个看似有用但其实无关紧要的问题 X,而忽略了实际问题 Y。这种情况通常会导致浪费时间和精力,因为人们会围绕着无关紧要的问题 X 进行讨论。比如:询问如何实现某种功能,但并不提供具体的背景或需求。其他人可能会问他为什么需要这个功能,但他回答说这不重要,只是想知道如何实现。这个人可能已经进入了 XY 问题的陷阱,因为他没有提供实际问题的信息,而其他人也无法提供真正有用的解决方案。
  • 注意提问的方式。如果以上尝试都无果,可以试着拿着自己的代码向他人——包括助教求助。但是在提供代码前,你可以试着把代码裁剪到一个最小工作样例(Minimum Working Example,简称 MWE)
什么是 MWE

所谓最小工作样例是一个能够演示问题的最小代码示例。它是一个简单的程序,包含最少的代码,可以重现问题或者展示某个功能。

通常情况下,一个完整的程序可能包含许多文件、依赖库以及复杂的逻辑,如果你遇到了一个问题,想要向他人寻求帮助,直接将整个程序发送给他人很可能不太现实,因为对方需要耗费大量时间去理解你的代码,同时也可能会遇到许多与问题无关的问题

因此,最小工作样例是非常有用的,它可以帮助其他人更好地理解你的问题,同时也可以帮助你更好地定位和解决问题。一个好的最小工作样例应该包含最少的代码,同时可以重现问题,使得其他人能够快速地复现你的问题,并给出解决方案。

所谓「提问的艺术」不是助教不解决你问题的借口,好的提问方式可以极大地增强双方体验,提升自己的效率,或许你在认真审视自己的问题时就找到了症结所在。

在为我们设计的所有小节进行详细反馈之前,我们将简单阐述一下如何给大家的实验内容进行评分。

  • 实验必做部分的满分为 100%,选做部分提供了高于 20% 的可选项,你最多获得 120% 的分数。这意味着我们会直接将你的必做部分得分与选做部分得分进行相加,如果超过 120%,将只能拿到 120%
  • 实验必做部分以及目标明确规定的选做部分将会按照文档中评分标准的条目进行 5% 粒度的测试命令设计,如果你的程序成功通过一个测试点,你将会获得 5% 的得分;如果出现问题,将会由助教讨论后决定
  • 对于未明确规定目标的自由发挥选做,助教将结合报告完整性尽力运行,如果验证顺利,可以拿到这一部分选做的分数。但是助教同时也会进行评审,过于简单/与操作系统设计理论关联太弱的自由发挥将不会得到分数
  • 如果你未在报告中说明编译方式,又未提供 Makefile,助教尽力编译后仍有错误,经私信联系你后才得以解决,助教在评分时可能扣除至多 5% 的分数
  • 其他诸如代码风格、commit 风格和 GitHub 工作流等要求主要是帮大家养成良好的习惯,除了对助教验收造成阻碍情况外,不会对大家的得分有实质性的影响,但是我们非常希望大家重视起来,这对大家今后的科研和工作非常重要

参考代码说明

为了给大家提供更加先进的编程范式案例,我们使用 C++ 为 lab2 编写了一个「参考代码」。由于不少同学可能没接触过 C++,所以我们为其含有 C++ 语言特性的部分详细地进行了注释。希望大家借此机会接触 C++,并且分清楚 C/C++ 的优缺点,根据合适的编程场景选用,提升自己的编程效率。

所谓「参考代码」,与你的设计不一定相同,这并不一定意味着你的设计出现了错误。相反,我们非常欢迎大家分享自己的 idea,共同进步。

助教提供的 C++「参考代码」源代码

对于使用 Rust 编程的同学,如果想获得针对性的反馈,可以咨询助教。

参考设计

首先,对于我们的 shell,一个执行框架是:

while (true) {
    auto input = getline();     // 获取一行新的命令
    auto cmd = parse(input);    // 对命令进行解析,并获得解析结果
    cmd.execute();              // 执行命令
}

我们的目的就是按照实验要求逐步填充上述框架。同学们在做实验的时候可能是从文档中已经给出的框架出发,一节一节完成。但是我们希望从整个 shell 的设计出发,讲解我们提供的参考代码的设计方法,同学们可以体会这样的设计好在哪里,也可以给其提出意见。

罗列文档中必做部分和选做部分的需求:

  • 实现内建指令、外部命令的区分
  • 实现管道,不需要考虑内建命令
  • 实现重定向,不需要考虑内建命令,重定向部分总是在末尾出现
  • 实现信号处理
  • 实现前后台进程

实际上我们可以发现,上述要求实现的功能之间是有层次的。高层次的指令是某些低层次的指令的组合。

  • 前后台进程只需要考虑命令最后是否有 & 符号,这符号不会出现在嵌套的语句中间
  • 重定向只要看末尾是否有重定向的相关记号,如果有,处理重定向后执行剩下的命令即可
  • 内建指令、外部命令只需要看第一个 arg 是否是特定的内建命令,如果是则不需要 fork 子进程然后 exec 新的程序

这样的处理方式很像一棵决策树,在不同的节点做不同的任务。用递归可以解决类似的问题,直到命令是简单的内建命令或外部命令,递归终止。

这里所说的递归,用 C 风格的实现亦可,如:

int execute(int cmd_type, command* cmd_ptr) {
    if (cmd_type == 重定向) {
        处理重定向;
        execute(简单指令, cmd_ptr);
    }
    其他 ...
}

但是为了不使 execute 处理逻辑变得复杂,主要是不希望我们之后添加的功能影响先前已经写好的功能,我们可以将「命令」抽象为一个类,在这个类中提供 execute 方法。这样不同类的指令之间就可以通过指针的类型转换达到「决策树任务」的类型转换。

你可以试着为这个实现添加 cmd1; cmd2; cmd3 这样的新的处理逻辑,与为你的实现添加做对比,看看哪种实现方式更轻松、语义更明确。

参考实现用到的部分 C++ 特性

参考实现中为了最大程度节省篇幅,使用了较多 C++ 提供的特性和标准库函数。这些库函数为我们做了大量的重复工作,使得我们的代码具有比较好的可读性,不用关心过于细节的实现。

如果想查询更详细的用法,可以参考 Cppreference。

下方的解释与参考代码中的注释大部分是互补的,可以结合食用。

虚函数、抽象类与多态

class Command {
  public:
    virtual void execute() = 0;
    virtual void wait(){};
    virtual ~Command() = default;
};

在 C++ 中,虚函数(被标记为 virtual 的函数)可以用于实现多态,因为它允许用基类的指针来调用子类的对应函数。

这里的 Command 是一个抽象类,包含了一个纯虚函数(未被实现的虚函数)execute,这意味着 Command 类无法实例化,只能被别的类继承后实例化别的类,起到了接口的作用。

多态的好处是我们并不需要知道某个对象具体的类型,只需要知道它实现了某个接口,就可以调用它了。

可以看到,ShellCommand 保存了指向某个命令的指针,它可能是 ExternalCommandRedirectCommandPipeCommand,但我们可以统一采用 cmd->execute() 调用。

类型转换

在 C++ 中,对于显式类型转换,不应该使用 C 风格的强制类型转换运算符,而应该用下列类型转换运算符代替:

dynamic_cast <new_type> (expression)     # 父类转换为子类进行检查对于指针若失败则返回 nullptr
reinterpret_cast <new_type> (expression) # 类似强制类型转换
static_cast <new_type> (expression)      # 父类转换为子类但不进行检查
const_cast <new_type> (expression)       # 去除常量限定符但修改去除常量限定符修饰的变量是未定义的

这里就使用到了:

auto is_built_in = dynamic_cast<BuiltInCommand *>(cmd.get()) != nullptr;

来进行运行时类型信息(RTTI)的获取。

for (auto i = 0U; i < args.size(); i++)
    argv[i] = const_cast<char *>(args[i].c_str());

则用于将参数放入 argv 指针数组(指针数组即二维指针,和二维数组是不同的概念)。

智能指针

用于自动调用其中对象的构造和析构函数,无需我们手动 freedelete 内存。

  • unique_ptr:表示持有指针的作用域拥有指针指向的对象的所有权。因此作用域结束时会自动回收。
  • shared_ptr:表示持有指针的作用域和其他作用域共享指针指向的对象的所有权。因此需要维护有多少个指针指向了某同一个资源,当计数清零时回收资源。
  • weak_ptr:当 shared_ptr 成环时会发生内存泄漏(若 A 指向 B,B 也指向 A,那么没有一方可以先析构)。此时可以使用 weak_ptr,表示一种借用所有权的语义。它本身并不会增加引用计数,但使用它需要获取一份 shared_ptr,此时才影响计数。

移动语义

涉及左值,右值,将亡值等概念,展开来说较为复杂,感兴趣的可以自行了解。

一个粗浅(且未必正确的理解是):

对于对象 A(B) 的构造,如果 B 在此之后就不会被用到,那么可以把 B 的资源转移给 A 以避免拷贝构造 A 和析构 B 的开销。这里如果 B 是右值(例如临时的运算结果),那么很容易推断出 B 此后不会用到,从而进行优化。但如果 B 是一个左值(例如某个变量),就引入 std::move() 把它的类型转换成右值,从而表明 B 的资源可以被移动走了。在此之后,对 B 的使用是未定义行为。

Lambda 表达式

允许函数作为一个对象,本质是重载了 operator() 的仿函数。也就是说,一个 Lambda 表达式对应一个匿名类,从而允许 Lambda 表达式捕获外部变量存入类的属性中。当不捕获外部变量时,Lambda 表达式和普通函数类似,因此可以被隐式类型转换成函数指针。

void init_signal() {
    signal(SIGTTOU, SIG_IGN);
    signal(SIGINT, [](int) { std::cout << "\n$ " << std::flush; });
    signal(SIGCHLD, [](int) {
        pid_t pid;
        while ((pid = waitpid(-1, nullptr, WNOHANG | WUNTRACED)) > 0) {
            auto it =
                std::find(background_pids.begin(), background_pids.end(), pid);
            if (it != background_pids.end()) {
                background_pids.erase(it);
                std::cout << '\n' << pid << " exited\n$ " << std::flush;
            }
        }
    });
}

这里方括号内就是 Lambda 表达式的捕获列表。

关于具体功能的实现

上面大多是比较宏观的设计,具体到每个功能该怎么实现,同学们可阅读我们提供的源代码,其中有比较详细的注释。