简化构建系统

C++的构建系统(build system)估计是最复杂,最不受重视在东西了。从用sh随手写一个配置脚本到autotools,CMake、Boost.build这种自成系统的东西,差不多像是单细胞到哺乳动物这么大的差距,最近看到很多开源项目的维护者也很头疼这个问题。最不重视的原因在于,相比于语言和平台,很少有文章和书对构建系统做研究。公司中也一般是以搭环境为名放半个人力,或者让配置管理员兼职维护。

中国的环境下,一定有人说用IDE不就好了。IDE的能力大约相当于Makefile,处理能力和复杂度都是一个折中水平。

最科学和理论一致的构建系统是boost.build,可惜最复杂的也是它。本文大部分理论分析是来自于boost.build的概念模型。

业界使用最多的可能是cmake和autotools,它们基本上同一个思路,cmake算是一个更好的autotools实现,基于这个思路,后面还有bazel/meson等构建系统。

方便java背景的程序员做个了解,makefile和ide大约相当于ant;cmake、autotools大约相当于maven;boost、bazel和meson大约相当于gradle;但注意这些工具都不包括包管理的部分。

C++的构建系统复杂度有多大,一个中等项目的构建脚本,一般都超过1000行。android这么大的项目一般都有一个自己的专用构建系统(其实有三个分别基于make, ninja, gradle,甚至cmake也在实验),构建系统本身就超过几万行。

最可怕的一点,除了最近几个构建系统如bazel/gyp,以及较早的scons,使用了python语言开发,其它构建系统基本上都没有使用主流编程语言。cmake的语法被吐槽了无数次,在邮件列表中,从2005年开始就有人提出用lua语法,甚至有实验版本。Boost.build也是反复摇摆,甚至有一个完全python实现的版本2015出现,到2019也没能转正。原因也很简单,DSL在用户角度上非常简单实用,不少用户希望基于这样简单的DSL就可以扩展出复杂构建系统。DSL在专用构建上的简洁,其实是牺牲了通用编程的能力。对于过千行的脚本,没有函数,模块,以及复杂数据结构的表达能力,编译系统不可能有可维护的实现。

那么,为什么这么复杂?

C/C++的传统是渐近式增强功能。一个C/C++软件适用范围可以非常广泛,从只有几K内存的SOC到以T为单位计算内存超大系统,只要重新配置,编译就可以使用。特别是同时可以支持多CPU架构,交叉编译的各种组合,让编译系统复杂度复杂的多。

C/C++喜欢做替代项目,SSL最少有5个API接近的库,还有十多个功能简化库。C++标准库其实都有三个独立的实现。它们各自有不同的适用环境。

除了window和macos,其实大多数C/C++软件运行的操作系统平台都是支持类Unix接口,但又有一些特殊之处,比如poll/epoll,pthread/nativethread/greenthread。C/C++要想达到一致的行为,需要为这些不一致构建不同的代码。随着微软逐步融入开源社区,C/C++软件也开始考虑对windows平台有更好的支持,而不是扔一个分支给windows社区自己玩。

这一切决策,都被放在构建阶段完成。用户的特性,开发者的特性,系统的特性都需要构建系统一一考虑,最后融合成一个配置,编译,安装过程。

更为糟糕的情况,每个库,每个软件的决策过程并不完全一致。

如何解决

当问题分析清楚了,那么解决方案也就在眼前了。

  1. 改渐近式增强功能为阶梯式增强。提供配置方案,而不是配置选项。window平台一套方案,linux一套方案,如果要两套,也没有问题。
  2. 为可替换的库做功能选择。用什么功能,引入什么库,不用这个功能,不引入这个库。
  3. 最后,也是最重要的一点。所有的配置方案放到源代码中,不要放到构建系统中。

综合来说,软件应该是可以按功能裁剪,而不是按环境支持水平来裁剪。也就是说,除了用户的要求,开发者和系统环境决定都是做功能减法。构建系统配置阶段,用户可以加功能,也可以减功能。但开发者可以决定,这个功能不支持在当前环境下使用。

在具体解决方案之前,我们还要讨论一下项目结构

从最简单的项目说起,那就是一个cpp文件,编译一个二进制文件,安装配置到一个地方。复杂一点就是多个cpp产生多个二进制文件。再复杂一点,就是多个二进制之间有共用的部分,一般是静态库或动态库。再复杂一点的项目,可能不止是cpp文件,可能还有其它生成的文件。再复杂一点可能就不只是一个cpp项目了。

我们可能把构建按传统的config/make/make install分成4个步骤:

  1. 功能选择
  2. 项目结构
  3. 生成/编译/测试
  4. 安装发布

传统上,构建系统生成的功能选择是一个config.h文件,包括一系列的宏。而在构建时,一些源文件会被编译,一些源文件不会被编译。所以实际上构建系统实际上生成了这些东西:

  1. 代码:主要是宏,可能包括一些生成代码
  2. 编译范围:文件选择
  3. 引用的库:根据支持库和功能,选择链接
  4. 编译选项:类似-std=c++17这种。

而真正构建系统需要构建的工具流程,依赖关系,其实很多简单的构建系统并没有关注到。我们把所有的配置方案放到源代码,就可以简化构建系统,让它更加关注工具流程,依赖关系。

用constexpr代替宏,用代码模板取代生成代码,这些都是可以做的事情。就算是只使用宏,也那么也只是代码的问题,所以并不关键。

文件全部编译,这点需要修改代码,通过编译宏或constexpr if或运行时分支都可以完成。

引用库,我们可以使用auto linking功能。或者通过构建工具自动连接。

编译选项,这需要一点配合,一方面,希望cxx_feature功能进一步强大;另一方面,我们可能可以与构建工具约定一个feature集合,比如

https://boostorg.github.io/build/manual/develop/index.html#bbv2.overview.builtins.featuresboostorg.github.io

Boost.Build User Manual

Builtin featuresboostorg.github.io

通过这个集合,我们可以约定一个文件或一个组件(多个文件组成)支持哪些编译特性,构建系统可以据些选择适当的编译选项。

C++构建系统比较复杂另一个原因,在于构建系统试图非侵入的构建一个构建流程。表现就是每个构建配置都是完全独立于目录结构和文件,用一个配置文件描述整个软件系统。甚至可以毫不冲突的定义多个构建系统。这种结构并没有错,但过度描述本身就会带来复杂度,同时也不满足一处定义的原则。很多人都会奇怪为什么要在构建配置文件中重新说明哪些源文件编译,哪些原文件不编译。构建系统如果使用约定大于配置,我们可以构建出非常简单的构建方案,以下是一种可能的想法。

一个软件由多个组件组成,每个组件由一个目录内的源码文件构建出来。

C++的组件包括三种,一是可执行文件 ,二是源代码库,三是预编译库。当然,这个组件类型可以扩展。

我们约定,组件的目录名就是它的组件名,也就是二进制的名称主体。如果需要名字空间,可以使用二级名或多级目录。目录下如果有子目录include或src或lib或test之一,它就是一个组件。如果组件没有被其它组件引用过,它就是一个可执行文件。反之,它是一个库。例如:

some-component/
  include/
  subname/
    src/
    include/

这是一个组件,名为some-component。而subname组件有一个二级组件名,它的名字会变成some-component_subname,注意使用了下划线。

这关于组件的类别约定有点多余。因为如果是库,它一定有include,而可执行文件它应该没有include目录。如果有lib目录,因为是libxxx.a文件,所以它一定是预编译的库。

需要多说两句关于预编译库。预编译库的lib目录下可以包括很多个变体,类似boost的约定,不同feature实际上是编码到库文件名上的,所以lib目录下可能包括这样一组文件libname-{compiler,runtime,config,...}.a或着windows的dll, lib等文件。

但是库的隐式依赖我们还没有地方保存,我考虑用一个build-request.toml来保存(toml是一个新式的轻量级配置文件格式)。

暂时包括两个部分。一是feature的说明,以名值对的形式。通过配置约定接受的feature取值。对于没有配置的feature就是默认是支持。正如前面提到的,开发者在这里做减法。另一部分是预编译库的变体隐式依赖。大约是这样

# 支持的变体
feature-name = ["value1", "value2"]

# 默认隐式依赖
depends = "name"

# 变体的隐式依赖
[<feature-name>value]
depends = ["name1", "name2"]

构建工具不是编译器,最终是需要调用编译工具。编译工具的多样性也是一个复杂性的来源。要想简化,就需要规范接口。大体上,编译工具是一个可执行程序,根据一些输入,产生指定输出。构建工具就根据输入和输出定义接口。

  • 编译compiler :输入源文件,输出目标文件。
  • 链接linker: 输入目标文件和库,输出库和可执行程序

有这么简单吗?现实没有,理想有。其实本质上是两点,编译器我们可以确定是源文件到目标文件一一映射,链接器是目标文件到可执行程序的多对一映射。这样两个工具就够我们生产一个构建树了。

我们只把范围限制在程序/配置/文档的生成上,大部分工具可以归在这两类工具中。这样配置就比较简单,甚至不需要配置,约定就可以完成大部分工作。

基本约定是这样:

  1. 一个目录一个构建树,作为一个独立的构建单元计算依赖。
  2. 一个构建树有一个主要输出,应该是文件。
  3. 一个构建树可以有多个编译过程,以文件类型(后缀)驱动,但应只汇聚一次。

这个约定,基于两个考虑,一是根据构建单元(组件)来计算依赖关系,会按文件计算会简化化很多,同时也照顾了每个单元有一个汇聚输出,可以简化多个变体的构建。

发布于 2019-10-24