C++运算符重载-上篇 (Boolan)

C++运算符重载-上篇 (Boolan)

本章内容:
1. 运算符重载的概述
2. 重载算术运算符
3. 重载按位运算符和二元逻辑运算符
4. 重载插入运算符和提取运算符
5. 重载下标运算符
6. 重载函数调用运算符
7. 重载解除引用运算符
8. 编写转换运算符
9. 重载内存分配和释放运算符

1. 运算符重载的概述

  • C++中的运算符是一些类似于+、<、*和<<的符号。这些符号可以应用于内建类型,例如int和double,从而实现算术操作、逻辑操作和其他操作。还有->和*运算符可以对指针进行解除引用操作。C++中运算符包括[] (数组索引)、()函数调用、类型转换以及内存分配和释放例程。可通过运算符重载来改变语言运算符对自定义类的行为。

1.1 重载运算符原因

  • 运算符重载的基本指导原则是为了让自定义类的行为和内建类型一样。自定义类的行为越接近内建类型,就越便于这些类的客户使用。例如,如果要编写一个表示分数的类,最好定义+、-、*和/运算符应用于这个类的对象时的意义。
  • 重载运算符的第二个原因是为了获得对程序行为更大的控制权。例如,可对自定义类重载内存分配和内存释放例程,来精确控制每个对象的内存分配和内存回收。
  • 需要强调的是,运算符重载未必能给类开发者带来方便;主要用途是给类的客户带来方便。

1.2 运算符重载的限制

下面列出重载运算符时不能做的事情:

  • 不能添加新的运算符符号。只能重定义语言中已经存在的运算符的意义。
  • 有少数运算符不能重载,例如.(对象成员访问)、::(作用域解析运算符)、sizeof、?:(三元运算符)以及其他几个运算符。不能重载的运算符通常是不需要重载的,因此这些限制应该不会令人感到受限。
  • arity描述了运算符关联的参数或操作数的数量。只能修改函数调用、new和delete运算符的arity。其他运算符的arity不能修改。一元运算符,例如++,只能用于一个操作数。二元运算符,例如+,只能用于2个操作数。
  • 不能修改运算符的优先级和结合性。这些规则确定了运算符在语句中的求值顺序。同样,这条约束对于大多数程序来说不是问题,因为改变了值顺序并不会带来什么好处。
  • 不能对内建类型重定义运算符。运算符必须是类中的一个方法,或者全局重载运算符函数至少有一个参数必须是一个用户定义的类型(例如一个类)。这意味着不允许做一些荒唐的事情,例如将int的+重定义为减法(尽管自定义类可以这么做)。这条规则有一个例外,那就是内存分配和释放例程;可以替换程序中所有的内存分配使用的全局例程。

有一些运算符已经有两种不同的含义。例如,-运算符可以作为二元运算符,如x = y-z;,还可以作为一元运算符,如x = -y;。*运算符可以作乘法操作,也可以用于解除指针的引用。根据上下文的不同,<<可以是插入运算符,也可以是左移运算符。可以重载具有双意义的运算符的两个意义。

1.3 运算符重载的选择

  • 重载运算符时,需要编写名为operator X的函数或者方法,X是表示这个运算符的符号,可以在operator和X之间添加空白字符。例如,operator+,如下:

       friend const SpreadsheetCell Operator+(const SpreadsheetCell &lhs, const SpreadsheetCell &rhs);
    

下面几节描述了编写每个重载运算符函数或者方法时需要做出的选择。

(1) 方法还是全局函数
  • 要决定运算符应该实现为类的方法还是全局函数(通常是类的友元)。首先,需要理解这两个选择之间的区别。当运算符是类的方法时,运算符表达式的左侧必须是这个类的对象。当编写全局函数时,运算符的左侧可以是不同类型的对象。
  • 有三种不同类型的运算符:
  • (i) 必须为方法的运算符:C++语言要求一些运算符必须是类中的方法,因为这些运算符在类外部没有意义。例如,operator=和类绑定的非常紧密,不能出现在其它地方。
  • (ii) 必须为全局函数的运算符:如果允许运算符左侧的变量除了自己定义之外的任何类型,那么必须将这个运算符定义为全局函数。确切的说,这条规则应用于operator<<和operator>>,这两个运算符的左侧是iostream对象,而不是自定义类的对象。此外,可交换的运算符(例如二元的+和-)允许运算符左侧的变量不是自定义类的对象。
  • (iii) 既可以为方法又可以为全局函数的运算符:有关编写方法重载运算符更好还是编写全局函数重载运算符更好的问题存在一些争议。不过建议的规则如下:把所有运算符都定义为方法,除非根据以上的描述必须定义为全局函数。这条规则的一个主要优点是方法可以是virtual的,但是friend函数不能。因此,如果准备在继承树种编写重载的运算符,那么应该尽可能将这些运算符定义为方法。

将重载的运算符定义为方法时,如果这个运算符不能修改对象,应该将整个方法标记为const。这样,就可以对const对象调用这个方法。

(2) 选择参数类型
  • 参数类型的选择有一些限制,因为如前面描述,大多数运算符不能修改参数的数量。例如,operator/在作为全局函数的情况下必须总是接受两个参数;在作为类方法的情况下必须总接受一个参数。如果不符合这个规则,编译器会产生错误。从这个角度看,运算符函数和普通函数有区别,普通函数可以使用任意数量的参数重载。此外,尽管可以编写接受任何类型参数的运算符,但是可选范围通常受到了这个运算符所在的类的限制。例如,如果要为类T实现一个加法操作,就不能编写接受两个string的operator+。真正需要选择的地方在于判断是按值还是引用接受参数,以及是否需要把参数标记为const。
  • 按值传递还是按引用传递的决策如下:应该按引用接受每一个非基本类型的参数。如果能按引用传递,就永远不要用按值传递对象。
  • const的决策如下:除非要真正修改参数,否则每一个参数都设置为const。
(3) 选择返回类型
  • C++不是根据返回类型来解析重载。因此,在编写重载运算符时,可以指定任意返回类型。然而,可以做某件事情并不意味着应该做这件事情。这种灵活性可能会导致令人迷惑的代码,例如比较运算符返回指针,算术运算符返回bool类型。不应该编写这样的代码。其实在编写重载运算符时,应该让运算符返回的类型和运算符对内建类型操作时返回的类型一样。如果编写比较运算符,那么应该返回bool。如果编写的是算术运算符,那么应该返回表示运算结果的对象。
  • 引用和const标记的决策也适用于返回类型。不过对于返回值来说,这些决策要更困难一些。值还是引用的一般原则:如果可以,就返回一个引用,否则返回一个值。如何判断何时能返回引用?这个决策只能应用于返回对象的运算符:对于返回bool值的比较运算符、没有返回类型的转换运算符和函数调用运算符(可能返回所需的任何类型)来说,这个决策没有意义。如果运算符构造了一个新的对象,那么必须按值返回新的对象。如果不构造新对象,那么可以返回调用这个运算符的对象的引用,或者返回其中一个参数的引用。
  • 可以作为左值(赋值表达式左侧的部分)修改的返回值必须是非const。否则,这个值应该是const。大部分很容易想到的运算符都要求返回左值,包括所有的赋值运算符(operator=、operator+=和operator-=等)。
(4) 选择行为
  • 在重载的运算符中,可以提供任意需要的实现。例如,可以编写一个启动Scrabble拼字游戏的operator+。通常情况下,应该将实现约束为客户期待的行为。编写operator+时,使这个运算符能够执行加法,或其他类似加法的操作,例如字符串串联。

1.4 不要重载的运算符

  • 有一些运算符即使允许重载,也不应该重载。具体来说,取地址运算符(operator&)的重载一般没有特别的用途,如果重载时会导致混乱,因为这样做会以可能异常的方式修改基础语言的行为(获得变量的地址)。整个STL大量使用了运算符重载,但从没有重载取地址运算符。
  • 此外,还要避免重载二元布尔运算符operator&&和operator||,因为这样会使C++的短路求值规范失效。
  • 最后,不要重载逗号运算符(operator,)。C++中确实有一个逗号运算符,它也称之为序列运算符,用于分隔一条语句中的两个表达式,确保从左至右的求值顺序。几乎没有什么正当的理由需要重载这个运算符。

1.5 可重载运算符小结

  • 下表1-1中总结了什么时候应该(或不应该)重载,并提供了示例原型,展示了正确的返回值。
  • 下表1-1中,T表示要编写的重载运算符的类名,E是一个不同的类型(不是这个类的名称)。
运算符 名称或类别 方法还是全局friend函数 何时重载 示例原型
operator+ operator- operator* operator/ operator% 二元算术运算符 建议使用全局friend函数 类需要提供这些操作时 friend const T operator+(const T&, const T&); friend T operator+(const T&, const E&);
operator- operator+ operator~ 一元算术运算符和按位运算符 建议使用方法 类需要提供这些操作时 const T operator~() const;
operator++ operator-- 前缀递增运算符和递减运算符 建议使用方法 重载了++和--时 T& operator++();
operator++ operator-- 后缀递增运算符和递减运算符 建议使用方法 重载了++和--时 T operator++(int);
operator= 赋值运算符 必须使用方法 在类中动态分配了内存或资源,或者成员是引用时 T& operator=(const T&);
operator+= operator-= operator*= operator/= operator%= 算术运算符赋值的简写 建议使用方法 重载了二元算术运算符,且类没有设计为不可变时 T& operator+=(const T&); T& operator=(const E&);
operator<< operator>> operator& operator▏ operator^ 二元按位运算符 建议使用全局friend函数 需要提供这些操作时 friend const T operator<<(const T&, const T&); friend T operator<<(const T&, const E&);
operator<<= operator>>= operator&= operator ▏= operator^= 按位运算符赋值的简写 建议使用方法 重载了二元按位运算符,类没有设计为不可变时 T& operator<<=(const T&); T& operator<<=(const E&);
operator< operator> operator<= operator>= operator== operator!= 二元比较运算符 建议使用全局friend函数 需要提供这些操作时 friend bool operator<(const T&, const T&); friend bool operator<(const T&, const E&);
operator++ operator-- I/O流运算符(插入操作和提取操作) 建议使用全局friend函数 需要提供这些操作时 friend ostream& operator<<(ostream&, const T&); friend istream& operator>>(istream&, T&);
operator! 布尔非运算符 建议采用成员函数 很少重载:应该改为bool或void*类型转换 bool operator!() const;
operator&& operator ▏▏ 二元布尔运算符 建议使用全局friend函数 很少重载 friend bool operator&&(const T& lhs, const T& rhs);
operator[] 下标(数组索引)运算符 必须使用方法 需要支持下标访问时 E& operator; const E& operator const;
operator() 函数调用运算符 必须使用方法 需要让对象的行为和函数指针一致时 返回类型和参数可以多种多样,下文有详细讲解
operator type() 转换(或强制类型转换,cast)运算符(每种类型有不同的运算符) 必须使用方法 需要将自己编写的类型转换为其它类型时 operator type() const;
operator new operator new[] 内存分配例程 建议使用方法 需要控制类的内存分配时(很少见) void* operator new(size_t size); void* operator new[](size_t size);
operator delete operator delete[] 内存释放例程 建议使用方法 重载了内存分配例程时 void* operator delete(void* ptr) noexcept; void* operator delete[](void* ptr) noexcept;
operator* operator-> 解除引用运算符 对于operator*,建议使用方法;对于operator->,必须使用方法 适用于智能指针 E& operator() const; E operator->() const;
operator& 取地址运算符 不可用 永远不要 不可用
operator->* 解除引用指针-成员 不可用 永远不要 不可用
operator, 逗号运算符 不可用 永远不要 不可用
operator& 取地址运算符 不可用 永远不要 不可用

表1-1

1.6 右值引用

  • 表1-1中列出的普通赋值运算符的原型如下所示:

  •   T& operator=(const T&); 
    
  • 移动赋值运算符的原型几乎一致,但使用了右值引用。这个运算符会修改参数,因此不能传递const参数,如下所示:

      T& operator=(T&&);
    
  • 表1-1中没有包含右值引用语义的示例原型。然而,对于大部分运算符来说,编写一个使用普通左值引用的版本和一个使用右值引用的版本都是有意义的,但是否真正有意义取决于类的实现细节。比如通过operator+避免不必要的内存分配。例如STL中的std::string类利用右值引用实现了operator+,如下所示(简化版本):

      string operator+(string&& lhs, string&& rhs);
    
  • 这个运算符的实现会重用其中一个参数的内存,因为这些参数是以右值引用传递的,也就是说这两个参数表示的都是operator+完成之后销毁的临时对象。上述operator+的实现具有以下效果(具体取决于两个操作数的大小和容量):

      return std::move(lhs.append(rhs));
    
  •   return std::move(rhs.insert(0, lhs));
    
  • 事实上,std::string定义了几个重载的具有不同左值引用和右值引用组合的operator+运算符。下面列出std::string中所有接受两个string参数的operator+运算符(简化版本):

      string operator+(const string& lhs, const string& rhs);
      string operator+(string&& lhs, const string& rhs);
      string operator+(const string& lhs, string&& rhs);
      string operator+(string&& lhs, string&& rhs);
    
  • 重用其中一个右值引用参数的内存的实现方式和移动赋值运算符一致。

1.7 关系运算符

  • C++标准库中一个方便的<utility>头文件,它包含几个辅助函数和类,还在std::rel_ops命名空间中给关系运算符包含如下函数模板:

      template<class T> bool operator!=(const T& a, const T& b);   //需要operator==
      template<class T> bool operator>(const T& a, const T& b);    //需要operator<
      template<class T> bool operator<=(const T& a, const T& b);   //需要operator<
      template<class T> bool operator>=(const T& a, const T& b);   //需要operator<
    
  • 这些函数模板根据==和<运算符给任意类定义了运算符!=、>、<=和>=。如果在类中实现operator==和operator<,就会通过这些模板自动获得其他关系运算符。只要添加#include <utility>和下面的using声明,就可以将这些运算符用于自己的类:

      using std::rel_ops::operator!=;
      using std::rel_ops::operator>;
      using std::rel_ops::operator>=;
      using std::rel_ops::operator<=;
    

2. 重载算术运算符

  • 本节主要讲如何重载其他算术运算符的相关方法。

2.1 重载一元负号和一元正号

  • C++有几个一元算术运算符,其中包括一元负号和一元正号。下面列出一些使用int的运算符例子:

      int i, j = 4;
      i = -j;           //一元负号
      j = +i;           //一元正号
      j = +(-i);        //对i做一元负号产生的结果再做一元正号运算
      j = -(-i);        //对i做一元负号产生的结果再做一元负号运算
    
  • 一元负号运算符对其操作数取反,而一元正号运算符直接返回操作数。注意,可以对一元正号或一元负号产生的结果应用一元正号或一元负号。这些运算符不改变调用它们的对象,所以应该把它们标记为const。

  • 下面的例子把一元operator-运算符重载为SpreadsheetCell类的成员函数。一元正号通常是恒等运算,因此这个类没有重载这个运算符:

      SpreadsheetCell SpreadsheetCell::operator-() const
      {
          SpreadsheetCell newCell(*this);
          newCell.set(-mValue);           //调用set方法去更新mValue和mString
          return newCell;
      }
    
  • operator-没有修改操作数,因此这个方法必须构造一个带有相反值的新SpreadsheetCell,并返回这个对象的副本。因此,这个运算符不能返回引用。

2.2 重载递增和递减运算符

  • 可以采用4种方法给一个变量增加1:

      i = i + 1;
      i += 1;
      ++i;
      i++;
    
  • 后两种称为递增运算符。第一种形式是前缀递增,这个操作将变量的值增加1,然后返回增加后的新值,供表达式的其他部分使用。第二种形式是后缀递增,返回旧的(没有增加的)值,供表达式其他部分使用。递减运算符的功能类似。

  • operator++和operator--的双重意义(前缀和后缀)给重载带来了问题。例如,编写重载的operator++时,怎么表示重载的是前缀版本还是后缀版本?C++引入了一个方法来区分:前缀的operator++和operator--不接受参数,而后缀的版本需要接受一个不用的int类型参数。

  • 如果要为SpreadsheetCell类重载这些运算符,原型如下所示:

      SpreadsheetCell& operator++();        //前缀(prefix)
      SpreadsheetCell operator++(int);        //后缀(postfix)
      SpreadsheetCell& operator--();        //前缀(prefix)
      SpreadsheetCell operator--(int);        //后缀(postfix)
    
  • 前缀形式的结果值和操作数的最终值一致,因此前缀递增和前缀递减返回被调用对象的引用。然而后缀版本的递增操作和递减操作返回的结果值和操作数的最终值不同,因此不能返回引用。

  • 下面是operator++运算符的实现:

      SpreadsheetCell& SpreadsheetCell::operator++()
      {
          set(mValue + 1);
          return *this;
      }
      SpreadsheetCell SpreadsheetCell::operator++(int)
      {
          SpreadsheetCell oldCell(*this);  //在递增之前保存当前值的值
          set(mValue + 1);                    //把值加1(递增)
          return oldCell;                  //返回之前保存过的原来的值
      }
    
  • operator--的实现几乎和递增的相同,我们通过递增和递减来操作SpreadsheetCell对象:

      SpreadsheetCell c1(4);
      SpreadsheetCell c2(4);
      c1++;
      ++c2;
    
  • 递增和递减还能应用于指针。当编写的类是智能指针或迭代器时,可以重载operator++和operator--,以提供指针的递增和递减操作。

3. 重载按位运算符和二元逻辑运算符

  • 按位运算符和算术运算符类似,简写的按位运算符也和简写的算术运算符类似。在表1-1中已经展示了示例原型。

  • 逻辑运算符要困难一些。建议不重载&&和||。这些运算符并不应用于单个类型:而是整合布尔表达式的结果。此外,重载这些运算符会失去短路求值,原因是在将运算符左侧和右侧的值绑定至重载的&&和||运算符之前,必须对运算符的左侧和右侧进行求值。因此,一般对特定的类型重载这些运算符都没有意义。

  • 下面来讲解下短路求值的概念:在C++中短路求值有逻辑与(&&)和逻辑或(||)。

    (1). 逻辑与的短路

  • 首先看如下代码:

      #include <iostream>
      using namespace std;
      int main()
      {
          int a = 1;
          cout << "a = " << a << endl;
          false && (a=3);
          cout << "a = " << a << endl;
      }
    
  • 运行结果如下:

      a = 1
      a = 1
    
  • 逻辑或的表现形式如下:

      expression1 && exexpression2
    
  • 这里用到了逻辑与,由于逻辑与的短路,expression1为false,则后面的expression2(即:(a=3))不再求值,整个表达式的结果为false,所以a的值仍为1,没有改变。

    (2). 逻辑或的短路

  • 首先看如下代码:

      #include <iostream>
      using namespace std;
      int main()
      {
          int a = 1;
          cout << "a = " << a << endl;
          true || (a=0);
          cout << "a = " << a << endl;
      }
    
  • 运行结果如下:

      a = 1
      a = 1
    
  • 逻辑或的表现形式如下:

      expression1 || exexpression2
    
  • 这里用到了逻辑或,由于逻辑或的短路,expression1为true,则后面的expression2(即:(a=0))不再求值,整个表达式的结果为true,所以a的值仍为1,没有改变。

    (3). 应用举例

  • 如何不用if语句,不用汇编使得两个数之积总是小于等于255?

  • 比如可以用简单的条件表达式:

      result = ((a*b) > 255) ? 255 : (a*b);
    
  • 也可以用逻辑或的短路来解:

      bool btmp = ((result = a*b) < 255) || (result = 255);
    
  • 同时也可以用逻辑与的短路来解:

      bool btmp = ((result = a*b) >= 255) && (result = 255);
    

4. 重载插入运算符和提取运算符

  • 在C++中,不仅算术操作需要使用运算符,从流中读写数据都可以使用运算符。例如,向cout写入int和string时使用插入运算符<<:

      int number = 10;
      cout << "The number is " << number << endl;
    
  • 从流中读取数据时,使用提取运算符>>:

      int number;
      string str;
      cin >> nubmer >> str;
    
  • 还可以为自己定义的类编写合适的插入和提取运算符,从而可按以下方式进行读写:

      SpreadsheetCell myCell, anotherCell, aThirdCell;
      cin >> myCell >> anotherCell >> aThirdCell;
      cout << myCell << " " << anotherCell << " " << aThirdCell << endl;
    
  • 在编写插入和提取运算符之前,需要决定如何将自定义的类向流输出,以及如何从流中提取自定义的类。在上面的例子中,SpreadsheetCell将读取和写入字符串。

  • 插入和提取运算符左侧的对象是istreamostream(例如cincout),而不是SpreadsheetCell对象。由于不能向istreamostream类添加方法,因此应该将插入和提取运算符写为SpreadsheetCell类的全局friend函数。这些函数在SpreadsheetCell类中的声明如下所示:

      class SpreadsheetCell
      {
      public:
          //省略……
          friend std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);
          friend std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);
          //省略……
      };
    
  • 将插入运算符的第一个参数设置为ostream的引用,这个运算符就能够应用于文件输出流、字符串输出流、cout、cerr和clog等。与此同时,将提取运算符的第一个参数设置为istream的引用,这个运算符就能应用于文件输入流、字符串输入流和cin。

  • operator<<和operator>>的第二个参数是对要写入或读取的SpreadsheetCell对象的引用。插入运算符不会修改写入的SpreadsheetCell,因此这个引用可以是const。然而提取运算符会修改SpreadsheetCell对象,因此要求这个参数为非const引用。

  • 两个运算符返回的都是第一个参数传入的流的引用,所以这个运算符可以嵌套。记住,这个运算符的语法实际上是显示调用全局operator>>函数或operator<<函数的简写形式。例如下面这一行代码:

      cin >> myCell >> anotherCell >> aThirdCell;
    
  • 实际上是这一行的简写形式:

      operator>>(operator>>(operator>>(cin, myCell), anotherCell), aThirdCell);
    
  • 从中可以看出,第一次调用operator>>的返回值作下一次调用的输入值。因此必须返回流的引用,结果才能可以用于下一次嵌套的调用。否则嵌套调用无法编译。

  • 下面是SpreadsheetCell类的operator<<和operator>>的实现:

      ostream& operator<<(ostream& ostr, const SpreadsheetCell& cell)
      {
          ostr << cell.mString;
          return ostr;
      }
      istream& operator>>(istream& istr, SpreadsheetCell& cell)
      {
          string strTemp;
          istr >> strTemp;
          cell.set(strTemp);
          return istr;
      }
    
  • 这些函数中最棘手的部分是为了正确配置mValue的值,operator>>必须调用SpreadsheetCell的set()方法,而不是直接设置mString。

推荐阅读更多精彩内容