C++使用ranges库解析INI文件

发布时间 2023-06-20 09:41:15作者: 鸿运三清

C++使用ranges库解析INI文件

引言

C++20引入了<ranges>头文件,C++23对其进行了完善,本文将使用该头文件提供的adaptor编写一个简单的ini解析器。

ini文件格式介绍


一般的ini文件由section和entry部分,比如

[section]
key=value ;This is entry.

section和entry均独占一行,其中section部分是由一对方括号构成,而entry由key和value俩个部分构成,使用等号隔开,分号后面的部分会被视为注释。


存储结构

一个比较容易想到的做法是使用map<string, entry>来代表section,使用map<string, string>来代表entry。

但是我们可能并不需要有序结构,所以我们可以使用unordered_map来代替map。

同时,大量的string可能会要效率上的损失,我们可以存储整个文件,然后使用string_view来代替string。

std::string context;
std::unordered_map<std::string_view, std::unordered_map<std::string_view, std::string_view>> result;

解析过程

我们可以将ini的解析过程分为如下几步:

  • 读取文本
  • 按行分割
  • 去除该行的注释部分和无效的空白
  • 去除空行
  • 将section和entry进行分组,每组一个section和若干个entry
  • 将entry拆分为键值对
  • 存储分组后的结果

1. 读取本文

std::string ReadFromFile(const char* filename)
{
    std::ifstream ifs { filename, std::ios::binary };
    return std::string(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}

context = ReadFromFile(filename);

这种方式比较简洁但是效率不是很理想。

2. 按行分割

lines = context | std::views::split('\n');

split分割完之后会生成若干个std::subrange<std::string::iterator, std::string::iterator, std::ranges::subrange::sized>,前俩个模板参数代表了迭代器的类型,最后一个参数表示这个subrange是知道size的。

3. 去除该行的注释部分和无效的空白

inline constexpr auto TrimBlank = [](std::string_view line) {
    auto first = std::ranges::find_if_not(line, ::isspace);
    auto last = std::ranges::find_if_not(line 
        | std::views::drop(std::ranges::distance(line.begin(), first)) 
        | std::views::reverse, ::isspace).base();
    return std::string_view(first, last);
};

inline constexpr auto GetSectionOrEntryContext = [](auto line) {

    // for empty line or line just with comments, return "".

    auto str = std::string_view(line);

    auto end_of_line = std::ranges::find(str, ';');  // find comment

    auto new_line = std::string_view(std::ranges::begin(str), end_of_line);

    auto line_after_trim = TrimBlank(new_line);

    auto result = std::string_view(line_after_trim.begin(), line_after_trim.end());

    return result;
};

lines = lines | std::views::transform(GetSectionOrEntryContext);

我们对于每一行,首先可以找到注释符号,然后将注释部分去除,再将剩下的部分trim就可以得到有效的内容。

4. 去除空行

lines = lines | std::views::filter(std::ranges::size);

filter会将满足条件的元素留下,空行的size为0,不会被留下。

5. 将section和entry进行分组,每组一个section和若干个entry

由于我们移除了所有的空行,所以现在每一行只可能是section和entry中的一个。我们可以列举所有情况:

  • 同一个section下的两个相邻的entry(entry + entry)
key1=value1
key2=value2
  • 同一个section下的section和entry(section + entry)
[section1]
key1=value1
  • 上一个section的最后一个entry和下一个section(entry + section)
key1=value1
[section1]
  • 一个空的section紧跟下一个section(section + section)
[section1]
[section2]

在以上的四种情况中,只有前面两种应该属于同一个section。我们可以使用adjacent_find来辅助我们分组,而chunk_by内部正好采用的就是adjacent_find。

inline constexpr auto IsSection = [](auto str) {
    return str.front() == '[';
};

inline constexpr auto IsEntry = [](auto str) {
    return !IsSection(str);
};

inline constexpr auto ChunkSectionAndEntries = [](auto l, auto r) {
    return (IsSection(l) && IsEntry(r)) || (IsEntry(l) && IsEntry(r));
};

lines = lines | std::views::chunk_by(ChunkSectionAndEntries); 

6. 将entry拆分为键值对

inline constexpr auto SplitEntryToKeyValue = [](auto line) {

    assert(line.front() != '='); // only allow empty value

    auto kv = line | std::views::split('=');

    auto iter = kv.begin();

    auto key = std::string_view(*iter);

    std::ranges::advance(iter, 1, kv.end());

    return std::make_pair(key, iter == kv.end() ? "" : std::string_view(*iter));

};

我们按照'='进行分割,然后将key和value以pair的形式返回。在这里我们可以进行一些错误检查,比如我们允许value为空,但是允许key为空的情况:

[section]
key1=   ; ok
=value1 ; error

当然我们还可以在这个函数里面增加一些其他的检查以防止不合法的输入。

7. 存储分组后的结果

到此为止,我们已经将所有的行进行了分组,对于每一组,有若干行,其中第一行为section,剩下的所有行均为entry。我们将他们转换为一个map存储起来。

inline constexpr auto MapEntriesToDict = [](auto lines) {
    std::unordered_map<std::string_view, std::string_view> dict;
    std::ranges::for_each(lines, [&](auto line) {
        auto [k, v] = SplitEntryToKeyValue(line);
        dict[k] = v; // 当一个section下有多个相同的entry时,后面的会覆盖前面的,当然这里也可以利用异常或者断言来组织这样的事情发生。
    });
    return dict;
};

inline constexpr auto ParseOneSectionAndEntriesChunkToResultValueType = [](auto lines) {
    // chuck_by产生若干个chunk(lines),每个chunk第一个元素是section,剩下的部分是entries。
    auto section = *lines.begin(); 
    auto entries = MapEntriesToDict(lines | std::views::drop(1));
    return std::make_pair(
        section.substr(1, section.size() - 2), // 去除section的括号部分
        std::move(entries));
};

lines = lines | std::views::transform(ParseOneSectionAndEntriesChunkToResultValueType);

我们可以在ParseOneSectionAndEntriesChunkToResultValueType中添加相应的检查机制,比如检查一下section是否以'['开头,和以']'结尾。

我们的ini可以出现一些极端的情况:

  • 只包含注释和空行
; Hi, guys!
; Whose life will be better if you learn more C++?
; End of file
  • 只包含section
[section1]
[section2]
[section3]
[section4]
; End of file
  • 只包含entry或者不是以section开头
key1=value1
key2=value2
key3=value3
; End of file

对于第一种情况我们的filter会将所有行都去掉最后生成一个空的map。

对于第二种情况chunk_by生成若干个只包含section不包含entry的chunk。

对于第三种情况chunk_by会将这些entries分到第一个chunk中,这会导致第一个chunk不包含section,不过我们其实并不需要做额外的处理,依旧只需要检查section的格式即可。

auto section = *lines.begin(); 
assert(section.front() == '[' && ...);
// ...

由于entry和section在格式上差距很大,所有检查section格式也可以防止此种情形出现。

到此位置解析和转化已经全部完成,我们最后再把结果放到map中即可,完整的代码点击这里