C++20:Modules(下):实现一个多模块图像处理工具

📅 2026/7/1 17:30:21
C++20:Modules(下):实现一个多模块图像处理工具
引言通过前面的学习我们掌握了模块的基本概念。这节课我们会一起学习怎样使用 C Modules 来组织实际的项目代码。相信你在动手实战后就能进一步理解应该如何使用 C Modules 和 namespace 来解决现实问题。掌握了基本概念和使用要点之后我们也会站在语言设计者的角度整体讨论一下 C Modules 能解决什么问题不能解决什么问题。好话不多说我们马上进入今天的学习。课程配套代码点击这里获取https://github.com/samblg/cpp20-plus-indepth。面向图像的对象存储系统要写的实例是一个常见的面向图像的对象存储系统核心功能是将图片存储在本地空间用户通过 HTTP 请求获取相应的图片而这个系统的特点是用户除了可以获取原始图片还可以通过参数获取经过处理的图片比如图像缩放、图像压缩等。想实现这样的功能需要哪些模块呢我们画一张系统的模块架构图可以清晰地看到系统模块以及模块内部分区的依赖关系。首先我们需要创建项目项目包括 5 个子模块分别是 app、cache、command、image、network其中 app 是业务应用模块cache 是本地缓存模块command 是命令行解析模块image 是图像处理模块network 是网络服务模块每个模块分别创建对应的目录存储模块内的源代码。对于这样一个多模块的项目我们的目的是学习如何灵活使用 C Modules 来组织程序中的几个模块所以接下来我们不对这个项目做具体实现主要看看如何编写接口。命令行解析模块command/argument.cpp 模块定义了 Argument 类用于描述命令行参数。第 1 行通过 export module 声明了这个文件属于 ips.command 模块的 argument 分区并用 export 表明这是一个模块接口编译单元。第 4 行定义了 Arugment 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.command:argument; import string; namespace ips::command { export class Argument { public: Argument( const std::string flag, const std::string name, const std::string helpMessage , bool required false ) : _flag(flag), _name(name), _helpMessage(helpMessage), _required(required) {} const std::string getFlag() const { return _flag; } void setFlag(const std::string flag) { _flag flag; } const std::string getHelpMessage() const { return _helpMessage; } void setHelpMessage(const std::string helpMessage) { _helpMessage helpMessage; } bool isRequired() const { return _required; } void setRequired(bool required) { _required required; } private: std::string _flag; std::string _name; std::string _helpMessage; bool _required; }; }command/parser.cpp本模块定义了 Parser 类用于解析命令行参数。第 1 行通过 export module 声明了这个文件属于 ips.command 模块的 parser 分区并用 export 表明这是一个模块接口编译单元。第 11 行定义了 Parser 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.command:parser; import string; import map; import vector; import functional; import :argument; namespace ips::command { export class Parser { public: Parser addArgument(const Argument argument) { _arguments.push_back(argument); return *this; } std::mapstd::string, std::string parseArgs() { return _parsedArgs; } std::string getNamedArgument(const std::string name) { std::string value _parsedArgs[name]; value; } template class T T getNamedArgument(const std::string name, std::functionT(const std::string) converter) { std::string value _parsedArgs[name]; return converter(value); } private: std::vectorArgument _arguments; std::mapstd::string, std::string _parsedArgs; }; }command/command.cpp 模块是整个 ips.command 模块的外部接口。在第 3 行和第 4 行通过 export import 导入并重新导出了:parser 和:argument 分区这样我们可以将一个模块下的不同类分别在不同分区中实现并在主模块接口单元中导入再导出便于我们维护代码。export module ips.command; export import :parser; export import :argument;网络服务接口模块network/request.cpp 模块定义了 Request 类用于描述 HTTP 网络请求。第 1 行通过 export module 声明了这个文件属于 ips.network 模块的 request 分区并用 export 表明这是一个模块接口编译单元。第 7 行定义了 Request 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.network:request; import string; import map; namespace ips::network { export class Request { public: Request() {} void setPath(const std::string path) { _path path; } const std::string getPath() { return _path; } void setQuery(const std::mapstd::string, std::string query) { _query query; } const std::mapstd::string, std::string getQuery() { return _query; } std::string getBody() { return ; } private: std::string _path; std::mapstd::string, std::string _query; }; }export module ips.network:response; import string; import iostream; namespace ips::network { export class Response { public: Response() {} void send(const std::string data) { std::cout Sent data data.size() std::endl; } }; } }network/connection.cpp 模块定义了 Connection 类用于描述 HTTP 网络连接。第 1 行通过 export module 声明了这个文件属于 ips.network 模块的 connection 分区并用 export 表明这是一个模块接口编译单元。第 11 行使用 using 定义了类型别名 RequestPtr表示请求对象指针并通过 export 将该符号导出。第 12 行使用 using 定义了类型别名 ResponsePtr表示响应对象指针并通过 export 将该符号导出。第 14 行使用 using 定义了类型别名 OnRequestHandler表示请求处理函数并通过 export 将该符号导出。第 16 行定义了 Connection 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.network:connection; import functional; import memory; import vector; import :request; import :response; namespace ips::network { export using RequestPtr std::shared_ptrRequest; export using ResponsePtr std::shared_ptrResponse; export using OnRequestHandler std::functionvoid(RequestPtr, ResponsePtr); export class Connection { public: Connection() {} void onRequest(OnRequestHandler requestHandler) { _requestHandlers.push_back(requestHandler); } private: std::vectorOnRequestHandler _requestHandlers; }; }network/network.cpp本模块是整个 ips.network 模块的外部接口。导入并导出了 ips.network 下的所有分区。export module ips.network; export import :server; export import :request; export import :response; export import :connection;图像处理模块images/processor.cpp 模块定义了 Processor 类用户实现图像处理。第 1 行通过 export module 声明了这个文件属于 ips.image 模块的 processor 分区并用 export 表明这是一个模块接口编译单元。第 7 行定义了 Processor 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.image:processor; import string; import cstdint; namespace ips::image { export class Processor { public: void setWidth(int32_t width) { _width width; } int32_t getWidth() const { return _width; } void setHeight(int32_t height) { _height height; } int32_t getHeight() const { return _height; } void setQuality(int32_t quality) { _quality quality; } int32_t getQuality() const { return _quality; } void setMode(const std::string mode) { _mode mode; } const std::string getMode() const { return _mode; } std::string processImage(const std::string data) { return ; } private: int32_t _width; int32_t _height; int32_t _quality; std::string _mode; }; }images/image.cpp本模块是整个 ips.image 模块的外部接口。导入并导出了 ips.image 下的所有分区。export module ips.image; export import :processor;本地缓存模块cache/loader.cpp 模块定义了 Loader 类用户实现缓存加载。第 1 行通过 export module 声明了这个文件属于 ips.cache 模块的 loader 分区并用 export 表明这是一个模块接口编译单元。第 6 行定义了 CacheLoader 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.cache:loader; import string; namespace ips::cache { export class CacheLoader { public: CacheLoader(const std::string basePath) : _basePath(basePath) {} bool loadCacheFile(const std::string key, std::string* cacheFileData) { return false; } private: std::string _basePath; }; }cache/cache.cpp 模块是整个 ips.cache 模块的外部接口。导入并导出了 ips.cache 下的所有分区。export module ips.cache; export import :loader;业务应用模块app/app.cpp 这个模块是整个 ips.app 模块的外部接口。由于比较简单只定义了一个 processRequest 函数因此没有定义其他的分区。第 1 行通过 export module 声明了这个文件为 ips.app 模块并用 export 表明这是一个模块接口编译单元。第 23 行定义了 processRequest 类并通过 export 将其标志为外部链接性对其他模块可见。export module ips.app; import string; import map; import ips.network; import ips.image; import ips.cache; namespace ips::app { export void processRequest( ips::cache::CacheLoader* cacheLoader, ips::network::RequestPtr request, ips::network::ResponsePtr response ) { const std::string path request-getPath(); const std::mapstd::string, std::string query request-getQuery(); std::string data request-getBody(); ips::image::Processor imageProcessor; std::string cacheKey path; auto widthIterator query.find(width); if (widthIterator ! query.cend()) { imageProcessor.setWidth(std::stoi(widthIterator-second)); cacheKey width widthIterator-second; } auto heighIterator query.find(height); if (heighIterator ! query.cend()) { imageProcessor.setHeight(std::stoi(heighIterator-second)); cacheKey height heighIterator-second; } auto qualityIterator query.find(quality); if (qualityIterator ! query.cend()) { imageProcessor.setQuality(std::stoi(qualityIterator-second)); cacheKey quality qualityIterator-second; } auto modeIterator query.find(mode); if (modeIterator ! query.cend()) { imageProcessor.setMode(modeIterator-second); cacheKey mode modeIterator-second; } std::string processedImageData; bool hasCache cacheLoader-loadCacheFile(cacheKey, processedImageData); if (hasCache) { response-send(processedImageData); return; } processedImageData imageProcessor.processImage(data); response-send(processedImageData); } }主程序调用main.cpp 是整个程序的调用的模块首先创建命令行解析器对命令行进行解析接着创建 HTTP 服务器和缓存加载器最后注册连接处理函数和请求处理函数并启动监听进入事件循环。第 1 到 3 行通过 import 导入 C 标准库的头文件。第 5-9 行通过 import 导入项目内部的各个模块后面就能使用这些模块内的符号了。import iostream; import string; import functional; import ips.command; import ips.network; import ips.image; import ips.app; import ips.cache; int main() { std::cout Image Processor std::endl; ips::command::Parser parser; parser.addArgument(ips::command::Argument(--host, host)); parser.addArgument(ips::command::Argument(--port, port)); parser.addArgument(ips::command::Argument(--cache, cachePath)); parser.parseArgs(); std::string cachePath parser.getNamedArgument(cachePath); ips::cache::CacheLoader cacheLoader(cachePath); std::string host parser.getNamedArgument(host); int port parser.getNamedArgumentint32_t(port, [](const std::string value)- int32_t { return std::stoi(value); }); ips::network::Server server(host, port); server.onConnection([cacheLoader](ips::network::ConnectionPtr connection) - void { connection-onRequest(std::bind( ips::app::processRequest, cacheLoader, std::placeholders::_1, std::placeholders::_2 )); }); server.startListen(); return 0; }相对于传统的方法我们不需要关心头文件和符号实现的各种细节C Modules 规定我们将接口和实现都组织在通过模块关联的代码文件中虽然灵活性相对较低但在一般的工程实践中这样的代码组织更加合理也能降低模块开发者和使用者的心智负担。深入理解 C Modules掌握了 C Modules 的基础概念也通过实例体会了 C Modules 的用法和好处我们再回过头来站在语言设计者的角度讨论一下 C Modules 中一些深层次问题C Modules 核心语言特性变更到底能为我们带来什么它能解决什么同时又不能解决什么问题Modules 能解决什么首先需要理解 Modules 到底帮助我们解决了什么问题在 C Modules 的基本概念介绍中我们说过了 Modules 解决的是符号可见性问题。在传统的 C 解决方案中处理符号可见性需要我们充分理解 C 的“编译 - 链接”原理甚至很多的实现技术细节。由于 C 中的各个编译单元需要独立编译同时在链接中通过检索符号填补缺失的符号我们不仅要在实现符号的编译单元中编写实现还要在引用的编译单元中通过书写符号声明来告知编译器这些符号会在链接过程中存在。所以我们需要通过头文件来为模块调用的编译单元提供这些必要的前置符号声明。这就出现了一个问题模块之间的符号引用因为这种“编译 - 链接”机制被硬生生拆分成两个阶段。哪怕能通过编译也可能在链接时产生错误而这种错误也很难被编译器和 IDE 在编译阶段提前侦测到更多的问题将链接时才暴露出来。只有经验丰富的 C 工程师在了解基本的“编译 - 链接”原理后才能熟练排查这些因为两阶段的不一致性导致的链接问题并找到方法尝试解决。因此传统的符号可见性解决方案对 C 初学者不友好。新的 C Modules 方法本质上抛弃了“头文件”这种 C/C 中的重要组成部分将头文件转换成了模块接口文件也为 C 提供了一种在编译期检测声明实现不一致的方法也为 IDE 的智能提示提供了新的可靠方法。另外C Modules 也部分抛弃了 C/C 原本通过简单的文本处理为编译单元引入声明的方式使得编译器可以为模块编译单元生成二进制的编译缓存为加快编译过程提供了一个新的契机。所以简单来说C Modules 给我们带来了一种更为现代化的更简单的符号可见性控制方案同时又能加快编译速度。Modules 不能解决什么那么Modules 不能解决什么呢第一Modules 不能解决符号命名冲突的问题。在实例中你会发现我们在代码中同时使用了命名空间和 Modules通过 Modules 来控制符号可见性然后使用命名空间来避免符号命名冲突。符号命名冲突可能因为两个不同的模块使用了相同名称的函数、类、全局变量等并将其 export 出来如果这两个模块同时 import 到同一个编译单元中就会出现问题因为编译器并不知道我们使用的是哪个模块中的符号。因此在不同的模块中我们仍习惯使用不同的命名空间确保一个编译单元导入两个模块的时候不会出现模块冲突。这就是我们一直所说的模块只解决符号可见性问题而命名冲突问题依然需要通过 namespace 解决这就是 Modules 和 namespace 是保持正交设计的。第二目前 Modules 不能用来解决二进制库分发的问题。现阶段编译器在编译模块编译单元的过程中会为每个模块编译单元生成对应的二进制缓存无论是模块接口单元还是模块实现单元都会生成甚至通过 import 导入 iostream 这种标准库也会为 iostream 生成二进制缓存。这些二进制缓存不仅包括编译后生成的中间码、机器码还包括源代码之类的 meta 数据这样其他编译模块在通过 import 导入模块的时候编译器将会直接读取二进制缓存不需要在预处理阶段做文本替换再在各个编译单元的编译过程中进行编译可以加快编译速度。但我们要注意的是在生成的静态链接库或者动态链接库中标准并没有定义需将这些缓存中的 meta 数据加入到库中。因此目前通过 Modules 编写的代码在进行二进制分发时会面临很多问题只有 Visual C自 Visual Studio 2022 起通过标头单元来实现通过 import 导入可以读取编译器自动生成的二进制缓存 ifc 文件ifc 文件是 VC 编译单元生成的标头单元二进制缓存文件格式其他的编译器只支持通过源代码分发的方式来使用 import。第三STL 内存布局问题。在使用 STL 的过程中我们会遇到 ABI 与内存布局的很多问题。比如一些 SIMD 的计算场景需要调用 CPU 的加速指令而这些加速指令对数据的内存地址对齐都有严格要求因此我们可能需要可以预期的内存对齐结果。但是实际上内存对齐会受到编译器和体系结构影响如下图。自己管理内存可以产生我们预期的内存对齐效果但如果使用 STL则需要依赖编译器和体系结构可能无法产生我们所预期的内存对齐。这只是 STL 内存布局问题的冰山一角。现阶段的 Modules 暂时无法解决各编译器之间 ABI尤其是使用模板后的问题。目前编译和链接还是会依赖编译器和体系结构定义的 ABI所以如果 A 编译器生成的二进制符号格式不同于 B 编译器的二进制符号格式那么 B 编译器也就无法使用 A 编译器生成的库无论是动态链接库还是静态链接库更不用说不同编译器生成的二进制缓存文件了。我们了解 C Modules 能做什么不能做什么就知道该在什么场景如何使用 C Modules 了。总的来说目前 C Modules 的支持还不够完善不同的 C 编译器对现代 C 新标准的支持情况各不相同这里也给出当下主流编译器对新特性的支持情况。随着编译器支持越来越成熟相信会带来更多的编译性能提升就像编译器对头文件支持的性能提升一样。总结自 C20 标准开始C Modules 给我们带来了一种更为现代化的、更简单的符号可见性控制方案同时又能加快编译速度。总体上看C Modules 很好地提供了解决符号可见性问题的方案。在传统的 C 解决方案中处理符号可见性需要在充分理解 C 的“编译 - 链接”原理甚至很多实现技术细节而现在我们可以更简单地掌控符号的可见性并在不牺牲编译性能的情况下使用 C 进行编码。但是目前 C Modules 并不是完美的。不能解决符号命名冲突的问题。不能用来解决二进制库分发的问题。现阶段的 Modules 暂时无法解决各编译器之间 ABI尤其是使用模板之后带来的问题。随着现代 C 标准化进程的稳步推进我们期待着这些问题能够在未来得到标准和编译器的统一支持。C Modules 已经逐渐成为解决编译性能和符号隔离的银弹但我们让这枚子弹“再飞一会儿”。