在之前文档讲述了一下 C X-Macros 的特性和一些应用场景,也提到在编译期可以确定的范式基本都可以通过 X-Macros 来辅助实现,比如命令行参数解析,这里就具体讲述如何实现。

具体代码在 github:macros_args

目标

因为使用过 argtable 这样的库,所以充分考虑了这种基于描述的命令行参数解析,但是也觉得还有一些繁琐,明明可以用一张表来表示所有参数,使用时却需要一个一个的初始化,而且库还有一些过于复杂的东西,比如特殊单位的处理支持。

设想中,这样的参数解析应该这样:

  1. 纯 header 文件实现,无 c 文件依赖;
  2. 表驱动配置解析参数,明确各个参数类型和特性;
  3. 支持参数重复,也就是同个参数传递多个值;
  4. 支持短参数和长参数,支持 literal/int/float/string 这样简单的参数类型;
  5. 尽量少依赖,不依赖动态内存分配,只适用少量 std 库和 getopt;
  6. 无全局状态,支持多实例;

考虑到实现复杂度,不依赖 std 库和 getopt 则会代码量巨大且不可维护,基本上就是需要利用标准库少量函数和getopt,来实现一个类似 argtable 这样的命令行参数解析。

思路

有了目标,就可以考虑如何实现了,本质上,就是如何将一个参数描述表转换为最终的 getopt 调用。

首先,需要考虑参数原型设计,基于以上,考虑设计4种基本参数类型:

  1. LIT, 也就是 literial,比如 -h -v 这样的开关参数,在内部应该是个整数表示是否出现及出现次数;
  2. INT,整数参数,内部用 int 保存即可。
  3. NUM,浮点参数,内部用 double 保存。
  4. STR,字符串参数,内部需要用 char *保存指针即可。 对于同参多值,可以将1个参数的情况作为多参数特例,所以表设计应该如下:
项目 描述
类型 参数类型,宏
最大数量 允许的最大数量,0 表示无限制(可以内部预设最大限制)
名称 参数在代码中的引用名称,需要符合 token 央视
短参名 类似 -t 的短参,用 'c' 这样表示,实际是整数,没有则写0
长参名 类似--long的长参,用"long"这样的字面量表示,没有则写 "" 空字符串
参数提示 类似 "<count>" 这样的字面量,用于帮助中提示带参的类型
默认值 参数默认值,对于多参数,只有第一个参数会被设置
描述 描述这个参数用来做什么

使用时,需要用户提供一个 X Macros 列表,我们再基于这个列表,来实现我们的必要拼装:

  1. 利用表生成一个结构体定义,用来存储我们的参数;
  2. 利用表来生成解析函数,用来解析命令行并存储结果;
  3. 利用表明来实现实例唯一,可以保证无全局状态多实例;
  4. 利用表进行约束检查,避免错误使用;

实现

具体实现可以参考代码,其中最重要的就是 _ARG_IF_ELSE 这个编译期条件宏,可以基于条件生成编译期代码,而非运行期:

1
2
3
4
#define _ARG_IF_ELSE_0(THEN, ELSE) ELSE
#define _ARG_IF_ELSE_1(THEN, ELSE) THEN
#define _ARG_IF_ELSE_IMPL(CONDITION, THEN, ELSE)    _ARG_CONCAT2(_ARG_IF_ELSE_, CONDITION)(THEN, ELSE)
#define _ARG_IF_ELSE(CONDITION, THEN, ELSE)         _ARG_IF_ELSE_IMPL(CONDITION, THEN, ELSE)

宏的实现就是简单的宏拼接,所以其应用很受限,只能基于宏展开时字面量数字来进行条件选择,参数必须是字面量0或1,如果直接使用,会很受限,需要配合_ARG_BOOL宏:

1
2
3
4
5
#define _ARG_CHECK_N(x, n, ...)     n
#define _ARG_PROBE(x)               x, 0,
#define _ARG_PROBE_0                _ARG_PROBE(~)
#define _ARG_CHECK(...)             _ARG_CHECK_N(__VA_ARGS__)
#define _ARG_BOOL(x)                _ARG_CHECK(_ARG_CONCAT2(_ARG_PROBE_, x), 1)

这个宏更绕一些,是根据宏字面量的0或非0来条件生成字面量0或1,这里用到了__VA_ARGS__ 展开参数数量,具体可以仔细揣摩。

有了这些宏,在结构体定义、静态检查和函数实现中,可以基于参数最大数量和参数类型来生成不同的编译期代码,避免编译器告警。

使用时,程序需要提供一个类似如下的参数列表宏,并利用库的宏来生成必要定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#define APP_ARGS_LIST(X) \
    ARG_LITX(X, help,       'h', "help",        "show help") \
    ARG_LIT1(X, print,      'p', "print",       "print arg struct") \
    ARG_LITX(X, verbose,    'v', "verbose",     "verbose") \
    ARG_NUM1(X, timeout,    't', "timeout",     "<secs>",  0.0,  "timeout in secs") \
    ARG_LIT1(X, longonly,   0,   "longonly",    "test long only arg") \
    ARG_LIT1(X, shortonly,  's', "",            "test short only arg") \
    ARG_STR1(X, from,       'f', "from",        "<file>",  "1.txt", "file path") \
    ARG_STRX(X, token,      'k', "token",       "<token>", "token0", "process tokens")

// 2. Generate essential
ARG_DEFINE(APP_ARGS_LIST);

然后在主函数中,可以进行解析:

1
2
3
4
5
// 3. Decleare struct to storage parse result
ARG_STRUCT_INIT(APP_ARGS_LIST, arg);
// 4. Parse argv with struct
// ret > 0 => optind, < 0 => error!
int ret = ARG_PARSE(APP_ARGS_LIST, argc, argv, &arg);

后续就可以利用arg成员,来判断参数,或者使用宏来打印参数帮助信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
if (ret < 0) {
    printf("Usage: %s", argv[0]);
    ARG_PRINT_SYNTAX(APP_ARGS_LIST);
    printf("\n");
    return ret;
}
// Use struct field to check result
// For LIT, use n_XXX
if (arg.n_print > 0) {
    printf("optind =%d\n", ret);
    ARG_STRUCT_PRINT(APP_ARGS_LIST, &arg);
    printf("\n");
}
if (arg.n_help > 0) {
    printf("Usage: %s", argv[0]);
    ARG_PRINT_SYNTAX(APP_ARGS_LIST);
    printf("\n\nExample:\n");
    ARG_PRINT_GLOSSARY(APP_ARGS_LIST, 30);
    return 0;
}

因为实现利用了大量宏,所以阅读起来会比较费劲,尤其是琐碎的条件处理。目前仍需要改进的部分就是利用短参字符字面量拼接getopt_long字符串参数时,通过手动去除单引号实现,这里之前考虑使用字面量而不是字符字面量,但是问题在于:

  1. #宏可以方便实现字符串字面量,但是很难基于宏实现字符字面量;
  2. getopt_long中即要字符字面量,也要字符串字面量;

手动去除虽然拙劣但十分有效。