C++運算子過載詳解

歐陽大哥2013發表於2019-03-02

C++語言的一個很有意思的特性就是除了支援函式過載外還支援運算子過載,原因就是在C++看來運算子也算是一種函式。比如一個 a + b 的加法表示式也可以用函式的形式:operator + (a, b)來表達。這裡的operator +代表的就是加法函式。高階語言中的表示式和數學表示式非常相似,在一定的程度上通過運算子來描述表示式會比通過函式來描述表示式更加利於理解和閱讀。一般情況下在過載某個運算子的實現時最好要和運算子本身的數學表示意義相似,當然你也可以完全實現一個和運算子本身意義無關的功能或者相反的功能(比如對某個+運算子實現為相減)。運算子函式和類的成員函式以及普通函式一樣,同樣可分為類運算子和普通運算子。要定義一個運算子函式總是按如下的格式來定義和申明:

      返回型別 operator 運算子(引數型別1 [,引數型別2] [,引數型別3] [, 引數型別N]);
複製程式碼

運算子過載需要在運算子前面加上關鍵字operator。一般情況下引數的個數不會超過2個,因為運算子大多隻是一元或者二元運算,而只有函式運算子()以及new和delete這三個運算子才支援超過2個引數的情況。

可過載的運算子的種類

並不是所有C++中的運算子都可以支援過載,我們也不能建立一個新的運算子出來(比如Σ)。有的運算子只能作為類成員函式被過載,而有的運算子則只能當做普通函式來使用。

  • 不能被過載的運算子有:. .* :: ?: sizeof
  • 只能作為類成員函式過載的運算子有:() [] -> =

下面我將會對各種運算子過載的方法進行詳細的介紹。同時為了更加表現通用性,我這邊對引數型別的定義都採用模板的形式,並給出運算子的一些大體實現的邏輯。實際中進行過載時則需要根據具體的型別來進行定義和宣告

1. 流運算子
描述
運算子種類 >> <<
是否支援類成員 YES
是否支援普通函式 YES
運算單元 二元
返回型別 左值引用

流運算子是C++特有的一種運算子。C++的標準庫裡面的iostream類就支援了流運算子並提供了讀取流>>和插入流<<兩種運算子,它們分別用來進行輸入和輸出操作,而且可以連續的進行輸入輸出,正是因為流運算子的這些特性使得函式的返回值型別必須是引用型別,而且對於普通函式來說第一個引數也必須是引用型別。下面的例子說明了對流運算子的宣告和定義方法:

  //普通流運算子函式模板
template<class LeftType,  class RightType>  
LeftType& operator << (LeftType& left, const RightType& right)
{
    //...
    return left;
}

template<class LeftType,  class RightType>  
LeftType& operator >> (LeftType& left, RightType& right)
{
    //...
    return left;
}

//類成員函式
class CA
{
     public:

     template<class RightType>
      CA& operator << (const RightType& right)
     {
           //...
            return *this;
      }

     template<class RightType>
     CA& operator >>(RightType& right)
    {
        //...
        return *this;
    }
};

複製程式碼

從上面的例子裡面可以看出:

  • 流運算子的返回總是引用型別的,目的是返回值可以做左值並且進行連續的流運算操作。
  • 對於輸入流運算子>>來說我們要求右邊的引數必須是引用型別的,原因就是輸入流會修改右邊引數變數的內容。如果右邊引數是普通的值型別則不會起到輸入內容被改變的效果。當然右邊引數型別除了採用引用之外,還可以設定為指標型別。
  • 對於輸出流運算子<<來說因為並不會改變右邊引數的內容,所以我們建議右邊引數型別為常量引用型別,目的是為了防止函式內部對右邊引數的修改以及產生資料的副本或者產生多餘的構造拷貝函式的呼叫。
  • 一般對流運算子進行過載可以採用普通函式也可以採用類成員函式的形式。二者的差別就是普通函式不能訪問類的私有變數。當然解決的方法是將普通函式設定為類的友元函式即可。
2. 算術表示式運算子
描述
運算子種類 + – * / % ^ & | ~ >> <<
是否支援類成員 YES
是否支援普通函式 YES
運算單元 除~是一元之外其他都是二元
返回型別 普通值型別

算術表示式是最常見的數學運算子號,上面分別定義的是加(+)、減(-)、乘(*)、除(/)、取餘(%)、異或(^)、與(&)、或(|)、非(~)、算術右移(>>)、邏輯左移(<<)幾個運算子。除~運算子外其他運算子都是二元運算子,而且運算的結果和原來的值無關,並且不能做左值引用。下面是這些運算子過載的例子程式碼:

  //普通算術運算子函式模板
template<class ReturnType, class LeftType,  class RightType>  
ReturnType operator + (const LeftType& left, const RightType& right)
{
    //...
    return 返回一個ReturnType型別的值
}

//取反運算子是一個一元運算子。
template<class ReturnType, class LeftType>
ReturnType operator ~(const LeftType& left)
{
    //...
   return 返回一個ReturnType型別的值
}

//類成員函式
class CA
{
     public:

     template<class ReturnType, class RightType>
      ReturnType operator + (const RightType& right) const
     {
           //...
            return 一個新的ReturnType型別物件。
      }

      //取反運算子是一個一元運算子。
     template<class ReturnType>
      ReturnType operator  ~ () const
     {
           //...
            return 一個新的ReturnType型別物件。
      }
};

複製程式碼

從上面的例子可以看出:

  • 函式的返回都是普通型別而不是引用型別是因為這些運算子計算出來的結果都和輸入的資料並不是相同的物件而是一個臨時物件,因此不能返回引用型別,也就是不能再作為左值使用。
  • 正是因為返回的值和輸入引數是不同的物件,因此函式裡面的入參都用常量引用來表示,這樣資料既不會被修改又可以減少構造拷貝的產生。
  • 函式的返回型別可以和函式的入參型別不一致,但在實際中最好是所有引數的型別保持一致。
  • 除了~運算子是一元運算子外其他的都是二元運算子,你可以看到上面的例子裡面一元和二元運算子定義的差異性。
  • 這裡面的<<和>>分別是表示位移運算而不是流運算。所以可以看出其實我們可以完全來自定義運算子的意義,也就是實現的結果可以和真實的數學運算子的意義完全不一致。
3. 算術賦值表示式運算子
描述
運算子種類 += -= *= /= %= ^= &= |= >>= <<=
是否支援類成員 YES
是否支援普通函式 YES
運算單元 二元
返回型別 左值引用

算術賦值表示式除了具有上面說的算術運算的功能之外,還有儲存結果的作用,也就是會將運算的結果儲存起來。因此這種運算子函式的第一個引數必須是引用型別,而不能是常量,同時返回型別要和第一個引數的型別一致。下面的例子說明了運算子的宣告和定義方法:

  //普通運算子函式模板
template<class LeftType,  class RightType>  
LeftType& operator += (LeftType& left, const RightType& right)
{
    //...
    return left;
}

//類成員函式
class CA
{
     public:

     template<class RightType>
      CA& operator += (const RightType& right)
     {
           //...
            return *this;
      }

     template<class RightType>
     CA& operator +=(RightType& right)
    {
        //...
        return *this;
    }
};

複製程式碼

從上面的例子裡面可以看出:

  • 算術賦值運算子的返回總是引用型別,而且要和運算子左邊的引數型別保持一致。
  • 函式的右邊因為並不會改變右邊引數的內容,所以我們建議右邊引數型別為常量引用型別,目的是為了防止函式內部對右邊引數的修改以及產生資料的副本或者產生多餘的構造拷貝函式的呼叫。
4. 比較運算子
描述
運算子種類 == != < > <= >= && || !
是否支援類成員 YES
是否支援普通函式 YES
運算單元 除!外其他的都是二元
返回型別 bool

比較運算子主要用於進行邏輯判斷,返回的是bool型別的值。這些運算子並不會改變資料的內容,因此引數都設定為常量引用最佳。下面的例子說明了運算子的宣告和定義方法:

  //普通算術運算子函式模板
template<class LeftType,  class RightType>  
bool operator == (const LeftType& left, const RightType& right)
{
    //...
    return true or false
}

//非運算子是一個一元運算子。
template<class LeftType>
bool  operator !(const LeftType& left)
{
    //...
   return true  or false
}

//類成員函式
class CA
{
     public:

     template<class RightType>
      bool operator == (const RightType& right) const
     {
           //...
            return true or false
      }

      //取反運算子是一個一元運算子。
      bool operator  ! () const
     {
           //...
            return true or false
      }
};

複製程式碼

從上面的例子可以看出:

  • 條件運算子返回的一般是固定的bool型別,因為不會改變資料的值所以無論引數還是成員函式都用常量來修飾。
5. 自增自減運算子
描述
運算子種類 ++ —
是否支援類成員 YES
是否支援普通函式 YES
運算單元 一元
返回型別 普通型別,和左值引用

自增和自減運算子都是一元運算子,而且都會改變自身的內容,因此左邊引數不能是常量而只能是引用型別。又因為自增分為字尾i++和字首++i兩種形式(自減也一樣,下面就只舉自增的例子了)。字尾自增返回的值不能做左值而字首自增返回的值則可以做左值。為了區分前自增和後自增,系統規定對字首自增的運算子函式上新增一個int型別的引數作為區分的標誌。下面的例子說明了運算子的宣告和定義方法:

  //普通函式運算子函式模板

//++i
template<class LeftType>  
LeftType& operator ++ (LeftType& left, int)
{
    //...
    return left
}

//i++
template<class LeftType>  
LeftType operator ++ (LeftType& left)
{
    //...
    return 新的LeftType值
}

//類成員函式
class CA
{
     public:

     CA& operator  ++ (int)
     {
           //...
            return *this;
      }

    CA operator ++ ()
     {
           //...
            return 新的CA型別值
      }
};

複製程式碼

從上面的函式定義可以看出:

  • 自增自減函式的引數以及返回值以及函式修飾都不能帶const常量修飾符。
  • 字首自增的返回是引用型別可以做左值,而字尾自增的返回型別則是值型別不能做左值。
  • 引數中有int宣告的是字首自增而沒有int宣告的是字尾自增。
6.賦值運算子
描述
運算子種類 =
是否支援類成員 YES
是否支援普通函式 NO
運算單元 二元
返回型別 左值引用

賦值運算子只能用於類的成員函式中不能用於普通函式。賦值運算子過載的目的是為了解決物件的深拷貝問題。我們知道C++中對於物件賦值的預設處理機制是做物件記憶體資料的逐位元組拷貝,這種拷貝對於只有值型別資料成員的物件來說是沒有問題的,但是如果物件中儲存有指標型別的資料成員則有可能會出現記憶體重複釋放的問題。比如下面的程式碼片段:


class CA
{
     public:
          int *m_a;
    
   ~CA(){ delete m_a;} 
};

void main()
{
      CA a, b;
      a.m_a = new int;
      b = a;  //這裡執行賦值操作,但是有危險!
}

複製程式碼

上面的程式碼可以看出當a,b物件的生命週期結束後的解構函式都會釋放資料成員的m_a所佔用的記憶體,但是因為我們的預設物件賦值機制將會導致這部分記憶體被釋放兩次,從而產生了崩潰。因此在這種情況下我們就需對類的賦值運算子進行過載來解決物件的淺拷貝問題。上面的情況除了要對一個類的賦值運算子進行過載外還有為這個類建立一個拷貝建構函式。這裡面有一個著名的構造類的大三原則

如果一個類需要任何下列的三個成員函式之一,便三者全部要實現,
這三個成員函式是:拷貝構造,賦值運算子,解構函式. 實踐中,很多類只要遵循”大二規則”即可,也就是說只要實現拷貝構造,賦值操作符就可以了,解構函式並不總是必需的.

實現大三原則的目的主要解決深拷貝的問題以及解決物件中有的資料成員的記憶體是通過堆分配建立的。在這裡拷貝建構函式的實現一般和賦值運算子的實現相似,二者的區別在於拷貝建構函式一般用在物件建立時的場景,比如物件型別的函式引數傳遞以及物件型別的值的返回都會呼叫拷貝構造,而賦值運算子則用於物件建立後的重新賦值更新。比如下面的程式碼:


    class CA
   {
       //... 
   };

CA foo(CA a)
{
    return a;
}

void main()
{
     CA  a, c;   //建構函式
     CA b = foo(a);   //a在傳遞給foo時會呼叫拷貝構造,foo在返回資料給b時也會呼叫拷貝構造,即使這裡出現了賦值運算子。
     c = b;       //賦值運算子

}   

複製程式碼

上面的程式碼你可以清楚的看到建構函式、拷貝建構函式、賦值運算子函式呼叫的時機和差異。下面我們來對賦值運算子以及大三原則進行定義:


     class CA
     {
             public:
               CA(){}   //建構函式
               CA(const CA& other){}  //拷貝構造
               CA& operator =(const CA& other)  //賦值運算子過載
               {
                    //..
                     return *this;
               }
              ~CA(){}   //解構函式
     }

複製程式碼

從上面的定義可以看出:

  • 賦值運算子要求返回的是類的引用型別,因為賦值後的結果是可以做左值引用的。
  • 賦值運算子函式引數是常量引用表明不會修改入參的值。
7. 下標索引運算子
描述
運算子種類 []
是否支援類成員 YES
是否支援普通函式 NO
運算單元 二元
返回型別 引用

我們知道在陣列中我們可以通過下標索引的方式來讀取和設定某個元素的值比如:

     int array[10] = {0};
     int a = array[0];
     array[0] = 10;
複製程式碼

在實際中我們的有些類也具備集合的特性,我們也希望獲取這個集合類中的資料元素通過下標來實現,為了解決這個問題我們可以對在類中實現下標索引運算子。這個運算子只支援在類中定義,並且索引的下標一般是整數型別,當然你可以定義為其他型別以便實現類似於字典或者對映表的功能。具體的程式碼如下:

    class CA
     {
          public:
                 //只用於常量物件的讀取操作
                 template<class ReturnType,  class IndexType>
                 const ReturnType& operator [](IndexType index) const
                 {
                       return 某個returnType的引用
                  }
 
                 //用於一般物件的讀取和寫入操作
                 template<class ReturnType,  class IndexType>
                 ReturnType& operator[](IndexType index)
                 {
                        return 某個returnType的引用
                  }
      }    

複製程式碼

從上面的程式碼可以看出:

  • 這裡定義了兩個函式主要是前者為常量集合物件進行下標資料讀取操作,而後者則為非常量集合物件進行下標資料讀取和寫入操作。
  • 這裡返回的不是值型別而是引用型別的目的是為了減少因為讀取而產生不必要的記憶體複製。而寫入操作則必須使用引用型別。
8. 型別轉換運算子
描述
運算子種類 各種資料型別
是否支援類成員 YES
是否支援普通函式 NO
運算單元 一元
返回型別 各種資料型別

在實際的工作中,我們的有些方法或者函式只接受特定型別的引數。而對於一個類來說,如果這個類的物件並不是那個特定的型別那麼就無法將這個物件作為一個引數來進行傳遞,為了解決這個問題我們必須要為類構建一個特殊的型別轉換函式來解決這個問題比如:


void foo(int a){
     cout << a << endl;
}

class CA
{
   private:
        int m_a;
    public:
        CA(int a):m_a(a){}
       int toInt()
       {
            return m_a;
       }
};

void main()
{
       CA a(10);
       
       foo(a);  // wrong!!!  a是CA型別而非整數,編譯時報錯。
       foo(a.toInt());  // ok!!   
}

複製程式碼

可以看出為了進行有效的引數傳遞,CA類必須要建立一個新的函式toInt來獲取整數並傳遞給foo。而型別轉換運算子則可以更加方便以及易讀的形式來解決這種問題,通過型別轉換運算子的過載我們的程式碼在進行引數傳遞時就不再需要藉助多餘的函式來完成,而是直接進行引數傳遞。型別轉換運算子過載其實是一種介面卡模式的實現,我們可以通過型別轉換運算子的形式來實現不同型別資料的轉換和傳遞操作。型別轉換運算子過載的定義方法如下:


     class CA
    {
        public:
               template<class Type>
               operator Type()
               {
                     return Type型別的資料。
               }
    };

複製程式碼

從上面的程式碼中可以看出:

  • 型別轉換運算子過載是不需要指定返回型別的,同時也不需要指定其他的入參,而只需要指定轉換的型別作為運算子即可。
  • 型別轉換運算子過載是可以用於任何的資料型別的,通過型別轉換運算子的使用我們就可以很簡單的解決這種型別不匹配的問題了,下面的程式碼我們來看通過型別轉換運算子過載的解決方案:
class CA
{
   private:
        int m_a;
    public:
        CA(int a):m_a(a){}
       operator int()
       {
            return m_a;
       }
};

void main()
{
       CA a(10);
       foo(a);  // ok!  在進行引數傳遞是a會呼叫型別轉換運算子進行型別的轉換。
}

複製程式碼
9. 函式運算子
描述
運算子種類 ()
是否支援類成員 YES
是否支援普通函式 NO
運算單元 N元
返回型別 任意

函式運算子在STL中的演算法中被大量使用。函式運算子可以理解為C++對閉包的支援和實現。 我們可以通過函式運算子來將一個物件當做普通函式來使用,這個意思就是說我們可以在某些接收函式地址作為引數的方法中傳遞一個物件,只要這個類實現的函式運算子並且其中的引數簽名和接收的函式引數簽名一致即可。我們先來看下面一段程式碼:


//定義一個模板fn他可以接收普通函式也可以接收實現函式運算子的物件
template<class fn>
void foo2(int a, fn pfn)
{
      int ret = pfn(a);
      std::cout << ret << std::endl;
}

int foo1(int arg)
{
     return arg + 1;
}


class CA
{
    private:
          int m_a;
     public:
      CA(int a):m_a(a){}
      //定義一個函式運算子
      int operator()(int arg)
     {
          return arg + m_a;
     }

    //定義另外一個函式運算子
    void operator()(int arg1, int arg2)
   {
        std::cout << arg1 + arg2 + m_a << std::endl;
   }
};

void main()
{
      foo2(10, &foo1);   //普通函式作為引數傳遞。
    
     CA a(20);
     foo2(10, a);  //將物件傳遞給foo2當做普通函式來用。

    a(20, 30);    //這裡將物件當做一個普通的函式來用。
}


複製程式碼

上面的程式碼可以看出來,因為CA類實現了2個函式運算子,所以我們可以將CA的物件當做普通的函式來用,在使用時就像是普通的函式呼叫一樣。我們稱這種實現了函式運算子的類的物件為函式物件。那麼為什麼要讓物件來提供函式的能力呢?答案就是我們可以在物件的函式運算子內部訪問一些物件本身具有的其他屬性或者其他成員函式,而普通的函式則不具備這些特性。上面的例子也說明了這個問題,在類的函式運算子內部還可以使用資料成員。一個類中可以使用多個函式運算子的過載,而且函式運算子過載時的引數個數以及返回型別都可以完全自定義。 我們知道C++中不支援閉包機制,但是在某種程度上來說我們可以藉助函式運算子過載的方式來實現這種類似閉包的能力。

10. 復引用運算子、地址運算子、成員訪問運算子
描述
運算子種類 * & ->
是否支援類成員 YES
是否支援普通函式 除了* &支援外,->不支援
運算單元 1元
返回型別 任意

在C++語言中我可以可以對一個指標物件使用*運算子來實現取值操作,也就是得到這個指標所指向的物件;對一個物件使用&運算子來得到物件的指標地址;對於一個指標物件我們可以使用->運算子來訪問裡面的資料成員。因此這裡的*運算子表示的是取值運算子(也叫復引用運算子,間接引用運算子)、&表示的是取地址運算子、->表示的是成員訪問運算子。


  class CA
{
   public:
       int m_a;

};

void main()
{
       CA a;

       CA *p = &a;   //取地址運算子
       cout << *p << endl;    //取值運算子
       p->m_a = 10;   //成員訪問運算子       

}
複製程式碼

可以看出來上面的三個運算子的主要目的就是用於指標相關的處理,也就是記憶體相關的處理。這三個運算子過載的目的主要用於智慧指標以及代理的實現。也是是C++從語言級別上對某些設計模式的實現。在程式設計中有時候我們會構造出一個類來,這個類的目的主要用於對另外一個類進行管理,除了自身的一些方法外,所有其他的方法呼叫都會委託給被管理類,這樣我們就要在管理類中實現所有被管理類的方法,比如下面的程式碼例子:


class CA
{
    public:
       void foo1();
       void foo2();
       void foo3();
};

class CB
{
   private:
       CA *m_p;
   public:
     CB(CA*p):m_p(p){}
     ~CB() { delete m_p;}  //負責銷燬物件

   CA* getCA(){ return m_p;}
   void foo1(){ m_p->foo1();}
   void foo2(){m_p->foo2();}
   void foo3(){m_p->foo3();}
};

void fn(CA*p)
{
   p->foo1();
}

void main()
{
    CB b(new CA);
     b.foo1();
     b.foo2();
     b.foo3();
    //因為fn只接受CA型別所以這裡CB要提供一個方法來轉化為CA物件。
    fn(b.getCA());
   
}

複製程式碼

上面的程式碼可以看出CB類是一個CA類的管理類,他會負責對CA類物件的生命週期的管理。除了這些管理外CB類還實現所有CA類的方法。當CA類的方法有很多時那麼這種實現的方式是低效的,怎麼來解決這個問題呢?答案就是本節裡面所說到的3個運算子過載。我們來看如何實現這三個運算子的過載:


   class CA
{
    public:
       void foo1();
       void foo2();
       void foo3();
};

class CB
{
   private:
       CA *m_p;
   public:
     CB(CA*p):m_p(p){}
     ~CB() { delete m_p;}  //負責銷燬物件

  public:
    //解引用和地址運算子是互逆的兩個操作
    CA&  operator *() { return *m_p;}
    CA*   operator &() {return m_p;}

    //成員訪問的運算子和&運算子的實現機制非常相似
    CA*  operator ->() { return m_p;}
 };


void fn1(CA*p)
{
   p->foo1();
}

void fn2(CA&r)
{
    r.foo2();
}

void main()
{
    CB b(new CA);
     
     b->foo1();
     b->foo2();   //這兩個呼叫了->運算子過載

    fn1(&b);   //呼叫&運算子過載
    fn2(*b);   //呼叫*運算子過載
}


複製程式碼

從上面的程式碼可以看出正是因為實現了對三個運算子的過載使得我們不需要在CB類中重寫foo1-foo3的實現,以及我們不需要提供特殊的型別轉換方法,而是直接通過運算子的方式就可以轉化為CA物件的並使用。當然一個完整的智慧指標的封裝不僅僅是對三個運算子的過載,我們還需要對建構函式、拷貝構造、賦值運算子、型別轉化運算子、解構函式進行處理。如果你要想更加的瞭解智慧指標就請去看看STL中的auto_ptr類

11. 記憶體分配和銷燬運算子
描述
運算子種類 new delete
是否支援類成員 YES
是否支援普通函式 YES
運算單元 N元
返回型別 new返回指標, delete不返回

是的,你沒有看錯C++中對記憶體分配new以及記憶體銷燬delete也是支援過載的,也就是說new和delete也是一種運算子。預設情況下C++中的new和delete都是在堆中進行記憶體分配和銷燬,有時候我們想對某個類的記憶體分配方式進行定製化處理,這時候就需要通過對new和delete進行過載處理了。並且系統規定如果實現了new的過載就必須實現delete的過載處理。關於對記憶體分配和銷燬部分我想單獨開闢一篇文章來進行詳細介紹。這裡面就只簡單了舉例如何來實現new和delete的過載:

class CA
{
    public:
     CA* operator new(size_t t){  return  malloc(t);}
     void operator delete(void *p) { free(p);}      
};
   
複製程式碼

關於對new和delete運算子的詳細介紹請參考文章:C++的new和delete詳解

相關文章