在C++工程中处理配置文件时,INI格式因其简单直观仍然很常见。标准库没有提供直接解析INI的现成函数,因此要实现一个轻量级解析器,核心工作就是逐行读取文件、清洗无效内容、拆出节名和键值对,并用合适的容器保存结果。本文采用自定义字符串分割函数配合嵌套的map结构,完成一套不依赖第三方库的解析方案。

一、解析流程与INI结构约定
INI文件由节、键值对和注释构成。节名用方括号包围,例如[server];键值对通常使用等号连接,左侧为键,右侧为值。注释通常以分号或井号开头,也可能出现在行尾。空行和行首尾空白不影响配置含义,但需要统一处理。一个典型的INI片段如下:
[server] host=127.0.0.1 port=8080 [database] user=root password=123456 ; 这是注释
解析流程通常分为四个步骤:打开文件、逐行预处理、识别节名与键值对、写入存储容器。打开文件可以用std::ifstream,读取失败要返回错误信息。逐行预处理需要处理BOM头、空白字符和注释,尤其要区分行首注释与行内注释。行内注释通常从分号开始,一直截断到行尾。识别节名时要检查第一个非空字符是否为左方括号,并找到配对右方括号。等号分隔键和值,键和值两侧可能有多余空格,都要去掉。
在上述流程中,全局键(出现在任何节之前的键值对)可以放入一个空字符串节名下,也可以单独处理。本文实现中,当尚未遇到任何节时,将键值对存入空节名对应的内层map。这样在查询时可以用config[""]获取全局配置。对于出现重复键的情况,后出现的值会覆盖先前的值,行为类似大多数INI解析库。文件路径需要由调用方保证有效,本实现会返回布尔值表示读取是否成功。
二、字符串分割与trim函数实现
逐行读取后,很多行都带有空格、制表符或换行符,直接使用find截取会把这些干扰字符带进键名和值里。trim函数的作用是移除字符串首尾的空白字符。C++标准库没有提供trim,但可以用find_first_not_of和find_last_not_of组合实现。首先找到第一个不是空白字符的位置,如果整个字符串全是空白就返回空串;再找到最后一个不是空白字符的位置,用substr截取这一段。
split函数则是把一行字符串按指定分隔符拆成若干部分。本文的键值对拆分并不需要通用的split,因为等号可能出现多次,只需要第一次出现的等号作为分隔。但节名提取、行内注释清理等场景可以复用split。使用std::stringstream配合std::getline可以方便地实现按单字符分割。需要注意的是,如果输入行中连续出现分隔符,getline会返回空字符串,调用方可以根据需要过滤。
代码示例给出了trim和split的完整实现。trim函数中find_first_not_of的参数是一个包含空格、制表符、回车和换行的C字符串,因为std::string::npos表示未找到。split函数通过std::stringstream把行转换成流,再逐段读取。对于仅需要查找一个等号的情况,不必调用split,直接使用line.find('=')效率更高。下面是一个分割和清洗的具体实现。
#include <string>
#include <sstream>
#include <vector>
std::string trim(const std::string& input) {
const std::string whitespace = " \t\r\n";
auto begin = input.find_first_not_of(whitespace);
if (begin == std::string::npos) return "";
auto end = input.find_last_not_of(whitespace);
return input.substr(begin, end - begin + 1);
}
std::vector<std::string> split(const std::string& input, char delimiter) {
std::vector<std::string> result;
std::stringstream ss(input);
std::string item;
while (std::getline(ss, item, delimiter)) {
result.push_back(item);
}
return result;
}
在实际解析时,行首可能有空格,因此先对整行调用trim,再判断是否为空行或注释行。行内注释的处理可以在找到等号和值之后进行,也可以先截断分号。本文选择在识别键值对之前截断行内分号,这样可以避免值中带分号导致歧义。如果配置值里确实需要包含分号,可以通过引号包裹或转义规则扩展,基础版本保持简单。
三、基于map的存储配置与查询接口
解析出来的配置项需要保存在内存中,选择什么数据结构直接影响查询效率和使用体验。使用std::map嵌套std::map可以自动按键排序,适合配置文件规模较小、需要按名称顺序遍历的场景。外层map的键是节名,内层map的键是配置项名,值统一为字符串。如果配置项非常多,可以换成std::unordered_map以获取平均常数时间查询,但会失去排序能力。本文以有序map为例。
除了直接访问嵌套map,最好封装几个常用的查询方法,例如getString、getInt、getBool。这些方法允许调用方传入节名、键名和默认值,当节或键不存在时返回默认值。因为配置文件中所有值先以字符串存储,整数和布尔值需要进行转换。整数可以使用std::stoi,布尔值可以把“true”、“yes”、“1”等识别为真。转换失败时可以抛出异常,也可以返回默认值,取决于业务需求。
为了更符合面向对象的使用习惯,还可以封装一个ConfigParser类,内部持有Config类型的成员,提供load、save和get、set接口。load负责打开文件并调用解析逻辑,save则反向遍历map写出INI文本。反向写回时需要注意键值之间用等号连接,节名用方括号包裹,空节名通常不写。保存时还可以选择是否保留空行和注释,基础版本不保留注释,只输出有效的节和键值。
查询接口示例代码如下,里面展示了如何从嵌套map中安全取值。
#include <map>
#include <string>
#include <stdexcept>
using Config = std::map<std::string, std::map<std::string, std::string>>;
std::string getString(const Config& config,
const std::string& section,
const std::string& key,
const std::string& defaultValue = "") {
auto secIt = config.find(section);
if (secIt == config.end()) return defaultValue;
auto keyIt = secIt->second.find(key);
if (keyIt == secIt->second.end()) return defaultValue;
return keyIt->second;
}
int getInt(const Config& config,
const std::string& section,
const std::string& key,
int defaultValue = 0) {
std::string value = getString(config, section, key);
if (value.empty()) return defaultValue;
try {
return std::stoi(value);
} catch (const std::exception&) {
return defaultValue;
}
}
四、完整解析类与测试用例
把前面提到的解析流程、字符串工具和存储结构组合起来,可以得到一个可复用的INI解析类。这个类适用于小型项目或插件系统,在不引入Boost或专门配置库的情况下,完成基本的配置读取和保存。下面给出一个完整实现,其中load方法包含打开文件和逐行解析逻辑,save方法负责将map内容写回文件。
在load中逐行处理时,需要特别注意文件编码问题。如果INI文件是UTF-8带BOM,第一行开头可能多出三个字节,导致节名或键名解析错误。可以在读取第一行后检测并去除BOM,或者要求配置文件使用无BOM的UTF-8编码。Windows记事本默认保存的UTF-8通常带BOM,实际项目中建议在文档中注明编码要求。
测试用例可以准备一个示例INI文件,使用std::ofstream写入临时目录,然后调用解析类读取并查询几个配置项。测试时可以断言主机名是否为127.0.0.1、端口是否为8080等。这里提供一个简单的main函数,演示加载当前目录下的config.ini并输出结果。
#include <iostream>
#include <fstream>
#include <string>
#include <map>
#include <vector>
#include <sstream>
#include <algorithm>
using Config = std::map<std::string, std::map<std::string, std::string>>;
// 此处省略trim和split函数,复制前文代码即可
// 以下是完整解析类
class ConfigParser {
public:
bool load(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
return false;
}
std::string currentSection;
std::string line;
while (std::getline(file, line)) {
line = trim(line);
if (line.empty()) continue;
if (line[0] == ';' || line[0] == '#') continue;
if (line[0] == '[') {
auto pos = line.find(']');
if (pos != std::string::npos) {
currentSection = trim(line.substr(1, pos - 1));
}
continue;
}
auto commentPos = line.find(';');
if (commentPos != std::string::npos) {
line = trim(line.substr(0, commentPos));
}
if (line.empty()) continue;
auto eqPos = line.find('=');
if (eqPos == std::string::npos) continue;
std::string key = trim(line.substr(0, eqPos));
std::string value = trim(line.substr(eqPos + 1));
if (key.empty()) continue;
config_[currentSection][key] = value;
}
file.close();
return true;
}
void save(const std::string& filePath) const {
std::ofstream file(filePath);
if (!file.is_open()) return;
auto globalIt = config_.find("");
if (globalIt != config_.end()) {
for (const auto& kv : globalIt->second) {
file << kv.first << "=" << kv.second << "\n";
}
file << "\n";
}
for (const auto& section : config_) {
if (section.first.empty()) continue;
file << "[" << section.first << "]\n";
for (const auto& kv : section.second) {
file << kv.first << "=" << kv.second << "\n";
}
file << "\n";
}
file.close();
}
std::string get(const std::string& section,
const std::string& key,
const std::string& defaultValue = "") const {
auto secIt = config_.find(section);
if (secIt == config_.end()) return defaultValue;
auto keyIt = secIt->second.find(key);
if (keyIt == secIt->second.end()) return defaultValue;
return keyIt->second;
}
private:
Config config_;
};
int main() {
ConfigParser parser;
if (!parser.load("config.ini")) {
std::cerr << "无法打开配置文件" << std::endl;
return 1;
}
std::cout << "server.host = " << parser.get("server", "host") << std::endl;
std::cout << "server.port = " << parser.get("server", "port") << std::endl;
std::cout << "database.user = " << parser.get("database", "user") << std::endl;
return 0;
}
基础版本已经能满足大部分配置需求,若需要更多特性,可以扩展支持引号包裹的值、反斜杠转义、数组和列表值、节继承等功能。在实际项目中,配置文件解析通常不是性能瓶颈,但频繁的map插入可以预先使用reserve或者改用unordered_map来减少哈希或树结构调整的开销。掌握字符串分割和嵌套容器组合的方法后,也可以把类似思路应用到JSON或自定义格式的轻量解析中。