有趣的 std::tuple - 一行代码解析命令行字符串

tuple

I heard you like tuple so i put tuple in your tuple so you have tuple in your tuple - Yo Dawg (Source)

由于微信会对外部页面重新排版,在微信内置浏览器中,本文会出现下面的异常:

  • 文中的链接无法正常访问
  • 文中的代码段落多出了许多 html tag,变得不可读

请选择右上角菜单“在浏览器中打开”即可正常阅读。(本文的永久链接)

需求

今天上午写代码时,写着写着遇到一个需求,是在 C++ 程序里把一个命令行字符串解析成对应的一组变量。也就是把形如:

app.exe foo 100 bar 0.05

这样的一个字符串解析一下,放到下面这样的一组变量里:

struct VariableGroup
{
    std::string   name = "";
    int           id = 0;
    std::string   description = "";
    float         precision = 0.0f;
};

这个需求很常见,相信大家都遇到过吧。在实际的程序里,同一个命令行可以有各种不同的用法,命令和参数,全部都手写的话,很罗嗦,维护也麻烦。如果是 Python,有现成的 argparsedocopt,C++ 的话,选择就少一些了,而我又不想用 boost::tokenizer 之类的库,脑子里突然闪过 std::tuple 这货。好吧,就是它了,试着手写一个类型安全,简洁轻便,又能有一定灵活性的版本吧。


我们知道,std::tuple (以下简洁起见直接说 tuple) 最好玩的特性就是可以装 n 个任意类型的变量(一个加强版的 std::pair)正好对应“命令行字符串的参数类型和个数的随意组合”的需求。

老规矩,先定义接口:

std::tuple<...> parse(const std::string& commandline)
{
    ...
    return ???;
}

上面的代码中,接收一个字符串,返回一个 tuple 包含解析好的各项数据。如果能定义出这样的接口,那么用的时候我们就可以这样了:

auto v = parse<std::string, int, float>("foo 123 0.05");

这样解析过之后 v 就是一个包含了三个元素的 tuple,分别是 std::string "foo"int 123float 0.05。这个 parse() 函数应该可以接收任意数目任意类型的命令行参数,返回一个内含对应数目和类型的 tuple

清楚,简洁和方便,对吧 ^_^


目标一明确,就可以开始挽起袖子写代码了。

......

约过了一炷香时间,(好吧,两柱香 -_-)写好了。现在回头对一下前面提出的接口,能满足我们“既类型安全,又简洁轻便,还有一定灵活性”的需求吗?我们一起来看看吧。


接口

实际写好的功能以类 BtArgParser 的形式提供:

class BtArgParser
{
public:
    BtArgParser(const std::string& args);
    ...
    template<class... Args> std::tuple<Args...> parse_tuple() { ... }
    template<class... Args> std::tuple<Args...> parse_tuple_tolerated() { ... }
    template<class... Args> bool parse_tuple_no_throw(std::tuple<Args...>* out_tuple) { ... }
};

这个类的构造函数接收一个命令行字符串,然后用户可以选择使用三个成员函数之一,完成解析的任务。

这时候你要问了,不是说好的一个接口吗,怎么多出了两个来?

别急,且容俺一一道来。

我们知道,根据经验,传入的字符串一般是用户在终端敲进去的。既然是用户输入,就有出错的可能,可能敲少了,敲多了,命令敲错了,类型弄错了,等等等等……咱们的 parse() 函数需要在遇到情况的时候,告诉用户出错了。瞅瞅前面的用例,返回值已经被 tuple 占据了,难道让我们学 map::insert 那样,返回一个 pair<iterator, bool> 吗?(喂喂喂,说好的简洁轻便呢?)

这里通过提供三个命名迥异的函数,提供不同的处理策略。

  1. parse_tuple() 返回 tuple ,遇到错误时抛对应的异常(同标准库大多数函数的行为一致)
  2. parse_tuple_tolerated() 返回 tuple,遇到错误尽可能恢复,并尽可能返回有效数据,不报错
  3. parse_tuple_no_throw() 不抛异常,但返回 bool 表示是否解析成功,tuple 以参数返回

这个看起来啰嗦的方案,实际上是让代码自带注释属性,而且顺便做到对查找友好 (grep-friendly) 的。来吧,跟我一起念,代码可读性是程序员生活质量的保证~~


parse_tuple() 标准版

第一个函数 parse_tuple() 没啥好说的,使用姿势就和前面的接口描述几乎一致:

BtArgParser p(args);
auto v = p.parse_tuple<std::string, int, float>();

注意这是抛异常的版本,如果不手动 try / catch 处理错误的话,当发生错误时会一层一层 unwind 直到程序退出或被外层捕获。这里可能抛出的异常都在 std::exception 之下。


parse_tuple_tolerated() 容错版

从上面可以知道,第二个函数 parse_tuple_tolerated() 会尽可能地把传进来的字符串和需求方 tuple 内的变量类型匹配,那么具体的策略是什么呢?如果命令行提供了多余的参数,那么直接忽略;如果参数不够,那么就补充空的字符串参数;如果类型不匹配,那么使用 0 (对应 int 和 float) 和 "" (对应 std::string) 去构造默认值。总之,能恢复就恢复,能分析出多少分析多少。

如下所示,当运行这一句代码时:

auto v = p.parse_tuple_tolerated<std::string, int, float>();

有意地提供下图中黑体字部分以 ed scale 开头的两条(不匹配的)命令

  1. ed scale foo 1.32 bar
  2. ed scale foo bar

(注意,黑体字部分将被解析为三个参数依次为 'string', 'int' 和 'float' 的 tuple):

info_2

紧接着命令的一行输出是返回的解析结果。简单描述一下,

第一次匹配 (匹配内容为 "foo 1.32 bar") 时,

  • "foo" 解析为 std::string 分析成功
  • "1.32" 解析为 int 强转为 1 分析成功
  • "bar" 解析为 float 失败,得到使用 0 构造的 0.0

结果是能转换的就转换,转不了的返回 0。

第二次匹配 (匹配内容为 "foo bar") 时,

  • "foo" 解析为 std::string 分析成功
  • "bar" 解析为 int 失败,得到 0
  • 第三个参数没提供,直接返回 0.0

结果是参数数目不符时,用零补足。

可以看到,使用 parse_tuple_tolerated() 来做高容忍度的匹配,总会返回有效的 tuple (当然里面的值并不总是有意义)。有 Lua 经验的程序员可能已经看出来了,这与 Lua 处理函数参数时的容错机制 (不够就补足,多了就忽略) 是类似的。


parse_tuple_no_throw() 静音版

呼,喝口水,接着说第三个函数 parse_tuple_no_throw() 。这个函数除了把抛异常改为返回 bool (以及由此导致的使用参数来返回 tuple) 以外,内部行为与第一条保持一致。需要补充说明的是,仅靠返回 bool 来表示是否成功,一些情况下是不够的。实践中我把错误细节打印到了日志中,当需要调试的时候可以查看日志,如下所示:

运行代码:

std::tuple<std::string, int, float> v;
if (!p.parse_tuple_no_throw(& v))
    return;

然后有意地提供下面图中黑体字部分的命令行参数,就可以看到如下图中的报错信息了

info_1

可以看到,

  • 第一次,我们传了 "foo""bar" 两个字符串进来,报了错误 std::logic_error:参数个数不匹配,需要三个,传了两个。
  • 第二次我们传了 123"foo""bar" 三个参数进来,又报 std::bad_cast 的错了:有类型转换失败,具体细节是第 1 个参数从 std::stringint 转换失败的。注意,这里的第一个实际上是第二个,我们保持 C/C++ 的传统,从 0 开始计数。

在这一例中,123 被成功地解析成字符串 "123" 而解析第二个参数 foo 时遇到了错误,无法转成 int,就报错并返回 false 退出了。


细节

好了,介绍到这里,本文就应该结束了。下次如果时间充裕的话,我们再来聊一下 BtArgParser 内部的实现吧。有兴趣自己看的同学,代码的传送门在这里 (BtArgParser.h) 这里 (BtArgParser.cpp) 。预备知识是 std::tuplestd::tuple_sizestd::tuple_element<...>::type,以及可变参模板模板偏特化模板递归的语意,这几点如果有疑问的话,可以点对应的链接进去了解一下。


不多说了,要回去继续撸代码了。不得不说,写代码比写文章好玩多了~~

[完]
Gu Lu
[2015-12-22]


[注]

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。