Effective C++ 条款06:若不想使用编译器自动生成的函数,就应该明确拒绝

在 C++ 中,编译器会默默地为你生成一些函数。但有时候,这些"好心"的自动生成反而会成为代码中的隐患。今天我们来聊聊如何对编译器说"不"。

一、编译器会默默生成哪些函数?

在 C++ 中,如果你声明了一个空类:

class Empty {};

编译器会自动为它生成以下函数(如果它们被需要的话):

自动生成的函数 说明
默认构造函数 Empty()
拷贝构造函数 Empty(const Empty& rhs)
拷贝赋值运算符 Empty& operator=(const Empty& rhs)
析构函数 ~Empty()
移动构造函数 (C++11) Empty(Empty&& rhs)
移动赋值运算符 (C++11) Empty& operator=(Empty&& rhs)

这意味着,下面这段代码完全可以编译通过:

Empty e1;       // 调用默认构造函数
Empty e2(e1);   // 调用拷贝构造函数
Empty e3;
e3 = e1;        // 调用拷贝赋值运算符

编译器的这种行为在大多数情况下是便利的,但在某些场景下,我们恰恰不希望对象被拷贝或赋值

二、为什么要拒绝编译器自动生成的函数?

2.1 典型场景

想象一下,你正在实现一个文件句柄管理类

class FileHandle {
public:
    FileHandle(const char* filename) {
        fd_ = open(filename, O_RDONLY);
    }
    ~FileHandle() {
        if (fd_ != -1) close(fd_);
    }
private:
    int fd_;
};

如果允许拷贝这个对象,会发生什么?

FileHandle fh1("data.txt");
FileHandle fh2 = fh1;  // 危险!两个对象持有同一个 fd
// fh1 和 fh2 析构时都会 close(fd_),导致双重关闭!

又或者,你设计了一个单例模式的类,或者一个表示唯一资源标识的类(如数据库连接、锁句柄等),拷贝行为在语义上本身就是不合理的。

2.2 问题的本质

这些类的共同特点是:

  • 拷贝会导致资源重复释放或状态不一致
  • 拷贝在业务逻辑上没有意义
  • 需要保证对象的唯一性

因此,我们必须明确拒绝编译器自动生成拷贝构造函数和拷贝赋值运算符

三、C++98 时代的解决方案:private + 不实现

在 C++11 之前,Scott Meyers 在《Effective C++》中推荐的做法是:

将不想使用的成员函数声明为 private,并且不予实现。

3.1 具体做法

class FileHandle {
public:
    FileHandle(const char* filename) {
        fd_ = open(filename, O_RDONLY);
    }
    ~FileHandle() {
        if (fd_ != -1) close(fd_);
    }

private:
    // 声明为 private,阻止外部调用
    FileHandle(const FileHandle&);             // 只声明,不实现
    FileHandle& operator=(const FileHandle&);  // 只声明,不实现

    int fd_;
};

3.2 为什么这样做有效?

层面 防护效果
外部代码 无法访问 private 成员,编译报错
成员函数 / 友元 如果误调用,链接时会报错(符号未定义)

3.3 更优雅的做法:Uncopyable 基类

为了代码复用,可以设计一个专门的基类:

class Uncopyable {
protected:
    Uncopyable() {}   // 允许派生类构造
    ~Uncopyable() {}  // 允许派生类析构

private:
    Uncopyable(const Uncopyable&);             // 阻止拷贝
    Uncopyable& operator=(const Uncopyable&);  // 阻止赋值
};

使用时只需继承即可:

class FileHandle : private Uncopyable {
public:
    FileHandle(const char* filename) { /* ... */ }
    ~FileHandle() { /* ... */ }
private:
    int fd_;
};

这样做的好处是:

  • 语义清晰:一眼就能看出该类不可拷贝
  • 代码复用:不需要在每个类中重复声明 private 函数
  • 错误信息友好:编译错误会指向 Uncopyable,提示"尝试拷贝不可拷贝的对象"

Boost 库中的 boost::noncopyable 就是这一思想的实现。

四、现代 C++ 的解决方案:= delete

从 C++11 开始,我们有了更直接、更优雅的语法:

class FileHandle {
public:
    FileHandle(const char* filename) { /* ... */ }
    ~FileHandle() { /* ... */ }

    FileHandle(const FileHandle&) = delete;             // 明确删除
    FileHandle& operator=(const FileHandle&) = delete;  // 明确删除

private:
    int fd_;
};

4.1 = delete 的优势

特性 private + 不实现 = delete
语义清晰度 间接,需要理解设计意图 直接表达"此函数被删除"
错误时机 链接期(调用时) 编译期(任何尝试使用的地方)
代码简洁度 需要额外基类或重复声明 一行搞定
现代性 C++98 兼容 C++11 及以上

4.2 = delete 的额外能力

= delete 不仅可以用于自动生成的函数,还可以用于禁止特定类型的隐式转换

class Widget {
public:
    Widget(int id) { /* ... */ }
    Widget(double) = delete;  // 禁止 double 隐式转换构造
};

Widget w1(42);      // OK
Widget w2(3.14);    // 编译错误!double 构造函数被删除

五、实际应用场景

5.1 资源管理类(RAII)

class MutexLock {
public:
    explicit MutexLock(pthread_mutex_t* mutex) : mutex_(mutex) {
        pthread_mutex_lock(mutex_);
    }
    ~MutexLock() {
        pthread_mutex_unlock(mutex_);
    }

    MutexLock(const MutexLock&) = delete;
    MutexLock& operator=(const MutexLock&) = delete;

private:
    pthread_mutex_t* mutex_;
};

5.2 单例模式的基础

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

private:
    Singleton() = default;
};

5.3 不可复制的策略对象

class LoggingStrategy {
public:
    virtual void log(const std::string& msg) = 0;
    virtual ~LoggingStrategy() = default;

    LoggingStrategy(const LoggingStrategy&) = delete;
    LoggingStrategy& operator=(const LoggingStrategy&) = delete;
};

六、常见误区与注意事项

6.1 只声明 private 但不实现,真的安全吗?

在 C++98 中,如果类的成员函数或友元函数内部调用了这些 private 函数,编译器不会报错(因为可以访问 private),但链接器会报错。这意味着错误发现时机被推迟到了链接期。

= delete 能在编译期就发现错误,更加安全。

6.2 需要同时禁用拷贝构造和拷贝赋值吗?

是的!如果只禁用其中一个,另一个仍然可能被误用,导致语义不一致。例如:

class Widget {
public:
    Widget(const Widget&) = delete;  // 禁用了拷贝构造
    // 但拷贝赋值仍然可用!
};

Widget w1;
Widget w2;
w2 = w1;  // 编译通过,但语义上可能有问题

6.3 移动语义怎么办?

在 C++11 中,如果你声明了拷贝构造/拷贝赋值为 = delete,编译器通常也不会自动生成移动构造/移动赋值。如果需要支持移动语义,需要显式声明:

class FileHandle {
public:
    FileHandle(FileHandle&& other) noexcept;             // 移动构造
    FileHandle& operator=(FileHandle&& other) noexcept;  // 移动赋值

    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
};

七、总结

做法 适用场景 推荐度
private + 不实现 需要兼容 C++98 的老项目 历史方案
Uncopyable 基类 C++98 中需要复用不可拷贝语义 历史方案
= delete C++11 及以上项目 强烈推荐

请记住

  • 编译器可以暗自为 class 创建默认构造函数、拷贝构造函数、拷贝赋值运算符和析构函数。
  • 如果不想使用这些自动生成的函数,就应该明确拒绝。
  • 在 C++11 及以后,使用 = delete 是最清晰、最安全的方式。
  • 在 C++98 中,将函数声明为 private 且不予实现,或使用 Uncopyable 基类模式。

拒绝编译器的"好意",有时候正是写出健壮代码的关键一步。希望这篇文章对你有所帮助!欢迎在评论区交流讨论。


参考阅读

  • 《Effective C++》第三版,Scott Meyers
  • 《C++ Primer》第五版,关于 = delete 的章节

更多推荐