IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    Gu Lu\'s Blog发表于 2015-12-22 08:24:00
    love 0

    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,有现成的 argparse 和 docopt,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 123 和 float 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::string 到 int 转换失败的。注意,这里的第一个实际上是第二个,我们保持 C/C++ 的传统,从 0 开始计数。

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


    细节

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


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

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


    [注]

    • 本文同时发在我的知乎专栏 (链接)
    • 本文遵循 Creative Commons BY-NC-ND 4.0 许可协议。


沪ICP备19023445号-2号
友情链接