前言「特殊类设计」不是 C 标准里的术语而是对一类带有额外约束的类的统称不能被拷贝的类、只能创建一个实例的类、只能建立在堆上或栈上的类、不能被继承的类、把实现完全隐藏起来的类。它们共同的特点是用语言机制把某些「不该发生」的操作变成编译错误而不是靠文档和约定。这类设计在实践中非常常见资源管理类通常禁止拷贝配置对象常常是单例跨 ABI 的库接口普遍使用 Pimpl。但围绕它们也有大量过时写法用private声明但不定义来禁止拷贝、用private构造函数加友元来禁止继承、用auto_ptr管理 Pimpl。C11 之后 delete、final、std::unique_ptr已经把这些问题解决得更干净。本文以 C17 为基准按「限制拷贝与移动、限制实例数量、限制创建位置、隐藏实现」四类展开。代码只按标准语义推演未在本机编译涉及 C20 的特性会单独注明。一、禁止拷贝从 private 未定义到 deleteC11 之前的标准做法是把拷贝构造函数和拷贝赋值声明为private并且不定义类外调用会因访问控制报错友元或成员函数误用则在链接期报「未定义符号」。这种写法能工作但错误发现得晚诊断信息也差。C11 起直接用 delete// 适用于 C17class NonCopyable {public:NonCopyable(const NonCopyable) delete;NonCopyable operator(const NonCopyable) delete;protected:NonCopyable() default;~NonCopyable() default; // 非虚protected};把它作为基类私有继承派生类就自动不可拷贝。析构函数放在protected且非虚是为了阻止「通过基类指针 delete 派生对象」——这种删除是未定义行为undefined behavior, UB。代价是NonCopyable本身不能直接实例化后销毁它只作为基类使用。有一个容易忽略的规则只要用户声明了拷贝构造函数哪怕写成 delete编译器就不会再隐式生成移动构造函数。因此「禁止拷贝」往往也意味着「默认不可移动」。如果希望类可移动但不可拷贝需要显式写出移动操作// 适用于 C17class MoveOnly {public:MoveOnly() default;MoveOnly(const MoveOnly) delete;MoveOnly operator(const MoveOnly) delete;MoveOnly(MoveOnly) noexcept default;MoveOnly operator(MoveOnly) noexcept default;};移动操作标记noexcept很重要std::vector在扩容时只有在移动构造被确认不抛异常或类型不可拷贝的情况下才会选择移动否则为了强异常安全会退回拷贝。这不是性能数字而是标准库选择策略的一部分。二、单例把「唯一」交给语言保证单例的目标是「全程序只有一个实例并提供统一访问点」。C11 之后最简洁可靠的写法是函数内静态变量// 适用于 C17class Config {public:static Config instance() {static Config inst; // C11 起初始化线程安全return inst;}Config(const Config) delete;Config operator(const Config) delete;int port() const { return port_; }void set_port(int p) { port_ p; }private:Config() default;int port_ 8080;};函数内静态变量的初始化在 C11 起由标准保证线程安全多个线程同时首次调用instance()只有一个线程执行构造其余线程等待。这解决了很多老代码里双重检查锁定double-checked locking写错的问题。注意「初始化线程安全」不等于「对象访问线程安全」多个线程读写port_仍然需要同步。单例的销毁发生在程序退出阶段顺序与构造顺序相反但跨翻译单元的顺序不作保证。如果单例 A 的析构依赖单例 B而 B 已经先被销毁就会访问已销毁对象。规避办法是避免单例之间互相依赖或者使用「永不销毁」的写法函数内静态指针 new代价是一次有意的泄漏。更根本的问题是单例把依赖隐藏成了全局状态单元测试难以替换。现代做法是把单例当作「默认实现」核心逻辑通过构造函数注入接口只有在确实需要全局唯一、且生命周期贯穿整个进程时才使用单例。三、限制创建位置堆上、栈上与不可继承只允许在堆上创建常见做法是把析构函数设为private或protected再提供一个静态销毁函数// 适用于 C17class HeapOnly {public:static HeapOnly* create() { return new HeapOnly(); }static void destroy(HeapOnly* p) { delete p; }HeapOnly(const HeapOnly) delete;HeapOnly operator(const HeapOnly) delete;private:HeapOnly() default;~HeapOnly() default;};栈上对象在作用域结束时需要调用析构函数而析构函数不可访问编译就会失败new出来的对象由destroy在类内delete访问权限没有问题。若把构造函数也设为私有create是唯一入口进一步收紧了创建方式。要提醒的是这只能约束「正常写法」不能防止有人用placement new在自定义缓冲区里构造对象因此它不是安全边界。只允许在栈上创建通常是把operator new和operator new[]声明为 delete// 适用于 C17#include cstddefclass StackOnly {public:StackOnly() default;StackOnly(const StackOnly) delete;StackOnly operator(const StackOnly) delete;static void* operator new(std::size_t) delete;static void* operator new[](std::size_t) delete;};这样new StackOnly会编译失败而局部变量不受影响。全局对象、静态对象也走静态存储不会被这条规则挡住如果连静态对象也要禁止需要另想办法通常没有必要。禁止继承在 C11 之前靠「私有构造 友元基类」的巧妙写法C11 起直接用final// 适用于 C17class Task final {public:virtual void run() 0;virtual ~Task() default;};final既可以修饰类禁止继承也可以修饰虚函数禁止派生类覆盖。它比老技巧直观得多编译错误也直接指向继承声明。注意final只禁止继承不影响拷贝、移动或实例化。四、隐藏实现Pimpl 与不完整类型Pimplpointer to implementation也叫 Cheshire Cat把私有成员放进一个只在前向声明中出现的嵌套结构体头文件只保留一个指针。好处是头文件不暴露实现细节减少编译依赖修改实现不会改变类的大小有利于二进制兼容。// 适用于 C17// widget.h#include memoryclass Widget {public:Widget();~Widget();Widget(Widget) noexcept;Widget operator(Widget) noexcept;Widget(const Widget) delete;Widget operator(const Widget) delete;void draw() const;int value() const;private:struct Impl;std::unique_ptrImpl impl_;};// 适用于 C17// widget.cpp#include widget.h#include vectorstruct Widget::Impl {int value 0;std::vectorint cache;};Widget::Widget() : impl_(std::make_uniqueImpl()) {}Widget::~Widget() default;Widget::Widget(Widget) noexcept default;Widget Widget::operator(Widget) noexcept default;void Widget::draw() const {// 使用 impl_}int Widget::value() const {return impl_-value;}关键点std::unique_ptrImpl的析构需要Impl是完整类型而头文件里Impl只有声明。因此析构函数必须在.cpp中定义不能写成头文件里的~Widget() default;否则会在实例化unique_ptr的删除器时报「不完整类型」错误。移动操作同理也要在.cpp中定义。这条例外经常被忽略也是 Pimpl 最常见的编译错误来源。拷贝操作被删除是因为unique_ptr本身不可拷贝如果需要可拷贝的 Pimpl得自己写深拷贝复制*impl_而不是复制指针。移动之后源对象的impl_变为空指针成员函数必须能容忍这种「有效但未指定」的状态通常约定移后对象只能销毁或重新赋值。五、完整示例非拷贝基类 单例 Pimpl下面的例子把三种技巧组合在一个小型「会话注册表」里。它展示结构不涉及平台 API也不断言运行输出。// 适用于 C17#include iostream#include memory#include string#include vectorclass NonCopyable {public:NonCopyable(const NonCopyable) delete;NonCopyable operator(const NonCopyable) delete;protected:NonCopyable() default;~NonCopyable() default;};class Session : private NonCopyable {public:explicit Session(int id) : id_(id) {}int id() const { return id_; }private:int id_;};class Registry {public:static Registry instance() {static Registry inst;return inst;}void add(int id) {sessions_.push_back(std::make_uniqueSession(id));}std::size_t size() const { return sessions_.size(); }Registry(const Registry) delete;Registry operator(const Registry) delete;private:Registry() default;std::vectorstd::unique_ptrSession sessions_;};int main() {Registry r Registry::instance();r.add(1);r.add(2);std::cout r.size() \n;return 0;}Session私有继承NonCopyable因此不可拷贝Registry通过删除拷贝操作保证唯一实例不会被复制构造函数私有保证外部无法另建。std::make_unique需要 C14 及以上。若把Registry改成 Pimpl就要按上一节的规则把析构与移动操作移到头文件之外。常见坑点❌ 用private声明拷贝构造但不定义期待编译期就报错。✅ 用 delete在类内明确删除错误信息直接指向调用点老写法还会把错误拖到链接期。❌ 删除了拷贝构造却以为类仍然可以移动。✅ 用户声明的拷贝构造包括 delete会抑制隐式移动需要移动时显式 default移动构造与移动赋值并标noexcept。❌ 多态基类的析构函数不写virtual却通过基类指针delete派生对象。✅ 这是 UB基类析构必须virtual或改用protected非虚析构禁止这种删除。❌ Pimpl 在头文件里写~Widget() default;而Impl只有前向声明。✅ 析构、移动构造、移动赋值都在.cpp中定义那时Impl已是完整类型。❌ 只允许堆分配时把析构设为私有却没有提供类内的销毁函数。✅ 提供静态destroy在类内delete或提供自定义删除器否则对象无法被正常释放。❌ 认为把operator new设为私有或删除就能完全禁止堆分配。✅placement new、自定义分配器等方式可以绕过这类设计是接口约束不是安全机制。❌ 单例之间互相依赖A 的析构函数里调用 B。✅ 跨翻译单元的静态对象析构顺序不保证避免依赖或改用不销毁的懒初始化并在退出前显式清理。❌ 用final禁止继承后以为类就「不可复制」或「不可实例化」。✅final只阻止派生拷贝、移动、构造规则需要另行设计。总结设计目标现代做法关键注意点禁止拷贝 delete拷贝构造与赋值同时考虑是否需要显式移动唯一实例函数内静态变量 私有构造初始化线程安全访问不自动安全仅堆创建私有析构 静态销毁函数不是安全边界可被 placement new 绕过仅栈创建删除operator new静态对象不受影响禁止继承final不影响拷贝与实例化隐藏实现Pimpl unique_ptr析构与移动必须在源文件定义多态基类virtual ~Base() default;否则经基类指针删除是 UB特殊类设计的共同思路是「让错误在编译期发生」删除函数、访问控制、final、不完整类型都是把运行期约定前移到类型系统。选择哪种技巧取决于你要限制的是拷贝、继承、创建位置还是实现可见性不要把多种约束混在一个类里也不要把接口约束误当成安全防护。现代 C 已经为绝大多数场景提供了直接语法老技巧只在维护旧代码时才有存在价值。