C++ 特殊类设计与单例模式
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
引言
C++ 中有些类并不是为了让用户随意创建、拷贝、继承,而是希望从语法层面限制对象的创建位置、对象数量或继承关系。比如:
- 只能在堆上创建对象
- 只能在栈上创建对象
- 不能被拷贝
- 不能被继承
- 只能创建一个对象,也就是单例模式
这些问题本质上都围绕 C++ 的几个核心机制展开:
- 构造函数控制对象能否被创建;
- 析构函数控制对象能否被销毁;
- 拷贝构造和赋值运算符控制对象能否被复制;
operator new/operator delete控制对象能否在堆上分配;static成员变量、静态成员函数和局部静态对象控制对象的生命周期和访问方式;= delete、private、final是现代 C++ 中常用的语法限制手段。
这类题经常不是单纯考代码,而是考我们是否理解对象创建、销毁、生命周期和线程安全。
一、对象可以创建在哪些位置
在 C++ 中,对象常见的创建位置有三类:栈、静态区/全局区、堆。
1. 栈对象
int main()
{
HeapOnly obj; // 栈上创建对象
}
栈对象通常是函数内部的局部变量。它的生命周期由编译器自动管理,进入作用域时构造,离开作用域时析构。
2. 静态区 / 全局区对象
HeapOnly global_obj; // 全局对象,静态区
int main()
{
static HeapOnly static_obj; // 静态局部对象,静态区
}
静态区对象的生命周期通常贯穿整个程序运行期间。全局对象一般在 main() 之前初始化,程序退出时销毁;函数内局部静态对象第一次执行到定义语句时初始化,程序退出时销毁。
3. 堆对象
int main()
{
HeapOnly* p = new HeapOnly; // 堆上创建对象
delete p;
}
堆对象由程序员手动管理。new 创建对象,delete 销毁对象。如果只 new 不 delete,就可能造成内存泄漏。
4. 三类对象的析构时机
| 对象位置 | 析构时机 | 谁触发析构 |
|---|---|---|
| 栈 | 作用域结束 | 编译器自动触发 |
| 静态区 / 全局区 | 程序结束 | 运行时系统自动触发 |
| 堆 | 执行 delete 时 |
程序员手动触发 |
这个表非常重要,因为“只能在堆上创建对象”的一种实现方式,正是利用了栈对象和静态对象会被自动析构这一点。
二、new 和 delete 到底做了什么
提问:
new是不是只申请内存?delete是不是只释放内存?
答案是:不是。
1. new 的过程
HeapOnly* p = new HeapOnly;
这句代码大致分为两步:
1. 调用 operator new 申请原始内存
2. 在这块内存上调用构造函数,完成对象初始化
所以:
new HeapOnly
本质上一定会涉及构造函数调用。
如果构造函数是 private,外部就不能直接:
HeapOnly* p = new HeapOnly; // 构造函数不可访问,编译报错
2. delete 的过程
delete p;
这句代码大致也分为两步:
1. 调用析构函数,清理对象内部资源
2. 调用 operator delete 释放对象占用的原始内存
所以:
delete p;
本质上一定会涉及析构函数调用。
如果析构函数是 private,外部就不能直接:
delete p; // 析构函数不可访问,编译报错
3. 一句话总结
new主要受构造函数访问权限影响,delete主要受析构函数访问权限影响。
这一点是理解“只能堆上创建对象”和“单例模式”的基础。
三、设计一个类:只能在堆上创建对象
“只能在堆上创建对象”的意思是:
HeapOnly* p = new HeapOnly; // 允许
HeapOnly obj; // 禁止
static HeapOnly obj; // 禁止
常见有两种思路:
- 构造函数私有化,通过静态工厂函数在类内部
new; - 析构函数私有化,利用栈对象和静态对象自动析构时无法访问私有析构函数的特点。
这两种写法侧重点不同,最好都能讲清楚。
1. 写法一:构造函数私有化 + 静态创建接口
核心思想
把构造函数放到 private 中,外部就不能直接创建对象。
HeapOnly obj; // 不允许,构造函数 private
HeapOnly* p = new HeapOnly; // 不允许,new 也需要调用构造函数
但是类内部可以访问自己的私有构造函数,所以可以提供一个 public static 创建接口:
static HeapOnly* CreateObj()
{
return new HeapOnly;
}
外部只能通过这个接口创建堆对象。
完整代码
#include <iostream>
using namespace std;
class HeapOnly
{
public:
static HeapOnly* CreateObj()
{
return new HeapOnly;
}
void Show()
{
cout << "HeapOnly object address: " << this << endl;
}
private:
HeapOnly()
{
cout << "Constructor called" << endl;
}
HeapOnly(const HeapOnly&) = delete;
HeapOnly& operator=(const HeapOnly&) = delete;
};
int main()
{
// HeapOnly h; // ❌ 构造函数 private,不能栈上创建
// HeapOnly* p1 = new HeapOnly; // ❌ 构造函数 private,外部不能直接 new
HeapOnly* p2 = HeapOnly::CreateObj(); // ✅ 类内部 new
p2->Show();
delete p2; // ✅ 这里析构函数没有私有化,可以外部 delete
p2 = nullptr;
return 0;
}
为什么 CreateObj() 必须是静态成员函数
如果 CreateObj() 不是静态成员函数,那么必须先有对象才能调用:
HeapOnly obj;
obj.CreateObj();
但问题是:这个接口本来就是为了创建对象的。如果调用接口之前必须先有对象,就会形成“先有鸡还是先有蛋”的问题。
因此创建接口必须设计成静态成员函数:
HeapOnly* p = HeapOnly::CreateObj();
不需要先创建对象,就可以通过类名直接调用。
为什么要禁用拷贝构造
如果没有禁用拷贝构造,外部可能通过已有堆对象拷贝出一个栈对象:
HeapOnly* p = HeapOnly::CreateObj();
HeapOnly h(*p); // 如果拷贝构造可用,就会在栈上复制出一个对象
这就破坏了“只能在堆上创建对象”的约束。
所以要写:
HeapOnly(const HeapOnly&) = delete;
HeapOnly& operator=(const HeapOnly&) = delete;
严格来说,赋值运算符不会创建新对象,但为了防止对象语义混乱,通常和拷贝构造一起删除。
2. 写法二:析构函数私有化 + Destroy() 销毁接口
核心思想
栈对象和静态对象在生命周期结束时,会由编译器或运行时系统自动调用析构函数。
如果把析构函数设置为 private,那么外部作用域无法访问析构函数,编译器在生成自动析构代码时会报错。
HeapOnly h; // ❌ 作用域结束时要自动调用析构,但析构函数 private
static HeapOnly s; // ❌ 程序结束时要自动调用析构,但析构函数 private
而堆对象不会被自动析构,只有手动 delete 时才会调用析构,所以单纯:
HeapOnly* p = new HeapOnly;
是可以通过的。
完整代码
#include <iostream>
using namespace std;
class HeapOnly
{
public:
void Destroy()
{
delete this;
}
void Show()
{
cout << "HeapOnly object address: " << this << endl;
}
private:
~HeapOnly()
{
cout << "Destructor called" << endl;
}
};
int main()
{
// HeapOnly h; // ❌ 栈对象,作用域结束要自动析构,析构函数 private
// static HeapOnly s; // ❌ 静态对象,程序结束要自动析构,析构函数 private
HeapOnly* p = new HeapOnly; // ✅ 构造函数可访问,可以在堆上创建
p->Show();
// delete p; // ❌ 外部 delete 要调用 private 析构函数,编译报错
p->Destroy(); // ✅ 类内部 delete this,可以访问 private 析构函数
p = nullptr;
return 0;
}
为什么代码中没有看到构造函数也能 new
如果类中没有显式定义构造函数,编译器会自动生成一个默认构造函数。
可以把上面的类理解成:
class HeapOnly
{
public:
HeapOnly() = default; // 编译器自动生成的默认构造函数
void Destroy()
{
delete this;
}
private:
~HeapOnly() {}
};
所以:
HeapOnly* p = new HeapOnly;
能够成功,因为 new 调用的是构造函数,而默认构造函数是可访问的。
真正被限制的是析构函数。
3. 更严格的堆对象写法:构造和析构都私有化
如果希望外部既不能直接 new,也不能直接 delete,只能通过类提供的接口创建和销毁,可以同时私有化构造函数和析构函数:
#include <iostream>
using namespace std;
class HeapOnly
{
public:
static HeapOnly* Create()
{
return new HeapOnly;
}
void Destroy()
{
delete this;
}
void Show()
{
cout << "HeapOnly object address: " << this << endl;
}
private:
HeapOnly()
{
cout << "Constructor called" << endl;
}
~HeapOnly()
{
cout << "Destructor called" << endl;
}
HeapOnly(const HeapOnly&) = delete;
HeapOnly& operator=(const HeapOnly&) = delete;
};
int main()
{
// HeapOnly h; // ❌ 构造函数 private
// HeapOnly* p1 = new HeapOnly; // ❌ 构造函数 private
HeapOnly* p2 = HeapOnly::Create(); // ✅ 类内部可以调用 private 构造函数
p2->Show();
// delete p2; // ❌ 析构函数 private
p2->Destroy(); // ✅ 类内部可以调用 private 析构函数
p2 = nullptr;
return 0;
}
这版代码的限制最完整:
| 操作 | 是否允许 | 原因 |
|---|---|---|
HeapOnly h; |
不允许 | 构造函数 private |
static HeapOnly h; |
不允许 | 构造函数 private,析构函数也 private |
new HeapOnly |
不允许 | 构造函数 private |
HeapOnly::Create() |
允许 | 类内部调用 private 构造函数 |
delete p |
不允许 | 析构函数 private |
p->Destroy() |
允许 | 类内部执行 delete this |
4. 构造函数私有化和析构函数私有化的区别
| 设计方式 | 主要限制什么 | 说明 |
|---|---|---|
| 构造函数私有化 | 禁止外部直接创建对象 | 因为栈创建和 new 都需要调用构造函数 |
| 析构函数私有化 | 禁止栈/静态对象和外部 delete |
因为栈/静态对象会自动析构,delete 会调用析构函数 |
| 构造 + 析构都私有 | 创建和销毁都走统一接口 | 最严格,外部只能 Create() / Destroy() |
总结:
如果只是把析构函数私有化,外部仍然可以直接
new,因为new调用的是构造函数;但是栈对象和静态对象在生命周期结束时需要自动调用析构函数,由于析构函数是私有的,所以会编译失败。如果进一步把构造函数也私有化,那么外部连new都不能直接调用,只能通过类内部提供的静态创建接口来创建对象。
5. delete this 使用注意点
delete this 并不是不能用,但必须保证:
- 当前对象确实是在堆上通过
new创建的; delete this执行后,不能再访问任何成员;- 外部保存的指针会变成悬空指针,最好立即置空;
- 不要对同一个对象重复调用
Destroy()。
例如:
p->Destroy();
p = nullptr; // 防止悬空指针
四、设计一个类:只能在栈上创建对象
“只能在栈上创建对象”的意思是:
StackOnly obj; // 允许
StackOnly* p = new StackOnly; // 禁止
static StackOnly obj; // 通常也希望禁止,但较难彻底限制
常见有两种思路。
1. 方法一:构造函数私有化 + 静态接口返回对象
class StackOnly
{
public:
static StackOnly CreateObj()
{
return StackOnly();
}
private:
StackOnly() {}
};
使用方式:
StackOnly obj = StackOnly::CreateObj();
这个方法的思想是:外部不能直接调用构造函数,只能通过类内部接口创建一个临时对象并返回。
但是这个方法有缺陷:如果拷贝构造函数可用,外部仍然可能通过拷贝把对象放到静态区或堆上:
StackOnly obj1 = StackOnly::CreateObj();
static StackOnly obj2(obj1); // 通过拷贝构造放到静态区
StackOnly* ptr = new StackOnly(obj1); // 通过拷贝构造放到堆上
传统写法中,如果把拷贝构造也删除,CreateObj() 返回局部对象可能受影响,所以这种方式并不完美。
2. 方法二:禁用类专属的 operator new 和 operator delete
更常见的做法是:禁止这个类使用 new。
#include <cstddef>
class StackOnly
{
public:
StackOnly() {}
private:
void* operator new(std::size_t size) = delete;
void operator delete(void* p) = delete;
};
这样外部可以:
StackOnly obj; // ✅ 栈上创建
但是不能:
StackOnly* p = new StackOnly; // ❌ operator new 被删除
原理是:new StackOnly 第一步要调用 operator new 申请堆空间。如果类自己的 operator new 被删除,那么这个类就不能通过 new 在堆上创建对象。
3. 这种方法的局限
删除 operator new 可以阻止堆上创建,但不能阻止静态区创建:
static StackOnly obj; // 仍然可以
所以“只能在栈上创建对象”在 C++ 中很难做到绝对严密。总结:
比较常见的做法是删除类专属的
operator new和operator delete,阻止外部通过new在堆上创建对象。但这种方式不能阻止静态对象的创建,所以严格意义上只能限制不能在堆上创建,而不能完全保证只能在栈上创建。
五、设计一个类:不能被拷贝
不能被拷贝,就是禁止调用拷贝构造函数和拷贝赋值运算符。
1. C++98 写法:声明为私有,只声明不实现
class CopyBan
{
public:
CopyBan() {}
private:
CopyBan(const CopyBan&);
CopyBan& operator=(const CopyBan&);
};
外部无法访问私有拷贝构造和私有赋值运算符。
2. C++11 写法:使用 = delete
class CopyBan
{
public:
CopyBan() {}
CopyBan(const CopyBan&) = delete;
CopyBan& operator=(const CopyBan&) = delete;
};
= delete 的语义更清晰:明确告诉编译器这个函数被删除,任何调用都会编译失败。
3. 禁止的操作
CopyBan a;
CopyBan b(a); // ❌ 拷贝构造被禁用
CopyBan c = a; // ❌ 拷贝构造被禁用
b = a; // ❌ 拷贝赋值被禁用
总结:
如果要设计一个不能被拷贝的类,需要禁用拷贝构造函数和拷贝赋值运算符。C++98 可以把它们声明为
private且不实现,C++11 以后更推荐使用= delete。
六、设计一个类:不能被继承
1. C++98 思路:构造函数私有化
class NonInherit
{
public:
static NonInherit CreateObj()
{
return NonInherit();
}
private:
NonInherit() {}
};
子类构造对象时,必须先调用父类构造函数初始化父类部分。但是父类构造函数是 private,子类访问不到,所以无法创建子类对象。
例如:
class Derive : public NonInherit
{};
// Derive d; // ❌ 子类无法调用父类 private 构造函数
2. C++98 写法的缺点
这种方式不够彻底。它并不是禁止继承语法本身,而是让继承后的子类无法实例化。
也就是说:
class Derive : public NonInherit
{};
这句继承关系本身可能仍然成立,只是在创建 Derive 对象时失败。
3. C++11 推荐写法:final
C++11 提供了 final 关键字,被 final 修饰的类不能被继承。
class NonInherit final
{
public:
NonInherit() {}
};
如果再写:
class Derive : public NonInherit
{};
会直接编译报错。
总结:
C++11 以后最直接的方式是使用
final修饰类。C++98 中可以通过把构造函数私有化,使子类无法调用父类构造函数,从而无法实例化子类对象,但这种方式不能真正禁止继承语法,只是不允许创建派生类对象。
七、单例模式基础
1. 什么是单例模式
单例模式是一种创建型设计模式,它的核心目标是:
保证一个类在整个程序运行期间只有一个实例,并提供一个全局访问点来获取这个实例。
典型应用场景包括:
- 配置管理器
- 日志系统
- 线程池管理器
- 数据库连接池管理器
- 全局资源管理器
比如服务器程序中,配置信息通常只需要读取一份。可以设计一个配置类单例,由它负责读取配置文件,其他模块统一通过这个单例对象获取配置。
2. 单例模式的三个基本要求
一个标准的单例一般要做三件事。
第一,构造函数私有化
private:
Singleton() {}
这样外部不能直接创建对象:
Singleton s; // ❌ 不允许
Singleton* p = new Singleton; // ❌ 不允许
第二,禁止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
防止通过已有对象复制出第二个对象:
Singleton s2 = Singleton::GetInstance(); // ❌ 如果返回引用,也会尝试拷贝
第三,提供静态访问接口
static Singleton& GetInstance();
因为构造函数已经私有化,外部不能先创建对象再调用普通成员函数,所以 GetInstance() 必须是静态成员函数,可以直接通过类名调用:
Singleton::GetInstance();
3. GetInstance() 返回指针还是引用
常见有两种返回方式:
static Singleton* GetInstance(); // 返回指针
static Singleton& GetInstance(); // 返回引用
对比如下:
| 返回方式 | 使用方式 | 特点 |
|---|---|---|
| 返回指针 | Singleton::GetInstance()->Func() |
语法上可能被误认为可以为空或可以 delete |
| 返回引用 | Singleton::GetInstance().Func() |
更自然,不容易被外部 delete,更推荐 |
如果内部用的是指针,也可以返回引用:
static Singleton& GetInstance()
{
return *_inst;
}
八、饿汉模式
1. 饿汉模式的核心思想
饿汉模式是:
程序启动阶段,也就是
main()执行之前,就把单例对象创建好。
因此调用 GetInstance() 时不需要判断对象是否存在,也不需要在创建对象时加锁。
可以理解为:
程序启动 → 单例对象初始化 → main() 执行 → 多线程开始运行
因为对象在多线程启动前已经创建好,所以对象创建过程天然没有多线程竞争。
2. 饿汉模式:静态对象版
代码
#include <iostream>
#include <map>
#include <string>
using namespace std;
class Singleton
{
public:
static Singleton& GetInstance()
{
return _instance;
}
void Add(const string& key, const string& value)
{
_dict[key] = value;
}
void Print()
{
for (const auto& kv : _dict)
{
cout << kv.first << ":" << kv.second << endl;
}
}
private:
Singleton()
{
cout << "Singleton constructor" << endl;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
map<string, string> _dict;
static Singleton _instance;
};
Singleton Singleton::_instance;
int main()
{
Singleton& s1 = Singleton::GetInstance();
Singleton& s2 = Singleton::GetInstance();
s1.Add("apple", "苹果");
s2.Add("banana", "香蕉");
s1.Print();
cout << &s1 << endl;
cout << &s2 << endl;
return 0;
}
关键代码解释
static Singleton _instance;
这是类内声明一个静态成员对象。它属于类本身,不属于某一个具体对象。
Singleton Singleton::_instance;
这是类外定义静态成员对象。真正的对象在这里被定义,并且通常会在 main() 之前初始化。
static Singleton& GetInstance()
{
return _instance;
}
返回的是 _instance 的引用,不是拷贝。所以每次拿到的都是同一个对象。
3. 为什么 Add() 多次调用不是创建多个单例
例如:
Singleton::GetInstance().Add("apple", "苹果");
Singleton::GetInstance().Add("banana", "香蕉");
Singleton::GetInstance().Add("orange", "橘子");
这三句不是创建了三个单例对象,而是三次都拿到同一个对象,然后修改这个对象内部的 map。
可以类比为:
单例对象 = 一个柜子
Add("apple", "苹果") = 往柜子里放一条数据
Add("banana", "香蕉") = 往同一个柜子里再放一条数据
柜子始终只有一个,只是里面放的数据越来越多。
4. 饿汉模式:静态指针版
代码
#include <iostream>
#include <map>
#include <string>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
return _inst;
}
void Add(const string& key, const string& value)
{
_dict[key] = value;
}
void Print()
{
for (const auto& kv : _dict)
{
cout << kv.first << ":" << kv.second << endl;
}
}
private:
Singleton()
{
cout << "Singleton constructor" << endl;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
map<string, string> _dict;
static Singleton* _inst;
};
Singleton* Singleton::_inst = new Singleton;
int main()
{
Singleton* p1 = Singleton::GetInstance();
Singleton* p2 = Singleton::GetInstance();
p1->Add("hello", "你好");
p2->Add("world", "世界");
p1->Print();
cout << p1 << endl;
cout << p2 << endl;
return 0;
}
关键代码解释
static Singleton* _inst;
类中保存的是一个静态指针。
Singleton* Singleton::_inst = new Singleton;
程序启动阶段,在堆上创建一个 Singleton 对象,并让 _inst 指向它。
所以静态对象版和静态指针版的区别是:
| 写法 | 类中保存什么 | 对象在哪里 |
|---|---|---|
| 静态对象版 | static Singleton _instance |
静态区 |
| 静态指针版 | static Singleton* _inst |
堆区 |
5. 饿汉模式的优点和缺点
优点
- 实现简单;
- 对象在
main()之前创建,创建过程通常天然线程安全; GetInstance()只返回已有对象,不需要加锁。
缺点
- 如果单例对象初始化很耗时,会拖慢程序启动;
- 如果程序没有使用这个单例,对象也会被提前创建,可能浪费资源;
- 多个全局/静态对象之间如果存在初始化依赖,不同编译单元之间的初始化顺序不容易保证,可能出现初始化顺序问题。
6. 饿汉模式线程安全怎么回答
总结:
饿汉模式在
main()执行之前就已经完成单例对象的创建,而多线程通常是在main()之后才启动,所以创建过程没有多线程竞争。后续多个线程调用GetInstance()只是读取同一个已经存在的对象指针或引用,这个获取过程通常不需要加锁。但是,如果多个线程拿到单例后要修改单例内部的数据,那么内部数据的访问仍然需要考虑线程安全。
九、懒汉模式
1. 懒汉模式的核心思想
懒汉模式是:
程序启动时不创建单例对象,第一次使用时才创建。
优点是延迟加载,避免对象没用到也提前创建。
缺点是第一次创建时可能发生多线程竞争,需要处理线程安全。
2. 懒汉模式:普通指针版,线程不安全
#include <iostream>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
if (_inst == nullptr)
{
_inst = new Singleton;
}
return _inst;
}
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
};
Singleton* Singleton::_inst = nullptr;
这个版本在单线程下可以工作,但多线程下不安全。
假设线程 A 和线程 B 同时第一次调用 GetInstance():
线程 A 判断 _inst == nullptr,成立
线程 B 判断 _inst == nullptr,也成立
线程 A 执行 new Singleton
线程 B 也执行 new Singleton
这样就创建了两个对象,破坏单例。
3. 为什么两次 new Singleton 不是同一个对象
需要注意:
_inst = new Singleton;
每执行一次,都会在堆上创建一个新的 Singleton 对象。
例如第一次可能是:
_inst = 0x1000
第二次可能是:
_inst = 0x2000
_inst 这个指针变量确实是同一个变量,但它先后指向了两个不同对象。
问题包括:
- 构造函数被调用多次,违反单例;
- 第一个对象可能丢失地址,造成内存泄漏;
- 极端情况下,不同线程可能拿到不同对象地址。
所以不能认为“同一个指针变量重新赋值还是同一个对象”。指针变量相同,不代表它指向的对象相同。
4. 懒汉模式:直接加锁版
#include <iostream>
#include <mutex>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
lock_guard<mutex> lock(_mtx);
if (_inst == nullptr)
{
_inst = new Singleton;
}
return _inst;
}
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
static mutex _mtx;
};
Singleton* Singleton::_inst = nullptr;
mutex Singleton::_mtx;
这个版本是线程安全的,因为同一时间只有一个线程能进入创建逻辑。
缺点是:
即使对象已经创建好了,后续每次调用
GetInstance()仍然要加锁,性能不够好。
5. 懒汉模式:双重检查锁定
#include <iostream>
#include <mutex>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
if (_inst == nullptr) // 第一次检查:避免对象创建后每次都加锁
{
lock_guard<mutex> lock(_mtx);
if (_inst == nullptr) // 第二次检查:防止排队线程重复创建
{
_inst = new Singleton;
}
}
return _inst;
}
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
static mutex _mtx;
};
Singleton* Singleton::_inst = nullptr;
mutex Singleton::_mtx;
6. 为什么要判断两次空
第一次判断:
if (_inst == nullptr)
作用是提高效率。对象创建完成后,后续调用直接返回,不需要加锁。
第二次判断:
if (_inst == nullptr)
作用是防止重复创建。
假设没有第二次判断,流程可能是:
线程 A:第一次判断 _inst == nullptr,准备加锁
线程 B:第一次判断 _inst == nullptr,准备加锁
线程 A:拿到锁,new Singleton,_inst = 0x1000,释放锁
线程 B:拿到锁,如果不再判断,继续 new Singleton,_inst = 0x2000
此时创建了两个对象,_inst 最终指向第二个对象,第一个对象可能泄漏。
所以:
| 判断 | 作用 |
|---|---|
| 第一次判空 | 避免对象创建后仍然频繁加锁,优化性能 |
| 第二次判空 | 防止多个线程都通过第一次判断后,在锁内重复创建对象 |
总结:
第一次判空是为了减少加锁开销,第二次判空是为了保证只有一个线程真正创建对象。两个判断不重复,分别解决性能和正确性问题。
7. 传统双重检查锁定的问题
传统裸指针双重检查在 C++ 中还要注意两个问题。
问题一:锁外读写普通指针可能产生数据竞争
第一个判断在锁外:
if (_inst == nullptr)
一个线程可能正在写 _inst,另一个线程在锁外读 _inst。如果 _inst 是普通指针,严格来说存在数据竞争风险。
问题二:new Singleton 不是一个不可分割的操作
_inst = new Singleton;
大致可以拆成三步:
1. 申请内存
2. 调用构造函数初始化对象
3. 把对象地址赋给 _inst
在某些优化场景下,可能出现类似“地址先赋值,对象还没完全构造好”的情况。其他线程看到 _inst != nullptr 后直接返回,就可能拿到一个尚未完全构造完成的对象。
因此,传统双重检查锁定适合理解思想,但现代 C++ 中不建议把裸指针双重检查作为首选实现。
更推荐的方式是:
- C++11 局部静态对象;
std::call_once;- 使用
std::atomic严格处理内存序。
最推荐掌握 C++11 局部静态对象版本。
十、C++11 推荐写法:局部静态对象懒汉单例
1. 完整代码
#include <iostream>
using namespace std;
class Singleton
{
public:
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
void Show()
{
cout << "Singleton address: " << this << endl;
}
private:
Singleton()
{
cout << "Constructor called" << endl;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
int main()
{
Singleton& s1 = Singleton::GetInstance();
Singleton& s2 = Singleton::GetInstance();
s1.Show();
s2.Show();
return 0;
}
2. 重点理解这句代码
static Singleton instance;
它是函数内部的局部静态对象。
它有两个特点:
局部:作用域只在 GetInstance() 函数内部,外部访问不到 instance 这个名字
静态:生命周期贯穿整个程序运行期间,只初始化一次
3. 为什么它是局部对象
因为它写在函数内部:
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
所以外部不能直接写:
instance; // ❌ 外部看不到这个名字
这就是“局部”的含义。
4. 为什么它又是静态对象
因为它前面有 static。
普通局部变量:
void Func()
{
Singleton obj;
}
每次调用 Func() 都会创建一个新的 obj,函数结束时销毁。
局部静态变量:
void Func()
{
static Singleton obj;
}
只会在第一次执行到这行代码时初始化一次,后续调用不会重新创建,直到程序结束才销毁。
对比如下:
| 变量类型 | 作用域 | 生命周期 | 初始化时机 |
|---|---|---|---|
| 普通局部变量 | 函数内部 | 函数调用期间 | 每次调用函数 |
| 局部静态变量 | 函数内部 | 整个程序运行期间 | 第一次执行到定义语句 |
| 全局变量/类静态成员对象 | 全局或类作用域 | 整个程序运行期间 | 通常在 main() 之前 |
5. 为什么后续调用不会重复创建
局部静态变量有语言规则保证:
函数内局部静态变量只会初始化一次。
第一次调用:
Singleton::GetInstance();
执行到:
static Singleton instance;
对象被创建。
第二次、第三次再调用时,程序发现这个局部静态对象已经初始化过了,就不会再构造,而是直接返回第一次创建的对象。
可以把它粗略理解成编译器内部维护了一个“是否初始化过”的标记:
第一次进入:发现没有初始化 → 构造 instance → 标记为已初始化
后续进入:发现已经初始化 → 直接使用原来的 instance
真实实现更复杂,但这样理解足够。
6. 为什么 C++11 以后线程安全
C++11 以后,标准保证函数内局部静态变量的初始化是线程安全的。
也就是说,多个线程第一次同时调用:
Singleton::GetInstance();
编译器和运行时会保证:
static Singleton instance;
只初始化一次。其他线程会等待初始化完成,不会重复创建对象。
7. 为什么返回引用
static Singleton& GetInstance()
返回的是引用,不是对象本身。
return instance;
如果返回类型写成:
static Singleton GetInstance()
就可能发生拷贝,破坏单例语义。所以单例通常返回引用或指针。
8. 局部静态对象版的缺点
这个版本非常推荐,但也有局限:
- 对象存储在静态存储区,生命周期到程序结束;
- 不能主动控制释放时机;
- 如果单例对象特别大,或者释放顺序很敏感,可能不适合;
- 析构阶段如果依赖其他已销毁的全局对象,仍然可能出现析构顺序问题。
不过对于常见业务场景,这仍然是 C++11 以后最推荐的单例写法。
十一、对象实现饿汉模式和懒汉模式的区别
对象版饿汉模式:
class Singleton
{
private:
static Singleton _instance;
};
Singleton Singleton::_instance;
对象版懒汉模式:
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
表面对比:
| 模式 | 对象写在哪里 | 创建时机 |
|---|---|---|
| 饿汉模式 | 类静态成员对象,类外定义 | main() 之前 |
| 懒汉模式 | 函数内部局部静态对象 | 第一次调用 GetInstance() 时 |
更准确地说:
二者的核心区别不是简单的“全局”和“局部”,而是初始化时机不同。饿汉模式提前创建,懒汉模式延迟创建。
局部静态对象虽然写在函数内部,但它不是普通局部变量。它的作用域是局部的,生命周期却贯穿整个程序运行期间。
总结:
对象方式实现单例时,饿汉模式通常把单例对象定义为类静态成员,并在类外进行定义,所以对象会在
main()之前初始化;懒汉模式通常把单例对象定义为GetInstance()内部的局部静态对象,所以只有第一次调用该函数时才初始化。它们的本质区别是创建时机,而不是单纯的全局和局部。
十二、单例对象的释放
1. 是否一定要释放单例对象
单例对象一般贯穿整个程序运行期间,因此很多场景中并不会主动释放它,而是让进程结束时由操作系统回收资源。
但如果单例对象持有文件句柄、网络连接、数据库连接,或者析构时需要写回数据,就需要考虑释放和析构。
2. 指针版单例:提供 DelInstance()
#include <mutex>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
if (_inst == nullptr)
{
lock_guard<mutex> lock(_mtx);
if (_inst == nullptr)
{
_inst = new Singleton;
}
}
return _inst;
}
static void DelInstance()
{
lock_guard<mutex> lock(_mtx);
if (_inst != nullptr)
{
delete _inst;
_inst = nullptr;
}
}
private:
Singleton() {}
~Singleton()
{
// 如果没有额外清理逻辑,可以不写析构函数,让编译器生成默认析构
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
static mutex _mtx;
};
Singleton* Singleton::_inst = nullptr;
mutex Singleton::_mtx;
delete _inst 会自动调用析构函数。如果类中没有需要额外处理的资源,默认析构就够了。
3. 什么时候需要自己写析构函数
如果析构时需要做额外收尾工作,就要自己写析构函数。
例如,把内存中的数据写回文件:
~Singleton()
{
FILE* fout = fopen("map.txt", "w");
if (fout == nullptr)
{
return;
}
for (const auto& kv : _dict)
{
fputs(kv.first.c_str(), fout);
fputs(":", fout);
fputs(kv.second.c_str(), fout);
fputs("\n", fout);
}
fclose(fout);
}
总结:
delete一定会调用析构函数。要不要自己写析构函数,取决于销毁时有没有额外清理逻辑。没有额外逻辑时,默认析构函数通常就够了。
4. 内嵌垃圾回收类释放单例
也可以定义一个静态的内嵌回收类对象,在程序结束时利用它的析构函数释放单例。
class Singleton
{
public:
static Singleton* GetInstance()
{
if (_inst == nullptr)
{
_inst = new Singleton;
}
return _inst;
}
private:
Singleton() {}
~Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
class CGarbo
{
public:
~CGarbo()
{
if (_inst != nullptr)
{
delete _inst;
_inst = nullptr;
}
}
};
private:
static Singleton* _inst;
static CGarbo _garbo;
};
Singleton* Singleton::_inst = nullptr;
Singleton::CGarbo Singleton::_garbo;
这种方式的思想是:
程序结束 → 静态 CGarbo 对象析构 → 在 CGarbo 析构函数中 delete 单例对象
十三、单例模式涉及的 static 知识点
1. 静态成员变量
class Singleton
{
private:
static Singleton _instance;
};
Singleton Singleton::_instance;
静态成员变量特点:
- 属于类,不属于某个对象;
- 所有对象共享一份;
- 类内通常只是声明,类外需要定义;
- 存储在静态存储区;
- 生命周期通常贯穿整个程序。
2. 静态成员函数
class Singleton
{
public:
static Singleton& GetInstance();
};
静态成员函数特点:
- 没有
this指针; - 可以通过类名直接调用;
- 不能直接访问非静态成员变量;
- 适合提供全局访问入口。
因为 GetInstance() 的目的就是在没有对象的情况下获取对象,所以它必须是静态成员函数。
3. 为什么类里可以写 static Singleton _instance
class Singleton
{
private:
static Singleton _instance;
};
这是可以的,因为类内的 static Singleton _instance; 只是声明,不占用当前对象的大小。
真正定义发生在类外:
Singleton Singleton::_instance;
此时类已经是完整类型,可以分配空间。
但如果写成非静态成员:
class Singleton
{
private:
Singleton _instance; // ❌ 错误
};
就不行。因为这表示每个 Singleton 对象内部又包含一个 Singleton 对象,导致类大小无限递归,无法确定。
4. 为什么 static Singleton* _inst 也可以
class Singleton
{
private:
static Singleton* _inst;
};
指针大小固定。在 64 位系统上,一个指针通常是 8 字节。指针本身不需要知道对象完整大小,所以即使类还没完全定义完,也可以声明指向自身类型的指针。
十四、饿汉模式和懒汉模式总对比
| 对比项 | 饿汉模式 | 懒汉模式 |
|---|---|---|
| 创建时机 | 程序启动阶段,通常在 main() 之前 |
第一次调用 GetInstance() 时 |
| 是否延迟加载 | 否 | 是 |
| 实现复杂度 | 简单 | 较复杂 |
| 线程安全 | 创建过程通常天然线程安全 | 需要处理第一次创建的线程安全 |
| 启动开销 | 可能增加启动耗时 | 启动时不开销 |
| 初始化顺序 | 多单例跨文件依赖时可能有问题 | 由首次调用顺序决定,可控性更强 |
| 推荐写法 | 静态对象或静态指针 | C++11 局部静态对象 |
十五、推荐代码
1. 最推荐:C++11 局部静态对象单例
class Singleton
{
public:
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
private:
Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
解释:
这个版本是懒汉模式。
instance是函数内部的局部静态对象,第一次调用GetInstance()时初始化,后续调用不会重复创建。C++11 以后局部静态变量初始化线程安全,所以不需要手动加锁。构造函数私有化防止外部创建对象,拷贝构造和赋值运算符删除防止复制出新对象。
2. 指针懒汉双重检查版
#include <mutex>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
if (_inst == nullptr)
{
lock_guard<mutex> lock(_mtx);
if (_inst == nullptr)
{
_inst = new Singleton;
}
}
return _inst;
}
private:
Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
static mutex _mtx;
};
Singleton* Singleton::_inst = nullptr;
mutex Singleton::_mtx;
解释:
双重检查中,第一次判空是为了避免对象已经创建后每次都加锁;第二次判空是为了防止多个线程同时通过第一次判断后,排队进入锁内重复创建对象。不过传统裸指针双重检查还要考虑数据竞争和指令重排,因此实际开发中更推荐 C++11 局部静态对象或
std::call_once。
3. std::call_once 版本
#include <mutex>
using namespace std;
class Singleton
{
public:
static Singleton* GetInstance()
{
call_once(_flag, []() {
_inst = new Singleton;
});
return _inst;
}
private:
Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
static Singleton* _inst;
static once_flag _flag;
};
Singleton* Singleton::_inst = nullptr;
once_flag Singleton::_flag;
std::call_once 可以保证初始化逻辑只执行一次,是比手写双重检查更稳妥的方式。
十六、单例模式一些问题
1. 基础回答
单例模式是一种创建型设计模式,它的目标是保证一个类在整个程序中只有一个实例,并提供一个全局访问点。实现时通常把构造函数私有化,防止外部直接创建对象;把拷贝构造和赋值运算符删除,防止复制出新对象;然后通过静态成员函数
GetInstance()返回唯一实例。
2. 饿汉和懒汉对比回答
单例常见实现有饿汉模式和懒汉模式。饿汉模式是在程序启动阶段就创建对象,优点是实现简单、创建过程通常线程安全,缺点是即使不用也会创建,可能影响启动速度,并且多个静态对象之间的初始化顺序不好控制。懒汉模式是在第一次使用时才创建对象,优点是延迟加载,缺点是多线程第一次创建时需要考虑线程安全。C++11 以后,推荐使用函数内局部静态对象实现懒汉单例,因为它第一次调用时初始化,并且初始化过程由标准保证线程安全。
3. 双重检查回答
双重检查锁定中,第一次判断
_inst == nullptr是为了避免对象已经创建后每次调用都加锁,主要是性能优化;第二次判断是在加锁之后再次确认对象是否仍然为空,防止多个线程同时通过第一次判断后依次拿到锁,从而重复创建对象。所以两次判空并不多余,第一次解决性能问题,第二次解决正确性问题。
4. new Singleton 回答
new Singleton不是简单地给指针赋值,而是先申请内存,再调用构造函数初始化对象,最后返回对象地址。每执行一次new Singleton,都会创建一个新的对象。即使_inst是同一个指针变量,多次赋值也可能让它先后指向不同对象,所以多线程下必须防止重复new。
5. 局部静态对象回答
static Singleton instance;写在GetInstance()函数内部,因此它是局部静态对象。它的名字只能在函数内部访问,但生命周期贯穿整个程序。它只会在第一次执行到定义语句时初始化一次,后续调用不会重复创建。C++11 以后,局部静态变量初始化是线程安全的,所以这种单例写法简单且可靠。
十七、易错点总结
new调用构造函数,delete调用析构函数,不要把它们理解成单纯的申请和释放内存。- 只把析构函数私有化,外部仍然可以直接
new,因为new主要需要访问构造函数。 - 只把构造函数私有化,外部不能直接创建对象,但如果拷贝构造没有禁用,可能通过已有对象拷贝出新对象。
Create()必须是静态成员函数,否则调用它之前必须先有对象,逻辑上矛盾。delete this只能用于确定在堆上创建的对象,调用后不能继续访问当前对象。- 单例不是“创建多个对象后覆盖成一个”,而是从语法上禁止外部创建第二个对象。
GetInstance()多次调用返回的是同一个对象,调用Add()等成员函数只是修改同一个对象内部的数据,不是创建新对象。- 指针变量相同不代表对象相同,每执行一次
new都会产生一个新的对象地址。 - 双重检查中第一次判空是为了性能,第二次判空是为了防止重复创建。
- 传统裸指针双重检查要考虑数据竞争和指令重排,现代 C++ 更推荐局部静态对象或
std::call_once。 - 局部静态对象不是普通局部变量,它的作用域是局部的,但生命周期是整个程序。
- 对象版饿汉和对象版懒汉的本质区别是初始化时机,不是简单的“全局”和“局部”。
- 静态成员变量类内通常只是声明,类外还需要定义,否则可能出现链接错误。
- 静态成员函数没有
this指针,所以不能直接访问非静态成员。 final是 C++11 中禁止继承的直接方式,比 C++98 私有构造函数方式更彻底。
十八、最终记忆版
特殊类设计的核心是利用 C++ 的访问控制和对象生命周期规则来限制对象行为。构造函数决定对象能否被创建,析构函数决定对象能否被销毁,拷贝构造和赋值运算符决定对象能否被复制,
operator new和operator delete决定对象能否在堆上分配。
设计只能在堆上创建的类,可以把构造函数私有化,并提供静态创建接口在类内部new;也可以把析构函数私有化,利用栈对象和静态对象会自动析构这一点,使它们因无法访问私有析构函数而编译失败。如果希望创建和销毁都受控,可以同时私有化构造和析构,提供Create()和Destroy()。
单例模式的目标是保证一个类全局只有一个实例。实现时一般私有化构造函数,删除拷贝构造和赋值运算符,并提供静态GetInstance()访问唯一对象。饿汉模式在main()之前创建对象,实现简单但可能影响启动速度,也可能有静态初始化顺序问题;懒汉模式第一次使用时创建对象,能延迟加载,但要处理线程安全。C++11 以后最推荐使用函数内局部静态对象实现懒汉单例,因为它只在第一次调用时初始化,并且初始化过程由标准保证线程安全。
更多推荐

所有评论(0)