SerenityOS 代码模式指南:从 TRY/MUST 错误处理到容器选型的工程实践
【免费下载链接】serenityThe Serenity Operating System 🐞项目地址: https://gitcode.com/GitHub_Trending/se/serenity
SerenityOS(一个以 64 位 x86、Arm 与 RISC-V 为目标的图形化类 Unix 操作系统)在长期演进中沉淀出了一整套贯穿内核(Kernel)与用户态(Userland)的 C++ 编码模式。本文以 Documentation/Patterns.md 为核心骨架,逐条拆解TRY(...)/MUST(...)错误传播、Fallible 构造器、serenity_main程序入口、侵入式链表、AssertSize静态断言、字符串视图字面量、SourceLocation与四种数组容器选型,并结合仓库中的真实源码与测试用例佐证其底层实现,帮助你写出风格统一、健壮且符合 SerenityOS 社区审美的代码。
引言:为什么 SerenityOS 需要一套"模式"文档
在大型 C/C++ 代码库中,如果没有统一的约定,同一类问题往往会被写成十几种风格迥异的解法。SerenityOS 的代码库覆盖内核、系统服务、图形界面、浏览器引擎与大量基础库,参与贡献者众多,因此文档 Documentation/Patterns.md 明确承担了"追踪并描述反复出现的模式"的职责:这些模式一部分是在项目演进中自发涌现的,另一部分则是有意采纳的,目的是让新代码与既有代码保持一致,并让好模式在代码库中进一步传播。
与一般"编码规范"不同,本文档关注的是具有实质技术收益的工程模式——它们大多与 SerenityOS 独特的错误处理哲学(ErrorOr返回值)和"拒绝 OOM 恐慌"的内核级健壮性要求深度绑定,因此不能简单地照搬通用 C++ 最佳实践。
TRY(...):基于ErrorOr的无样板错误传播
模式动机
SerenityOS 的错误处理并不依赖 C++ 异常,而是通过AK::ErrorOr<T>返回值显式传播错误。AK/Error.h 中定义了[[nodiscard]]的ErrorOr<T, E>:它内部是一个联合体,要么持有值T,要么持有ErrorType(默认即AK::Error),并额外用一个bool标记当前处于哪种状态。ErrorOr<void, E>则特化为持有AK::Empty的ErrorOr<Empty, E>,把"无返回值"建模为一种合法的值状态。
如果每个可能失败的调用点都要手写if (result.is_error()) return result.release_error();,代码会被样板淹没。TRY(...)宏正是为此而生,它把"执行 → 判错 → 提前返回"压缩成一个表达式。
宏的实现原理
宏定义位于 AK/Try.h:
#define TRY(expression) \ ({ \ AK_IGNORE_DIAGNOSTIC("-Wshadow", \ auto&& _temporary_result = (expression)); \ static_assert(!::AK::Detail::IsLvalueReference<decltype(_temporary_result.release_value())>, \ "Do not return a reference from a fallible expression"); \ if (_temporary_result.is_error()) [[unlikely]] \ return _temporary_result.release_error(); \ _temporary_result.release_value(); \ })逐行解读:
- 它借助 GCC/Clang 都支持的语句表达式(statement expressions)扩展,让整个
TRY(...)可以作为一个表达式嵌入赋值语句右侧; - 用
auto&&承接表达式结果,配合AK_IGNORE_DIAGNOSTIC("-Wshadow")允许宏嵌套(在另一个TRY内部再写TRY); - 通过
static_assert禁止从 fallible 表达式返回引用——因为语句表达式无论如何都会产生拷贝,返回引用会得到悬垂引用,这是宏作者刻意设计的编译期防线; - 若结果处于错误态,
release_error()会从当前函数直接return,因此TRY只能用在返回类型同样为ErrorOr(或Error/Result)的函数中; - 若成功,整个表达式的值即
release_value()的结果。
注意宏头部注释中的说明:TRY面向任何"具有预期 API 的结果类型",主要以AK::Result与AK::Error为设计目标。而在内核态与用户态中错误载体通常都是ErrorOr<T>(默认错误类型AK::Error)。
实战示例:LibGfx 中的位图创建
文档给出的第一个完整示例来自图形库Bitmap::create_shareable(参见 AK/Try.h 的配套用法与 Userland/Libraries/LibGfx 目录):
#include <AK/Try.h> ErrorOr<NonnullRefPtr<Bitmap>> Bitmap::create_shareable(BitmapFormat format, IntSize size, int scale_factor) { if (size_would_overflow(format, size, scale_factor)) return Error::from_string_literal("Gfx::Bitmap::create_shareable size overflow"); auto const pitch = minimum_pitch(size.width() * scale_factor, format); auto const data_size = size_in_bytes(pitch, size.height() * scale_factor); auto buffer = TRY(Core::AnonymousBuffer::create_with_size(round_up_to_power_of_two(data_size, PAGE_SIZE))); auto bitmap = TRY(Bitmap::create_with_anonymous_buffer(format, buffer, size, scale_factor, {})); return bitmap; }这段代码展示了TRY的三种典型用法:
- 前置校验:
size_would_overflow不通过时直接return Error::from_string_literal(...),错误信息采用"模块: 描述"的字符串字面量风格; - 连续传播:匿名缓冲区创建失败、位图创建失败都会立即返回,不再需要层层
if; - 返回值收尾:最后一个表达式
return bitmap;直接返回NonnullRefPtr<Bitmap>,它会被隐式转换为ErrorOr<NonnullRefPtr<Bitmap>>的成功态。
实战示例:内核中的地址空间分配
TRY同样贯穿内核内存管理代码,文档引用了AddressSpace::allocate_region:
#include <AK/Try.h> ErrorOr<Region*> AddressSpace::allocate_region(VirtualRange const& range, StringView name, int prot, AllocationStrategy strategy) { VERIFY(range.is_valid()); OwnPtr<KString> region_name; if (!name.is_null()) region_name = TRY(KString::try_create(name)); auto vmobject = TRY(AnonymousVMObject::try_create_with_size(range.size(), strategy)); auto region = TRY(Region::try_create_user_accessible(range, move(vmobject), 0, move(region_name), prot_to_region_access_flags(prot), MemoryType::Normal, false)); TRY(region->map(page_directory())); return add_region(move(region)); }值得注意的细节:
- 名称以
try_开头的工厂函数(KString::try_create、AnonymousVMObject::try_create_with_size、Region::try_create_user_accessible)是"可能失败"的显式信号,返回值通常是ErrorOr<T>; TRY(region->map(page_directory()))用于传播ErrorOr<void>类型的错误——即使没有值需要取出,判错返回依然成立;- 此处不用异常、不记录错误栈,错误类型在 AK/Error.h 中即
AK::Error(持有m_code、m_string_literal与m_syscall标志)。
与 Rust?运算符的类比
TRY的行为与 Rust 中的?运算符几乎一致(Rust 官方文档对?的描述是"错误传播的快捷方式"):二者都做"出错即从当前函数返回错误,成功则解包出值"。区别在于?是语言语法,而TRY依赖编译器扩展,且TRY的解包会强制产生一次移动/拷贝(这也是它禁止返回引用的原因)。
MUST(...):语义明确的"绝不允许失败"
与TRY的区别及使用纪律
MUST(...)与TRY(...)结构几乎相同(同样定义于 AK/Try.h),唯一区别是:当结果处于错误态时,MUST调用VERIFY(!_temporary_result.is_error())直接断言崩溃,而不是返回错误:
#define MUST(expression) \ ({ \ AK_IGNORE_DIAGNOSTIC("-Wshadow", \ auto&& _temporary_result = (expression)); \ static_assert(!::AK::Detail::IsLvalueReference<decltype(_temporary_result.release_value())>, \ "Do not return a reference from a fallible expression"); \ VERIFY(!_temporary_result.is_error()); \ _temporary_result.release_value(); \ })文档给出了两条明确的使用纪律:
- 不要用
MUST顶替暂时无法传播错误的TRY。此时应调用ErrorOr的release_value_but_fixme_should_propagate_errors()方法(定义见 AK/Error.h)取出值,并用方法名中的FIXME标记未来改进点; MUST仅用于两种场景:要么通过其他途径已经证明宏内代码不可能失败,要么失败后果严重到程序必须崩溃。
示例:先扩容后追加的循环
#include <AK/Vector.h> ErrorOr<void> insert_one_to_onehundred(Vector<int>& vector) { TRY(vector.try_ensure_capacity(vector.size() + 100)); for (int i = 1; i <= 100; i++) { // We previously made sure that we allocated enough space, so the append operation shouldn't ever fail. MUST(vector.try_append(i)); } return {}; }这里先用TRY确保容量足够(容量不足时Vector的追加会因堆分配失败而报错),随后循环内的try_append被MUST包裹:因为容量已保证,追加只会在桶内写入,不可能失败,若真失败则说明发生了更严重的逻辑错误,断言崩溃是合理响应。
错误构造工具的补充
配合使用时会频繁见到 AK/Error.h 提供的错误构造方法:
Error::from_errno(int code):包装系统调用返回的 errno(会VERIFY(code != 0));Error::from_string_literal(char const (&)[N]):用户态专用,直接书写静态错误信息,如"Class: Some failure";Error::from_string_view(StringView):按视图持有字符串;为避免悬垂,该函数对ByteString、String、FlyString等所有权类型做了= delete禁用,临时字符串必须显式.view()才能传入;Error::from_syscall(StringView syscall_name, int rc):记录失败的系统调用名与返回码;Error::copy(Error const&):显式拷贝错误。
Fallible Constructors:用静态工厂取代可失败构造函数
为什么需要这个模式
C++ 构造函数无法返回ErrorOr<T>,而在 SerenityOS 中一切可能失败的操作(内存分配、IO、解码)都通过ErrorOr表达。若在构造函数体内失败,只能靠异常或置位标志,这与系统哲学相悖。因此约定:需要执行可失败操作的类,不提供"会失败"的构造函数,而是定义名为create的静态工厂函数。
模式结构
create返回ErrorOr<T>或ErrorOr<NonnullOwnPtr<T>>;- 它在内部完成两类工作:为私有构造函数准备参数(其中任何一步都可能
TRY返回)、在对象构造完成后执行仍需的可失败初始化; - 真正的构造函数保持
private,只做纯成员初始化,从而保证"对象一旦构造出来就是有效的"。
文档示例:解压器
class Decompressor { public: static ErrorOr<NonnullOwnPtr<Decompressor>> create(NonnullOwnPtr<Core::Stream::Stream> stream) { auto buffer = TRY(CircularBuffer::create_empty(32 * KiB)); auto decompressor = TRY(adopt_nonnull_own_or_enomem(new (nothrow) Decompressor(move(stream), move(buffer)))); TRY(decompressor->initialize_settings_from_header()); return decompressor; } // ... snip ... private: Decompressor(NonnullOwnPtr<Core::Stream::Stream> stream, CircularBuffer buffer) : m_stream(move(stream)) , m_buffer(move(buffer)) { } CircularBuffer m_buffer; NonnullOwnPtr<Core::Stream::Stream> m_stream; }此例同时展示了三个模式的组合:
TRY(CircularBuffer::create_empty(32 * KiB)):缓冲区创建失败即返回;TRY(adopt_nonnull_own_or_enomem(new (nothrow) Decompressor(...))):new (nothrow)在分配失败时返回空指针而非抛异常,随后由adopt_nonnull_own_or_enomem将其转换为ErrorOr<NonnullOwnPtr<Decompressor>>(OOM 时返回 ENOMEM 错误);TRY(decompressor->initialize_settings_from_header()):对象构造完成后继续做可能失败的头解析初始化。
注意:new (nothrow)、adopt_nonnull_own_or_enomem、VERIFY这些原语广泛定义于 AK/OwnPtr.h、AK/Assertions.h 与 AK/NonnullOwnPtr.h 中,读者可在 AK 目录下继续追读。
serenity_main(...):SerenityOS 风格的程序入口
模式动机
SerenityOS 的程序不再暴露普通的 Cmain函数,而是暴露serenity_main(Main::Arguments)。动机有二:
Main::Arguments以更贴近 Serenity API 的形式组织命令行参数;- 返回
ErrorOr<int>使入口函数可以直接用TRY(...)无缝传播错误,省去大量 C 风格if (rc < 0) return rc;样板。
从 C main 到 serenity_main
传统写法:
int main(int argc, char** argv) { return 0; }SerenityOS 写法:
#include <LibMain/Main.h> ErrorOr<int> serenity_main(Main::Arguments arguments) { return 0; }底层链接机制
Main::Arguments与serenity_main的声明位于 Userland/Libraries/LibMain/Main.h:
namespace Main { struct Arguments { int argc {}; char** argv {}; Span<StringView> strings; }; int return_code_for_errors(); void set_return_code_for_errors(int); } ErrorOr<int> serenity_main(Main::Arguments);Arguments在保留argc/argv的同时,额外提供了Span<StringView> strings,可直接按StringView遍历参数,无需手工指针运算;- 实现位于 Userland/Libraries/LibMain/Main.cpp,库的构建配置见 Userland/Libraries/LibMain/CMakeLists.txt:可执行程序链接
LibMain后,真正的 C 入口int main(int, char**)由库提供并在启动时调用serenity_main(...); - 错误码策略由
Main::return_code_for_errors()/set_return_code_for_errors(int)控制——当serenity_main返回错误时,LibMain会以此决定进程退出码。
也就是说,应用程序作者只需要写serenity_main,main的样板与错误到退出码的转换全部由LibMain统一承担。这一模式的历史由来记录在 "OS hacking: A better main() for SerenityOS C++ programs" 视频中(文档内引用,此处不展开外部链接)。
Intrusive Lists:面向 OOM 韧性的内核数据结构
什么是侵入式链表
文档引用 Intrusive linked lists 的定义:当每个元素自身持有用于记录其在数据结构中归属的元数据(对链表而言就是内嵌的节点对象)时,该数据结构就是"侵入式"的。与之相对,Vector这样的非侵入式容器由容器自身分配存储。
侵入式链表的核心收益是:插入操作不进行任何内存分配,因此插入绝不会因 OOM 失败,错误处理代码可以大幅简化。这正是内核这种"必须对 OOM 有韧性"的环境所需要的。
声明模式:私有节点 + 公开类型别名
通用约定是:把侵入式链表节点作为私有成员存储,再用公开类型别名把链表类型暴露给外部使用者。文档示例来自内核Region类(相关代码位于 Kernel/Memory 目录):
class Region final : public Weakable<Region> { public: // ... snip ... private: bool m_syscall_region : 1 { false }; IntrusiveListNode<Region> m_memory_manager_list_node; IntrusiveListNode<Region> m_vmobject_list_node; public: using ListInMemoryManager = IntrusiveList<&Region::m_memory_manager_list_node>; using ListInVMObject = IntrusiveList<&Region::m_vmobject_list_node>; };一个对象可以同时挂入多个链表(m_memory_manager_list_node与m_vmobject_list_node是两个独立节点),对应Region同时属于"内存管理器维护的全局区域表"与"每个 VMObject 维护的区域表"两种关系。
使用方通过公开别名直接持有链表:
class MemoryManager { // ... snip ... Region::ListInMemoryManager m_kernel_regions; Vector<UsedMemoryRange> m_used_memory_ranges; Vector<PhysicalMemoryRange> m_physical_memory_ranges; Vector<ContiguousReservedMemoryRange> m_reserved_memory_ranges; };底层实现
侵入式链表的实现在 AK/IntrusiveList.h:
IntrusiveListNode<V, Container>(第 157 行起)内嵌m_next/m_prev指针,节点本身可感知所属容器;IntrusiveList以非类型模板参数SubstitutedIntrusiveListNode<T, Container> T::* member绑定"节点在宿主类型中的成员指针",因此同一个IntrusiveList类型天然知道自己遍历的是宿主的哪个成员,node_to_value通过成员指针偏移从节点找回宿主对象;IntrusiveListStorage持有m_first/m_last,实现双向链表的头尾锚点。
正是"成员指针作为模板参数"这一设计,让Region::ListInMemoryManager与Region::ListInVMObject虽然是同一种节点类型的不同成员,却能静态地区分开来。IntrusiveList在用户态的少数特定场景也会被使用,但主体仍是内核代码。
类型大小静态断言:AK::AssertSize
为什么普通 static_assert 不够好
static_assert(sizeof(T) == N)失败时,编译器错误信息里只有你写下的期望值,没有类型的真实大小,开发者还得手动打印或用调试器查。SerenityOS 为此在 AK/StdLibExtraDetails.h 中引入了AssertSize:
template<typename T, unsigned ExpectedSize, unsigned ActualSize> struct __AssertSize : TrueType { static_assert(ActualSize == ExpectedSize, "actual size does not match expected size"); consteval explicit operator bool() const { return value; } }; template<typename T, unsigned ExpectedSize> using AssertSize = __AssertSize<T, ExpectedSize, sizeof(T)>;技巧在于:真实大小sizeof(T)被放进了模板参数。当断言失败时,编译器会把ExpectedSize与ActualSize两个具体数值一并打印在错误信息中(作为模板实参的一部分),省去手工排查。consteval explicit operator bool()让它可以写进static_assert(AssertSize<T, N>());。
使用示例与仓库中的真实调用
#include <AK/StdLibExtras.h> struct Empty { }; static_assert(AssertSize<Empty, 1>());仓库中已有大量实际调用,例如 AK/FloatingPoint.h:
static_assert(AssertSize<f128, 16>()); static_assert(AssertSize<FloatExtractor<f128>, sizeof(f128)>()); static_assert(AssertSize<FloatExtractor<f80>, sizeof(f80)>()); static_assert(AssertSize<FloatExtractor<f64>, sizeof(f64)>()); static_assert(AssertSize<FloatExtractor<f32>, sizeof(f32)>());这些断言保障了位级浮点提取器(FloatExtractor)的布局与预期一致——任何结构体布局意外变化都会在编译期被捕捉,且错误信息直接给出"实际多少字节"。
字符串视图字面量:operator""sv
零运行时开销的 StringView 构造
AK::StringView支持 C++17 引入的sv字符串字面量运算符,定义于 AK/StringView.h:
[[nodiscard]] ALWAYS_INLINE consteval AK::StringView operator""sv(char const* cstring, size_t length) { return AK::StringView(cstring, length); }consteval保证编译期求值:构造StringView时不需要在运行时用strlen扫描长度;- 字面量本身位于二进制文件的只读数据段,
StringView只是"指针 + 长度"的轻量视图,不拥有也不复制数据。
用法与测试
#include <AK/String.h> #include <AK/StringView.h> #include <LibTest/TestCase.h> TEST_CASE(string_view_literal_operator) { StringView literal_view = "foo"sv; String test_string = "foo"; EXPECT_EQ(literal_view.length(), test_string.length()); EXPECT_EQ(literal_view, test_string); }示例中的TEST_CASE/EXPECT_EQ宏来自 Userland/Libraries/LibTest 测试框架,说明了该模式在单元测试中的标准用法:"foo"sv与运行时构造的String在长度与内容上完全相等,而前者无需动态分配。
Source Location:免预处理器宏的调用点捕获
与 C++20 std::source_location 的关系
C++20 的std::source_location允许以默认参数捕获调用者的文件 / 行号 / 函数名。AK/SourceLocation.h 提供了同名等价实现,内部直接基于__builtin_FILE()、__builtin_LINE()、__builtin_FUNCTION()(见SourceLocation::current()),同时为AK::SourceLocation特化了AK::Formatter,使其能直接以"[\x1b[34m{}\x1b[0m @ {}:{}]"的带色格式输出(函数名 @ 文件名:行号)。
#include <AK/SourceLocation.h> #include <AK/StringView.h> static StringView example_fn(const SourceLocation& loc = SourceLocation::current()) { return loc.function_name(); } int main(int, char**) { return example_fn().length(); }要点是SourceLocation::current()作为默认实参,在调用点实例化,因此example_fn()内部拿到的loc是调用者那一行的位置信息,而不是example_fn自己的定义位置。这已成为 SerenityOS 添加调试插桩的惯用方式——不再需要把__FILE__/__LINE__塞进宏。
按调试宏裁剪的别名技巧
如果只想在某个调试宏开启时才真正捕获位置信息,文档建议不要在带SourceLocation参数的函数里到处加#ifdef,而是定义两个同名的类型别名/空类型:
#if LOCK_DEBUG # include <AK/SourceLocation.h> #endif #if LOCK_DEBUG using LockLocation = SourceLocation; #else struct LockLocation { static constexpr LockLocation current() { return {}; } private: constexpr LockLocation() = default; }; #endif当LOCK_DEBUG关闭时,LockLocation退化为一个可被编译器完全优化掉的空结构体,current()返回空对象,所有调用点代码照常编译,零运行时开销;开启时则自动获得真实的调用位置。这样既保留了干净的调用方代码,又实现了"按配置裁剪调试信息"。
四种连续存储容器选型:type[]、Array、Vector、FixedArray
共同的视图搭档:Span<type>
在讨论四种"拥有数据"的容器之前,先明确Span<type>的角色:它不拥有数据,只是对他人数据的视图。上述四种容器都能提供Span来观察全部或部分数据。凡是"不关心容器种类、不需要调整大小"的 API,优先以Span作为参数类型——这与 AK/Span.h 中的定义一致,也是 SerenityOS 中大量函数签名采用Span的原因。
四者的定位对比
| 容器 | 存储方式 | 是否可动态扩容 | 典型用途 |
|---|---|---|---|
C 风格数组type[] | 内联(inline) | 否 | 仅用于实现其他集合或极特殊情况;一般不鼓励直接使用,指针+长度风格同样被劝阻 |
Array<type, N> | 内联 | 否 | 编译期已知大小的固定数组,std::array的等价物,零动态分配 |
Vector<type> | 内联 + 堆上外置存储 | 是 | 绝大多数"列表"场景的默认选择,见 AK/Vector.h |
FixedArray<type> | 堆上外置存储 | 否 | 运行期才知道大小、但初始化后不再变化;仅在构造/析构时分配与释放 |
逐条展开:
- C 风格数组:文档明确给出"一般不建议"的立场,包括以"指针 + 长度"方式传递数组。它们仅作为其他集合的内部实现或特殊场景的兜底存在;
Array:std::array的薄封装,大小是模板参数的一部分,数据内联分配,从不做动态分配,适合编译期常量大小的场景;Vector:std::vector的等价物,可动态调整大小,是基本列表需求的默认容器。其第二模板参数是可选的内联容量(inline capacity):数据优先放在内联缓冲中,一旦超出内联容量就自动切换到堆上的外置存储,并在扩容/缩容时自动搬移;FixedArray:本质是"运行期定大小"的Array。不能像Vector那样扩容,但非常适合"大小在编译期未知、初始化后不再变化"的场景;它保证除了构造函数与析构函数之外不做任何分配/释放——这对性能敏感路径与内核式资源纪律很有价值。
选型决策建议
- 尺寸编译期已知且固定 →
Array<type, N>; - 尺寸运行期才知道、但定下来就不变 →
FixedArray<type>; - 需要随时增删、动态伸缩 →
Vector<type>; - 只是想"看看别人拥有的数据"或作为函数参数 →
Span<type>; - 除非在实现底层集合,否则避免裸
type[]与指针+长度风格。
总结:模式如何共同构成 SerenityOS 的代码气质
把文档中这些模式放在一起,可以看到一条清晰的工程主线:
- 错误即值:
ErrorOr<T>承载一切可失败操作,TRY/MUST让传播与断言各得其所,release_value_but_fixme_should_propagate_errors()负责标记待改进点; - 构造即有效:Fallible 构造器模式(
create静态工厂 + 私有构造函数)确保对象一旦创建就处于一致状态; - 入口即框架:
serenity_main+LibMain把进程样板统一收敛; - OOM 韧性:侵入式链表在必须对 OOM 稳健的环境(内核)中消除插入失败的路径,
Vector的try_ensure_capacity则把 OOM 显式化为可处理错误; - 编译期把关:
AssertSize让布局错误在编译期暴露并给出真实数值,operator""sv与consteval让字符串视图构造零运行时开销,SourceLocation让调试插桩告别预处理器宏; - 容器各司其职:
Array/Vector/FixedArray/Span的选型规则把"内存生命周期"这一最易出错的维度显式化。
对想要为 SerenityOS 贡献代码、或仅仅是想学习"如何在一个大型 C++ 代码库中建立统一且可扩展的错误处理纪律"的开发者而言,Documentation/Patterns.md 是一份值得反复对照的活文档;而 AK、Kernel、Userland 中的源码与 Tests/AK 下的测试则是验证这些模式的最佳教材。
【免费下载链接】serenityThe Serenity Operating System 🐞项目地址: https://gitcode.com/GitHub_Trending/se/serenity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考