在C++的学习中,看到了许多关于单例跨动态库问题的解释。
但是大多数解释,都没能让我很好地理解这一问题出现的原因,以及解决方式。
下面是我对这一问题的个人理解,可能存在错误,仅供参考。
1. 单例动态库-声明与定义分离时
一段程序,通常由头文件(.h/.hpp)和源文件(.cpp)组成,我们习惯在头文件中写声明,在源文件中写实现。
下面是一个简单的单例动态库示例,dog.cpp将被编译成动态库,供主程序使用。
// --------- dog.hpp 头文件,仅声明
#pragma once
class Dog{
public:
// 静态函数,获取单例
static Dog& getInstance();
void printAddr(); // 输出当前对象地址
Dog(const Dog& dog) = delete;
Dog(Dog&& dog) = delete;
private:
Dog() = default;
};
// 输出对象地址
void printAddr();
// --------- dog.cpp 源文件,仅实现
#include "dog.hpp"
#include <iostream>
// C++ 11 起的线程安全的方式
Dog& Dog::getInstance(){
static Dog dog{};
return dog;
}
void Dog::printAddr(){
std::cout << (long long)this << std::endl;
}
void printAddr(){
Dog::getInstance().printAddr();
}
// --------- main.cpp 主程序
#include <iostream>
#include "dog.hpp"
int main(){
printAddr();
Dog::getInstance().printAddr();
}
正常配置下,无论Windows还是Linux,这段程序都没有问题。
在Windows下,动态库的头文件还需要导入导出符号。
主函数两次的输出相同。 无论如何操作,生成的单例对象唯一。
2. 直接在头文件中实现,或inline的方式
在某些情况下,我们可能采取内联的方式,直接将函数的实现写在头文件中,或者使用inline修饰函数。
此时代码如下:(注意dog会被编译成动态库)
// ---------- dog.hpp 头文件
#pragma once
#include <iostream>
class Dog{
public:
// 部分函数实现直接写在了dog.hpp中
static Dog& getInstance(){
static Dog dog{};
return dog;
}
void printAddr(){
std::cout << (long long)this << std::endl;
}
Dog(const Dog& dog) = delete;
Dog(Dog&& dog) = delete;
private:
Dog() = default;
};
void printAddr();
// ---------- dog.cpp 源文件
#include "dog.hpp"
#include <iostream>
void printAddr(){
Dog::getInstance().printAddr();
}
// ---------- 主程序
#include <iostream>
#include "dog.hpp"
int main(){
// 两行的输出可能不同
printAddr();
Dog::getInstance().printAddr();
}
此时,在Windows中,由于导入导出符号的存在,两次输出的地址依然相同,单例对象唯一。
但是在Linux中,两次输出的地址将不同,单例对象不在唯一!!
动态库和主程序将各自持有一个单例对象,根据代码调用位置的不同,使用不同对象。
3. 单例不唯一原因
第1种情况:
声明与实现分开,static Dog dog
仅出现在dog.cpp
中,因此单例对象只会有这一个,是正确的。
第2种情况:
将实现写在头文件中,各源文件#include
复制了头文件内容后,导致static Dog dog
出现在了多个.cpp
文件中。
如果是静态库,能够在编译期消除重复符号确保唯一性;但是动态库不行,因为编译期不知道库的具体内容。
包含该头文件的 主程序或动态库 都会认为自己有一份这个对象,如果在使用了getInstance(),会调用模块自身从头文件导入的那份代码,生成自己的 static Dog dog 实例。
比如情况2主函数中的代码:
1. printAddr()的实现在动态库中,会在动态库代码段调用getInstance,动态库生成了单例对象。
2. Dog::getInstance() 则直接调用头文件复制进来的代码,在主程序中调用,主程序又生成了自己的单例。
inline
关键字赋予对象外部链接,效果类似。
注意:
MSVC
工具链需要在动态库的头文件中设置导出符号__declspec(dllexport)
,在导入的地方设置导入符号__declspec(dllimport)
,使其仅在导出的地方生效,从而避免了第2种情况的错误。
在 Linux
下,动态库(.so)的符号默认是全局可见的,这会导致情况2中多个编译单元的 static
变量各自独立,从而产生多个单例对象。
4. 如何解决
就像上面说的,最好的方式就是 将声明与实现分离,头文件中写声明,源文件中写定义。
也可以使用“饿汉”模式,将单例设置为类的静态成员。
有的时候,比如纯头文件库,必须全部写在头文件中,此时有2种方式:
- 封装一个动态库,所有单例都只能通过此动态库提供的接口(函数)获取。
这个接口的实现必须在.cpp文件中。
通过接口调用,让单例对象的创建只在此动态库中发生,单例唯一。 - 通过编译器的导入导出符号进行设定。win和linux有各自的导入导出符号,可以通过宏控制,但依然较繁琐。
5. 更复杂的情况
现在有主程序A ,动态库B,静态库C;静态库C中封装了一个单例类。
现在B直接调用(链接)了C; A直接调用了B和C。
此时,动态库B内部有一份静态库C,而可执行程序A中也有一份静态库C。
那么,程序A直接调用C的单例,和通过B调用C的单例,就会是2个不同的单例对象!!
推荐措施:
- 如果库封装了单例类,那就构建成动态库,而非静态库。
- 如果一定要构建成静态库,那么只能由主程序调用,不能让其他动态库使用此单例。