运算符重载的基本规则和惯用法是什么?

注意:答案是按特定顺序给出的,但是由于许多用户是根据投票而不是给出时间来对答案进行排序的,因此以下是最有意义的顺序索引

(注意:这本来是Stack Overflow 的 C ++ FAQ的条目。如果您想批评以这种形式提供 FAQ 的想法,那么在所有这些开始的 meta 上的张贴将是这样做的地方。该问题在C ++ 聊天室中进行监控,该问题最初是从 FAQ 想法开始的,所以提出这个想法的人很可能会读懂您的答案。)

答案

普通运算符重载

重载操作员中的大部分工作是样板代码。这也就不足为奇了,由于运算符仅仅是语法糖,它们的实际工作可以由(通常转发给)普通函数来完成。但是,重要的是要正确编写此样板代码。如果失败,则操作员的代码将无法编译,或者用户的代码将无法编译,或者用户的代码将表现出惊人的性能。

赋值运算符

关于任务有很多要说的。但是,大多数内容已经在GMan 著名的复制和交换 FAQ 中进行了介绍 ,因此在此我将跳过大部分内容,仅列出完美的赋值运算符以供参考:

X& X::operator=(X rhs)
{
  swap(rhs);
  return *this;
}

位移位运算符(用于流 I / O)

尽管移位移位运算符<<>>仍用于它们从 C 继承的位处理函数的硬件接口中,但在大多数应用程序中,它们已作为重载流输入和输出运算符而变得更加普遍。有关作为位操作运算符的指导超载,请参见下面有关二进制算术运算符的部分。当对象与 iostream 一起使用时,要实现自己的自定义格式和解析逻辑,请继续。

在最常见的重载运算符中,流运算符是二进制中缀运算符,其语法对它们应为成员还是非成员不加限制。由于它们更改了左参数(它们更改了流的状态),因此应根据经验法则将其实现为其左操作数类型的成员。但是,它们的左操作数是标准库中的流,尽管标准库定义的大多数流输出和输入运算符的确定义为流类的成员,但是当您为自己的类型实现输出和输入操作时,无法更改标准库的流类型。这就是为什么您需要将自己的类型的这些运算符实现为非成员函数。两种的规范形式是:

std::ostream& operator<<(std::ostream& os, const T& obj)
{
  // write obj to stream

  return os;
}

std::istream& operator>>(std::istream& is, T& obj)
{
  // read obj from stream

  if( /* no valid object of T found in stream */ )
    is.setstate(std::ios::failbit);

  return is;
}

当实现operator>> ,仅当读取本身成功时才需要手动设置流的状态,但是结果不是预期的。

函数调用运算符

必须将用于创建函数对象(也称为函子)的函数调用运算符定义为成员函数,因此它始终具有成员函数的隐式this参数。除此之外,可以重载任何数量的附加参数,包括零。

这是语法示例:

class foo {
public:
    // Overloaded call operator
    int operator()(const std::string& y) {
        // ...
    }
};

用法:

foo f;
int a = f("hello");

在整个 C ++ 标准库中,始终复制功能对象。因此,您自己的函数对象应该廉价复制。如果功能对象绝对需要使用复制成本高昂的数据,则最好将数据存储在其他位置并让功能对象引用它。

比较运算符

根据经验法则,二进制中缀比较运算符应实现为非成员函数1 。一元前缀否定!应该(按照相同的规则)实现为成员函数。 (但通常不建议重载它。)

标准库的算法(例如std::sort() )和类型(例如std::map )将始终只期望operator<存在。但是, 您的类型用户也希望所有其他运算符也都存在 ,因此,如果您定义operator< ,请确保遵循运算符重载的第三条基本规则,并且还要定义所有其他布尔比较运算符。实施它们的规范方法是:

inline bool operator==(const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator!=(const X& lhs, const X& rhs){return !operator==(lhs,rhs);}
inline bool operator< (const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator> (const X& lhs, const X& rhs){return  operator< (rhs,lhs);}
inline bool operator<=(const X& lhs, const X& rhs){return !operator> (lhs,rhs);}
inline bool operator>=(const X& lhs, const X& rhs){return !operator< (lhs,rhs);}

这里要注意的重要一点是,这些运算符中只有两个实际执行任何操作,其他运算符只是将其参数转发给这两个运算符中的任何一个以进行实际工作。

重载其余二进制布尔运算符( ||&& )的语法遵循比较运算符的规则。然而,这是不太可能,你会发现这些2合理的利用情况。

1 与所有经验法则一样,有时也可能有理由打破这一原则。如果是这样,请不要忘记二进制比较运算符的左操作数(对于成员函数而言,它是*this )也必须是const 。因此,实现为成员函数的比较运算符必须具有以下签名:

bool operator<(const X& rhs) const { /* do actual comparison with *this */ }

(请注意最后的const 。)

2 应该注意的是||的内置版本和&&使用快捷方式语义。虽然用户定义的语法(因为它们是方法调用的语法糖),却不使用快捷方式语义。用户将期望这些运算符具有快捷方式语义,并且它们的代码可能依赖于此,因此,强烈建议不要定义它们。

算术运算符

一元算术运算符

一元递增和递减运算符具有前缀和后缀形式。为了彼此区分,postfix 变体采用了另一个哑 int 参数。如果您使增量或减量过载,请确保始终同时实现前缀和后缀版本。这是递增的规范实现,递减遵循相同的规则:

class X {
  X& operator++()
  {
    // do actual increment
    return *this;
  }
  X operator++(int)
  {
    X tmp(*this);
    operator++();
    return tmp;
  }
};

请注意,后缀变体是根据前缀实现的。另请注意,后缀会额外复制。 2

一元负号和加号的重载不是很常见,最好避免。如果需要,它们可能应该作为成员函数重载。

2 还请注意,postfix 变体比前缀变体执行更多的工作,因此使用效率较低。这是一个很好的理由,通常优先选择前缀增量而不是后缀增量。尽管编译器通常可以优化内置类型的后缀增量的其他工作,但对于用户定义的类型,它们可能无法做到相同(这可能看起来像列表迭代器一样无辜)。一旦您习惯使用i++ ,当i不是内置类型(加上更改类型时必须更改代码)时,就很难记住要执行++i了。养成始终使用前缀增量的习惯,除非明确需要后缀。

二元算术运算符

对于二进制算术运算符,请不要忘记遵守第三个基本规则运算符重载:如果提供+ ,还提供+= ,如果提供- ,请不要省略-= ,等等。据说安德鲁 · 科尼希(Andrew Koenig)是第一个观察到化合物赋值运算符可以用作其非化合物对应物的基础。也就是说,运算符+是根据+=实施的, -是根据-=等实施的。

根据我们的经验法则, +及其同伴应为非成员,而其复合赋值对应对象( +=等)(更改其左自变量)应为成员。这是+=+的示例代码;其他二进制算术运算符应以相同的方式实现:

class X {
  X& operator+=(const X& rhs)
  {
    // actual addition of rhs to *this
    return *this;
  }
};
inline X operator+(X lhs, const X& rhs)
{
  lhs += rhs;
  return lhs;
}

operator+=返回每个引用的结果,而operator+返回其结果的副本。当然,返回引用通常比返回副本更有效,但是在operator+的情况下,无法进行复制。写a + b ,您期望结果是一个新值,这就是为什么operator+必须返回一个新值的原因。 3还请注意, operator+ 通过复制而不是通过 const 引用获取其左操作数。其原因与给operator=每个副本取其参数的原因相同。

位操作运算符~ & | ^ << >>应该以与算术运算符相同的方式实现。但是,(除了重载<<>>用于输出和输入),很少有合理的用例来重载它们。

3 同样,从中可以得出的教训是, a += b通常比a + b更有效,如果可能的话,应优先考虑。

数组下标

数组下标运算符是二进制运算符,必须将其实现为类成员。它用于类容器类型,允许通过键访问其数据元素。提供这些的规范形式是这样的:

class X {
        value_type& operator[](index_type idx);
  const value_type& operator[](index_type idx) const;
  // ...
};

除非您不希望您的类的用户能够更改operator[]返回的数据元素(在这种情况下,您可以忽略 non-const 变体),否则应始终提供两种运算符变体。

如果已知 value_type 引用内置类型,则运算符的 const 变体最好返回一个副本,而不是 const 引用:

class X {
  value_type& operator[](index_type idx);
  value_type  operator[](index_type idx) const;
  // ...
};

指针类型的运算符

为了定义自己的迭代器或智能指针,您必须重载一元前缀取消引用运算符*和二进制中缀指针成员访问运算符->

class my_ptr {
        value_type& operator*();
  const value_type& operator*() const;
        value_type* operator->();
  const value_type* operator->() const;
};

注意,这些也几乎总是需要 const 版本和非 const 版本。对于->运算符,如果value_typeclass (或structunion )类型,则将递归调用另一个operator->() ,直到operator->()返回非类类型的值。

一元地址运算符绝对不能重载。

对于operator->*()请参阅此问题 。它很少使用,因此也很少过载。实际上,即使迭代器也不会使它过载。


继续向转换运算符

C ++ 中运算符重载的三个基本规则

对于 C ++ 中的运算符重载, 应遵循三个基本规则 。与所有此类规则一样,确实存在例外。有时人们偏离了他们,结果不是不好的代码,但是这种积极的偏差很少而且相差甚远。至少在我看到的 100 个此类偏差中,有 99 个是不合理的。但是,也有可能是 1000 分之 999。因此,您最好遵循以下规则。

  1. 只要运算符的含义不明确且无可争辩,就不应重载。 而是提供一个功能齐全的名称。
    基本上,重载运算符的首要原则就是说: 不要这样做 。这似乎很奇怪,因为关于运算符重载有很多已知的知识,因此许多文章,书籍章节和其他文本都涉及到这一切。但是,尽管有这些看似显而易见的证据, 但在极少数情况下,才适合使用运算符重载 。原因是实际上很难理解运算符应用程序背后的语义,除非在应用程序域中对运算符的使用是众所周知且无可争议的。与普遍的看法相反,几乎没有这种情况。

  2. 始终遵守操作员的众所周知的语义。
    C ++ 对重载运算符的语义没有任何限制。您的编译器将很乐意接受实现二进制+运算符的代码,以从其右操作数中减去。然而,这样的操作者的用户决不会怀疑表达a + b减去ab 。当然,这假定在应用程序域中运算符的语义是无可争议的。

  3. 始终提供一组相关操作中的所有内容。
    运算符彼此相关,并且与其他操作相关。如果您的类型支持a + b ,那么用户也希望能够调用a += b 。如果它支持前缀增量++a ,他们将期望a++能正常工作。如果他们能够检查a < b ,那么他们肯定会期望也能够检查a > b 。如果他们可以复制构造您的类型,他们希望分配也能正常工作。


继续进行成员与非成员之间的决定

C ++ 中运算符重载的通用语法

您不能更改 C ++ 中内置类型的运算符的含义,只能对用户定义的类型1重载运算符。即,至少一个操作数必须是用户定义的类型。与其他重载函数一样,运算符只能对一组特定参数重载一次。

并非所有运算符都可以在 C ++ 中重载。不能重载的运算符包括: . :: sizeof typeid .*和 C ++ 中唯一的三元运算符?:

在 C ++ 中可以重载的运算符包括:

  • 算术运算符: + - * / %+= -= *= /= %= (所有二进制中缀); + - (一元前缀); ++ -- (一元前缀和后缀)
  • 位操作: & | ^ << >>&= |= ^= <<= >>= (所有二进制中缀); ~ (一元前缀)
  • 布尔代数: == != < > <= >= || && (所有二进制中缀); ! (一元前缀)
  • 内存管理: new new[] delete delete[]
  • 隐式转换运算符
  • 其他: = [] -> ->* , (所有二进制中缀); * & (所有一元前缀) () (函数调用,n 元中缀)

但是,您可以重载所有这些事实并不意味着您应该这样做。请参阅运算符重载的基本规则。

在 C ++ 中,运算符以具有特殊名称函数的形式被重载。与其他函数一样,重载运算符通常可以实现为其左操作数类型成员函数,也可以实现为非成员函数 。您是自由选择还是必须使用其中之一,取决于多个条件。 2应用于对象 x 的一元运算符@ 3被作为operator@(x)x.operator@()调用。应用于对象xy的二进制中缀运算符@称为operator@(x,y)x.operator@(y)4

实施为非成员函数的运算符有时是其操作数类型的朋友。

1 “用户定义” 一词可能会引起误解。 C ++ 区分内置类型和用户定义类型。前者属于 int,char 和 double。后者属于所有 struct,class,union 和 enum 类型,包括那些来自标准库的类型,即使它们不是由用户定义的。

2 此常见问题的后续部分将对此进行介绍。

3 @在 C ++ 中不是有效的运算符,这就是为什么我将其用作占位符。

4 C ++ 中唯一的三元运算符不能重载,并且唯一的 n 元运算符必须始终实现为成员函数。


继续遵循 C ++ 中运算符重载的三个基本规则

会员与非会员之间的决定

二进制运算符= (赋值), [] (数组订阅), -> (成员访问)以及 n-ary () (函数调用)运算符必须始终作为成员函数实现,因为语言要求他们这样做。

其他运算符既可以实现为成员,也可以实现为非成员。但是,其中一些通常必须实现为非成员函数,因为您无法修改其左操作数。其中最突出的是输入和输出运算符<<>> ,其左操作数是标准库中的流类,您不能更改它们。

对于必须选择将其实现为成员函数或非成员函数的所有运算符,请使用以下经验法则来确定:

  1. 如果它是一元运算符 ,请将其实现为成员函数。
  2. 如果二进制运算符将两个操作数均等地对待(使它们不变),则将该运算符实现为非成员函数。
  3. 如果二进制运算符不能 平等地对待其两个操作数(通常会更改其左操作数),则在必须访问该操作数的私有部分的情况下,使其成为其左操作数类型的成员函数可能会很有用。

当然,与所有经验法则一样,也有例外。如果你有类型

enum Month {Jan, Feb, ..., Nov, Dec}

并且要为其重载递增和递减运算符,则不能作为成员函数来执行此操作,因为在 C ++ 中,枚举类型不能具有成员函数。因此,您必须将其作为自由函数进行重载。当在类定义中作为成员函数内联完成时,嵌套在类模板中的类模板的operator<()易于编写和读取。但是这些确实是罕见的例外。

(但是, 如果您例外,请不要忘记操作数的const -ness 问题,对于成员函数,该操作数将成this参数的隐式参数。如果运算符作为非成员函数,则将其最左端的参数用作const引用,与成员函数相同的运算符需要在末尾具有const才能使*this成为const引用。)


继续让Common 运算符超载

转换运算符(也称为用户定义的转换)

在 C ++ 中,您可以创建转换运算符,即允许编译器在您的类型和其他定义的类型之间进行转换的运算符。转换运算符有两种,隐式和显式。

隐式转换运算符(C ++ 98 / C ++ 03 和 C ++ 11)

隐式转换运算符允许编译器将用户定义类型的值隐式转换(例如intlong之间的转换)。

以下是带有隐式转换运算符的简单类:

class my_string {
public:
  operator const char*() const {return data_;} // This is the conversion operator
private:
  const char* data_;
};

隐式转换运算符(如一参数构造函数)是用户定义的转换。尝试将调用与重载函数匹配时,编译器将授予一个用户定义的转换。

void f(const char*);

my_string str;
f(str); // same as f( str.operator const char*() )

乍一看,这似乎很有帮助,但是问题在于隐式转换甚至会在预期不到的时候启动。在以下代码中,将调用void f(const char*)因为my_string()不是左值 ,因此第一个不匹配:

void f(my_string&);
void f(const char*);

f(my_string());

初学者容易犯错,甚至经验丰富的 C ++ 程序员有时也会感到惊讶,因为编译器会选择他们不怀疑的重载。这些问题可以通过显式转换运算符缓解。

显式转换运算符(C ++ 11)

与隐式转换运算符不同,显式转换运算符在您不希望它们出现时永远不会起作用。以下是带有显式转换运算符的简单类:

class my_string {
public:
  explicit operator const char*() const {return data_;}
private:
  const char* data_;
};

注意explicit 。现在,当您尝试从隐式转换运算符执行意外的代码时,会出现编译器错误:

prog.cpp: In function ‘int main()’:
prog.cpp:15:18: error: no matching function for call to ‘f(my_string)’
prog.cpp:15:18: note: candidates are:
prog.cpp:11:10: note: void f(my_string&)
prog.cpp:11:10: note:   no known conversion for argument 1 from ‘my_string’ to ‘my_string&’
prog.cpp:12:10: note: void f(const char*)
prog.cpp:12:10: note:   no known conversion for argument 1 from ‘my_string’ to ‘const char*’

要调用显式static_cast运算符,必须使用static_cast ,C 样式static_cast或构造函数样式static_cast (即T(value) )。

但是,有一个例外:允许编译器隐式转换为bool 。另外,编译器在转换为bool之后,不允许进行其他隐式转换(编译器一次只能进行 2 次隐式转换,但最多只能进行 1 个用户定义的转换)。

因为编译器不会bool转换过去,所以显式转换运算符现在无需使用Safe Bool 习惯用法 。例如,C ++ 11 之前的智能指针使用 Safe Bool 习惯用法来防止转换为整数类型。在 C ++ 11 中,智能指针使用显式运算符代替,因为在将类型显式转换为 bool 之后,不允许编译器隐式转换为整数。

继续重载newdelete

重载newdelete

注意:这仅处理重载newdelete语法 ,而不处理此类重载运算符的实现 。我认为重载newdelete的语义应该得到他们自己的 FAQ ,在运算符重载的主题内,我永远无法做到这一点。

基本

在 C ++ 中,当您编写一个类似于new T(arg)新表达式时,对该表达式求值时会发生两件事:首先调用operator new以获取原始内存,然后调用T的适当构造函数以将该原始内存转换为 a 有效对象。同样,删除对象时,首先将调用其析构函数,然后将内存返回给operator delete
C ++ 允许您调整以下两个操作:内存管理和分配的内存中对象的构造 / 销毁。后者是通过为类编写构造函数和析构函数来完成的。通过编写自己的operator newoperator delete可以对内存管理进行微调。

操作符重载的第一条基本规则( 不要这样做)尤其适用于重载newdelete 。使这些运算符超负荷的几乎唯一原因是性能问题内存限制 ,并且在许多情况下,其他操作(如更改所使用的算法 )将提供比尝试调整内存管理更高的成本 / 收益比

C ++ 标准库附带了一组预定义的newdelete运算符。最重要的是:

void* operator new(std::size_t) throw(std::bad_alloc); 
void  operator delete(void*) throw(); 
void* operator new[](std::size_t) throw(std::bad_alloc); 
void  operator delete[](void*) throw();

前两个为对象分配 / 取消分配内存,后两个为对象数组分配 / 释放内存。如果您提供自己的版本,则它们不会过载,但会替换标准库中的版本。
如果您重载了operator new ,那么即使您从未打算调用它,也应该始终重载匹配的operator delete 。原因是,如果构造函数在对新表达式求值时抛出异常,则运行时系统会将内存返回给operator delete将其与匹配的operator new operator delete相匹配,后者被调用以分配内存以创建对象。没有提供匹配的operator delete ,则调用默认的operator delete ,这几乎总是错误的。
如果重载newdelete ,则也应考虑重载数组变量。

new刊登位置

C ++ 允许 new 和 delete 运算符采用其他参数。
所谓的 “新放置” 功能使您可以在某个地址处创建对象,该对象将传递给:

class X { /* ... */ };
char buffer[ sizeof(X) ];
void f()
{ 
  X* p = new(buffer) X(/*...*/);
  // ... 
  p->~X(); // call destructor 
}

标准库为此提供了 new 和 delete 运算符的适当重载:

void* operator new(std::size_t,void* p) throw(std::bad_alloc); 
void  operator delete(void* p,void*) throw(); 
void* operator new[](std::size_t,void* p) throw(std::bad_alloc); 
void  operator delete[](void* p,void*) throw();

注意,在上面给出的放置新位置的示例代码中,除非 X 的构造函数抛出异常,否则永远不会调用operator delete

您还可以使用其他参数重载newdelete 。与放置 new 的附加参数一样,这些参数也在关键字new后面的括号内列出。仅出于历史原因,此类变体通常也称为 “新放置”,即使它们的参数不是要在特定地址放置对象。

特定于类的 new 和 delete

通常,您会希望微调内存管理,因为测量表明,经常创建和销毁特定类或一组相关类的实例,并且已针对运行时系统的默认内存管理进行了调整。一般性能,在这种情况下交易效率低下。为了改善这一点,您可以为特定的类重载 new 和 delete:

class my_class { 
  public: 
    // ... 
    void* operator new();
    void  operator delete(void*,std::size_t);
    void* operator new[](size_t);
    void  operator delete[](void*,std::size_t);
    // ... 
};

因此,new 和 delete 重载时的行为类似于静态成员函数。对于my_class对象, std::size_t参数始终为sizeof(my_class) 。但是,也需要为派生类的动态分配的对象调用这些运算符,在这种情况下,它可能会更大。

全局新建和删除

要使全局 new 和 delete 重载,只需用我们自己的标准库替换预定义的运算符。但是,很少需要这样做。

为什么operator<<函数无法将对象流式传输到std::cout或文件中,成为成员函数?

假设您有:

struct Foo
{
   int a;
   double b;

   std::ostream& operator<<(std::ostream& out) const
   {
      return out << a << " " << b;
   }
};

鉴于此,您不能使用:

Foo f = {10, 20.0};
std::cout << f;

由于operator<<作为Foo的成员函数被重载,因此该 operator 的 LHS 必须是Foo对象。这意味着,您将需要使用:

Foo f = {10, 20.0};
f << std::cout

这是非常不直观的。

如果您将其定义为非成员函数,

struct Foo
{
   int a;
   double b;
};

std::ostream& operator<<(std::ostream& out, Foo const& f)
{
   return out << f.a << " " << f.b;
}

您将可以使用:

Foo f = {10, 20.0};
std::cout << f;

这是非常直观的。