前言
yaml-cpp是社区里最常用的 C++ YAML 解析与生成库,作者是 Jesse Beder,托管在 GitHub 上。这里要先纠正一个常见前提错误:它完全不是 C++ 标准库的一部分,标准库至今没有任何 YAML 设施。所以"用 yaml-cpp"意味着你要额外引入一个第三方依赖——安装(vcpkg、Conan、系统包管理器或源码 submodule 都行)、写find_package、链接-lyaml-cpp,一样都不能少。只把源码文件复制进项目是不会通过编译的。
第二个容易被忽视的点是版本漂移。yaml-cpp 在 0.6、0.7、0.8 之间有过若干次破坏性调整:CMake 导出的目标名从早期的yaml-cpp变成带命名空间的yaml-cpp::yaml-cpp,Emitter与Node的部分成员行为也有变化。本文的代码以yaml-cpp 0.7 及之后的公开 API 为准,C++17、GCC 13 / Clang 17 / MSVC 19.3x都能编过;如果你用的是 0.6 或更早的版本,请对着自己版本的官方文档核对一遍。
本文覆盖三条主线:把 YAML 读成 C++ 结构(含类型检查与异常处理)、用Node与Emitter生成 YAML、以及给自己的结构体做YAML::convert特化。最后给出 6 个真会踩的坑。
一、读取:LoadFile、节点类型与安全取值
假设有一份配置server.yaml:
host: 127.0.0.1 port: 8080 tls: false plugins: - auth - access_log limits: max_conn: 10000 timeout_ms: 3000读取入口有三个:YAML::Load(const std::string&)从字符串解析,YAML::LoadFile(const std::string&)从文件解析,YAML::LoadAll/YAML::LoadAllFromFile解析多文档(用---分隔)并返回std::vector<YAML::Node>。它们返回的都是YAML::Node——一个引用语义的句柄,拷贝Node不会深拷贝底层数据,改变其中一个副本会影响另一个;需要深拷贝时用YAML::Clone(node)。
Node的类型用NodeType表示,判断函数是最常用的 API:
| 判断函数 | 为真的含义 | 典型用途 |
|---|---|---|
IsScalar() | 标量(字符串/数字/布尔都算) | 取值前的前置检查 |
IsSequence() | 序列,可size()、可范围 for | 读列表 |
IsMap() | 映射,可按下标取子节点 | 读配置表 |
IsNull() | 显式 null(key:后面空着) | 区分"没写"和"写成 null" |
IsDefined() | 节点存在 | 判断 key 是否真的出现 |
IsSequence()之外的Type() | 返回YAML::NodeType::Undefined等枚举 | 需要精确分支时 |
一个完整的解析函数如下。注意每个取值前都做了类型判断,这样任何格式问题都会变成明确的错误,而不是未定义行为:
#include <yaml-cpp/yaml.h> #include <iostream> #include <stdexcept> #include <string> #include <vector> struct Limits { int max_conn = 0; int timeout_ms = 0; }; struct ServerConfig { std::string host = "0.0.0.0"; int port = 0; bool tls = false; std::vector<std::string> plugins; Limits limits; }; ServerConfig load_config(const std::string& path) { // LoadFile 打不开文件会抛 YAML::Exception 派生异常,语法错误会抛 YAML::ParserException const YAML::Node root = YAML::LoadFile(path); if (!root.IsMap()) { throw std::runtime_error("server.yaml: 根节点必须是映射"); } ServerConfig cfg; // 用 const YAML::Node 接住子节点:const 版本的 operator[] 不会插入新节点 if (const YAML::Node n = root["host"]; n.IsScalar()) { cfg.host = n.as<std::string>(); } if (const YAML::Node n = root["port"]; n.IsScalar()) { cfg.port = n.as<int>(); // 转换不了会抛 YAML::TypedBadConversion<int> } if (const YAML::Node n = root["tls"]; n.IsScalar()) { cfg.tls = n.as<bool>(); } if (const YAML::Node n = root["plugins"]; n.IsSequence()) { for (const YAML::Node& item : n) { cfg.plugins.push_back(item.as<std::string>()); } } if (const YAML::Node n = root["limits"]; n.IsMap()) { if (const YAML::Node mc = n["max_conn"]; mc.IsScalar()) { cfg.limits.max_conn = mc.as<int>(); } if (const YAML::Node tm = n["timeout_ms"]; tm.IsScalar()) { cfg.limits.timeout_ms = tm.as<int>(); } } return cfg; } int main() { try { const ServerConfig cfg = load_config("server.yaml"); std::cout << cfg.host << ':' << cfg.port << " plugins=" << cfg.plugins.size() << '\n'; } catch (const YAML::ParserException& e) { std::cerr << "YAML 语法错误: " << e.what() << '\n'; return 1; } catch (const YAML::Exception& e) { // 其他所有 yaml-cpp 异常的基类 std::cerr << "YAML 处理失败: " << e.what() << '\n'; return 1; } return 0; }as<T>()是模板成员函数,支持的内置转换包括std::string、bool、各种整型、float/double,以及std::vector<T>、std::map<K, V>等容器(容器版本要求整个节点类型匹配)。任何一层不匹配都会抛异常而不是返回默认值,所以外层一定要有try/catch。
二、生成:Node 赋值与 Emitter
写出 YAML 有两条路。第一条是把数据塞进Node再交给 Emitter;第二条是用 Emitter 的流式接口逐段拼。两条路可以混用,但键的顺序与输出格式只有流式写法才由你控制。补充一个实现细节:在 yaml-cpp 目前的实现里,Node的映射按插入顺序保存(内部用一个递增的插入序号做比较),所以 dump 出来通常就是插入顺序;但 YAML 规范本身不保证映射有序,不要把"顺序"当成库的契约来依赖,需要确定顺序时就用Emitter显式写。
#include <yaml-cpp/yaml.h> #include <iostream> #include <string> int main() { // 1) 先构造文档树 YAML::Node doc; doc["host"] = "127.0.0.1"; doc["port"] = 8080; doc["tls"] = false; // 显式构造序列,避免依赖"对空节点 push_back 会自动变成序列"这种细节 YAML::Node plugins(YAML::NodeType::Sequence); plugins.push_back("auth"); plugins.push_back("access_log"); plugins.push_back("metrics"); doc["plugins"] = plugins; // 2) 用 Emitter 控制缩进与键顺序 YAML::Emitter out; out.SetIndent(2); out << YAML::BeginMap; out << YAML::Key << "host" << YAML::Value << doc["host"].as<std::string>(); out << YAML::Key << "port" << YAML::Value << doc["port"].as<int>(); out << YAML::Key << "tls" << YAML::Value << doc["tls"].as<bool>(); out << YAML::Key << "plugins" << YAML::Value << YAML::BeginSeq; const YAML::Node seq = doc["plugins"]; for (const YAML::Node& item : seq) { out << item.as<std::string>(); } out << YAML::EndSeq; out << YAML::EndMap; if (!out.good()) { // Emitter 出错时 good() 返回 false std::cerr << "YAML 输出失败\n"; return 1; } std::cout << out.c_str() << '\n'; // c_str() 返回 const char* return 0; }输出大致如下(Emitter的换行与缩进风格由SetIndent、SetSeqFormat、SetMapFormat等影响):
host: 127.0.0.1 port: 8080 tls: false plugins: - auth - access_log - metrics如果只是想拿到字符串而不控制格式,YAML::Dump(const Node&)直接返回std::string,比自己搭 Emitter 省事。
编译与链接要显式带上库。命令行方式:
# 假定 yaml-cpp 已装在系统默认前缀下 g++ -std=c++17 -O2 -Wall -Wextra main.cpp -lyaml-cpp -o yaml_demoCMake 方式(yaml-cpp 0.7 及以上导出带命名空间的导入目标):
cmake_minimum_required(VERSION 3.16) project(yaml_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(yaml-cpp REQUIRED) add_executable(yaml_demo main.cpp) target_link_libraries(yaml_demo PRIVATE yaml-cpp::yaml-cpp)注意:如果你拿到的是 yaml-cpp 0.6 或某些发行版的打包版本,导入目标可能不叫yaml-cpp::yaml-cpp而叫yaml-cpp,也可能只能用变量${YAML_CPP_LIBRARIES}。链接报 undefined reference 时,第一件事就是确认目标名。
三、自定义类型:YAML::convert 特化
把ServerConfig这样的自定义结构直接交给node.as<T>(),需要为它特化YAML::convert。这个模板约定两个静态成员:encode(C++ 对象转Node)和decode(Node转 C++ 对象,返回bool——返回false表示"这个节点不是我能解的",yaml-cpp 会转而抛YAML::TypedBadConversion<T>)。
#include <yaml-cpp/yaml.h> #include <string> #include <vector> struct ServerConfig { std::string host = "0.0.0.0"; int port = 0; bool tls = false; std::vector<std::string> plugins; }; namespace YAML { template <> struct convert<ServerConfig> { static Node encode(const ServerConfig& rhs) { Node node; node["host"] = rhs.host; node["port"] = rhs.port; node["tls"] = rhs.tls; node["plugins"] = rhs.plugins; // std::vector<std::string> 有内置转换 return node; } static bool decode(const Node& node, ServerConfig& rhs) { if (!node.IsMap()) return false; if (const Node h = node["host"]; h.IsScalar()) rhs.host = h.as<std::string>(); if (const Node p = node["port"]; p.IsScalar()) rhs.port = p.as<int>(); if (const Node t = node["tls"]; t.IsScalar()) rhs.tls = t.as<bool>(); if (const Node pl = node["plugins"]; pl.IsSequence()) { rhs.plugins = pl.as<std::vector<std::string>>(); } return true; } }; } // namespace YAML有了这个特化,cfg = node.as<ServerConfig>();和node = cfg;(经由Node的模板赋值运算符,内部走convert)就都能用了。特化必须出现在第一次使用之前,否则编译器可能已经隐式实例化了主模板,报"explicit specialization after instantiation"。稳妥做法是把convert特化和结构体定义一起放到头文件里,在所有用到as<ServerConfig>()的翻译单元之前#include。
常见坑点
| # | 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|---|
| 1 | 用下标访问不存在的键 | 在非 const的Node上写root["nope"],它会就地插入一个 null 节点,之后root.size()和重新 dump 的结果都被污染 | 用const YAML::Node接住,或先IsDefined()判断;只读场景让 Node 保持 const |
| 2 | 不检查类型就取值 | int p = root["port"].as<int>();而port可能是 null 或字符串 | 先IsScalar(),再as<int>() |
| 3 | 不接异常 | 解析用户的配置文件却没有try/catch,一条语法错误直接 terminate | 捕捉YAML::ParserException与YAML::Exception |
| 4 | 依赖输出格式 | 用YAML::Dump(node)却指望得到特定的缩进与块式/流式风格 | 需要控制格式就用Emitter,配合SetIndent等手段逐项写出 |
| 5 | 版本漂移 | 拿 0.8 的文档去写 0.6 的代码(CMake 目标名、Emitter 行为都不同) | 在 CMake 里锁版本(如 vcpkg 的yaml-cpp固定基线),并核对对应版本文档 |
| 6 | YAML 1.1 的布尔陷阱 | 配置里写country: NO,下游用as<bool>()解释,得到false(Norway 问题) | 该字段明确写成false;或先as<std::string>()再自己判枚举 |
| 7 | YAML 语法本身 | 用 Tab 做缩进、冒号后不留空格(port:8080会解析成一个标量) | 只用空格缩进;冒号后固定一个空格 |
第 1 条尤其阴险:Node的非 constoperator[]在键不存在时会创建一个 null 节点并挂到映射上,所以"我只是读一下"的代码其实改了数据。写只读逻辑时把Node声明成const(或者在函数签名里用const YAML::Node&),const 版本的operator[]只返回一个未定义的节点,不会污染原树。
总结
| 要点 | 说明 |
|---|---|
| 出身 | yaml-cpp 是第三方库,需要单独安装并链接;标准库没有 YAML 支持 |
| 入口 | YAML::Load/LoadFile/LoadAll,返回值都是引用语义的Node |
| 取值纪律 | 先IsScalar/IsSequence/IsMap,再as<T>();as失败抛异常 |
| 只读加 const | const 的Node下标不会插入节点,也不会污染原文档 |
| 生成 | 要控制键顺序用Emitter,只想拿字符串用YAML::Dump |
| 自定义类型 | 特化YAML::convert<T>,实现encode与decode,且必须在首次使用前可见 |
一句话:yaml-cpp 的 API 本身不复杂,难的是"版本、链接、类型检查"这三件工程上的事——把解析包在try/catch里、把只读节点声明成const、把库版本钉死,基本就能避开九成的坑。