C++进阶(09):特殊类设计

💬 :如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区畅所欲言!我们一起学习、共同成长~!

👍 :如果你觉得这篇文章还不错,不妨顺手点个赞、加入收藏,并分享给更多的朋友噢~!


1. 设计一个不能被拷贝的类

1.1 核心原理【必考核心】

考察场景:笔试选择题、面试口述、手撕代码基础题

类的拷贝行为仅通过两个成员函数触发:拷贝构造函数拷贝赋值运算符重载。禁止拷贝的核心思路是禁用这两个函数,让编译器在编译期阻止拷贝行为。

1.2 C++11 标准实现:= delete【必考核心】

C++11 引入 = delete 语法,显式告知编译器删除该默认成员函数,编译期直接报错,是校招面试的首选写法,也是工程中的规范实现。

cpp 复制代码
class NonCopyable {
public:
    NonCopyable() = default;
    ~NonCopyable() = default;
    
    // 核心:禁用拷贝构造与拷贝赋值
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
    
    // 可选拓展:同时禁用移动语义,根据业务需求补充
    NonCopyable(NonCopyable&&) = delete;
    NonCopyable& operator=(NonCopyable&&) = delete;
};

面试标准话术

C++11 的=delete是最规范的禁止拷贝方式,优势:

① 编译期即可发现错误,报错信息明确;

② 语法语义清晰,代码可读性强;

③ 不受友元、成员函数等场景的绕过风险。

1.3 C++98 兼容实现(旧标准)【高频易错】

考察场景:笔试概念辨析、代码找错题

C++98 无=delete语法,通过「私有声明拷贝构造和拷贝赋值 + 只声明不定义」实现,存在多处易错陷阱:

cpp 复制代码
class NonCopyable {
private:
    // 仅声明,不提供实现;且必须设为私有
    NonCopyable(const NonCopyable&);
    NonCopyable& operator=(const NonCopyable&);
public:
    NonCopyable() {}
    ~NonCopyable() {}
};

高频踩坑点

  1. 必须设为private:若为公有访问权限,类外可自行定义函数实现,无法真正禁止拷贝;
  2. 只声明不定义:若完整实现函数,类内成员函数、友元函数仍可正常调用拷贝,失去禁用效果;
  3. 报错时机为链接期 而非编译期:类外调用拷贝时编译不报错,链接时才提示找不到函数定义,排查成本远高于=delete

面试官常见追问

C++98 的写法有没有漏洞?

答:存在漏洞。类的友元函数、成员函数内部仍可尝试调用拷贝,此时会触发链接错误 而非编译错误,排查成本高;且语法不直观,可读性差。因此工程与面试中优先使用 C++11 的=delete


2. 设计一个只能在堆上创建对象的类

2.1 主流核心思路【高频易错】

考察场景:面试口述、代码改错题、手撕代码拓展题

C++ 对象可创建在栈区、全局静态区、堆区,想要对象仅能在堆上创建,就必须禁止在栈区、全局静态区实例化对象

➡ 对象创建阶段,编译器提前校验该对象生命周期结束后能否正常析构,未来无法析构直接禁止创建

栈对象、全局对象 离开作用域 / 程序结束时,编译器自动调用析构函数 释放;堆对象 由程序员手动 delete释放

析构函数设为私有 / 保护 ,可直接阻止栈对象、全局对象的创建(编译报错),仅允许通过new在堆上创建

2.2 实现方案

2.2.1 析构私有 + 公有释放接口(装delete)

cpp 复制代码
class HeapOnly {
public:
    HeapOnly() = default;

    // 提供公有释放接口
    void destroy() const {
        delete this; // 堆对象手动delete
    }
private:
    // 析构函数私有
    ~HeapOnly() {}

    // ⚠️【高频易错】必须禁用拷贝构造,否则可绕过限制
    HeapOnly(const HeapOnly&) = delete;
    HeapOnly& operator=(const HeapOnly&) = delete; // 习惯拷贝赋值一起禁用
};

int main() {
    HeapOnly* ptr = new HeapOnly;
    ptr->destroy();

    // ✅ 禁用拷贝构造:栈上创建,编译报错
    // HeapOnly stackObj;
    // ❌ 未禁用拷贝构造:用堆对象拷贝构造【栈对象】
    // HeapOnly* p = new HeapOnly; 
    // HeapOnly obj(*p);

    return 0;
}

2.2.2 优化:protected 析构支持继承【进阶拔高】

派生类析构时,自动向上逐层调用基类析构函数。

  • 基类析构 private :仅基类内部可访问,派生类完全无法调用基类析构,派生类无法完成析构,基类不能被继承
  • 基类析构 protected :基类内部、所有派生类均可访问,外部不可访问,派生类能正常调用析构

改为 protected 可兼顾「禁止栈对象」和「支持继承」:

cpp 复制代码
class HeapOnly {
public:
    void destroy() const { delete this; }
protected:
    ~HeapOnly() {} // 派生类可访问,类外不可访问
};

2.3 替代实现:普通构造私有 + 静态创建接口【高频易错】

  1. 普通构造私有:定义栈对象、全局对象,编译器在类外调用普通构造完成初始化,普通构造私有类外就无法调用。
  2. 静态创建接口
    ① 无需对象实例,外部可直接通过 类名::静态函数 调用创建接口;
    ② 静态成员函数属于类,能够访问私有构造,从而在内部使用 new 创建堆对象。
cpp 复制代码
class HeapOnly {
public:
    static HeapOnly* createInstance() {
        return new HeapOnly;
    }
    void destroy() { delete this; }
private:
    HeapOnly() = default;
    // ⚠️【高频易错】必须禁用拷贝构造,否则可绕过限制
    HeapOnly(const HeapOnly&) = delete;
    HeapOnly& operator=(const HeapOnly&) = delete;
};

int main() {
	HeapOnly* ptr = HeapOnly::createInstance();
	ptr->destroy();
	return 0;
}

3. 设计一个只能在栈上创建对象的类

3.1 核心思路【高频易错】

考察场景:面试口述、笔试选择题、代码找错题

只有使用new运算符时,对象才会分配在堆上。new的执行流程是:调用operator new申请内存 ➡ 调用构造函数。因此禁用operator newoperator delete,即可阻止堆上创建。

  • 主流常规方案:只禁用operator new/operator delete阻断堆对象创建,无法限制全局、静态对象
  • 若要彻底禁止全局 / 静态对象,必须额外将构造函数私有,对外提供静态函数仅返回临时栈对象。

3.2 实现方案

3.2.1 常规方案:只禁用 operator new/operator delete

cpp 复制代码
class StackOnly {
public:
    StackOnly() = default;
    ~StackOnly() = default;

    // 禁用普通new/delete,阻断单个对象堆分配
    void* operator new(size_t size) = delete;
    void operator delete(void* p) = delete;
    // 禁用数组new/delete,阻断数组对象堆分配
    void* operator new[](size_t size) = delete;
    void operator delete[](void* p) = delete;
private:
    int _a;
};

// 仍可创建全局对象、静态对象
StackOnly globalObj;
void func() {
    static StackOnly staticObj;
}

int main() {
    StackOnly localObj; // 栈对象

    // 堆上创建,编译报错
    // StackOnly* ptr = new StackOnly;

    return 0;
}

3.2.2 严格方案【进阶拔高】

cpp 复制代码
class StackOnly {
public:
    // 唯一合法创建入口,仅返回栈临时对象
    static StackOnly CreateObj() {
        return StackOnly();
    }
    // 禁用所有堆内存分配入口
    void* operator new(size_t size) = delete;
    void operator delete(void* p) = delete;
    void* operator new[](size_t size) = delete;
    void operator delete[](void* p) = delete;
private:
    StackOnly() :_a(0) {}
    int _a;
};

int main() {
    // 合法:栈临时对象
    StackOnly obj = StackOnly::CreateObj();

    // 非法1:直接定义栈对象,构造私有,编译报错
    // StackOnly obj2;
    // 非法2:全局/静态对象,构造私有,编译报错
    // static StackOnly s_obj;
    // StackOnly g_obj;
    // 非法3:堆对象,new被禁用,编译报错
    // StackOnly* p = new StackOnly;
    return 0;
}

3.3 常见陷阱与边界

3.3.1 禁用new语法:拷贝构造绕不过【高频易错】

只要类内删除了operator new,无论调用普通构造还是拷贝构造,只要搭配new语法创建堆对象,一定会调用operator new,直接编译报错。

3.3.2 placement new 绕过【进阶拔高】

考察场景:二面深度追问

placement new(定位 new)不调用operator new,直接在已分配的内存上构造对象,内存来源可以是堆、栈、静态缓冲区,能够绕开 new 禁用限制:

cpp 复制代码
void* buf = malloc(sizeof(StackOnly));
StackOnly* ptr = new(buf) StackOnly; // 定位new,可在堆内存上构造对象

工程开发几乎不会使用 placement new;笔试基础题默认不考察该漏洞,二面深度追问时说明该漏洞即可,无需编写封堵代码。


4. 设计一个不能被继承的类

4.1 C++11 标准实现:final【必考核心】

考察场景:笔试选择题、面试口述

C++11 引入final关键字,修饰类时表示该类不可被继承,是校招的标准作答,语法简单且编译期校验。

cpp 复制代码
class NonInherit final {
    // 类内容
};

// 编译报错:无法继承final类
// class Derived : public NonInherit {};

补充关联考点final还可修饰虚函数,表示该虚函数不可被派生类重写,属于同一系列高频考点。

4.2 C++98 兼容实现【冷门了解】

C++98 无final,通过「构造函数私有」实现不可继承:派生类构造必须调用基类构造,若基类构造私有,派生类无法初始化基类,因此无法继承。

cpp 复制代码
class NonInherit {
private:
    NonInherit() {} // 构造私有
public:
    static NonInherit createInstance() {
        return NonInherit();
    }
};

说明 :该写法同时会限制类的实例化方式,使用不便,工程中已被final完全替代,2024-2026 大厂校招几乎不考察该写法。


5. 设计一个只能创建一个对象的类(单例模式)

5.1 核心概念【必考核心】

考察场景:手撕代码必考题、面试连环追问、笔试代码分析题

单例模式:保证一个类在整个程序运行周期中有且仅有一个实例,并提供全局统一的访问接口。

  • 典型应用场景:配置管理、日志器、线程池、数据库连接池等全局唯一资源。
  • 核心实现三要素:① 构造函数私有;② 禁用拷贝构造与拷贝赋值;③ 静态接口获取唯一实例。
    【关联】与只能在堆上创建对象的类 2.3 替代实现:普通构造私有 + 静态创建接口 类似

5.2 饿汉模式(Eager Initialization)【必考核心】

5.2.1 实现代码

饿汉模式将单例实例定义为类静态全局变量,在 main 函数执行前完成初始化。此时所有业务线程还未创建,不存在多线程并发创建对象的竞争,因此天然线程安全。

cpp 复制代码
class SingletonEager {
public:
	// ③ 成员m_instance私有,只能通过getInstance获取
	static SingletonEager* getInstance() {
		return &m_instance;
	}

	// ② 禁用拷贝构造与拷贝赋值
	SingletonEager(const SingletonEager&) = delete;
	SingletonEager& operator=(const SingletonEager&) = delete;

private:
	// ① 构造函数私有
	SingletonEager() = default;
	~SingletonEager() = default;
	// 类内声明静态成员(同一个类的静态成员变量,整个程序全局永远只有 1 份)
	static SingletonEager m_instance;
};

// 静态变量类外初始化
// 类型是类名SingletonEager,初始化类对象
SingletonEager SingletonEager::m_instance;

int main() {
	auto p = SingletonEager::getInstance();
	return 0;
}

5.2.2 优缺点与适用场景

  • 优点:实现简单,天生线程安全,无加锁开销,获取实例速度快;
  • 缺点:程序启动时就创建实例,若实例初始化耗时久、占用资源多,会拖慢启动速度;多个编译单元的单例之间初始化顺序无保证。
  • 适用场景:实例初始化快、体积小,程序启动后必然使用的场景。

⚠️【高频易错】饿汉模式的实例是静态对象,存储在全局 / 静态区,不需要手动释放,程序结束时自动析构

5.3 懒汉模式(Lazy Initialization):双检锁实现【必考核心】

  • 懒汉模式程序启动时不立刻创建对象,第一次调用创建接口 getInstance 时才创建对象;
  • 多线程并发会导致多次 new,破坏单例,因此需要双重检查锁定 DCL(Double-Checked Locking)。
cpp 复制代码
#include <mutex>
#include <atomic>

class SingletonLazy {
public:
    // ③ 静态接口获取唯一实例
    static SingletonLazy* getInstance() {
        // 第一次检查:无锁快速判断,99%直接返回,避免每次加锁
        if (m_instance == nullptr) {
            // 保证同一时刻仅一个线程进入后续实例创建代码段
            std::lock_guard<std::mutex> lock(m_mutex);
            // 第二次检查:防止多线程同时通过第一次检查,重复创建实例
            if (m_instance == nullptr) {
                m_instance = new SingletonLazy;
            }
        }
        return m_instance;
    }

    // ② 禁用拷贝与赋值
    SingletonLazy(const SingletonLazy&) = delete;
    SingletonLazy& operator=(const SingletonLazy&) = delete;

private:
    // ① 构造私有
    SingletonLazy() = default;
    ~SingletonLazy() = default;

    // 原子指针:防止CPU/编译器指令重排,避免new乱序造成野指针
    static std::atomic<SingletonLazy*> m_instance;
    // 全局互斥锁
    static std::mutex m_mutex;
};

// 类外初始化
// 类型是指针,仅指针赋空,未初始化类对象
std::atomic<SingletonLazy*> SingletonLazy::m_instance{ nullptr }; 
std::mutex SingletonLazy::m_mutex;

5.3.2 双检锁核心考点【高频易错】

5.3.2.1 为什么要两次检查?
  1. 第一次外层无锁检查:唯一实例创建后,后续绝大多数调用无需加锁阻塞,仅做一次空判断即可直接返回,避免高并发时锁竞争带来的性能损耗;
  2. 第二次内层加锁检查:多线程场景下,可能有多个线程同时通过第一次检查。先拿到锁的线程创建实例并释放锁后,下一个线程会立刻获取锁。若无内层二次判断,该线程会再次执行 new,生成第二个实例,单例被破坏。
5.3.2.2 指令重排问题

普通指针执行 m_instance = new SingletonLazy; ,底层分 3 步:

① 分配堆内存;

② 指针 m_instance 赋值;

③ 调用构造函数初始化对象。

编译器、CPU 会指令重排,可能乱序为①→②→③: 线程 A 指针已赋值,但对象未初始化,线程 B 判断指针非空,返回未初始化的野指针,野指针访问成员导致崩溃。

💡标准解决方案 :使用std::atomic原子指针,默认内存序顺序一致,禁止指令重排,避免野指针。

5.3.3 实例释放方案【进阶拔高】

堆上创建的懒汉单例需要释放资源,常见三种实现方式:

  1. 内嵌垃圾回收类:利用静态对象析构时自动释放

    cpp 复制代码
    class CGarbo {
    public:
        ~CGarbo() {
            if (SingletonLazy::m_instance) {
                delete SingletonLazy::m_instance.load();
            }
        }
    };
    static CGarbo garbo; // 静态成员,程序结束自动析构触发释放
  2. atexit() 注册释放函数:程序退出时自动调用

  3. 手动释放接口:提供静态 destroy 方法,业务主动调用(不推荐,易遗漏)

5.4 进阶实现:Meyers' Singleton(最推荐)【进阶拔高】

考察场景:二面高频追问

利用 C++11 标准特性:函数内静态局部变量的初始化是线程安全的,实现最简线程安全懒汉单例。

cpp 复制代码
class SingletonMeyers {
public:
    static SingletonMeyers& getInstance() {
        static SingletonMeyers instance; // 首次调用时初始化,C++11保证线程安全
        return instance;
    }
    
    SingletonMeyers(const SingletonMeyers&) = delete;
    SingletonMeyers& operator=(const SingletonMeyers&) = delete;
    
private:
    SingletonMeyers() = default;
    ~SingletonMeyers() = default;
};
  • 优势:代码极简,无需手动加锁,无指令重排问题,C++11 及以上标准保证线程安全;
  • 面试价值:主动提出该方案,可体现对现代 C++ 标准的熟悉度,拉开与普通候选人的差距。
相关推荐
宁风NF1 小时前
JavaScript:网络请求与前端通信
开发语言·前端·javascript·网络·学习·ecmascript
皓月斯语2 小时前
B3867 [GESP202309 三级] 小杨的储蓄 题解
c++·算法·题解
techdashen2 小时前
Go设计取舍之三: 0.3ns每次的错误Benchmark
开发语言·后端·golang
rosmis2 小时前
agent各指标定义
android·java·开发语言
mifengxing2 小时前
Java 集合进阶(一)
java·开发语言·数据结构·复习笔记
choumin2 小时前
行为型模式——模板方法模式
c++·设计模式·模板方法模式·行为型模式
爱码小白2 小时前
importlib模块
开发语言·前端·python
techdashen3 小时前
Go设计取舍之四: map不变时能否并发修改不同value
开发语言·后端·golang