单例模式
单例模式作为最简单但最常用的设计模式,值得仔细整理一下 C++ 的相关语法。
单例模式简介
单例模式(Singleton Pattern),使用最广泛的设计模式之一。其意图是保证一个类仅有一个实例被构造,并提供一个访问它的全局访问接口,该实例被程序的所有模块共享。
大致的做法为:
- 定义一个单例类;
- 私有化构造函数,防止外界直接创建单例类的对象;
- 禁用拷贝构造,移动赋值等函数,可以私有化,也可以直接使用
=delete;
- 使用一个公有的静态方法获取该实例;
- 确保在第一次调用之前该实例被构造。
在 C++中我们需要考虑:
- 在什么时机构造?程序一开始,第一次调用前?
- 如何构造这个实例?在堆上构造,还是通过静态变量?如果单例在堆上构造,是否存在内存泄漏?
- 实例构造失败会如何?抛异常?返回空指针?
- 通过接口返回指针还是引用?
- 线程安全?多线程下会不会重复构造?加锁?
我们暂时不考虑类的继承问题,以及需要给单例的构造传参数的问题。
关于构造时机的不同,有以下两种习惯的称呼:
- 饿汉模式(Eager Singleton):在程序启动后立刻构造单例;
- 懒汉模式(Lazy Singleton):在第一次调用前构造单例。
单例模式实现
我们在下文中会实现多个略有不同的 Singleton 类,提供get_instance作为接口,在实例的构造和析构时分别输出消息
1 2
| Singleton: call Constructor Singleton: call Destructor
|
并且使用如下的 main 函数进行调用,验证构造时机并确保没有重复构造。
1 2 3 4 5 6 7 8
| int main(int argc, char *argv[]) { std::cout << "main: begin\n"; Singleton::get_instance(); std::cout << "main: hello,world\n"; Singleton::get_instance(); std::cout << "main: end\n"; return 0; }
|
这里我们把做法分成两类,然后进行进一步的讨论:
- 第一类通过静态变量实现;
- 第二类需要在堆上构造单例(更加复杂,存在更多的问题)。
基于静态变量
已知类的静态变量的构造是在程序启动后,main 函数之前完成的,而函数体内部的局部静态变量则是在第一次调用函数时完成的,这两个特点刚好可以分别满足我们的需求。
由于是通过静态变量实现的单例,我们不需要考虑内存泄漏的问题,构造失败也直接通过抛异常体现,我们的接口返回静态变量的引用即可。C++保证静态变量的构造是线程安全的,从 C++11 开始,保证局部静态变量的构造也是线程安全的,这些是编译器自动完成的,我们不需要考虑。
基于类的静态变量,可以实现饿汉版的单例模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
static Singleton demo; public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton &get_instance() { return demo; } };
Singleton Singleton::demo;
|
运行结果:(确实在 main 函数之前构造,而且只构造了一次)
1 2 3 4 5
| Singleton: call Constructor main: begin main: hello,world main: end Singleton: call Destructor
|
基于类的静态函数的局部静态变量,可以实现懒汉版的单例模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton &get_instance() { static Singleton demo; return demo; } };
|
运行结果:(确实在第一次调用时构造,而且只构造了一次)
1 2 3 4 5
| main: begin Singleton: call Constructor main: hello,world main: end Singleton: call Destructor
|
这个实现在语法上就漂亮了很多。事实上,这就是在 C++11 之后,Effective C++最推荐的单例模式写法。
C++11 保证局部静态变量的构造是线程安全的,我们可以通过cppinsight.io查看经过简单处理之后的更本质的形式,上文懒汉版单例类的get_instance可能被处理为如下形式(具体实现与编译器有关)。由此可见,编译器确实做了一些确保构造安全和线程安全的工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| static inline Singleton &get_instance() { static uint64_t __demoGuard; alignas(Singleton) static char __demo[sizeof(Singleton)];
if (!__demoGuard) { if (__cxa_guard_acquire(&__demoGuard)) { try { new (&__demo) Singleton(); __demoGuard = true; } catch (...) { __cxa_guard_abort(&__demoGuard); throw; }
__cxa_guard_release(&__demoGuard); } } return *reinterpret_cast<Singleton *>(__demo); }
|
基于 new 实现
如果不使用静态变量,那么我们就需要直接操作指针,直接面对构造安全、线程安全和内存安全等问题,这通常是吃力不讨好的行为,但是鉴于单例模式在早期有很多类似的实现,还是记录一下吧。
饿汉版的直接实现如下,这里还是使用了类的静态成员,虽然是指针类型,仍需要在类的定义之外进行 new,因为必须在类的定义完成之后。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
static Singleton *demo;
public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton *get_instance() { return demo; } };
Singleton *Singleton::demo = new Singleton();
|
运行结果:(确实在 main 函数之前构造,而且只构造了一次,注意这里没有调用析构函数)
1 2 3 4
| Singleton: call Constructor main: begin main: hello,world main: end
|
懒汉版的直接实现如下,这里还是使用了类的静态成员,作为指针类型可以 inline 初始化为 nullptr,不需要在类的外面加代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
inline static Singleton *demo{nullptr};
public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton *get_instance() { if (demo == nullptr) { demo = new Singleton(); } return demo; } };
|
运行结果:(确实在第一次调用之前构造,而且只构造了一次,注意这里没有调用析构函数)
1 2 3 4
| main: begin Singleton: call Constructor main: hello,world main: end
|
这两个基于 new 的原始版本的单例模式,存在非常多的问题:
- 第一个问题是单例的析构函数始终不会被调用,直到程序结束回收内存。解决方法可以是 RAII,直接使用 C++的智能指针,或者自己实现一个嵌套类,在类的静态成员析构时调用 delete,这里略去。
- 第二个问题是构造失败的处理,比如 new 失败了?这只是细节末节,加一些保护措施即可,不做讨论。
- 第三个问题是线程安全,我们只需要考虑饿汉版本,因为全局变量的构造是线程安全的。
我们重点关注第三个问题,针对线程安全进行改进。
下面是改进后的一种饿汉版实现,这里采用两次判断:如果是空,先加锁,如果是空,才构造。这种写法曾经被广泛采用,称为双检测锁模式(DCL: Double-Checked Locking Pattern),可以很大程度上确保线性安全。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
inline static Singleton *demo{nullptr}; inline static std::mutex lock;
public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton *get_instance() { if (demo == nullptr) { std::lock_guard tmp{lock}; if (demo == nullptr) { demo = new Singleton(); } } return demo; } };
|
运行结果:(确实在第一次调用之前构造,而且只构造了一次,注意这里没有调用析构函数)
1 2 3 4
| main: begin Singleton: call Constructor main: hello,world main: end
|
DCL 的写法还是可能有问题的,在特定情况下可能出现内存操作在编译器优化后的顺序冲突问题,这里不做讨论,只是给出解决办法:在 C++11 之后使用 atomic 原子操作。进一步改进得到的饿汉版实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Singleton { protected: Singleton() { std::cout << "Singleton: call Constructor\n"; };
inline static std::atomic<Singleton *> demo{nullptr}; inline static std::mutex lock;
public: Singleton(const Singleton &) = delete; Singleton &operator=(const Singleton &) = delete;
virtual ~Singleton() { std::cout << "Singleton: call Destructor\n"; }
static Singleton *get_instance() { if (demo == nullptr) { std::lock_guard lc{lock}; if (demo == nullptr) { demo = new Singleton(); } } return demo; } };
|
运行结果:(确实在第一次调用之前构造,而且只构造了一次,注意这里没有调用析构函数)
1 2 3 4
| main: begin Singleton: call Constructor main: hello,world main: end
|
补充
单例基类
我们可以通过继承一个不可复制基类,实现一个可复用的单例模式:它自动删除了拷贝构造和赋值,并且私有化了构造函数,但是仍然需要具体写出(可带参数的)get_instance接口,这里没有选择 CRTP 等技巧通过模板类实现,因为那样的实例化可能不受控制,无法禁止某些非法语法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #include <iostream>
class SingletonBase { protected: SingletonBase() = default;
public: SingletonBase(const SingletonBase &) = delete; SingletonBase &operator=(const SingletonBase &) = delete; ~SingletonBase() = default; };
class A : public SingletonBase { private: int m_s{0};
A(int s) : m_s(s) { std::cout << "A: call Constructor with s=" << m_s << "\n"; }
~A() { std::cout << "A: call Destructor with s=" << m_s << "\n"; }
public: static A &get_instance(int s) { static A tmp(s); return tmp; } };
int main(int argc, char *argv[]) { std::cout << "main: begin\n"; A::get_instance(4); std::cout << "main: hello,world\n"; A::get_instance(40); std::cout << "main: end\n"; return 0; }
|
运行结果如下:
1 2 3 4 5
| main: begin A: call Constructor with s=4 main: hello,world main: end A: call Destructor with s=4
|
动态库中的单例模式
对于这里所讨论的单例模式,如果我们将其封装在动态库中使用,那么其实还会遇到很多问题,这些问题可能会导致单例并不是真正的单例,而是多个编译单元的不同实例。
这些问题可能与平台相关,可能和符号导出相关,还可能和静态库动态库混合使用相关。目前我没有精力去关注这些细节,只是将一些可能的避免措施记录一下:
- 不要将单例的实现使用inline的写法放在类的头文件中,而是在头文件中只进行声明,在单独的cpp文件中进行实现;
- 对于一个大的系统,将所有的单例集中到一个动态库模块中,避免分散。
- 对于跨动态库使用单例,注意明确标记当前应该是导入还是导出符号。
CRTP
奇异递归模板模式 CRTP 是一个非常经典的C++静态多态实现方案,在很多库中都有应用,这里整理一下。
概述
奇异递归模板模式(Curiously Recurring Template Pattern, CRTP)是 C++ 编程中一种常用的静态多态方案,它通过模板继承和静态绑定来实现类型多态。
相比于C++直接提供的基于虚函数的动态多态方案,CRTP既可以避免虚函数的额外性能开销,还可以在编译时获得更好的类型检查和优化。
C++ 社区实在是一群命名鬼才,几乎所有的命名都很糟糕,包括但不限于 CRTP,RAII,deducing this,通过名字根本无法判断它到底是什么。
CRTP的核心思想如下:
- 模板基类是一个模板类,模板参数是派生类自身。(这种递归的模板参数是这个模式名字的来源)
- 派生类:派生类继承自通过自身类型实例化的基类。
通过基类调用方法时,首先会将this指针进行类型转换为派生类的类型(因为派生类类型是模板参数,基类可以获取到派生类类型),然后就可以调用派生类的方法。
这种处理导致我们无法添加基类自身的方法实现并使用,因此基类只能作为一个抽象基类使用。
这一切都发生在模板实例化的过程中,在编译期中即可完全确定。
CRTP具有如下的优势:
- 静态多态:CRTP的是静态多态,通过基类调用派生类的方法是静态绑定的,完全在编译期通过模板实例化确定。与动态多态相比效率更高,因为完全避免了虚函数调用的开销。
- 类型安全:CRTP在编译时可以进行类型检查,从而确保方法调用的类型安全。
事实上,除了原理不同,CRTP和虚函数的使用情景并不是完全重合的,例如CRTP可以使用在某些虚函数无法应用的情景(静态成员函数,模板成员函数等)。
示例
我们从传统的,基于虚函数的动态多态出发,有下面的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| #include <iostream> #include <string>
class Base { public: virtual ~Base() = default;
virtual void run() const { std::cout << "Base Run\n"; } };
class Derived1 : public Base { private: std::string m_name;
public: explicit Derived1(std::string name) : m_name(std::move(name)) {}
void run() const override { std::cout << "Derived1 Run, his name is " << m_name << '\n'; } };
class Derived2 : public Base { private: std::string m_name;
public: explicit Derived2(std::string name) : m_name(std::move(name)) {}
void run() const override { std::cout << "Derived2 Run, her name is " << m_name << '\n'; } };
void Action(Base &obj) { obj.run(); }
int main() { Base b; Action(b);
Derived1 d1("Tom"); Action(d1);
Derived2 d2("Jerry"); Action(d2);
return 0; }
|
运行结果如下
1 2 3
| Base Run Derived1 Run, his name is Tom Derived2 Run, her name is Jerry
|
其中的Action函数中,我们通过基类的引用(或指针)调用了虚函数,实现了动态多态
1 2 3
| void Action(Base &obj) { obj.run(); }
|
在CRTP的实现中,我们将派生类作为模板参数传递给基类,通过基类调用时,首先通过类型转换到派生类,然后调用对应的方法。
1 2 3 4 5
| template <typename Derived> class Base { public: void run() { static_cast<Derived *>(this)->run(); } };
|
CRTP的完整实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| #include <iostream> #include <string>
template <typename Derived> class Base { public: void run() { static_cast<Derived *>(this)->run(); } };
class Derived1 : public Base<Derived1> { private: std::string m_name;
public: explicit Derived1(std::string name) : m_name(std::move(name)) {}
void run() const { std::cout << "Derived1 Run, his name is " << m_name << '\n'; } };
class Derived2 : public Base<Derived2> { private: std::string m_name;
public: explicit Derived2(std::string name) : m_name(std::move(name)) {}
void run() const { std::cout << "Derived2 Run, her name is " << m_name << '\n'; } };
template <typename T> void Action(Base<T> &obj) { obj.run(); }
int main() { Derived1 d1("Tom"); Action(d1);
Derived2 d2("Jerry"); Action(d2); return 0; }
|
运行结果如下
1 2
| Derived1 Run, his name is Tom Derived2 Run, her name is Jerry
|
注意到这里我们无法实例化和调用基类对象,基类只是起到抽象基类的作用。
有时为了省略频繁出现的static_cast,也可以在类中提供derived方法
1 2 3 4 5 6
| constexpr const Derived &derived() const { return static_cast<const Derived &>(*this); } constexpr Derived &derived(){ return static_cast<Derived &>(*this); }
|
CRTP也可以用于多层继承,此时只有最底层的具体类可以被实例化,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| #include <iostream>
template <typename D> class A { public: void run() { static_cast<D*>(this)->func1(); static_cast<D*>(this)->func2(); static_cast<D*>(this)->func3(); }
void func1() { std::cout << "A::func1\n"; }
void func2() { std::cout << "A::func2\n"; }
void func3() { std::cout << "A::func3\n"; } };
template <typename D> class B : public A<D> { public: void func3() { std::cout << "B::func3\n"; static_cast<D*>(this)->func4(); }
void func4() { std::cout << "B::func4\n"; } };
class C1 : public B<C1> { public: void func4() { std::cout << "C1::func4\n"; } };
class C2 : public B<C2> { public: void func2() { std::cout << "C2::func2\n"; }
void func4() { std::cout << "C2::func4\n"; } };
|
注意中间基类在定义时仍然传递的是模板类型参数D,并没有将自身传入,只有C1和C2因为在定义时传入了自身,因此可以实例化。
如果将中间类改成嵌套的写法
1 2 3 4 5 6 7 8 9 10 11 12 13
| template <typename D> class B : public A<B<D>> { public: void func3() { std::cout << "B::func3\n"; static_cast<D*>(this)->func4(); }
void func4() { std::cout << "B::func4\n"; } };
|
可能有意料之外的行为,因为类模板的递归太复杂了,懒得去考究。
Pimpl
Pimpl(Pointer to Implementation)是一种常用的C++设计模式,用于隐藏类的实现细节、减少编译依赖性和提高封装性。
概述
Pimpl模式主要的思路就是:在实现类的外层套上一层简单的接口类,接口类只含有一个指向实现类的指针,并暴露必要的接口。
注意接口类包含的不是实现类对象,而是实现类指针,否则修改实现类无法做到二进制的稳定性。
Pimpl模式可以达到如下的效果:
- 隐藏实现细节:实现细节被封装在实现类中,提供给用户的接口类只暴露必要的接口,提高代码的封装性
- 维护接口稳定性:我们只需要维护暴露在接口类中的接口稳定性即可,实现类的内部可以自由地进行更改
- 减少编译依赖性:由于接口类的实现细节被隐藏,接口类只含有实现类的指针,对实现类进行的细节改变不会导致依赖于接口类的文件重新编译,从而减少编译时间。
Pimpl模式和继承以及虚函数的作用是不重合的,并不存在相互取代的关系。
和虚函数类似,Pimpl增加了间接的指针调用,这必然会影响到程序的运行效率。
示例
包括三个文件:
Demo.h:接口类的声明
Demo.cpp:实现类的完整实现,以及接口类的实现
main.cpp:使用接口类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| #pragma once #include <memory> #include <string>
class Demo { public: explicit Demo(std::string name); ~Demo();
void run() const;
private: class DemoImpl; std::unique_ptr<DemoImpl> m_impl; };
#include "demo.h" #include <iostream>
class Demo::DemoImpl { public: explicit DemoImpl(std::string name) : m_name(std::move(name)) {}
void run() const { std::cout << "call DemoImpl " << m_name << " run()\n"; }
private: std::string m_name; };
Demo::Demo(std::string name) : m_impl(std::make_unique<DemoImpl>(std::move(name))) {}
Demo::~Demo() = default;
void Demo::run() const { m_impl->run(); }
#include "demo.h"
int main(int argc, char *argv[]) { Demo("temp").run();
return 0; }
|
运行结果
1
| call DemoImpl temp run()
|