1. 运算符重载的基本概念
在
中预定义了许多运算符,其只能用于基本数据类型的运算,如整型、浮点型、字符型、布尔类型等。运算符重载就是使得基本运算符可以作用了非基本类型,如类的对象等。而对于运算符重在的基本需求是:如在数学上,两个复数是可以直接相加减的。有时我们希望在
中完成这项功能,即complex_a+complex_b
。直接使用上述式子是不行的,我们要为加号赋予新的功能,即
中的运算符重载。运算符重载,就是对已有的运算符赋予多重的含义,使同一运算符作用于不同类型的数据时导致不同类型的行为。
运算符重载的实质就是函数重载,可以将其定义为普通函数,也可以定义为类的成员函数。而使用重载运算符的过程就是对运算符函数的调用,运算符的操作数实质上是函数的参数。同时,运算符可以被多次重载(参考函数重载),然后根据实参的类型决定调用运算符的某种具体功能。下面以例子来说明运算符重载,运算符重载通过关键字operator
实现,其形式为:
返回值 operator 运算符 (形参表) {
...
}
如果我们把以上形式的operator 运算符
当作函数名,也可以看出运算符重载的实质就是函数重载。下面是运算符重载关于复数类的例子:
class Complex {
public:
// 复数的实部和虚部
double real, imag;
// 构造函数使用初始化列表形式
Complex(double r = 0.0, double i = 0.0) :real(r), imag(i) {}
// "-"号的重载,定义为类的成员函数
Complex operator-(const Complex& c);
};
Complex Complex::operator-(const Complex& c) { // 只有一个参数
// 返回的是类的对象,该对象的实部/虚部是调用该函数的对象对象实部/虚部的差
return Complex(real - c.real, imag - c.imag); // 返回临时对象
}
Complex operator+(const Complex& a, const Complex& b) { // 有两个参数
// 返回的是类的对象,该对象的实部/虚部是两个形参实部/虚部的和
return Complex(a.real + b.real, a.imag + b.imag); // 返回临时对象
}
由上述程序我们可以观察到,当运算符减号重载为类的成员函数时,它只有一个参数,即运算符目数减一;当运算符加号重载为普通函数时,它有两个参数,即运算符目数。我们来使用上述重载后的运算符:
Complex a(1, 1), b(2, 2), c;
c = a + b; // 等价于c=operator+(a,b)
cout << c.real << c.imag << endl; // 33
cout << (a - b).real << (a - b).imag << endl; // -1-1,等价于a.operator-(b)
当运算符重载为类的成员函数时,它需要使用一个类的对象去调用,所以对于双目运算符减号来说,通过调用运算符的对象以及实参列表即可实现;当运算符重载为普通函数时由于可以直接调用,我们就必须指明参数双目运算符加号的两个具体对象。
2. 赋值运算符的重载
赋值运算符的初衷是我们希望赋值运算符的两边类型可以不匹配,如把一个整型数值赋值给类的对象等。需要注意的是,在
中,赋值运算符只能重载为类的成员函数。即赋值运算符不能定义为普通函数、类的静态成员函数、友元函数等,而只能供类的对象调用。其它的如( ) [ ] ->
等运算符也有如上规定。这里涉及
指针的相关内容,上述四类运算符不能重载为全局/友元函数的主要目的是防止出现修改不能被修改的值的现象,如常量等。
下面来看一个关于赋值运算符重载的例子。
class String {
private:
char* str;
public:
// 无参构造函数为char分配空间,并写入0
// 即初始化后str指向一个空字符串
String() :str(new char[1]) {
str[0] = 0;
}
// 返回str的值
const char* c_str() {
return str;
}
// 对赋值运算符重载
String& operator=(const char* s);
// 析构函数
~String() {
delete[]str;
}
};
// 重载"="使得,obj="hello"能够成立
String& String::operator=(const char* s) {
delete[]str; // 释放调用对象的空间
str = new char[strlen(s) + 1]; // 重新分配空间,包括结束符
strcpy(str, s); // 调用函数给str赋值
return *this; // this表示返回对象自身的引用
}
上述重载赋值运算符的过程是:首先释放掉调用对象的空间,并重新分配一个参数大小的空间加上结束符'\0'
;然后调用strcpy
将参数内容赋值给对象;最后利用this
指针返回对象本身。下面是重载后的赋值运算符的使用。
String s;
s = "Hello Word"; // 等价于s.operator=("Hello World");
cout << s.c_str() << endl; // 输出"Hello World"
String s_ = "Hello"; // 出错
// 报错内容为:不存在从"const char[6]"到"String"的适当构造函数
对于上述语句出错原因的分析:String s_ = "Hello"
相当于初始化语句,而没有调用重载后的赋值运算符,而对象的初始化需要调用构造函数,上述程序没有与之对应的构造函数形式,所以会报错。对于如上程序,我们考虑一种情况:String s1, s2;
,s1="this";
,s2="that";
。首先声明String
类的两个对象,然后使用重载后的赋值运算符为两个对象赋值。即达到如下效果:
现在执行语句s1=s2
,由等号两边的类型可以判断,这里没有使用重载后的赋值运算符。而在
中字符串间可以使用等号赋值,即上条语句达到如下效果:
根据以上图示,存放this
的存储空间无法再被重新指向,即产生内存垃圾。此外,如果对象s1
消亡,则会调用析构函数释放that
的存储空间。而此时s2.str
将会变成野指针,对象s2
再消亡时调用析构函数就会出错。再如执行s1="when"
也会产生上述问题。这里的解决方案就是对赋值运算符的函数进行重载,如下:
String& String::operator=(const String& s) {
if (this == &s) { // 防止出现对象自己给自己赋值时造成错误,即s=s
return *this;
}
delete[]str; // 释放调用对象的空间
str = new char[strlen(s.str) + 1]; // 重新分配空间,包括结束符
strcpy(str, s.str); // 调用函数给str赋值
return *this; // this表示返回对象自身的引用
}
这篇文章介绍复制构造函数的时候也说明过这个问题,如String s1(s2);
,具体的解决方案就是定义复制构造函数,如下:
String::String(String& s) {
str = new char[strlen(s.str) + 1];
strcpy(str, s.str);
}
最后,为了保留运算符原本的特性,我们将赋值运算符重载函数的返回值类型定义为String
类的引用,即String&
。
3. 运算符重载为友元函数
有时候,我们希望在当前类中重载的运算符在其他类里或类外也能被使用,一种选择是将运算符重载函数定义为全局函数,但全局函数又不能说明其和类间的关系(参考这里)和访问类的私有成员;另一种方案就是将重载函数定义为类的友元函数,如:
class Complex {
double real, imag;
public:
Complex(double r, double i) :real(r), imag(i) {};
Complex operator+(double r); // "+"重载,使其完成功能c=c+5
friend Complex operator+(double r, const Complex& c); // "+"重载,使其完成功能c=5+c
};
在类外实现运算符的重载:
// 类的成员函数,完成c=c+5的重载
Complex Complex::operator+(double r)
{
return Complex(real + r, imag);
}
// 类的友元函数,完成c=5+c的重载
Complex operator+(double r, const Complex& c)
{
return Complex(c.real + r, c.imag);
}
4. 利用运算符重载实现可变长数组
由于数组的可快速访问性,它是 中常用的数据结构。但是在使用数组时,我们首先需要定义其大小,而有时候我们并不明确所需的大小是多少。一种选择是使用动态内存分配的方法,但分配的空间我们需要依次手动释放;另一种方案就是本节介绍的利用运算符重载的相关知识实现的可变长数组(当然, 的 中提供的向量就是一种可变长数组)。首先,我们来明确可变长数组需要实现的功能有哪些。
int main() {
CArray a; // 开始数组里面是空的,#1
for (int i = 0; i < 5; ++i) {
a.push_back(i); // 往数组里面添加元素,#2
}
CArray a2, a3;
a2 = a; // 将a中的内容赋值给a2,#3
// 打印a中的元素,根据[ ]取对应索引的元素,#4
for (int i = 0; i < a.length(); ++i) {
cout << a[i] << " ";
}
a2 = a3; // a2是空的
for (int i = 0; i < a2.length(); ++i) {
cout << a2[i] << " ";
}
cout << endl;
a[3] = 100; // 将a[3]赋值为100
CArray a4(a); // 使用对象a初始化a4,#5
for (int i = 0; i < a4.length(); ++i) {
cout << a4[i] << " ";
}
return 0;
}
程序的期望输出结果是:
0 1 2 3 4
0 1 2 100 4
上面主函数里面基本展示了我们对于可变长数组的基本要求,现在根据上述标出的重要的
条语句来实现CArray
类。
首先,根据第一条语句CArray a
声明一个对象,同时开始时数组为空。我们定义类的构造函数,实现如下:
// 初始化列表,根据初始化对象时传入的参数为ptr赋值,size表示数组的长度,
// ptr用于动态分配内存空间
CArray::CArray(int s):size(s)
{
if (s == 0) { // 产生空数组
ptr = NULL;
}
else
{ // 分配大小为s的空间
ptr = new int[s];
}
}
构造函数根据传入的整型值确定需要分配的空间的长度,并使用初始化列表初始化成员变量size
。
其次,根据第二条语句,对象的成员函数push_back
完成在数组尾部添加一个元素的功能,实现如下:
// 在数组尾部插入一个元素
void CArray::push_back(int v)
{
if (ptr) { // 原数组不为空
int* tempPtr = new int[size + 1]; // 重新分配空间
memcpy(tempPtr, ptr, sizeof(int) * size); // 复制数组内容
delete[]ptr; // 释放原来的空间
ptr = tempPtr;
}
else
{ // 原数组为空
ptr = new int[1]; // 分配1个长度
}
ptr[size++] = v; // 添加元素
}
push_back
函数的实现方式是:首先根据原数组是否为空进行不同处理。如果不为空,我们将其内容赋值到一个临时空间,然后重新分配一个比元素数组长度多
的空间用于存放添加的值;如果原数组为空,我们仅分配一个空间用于存放添加值。
其次,根据第三条语句,我们需要完成赋值运算符的重载以完成对象间的赋值,实现如下:
// 等号完成的功能是使等号左边的对象存放的数组内容和大小都与右边一样
CArray& CArray::operator=(const CArray& a)
{
if (ptr == a.ptr) {
return *this; // 访问出现a=a的情况
}
if (a.ptr == NULL) { // 如果a是空数组,将空赋值给左边,则左边也为空
if (ptr) { // 如果被赋值的对象不为空,先释放空间
delete[]ptr;
}
ptr = NULL;
size = 0;
return *this;
}
// 如果被赋值对象的空间大于等号右边的空间,我们可以不用新分配空间而直接赋值
if (size < a.size) {
if (ptr) {
delete[]ptr;
}
ptr = new int[a.size]; // 分配空间
}
// 将a.ptr指向的内容复制到ptr指向的内容,共复制sizeof(int) * a.size个字节
memcpy(ptr, a.ptr, sizeof(int) * a.size);
size = a.size; // 给数组长度变量赋值
return *this;
}
首先,加一个条件语句防止出现a=a
的语句而产生错误。如果等号右边为空,我们需要根据等号左边是否为空来执行不同操作,即如果等号左边数组不为空,我们首先需要释放掉。这里,为了减少运算,如果等号左边所指的内存空间本来就已经大于等号右边所指的内存空间,我们可以不同重新分配空间,而就利用原来的空间。
其次,根据第四条语句,我们需要通过a[i]
的方式访问数组中的某个值,即我们需要重载[ ]
。考虑到可能出现n=a[i]
或a[i]=n
的情况,而赋值运算符左边必须是可修改的左值,所以该运算符重载的返回类型我们必须定义为引用类型,即整型的应用。
// 对中括号进行重载,使其完成使用下标对数组访问的功能,i即为下标
// 根据i访问数组中的指定元素,而数组中的元素在由ptr动态分配的内存空间,所以我们返回ptr[i];
// 而对数组使用索引取值时返回为整型的引用,这里使用引用的目的是完成a[i]=100等语句的功能
int& operator[](int i) {
return ptr[i];
}
其次,根据第五条语句需要使用一个对象初始化另一个对象,根据上述内容的介绍,我们需要完成对象的深复制,即自定义的复制构造函数。这和赋值运算符重载的内容相似。实现如下:
// 完成深复制
CArray::CArray(CArray& a)
{
if (!a.ptr) { // 如果a为空,则复制后的数组也为空
ptr = NULL;
size = 0;
return;
}
ptr = new int[a.size]; // 新分配一片与a等大的空间
// 将a.ptr指向的内容复制到ptr指向的内容,共复制sizeof(int) * a.size个字节
memcpy(ptr, a.ptr, sizeof(int) * a.size);
size = a.size; // 给数组长度变量赋值
}
最后是析构函数,用于释放动态分配的内存空间。实现如下:
CArray::~CArray() // 析构函数释放动态分配的空间
{
if (ptr) { // 数组不为空
delete[]ptr;
}
}
上述可变长数组类的实现,其实是实现一个类似于
的
中提供的向量vector
,
的
中包含许多简便高效的数据结构,后面会作介绍。
5. 总结
的运算符重载是其面向对象编程中的重要一环,通过对运算符重载以实现不同功能,不仅丰富了原运算符的功能,同时也对任何数据类型间的运算变为可能。值得注意的是,一个良好的重载运算符应保留原运算符的某些特性,如a=b=c
的情况。本文首先介绍运算重载的基本概念,以及一种重要的运算符——赋值运算符——的重载、将友元应用于运算符重载等。最后根据运算符重载的相关内容实现一个可变长的数组。下一篇文章将介绍其他几种特殊的运算符重载的实现。
参考
- 北京大学公开课:程序设计与算法(三)C++面向对象程序设计.
附录:C++中运算符的优先级
附录部分顺便介绍一下 中的运算符及其优先级。
优先级 | 运算符 | 功能 | 结合顺序 |
---|---|---|---|
1 | :: | 作用域运算符,如在类外实现成员函数 | 自左向右 |
2 | |||
++和-- | 后自加和后自减,如a++ |
自左向右 | |
( ) | 函数调用,如func() |
||
[ ] | 下标访问,如a[0] |
||
.和-> | 成员访问,如c.value |
||
3 | |||
++和-- | 前自加和前自减,如++a |
自右向左 | |
~ ! | 逐位非和逻辑非,如~a 和!a |
||
+和- | 一元加和减,如+a 和+b |
||
&和* | 引用和取地址,如&a 和*a |
||
new和delete | 分配空间和释放空间 | ||
sizeof | 取大小,如sizeof(int) |
||
(type) | 类型转换,如(int)a |
||
4 | .*和->* | 成员指针,如c.*a |
自左向右 |
5 | * / % | 乘法,除法和取余 | 自左向右 |
6 | +和- | 加法和减法,如a-b |
自左向右 |
7 | <<和>> | 逐位左移和逐位右移,如a<<1 |
自左向右 |
8 | < > <= >= | 比较运算符 | 自左向右 |
9 | ==和!= | 等于和不等于 | 自左向右 |
10 | & | 逐位与,如a&b |
自左向右 |
11 | ^ | 逐位异或,如a^b |
自左向右 |
12 | | | 逐位或,如a|b |
自左向右 |
13 | && | 逻辑与,如a&&b |
自左向右 |
14 | || | 逻辑或,如a||b |
自左向右 |
15 | |||
= *= /= %= += -= >= <<= &= ^= |= | 赋值运算符,如a=b |
自右向左 | |
?: | 三目条件运算符,如flag?1:0 |
||
16 | , | 逗号运算符,如int a,b |
自左向右 |
上述表格内容来自这里。