Skip to content

一块内存,六个名字 ​

咱们排内核的错,最常见的遗言就是我们抓到的日志里孤零零的一句 failed。谁砸的、砸在哪、又是为什么砸:咱们从它身上一个字都问不出来。您能做的其实不多:盯着它,试着和三个月前的自己心灵感应一下吧。

写抽象代码,三个月前的我是吧,我让你飞起来!你如是感应到。

它是怎么来的呢?来路其实不复杂。咱们在内核里到处会撞上可能失败的调用:分配内存、按编号查东西、跟设备打交道,样样都可能砸在咱们头上。对付它们呢,人人都会最省事的那一手:函数返回一个 int、成了给 0、砸了给 -1。咱们头几天写起来痛快得很:判断一下是不是 -1、直呼一声完事,写起来足够爽吧!

咱们把时间往后拨三个月、跟一个真实的调用走一遍。咱们假设内核里有个按编号查槽位的函数:查到了,给您一个槽位号。查不到呢,给您一个 -1。来调它的人多半就是三个月后的您自己。您拿到 3,咱们心里就有底:查到了、3 号槽。您拿到 -1,咱们麻烦就来了:这是"没有这个槽"、还是"查的过程出了错"呢?int 本人对咱们守口如瓶。答案咱们只能去当初写它的人脑子里找、顶多再附一条随时可能过时的注释。

那您去翻文档,能翻到什么呢?

咱们翻来翻去,其实顶多一句"失败返回 -1"。您再看 -1,简直就是个筐:"没找到"啦、"盘坏了"啦、"参数非法咯",全给咱们挤在同一个数字里。有的只有抓耳挠腮,这到底是啥啊。。。

老 C 给咱们传下来一个办法:再挂一个全局错误变量。可这个变量的值咱们路上任何一段代码都能顺手改掉。等咱们读到它的时候,它还说不说得清当年的事?咱们谁也不敢打包票。

更麻烦的还在后头:咱们猜意思也好、查全局变量也好,全指着咱们人的自觉。编译器对咱们从头到尾一声不吭,咱们接没接返回值、查没查,它对咱们一概不管。一个没被检查的 -1 呢,就这么揣着垃圾值往咱们的系统深处走,等咱们找到它,已经是在离咱们很远的地方炸完的现场了。等咱们摸回去,咱们能捡回的全部线索就只剩一句孤零零的 failed。

那改用异常呢?咱们不用重新论证:在七个头,五面旗里 std::__throw_bad_alloc 这一类函数已经替咱们试过水了——库代码落笔的时候,并不知道您将来关不关异常,那些名字呢,它可是照常引用着的。被真正调用到的时候,咱们能给它的结局也只有终止。咱们说到底:异常背后是一整套运行时机器,咱们这独立环境一样都背不起。所以咱们只能让错误走返回值,咱们要做的就一件事:把返回值本身做成一个能说清失败的东西。

两种结局,一块内存 ​

咱们要造的东西叫 Result。肚子里的构造咱们待会儿开箱再讲,眼下您只看两行用法,它想说什么、您就能全知道:

C++
Result<int> hit  = 42;                      // 成功:直接装值
Result<int> miss = KernelError::kNotFound;  // 失败:装一个有名有姓的错误

咱们光看签名就听得懂:Result<int> 说的是"可能给您一个 int,也可能给您一个错误"。调用的时候,咱们连文档都不用翻、看一眼类型就够了。

那肚子里怎么长?咱们来试最老实的写法:值放一份、错误也放一份,旁边再给咱们立一个 bool、记着此刻该看哪边。这当然写得出来,可它连一种咱们看着荒唐的状态都照收不误。您会看到值装着 3、错误写着 KernelError::kNotFound、标志还咬定着"请看错误"。咱们会看到三个成员各执一词,编译器站在旁边对咱们一声不吭。您从类型上挑不出任何毛病、想骂它都没处下嘴。

咱们既然认定 Result 只可能成功或者失败,"两边同时有效"咱们干脆也不让人写出来。于是咱们把存储换成 union、学名判别联合,拿咱们的人话讲就是:一块存储、两种形态,再配一个标志给咱们记着此刻活着的是哪一边:

C++
template <typename RawResult, typename ErrorType = KernelError>
class [[nodiscard]] Result {
    union Storage {
        RawResult value;   // 成功时活着
        ErrorType error;   // 失败时活着
        constexpr Storage() : error{} {}
        ~Storage() {}
    };

    Storage internal_storage_;
    bool    is_ok_;        // 判别标志:此刻谁活着
    // ...
};

union 的头一个好处您肯定听过:紧凑。咱们看结构体那边:成员各占各的地。union 呢,咱们让两种形态共用同一块内存,装的东西越大、这边咱们省得越明显。不过咱们的错误枚举统共就一个字节:想靠紧凑省钱、其实省不出几个钱。

咱们刚才已经亲眼见过它把"都摆着"拦下来的样子:union 从物理上断了咱们的念想,咱们让两种形态同住一块内存、任意时刻只放一边活着,咱们连"两个都装好"都没地方表达。

咱们顺着问一句:存储刚构造出来的时候,哪边活着呢?您看 Storage 的默认构造:挑的是错误这一边、初始化成 error{},咱们是故意的。咱们要是不挑:这段内存睁眼就是没人管过的字节,里面躺的是不是垃圾、咱们谁也说不准。咱们日后撞上的"偶尔读到垃圾、复现全看运气"一类 bug,追过去的源头十有八九就是没初始化的内存。

在一块现成的内存上生 ​

union 的紧凑咱们是拿一件事换来的。您去看 Storage 的默认构造和析构:函数体都空着,咱们的 union 成员不会自动构造、也不会自动析构,咱们得亲手安排它们的一生。哪边活、什么时候活、什么时候谢幕呢,全得咱们亲手办。咱们细想一下也只能认下:两个成员共用一块内存。编译器要是自作主张把两边都构造一遍呢,那还共用什么内存。

装值的动作呢,咱们就亲手来,写法咱们叫它 placement new(定点构造)。咱们在一块已经有的内存上就地构造一个对象、不向系统伸手要新内存。咱们把真实的那一行原样抄来:

C++
Result(RawResult value) : is_ok_(true) {
    new (&internal_storage_.value) RawResult(std::move(value));
}

您看 new 后面的括号:里面跟的是一块地址而不是大小,咱们就在 Result 自己身上这块地把值造出来。咱们在七个头,五面旗里欠下的那句"用法细讲"呢,就在这儿补齐:一个地址、一次就地构造、不分配。待会儿讲赋值的时候,咱们还会再见到它一回。

这一手为什么在内核里格外金贵呢?因为内核起步的时候还没有堆分配器、而 placement new 不分配,咱们构造用的内存,就是 Result 自己身上带的。于是哪怕没有堆、咱们照样能拿 Result 装复杂对象,咱们的前提只有一个:装进去的东西、自己也得不向堆要内存。您还记得"不算破戒"的说法吧?咱们在七个头,五面旗里跟您交代过它:placement new 只是在已有内存上构造、不向系统要新内存,所以咱们放它进门。

placement new 的声明呢,咱们只能在 <new> 里找到、别处没有。这个头不在七个头的班底里,清单为咱们单开了一格正式的例外栏。所以咱们翻开 result.hpp,头顶上就有明明白白的一行 #include <new> 等着咱们。

咱们头顶上这行 include,可是后来才站稳的。咱们第一版实现就把它漏了,偏偏咱们的构建照样全过。欸,这个结果就很迷惑:placement new 的声明明明只住在 <new> 里、咱们偏偏没包含它,编译器怎么一点脾气都没有?咱们顺着测试文件往上翻就能见到谜底:测试文件里恰好也包含了 <string>。<string> 在当前的标准库实现里替咱们热心了一回、顺路把 placement new 的声明捎带进了翻译单元,咱们缺的东西就被不声不响地补齐了。等一个只包含 result.hpp 的新文件一出现:咱们当场就吃了一个编不过。咱们这才回过神来。今天咱们去仓库看测试文件,它头顶上那行 <string> 还躺着呢,您可以在 test/unit/ 里亲眼见见它,顺便认识一下咱们无心插柳的功臣。

靠捎带过日子的写法呢,术语咱们叫它传递包含。咱们等于把自己能不能编过,押在"<string> 明天还替不替咱们带声明"上,标准可从没承诺过它替咱们带。咱们安排一条常年站岗的探针:喂给编译器一个只包含 result.hpp 的翻译单元,咱们别的什么都不给它带(在仓库根目录跑):

bash
printf '#include <cinux/result.hpp>\nint Probe() { cinux::base::Result<int> r = 1; return r.value(); }\n' \
    | g++ -x c++ -std=c++23 -Ibase/include -fsyntax-only -

-fsyntax-only 呢,咱们让编译器只过语法语义这道关、不出产物。咱们今天这一跑:风平浪静。您想亲眼看它发威?咱们把 result.hpp 里那行 #include <new> 注释掉再跑一遍,就能收到报错的核心一句:

text
error: no matching function for call to 'operator new(sizetype, int*)'

咱们眼前带地址的 operator new 找不到主了。咱们缺了声明、placement new 就开不了工。您把注释还原再跑一遍,咱们看到的又是风平浪静。

送走旧的,再造新的 ​

咱们把对象装进去了,它也活得好好的呢。可咱们要换状态:要么把值换成错误、要么把错误换回值。咱们麻烦的地方:手里的内存还是同一块、旧对象却还活着。

咱们把赋值的全部讲究落在一个顺序上:销毁在前、构造在后(行话叫 destroy-then-construct):

C++
Result& operator=(const Result& other) {
    if (this != &other) {
        release_self();              // 先销毁现在活着的成员
        is_ok_ = other.is_ok_;
        if (is_ok_) {
            new (&internal_storage_.value) RawResult(other.internal_storage_.value);
        } else {
            internal_storage_.error = other.internal_storage_.error;
        }
    }
    return *this;
}

直接覆盖不行吗?咱们一试就知道不行:等于一个字节一个字节把活着的对象抹掉,抹的过程里咱们一次都没等到析构函数跑。装在里头的要是 std::string 一类攥着资源的对象(咱们的测试里真拿它装过),咱们就再也等不到谁去释放缓冲区、留下一笔无声无息的泄漏。那把顺序倒过来、构造在前销毁在后呢?咱们会让同一块内存短暂叠上两个对象,构造半道一出岔子、留下的就是一个咱们谁也说不清的状态。咱们销毁在前就干净了:任一时刻咱们这块内存上都只有一个对象,就算出事咱们也出得清清楚楚。

销毁这一手呢,跟构造是一个脾气:咱们只送活着的成员。

C++
void release_self() {
    if (!is_ok_) {
        return;
    }
    internal_storage_.value.~RawResult();   // 显式调用析构函数
}

开头的判断省不得。咱们在错误态下打开 union 一看:里面活着的是枚举,咱们面前的 RawResult 那个对象压根就没存在过。这时候您要是无条件调 ~RawResult()、等于对一个不存在的对象行析构之礼,那可就是未定义行为了。

在咱们手里呢,显式析构和 placement new 构成一对照应:一个跟咱们说"在这里灭",一个跟咱们说"在这里生"。不过您留心,两条路并不是左右对称的。RawResult 可能是 std::string 这样攥着资源的对象,送走它非得正经调用析构、迎回来也非得 placement new。错误那边只是一个枚举,生命轻得多:切过去时直接给 error 赋值就够了;从错误态离开时,release_self() 看见 is_ok_ 是假、什么都不做。

咱们就拿 Result<std::string> 排一次。动画上面一行是成功变失败:先把活着的字符串送走,再翻牌子、写错误。下面一行是失败变成功:原地没有字符串可析构,于是直接翻牌子、在同一块存储上把字符串造回来。两条路动作多少不一样,守的规矩却是同一条:牌子指向谁,谁才允许活着。

0.0s / 44.2s
STEP 01开场

所以您看赋值运算符时,别把它读成"复制几个字段"。它真正办的是一次换房手续:release_self() 先清场,is_ok_ 换门牌,最后才让新住户进来。顺序一乱,门牌和屋里的人就对不上了。

咱们看移动构造那边还标着一个条件 noexcept:值类型的移动构造不抛,咱们 Result 的移动就跟着不抛。咱们将来敢不敢在要紧路径上放心地移动它:看的就是这一行。

站到调用方那边去 ​

咱们在类型这边能立的都立了。咱们换个座位、坐到调用方那边去,看看拿它写代码的一天长什么样。孩子们!这可是咱们日常消费的姿势啊!您天天要写的,现在就是这么简单的几行:

C++
Result<int> slot = LookupSlot(key);
if (!slot) {                  // operator bool:先问错没错
    return slot.error();      // 失败:错误带着名字,原样上交
}
UseSlot(slot.value());        // 能走到这里,必然有值

错误上交的时候,咱们是带着名字上交的:接手的函数接着问、接着交,一路给咱们传到顶、谁也不用在半路猜。

那要是调用方连返回值都懒得接呢,一句 LookupSlot(key); 完事、转身就走?咱们把这个问题交给编译器。[[nodiscard]] 呢,咱们把它挂在类型身上:凡是返回 Result 的函数,您要是扔了返回值,编译器就会出声提醒您。提醒其实只是一声警告。咱们真正把它变成报错,靠的是咱们在七个头,五面旗里立下的 -Werror:警告一冒头,编译当场给咱们判没过。真实代码里它还给咱们带着一句话:

C++
class [[nodiscard("Dont throw away the Result, check it!")]] Result {

您要是扔了返回值呢,编译器就把括号里这句原样甩给您。

它跟 value() 呢,是分工的:[[nodiscard]] 给咱们抓"整只丢弃"、发生在编译期。咱们接住了、忘了检查就取值,就出了它的管辖,那一层咱们交给 value() 里的断言兜底。运行期的事咱们到断言一节再正式立起来。

咱们还做了一处顺滑的安排:两个构造函数咱们都故意没加 explicit。咱们图的:是 return 42; 和 return KernelError::kNotFound; 不经包装、直接当返回值写出去,咱们写函数体就干干净净。咱们也别以为顺滑是白来的:非显式转换构造、工具看了会有意见。所以实现里咱们给两个构造函数各压了一条豁免注释,咱们用一句话交代意思:故意的、别报。

没有产物的失败 ​

咱们还有一大类失败没安顿! 也就是那些什么都不产出的失败。您听:"设置模式成功了吗""刷新完成了吗",咱们内核里大量的操作做完就完了。成了呢,咱们没有任何值可给。砸了呢,咱们才有话要说。咱们最常见的失败形态偏偏就出在它们身上。

咱们给它们留的形态叫 Result<void>:

C++
Result<void> SetMode(int mode) {
    if (mode < 0) {
        return KernelError::kInvalidArgument;
    }
    // ...设置模式...
    return {};   // 成功:什么都不装
}

成功的时候,咱们 return {};。失败的时候,咱们 return 一个错误码。两边咱们都只写一句话。

咱们看它的内部跟泛型版长得不一样:泛型版靠 union 共用存储,Result<void> 呢,咱们把错误码和标志并排放、得了个"胖结构"的名号。您可能要问了:咱们前面否决"都摆着"的时候,不是说过两条嘛,内存浪费、非法状态写得出来?到它这儿咱们一条都挑不出来:咱们没有值可装、谈不上省内存。咱们没有两边可同时活、非法状态由构造函数直接给咱们管住了。

接住了,没检查 ​

咱们还剩最尖锐的一种情况没正面回答:接是接住了,咱们检查没做就取值。您说,程序该怎么死呢?

咱们把答案藏在 value() 的第一行里:

C++
RawResult& value() {
    safety::Check(is_ok_, "Result::value() called on error");
    return internal_storage_.value;
}

咱们干脆选了当场炸。您忘了检查就取值,程序立刻给您终止,把死在哪一行报给您。您听着粗暴,是因为旁边还摆着一个体面得多的选项:静默地把垃圾值交出去。可咱们炸在这一行,等于把案发现场原样留给您:您打开报告就能一眼看到出事的那行。垃圾值呢,一声不吭往咱们系统的更深处走,在三层之外的文件系统里给咱们惹出一个不相干的麻烦,等咱们循着残骸赶到:现场早被踩得稀烂。

都 C++23 了,为什么不用 std::expected ​

咱们把问题摆出来:"为什么不用标准库的 std::expected?"您多半也想到了,它最忌咱们凭印象答。印象里那句"它编不进独立环境"呢,实测会给出细得多的答案。咱们不猜,直接摆四组对照实验:同一个"忘了检查就取值"的过错,咱们换四种编译姿势去编,您就能看到四种下场。整套实验您可以亲手复跑,程序咱们十几行就能写完。咱们开头往标准输出留一行字、然后故意"忘了检查就取值"。您把它存成 exp.cpp:

C++
#include <cstdio>
#include <expected>

std::expected<int, int> Lookup(bool ok) {
    if (ok) { return 42; }
    return std::unexpected(-1);
}

int main() {
    std::printf("before failure\n");   // 先留一行,看它能不能活过终止
    return Lookup(false).value();      // 忘了检查就取值
}

咱们看 A、B、C 三条命令(以下在 GCC 16.2.1 上实录):

bash
g++ -std=c++23 exp.cpp -o a && ./a                           # A
g++ -std=c++23 -fno-exceptions exp.cpp -o b && ./b           # B
g++ -std=c++23 -ffreestanding -fno-exceptions -fno-rtti \
     -nostdlib -c exp.cpp -o exp.o && nm -u exp.o            # C

咱们从 A 看起:死相最体面。终止之前咱们能收到运行时的一行诊断、写明抛出的是 std::bad_expected_access<int>,后面还给咱们缀着一条 what() 消息,咱们至少知道它为什么死。亮堂从哪来?咱们人在正常电脑上、异常机器在场,它才替咱们把话说完。

B 呢,就露底了:咱们把异常一关,同一个程序干干净净地终止、诊断一个字都没有。您可别弄混,咱们在内核里的真实待遇是 B、不是 A。

C 呢,给咱们的结果最出人意料:它居然编过了。您别急着改口,咱们看看它带了什么出门。咱们跑的命令把独立环境的全套开关一面不落全加上了:关异常、关 RTTI、不链标准库,而且咱们只编译、不链接。链接是什么?咱们把编译产物拼成完整程序的那一步。咱们让 C 只走到编译为止、链接没往下走。链接怎么落地,咱们到断言一节再说。咱们拿 nm 一看:C 欠下的名字都列出来了。

text
                 U abort
                 U printf
                 U __stack_chk_fail

printf 咱们不去管它:程序自己调用的。__stack_chk_fail 是咱们的老熟人:咱们在七个头,五面旗里数 vector 欠的名字那回,咱们见它排在第二行。它在咱们这儿的老本行,是栈保护埋下的金丝雀:函数返回前咱们核对一遍,对不上咱们就调它终止。另一个 abort 是谁欠的?咱们追下去:std::expected 发现自己取不到值、又没有异常可抛,给咱们的退路就只剩终止。而"终止"本身咱们到了独立环境里也得有人提供,目标文件里就这么白纸黑字地欠了咱们一笔。咱们带着这俩没人认领的符号走到链接这一关,终究是过不去了。

咱们再往深一层看头文件自己的地位:标准从不承诺 <expected> 在独立环境里可用。freestanding 承诺提供的头,咱们一张小名单就数完了,咱们在名单上找不到它。咱们今天碰上的 GCC 心肠好,给咱们放行了。咱们换一个版本、换一家编译器,它随时可以不给咱们开门。所以"std::expected 编不过裸机"这话咱们说过头了,咱们实测下来它偏偏编得过。但咱们能编归能编:赌的是编译器仁慈。

D 呢,咱们不另起炉灶,您把程序补完整就能亲手跑。咱们把同一份程序抄成 expd.cpp,咱们跟 exp.cpp 比只改两处。头部咱们添一行 #include <cinux/assert.hpp>。main 的末行咱们换成三行:把结果接住、过一道检查、再取值。咱们用的正是 value() 第一行那个 Check:

C++
#include <cinux/assert.hpp>
#include <cstdio>
#include <expected>

std::expected<int, int> Lookup(bool ok) {
    if (ok) { return 42; }
    return std::unexpected(-1);
}

int main() {
    std::printf("before failure\n");
    std::expected<int, int> result = Lookup(false);
    cinux::base::safety::Check(result.has_value(), "Lookup failed");
    return result.value();
}

咱们手上的命令比 A、B 多带一份断言的宿主实现:它就住在仓库的 test/framework/ 里,咱们从仓库根目录跑:

bash
g++ -std=c++23 -fno-exceptions -Ibase/include expd.cpp test/framework/assert_host.cpp -o d && ./d

退出码咱们收到的是 A、B 同款的 134。标准错误流上还给咱们落下一行(同样在 GCC 16.2.1 上实录):

text
[assert] expd.cpp:13:31 in int main(): Lookup failed

咱们真正的对照组是 B 和 D,咱们得细看。两边咱们都在开头往标准输出打了一行 before failure、然后触发终止。B 这边咱们的标准输出是攒着走的:您直接在终端里跑,它就按行放行、咱们兴许还能看见那行字赶在被终止前出门。咱们要是把输出重定向进文件或管道,它就整块整块地给咱们攒着:程序一终止、没出门的部分跟着进程一起蒸发。同一行遗言留没留下呢,全看咱们的程序死在终端里还是管道里。D 这边咱们的报告走的是标准错误流、从不缓冲:写一行、落地一行,终止之前就全写完了、咱们谁也别想再把它收回去。

Q: 这个 134 是怎么来的?

咱们来算:程序是叫信号带走的,咱们把"死于信号"按 shell 的惯例记作 128 加信号编号。咱们查 SIGABRT 的编号是 6,咱们拿 128 一加就得到 134。咱们看 A、B、D 三家死法各不相同,等到被操作系统一击薄纱的时候交给咱们的退出码:清一色都是 134。

咱们四组跑完,给大伙分档说。C 帮咱们证明了一件事:"编不过裸机"说过头了。可咱们也就证明到这儿:它编得过,靠的是 GCC 给咱们高抬贵手。咱们真正的否决项落在死法上,您拿 B 对 D 看看:同样关着异常、同样忘了检查,咱们一边看 std::expected 静默消失、一字不留,一边看断言给咱们指名道姓:文件、行号、消息全备齐。咱们真在内核里出了事,前一种死法留给您的:是开底层调试器、在零信息里捞针的半天。后一种呢,咱们文件加行号在手、一打开就是出事的那一行。

至于类似 std::string_view 这样的纯视图类型,咱们在独立环境里多半真能用。咱们不用它,是"能不用"的取舍:咱们让允许清单不开口子,一开它就给咱们从"必须遵守"软成"仅供参考"。一边是给咱们的实测否决、一边是咱们主动立的线、强度不同,您可别把它们混在一锅里。

六个,不是六十个 ​

咱们把类型立完,咱们还欠一样东西没交代:错误本身。KernelError 您已经照过好几次面了,现在咱们正式认识一下:

C++
enum class KernelError : std::uint8_t {
    kOk = 0,
    kOutOfMemory,
    kInvalidArgument,
    kNotFound,
    kIOError,
    kWouldBlock,

    kCount,   // 哨兵:真实条目的个数,永不作为错误返回
};

咱们数得过来:统共六个。错误种类在咱们这儿是一路撞出来的、不是设计出来的。您坐在桌前凭空盘算"内核会遇上哪些错",咱们得到的清单要么残缺、要么臃肿、多半两样都占。真有调用方撞上"枚举里没有这个错"的那天:咱们再加一行。到那天您才知道,咱们为它补的这一行,是真的值呢。

笔者第一遍做这套类型库的时候可是交过学费的:错误枚举一口气定了十四个。后来撞上的故障、非目录、路径循环呢,这些名目咱们是一个一个补进去的,没有一个出自咱们当初的桌面设计。咱们连 [[nodiscard]] 这道闸也是后来补上的。

咱们"撞到才加"的路数自带一个隐患。哪天咱们加了枚举、忘了更新错误名字表?咱们把名字表做成查表实现:名字集中一处、加错误只加一行:

C++
constexpr const char* ErrorString(KernelError error) {
    constexpr const char* kNames[] = {
        "Ok", "OutOfMemory", "InvalidArgument", "NotFound", "IOError", "WouldBlock",
    };
    static_assert(sizeof(kNames) / sizeof(kNames[0])
                  == std::to_underlying(KernelError::kCount));

    const auto kIndex = std::to_underlying(error);
    return kIndex < std::to_underlying(KernelError::kCount) ? kNames[kIndex] : "Unknown";
}

里面那道 static_assert 呢,咱们是把一道丢掉的检查给补了回来。咱们得从 switch 说起:名字表要是逐个 case 地写——

C++
switch (error) {
case KernelError::kOk:          return "Ok";
case KernelError::kOutOfMemory: return "OutOfMemory";
// ...
}

咱们手上的 switch 写法自带一道闸。-Wswitch 这一类警告会把咱们写下的 case 同枚举的全部成员逐一对照。枚举里多了谁、case 里缺了谁呢,警告立刻点它的名。咱们又把警告当错误:漏一个分支、等于当场编译不过。可咱们一换成查表、闸就没了、数组只认下标,咱们根本问不出枚举一共有几个成员。枚举加了值、名字表没跟上呢,它照常给咱们编过、一声不吭。

static_assert 给咱们补回来的正是那道核对。咱们在编译期比较两个量,一头拿表的实际长度 sizeof(kNames) / sizeof(kNames[0])。咱们另一头拿的是哨兵标定的错误个数 std::to_underlying(KernelError::kCount)。咱们再请出 kCount:它不代表任何真实错误,只替咱们数着"真实错误有几个"。哪天您添了第七个错误,咱们看枚举这边 kCount 自动变成 7、表里还是 6 项:7 不等于 6、当场编不过,咱们数着把新名字补齐就行。

残余的风险呢,咱们也如实交代。咱们要是把名字表里两个名字填串了行,switch 抓不住它、查表也给咱们抓不住。咱们拿测试兜底,第一个测试就把每个错误的名字逐一核对一遍。哦对,std::to_underlying 是 C++23 的库设施,咱们到 <utility> 里能找到它。咱们在七个头,五面旗里跟您掰过界线:库设施和语言标准是两码事。它进不进得来呢,咱们看的就是住哪个头。<utility> 恰好在咱们的允许清单上,咱们就放它进来了。

咱们现在任何一个错误都能打成一个词:NotFound、而不是一个 -2。开场那句孤零零的 failed 也总算给咱们有了下文。

咱们把类型立起来,其实只做到了让错误能表达出来。往后咱们还要干一件事:把内核里成批的调用方从裸返回值迁到 Result 身上、用起来是另一个工程,咱们留给后面把地基做深的一站当主线。眼下咱们还得把有了名字的错误排成人能读的字、送到屏幕上和串口里。怎么排呢,咱们下一节接着看格式引擎。

r05_long_mode-2-gfc5569b · fc5569b · 2026-10-01