关于C++:操作符重载的基本规则和习惯用法是什么?

What are the basic rules and idioms for operator overloading?

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

  • C++中运算符重载的一般语法
  • C++中运算符重载的三个基本规则
  • 会员与非会员之间的决定
  • 重载的常用运算符
    • 分配运算符
    • 输入和输出运算符
    • 函数调用运算符
    • 比较运算符
    • 算术运算符
    • 数组订阅
    • 类指针类型的运算符
    • 转换运算符
  • 重载新建和删除

(注:这意味着是堆栈溢出的C++FAQ的一个条目。如果你想批评在这个表单中提供一个常见问题解答的想法,那么在meta上发布的开始所有这一切的地方就是这样做的地方。这个问题的答案是在C++聊天室中进行监控的,FAQ的想法一开始就出现了,所以你的答案很可能会被那些想出这个想法的人读到。


重载的常用运算符

超载操作中的大部分工作是锅炉板代码。这也就不足为奇了,因为操作符只是语法上的糖分,所以它们的实际工作可以通过(并且经常被转发到)普通函数来完成。但重要的是你要把这个锅炉的代码弄对。如果你失败了,要么你的操作员代码无法编译,要么你的用户代码无法编译,要么你的用户代码的行为会令人惊讶。好的。分配运算符

关于任务有很多话要说。不过,其中大部分已经在GMAN著名的复制和交换常见问题解答中提到过,因此我将跳过这里的大部分内容,只列出完美的分配运算符供参考:好的。

1
2
3
4
5
X& X::operator=(X rhs)
{
  swap(rhs);
  return *this;
}

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

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

流运算符是最常见的重载运算符之一,是二进制中缀运算符,其语法对它们应该是成员还是非成员没有任何限制。因为它们更改了左参数(它们更改了流的状态),所以根据经验法则,它们应该作为左操作数类型的成员实现。但是,它们的左侧操作数是来自标准库的流,虽然标准库定义的大多数流输出和输入运算符确实被定义为流类的成员,但是当您为自己的类型实现输出和输入操作时,不能更改标准库的流类型。这就是为什么您需要将这些运算符作为非成员函数实现为您自己的类型的原因。这两者的规范形式如下:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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参数。除此之外,它可以重载以接受任何数量的附加参数,包括零。好的。

以下是语法示例:好的。

1
2
3
4
5
6
7
class foo {
public:
    // Overloaded call operator
    int operator()(const std::string& y) {
        // ...
    }
};

用途:好的。

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

在C++标准库中,函数对象总是被复制。因此,您自己的函数对象的复制成本应该很低。如果一个函数对象绝对需要使用复制成本很高的数据,最好将该数据存储在其他地方,并让函数对象引用它。好的。比较运算符

根据经验法则,二进制中缀比较运算符应实现为非成员函数1。一元前缀否定!应(根据相同规则)作为成员函数实现。(但通常超载不是一个好主意。)好的。

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

1
2
3
4
5
6
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。因此,作为成员函数实现的比较运算符必须具有此签名:好的。

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

(note the constat the end.)好的。

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

一元递增和递减运算符具有前缀和后缀风格。为了区分两者,后缀变量采用了一个额外的伪int参数。如果重载增量或减量,请确保始终实现前缀和后缀版本。这里是增量、减量的规范实现,遵循相同的规则:好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class X {
  X& operator++()
  {
    // do actual increment
    return *this;
  }
  X operator++(int)
  {
    X tmp(*this);
    operator++();
    return tmp;
  }
};

注意,postfix变量是根据前缀实现的。还要注意,postfix会额外复制一份。2好的。

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

2还请注意,postfix变量的作用更大,因此比前缀变量的使用效率更低。这是一个很好的理由,通常优先选择前缀增量而不是后缀增量。虽然编译器通常可以为内置类型优化额外的后缀增量工作,但它们可能无法为用户定义的类型执行相同的工作(这可能是像列表迭代器那样的无辜操作)。一旦习惯了使用i++,当i不是内置类型(另外,在更改类型时必须更改代码)时,很难记住使用++i,因此最好养成始终使用前缀增量的习惯,除非明确需要后缀。好的。二进制算术运算符

对于二元算术运算符,不要忘记遵守第三个基本规则运算符重载:如果提供+,也提供+=,如果提供-,不要省略-=等。据说Andrew Koenig是第一个观察到复合赋值运算符可以用作EIR非复合副本。也就是说,运营商++=执行,--=等执行。好的。

根据我们的经验法则,+及其同伴应为非成员,而其复合任务对应方(+=等)改变其左论点应为成员。下面是+=+的示例代码,其他二进制算术运算符的实现方式应相同:好的。

1
2
3
4
5
6
7
8
9
10
11
12
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+通过复制而不是常量引用来获取其左操作数。这样做的原因与operator=在每份文件中提出论点的原因相同。好的。

位操作操作符~&|^<<>>的实现方式与算术运算符相同。但是,(除了输出和输入的<<>>过载之外)很少有合理的使用案例来过载这些。好的。

3同样,要从中吸取的教训是,a += b通常比a + b更有效,如果可能的话,应该首选。好的。数组订阅

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

1
2
3
4
5
class X {
        value_type& operator[](index_type idx);
  const value_type& operator[](index_type idx) const;
  // ...
};

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

如果已知值_type引用内置类型,则运算符的const变量应返回一个副本,而不是const引用。好的。类指针类型的运算符

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

1
2
3
4
5
6
class my_ptr {
        value_type& operator*();
  const value_type& operator*() const;
        value_type* operator->();
  const value_type* operator->() const;
};

请注意,它们也几乎总是同时需要一个常量和一个非常量版本。对于->运算符,如果value_typeclass类型(或structunion类型),则递归调用另一个operator->(),直到operator->()返回非类类型的值。好的。

绝不应重载运算符的一元地址。好的。

关于operator->*()见本问题。它很少使用,因此很少超载。实际上,即使是迭代器也不会重载它。好的。

继续转换运算符好的。好啊。


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

在C++中运算符重载时,有三条基本规则要遵循。像所有这些规则一样,确实有例外。有时人们已经偏离了他们,结果并不是不好的代码,但这种积极的偏离是少之又远之又少。至少,我所看到的100种偏差中有99种是不合理的。不过,这也可能只是千分之999。所以你最好遵守以下规则。

  • 当一个运算符的含义不明显和毫无疑问时,就不应该重载它。相反,提供一个具有精心选择的名称的函数。基本上,重载操作符的第一条也是最重要的规则,在它的核心,是说:不要这样做。这可能看起来很奇怪,因为有很多关于运算符重载的知识,所以很多文章、书籍章节和其他文本都处理这些问题。但是,尽管这似乎是显而易见的证据,但只有少数情况下,运算符重载才是合适的。原因是,除非在应用程序域中使用操作符是众所周知的和无可争议的,否则实际上很难理解操作符应用程序背后的语义。与人们的普遍看法相反,这种情况很难发生。

  • 始终遵循操作员的众所周知的语义。C++对重载运算符的语义没有限制。编译器很乐意接受实现二元+运算符以从其右操作数中减去的代码。然而,这种运算符的用户绝不会怀疑表达式a + bb中减去a。当然,这假设应用程序域中的操作符的语义是无争议的。

  • 始终提供一组相关操作中的所有操作。操作员之间以及与其他操作相关。如果您的类型支持a + b,那么用户也可以调用a += b。如果它支持前缀增量++a,那么它们也会期望a++工作。如果他们能检查a < b是否正常,他们肯定也能检查a > b是否正常。如果它们可以复制构造您的类型,那么它们也希望分配工作正常进行。

  • 继续会员和非会员之间的决定。


    C++中运算符重载的一般语法

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

    并非所有的运算符都可以在C++中重载。在不可重载的运算符中有:.::sizeoftypeidEDCOX1〔4〕,而C++中唯一的三元运算符,EDCOX1〔5〕。

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

    • 算术运算符:+-*/%+=-=*=/=%=(全二进制中缀);+**/%+=-=*=/=%=(全二进制中缀);++-前缀;EDOCX11〔7〕一元前缀;EDOCX11〔18〕++++EDOCX1〔(一元前缀和后缀)
    • 位操作:&|^<<>>&=|=^=<<=>>=(全二进制插入);~单前缀。
    • 布尔代数:==!=<><=>=||&&全二进;!一元前缀。
    • 内存管理:newnew[]deletedelete[]
    • 隐式转换运算符
    • 其它:=[]->->*,(全二进制中缀);*&(全一元前缀)()(函数调用,N元中缀)

    然而,事实上你可以超载所有这些并不意味着你应该这样做。请参见运算符重载的基本规则。

    在C++中,操作符以具有特殊名称的函数的形式重载。与其他函数一样,重载运算符通常可以作为左操作数类型的成员函数或非成员函数实现。一元运算符@3应用于对象x,可以作为operator@(x)x.operator@()调用。应用于对象xy的二进制中缀运算符@称为operator@(x,y)x.operator@(y)4。

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

    1术语"用户定义"可能有点误导性。C++对内置类型和用户定义类型进行区分。前者属于例如int、char和double;后者属于所有struct、class、union和enum类型,包括来自标准库的类型,即使它们不是,如这样,由用户定义。

    2这在本常见问题解答的后面部分介绍。

    3 EDCOX1→52 }不是C++中的有效运算符,这就是为什么我使用它作为占位符。

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

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


    会员与非会员之间的决定

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

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

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

  • 如果它是一元运算符,则将其作为成员函数实现。
  • 如果一个二元运算符对两个操作数的处理相同(保持不变),则将此运算符实现为非成员函数。
  • 如果一个二元运算符不能同时处理两个操作数(通常它会更改其左操作数),如果它必须访问操作数的私有部分,则使其成为其左操作数类型的成员函数可能很有用。
  • 当然,就像所有的经验法则一样,也有例外。如果你有一个类型

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

    而且,您希望为它增加增量和递减运算符,不能将其作为成员函数来执行,因为在C++中,枚举类型不能具有成员函数。所以你必须把它作为一个自由函数重载。对于嵌套在类模板中的类模板,当在类定义中作为内嵌的成员函数执行时,operator<()更容易编写和读取。但这些确实是罕见的例外。

    (但是,如果您例外,请不要忘记操作数的const性问题,对于成员函数,它将成为隐式this参数。如果作为非成员函数的运算符将其最左边的参数作为const引用,则作为成员函数的同一个运算符的末尾需要有一个const以使*this成为const引用。)

    继续普通操作人员超载。


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

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

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

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

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

    1
    2
    3
    4
    5
    6
    class my_string {
    public:
      operator const char*() const {return data_;} // This is the conversion operator
    private:
      const char* data_;
    };

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

    1
    2
    3
    4
    void f(const char*);

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

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

    1
    2
    3
    4
    void f(my_string&);
    void f(const char*);

    f(my_string());

    初学者很容易出错,甚至有经验的C++程序员有时会感到惊讶,因为编译器选择了他们没有怀疑的过载。这些问题可以通过显式转换运算符来减轻。

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

    与隐式转换操作符不同,显式转换操作符永远不会在您不期望它们出现的时候出现。下面是一个带有显式转换运算符的简单类:

    1
    2
    3
    4
    5
    6
    class my_string {
    public:
      explicit operator const char*() const {return data_;}
    private:
      const char* data_;
    };

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

    1
    2
    3
    4
    5
    6
    7
    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、C样式强制转换或构造函数样式强制转换(即T(value))。

    但是,有一个例外:编译器可以隐式转换为bool。此外,编译器在转换为bool之后,不允许再进行一次隐式转换(一次允许编译器进行两次隐式转换,但最多只能进行一次用户定义的转换)。

    因为编译器不会强制转换"past"bool,所以显式转换操作符现在不再需要安全的bool习惯用法。例如,C++ 11之前的智能指针使用安全BooL习语来防止转换成整数类型。在C++ 11中,智能指针使用显式运算符,因为编译器在显式将类型转换为BoOL后不允许隐式转换为整型。

    继续超载newdelete


    超载newdelete

    注意:这只处理重载newdelete的语法,而不处理此类重载运算符的实现。我认为重载newdelete的语义应该有它们自己的常见问题,在操作符重载的主题中,我永远也做不到公正的处理。

    基础

    在C++中,当您编写一个新的表达式,如EDCOX1,6,当这个表达式被评估时发生两件事:首先调用EDCOX1(7),以获得原始内存,然后调用EDCOX1 OR 8的适当构造函数将原始内存转换为有效对象。同样,当您删除一个对象时,首先调用它的析构函数,然后将内存返回到operator delete。C++允许您调整这两种操作:内存管理和对象在分配内存中的构建/销毁。后者是通过为类编写构造函数和析构函数来完成的。微调内存管理是通过编写自己的operator newoperator delete来完成的。

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

    C++标准库附带一组预定义的EDCOX1、0和EDCOX1,1个操作符。最重要的是:

    1
    2
    3
    4
    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 new匹配的operator delete中,以便分配内存来创建对象。如果不提供匹配的operator delete,则调用缺省值,这几乎总是错误的。如果重载newdelete,也应该考虑重载数组变量。

    放置new

    C++允许新的和删除运算符接受额外的参数。所谓的Placement New允许您在某个地址创建一个对象,该对象将传递给:

    1
    2
    3
    4
    5
    6
    7
    8
    class X { /* ... */ };
    char buffer[ sizeof(X) ];
    void f()
    {
      X* p = new(buffer) X(/*...*/);
      // ...
      p->~X(); // call destructor
    }

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

    1
    2
    3
    4
    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();

    注意,在上面给出的用于放置new的示例代码中,除非x的构造函数抛出异常,否则从不调用operator delete

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

    类特定的新建和删除

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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或文件中?

    假设你有:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Foo
    {
       int a;
       double b;

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

    鉴于此,您不能使用:

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

    由于operator<<作为Foo的成员函数被重载,因此运算符的lhs必须是Foo对象。也就是说,您需要使用:

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

    这是非常不直观的。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Foo
    {
       int a;
       double b;
    };

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

    您将能够使用:

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

    这是非常直观的。