提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


引言

C++ 中有些类并不是为了让用户随意创建、拷贝、继承,而是希望从语法层面限制对象的创建位置、对象数量或继承关系。比如:

  • 只能在堆上创建对象
  • 只能在栈上创建对象
  • 不能被拷贝
  • 不能被继承
  • 只能创建一个对象,也就是单例模式

这些问题本质上都围绕 C++ 的几个核心机制展开:

  1. 构造函数控制对象能否被创建;
  2. 析构函数控制对象能否被销毁;
  3. 拷贝构造和赋值运算符控制对象能否被复制;
  4. operator new / operator delete 控制对象能否在堆上分配;
  5. static 成员变量、静态成员函数和局部静态对象控制对象的生命周期和访问方式;
  6. = deleteprivatefinal 是现代 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 销毁对象。如果只 newdelete,就可能造成内存泄漏。

4. 三类对象的析构时机

对象位置 析构时机 谁触发析构
作用域结束 编译器自动触发
静态区 / 全局区 程序结束 运行时系统自动触发
执行 delete 程序员手动触发

这个表非常重要,因为“只能在堆上创建对象”的一种实现方式,正是利用了栈对象和静态对象会被自动析构这一点。

二、newdelete 到底做了什么

提问:

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;        // 禁止

常见有两种思路:

  1. 构造函数私有化,通过静态工厂函数在类内部 new
  2. 析构函数私有化,利用栈对象和静态对象自动析构时无法访问私有析构函数的特点。

这两种写法侧重点不同,最好都能讲清楚。

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 并不是不能用,但必须保证:

  1. 当前对象确实是在堆上通过 new 创建的;
  2. delete this 执行后,不能再访问任何成员;
  3. 外部保存的指针会变成悬空指针,最好立即置空;
  4. 不要对同一个对象重复调用 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 newoperator 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 newoperator 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. 饿汉模式的优点和缺点

优点
  1. 实现简单;
  2. 对象在 main() 之前创建,创建过程通常天然线程安全;
  3. GetInstance() 只返回已有对象,不需要加锁。
缺点
  1. 如果单例对象初始化很耗时,会拖慢程序启动;
  2. 如果程序没有使用这个单例,对象也会被提前创建,可能浪费资源;
  3. 多个全局/静态对象之间如果存在初始化依赖,不同编译单元之间的初始化顺序不容易保证,可能出现初始化顺序问题。

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 这个指针变量确实是同一个变量,但它先后指向了两个不同对象。

问题包括:

  1. 构造函数被调用多次,违反单例;
  2. 第一个对象可能丢失地址,造成内存泄漏;
  3. 极端情况下,不同线程可能拿到不同对象地址。

所以不能认为“同一个指针变量重新赋值还是同一个对象”。指针变量相同,不代表它指向的对象相同。

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++ 中不建议把裸指针双重检查作为首选实现。

更推荐的方式是:

  1. C++11 局部静态对象;
  2. std::call_once
  3. 使用 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. 局部静态对象版的缺点

这个版本非常推荐,但也有局限:

  1. 对象存储在静态存储区,生命周期到程序结束;
  2. 不能主动控制释放时机;
  3. 如果单例对象特别大,或者释放顺序很敏感,可能不适合;
  4. 析构阶段如果依赖其他已销毁的全局对象,仍然可能出现析构顺序问题。

不过对于常见业务场景,这仍然是 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;

静态成员变量特点:

  1. 属于类,不属于某个对象;
  2. 所有对象共享一份;
  3. 类内通常只是声明,类外需要定义;
  4. 存储在静态存储区;
  5. 生命周期通常贯穿整个程序。

2. 静态成员函数

class Singleton
{
public:
    static Singleton& GetInstance();
};

静态成员函数特点:

  1. 没有 this 指针;
  2. 可以通过类名直接调用;
  3. 不能直接访问非静态成员变量;
  4. 适合提供全局访问入口。

因为 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 以后,局部静态变量初始化是线程安全的,所以这种单例写法简单且可靠。

十七、易错点总结

  1. new 调用构造函数,delete 调用析构函数,不要把它们理解成单纯的申请和释放内存。
  2. 只把析构函数私有化,外部仍然可以直接 new,因为 new 主要需要访问构造函数。
  3. 只把构造函数私有化,外部不能直接创建对象,但如果拷贝构造没有禁用,可能通过已有对象拷贝出新对象。
  4. Create() 必须是静态成员函数,否则调用它之前必须先有对象,逻辑上矛盾。
  5. delete this 只能用于确定在堆上创建的对象,调用后不能继续访问当前对象。
  6. 单例不是“创建多个对象后覆盖成一个”,而是从语法上禁止外部创建第二个对象。
  7. GetInstance() 多次调用返回的是同一个对象,调用 Add() 等成员函数只是修改同一个对象内部的数据,不是创建新对象。
  8. 指针变量相同不代表对象相同,每执行一次 new 都会产生一个新的对象地址。
  9. 双重检查中第一次判空是为了性能,第二次判空是为了防止重复创建。
  10. 传统裸指针双重检查要考虑数据竞争和指令重排,现代 C++ 更推荐局部静态对象或 std::call_once
  11. 局部静态对象不是普通局部变量,它的作用域是局部的,但生命周期是整个程序。
  12. 对象版饿汉和对象版懒汉的本质区别是初始化时机,不是简单的“全局”和“局部”。
  13. 静态成员变量类内通常只是声明,类外还需要定义,否则可能出现链接错误。
  14. 静态成员函数没有 this 指针,所以不能直接访问非静态成员。
  15. final 是 C++11 中禁止继承的直接方式,比 C++98 私有构造函数方式更彻底。

十八、最终记忆版

特殊类设计的核心是利用 C++ 的访问控制和对象生命周期规则来限制对象行为。构造函数决定对象能否被创建,析构函数决定对象能否被销毁,拷贝构造和赋值运算符决定对象能否被复制,operator newoperator delete 决定对象能否在堆上分配。
设计只能在堆上创建的类,可以把构造函数私有化,并提供静态创建接口在类内部 new;也可以把析构函数私有化,利用栈对象和静态对象会自动析构这一点,使它们因无法访问私有析构函数而编译失败。如果希望创建和销毁都受控,可以同时私有化构造和析构,提供 Create()Destroy()
单例模式的目标是保证一个类全局只有一个实例。实现时一般私有化构造函数,删除拷贝构造和赋值运算符,并提供静态 GetInstance() 访问唯一对象。饿汉模式在 main() 之前创建对象,实现简单但可能影响启动速度,也可能有静态初始化顺序问题;懒汉模式第一次使用时创建对象,能延迟加载,但要处理线程安全。C++11 以后最推荐使用函数内局部静态对象实现懒汉单例,因为它只在第一次调用时初始化,并且初始化过程由标准保证线程安全。

更多推荐