每周技巧 #143:C++11 删除函数(`= delete`)

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #143: C++11 Deleted Functions (= delete)

原文最初作为 TotW #143 发布于 2018 年 3 月 2 日。

作者:Leonard Mosescu

更新于 2020 年 4 月 6 日。

快捷链接:abseil.io/tips/143

引言

一般意义上的接口通常定义可以调用的一组操作。但有时我们可能想表达相反的意思:显式定义一组不应该使用的操作。例如,禁用拷贝构造函数和拷贝赋值运算符,是限制特定类型拷贝语义的常见方式。

语言提供了多种选项来施加这类限制(我们很快会逐一探索):

  1. 提供一个只包含运行时检查的哑实现。
  2. 使用访问控制(protected/private)让函数不可访问。
  3. 声明函数,但有意省略定义。
  4. 从 C++11 开始:显式把函数定义为“已删除”。

C++11 之前的技术范围很广,从运行时检查(#1)到编译期(#2)或链接期(#3)诊断。虽然久经考验,但这些技术远非完美:对于约束是静态的多数情况,运行时检查并不理想;链接期检查又把诊断推迟到构建流程非常晚的阶段。此外,链接期诊断并不保证发生(缺少 ODR 使用函数的定义是一种 ODR 违规),而且实际诊断消息很少对开发者友好。

编译期检查更好,但仍有缺陷。它只适用于成员函数,并且基于访问性约束,这既啰嗦又容易出错,还容易有漏洞。此外,引用这类函数产生的错误可能具有误导性,因为错误说的是访问限制,而不是接口误用。

用 #2 和 #3 禁用拷贝会像这样:

1
2
3
4
5
6
class MyType {
 private:
  MyType(const MyType&);  // Not defined anywhere.
  MyType& operator=(const MyType&);  // Not defined anywhere.
  // ...
};

每个类都手动这样写很快会令人厌烦,所以开发者通常会用下面某种方式打包它们:

“mixin”方式boost::noncopyablenon-copyable mixin

1
2
3
class MyType : private NoCopySemantics {
  ...
};

宏方式

1
2
3
4
class MyType {
 private:
  DISALLOW_COPY_AND_ASSIGN(MyType);
};

C++11 删除定义

C++11 通过一个新的语言特性解决了对更好方案的需求:删除定义 [dcl.fct.def.delete]。(见 C++ 标准草案中的“deleted definitions”。)任何函数都可以被显式定义为已删除

1
void foo() = delete;

这个语法很直接,类似默认化函数,但有几个值得注意的差异:

  1. 任何函数都可以删除,包括非成员函数(与 =default 相比,后者只适用于特殊成员函数)。
  2. 函数只能在第一次声明时删除(不同于 =default)。

需要牢记的关键点是,=delete 是函数定义(它不会移除或隐藏声明)。因此,被删除函数是已定义的,并且和任何其他函数一样参与名称查找和重载决议。它是一种特殊的*“放射性”定义,意思是“不要碰!”*。

尝试使用已删除函数会导致清晰诊断的编译期错误,这是它相对 C++11 前技术的关键优势之一。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyType {
 public:
  // Disable default constructor.
  MyType() = delete;

  // Disable copy (and move) semantics.
  MyType(const MyType&) = delete;
  MyType& operator=(const MyType&) = delete;

  //...
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// error: call to deleted constructor of 'MyType'
// note: 'MyType' has been explicitly marked deleted here
//   MyType() = delete;
MyType x;

void foo(const MyType& val) {
  // error: call to deleted constructor of 'MyType'
  // note: 'MyType' has been explicitly marked deleted here
  //   MyType(const MyType&) = delete;
  MyType copy = val;
}

注意:通过显式把拷贝操作定义为已删除,我们也抑制了移动操作(有用户声明的拷贝操作会阻止隐式声明移动操作)。如果意图是使用隐式移动操作定义一个只可移动类型,可以用 =default 把它们“带回来”,例如:

1
2
MyType(MyType&&) = default;
MyType& operator=(MyType&&) = default;

其他用法

虽然上面的例子集中在拷贝语义上(这可能是最常见的情况),但任何函数(成员或非成员)都可以删除。

由于已删除函数参与重载决议,它们可以帮助捕捉非预期用法。假设我们有下面这个重载的 print 函数:

1
2
void print(int value);
void print(absl::string_view str);

调用 print('x') 会打印 'x' 的整数值,而开发者很可能想写的是 print("x")。我们可以捕捉这一点:

1
2
3
4
void print(int value);
void print(const char* str);
// Use string literals ":" instead of character literals ':'.
void print(char) = delete;

注意,=delete 不只影响函数调用。尝试取得已删除函数的地址也会导致编译错误:

1
2
void (*pfn1)(int) = &print;  // ok
void (*pfn2)(char) = &print; // error: attempt to use a deleted function

这个例子提取自真实应用:absl::StrCat()。当接口的某个特定部分必须受限时,删除函数都很有价值。

把析构函数定义为已删除,比把它设为 private 更严格(虽然这是把大锤,可能引入超出预期的限制):

1
2
3
4
5
6
7
8
9
// 一个_非常_受限的类型:
//   1. 只能动态存储。
//   2. 永远存在(不能析构)。
//   3. 不能作为成员或基类。
class ImmortalHeap {
 public:
  ~ImmortalHeap() = delete;
  //...
};

再举一个例子,这次我们只想允许分配非数组对象(真实世界示例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Don't allow new T[].
class NoHeapArraysPlease {
 public:
  void* operator new[](std::size_t) = delete;
  void operator delete[](void*) = delete;
};

auto p = new NoHeapArraysPlease;  // OK

// error: call to deleted function 'operator new[]'
// note: candidate function has been explicitly deleted
//   void* operator new[](std::size_t) = delete;
auto pa = new NoHeapArraysPlease[10];

总结

=delete 提供了一种显式方式,用来表达接口中不应被引用的部分,同时也比 C++11 前的惯用法提供更好的诊断。任何代码,包括编译器生成的代码,都不能引用已删除函数。对于更细致的访问控制,访问说明符或更复杂的技术(例如 技巧 #134 中讨论的 passkey idiom)更合适。

重要:由于删除定义是接口的一部分,它们应该和接口的其他部分具有相同访问说明符。具体来说,这意味着它们通常应该是 public。实践中,这也会产生最好的诊断(private 加 =delete 没有太大意义)。

致谢:本技巧包含许多人的关键贡献和反馈,特别感谢 Mark Mentovai、James Dennett、Bruce Dawson 和 Yitzhak Mandelbaum。

参考

每周技巧 #142:多参数构造函数和 `explicit`

上一节

每周技巧 #144:关联容器中的异构查找

下一节