CMake构建实战:项目开发卷
上QQ阅读APP看书,第一时间看更新

1.6.4 使用要求的传递性

1.6.3小节说明了构建要求和使用要求之间存在一定的传递性,从而使得构建目标这个抽象概念变得十分实用。本小节将继续深入探索有关传递性的问题。首先请思考以下问题。


如图1.6所示,如果一个库A被另一个库B链接,那么很显然,库A的使用要求应当传递到库B的构建要求中;如果库 B又被可执行文件main链接,那么同样地,库B的使用要求也应当传递到main的构建要求中。以上陈述都没有什么问题,那么问题来了:库A的使用要求是否也应传递到main的构建要求中呢?

图1.6 传递性问题的示意图

对于这个问题,我们先建立一个共识:如果可执行文件main本身使用了库A,那么库A的使用要求肯定应该传递给main的构建要求。这样一来,问题就变成了:main怎样才算使用了库A?一定是引用了库所对应的头文件,并调用了其中的函数或类吗?

当然未必。比如库B中的某个函数可能会返回一个在库A中定义的类型,main又调用了库B中的该函数,这就意味着main间接使用了库A。具体来说,main一定是引用了库B的某个头文件才能调用其中的函数,而这个库B的头文件又一定直接或间接地引用了库A中的头文件,否则它返回的库A中定义的类型就是未定义类型了。

既然main间接地引用了库A的头文件,也就意味着main应该根据库A的使用要求来链接它。这种情形称作“递归传递”。然而,如果库B不会在接口处暴露库A中定义的符号,而且main本身也不存在对库A的直接引用,那么,库A的使用要求自然也就不必递归传递给main了。

下面一起来看一下这两种情况的具体例程。

无须递归传递的例程

为了更好地演示构建要求和使用要求的传递性,这里会将库A和库B分别放在不同的子目录中。这样在编译时就必须指定头文件搜索目录,也就是形成了一个强制的要求。另外,为了方便起见,我们会将库A和库B作为静态库来构建。

库A的头文件和源文件分别如代码清单1.32和代码清单1.33所示。其中,定义了一个类A,提供对其私有整型成员变量的取值和写值函数。

代码清单1.32 ch001/无须传递/liba/a.h

struct A {
    void set(int val);
    int get();
 
  private:
    int f;
};

代码清单1.33 ch001/无须传递/liba/a.cpp

#include "a.h"
 
void A::set(int val) { f = val; }
 
int A::get() { return f; }

库B的头文件和源文件如代码清单1.34和代码清单1.35所示。其中,定义了一个函数f,用于操作库A中的类A并输出取值结果。

代码清单1.34 ch001/无须传递/libb/b.h

void f();

代码清单1.35 ch001/无须传递/libb/b.cpp

#include "b.h"
#include <a.h>
#include <cstdio>
 
void f() {
    A a;
    a.set(10);
    printf("%d\n", a.get());
}

主程序的代码则直接调用库B中的函数f,如代码清单1.36所示。

代码清单1.36 ch001/无须传递/main.cpp

#include <b.h>
 
int main() {
    f();
    return 0;
}

各个构建目标的构建要求和使用要求及其关系如图1.7所示。需要注意的是,在构建静态库时没有链接这一步,因此静态库A有关链接的使用要求需要传递到静态库B的使用要求中,从而保证最终链接为可执行文件时能够同时链接这两个静态库。

图1.7 “无须传递”例程的目标要求示意图(*标记的要求为传递的要求)

在“传递(2)”过程中,静态库直到构建最终的可执行文件或动态库时才会被链接,因此构建静态库B时无须链接静态库A,“链接库A”这个使用要求将会传递到B的使用要求中。

另外,不同于在前面分别为Windows和Linux平台绘制了不同的目标要求示意图,这里绘制的是一个“平台无关”的示意图。在构建要求和使用要求的描述中,我们没有使用任何具体的命令和参数。可以说,这样一个示意图所展示的结构,是一个跨平台构建系统应该能够处理的构建拓扑。就像编译器处理“抽象语法树”或“中间表示”一样,跨平台构建系统有责任将这个构建拓扑的“表示”翻译成所需平台环境中支持的构建命令和参数。这也是后面介绍的CMake能够完成的工作。

现在,先来手动完成这项翻译任务吧。

使用MSVC和NMake构建

NMake Makefile如代码清单1.37所示。

代码清单1.37 ch001/无须传递/NMakefile

main.exe: main.obj a.lib b.lib
    cl main.obj a.lib b.lib /Fe"main.exe"
 
a.lib: a.obj
    lib /out:a.lib a.obj
 
b.lib: b.obj
    lib /out:b.lib b.obj
 
a.obj: liba/a.cpp
    cl /c liba/a.cpp /Fo"a.obj"
 
b.obj: libb/b.cpp
    cl /c libb/b.cpp /I liba /Fo"b.obj"
 
main.obj: main.cpp
    cl /c main.cpp /I libb /Fo"main.obj"
    
clean:
    del *.obj *.lib *.exe

使用GCC和make构建

Makefile如代码清单1.38所示。

代码清单1.38 ch001/无须传递/Makefile

main: main.o liba.a libb.a
    g++ main.o -o main -L. -la -lb
 
liba.a: a.o
    ar rcs liba.a a.o
 
libb.a: b.o
    ar rcs libb.a b.o
 
a.o: liba/a.cpp
    g++ -c liba/a.cpp -o a.o
 
b.o: libb/b.cpp
    g++ -Iliba -c libb/b.cpp -o b.o
 
main.o: main.cpp
    g++ -Iliba -Ilibb -c main.cpp -o main.o
    
clean:
    rm *.o *.a *.so main 

尝试执行make,发现有错误产生:

$ cd CMake-Book/src/ch001/无须传递
$ make
...
g++ main.o -o main -L. -la -lb
./libb.a(b.o): In function `f()':
b.cpp:(.text+0x24): undefined reference to `A::set(int)'
b.cpp:(.text+0x30): undefined reference to `A::get()'
collect2: error: ld returned 1 exit status
Makefile0:2: recipe for target 'main' failed
make: *** [main] Error 1

在最后的链接过程中,链接器无法解析libb.a,也就是静态库B的函数f中引用的两个符号:A::set(int)和A::get()。这两个符号应该在静态库A中定义过了,链接器却没有找到,这是为什么呢?

对于GCC来说,提供的链接库的参数-la和-lb的顺序对链接过程存在重要影响。链接器会根据参数指定的链接库顺序依次解析之前遇到过的未定义的符号,不走回头路。也就是说,静态库B中未定义的符号,链接器不会再回到A中去检索了。

为了避免这个问题,我们应当根据依赖关系,先链接有依赖的库,再链接被依赖的库。这样,有依赖的库中遇到的未定义的符号,总能被链接器从被依赖的库中找到。因此,对于该例程而言, Makefile的第二行命令应当做一点修改,即调换参数-la和-lb的顺序。

MSVC中不存在这个问题,因为MSVC链接器会尝试在所有参数指定的链接库中检索并解析未定义的符号。不过,当多个库中同时定义了一个相同的符号(符号重名)时, MSVC链接器也会根据参数指定的顺序来决定到底将符号解析为哪一个库中的定义。

存在间接引用的例程

接下来看一下另一种情况的例程——存在间接引用,也就是需要将使用要求递归传递到最终的可执行文件的构建要求中。本例基本上会复用前面的例程代码,只对库B的代码做一些修改,其修改后的头文件和源文件如代码清单1.39和代码清单1.40所示。

代码清单1.39 ch001/间接引用/libb/b.h

#include <a.h>
 
A f();

代码清单1.40 ch001/间接引用/libb/b.cpp

#include "b.h"
#include <cstdio>
 
A f() {
    A a;
    a.set(10);
    return a;
}

这里将库B中的函数f的返回值类型从void 改为了类A。类A是定义在库A中的类型,所以库B的头文件b.h中也必须先引用库A的头文件a.h。可执行文件代码 main.cpp中引用了头文件b.h,这也就意味着间接引用了库A。

对于本例来说,库A的头文件搜索目录这个使用要求,会被传递到库B同时作为其构建要求和使用要求,如图1.8所示。当库B的使用要求传递到可执行文件main时,库A所要求的头文件搜索目录会一同传递到可执行文件的构建要求中。当然,在编写Makefile时,需要为main目标的构建规则增加设定头文件搜索目录的编译器选项。

图1.8 “间接引用”例程的目标要求示意图(*标记的要求为传递的要求,
突出显示了不同之处)

传递方式总结

结合前两个例程能够发现,使用要求在被传递时存在多种可能性:

1.传递到使用者的构建要求;

2.传递到使用者的使用要求;

3.同时传递到使用者的构建要求和使用要求。

前面两个例程分别对应第一种情况和第三种情况。第二种情况一般在当头文件(接口)使用了某个库,而源程序(实现)中并没有使用这个库时才会用到,多见于伪构建目标。

举个另类但还算实用的例子:当希望引用一个接口库就可以自动链接多个库时,实际上就是要将多个链接库的使用要求传递给这个接口库的使用要求。接口库是伪构建目标,不需要编译,也就不存在构建要求。因此,这正是仅传递给使用者的使用要求的情形。如图1.9所示,这里的接口库AB就相当于库A和库B的集合的别名。

图1.9 仅传递到接口库使用要求的目标要求示意图

至此,构建目标最重要的两类属性“构建要求”和“使用要求”基本介绍完毕。笔者通过多个实例展示了二者的表现形式和作用原理,体现了抽象出这几个概念的动机——分离关注点,面向目标解耦构建参数,这样更容易厘清大型复杂工程的各部分关系,轻松搞定构建过程。另外,通过这些属性,我们也能够用统一的方式描述在不同平台中构建各部分程序的拓扑结构和具体要求,并最终将其翻译成不同平台中具体的构建命令和参数。这也是一个合格的跨平台构建系统应当具备的能力。