单例模式

单例模式作为最简单但最常用的设计模式,值得仔细整理一下 C++ 的相关语法。

单例模式简介

单例模式(Singleton Pattern),使用最广泛的设计模式之一。其意图是保证一个类仅有一个实例被构造,并提供一个访问它的全局访问接口,该实例被程序的所有模块共享。

大致的做法为:

  1. 定义一个单例类;
  2. 私有化构造函数,防止外界直接创建单例类的对象;
  3. 禁用拷贝构造,移动赋值等函数,可以私有化,也可以直接使用=delete
  4. 使用一个公有的静态方法获取该实例;
  5. 确保在第一次调用之前该实例被构造。

在 C++中我们需要考虑:

  1. 在什么时机构造?程序一开始,第一次调用前?
  2. 如何构造这个实例?在堆上构造,还是通过静态变量?如果单例在堆上构造,是否存在内存泄漏?
  3. 实例构造失败会如何?抛异常?返回空指针?
  4. 通过接口返回指针还是引用?
  5. 线程安全?多线程下会不会重复构造?加锁?

我们暂时不考虑类的继承问题,以及需要给单例的构造传参数的问题。 关于构造时机的不同,有以下两种习惯的称呼:

  • 饿汉模式(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; // declare
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; // define

运行结果:(确实在 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既可以避免虚函数的额外性能开销,还可以在编译时获得更好的类型检查和优化。

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>

// 第一层:基类 A
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";
}
};

// 第二层:中间类 B
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";
}
};

// 第三层:具体类 C1
class C1 : public B<C1> {
public:
void func4() {
std::cout << "C1::func4\n";
}
};

// 第三层:具体类 C2
class C2 : public B<C2> {
public:
void func2() {
std::cout << "C2::func2\n";
}

void func4() {
std::cout << "C2::func4\n";
}
};

注意中间基类在定义时仍然传递的是模板类型参数D,并没有将自身传入,只有C1C2因为在定义时传入了自身,因此可以实例化。

如果将中间类改成嵌套的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第二层:中间类 B
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
// Demo.h
#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;
};

// Demo.cpp
#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(); }

// main.cpp
#include "demo.h"

int main(int argc, char *argv[]) {
Demo("temp").run();

return 0;
}

运行结果

1
call DemoImpl temp run()