操作符重载的基本规则和习惯用法是什么?

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

(注意:这是Stack Overflow的C++FAQ的一个条目。如果你想批评以这种形式提供FAQ的想法,那么Meta上的帖子引发了这一切就是这样做的地方。这个问题的答案在C++聊天室中被监控,FAQ的想法首先开始在那里,所以你的答案很可能会被提出这个想法的人阅读。)

981819 次浏览

C++中操作符重载的三个基本规则

C++中运算符重载的例子有你应该遵循的三个基本规则。和所有这些规则一样,确实也有例外。有时人们偏离了它们,结果是不错的代码,但这样的正偏差很少。至少,我看到的100个偏差中有99个是不合理的。然而,1000个偏差中有999个可能是不合理的。所以你最好坚持以下规则。

  1. 每当一个运算符的含义不是很明显的清晰和无可争议的时候,就不应该重载它。相反,提供一个具有精心选择的名称的函数。
    基本上,重载运算符的第一条也是最重要的规则,在其核心是:别那么做。这可能看起来很奇怪,因为关于运算符重载有很多东西需要了解,所以很多文章、书籍章节和其他文本都处理了所有这些。但是,尽管有这些看似明显的证据,只有极少数情况下操作符重载是合适的。原因是,实际上很难理解应用运算符背后的语义学,除非在应用程序域中使用运算符是众所周知的和无可争议的。与流行的看法相反,情况很少如此。

  2. 始终坚持操作员众所周知的语义学。
    C++对重载运算符的语义学没有限制。你的编译器会很高兴地接受实现二进制+运算符从其右操作数中减去的代码。然而,这种运算符的用户永远不会怀疑表达式a + bb中减去a。当然,这假设应用程序域中运算符的语义学是无可争议的。

  3. 总是提供一组相关操作中的所有操作。
    运算符相互关联和其他操作。如果您的类型支持a + b,用户也希望能够调用a += b。如果它支持前缀增量++a,他们希望a++也能工作。如果他们能检查a < b,他们肯定希望也能检查a > b。如果他们能复制构造你的类型,他们希望赋值也能工作。


继续会员与非会员之间的决定

C++中操作符重载的一般语法

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

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

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

  • 算术运算符:+-*/%+=-=*=/=%=(全二进制中缀);+-(一元前缀);-2-3(一元前缀和后缀)
  • 位操作:&|^<<>>&=|=^=<<=>>=(全二进制中缀);|0(一元前缀)
  • 布尔代数:==!=<><=>=||&&(全二进制中缀);!(一元前缀)
  • 内存管理:newnew[]deletedelete[]
  • 隐式转换运算符
  • 混合:=[]->->*,(全二进制中缀);*&(全一元前缀)()(函数调用,n元中缀)

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

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

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

1术语“用户定义”可能有点误导。C++区分了内置类型和用户定义类型。前者属于例如int、char和double;后者属于所有struct、class、Union和enum类型,包括标准库中的类型,即使它们本身不是由用户定义的。

2这在后面的部分

3#0在C++中不是有效的运算符,这就是为什么我使用它作为占位符。

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


继续C++中操作符重载的三个基本规则

重载的常用运算符

重载运算符的大部分工作是样板代码。这不足为奇,因为运算符只是语法糖,它们的实际工作可以由(并且通常被转发给)普通函数完成。但重要的是,你要正确处理这个样板代码。如果你失败了,要么你的运算符代码无法编译,要么你的用户代码无法编译,要么你的用户代码会有令人惊讶的行为。

赋值运算符

关于赋值有很多要说的。然而,大部分已经在GMan著名的复制和交换FAQ中说过了,所以我在这里跳过大部分,只列出完美的赋值操作符供参考:

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

BitShift运算符(用于流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 operatorint 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与所有经验法则一样,有时也可能有理由打破这一规则。如果是这样,不要忘记二元比较运算符的左侧操作数(对于成员函数来说将是#0)也需要是#1。因此,作为成员函数实现的比较运算符必须具有以下签名:

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

(注意最后的const

2需要注意的是,#0和#1的内置版本使用快捷语义学。而用户定义的(因为它们是方法调用的语法糖)不使用快捷语义学。用户会期望这些运算符有快捷语义学,他们的代码可能依赖于它,因此强烈建议永远不要定义它们。

算术运算符

一元算术运算符

一元增量和减量运算符有前缀和后缀两种风格。为了区分彼此,后缀变体采用额外的虚拟int参数。如果您重载增量或减量,请确保始终同时实现前缀和后缀版本。这是增量的规范实现,减量遵循相同的规则:

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

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

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

2另请注意,后缀变体的工作更多,因此使用效率低于前缀变体。这是通常更喜欢前缀增量而不是后缀增量的一个很好的理由。虽然编译器通常可以优化内置类型的后缀增量的额外工作,但他们可能无法对用户定义的类型(可能看起来像列表迭代器一样天真)做同样的事情。一旦你习惯了#0,当#2不是内置类型时,就很难记住要做#1(再加上你必须在更改类型时更改代码),所以最好养成总是使用前缀增量的习惯,除非明确需要后缀。

二元算术运算符

对于二进制算术运算符,不要忘记遵守第三个基本规则运算符重载:如果你提供+,也提供+=,如果你提供-,不要省略-=,等等。据说Andrew Koenig是第一个观察到复合赋值操作符可以用作非复合对应项的基础的人。也就是说,运算符+是按照+=实现的,-是按照-=实现的等等。

根据我们的经验法则,+及其同伴应该是非成员,而它们的复合赋值对应物(+=等)改变了它们的左参数,应该是成员。这是+=+的示例代码;其他二元算术运算符应该以相同的方式实现:

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

operator+=返回每个引用的结果,而operator+返回其结果的副本。当然,返回引用通常比返回副本更有效,但是在operator+的情况下,没有办法绕过复制。当你写a + b时,你希望结果是一个新值,这就是为什么operator+必须返回一个新值。3另请注意,operator+采用其左操作数复制而不是const引用。这样做的原因与operator=为每个副本取其参数的原因相同。

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

3同样,从中吸取的教训是,一般来说,#0比#1更有效,如果可能的话,应该首选。

数组订阅

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

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

除非您不希望类的用户能够更改operator[]返回的数据元素(在这种情况下您可以省略非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->*(),请参阅这个问题。它很少使用,因此很少重载。事实上,即使是迭代器也不会重载它。


继续转换操作员

会员与非会员之间的决定

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

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

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

  1. 如果它是一元操作符,则将其实现为<强>成员函数。
  2. 如果二元运算符处理两个操作数相等(它保持不变),则将此运算符实现为非成员函数。
  3. 如果二元运算符<强>不处理它的两个操作数<强>同样(通常它会改变它的左操作数),如果它必须访问操作数的私有部分,则将其设为其左操作数类型的<强>成员函数可能会很有用。

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

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

你想重载它的增量和减量运算符,你不能作为成员函数这样做,因为在C++中,枚举类型不能有成员函数。所以你必须将其重载为自由函数。嵌套在类模板中的类模板的operator<()在类定义中作为成员函数内联时更容易编写和读取。但这些确实是罕见的例外。

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


继续重载的常用运算符

重载newdelete操作符

注意:这只涉及重载newdelete语法,而不涉及此类重载运算符的实现。我认为重载newdelete的语义学值得他们自己的FAQ,在运算符重载的主题中,我永远无法做到公正。

基础

在C++中,当你像new T(arg)一样编写新表达时,当计算此表达式时会发生两件事:首先调用#1以获取原始内存,然后调用T的相应构造函数以将此原始内存转换为有效对象。同样,当你删除一个对象时,首先调用它的析构函数,然后将内存返回到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 new匹配的operator delete,以分配内存以创建对象。如果您不提供匹配的operator delete,则调用默认值,这几乎总是错误的。
如果重载newdelete,您也应该考虑重载数组变体。

位置new

C++允许new和删除运算符接受额外的参数。
所谓的放置new允许您在传递给以下地址的某个地址创建对象:

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

标准库附带了新的和删除运算符的适当重载:

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。与放置new的附加参数一样,这些参数也列在关键字new之后的括号中。仅仅出于历史原因,此类变体通常也称为放置new,即使它们的参数不是为了将对象放置在特定地址。

特定于类的new和删除

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

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

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

全球新建和删除

要重载全局new并删除,只需将标准库的预定义运算符替换为我们自己的运算符即可。

转换操作符(也称为用户定义的转换)

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

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

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

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

class my_string {public:operator const char*() const {return data_;} // This is the conversion operatorprivate: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、C风格转换或构造函数风格转换(即T(value))。

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

因为编译器不会将“过去”转换为bool,显式转换运算符现在不再需要安全布尔习语。例如,C++11之前的智能指针使用Safe Bool习语来防止转换为整型类型。在C++11中,智能指针使用显式运算符,因为编译器在显式将类型转换为bool后不允许隐式转换为整型类型。

继续重载#0和#1

为什么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的成员函数重载,因此运算符的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;

这是非常直观的。

简明扼要地说,我将参考一些要点,这些要点是我过去一周在学习Python和C++,哎呀和其他东西时遇到的,如下所示:

  1. 运算符的Arity不能再修改了!

  2. 重载的操作符只能有一个默认参数,函数调用操作符的其余部分不能。

  3. 只有内置运算符可以重载,其他不能!

有关详细信息,您可以参考以下链接,该链接将您重定向到GeekforGeeks提供的留档。

https://www.geeksforgeeks.org/g-fact-39/