一些关于C++单例跨动态库的个人理解

在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种方式:

  1. 封装一个动态库,所有单例都只能通过此动态库提供的接口(函数)获取。
    这个接口的实现必须在.cpp文件中。
    通过接口调用,让单例对象的创建只在此动态库中发生,单例唯一。
  2. 通过编译器的导入导出符号进行设定。win和linux有各自的导入导出符号,可以通过宏控制,但依然较繁琐。

5. 更复杂的情况

现在有主程序A ,动态库B,静态库C;静态库C中封装了一个单例类。

现在B直接调用(链接)了C; A直接调用了B和C。

此时,动态库B内部有一份静态库C,而可执行程序A中也有一份静态库C。
那么,程序A直接调用C的单例,和通过B调用C的单例,就会是2个不同的单例对象!!

推荐措施:

  1. 如果库封装了单例类,那就构建成动态库,而非静态库。
  2. 如果一定要构建成静态库,那么只能由主程序调用,不能让其他动态库使用此单例。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
下一篇