预处理指令 #embed 来了!C++23 如何优雅地处理外部文件
一、背景
在 C/C++ 的世界里,我们经常需要处理外部文件,比如配置文件、图像、音频、字体等等。这些文件就像我们程序的“原料”,为程序提供各种数据和资源。在 C++23 之前,通常采用以下几种方式来“嵌入”这些外部文件:
-
运行时加载: 使用标准库的
fopen
、fread
、fclose
等函数,在程序运行时打开文件,读取数据到内存中。缺点是 需要编写大量的打开、读取、关闭文件的代码,而且在程序运行时进行的文件操作会增加程序的运行时间,带来性能损耗。 -
使用外部工具生成头文件/代码: 使用诸如
xxd
或自定义脚本之类的工具将文件转换为 C/C++ 代码(通常是字符数组或字节数组),然后将其包含到程序中。 缺点是 需要额外的工具和步骤来处理文件,每次修改文件都需要重新生成头文件或代码,容易造成不一致,增加开发流程的复杂性。而且生成的代码可能会很长,导致代码体积增大。 -
硬编码: 直接将文件内容以字符串或数组的形式硬编码到程序中。缺点也很明显,修改文件需要修改源代码并重新编译,维护成本高。而且 难以适应不同的需求。
很明显,上述这些方式都存在一些缺陷:要么代码冗长、要么步骤繁琐、要么维护困难。这让我们不禁思考:有没有一种更优雅、更简洁的方式来处理这些外部文件,能让我们像嵌入代码一样轻松地嵌入文件?
答案是肯定的! C++23 标准引入了预处理指令 #embed
,它将为我们解决这些痛点提供一种全新的解决方案,它可以让你像嵌入头文件一样轻松地嵌入外部文件,代码更简洁,维护更方便。
#embed
就像一个神奇的“传送门”,可以将外部文件的内容直接嵌入到 C++ 代码中。这就像你在厨房里有了一个“一键嵌入”的按钮,可以直接把食材(外部文件)放到你的菜肴(程序)中,无需再进行繁琐的处理。
简单来说,#embed
允许在编译时将指定文件的内容直接插入到源代码中,并将其转化为一个字符数组或字节流。#embed
的出现,不仅仅是简化了代码,更重要的是提升了我们的开发体验。
既然 #embed
如此强大和重要,那么我们如何才能熟练地使用它呢?接着往下看!
二、理解 #embed 指令
2.1、基本语法
掌握一门新工具,首先要了解它的基本用法。#embed
指令虽然强大,但它的基本语法却非常简洁明了。
#embed
指令的基本语法形式如下:
#embed <header-name> [optional-specifiers]
-
#embed
: 这是指令的关键字,它告诉编译器这是一个#embed
指令,需要按照#embed
的规则进行处理。这就像一个特殊的“标签”,告诉编译器这里需要“嵌入”一个文件。 -
<header-name>
: 这是embed
指令的核心部分,它指定了要嵌入的文件路径,也是一个头文件名,它必须被""
或者<>
包裹。注意,这里的文件路径不是一个字符串常量,而是一个“包含路径”,和#include
指令所使用的路径类似。 -
[optional-specifiers]
: 这是可选的修饰符列表,用于更精细地控制#embed
的行为。
<header-name>
,顾名思义,它的形式看起来像一个头文件。但实际上,它表示的是要嵌入的文件路径。编译器在处理 #embed
指令时,会按照以下方式查找指定的文件:
-
包含路径搜索: 编译器会根据你的编译配置中的包含路径列表(include paths)来查找文件。
- 双引号 (
""
): 使用双引号""
包裹<header-name>
,编译器会首先在当前源文件所在目录下查找文件,如果没有找到,才会按照编译配置的包含路径进行查找。 - 尖括号 (
<>
): 使用尖括号<>
包裹<header-name>
,编译器会直接按照编译配置的包含路径进行查找,不会先在当前源文件所在目录查找。
- 双引号 (
-
相对路径和绝对路径:
<header-name>
中可以使用相对路径或者绝对路径。- 相对路径: 相对于当前源文件所在目录的路径,比如
"data/config.json"
。 - 绝对路径: 从根目录开始的完整路径,例如
"/home/user/project/data/image.png"
。
- 相对路径: 相对于当前源文件所在目录的路径,比如
举个例子,假设项目目录结构如下:
project/
├── src/
│ └── main.cpp
├── data/
│ └── config.json
那么,在 main.cpp
中,可以使用以下方式来嵌入 config.json
:
// 使用相对路径和双引号
#embed "data/config.json"
// 使用相对路径和尖括号(需要在编译配置中添加 `project/data` 为包含路径)
#embed <config.json>
//使用绝对路径(不推荐,因为依赖性太强)
//#embed "/path/to/project/data/config.json"
注意: #embed
指令只能用来嵌入文件,而不能用来嵌入目录。
2.2、可选修饰符
#embed
指令除了最基本的 <header-name>
之外,还可以使用可选的修饰符,这些修饰符给 embed
指令增加了一些“特殊能力”,能够更灵活地控制嵌入文件的行为。
2.2.1、limit
作用:限制嵌入文件内容的最大字节数。 这可以防止嵌入过大的文件,导致编译时间过长或者编译错误。
语法:
#embed <header-name> limit(expression)
其中 expression
是一个编译时常量表达式,表示允许嵌入的最大字节数。
示例: 假设有一个配置文件 config.ini
,希望确保它不会超过 1024 字节。可以这样使用 limit
修饰符:
// 限制最大嵌入字节数为 1024
#embed "config.ini" limit(1024)
// 如果 config.ini 的实际大小不超过 1024 字节,那么它将完整地嵌入到代码中。
// 如果 config.ini 的实际大小超过 1024 字节,那么编译器将只嵌入文件的前 1024 字节。
// 超过部分会被截断,不会报错,只会丢失信息。
// 如果 expression 的值为 0,则表示不允许嵌入任何数据,这相当于禁止嵌入这个文件。
// expression 必须是编译时常量,必须在编译时就能够确定值。
为什么需要 limit
?
- 防止嵌入过大的二进制文件(如图像、音频)导致编译时间过长、内存占用过大或者编译失败。
- 有时并不需要嵌入整个文件,使用
limit
可以避免嵌入不必要的数据,减少错误的可能性。
2.2.2、if_empty
作用:指定当文件为空时返回的值。 if_empty
修饰符的作用就像一个“备用方案”,当要嵌入的文件为空时,它会返回预先设置的值,而不是空字符数组或字节流。这可以避免程序因为空文件而发生意外的错误。
语法:
#embed <header-name> if_empty(expression)
其中 expression
是一个编译时常量表达式,表示当文件为空时返回的值。这个 expression
的类型必须能够转换为一个字符数组或者字节流。返回值类型取决于 expression
的类型,可以是字符串,字符数组,数字等等。
示例: 假设有一个数据文件 data.txt
,但这个文件可能为空。为了避免程序崩溃,可以这样使用 if_empty
修饰符:
// 如果 data.txt 为空,则返回 "default data"
#embed "data.txt" if_empty("default data")
// 如果 data.txt 为空,则返回一个空字符串
#embed "data.txt" if_empty("")
// 如果 data.txt 为空,则返回一个包含单个 0 值的数组
#embed "data.txt" if_empty({0})
注意:expression
必须是编译时常量,并且必须能被转换为 char
数组或 unsigned char
数组。
为什么需要 if_empty
?
空文件可能会导致程序运行时出现意料之外的错误,使用 if_empty
可以提供一个默认值,避免崩溃。 在文件不存在或为空的情况下也能正常运行。
2.2.3、prefix
作用:在嵌入文件内容之前添加前缀。
prefix
修饰符的作用就像一个“开头”,它可以在嵌入的文件内容之前添加指定的前缀字符串或字符序列。这可以用来添加一些版权信息、标识符或其他需要添加的固定文本。
语法:
#embed <header-name> prefix(expression)
其中 expression
的类型必须能够转换为一个字符数组或者字节流。
示例:
// 在 code.cpp 内容之前添加版权声明
#embed "code.cpp" prefix("// Copyright (c) 2023 My Company")
为什么需要 prefix
?
- 添加版权信息: 方便在嵌入的文件内容之前添加版权声明或其他法律声明。
- 添加标识符: 为嵌入的文件内容添加唯一的标识符,方便程序区分。
2.2.4、suffix(expression)
作用:在嵌入文件内容之后添加后缀。
suffix
修饰符的作用就像一个“结尾”,它可以在嵌入的文件内容之后添加指定的后缀字符串或字符序列。这可以用来添加一些文件结束符、分隔符或其他需要添加的固定文本。
语法:
#embed <header-name> suffix(expression)
示例:
// 在 config.json 内容之后添加文件结束符 \0
#embed "config.json" suffix("\0")
为什么需要 suffix
?
- 添加文件结束符: 方便在嵌入的文本文件内容后添加
\0
,避免字符串访问时越界。 - 添加分隔符: 在嵌入的多个文件内容之间添加分隔符。
2.3、#embed 的工作原理
#embed
指令并不是在程序运行时才去读取文件,而是在编译的预处理阶段就完成了文件内容的读取和嵌入。
C++ 代码的编译过程可以简单分为几个阶段:预处理、编译、汇编和链接。 #embed
指令的工作就在预处理阶段完成。预处理器会扫描源代码,并执行一些特定的操作,比如:
- 预处理器会删除代码中的注释,让编译器只关注代码本身。
- 预处理器会替换代码中定义的宏。
- 预处理器会展开
#include
指令,将头文件的内容复制到当前源文件中。 #embed
处理: 这也是#embed
指令发挥作用的地方。
当预处理器遇到 #embed
指令时,它会执行以下步骤:
- 解析
#embed
指令,提取出<header-name>
和可选的修饰符。 - 根据
<header-name>
中指定的路径,以及编译配置中的包含路径,查找要嵌入的文件。 - 如果文件存在,预处理器会读取文件的全部内容或者部分内容(如果使用了
limit
修饰符)。 - 按照指令中的修饰符对文件内容进行处理。例如,添加
prefix
、suffix
,或者在文件为空时替换为if_empty
指定的值。 - 将处理后的文件内容转换为一个字符数组(
char[]
)或者字节流(unsigned char[]
),具体取决于文件内容和上下文。这个数组或者字节流会在编译时被当作常量数据存储在程序的数据段中。 - 将
#embed
指令替换成转换后的字符数组或字节流,从而完成嵌入操作。
例如:
#embed "config.json" limit(1024) prefix("{") suffix("}")
在预处理阶段,如果 config.json
的内容是 "name": "Lion Long", "version": 1
, 预处理器会将上述指令替换成类似下面的代码:
char _embedded_data[] = "{ \"name\": \"Lion Long\", \"version\": 1 }";
这样,编译后的程序就直接包含了 config.json
的内容,无需在运行时再去读取文件。
流程图:
三、#embed 的应用场景
3.1 嵌入文本文件
#embed
可以直接嵌入各种类型的文本文件,例如配置文件、SQL 查询语句、HTML 代码片段等等。
(1)配置文件 是应用程序中不可或缺的一部分,它允许在不修改代码的情况下调整程序的行为。传统的读取配置文件的做法通常需要在运行时读取文件内容,而 #embed
可以直接将配置文件嵌入到程序中,避免了文件读取的开销,并且使得程序的部署更加简单。
示例:嵌入 JSON 配置文件 config.json
。
{
"appName": "MyAwesomeApp",
"version": "1.0.0",
"debugMode": true,
"serverAddress": "127.0.0.1",
"serverPort": 8080
}
使用 #embed
将其嵌入到 C++ 代码中:
#include <iostream>
#include <string_view>
#include <nlohmann/json.hpp> // 使用 nlohmann/json 库解析 JSON
// 嵌入 config.json 文件
#embed "config.json"
int main() {
// 使用 string_view 避免复制
std::string_view config_str((const char*)_embedded_data, sizeof(_embedded_data));
// 解析 JSON 数据
auto config_json = nlohmann::json::parse(config_str);
// 输出配置信息
std::cout << "App Name: " << config_json["appName"] << std::endl;
std::cout << "Version: " << config_json["version"] << std::endl;
std::cout << "Debug Mode: " << (config_json["debugMode"].get<bool>() ? "true" : "false") << std::endl;
std::cout << "Server Address: " << config_json["serverAddress"] << std::endl;
std::cout << "Server Port: " << config_json["serverPort"] << std::endl;
return 0;
}
其他类型的配置文件, 比如 INI 文件、XML 文件 也是一样的。优势:
- 配置文件和程序捆绑在一起,减少了部署时需要额外处理的文件。
- 避免了运行时文件读取的开销,程序启动更快。
(2)使用 #embed
可以将 SQL 查询语句存储在一个独立的文件中,并在需要的时候将其嵌入到代码中。
示例:嵌入 SQL 查询语句 user_query.sql
。
SELECT id, name, email FROM users WHERE status = 'active';
使用 #embed
将其嵌入到 C++ 代码中:
#include <iostream>
#include <string_view>
#include <sqlite3.h> // 假设使用 sqlite3 库
// 嵌入 user_query.sql 文件
#embed "user_query.sql"
int main() {
sqlite3 *db;
int rc = sqlite3_open("mydatabase.db", &db);
if (rc) {
std::cerr << "Can't open database: " << sqlite3_errmsg(db) << std::endl;
return 1;
}
// 使用 string_view 避免复制
std::string_view query_str((const char*)_embedded_data, sizeof(_embedded_data));
sqlite3_stmt *stmt;
rc = sqlite3_prepare_v2(db, query_str.data(), query_str.size(), &stmt, NULL);
if (rc != SQLITE_OK) {
std::cerr << "Failed to prepare SQL: " << sqlite3_errmsg(db) << std::endl;
sqlite3_close(db);
return 1;
}
// 执行查询
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
const unsigned char* name = sqlite3_column_text(stmt, 1);
const unsigned char* email = sqlite3_column_text(stmt, 2);
std::cout << "ID: " << id << ", Name: " << name << ", Email: " << email << std::endl;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
return 0;
}
优势: 将 SQL 查询语句从 C++ 代码中分离出来,而且SQL 查询语句可以被多个 C++ 文件复用。最重要的是修改 SQL 查询语句时,只需要修改 SQL 文件,不需要修改 C++ 代码。
同样的,HTML 代码片段等等也可以嵌入到程序中,从而简化代码结构、提高代码的可维护性和效率。
3.2 嵌入二进制文件
#embed
指令不仅可以嵌入文本文件,还可以嵌入各种类型的二进制文件,例如图像、音频和字体文件等。
在 GUI 应用程序或游戏中,图像数据是必不可少的。使用 #embed
可以直接将图像文件(例如图标、Logo 等)嵌入到程序中。
示例:嵌入 PNG 图像数据 logo.png
。
#include <iostream>
#include <vector>
#include <fstream>
// 嵌入 logo.png 文件
#embed "logo.png"
int main() {
// 使用 std::vector 存储嵌入的字节数据
std::vector<unsigned char> logo_data((unsigned char*)_embedded_data, (unsigned char*)_embedded_data + sizeof(_embedded_data));
// 打印嵌入的图片数据的字节大小
std::cout << "Embedded logo.png data size: " << logo_data.size() << " bytes" << std::endl;
//将嵌入的数据保存到新文件 (仅做演示)
std::ofstream outfile("output_logo.png", std::ios::binary);
outfile.write((const char*)logo_data.data(), logo_data.size());
outfile.close();
std::cout << "Embedded logo data was also saved to output_logo.png for demonstratin." << std::endl;
return 0;
}
与图像数据类似,音频数据也是多媒体应用程序中不可或缺的一部分。使用 #embed
可以直接将音频文件(例如背景音乐、音效等)嵌入到程序中,方便程序进行音频播放。
示例:嵌入 WAV 音频数据 background.wav
。
#include <iostream>
#include <vector>
#include <fstream>
// 嵌入 background.wav 文件
#embed "background.wav"
int main() {
// 使用 std::vector 存储嵌入的字节数据
std::vector<unsigned char> audio_data((unsigned char*)_embedded_data, (unsigned char*)_embedded_data + sizeof(_embedded_data));
// 打印嵌入的音频数据的字节大小,你可以将此数据传递给音频库
std::cout << "Embedded background.wav data size: " << audio_data.size() << " bytes" << std::endl;
// 示例:将嵌入的数据保存到新文件 (仅做演示)
std::ofstream outfile("output_audio.wav", std::ios::binary);
outfile.write((const char*)audio_data.data(), audio_data.size());
outfile.close();
std::cout << "Embedded audio data was also saved to output_audio.wav for demonstration." << std::endl;
// 在实际应用中,你需要使用音频库 (如 SDL_mixer, FMOD, OpenAL 等) 加载和播放这些数据。
return 0;
}
另外,#embed
指令同样可以用在数据常量初始化和微控制器编程中。它可以用于初始化查找表、数据数组、嵌入固件代码、嵌入配置信息等。
四、#embed 的优势与局限
优势:
-
大大简化了代码,避免了繁琐的文件操作。
-
将资源以文件的形式存储,而不是直接硬编码在代码中,使得资源修改和维护更加方便。当需要更新资源时,只需要修改对应的文件,而无需修改代码。
-
在编译时资源文件被读取并嵌入到可执行文件中的。这种方式相比于运行时读取文件,性能更高,避免了运行时文件读取带来的开销。
但也存在一些局限性:
-
#embed
是 C++23 标准引入的新特性,并非所有编译器都支持 C++23 标准。需要使用支持 C++23 标准的编译器(如 GCC 13 及以上版本,Clang 16 及以上版本,MSVC 2022 17.5 及以上版本)才能使用#embed
指令。 -
文件大小限制: 编译器可能会对嵌入的文件大小有限制,具体限制取决于编译器和操作系统。如果嵌入的文件过大,可能会导致编译失败或编译时间过长。建议将大型资源拆分为多个较小的文件嵌入。
-
#embed
生成的数组是const
的,这意味着嵌入的数据是只读的,不能在运行时进行修改。
五、总结
#embed
预处理指令作为 C++23 标准中引入的一项重要特性,为 C++ 开发者带来了一种全新的、便捷的嵌入文件内容的方式。#embed
在很多场景下都能发挥重要作用,例如:
- 将配置文件嵌入到程序中,简化配置读取流程。
- 嵌入静态查找表、数据数组等,提高数据访问效率。
- 将固件代码片段嵌入到嵌入式系统中。
- 嵌入 Shader 代码,方便图形渲染。
#embed
指令不仅是 C++23 标准的一个重要补充,也代表了 C++ 语言在资源管理方面的新方向。它的出现不仅简化了开发流程,也提高了代码的质量和性能。
参考:
- P1040R2: embed — A facility for embedding resources in C++ code
- Binary resource inclusion (since C23)