一文读懂 C++ 运算符重载

运算符重载是 C++ 语言的一个特性,利用运算符重载能够写出更加简洁的代码,对外封装类的实现细节。本文介绍运算符重载相关知识点。

谈到运算符重载,还需要从 “函数重载” 说起。函数重载是指函数名相同,但函数参数类型、参数数量不同(返回类型相同)的函数。比如实现某种数据类型向 string 类型运算的函数:

1
2
3
4
5
6
// 函数重载示例
string to_string(int i);
string to_string(double d);
string to_string(char c);
string to_string(float d);
// 等等
这就是典型的函数重载。

运算符重载本质上也是函数重载,它们可以看做函数名为 operator+operator-operator*operator++operator<<等等的函数。重载即体现在函数名相同,但函数参数类型不同。当然,一些运算符也允许参数数量不同的重载,典型的就是函数调用运算符operator()的重载。

利用函数重载可以实现更好的封装,同时提高代码的简洁度。例如,我们要实现一个大整数类,用于支持高精度运算,如果没有运算符重载,我们只能按照如下方式实现:

1
2
3
4
5
6
7
8
struct BigNum{
// 相关数据成员声明
};
BigNum add(const BigNum &,const BigNum &); // 加法
BigNum sub(const BigNum &,const BigNum &); // 减法
BigNum mul(const BigNum &,const BigNum &); // 乘法
BigNum div(const BigNum &,const BigNum &); // 除法
BigNum mod(const BigNum &,const BigNum &); // 取模
进行函数调用时,则是以下形式:
1
2
BigNum n1, n2, n3;
n3 = add(n1, n2); // 显式函数调用
利用运算符重载,则实现方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
class BigNum{
private:
// 相关数据成员
public:
friend BigNum operator+ (const BigNum&, const BigNum &); // 加法
friend BigNum operator- (const BigNum&, const BigNum &); // 减法
friend BigNum operator* (const BigNum&, const BigNum &); // 乘法
// 略
};
// 进行加法运算
BigNum n1, n2, n3;
n3 = n1 + n2; // 直接使用 + 运算符即可,相当于调用 operator+ 函数
由上面两种实现方式和调用方式,不难看出运算符重载的优点:封装与简洁(对使用者而言的简洁,对类的设计者而言工作量增加)。

知道了上面的基础知识,我们正式进入 C++ 运算符重载的主题,重点介绍运算符重载的语法特点和一些需要注意的问题。(下文以 TypeName 代表自定义类型名,如上面的 BigNum

1. 重载输出运算符 <<

输入输出运算符的重载是为了更加方便的输入/输出数据。其语法规则如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 类内声明友元
class TypeName{
private:
// 相关成员定义 略
public:
friend ostream& operator<< (ostream &, const TypeName &);
};
// 类外定义函数
ostream & operator<< (ostream &os, const TypeName &temp)
{
// 具体实现 略
return os;
}

注意点如下:

  1. 形参 1 :ostream &类型,非 const 引用
  2. 形参 2 :const 引用类型。const 是因为输出运算符一般不应该改变对象的的状态;使用引用类型是处于性能与效率的考虑,减少调用时对象的拷贝,从而提高程序运行效率
  3. 返回类型: ostream 引用,这是为了能够连续输出
  4. 一般定义为友元

2. 重载输入运算符 >>

输入运算符与输出运算符比较相似,其语法规则如下:

1
2
3
4
5
6
7
// 类内声明友元与上面输出运算符类似 此处略
// 类外实现如下
istream & operator>> (istream &is, TypeName &temp)
{
// 具体实现 略
return is;
}

注意点如下:

  1. 形参 1 :istream &类型, 同样是非 const 引用类型
  2. 形参 2 :非 const 引用,之所以是非 const 是因为输入一般要改变对象内部状态;之所以是引用类型是因为要改变形参对象本身
  3. 返回类型:与输出运算符重载类型,返回引用,目的是能够连续输入
  4. 一般定义为友元

3. 重载算术和关系运算符

实际上,算术和关系运算符重载有两种实现方法,即重载是定义为成员函数还是定义为非成员函数(友元)。以加法为例说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方法 1 :非成员函数
class TypeName{
public:
// 类内声明
friend TypeName operator+ (const TypeName &, const TypeName &);
};
// 类外定义
TypeName operator+ (const TypeName &lop, const TypeName &rop)
{
// 略
}

// 方法 2: 成员函数
class TypeName{
public:
TypeName operator+ (const TypeName &rop)
{
// 略
}
};
一般情况下,这两种方式差别不大,但是当类含有一个接收普通类型的构造函数时,第一种实现方法更好,这是因为非成员函数允许对左侧或者右侧的运算对象进行类型转换(即调用构造函数由普通类型构造类的对象);而成员函数方式只允许对右侧运算对象进行类型转换。按照《C++ Primer 第五版》一书的说法,推荐以非成员函数方式实现。

3.1 算术运算符

我们以加法为例介绍算术运算符重载。语法规则如下:(实际上这段代码和上面方法 1 代码相同)

1
2
3
4
5
6
7
8
9
10
class TypeName{
public:
// 类内声明
friend TypeName operator+ (const TypeName &, const TypeName &);
};
// 类外定义
TypeName operator+ (const TypeName &lop, const TypeName &rop)
{
// 略
}

注意点如下:

  1. 参数类型:两个形参都为 const &类型 ,这是因为算术运算符不改变运算对象本身;同时引用减少拷贝提高效率
  2. 返回值类型:返回一个经算术运算后生成的临时对象

3.2 关系运算符

关系运算符很多,包括:==!=>>=<<=共 6 种。它们的实现方式同样推荐方法 1 ,即以非成员函数方式实现。它们的参数类型都是 const 引用类型 ,返回值都是 bool 类型

这 6 种关系运算符实际上存在逻辑上的转换关系,因此在实际编程中,往往采用如下方式实现这些运算符重载:

  • 首先实现 == 运算符
  • 利用 == 实现 != 运算符,即 a != b 相当于 !(a == b)
  • 实现 < 运算符
  • 实现 > 运算符
  • 利用 <== 实现 <= 运算符,即 a <= b 相当于 (a < b || a == b) (当然,也可以用 !(a > b) 实现)
  • 利用 >== 实现 >= 运算符,即 a >= b 相当于 (a > b || a == b) (当然,也可以用 !(a < b) 实现)

实际上,除了上述实现方式也有其他方法,如 a == b 相当于 !( (a>b) || (a<b) ) ,其他运算符也有另外实现方式,只须符合逻辑规则即可,这里不赘述。

下面以 == 为例给出语法规则:

1
2
3
4
5
6
7
8
9
10
class TypeName{
public:
// 类内声明
friend bool operator== (const TypeName &, const TypeName &);
};
// 类外定义
bool operator== (const TypeName &lop, const TypeName &rop)
{
// 略
}

4. 重载赋值运算符

赋值运算符分为两种,一为普通的赋值运算符 =;二为复合赋值运算符,如 +=-=等。其中,普通赋值运算符又分为拷贝赋值和移动赋值,它们实际上更应该归类与类的拷贝控制。公众号此前写过文章介绍类的拷贝控制,其中包括拷贝赋值和移动赋值运算符介绍:《浅谈 C++ 类的拷贝控制》 。普通赋值运算符的重载必须为类的成员函数。此外,赋值运算符的也可以重载其它参数类型,如 C++ 标准库提供的初始化列表类型 initializer_list<TypeName> ,不过其不是本文重点,相关资料请自行查阅。

下面重点介绍复合赋值运算符的重载。复合赋值运算符不是必须定义为类的成员,但是绝大多数情况下,最好定义为类的成员函数。其语法规则如下:

1
2
3
4
5
TypeName & operator+= (const TypeName &rop)
{
// 具体实现 略
return *this;
}

注意点如下:

  1. 形参类型:const 引用类型
  2. 返回值类型:引用类型,这是为了与 C++ 内置与普通数据成员的复合赋值运算符保持一致的特性,即返回引用类型,在代码中体现为 return *this
  3. 所有复合赋值运算符都应该定义在类内部,作为成员函数

5. 重载下标运算符 [ ]

下标运算符必须是成员函数。其语法规则如下:

1
2
3
4
5
6
7
8
9
10
// 不完全正确版本
class TypeName{
private:
// 略
public:
ReturnType & operator[] (int i)
{
// 略
}
}
代码中 ReturnType 代表返回类型,一般取决于类内部数据成员类型。以 C++ 提供的 vector 为例,vector<T>类型 的下标运算符返回类型为 T &。这样我们就可以通过下标运算符修改数据成员。

注意点如下:

  1. 参数类型:一般为整型,代表下标
  2. 返回值类型:一般返回引用类型
  3. 对于 const 类型数据,其下标运算符应该返回 const 引用类型。因此,在类中应该定义下标运算符的 const 和非 const 两个版本重载函数,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 正确版本
    class TypeName{
    private:
    // 略
    public:
    ReturnType & operator[] (int i) // 非 const
    {
    // 略
    }
    // const 类型重载
    const ReturnType & operator[] (int i) const
    {
    // 略
    }
    }

6. 重载递增和递减运算符 ++ --

递增递减运算符分前置和后置两个版本,它们一般应该定义为类的成员。前置与后置版本二者差别较大,下面以 ++ 运算符为例分别介绍。

6.1 前置版本递增/递减运算符

前置版本递增运算符重载语法规则如下:

1
2
3
4
5
6
7
8
class TypeName{
public:
TypeName & operator++ ()
{
// 具体实现
return *this;
}
};

注意点如下:

  1. 无参函数
  2. 返回值类型:引用类型,代码中 return *this 体现

6.2 后置版本递增/递减运算符

后置版本递增运算符重载语法规则如下:

1
2
3
4
5
6
7
8
class TypeName{
public:
TypeName operator++ (int)
{
// 具体实现
return 自增前的对象;
}
};

注意点如下:

  1. 参数类型: int ,不过,这个 int 放在函数形参列表只是为了区分前置与后置, int 形参在函数内部不需要用到,因此也不必给出标识符
  2. 返回值类型:普通类型,返回递增/递减前保存的临时对象
  3. 后置版本一般利用前置版本实现,示例代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class TypeName{
    public:
    TypeName & operator++ ()
    {
    // 具体实现
    return *this;
    }

    TypeName operator++ (int)
    {
    TypeName temp = *this;
    ++*this; // 调用前置版本++
    return temp; // 返回递增前保存的临时对象
    }
    };

7. 重载成员访问运算符 -> *

成员访问运算符包括两个:对于指针的 -> 以及对于对象的 *-> 必须是类的成员;* 通常是类的成员,但并非必须如此。它们的重载函数通常定义为 const 的,语法规则如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TypeName{
public:
ReturnType1 operator* () const
{
// 具体实现
// 可返回任意类型
}

ReturnType2 operator-> () const
{
// 具体实现
// 必须返回类的指针或者自定义了箭头运算符的某个类的对象
}
};
成员访问运算符重载一般用的比较少,这里仅做简要介绍。实际中需要用到时,可进一步参考相关资料,并根据类的需求和操作,定义合适的重载方法。

8. 重载函数调用运算符 ( )

函数调用运算符的重载相对比较特殊。理论上,我们可以重载任意多个函数调用运算符,只要保证这些重载之间不发生冲突即可。函数调用运算符必须是成员函数。定义了函数调用运算符的类的对象称为函数对象。举一个例子如下:

1
2
3
4
5
6
7
class TypeName{
public:
bool operator() (const int &a, const int &b)
{
return a > b;
}
};
定义了上面的类后,我们可以显式地调用函数:
1
2
TypeName obj;
bool b = obj(12, 15);
12 比 15 小,因此上述代码中 b 应该为 false。

不过,函数对象更为常用的是作为函数参数提供给标准库函数,典型的如 sort 函数:

1
2
3
4
5
6
int a[] = {1,2,3,5,7,8,9,22,343,0,-232,-23423};
int n = sizeof(a)/sizeof(int);
sort(a, a+n, TypeName());
for(int i = 0;i < n; ++i)
cout << a[i] << "\t";
cout << endl;
TypeName() 临时对象作为函数参数传递给 sort 函数,就能够实现自定义规则排序。上面代码将数组降序排序,而 sort 函数默认为升序排序。

值得一提的是,C++ 中提供的 lambda 表达式,实际上都转换为类,这个类重载了函数调用运算符,参数类型即为 lambda 表达式的参数类型。这样,调用 lambda 表达式就转换为调用函数对象的函数调用运算符。

9. 重载类型转换运算符

类型转换运算符的重载更为特殊,是类的一种特殊成员函数,其语法规则如下:

1
2
3
4
operator type() const
{
// 具体实现
}
其中, type 表示目标转换类型。

类型转换运算符一般很少使用,在实际编程中也应该谨慎使用。若定义不当,可能产生错误或者意料之外的结果。一般而言,类定义向 bool 类型的转换规则就足够了,而且该转换函数最好定义为 explicit 的。


以上就是本文全部内容。不过这些并不是对运算符重载的全面介绍,想了解更深入详细的知识,仍需阅读相关书籍。


一文读懂 C++ 运算符重载
https://arcsin2.cloud/2023/02/19/一文读懂-C-运算符重载/
作者
arcsin2
发布于
2023年2月19日
许可协议