🌟《C++从零开始》 系列,开始更新中…

六、运算符重载

在前面我们接触过函数重载,让我们可以使用多个同名函数,只要它们有唯一的函数签名(由函数参数类型、个数、函数名组成)可以让编译器区分即可。

在C++中,运算符被视为函数,自然也可以进行重载

认识下运算符这个特殊的函数。

运算符operator+ 作为函数,接收了两个int类型参数:

1
2
3
int x { 2 };
int y { 3 };
x+y;

重载函数operator+ ,又接收了两个double 类型参数:

1
2
3
double x { 2 };
double y { 3 };
x+y;

这都是C++为我们内置实现了operator+ 重载,用于C++基本类型之间运算。

我们也可以自定义重载自己的运算符函数

虽然C++ 中几乎所有的运算符我们都可以进行重载,但要注意以下规则:

  • 重载运算符中的至少一个操作数必须是用户定义的类型,比如重载operator+ 函数,一个参数是int,一个是double 是不行的;
  • 无法更改运算符支持的操作数数量,优先级也被保留。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
class myInt
{
private:
int i;
public:
myInt(int a):i(a){ }
int getValue(){return i;}
};
// 重载操作符形式
myInt operator+(myInt& i1,myInt& i2)
{
return myInt(i1.getValue()+i2.getValue());
}
// 普通函数
myInt add(myInt& i1,myInt& i2)
{
return myInt(i1.getValue()+i2.getValue());
}

普通函数也可以实现重载运算符所需的功能,重载运算符有什么好处

重载运算符可以让程序表达更直观:

1
2
3
4
5
6
7
8
9
int main()
{
myInt i1{1};
myInt i2{2};

myInt i3 = i1+i2; // i3.getValue()=3,更直观
myInt i4 = add(i1,i2); // i3.getValue()=3
return 0;
}

如果重载运算符并不能让你的程序更清晰,请谨慎使用

本章主要介绍:

  • 重载运算符的友元/普通/成员三种方式。
  • 特殊重载,如重载()、=介绍。

6.1 重载运算符的三种方式

重载运算符有三种不同的方式:

  • 友元函数方式
  • 普通函数方式
  • 成员函数方式

按照顺序我们依次来看下。

6.1.1 友元函数

重载友元初识

下面展示了重载operator+ ,其余操作符-/* 等类似。

注意到函数operator+ 是类MinMax 的友元函数,这样就可以使用MinMax的私有成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

class Cents
{
private:
int m_cents{};

public:
Cents(int cents): m_cents{ cents }{}
// 类中声明友元函数原型
friend Cents operator+(const Cents& c1, const Cents& c2)
};

// 友元函数定义
Cents operator+(const Cents& c1, const Cents& c2)
{
return { c1.m_cents + c2.m_cents };
}

int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum = cents1 + cents2 ;
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}

输出:

1
I have 14 cents.
更多例子:重载I/O运算符

下面是一个同时使用重载的 operator<<operator>> 的示例 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
include <iostream>

class Point
{
private:
double m_x{};
double m_y{};
double m_z{};

public:
Point(double x=0.0, double y=0.0, double z=0.0): m_x{x}, m_y{y}, m_z{z}{}
// 类型友元函数声明
friend std::ostream& operator<< (std::ostream& out, const Point& point);
friend std::istream& operator>> (std::istream& in, Point& point);
};

std::ostream& operator<< (std::ostream& out, const Point& point)
{
out << "Point(" << point.m_x << ", " << point.m_y << ", " << point.m_z << ')';
// 返回std::ostream对象out,这样就可以形成链式使用
// 像这样:out<<point1<<" " << point2
return out;
}
// 和前有点不一样,重载的参数类型不同
std::istream& operator>> (std::istream& in, Point& point)
{
in >> point.m_x;
in >> point.m_y;
in >> point.m_z;
// 同前返回std::istream对象in,形成链式
return in;
}

int main()
{
std::cout << "Enter a point: ";
Point point1;
Point point2;
// 链式输入
std::cin >> point1 >> point2;
// 链式输出
std::cout << "You entered: " << point1 << " " << point2<<'\n';
return 0;
}

输出:

1
2
3
4
[root@roy-cpp test]# ./test.out 
Enter a point: 1 2 3
4 5 6
You entered: Point(1, 2, 3) Point(4, 5, 6)

6.1.2 普通函数

使用友元函数虽然很方便,但也一定程度的破坏了类的封装性。因此我们建议尽量将重载运算符实现为普通函数。

下面是一个小例子。

注意到普通函数和实现为友元函数极为类似,除了友元函数需要在类中先声明函数原型 friend Cents operator+(const Cents&,const Cents&) ,其余没有区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
class Cents
{
private:
int m_cents{};

public:
Cents(int cents): m_cents{ cents }{}
// 访问私有成员的接口函数
int getCents() const { return m_cents; }
};
// 普通函数,非友元
Cents operator+(const Cents& c1, const Cents& c2)
{
return Cents{ c1.getCents() + c2.getCents() };
}

int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum = cents1 + cents2 ; // ok
return 0;
}

6.1.3 成员函数

重载成员初识

成员重载运算符和友元重载运算符很类似,但是也有些不同:

  • 重载的运算符被定义为成员而不是友元(例如,是Cents::operator+ 而不是operator+ );
  • 左边的类类型参数const Cents&被移除了,变成了隐含的 *this 对象。

看代码说话。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

class Cents
{
private:
int m_cents {};

public:
Cents(int cents): m_cents { cents } { }

// friend Cents operator+(const Cents &cents, int value);
// 对比普通函数声明:
// 1. 少了friend声明,本来就是成员函数可以访问私有成员没必要
// 2. 左参数被省略,隐含为*this对象
Cents operator+ (int value);
int getCents() const { return m_cents; }
};

// 对比普通函数多了Cents::(成员函数),多了左参数
Cents Cents::operator+ (int value)
{
// m_cents被视为this->m_cents
return Cents { m_cents + value };
}

int main()
{
Cents cents1 { 6 };
Cents cents2 { cents1 + 2 };
std::cout << "I have " << cents2.getCents() << " cents.\n";
return 0;
}

输出:

1
I have 8 cents.
重载一元函数

正 (+)、负 (-) 和逻辑非 (!) 运算符都是一元运算符,这意味着它们只对一个操作数进行操作,也就是只对应用它们的对象进行操作,所以通常一元运算符重载被实现为成员函数。

下面是个简单的例子,注意负运算符和减号运算符之间没有混淆,因为它们具有不同数量的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class Cents
{
private:
int m_cents {};

public:
Cents(int cents): m_cents(cents) {}
// 重载-负运算符,成员函数方式
Cents operator-() const;
int getCents() const { return m_cents; }
};
// 定义处
Cents Cents::operator-() const
{
return -m_cents;
}

int main()
{
const Cents nickle{ 5 };
std::cout << "A nickle of debt is worth " << (-nickle).getCents() << " cents\n";

return 0;
}

6.1.4 最佳实现:使用什么方式重载

下面是一些经验法则:

  • 普通函数或友元函数优先考虑普通函数,可避免封装性被破坏。

  • 使用成员函数

    • 在处理修改左操作数的二元运算符(例如 operator+=)时,通常首选成员函数版本,因为最右边的操作数成为一个显式参数,不会混淆谁(左参数)被修改;

      但是左参数类型必须要是当前类类型,如operator<<,它具有 ostream 类型的左操作数,此时按普通函数方式重载最好。

    • 一元运算符通常也作为成员函数重载,此时成员版本没有参数。

  • 使用普通函数

    • 在处理不修改左操作数的二元运算符(例如 operator+)时,普通或友元函数版本具有“对称”的额外好处,因为所有操作数都成为显式参数。

6.2 重载()运算符

到目前为止我们接触的运算符,参数类型虽然可以设置,但参数类型是固定的。

而重载括号运算符 operator() ,允许我们改变它的参数类型和数量

这有什么用?

operator() 一大用处是用来实现函子(函数对象)。相比普通函数,

  • 函子可以将任意数量的数据存储在成员变量,而不用在形参中定义避免了可能的值传递开销;
  • 函子可以保存结果和状态,提高代码灵活性;
  • 函数指针不能内联,函子可以,效率更高(没验证过)。

6.2.1 实现函子

请看下例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

class Accumulator
{
private:
int m_counter{ 0 };

public:
int operator() (int i);
int getCounter() { return m_counter;}
};
// 1.函子数据成员m_counter保存在类中,而不是形参中定义
int Accumulator::operator() (int i)
{
return (m_counter += i);
}
int main()
{
Accumulator acc{};
// 函子就像函数一样使用
std::cout << acc(10) << '\n'; // 10
std::cout << acc(20) << '\n'; // 30
// 2.函子保存了结果状态
acc.getCounter(); // 30
return 0;
}

函子更多的是应用在关联容器和STL,其中许多算法都是可以用函数或函数对象自定义比较器的

请看下例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <algorithm>
#include <array>
#include <iostream>

class MyGreater
{
public:
bool operator()(int a, int b)
{
return (a > b);
}
};

bool myLesser(int a, int b)
{
return (a < b);
}

void print(const std::array<int,6>& arr)
{
for (int i : arr)
{
std::cout << i << ' ';
}
std::cout << '\n';
}

int main()
{
std::array<int,6> arr = { 13, 90, 99, 5, 40, 80 };
// std::sort第三个指针接受一个函数指针
// 普通函数,此时myLesser是函数地址
std::sort(arr.begin(), arr.end(), myLesser);
print(arr);
// 函子,此时MyGreater()也是函数地址,不要()迷惑
std::sort(arr.begin(), arr.end(), MyGreater());
print(arr);
return 0;
}

输出:

1
2
3
4
[root@roy-cpp test]# g++ -std=c++11 test.cpp -o test.out
[root@roy-cpp test]# ./test.out
5 13 40 80 90 99
99 90 80 40 13 5

6.3 重载=运算符

6.3.1 区分复制构造函数

复制构造函数和赋值运算符的用途几乎相同——都是将一个对象复制到另一个对象

但它们最核心的区别在于作用的时机不同:

  • 复制构造函数:只要对象在声明时用了另一个对象(可能是编译器创建的匿名对象)进行初始化,就会触发复制构造函数,也就是进行复制初始化
  • 赋值运算符:不是在对象声明时,而是一个已存在的对象被另一个对象重新赋值时 ,就会触发赋值运算符函数。

我们通过例子来理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date() = default;
Date(int day,int month=1,int year=2022)
{
std::cout<< "构造函数被调用"<<std::endl;
m_year=year;m_month=month;m_day=day;
};
Date(Date const& tmp): m_year(tmp.m_year),m_month(tmp.m_month),m_day(tmp.m_day)
{
std::cout<< "复制构造函数被调用"<<std::endl;
}
Date& operator=(const Date& date)
{
std::cout<< "oprator=被调用"<<std::endl;
m_year=date.m_year; m_month=date.m_year; m_day=date.m_year;
}
};

主函数中:

1
2
3
4
5
6
7
int main()
{
// 都是复制初始化
Date date1 = 25; // 1
Date date2 = Date{25}; // 2
Date date3 = date2; // 3
}
  • 1、2在对象date1、date2声明时用匿名对象进行初始化(编译器要使得= 左右操作类型一致,分别在=右侧创建了Date匿名对象,然后调用复制构造函数来初始化date1、date2)。
  • 3处date3也是声明时使用了其它对象(date2)进行初始化,所以也会调用复制构造函数。

但是1、2处会被编译器优化使用普通构造函数初始化,所以最后输出:

1
2
3
构造函数被调用
构造函数被调用
复制构造函数被调用

我们再来看看调用赋值运算符= 的情况:

1
2
3
4
5
6
7
int main()
{
Date date1 = 25; // 1
Date date2 = Date{25}; // 2
date2 = 31; // 3
date2 = date1; // 4
}

输出:

1
2
3
4
构造函数被调用
构造函数被调用
oprator=被调用
oprator=被调用

也就是3、4处都是调用了赋值运算符= 进行赋值(不是初始化):

  • 3处,date2不是声明时进行初始化,而是使用编译器生成的匿名对象进行重新赋值
  • 4处,同理,date2不是声明时进行初始化 ,使用date1进行重新赋值

它们都符合前文所述:“而是一个已存在的对象被另一个对象重新赋值时 ,就会触发赋值运算符函数” 。

更新记录

2022-01-29 :更新笔记

  1. 第一次更新

参考资料


  1. 1.C++STL中的函数对象 :https://blog.csdn.net/CV_Jason/article/details/83899253