Morrison.J Android Dev Engineer

开发可以相互依赖的C++库


本文介绍了一种库开发技术,并用例子分析这种技术的可行性。使用这种技术开发的库,除了满足ABI,良好的可拓展性,同时,还具有可以相互依赖的特点。当我们需要开发两个插件,一个插件依赖另一个插件的so,反过来同样也适用,这种技术(设计方式)是验证可行的。

1. 前言

一般的,我们做一个库的做法,多半是将公共部分独立出来作为库,其它差异部分(客户端)动态加载库。目的是可以复用,避免重复劳动,同时,也有利于升级维护。那么,这样的库设计,可以说是具有明显层次关系的程序开发模式,客户端在上层,库在下层,库是不能反过来依赖客户端的。

在某些情况下,我们需要突破这种层次关系,将两个完全相同级别的东西(库)进行相互依赖。就好比两个进程一样,一个进程通过进程间通信,发起信号请求另一个进程处理事件,并将结果通过进程间通信返回。同样的,任何一个进程都可以向其它进程发起通信,请求任务并获得结果。如果我们不想要这种进程间的调用,直接在同一个进程中完成两个库的相互依赖,并保持独立性。这种设计方式我称为“平行库依赖”设计方式,简称“平行依赖”或者“平行设计”。

实现平行依赖,需要考虑三个关键点:

  1. 两个库的接口是一致的,调用平行层的另一个库的接口跟调用自己的接口完全相同;
  2. 接口必须满足升级后保持ABI;
  3. API可以自由拓展,一般来说,满足ABI的接口都认为是可以自由拓展的,但是还可以做得更好。

2. 平行依赖库接口的设计

这里先说结果,后续在罗列与一般库开发所不同的,需要思考的问题,最后给出这样的设计为什么能解决这些问题。

假设有两个库,LibA和LibB,上图从LibA的视角来展现了整个库接口的设计框架。

左边是LibA,代表着一个so。右边是LibB,代表着另一个so。

CallAPI:C-API的包装类,调用代理类;
C API:Lib的export函数,其它库可见;
C++API:Lib内部接口,跨库调用需要携带该类的对象;
C++Obj:C++API的实例,跨库调用携带的对象,实际上,携带的是对象的指针;
CPP:Lib内部实现;

LibA通过CallAPI封装的函数,实线表示对LibA自身进行访问,或者用虚线表示对LibB进行访问。

当创建一个CallAPI对象时,对象内保存了C-API的函数指针,要么是直接获得的,要么是通过dlsym获得。

对于LibA来说,拥有了CallAPI,调用LibB与调用自身并没有任何差别。

当CallAPI调用时,携带了一个C++Obj的指针,是C++API的实例指针。C-API的任务是调用C++Obj的成员函数。

其它隐含的内容,比如API的参数,如果不是内置类型,则与C++Obj类似,不同的地方在于,参数是传对象,C++Obj是传指针。

3. 平行依赖设计需要考虑的问题

3.1. 是否满足ABI?

对于层次依赖,lib满足ABI的做法就简单一些,毕竟它不需要反过来依赖。直接使用到C++ API这一层就可以了,无需后面的C API再套一层。因为lib创建的对象,客户端没有相同的签名,不会导致库加载冲突。对象的调用不会产生任何优先级问题。

但是这里不行,C++API创建的C++Obj对象,必须隐藏在lib内部,否则,调用C++API时,可能调用到优先级更高的一份代码。

3.2. API的参数是否满足ABI?

正如上一章最后一个说明,当参数是对象时,对象的成员函数也会存在优先级问题。LibA和LibB都有参数对象的一份成员函数的实现,我们希望:当在LibA中调用参数对象,使用的是LibA的实现,即使这个对象是从LibB传过来的。这种期望不应该被 “谁加载谁” 而破坏。比如LibA加载LibB,一般会调用LibA的实现(优先级更高,谁先加载用谁,后加载的会失败,所以调用不到),反之,LibB加载LibA,则使用LibB的实现。

3.3. 是否了解基类冲突问题?

基类冲突,也就是,我们在LibA和LibB中分别定义了两个对象,A和B,同时,它们都继承自C。当LibA加载LibB时,请问,C是LibA中的C,还是LibB中的C。答案是LibA中的C。如果此时,我们将任何一个Lib中的C改变,结果很可能让你很意外。这样的问题,我称它为“基类冲突”问题。归根到底,还是库加载的优先级问题。

3.4. 解决方案

上述几个问题,都可以通过一个GCC的编译参数解决,-fvisibility=hidden。然后我们定义一个宏: #define LIB_EXPORT __attribute__ ((visibility ("default")))

声明函数时,加上这个宏前缀: LIB_EXPORT void Lib_Func(int a);

这样,除了被LIB_EXPORT修饰的API,其它函数都是保存在Lib内部,可见性的范围被严格限制住。当一个对象的成员函数被C-API执行时,C-API在哪个lib,就调用哪个lib的实现。

3.5. 删除了参数对象的成员怎么办?

正如第二点说的,在哪里调用就使用哪里的那份实现。但是,如果我们在LibA中删除了一个对象的成员函数,而在LibB中又调用了这个成员函数,会发生什么事情呢?会不会因找不到函数符号而crash呢?

答案是不会,因为我们把参数类的CPP编译到LibA和LibB中,各自有一份实现。参数对象传递,只是传递了对象的数据,而成员函数不会传递。

当LibA删除了成员函数,但是LibB中有一份实现,就不要紧。这个与C++的对象模型有关,C++将成员函数编译为普通函数,并将该普通函数的第一个参数设置为对象的指针。 当我们调用对象的成员函数时,会像调用普通函数一样,将对象传入第一个参数,普通函数内部访问对象的数据成员。

所以,就有了我们说的ABI中一个要点:只要不改变类的结构(不增删成员变量),就不会破坏ABI,增加non-virtual函数是没有问题的。

3.6. Struct是否可以作为API的参数?

我们都很害怕在API中使用struct,因为我们改变不了成员变量。的确,但是我们可以使用struct包装一个容器,再定义一些成员函数访问和操作容器,达到我们的自定义参数类型的目的。 同样的道理,可以拓展到class。

下面以例子说明: 假设LibA加载LibB,称LibA为调用端,LibB为库lib。

Struct头文件:

struct MyStruct
{
    private
    int data = 0;
    public:
        void SetData_V1(const int& d)
        {
            data = d;
        }
}

void SetStruct(MyStruct& st);

lib代码:

void SetStruct(MyStruct& st)
{
    st.SetData_V1(2);
}

调用端代码:

MyStruct ms;
SetStruct(ms);

在此,称上述代码为版本1,并生成了一个lib_v1.so,结果表现为ms的data被设置为2.

在版本2的时候,MyStruct中的SetData_V1删除了,头文件变成:

struct MyStruct
{
    private
    int data = 0;
    public:
        void SetData_V2(const int& d)
        {
            data = d + 1;
        }
}

void SetStruct(MyStruct& st);

lib代码:

void SetStruct(MyStruct& st)
{
    st.SetData_V2(2);
}

调用端代码不变。

假设,此时我们没有升级lib,只是升级了调用端程序。请问,ms的值会是多少? 答案是:2,且不会发生因函数符号缺失而导致crash的问题。

深入分析: 这是由C++对象模型决定的,C++编译器会将成员函数变成普通函数的形式,比如上面的成员函数SetData_V1(const int& d),生成普通函数 SetData_V1(MyStruct* ms, const int& d). 这个普通函数已经存在于lib中,可以被调用到。

总结:当我们将Struct定义在头文件中,或者将其定义同时编译到调用端和lib时,增删函数都不会影响ABI。但是,隐含的,变更函数的行为,自然影响了程序逻辑。 所以,Struct可以用于满足ABI的API接口中作为参数,并且删除函数也不会导致crash。但应注意,两份实现可能会给后续的程序功能带来意外影响。

3.7. 参数Struct使用模板成员函数是否安全?

这个问题其实上面已经回答了,只是作为一个思考的角度,这里重申一下。我们知道,模板函数在使用到时,编译器才会为我们生成具体的函数,也称为“模板实例化”。当我们的LibA或者LibB任意一方对Struct的访问发生了变化,模板的实例化函数就会跟着变化,导致Struct成员函数的增删。但由于LibA和LibB中,肯定有一份实现,自然不存在找不到函数符号而crash的问题。近一步的,如果Struct的实现都是hidden的,各自独立,连程序逻辑的变更都不会存在互相干扰。

同样的道理,可以拓展到参数class。

3.8. C++API 应当怎么设计?

上面设计图中的C++API类,应当是遵循pImpl技法。这样,C++API的size不会改变,它可以稳定地作为C-API的参数。

3.9. 这样的设计存在哪些隐患?

由于LibA和LibB都拥有参数对象的一份实现,如果我们任意修改参数对象的成员函数,会导致前后功能不一致,导致不可预测的问题。这就要求我们严格遵守,只增不减的开发原则。

4. 总结

使用GCC的可见性参数配置 -fvisibility=hidden, 加上pImpl技法 和 C-API style,可以实现平行依赖。实现了平行依赖的两个Lib,可以相互加载,互相调用,并能保持二进制兼容性(ABI)。

5. 参考

《C++ API设计》
《程序员的自我修养—链接、装载与库》
《深度探索C++对象模型》


Comments

Content