向 QT5 (C++) 程序传递编译期参数

记在一个遗留 C++ 项目上改造版本号。

背景

C++ 加 QT5 的历史遗留项目。彻底重写不实际,很多坑要重新踩。原作者找不着人,连问都没人可以问。只能重构梳理之后 fix bug 先凑合用。

琐碎的重构不值一提,唯独版本号的改造,可以记录一下,给以后参考。

版本号更新

之前的版本号是写死在代码里的。想起来改一下,有时就忘了。

这样会有一些弊端:

  • 首先是增加记忆负担。

    修改版本号动作很小。但开发的粒度跟发布的粒度并不一致。提交的是最小可运行粒度。发布则还要考虑业务和运营。一次发布往往包含多个提交。提交的时候,未必能预计到发布的时机并修改版本号。

  • 相对可行的办法,是每次发布完,马上改掉版本号,作为下次发布的预备。

    但这样提交历史里,会有大量版本号的提交,稀释了真正有用的提交。

  • 何况即使这样,还是无法跟踪两次正式发布之间的测试版本。

    一般完整的版本号,会同时包含 供用户关注的、粒度较大的『发布版本号』,和 内部跟踪的、粒度更小的『内部版本号』。git 的 commit hash (SHA-1) 就是一个天然的内部版本号,帮我们把程序唯一对应到一个提交。有些时候还要加上系统平台、架构或编译器的信息。可一旦加上这些信息,上面的问题就更严重了。

要么不记得,要么改不对,还污染历史。

这些矛盾的根源,在于 版本号主要作用于 测试、发布、运维,帮我们跟踪代码版本、构建信息,其内容和代码逻辑本身几乎是正交的,是在代码提交完之后才真正确定下来的;却为了后续的跟踪,偏偏需要内置在程序里

在代码里修改版本号,就成了当预言家,要预测版本什么时候发布,能不能通过测试,能不能正常发布,注定吃力不讨好。

如果版本号里还需要包含 commit hash,在提交前就预测并写入代码,则几乎是不可能的任务。信息摘要有雪崩效应,hash 由提交的所有信息共同确定;但是在这个提交里,却要先把 hash 写入。这就陷入了 鸡-蛋悖论。

编译期确定

所以合理的做法是,代码里预留这些信息的位置和相关的代码,值等到代码提交后的某一刻再决定。一般来说,这个时刻就是编译的时候。

对于 Go 而言,就是在编译时,给 linker 传递 -ldflags "-X '<包名>.<变量名>=<字符串>'" ,就可以给代码里对应的(string)变量赋值。

举例说,如果在 main.go 里这样写

1
2
3
4
5
6
7
8
9
10
11
package main
import (
"fmt"
)
var version string
func main() {
fmt.Println(version)
}

编译时加上 -ldflags "-X 'main.version=v1.2.3'" ,那么我们最后运行程序得到的输出就是 v1.2.3

更具体地,我一般会在 Makefile 里这样写:

1
2
build:
go build -ldflags "-X 'main.version=$(TAG)' -X 'main.goVersion=$(shell go version)' -X 'main.commitHash=$(shell git show -s --format=%H)' -X 'main.buildTime=$(shell date "+%Y-%m-%d %T%z")'"

主版本号通过命令行通过环境变量 TAG 传入,其它的信息则通过 shell 自动获取。

C++ 和 QT5 下的方案

我需要在 C++ 里做到类似的事情。

特别强调,我本来用 C++ 就用得很浅,再加上已经有十年不怎么写,下面的内容仅仅对我而言值得记录一笔,对 C++ 高手来说可能完全不值一提。

-D 参数

项目用的是 g++ 编译器,可以很容易查到,最接近的做法,是给编译器传递 -D<宏>[=值] 来定义宏。

举例说,-DDEBUG_LOG 相当于 #define DEBUG_LOG-DVERSION=v1.2.3 则相当于 #define VERSION v1.2.3 。注意 -D 和后续内容中间没有任何间隔或者符号。

时间关系,我没有去确认其它编译器是否也有这个参数。

qmake 和 .pro

然而我们并不会手动调用 g++ ,甚至都不会手写 Makefile 。项目下确实是有一个 Makefile ,但它是 qmake 自动生成的,任何修改都会在后续被覆盖。qmake<项目名>.pro 生成 Makefile

结合现有的 .pro 文件和网上的信息,很容易确定 DEFINES 的值,最终会变成 Makefile 里的 -D 参数。

于是我在 .pro 加了一行

1
2
# git commit hash
DEFINES += COMMIT_HASH=$(shell git show -s --format=%h --abbrev=10)

(暂时版本号里不想添加太多东西,只传入了 commit hash;甚至完整的 hash 都略嫌太长,只保留了前 10 位。)

迫不及待地开始编译,得到的 Makefile 里却是这样的

1
-DCOMMIT_HASH=$(shell -Dgit -Dshow -D-s -D--format=%h -D--abbrev=10)

以空格为分隔符,每一段都成了一个参数……

不过这个很好解决,加个引号就行

1
DEFINES += COMMIT_HASH="$(shell git show -s --format=%h --abbrev=10)"

引号也可以加在别的地方,只要把空格放在引号里,保证不会断开就行

1
DEFINES += "COMMIT_HASH=$(shell git show -s --format=%h --abbrev=10)"

无论是哪一种,最后得到的 Makefile 里都是

1
-DCOMMIT_HASH=$(shell git show -s --format=%h --abbrev=10)

有效信息已经从 qmake.pro 传到了 makeMakefile 手里。接下来 make 执行时,会执行 $(shell ...) 里的内容,并将结果原地展开。那么从 make 传递给 g++ 的时候,-DCOMMIT_HASH= 后面就已经是具体的值了。

相当于 #define COMMIT_HASH 具体的hash

宏不是字符串

宏有了,我们终于来到代码部分。

由于宏是外部定义的,代码里没有,直接用会报错。只是不同 IDE 具体报的错不同。(因为 QtCreator 不好用,我在 VS Code 里写代码,QtCreator 只用来编译。)

即使不考虑这些报错,也可能存在后续忘了传对应参数的情况,从而导致代码有不可预料的结果。最好的办法还是加上 #ifdef

1
2
3
4
QString version;
#ifdef COMMIT_HASH
version = COMMIT_HASH;
#endif

加了之后,VS Code 不再报错了(其实是因为它认为这个宏没有定义,直接忽略了这段代码)。但 QtCreator 很聪明地意识到还有问题,报了一个 expected expression (这里需要一个表达式)。而如果改为将 COMMIT_HASH 当做字符串参数用,会提示少了参数。

究其原因,是 COMMIT_HASH 只是一个宏,而且是一个外部定义的宏,不是字符串。

外部定义

到这里,对 C++ 已经很生疏的我有点懵,然后就开始胡乱地尝试。(接下来的病急乱投医让各位见笑了)

如果改为 version = "COMMIT_HASH"; ,倒是不报错了,但是 version 得到的,也确实只有一个 "COMMIT_HASH" 的字符串。这是因为

宏参数不会在字符串常量内部替换(parameters are not replaced inside string constants)。

宏是预处理器负责处理的,早在开始编译之前就已经被替换。如果在 .pro 里再额外加了引号,试图让宏的值带上引号呢:

1
DEFINES += COMMIT_HASH="\"$(shell git show -s --format=%h --abbrev=10)\""

相当于 #define COMMIT_HASH "具体的hash" 。然而没有用,仍然报错。

因为这种外部定义的宏,跟真实写在代码里的宏,还不一样。

实际在代码里定义一个 COMMIT_HASH 对比一下效果(以下报错都来自 VS Code):

  • #define COMMIT_HASH 1234 ,引用处报错 no suitable constructor exists to convert from "int" to "QString" : C/C++(415)
  • #define COMMIT_HASH 123aef (没有 0x 前缀的 16进制数,模拟真实的 commit hash 值),报错 user-defined literal operator not found : C/C++(2486)
  • #define COMMIT_HASH "123aef" ,可以正常编译。

有这些差别,是因为写在代码里的宏定义,就在编译器和工具链 眼皮底下 ,完全可以先展开,再做静态分析。

可对于外部定义的宏,这些分析就没法做。工具链无法确定这是什么东西,也就无法确定究竟是以上哪种情况。

套娃的 # 展开

我们需要给工具链一个保证:在不知道宏的值的情况下,拿到的仍然是一个字符串。

对 C++ 来说,我们需要预处理运算符(stringizing operator):# ,它会把参数对应的字面文本转换为字符串。

是的,# 不仅是所有预处理指令(preprocessing directive)的开头,单独的 # 也是一个预处理运算符(preprocessing operator)。

为此我找了两个参考文档,分别来自

两边结合着理解。

第一层:直接用

上来直接把代码改为 version = #COMMIT_HASH; ,得到的错误是 '#' not expected here : C/C++(10)

第二层:函数宏

结合例子理解一下,哦,貌似 # 只能在宏里面生效。好,改成这样

1
2
3
4
5
6
7
8
#define STR(S) #S
//....
QString version;
#ifdef COMMIT_HASH
version = STR(COMMIT_HASH);
#endif

再试一下。这回可以正常编译。可是运行之后发现,得到的还是一个 "COMMIT_HASH"

还是着急了,只看了文档开头就动手。还是要把文档看完。

咦,不对啊,为什么 GNU 的例子展开了。你看

1
2
3
4
5
6
#define WARN_IF(EXP) \
do { if (EXP) \
fprintf (stderr, "Warning: " #EXP "\n"); } \
while (0)
WARN_IF (x == 0);

等价于

1
2
do { if (x == 0)
fprintf (stderr, "Warning: " "x == 0" "\n"); } while (0);

EXP 不是展开成 x == 0 了吗?

真的吗?

这里暂停让读者自己理一下。

1

2

3

4

5

哎呀,把自己绕进去了。让我们一一对应一下:

  • EXP 对应 S
  • 展开之后 x == 0 对应 COMMIT_HASH
  • 字符串里,x 没有进一步展开,COMMIT_HASH 也没有。

好吧,确实没错。

EXP 的参数会 原封不动 替换到 if 语句中,并且被字符串化成为 fprintf 的参数。如果 x 是一个宏,它会在 if 语句中继续展开,但在字符串中不会。

(The argument for EXP is substituted once, as-is, into the if statement, and once, stringized, into the argument to fprintf. If x were a macro, it would be expanded in the if statement, but not in the string.)

看到这里我懂了,# 的字符串化 先起作用。然后 双引号阻止了里面的宏进一步展开

第三层:套娃

那我就是需要继续展开怎么办?

其实两边文档后面都给出了例子。还是以 GNU 的来参考(毕竟我们用的 g++):

1
2
3
4
5
6
#define xstr(s) str(s)
#define str(s) #s
#define foo 4
str (foo)
xstr (foo)

str (foo) 对应的展开是 "foo"xstr (foo) 则经历了两次展开:

  • 第一步先展开成 str(4)
  • 第二步再展开成 "4"

其实就是多展开一次,让 # 不那么快生效,好让传进去的宏有机会展开。

果然,我的代码改为

1
2
3
4
5
6
7
8
9
#define STR(S) #S
#define VSTR(V) STR(V)
//....
QString version;
#ifdef COMMIT_HASH
version = VSTR(COMMIT_HASH);
#endif

就达到了目的。到这里,运行之后,version 输出的就是具体的 git commit hash 了。

一点扩展

这里有了一个问题,如果传进去的宏,里面还有一层宏呢?

以 GNU 的例子为例,如果变成这样

1
2
3
4
5
6
7
#define xstr(s) str(s)
#define str(s) #s
#define foo bar
#define bar 4
xstr (foo)

会得到什么?

"bar" 还是 "4"

函数宏的层数需要总是比参数宏多一层吗?

需要再定义一层 #define ystr(s) xstr(s) 吗?

还说说两层的函数宏就能应对所有情况?

这个问题就留给读者自己尝试吧。

欢迎在留言说说你的看法。

后记

我有十年不怎么写过 C++ 了。

C++ 性能好,可以访问底层接口,可以精确控制代码行为和内存分配,支持多范式,开发自由度大,优点很多。所以在很多领域是首选。

但真的太复杂了,新手容易玩脱,老手把代码写对也不算轻松。心智负担大,历史包袱重。

要获得它那些优点,我甚至宁可写 C。其它场景,也优先 选择 Java,或者 Go。反正我是不可能选 C++ 作为新项目的开发语言了。何况现在还多了 Rust 这个选项。

偏偏碰上这么一个遗留项目。

只好把 C++ 临时捡起来,一顿重构。

原作者大概水平不高,或者写得不用心,又或者两者都有。代码质量很差,大量面条代码,动辄几百行的函数,宁可拷贝也不肯抽取函数,前后打脸的变量命名和代码逻辑,想当然的并发代码 …… 反正就一个编程规范的反面教材,吐槽不完。他大概也发现自己维护不下去,在我接手之前就撂挑子了。

重构过程按下不表,反正是一边猜测代码的意图,一边看运行效果去验证,等理解了,再去修正逻辑错误。

慢慢算是改出一点效果。但是还是有很多地方不太确定意图,为了保持跟服务端的兼容性,需要进行灰度更新。

出问题时,我需要能区分具体版本。

于是就有了上面的一出。

补充

关于 hash 部分,之所以说预测 hash 值只是 几乎 不可能,是因为还有一种办法:随机碰撞。

随机构造一个合法的 hash,尝试写入之后求真正的 hash,不断重试,直到构造的 hash 刚好和生成的 hash 一致为止。

理论上这是可以做到的。但这种做法会消耗大量的算力和时间。比特币挖矿的 POW 跟这个接近,但只是限定得到的 hash 的开头的零的数量,就已经耗费如此巨大的算力。如果要求算出的 hash 跟 构造的值 完全一致,需要的碰撞次数可能是天文数字。我不是这方面的专业人员,估算得可能不准确。但要说对普通开发电脑来说不可能,应该还是中肯的。(Google 破解 SHA-1 时,动用了大量的计算机集群,才构造出一个 PDF,获得预先构造的 hash)

而这,只是一次提交。难道每次提交都要动用超算,还要跑半天?


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。