单例九品--第八品[可用的设计]
- 上一品引入
- 写在前边
- 代码部分1
- 代码部分2
- 代码部分3
- 实现方式评注与思考
- 下一品设计的思考
上一品引入
第七品使用std::atomic静态函数实现了计数变量singletonCount的定义,解决了第六品引入的多线程安全的问题,但是从第五品开始全局对象都换成了指针的类型。因此一直存在着对象可能被修改的风险。第八品将来解决这个问题。
写在前边
- 基本思路
• 使用引用代替指针
• 方法1:将原始指针封装到类内部,使用函数提供引用接口
• 方法2:使用 placement new - 优点
• 避免指针赋值而产生的问题 - 缺点
• 代码组织相对复杂,不易扩展
本篇章将使用两种方法解决前边几品出现的指针类型的全局对象被修改的问题。
代码部分1
三个文件: main.cpp, sing.cpp和sing.h
- main.cpp
#include "sing.h"
static Sing::Init init;
static Sing& singletonInst = Sing::Instance();auto singletonInst2 = singletonInst.val;int main(int argc, char** argv)
{std::cout << "get value: " << singletonInst2 << '\n';std::cout << Sing::Instance().val << std::endl;
}
- sing.cpp
#include "sing.h"
#include <memory>
#include <iostream>Sing* Sing::singletonPtr;Sing::Init::Init()
{auto& count = RefCount();auto ori = count.fetch_add(1);if (ori == 0){singletonPtr = new Sing();}
}Sing::Init::~Init()
{auto& count = RefCount();auto ori = count.fetch_sub(1);if (ori == 1){delete singletonPtr;singletonPtr = nullptr;}
}
- sing.h
#pragma once
#include <iostream>
#include <atomic>class Sing
{
public:struct Init{Init();Init(const Init&) = delete;Init& operator= (const Init&) = delete;~Init();static auto& RefCount(){static std::atomic<unsigned> count{ 0 };return count;}};private:Sing(){std::cout << "Sing construct\n";val = 100;}~Sing(){std::cout << "Sing destroy\n";}Sing(const Sing&) = delete;Sing& operator= (const Sing&) = delete;public:int val;private:static Sing* singletonPtr;public:static Sing& Instance(){return *singletonPtr;}
};
在前边的实现中,都是定义一个指针类型的全局单例对象,然后通过静态函数instance完成单例的初始化。在这里实现的方式是,在sing类中定义一个私有sing*类型的全局对象,然后提供一个全局对象的public引用访问接口,一方面私有的全局对象能避免一些sing类之外的一切行为修改。另一方面, 共有的引用接口函数返回的是全局对象的引用,引用绑定以后,不会更换绑定对象,因此就避免了全局对象被修改的风险。这种实现方式是可以使用的单例设计模式了。
代码部分2
三个文件: sing.cpp main.cpp 和sing.h
- sing.cpp
#include "sing.h"
#include <memory>
#include <iostream>alignas(Sing) char singBuf[sizeof(Sing)];
Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf));Sing::Init::Init()
{auto& count = RefCount();auto ori = count.fetch_add(1);if (ori == 0){Sing* ptr = reinterpret_cast<Sing*>(singBuf);new (ptr) Sing(); // placement new的方式, 与new的方式构建对象有区别}
}Sing::Init::~Init()
{auto& count = RefCount();auto ori = count.fetch_sub(1);if (ori == 1){Sing* ptr = reinterpret_cast<Sing*>(singBuf);ptr->~Sing();}
}
- main.cpp
#include "sing.h"auto singletonInst2 = singletonInst.val;int main(int argc, char** argv)
{std::cout << "get value: " << singletonInst2 << '\n';std::cout << singletonInst.val << std::endl;return 0;
}
- sing.h
#pragma once
#include <iostream>
#include <atomic>class Sing
{
public:struct Init{Init();Init(const Init&) = delete;Init& operator= (const Init&) = delete;~Init();static auto& RefCount(){static std::atomic<unsigned> count{ 0 };return count;}};private:Sing(){std::cout << "Sing construct\n";val = 100;}~Sing(){std::cout << "Sing destroy\n";}Sing(const Sing&) = delete;Sing& operator= (const Sing&) = delete;public:int val;
};static Sing::Init init;
extern Sing& singletonInst;
这个例子,使用reinterpret_cast强制类型转换,通过placement new的方式完成的单例构造。这个写法在这种情况下(因为reinterpret_cast强制转换在这种情况下是在编译期完成的)是可以正常运行,并且不会因为翻译单元链接顺序造成静态初始化灾难的问题。
翻译单元中的这两句是是编译期的行为,具体解释如下:
alignas(Sing) char singBuf[sizeof(Sing)];
Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf));
-
alignas(Sing) char singBuf[sizeof(Sing)]; 这行代码是在编译期声明了一个名为 singBuf 的数组,其大小为 sizeof(Sing),并要求按照 Sing 类型的对齐方式进行对齐。这意味着编译器会在编译期为这个数组选择合适的内存布局和对齐方式。
-
Sing& singletonInst = (reinterpret_cast<Sing>(singBuf)); 这行代码在编译期进行了类型转换和引用绑定。reinterpret_cast 是一种类型转换操作符,它在编译期将 singBuf 的地址转换为 Sing* 类型的指针,然后通过解引用 * 操作符将其转换为 Sing& 类型的引用,最终将这个引用绑定到了 singletonInst 变量上。
所以,这两行代码都是在编译期执行的,而不是在运行期执行的。它们的目的是在编译期进行一些内存布局和类型转换的操作,以便在运行期能够正确地使用 singletonInst 变量。 -
reinterpret_cast 强制转换什么情况是运行期的行为,什么时候是编译期的行为?
答: 对于大多数情况来说,reinterpret_cast 操作符是在编译期完成的,但是在一些特殊情况下,比如使用了动态内存分配或者其它运行期的特性时,reinterpret_cast 的结果可能会在运行期才被确定。
在上述例子中,reinterpret_cast 的结果是一个编译时已知的常量表达式,因为它是根据 singBuf 的类型进行计算的,而 singBuf 是一个静态数组,其大小在编译期已知。因此,在这个特定的情况下,reinterpret_cast 的结果是在编译期确定的,而不是在运行期。
所以这里即便是不加constinit关键字,使Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf))
;在编译期完成强制转换,也不会因为链接顺序出现静态初始化灾难的问题。但是对于其他特定情况,就不行了,所以这种写法不具备通用性。因此最好在sing.cpp中写为constinit Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf))
;
那如果当reinterpret_cast面临不能自动在编译期完成的时候的情况,可以不使用constinit关键字解决及静态编译器灾难吗?请看下一个例子
代码部分3
三个文件: sing.h sing.cpp main.cpp
与代码部分2的区别就是将Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf))
放在sing.h中,这样在main.cpp中使用单例对象的时候就一定已经完成了初始化;
- sing.h
#pragma once
#include <iostream>
#include <atomic>class Sing
{
public:struct Init{Init();Init(const Init&) = delete;Init& operator= (const Init&) = delete;~Init();static auto& RefCount(){static std::atomic<unsigned> count{ 0 };return count;}};private:Sing(){std::cout << "Sing construct\n";val = 100;}~Sing(){std::cout << "Sing destroy\n";}Sing(const Sing&) = delete;Sing& operator= (const Sing&) = delete;public:int val;
};static Sing::Init init; // 完成Sing实例的构建,使用newplace new的方式在编译器已经分配好的那块地址上
extern char singBuf[sizeof(Sing)];
static Sing& singletonInst = *(reinterpret_cast<Sing*>(singBuf));
- sing.cpp
#include "sing.h"
#include <memory>
#include <iostream>alignas(Sing) char singBuf[sizeof(Sing)]; // 这句话在编译期会完成内存分配和内存对齐,也就是说会为char类型的字符数组singBuf分配一定大小的内存,但是具体的内存块(地址)// 要在运行期决定。Sing::Init::Init()
{auto& count = RefCount();auto ori = count.fetch_add(1);if (ori == 0){Sing* ptr = reinterpret_cast<Sing*>(singBuf);new (ptr) Sing(); // placement new的方式, 与new的方式构建对象有区别//这种情况下,singBuf是一个字符数组,其内存地址由编译器分配,但是具体地址在编译时尚未确定。//可以使用 placement new 在这个数组的内存上构建对象,因为编译器会为 new (singBuf) Sing() 提供所需的地址信息}
}Sing::Init::~Init()
{auto& count = RefCount();auto ori = count.fetch_sub(1);if (ori == 1){Sing* ptr = reinterpret_cast<Sing*>(singBuf);ptr->~Sing();}
}
- main.cpp
#include "sing.h"
auto singletonInst2 = singletonInst.val;int main(int argc, char** argv)
{std::cout << "get value: " << singletonInst2 << '\n';std::cout << singletonInst.val << std::endl;
}
这里将singletonInst实例在sing.h中完成初始化,这样不会出现mian.cpp调用实例,但是实例没有初始化构建的问题。
实现方式评注与思考
-
place new与new的区别
-
reinterpret_cast的用法, 强制转换是在编译期完成的还是运行期?
对于大多数情况来说,reinterpret_cast 操作符是在编译期完成的,但是在一些特殊情况下,比如使用了动态内存分配或者其它运行期的特性时,reinterpret_cast 的结果可能会在运行期才被确定。
下一品设计的思考
下一品将会完成 CRTP的设计思路,将单例逻辑与功能逻辑分开。