1. 写在前面
c++在线编译工具,可快速进行实验: https://www.bejson.com/runcode/cpp920/
这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。
和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉
资料参考主要是C语言中文网和光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象。 关于更多的细节,还是建议看这两个教程。
今天这篇文章算是小清新,整理点稍微简单的内容,运算符重载,重载呢, 就是赋予新的含义,函数重载,可以让一个函数名有多种功能,不同情况下进行不同操作, 运算符重载类比过来, 同一个运算符可以有不同的功能。而这就是这篇文章内容啦。
主要内容:
- C++运算符重载初识
- C++运算符重载规则
- C++重载运算符到底应该以成员函数还是全局函数(右元函数)形式重载?
- C++重载各种运算符示例
- 小总
Ok, let’s go!
2. C++运算符重载初识
上面聊到,运算符重载可以让相同的运算符有不同的功能,比如"+" 这个运算符,可以对不同类型的数据进行加法操作, 再比如"<<" 既是位移运算符,又可以配合cout做输出,这其实背后是C++做好的一些重载。
那么, 如果我们想自己重载运算符, 让符号再实现一波新功能,应该怎么做呢? 比如让"+" 实现复数的加法运算,而不仅仅是int或者float运算, how do it?
两种方式。
2.1 类内重载运算符
就像上面的这个需求, + 实现复数之间的运算,这时候,就可以先定义一个复数类,然后把重载运算符的实现逻辑作为类的成员出现,like this:
class complex{
public:
complex();
complex(double real, double imag);
public:
complex operator+(const complex &A) const;
void display() const;
private:
double m_real;
double m_imag;
};
complex::complex(): m_real(0.0), m_imag(0.0){ }
complex::complex(double real, double imag): m_real(real), m_imag(imag){ }
complex complex::operator+(const complex &A) const{
complex B;
B.m_real = this->m_real + A.m_real;
B.m_imag = this->m_imag + A.m_imag;
return B;
}
void complex::display() const{
cout<<m_real<<" + "<<m_imag<<"i"<<endl;
}
int main(){
complex c1(4.3, 5.8);
complex c2(2.4, 3.7);
complex c3;
c3 = c1 + c2;
c3.display();
return 0;
}
运算符重载其实就是定义一个函数,在函数体内实现想要的功能, 当用到该运算符时,编译器会自动调用这个函数,即运算符重载是通过函数实现的,本质上是函数重载。
格式:
返回值类型 operator 运算符名称 (形参表列){
}
operator 是关键字,专门用于定义重载运算符的函数。 可以将operator 运算符名称 这一部分看做函数名,即operator + 。运算符重载函数除了函数名有特定方式,其他地方和普通函数没啥区别。
上面例子的过程倒是感觉有点意思, 首先,complex类中重载了运算符+ ,该重载只对complex对象有效。 当执行c3=c1+c2 的时候,由于+ 具有左结合性,编译器检测到+ 号左边是一个complex对象,就会调用该对象的成员函数operator +() , 进行下面的转换:
c3 = c1.operator+(c2);
上面运算符重载的更简洁方式:
complex complex::operator+(const complex &A)const{
return complex(this->m_real + A.m_real, this->m_imag + A.m_imag);
}
这,就是运算符重载背后的故事!
2.2 全局范围内
运算符重载函数不仅可以作为类的成员函数, 还可以作为全局函数。上面的代码如果改写成全局版的, 应该是下面这样:
class complex{
public:
complex();
complex(double real, double imag);
public:
void display() const;
friend complex operator+(const complex &A, const complex &B);
private:
double m_real;
double m_imag;
};
complex operator+(const complex &A, const complex &B);
complex operator+(const complex &A, const complex &B){
complex C;
C.m_real = A.m_real + B.m_real;
C.m_imag = A.m_imag + B.m_imag;
return C;
}
此时,如果执行到c3=c1+c2 语句时,编译器检测到+ 号两边都是complex对象,就会转换为下面的函数调用:
c3 = operator+(c1, c2);
3. C++运算符重载时的规则
运算符时通过函数重载实现的, 但有几点注意:
- 并不是所有的运算符都可以重载, 能够重载的运算符包括:
+ - * / % ^ & | ~ ! = < > += -= *= /= %= ^= &= |= << >> <<= >>= == != <= >= && || ++ -- , ->* -> () [] new new[] delete delete[] , 自增自减运算符等。 而长度运算符sizeof , 条件运算符:? , 成员选择符. 和域解析运算符:: 不能被重载 - 重载不能改变运算符的优先级和结合性。
- 重载不会改变运算符的用法,原有有几个操作数,操作数在左边还是右边,这些都不会改变。 例如
~ 号右边只有一个操作数, + 号总是出现在两个操作数之间,重载后也必须如此。 - 运算符重载函数不能有默认的参数,否则就改变了运算符操作数的个数,这显然错误
- 运算符重载函数既可以作为类的成员函数,也可以作为全局函数
- 将运算符重载函数作为类的成员函数时,二元运算符的参数只有一个,一元运算符不需要参数。 之所以少一个参数,是因为这个参数是隐含的。比如
complex operator+(const complex & A) const; ,当执行c3=c1+c2 时,会被转换为c3=c1.operator+(c2) ; - 将运算符重载函数作为全局函数时,二元操作符就需要两个参数,一元操作符需要一个参数,而且其中必须有一个参数是对象,好让编译器区分是程序员自定义运算符,防止程序员修改用于内置类型的运算符性质。比如下面这种操作是不允许的:
int operator + (int a,int b){
return (a-b);
}
+ 号原来是对两个数相加,现在企图通过重载使它的作用改为两个数相减, 如果允许这样重载的话,那么表达式4+3 的结果是 7 还是 1 呢?显然,这是绝对禁止的。如果有两个参数, 这两个参数可以都是对象,也可以一个是对象,一个是C++内置类型的数据:complex operator+(int a, complex &c){
return complex(a+c.real, c.imag);
}
它的作用是使一个整数和一个复数相加。
另外,将运算符重载函数作为全局函数时,一般都需要在类中将该函数声明为友元函数。 原因很简单,该函数大部分情况下,需要使用类的private成员。 - 箭头运算符
-> , 下标运算符[] ,函数调用运算符() ,赋值运算符= 只能以成员函数的形式重载。
那么,此时可能有的一个问题,既然重载运算符,有全局函数和成员函数两种方式, 那么我们当遇到运算符重载需求的时候,应该用哪一种方式呢? 会有什么不同?
4. 成员函数 VS 全局函数重载运算符
不同运算符应该采用不同的重载方式, 不能一股脑都写作成员函数或者全局函数, 比如+, -, *, /, ==, != 这种, 一般建议是全局函数的形式, 而如果是+=, -=, *=, /= 这种, 一般建议是成员函数的形式, But, why?
这里会涉及到一个概念叫做转换构造函数, 关于这个概念的更深层原理, 后面会整理到, 这里只需要先看一个例子:
class Complex{
public:
Complex(): m_real(0.0), m_imag(0.0){}
Complex(double real, double imag): m_real(real), m_imag(imag){}
Complex(double real): m_real(real), m_imag(0.0){}
public:
friend Complex operator+(const Complex &c1, const Complex &c2);
public:
double real() const{return m_real;}
double imag() const{return m_imag;}
private:
double m_real;
double m_imag;
};
Complex operator+(const Complex &c1, const Complex &c2){
return Complex(c1.m_real + c2.m_real, c1.m_imag + c2.m_imag);
}
int main()
{
Complex c1(25, 35);
Complex c2 = c1 + 15.6;
Complex c3 = 28.3 + c1;
cout << c2.real() << "+" << c2.imag() << "i" << endl;
cout << c3.real() << "+" << c3.imag() << "i" << endl;
return 0;
}
主函数里面的2,3行代码,说明Complex类型可以和double类型相加, 这很奇怪,因为并没有针对这两个类型重载+ , 那么这是怎么做到的呢?
实际上,编译器在检测到Complex和double相加时,会尝试先将double转换成Complex或者反过来把Complex转成double,然后才能相加,但如果转换失败或者都转换成功, 就会报错。
在上面例子里面, 编译器会先通过构造函数Complex(double real) , 将double转换为Complex,再调用重载过的+ 进行计算, 整个过程类似下面这样:
即,小数被转换成了匿名的Complex对象。 这个转换过程中, 构造函数Complex(double real) , 起到了至关重要的作用, 如果没有它, 转换就会失败, Complex也不能和double相加。
Complex(double real), 在作为普通构造函数的同时,还能将double类型转换为Complex类型,集合了"构造函数"和"类型转换"的功能, 所以被称为转换构造函数。 换句话说,转换构造函数用来将其他类型(bool, int, double等基本类型, 数组,指针,结构体,类等构造类型)转换为当前类型。
4.1 为什么要用全局函数的形式重载+
上面例子中,定义的operator + 是一个全局函数(友元函数), 而不是成员函数, 这样做是为了保证+ 运算符的操作数能够被对称的处理。换句话说,小数(double类型)在+ 左边和右边都是正确的。
如果将operator + 定义为成员函数, 根据"+ 运算符具有左结合性"原则, Complex c2=c1+15.6 会被转换成下面形式:
Complex c2 = c1.operator+(Complex(15.6));
这就是通过对象调用成员函数, 这是正确的。 但是对于Complex c3=28.2+c1 ; 编译器尝试转成下面的形式:
Complex c3 = (28.2).operator+(c1);
这个很显然是错误的,double类型没有以成员函数的形式重载+ 。
也就是说,以成员函数的形式重载+ , 只能计算c1+15.6 , 不能计算28.2+c1 , 这个是不对称的。
那为啥不能把28.2先转成Complex,然后再相加啊,类似这样:
Complex c3 = Complex(28.2).operator+(c1);
为什么不转呢? 这是因为C++只会对成员函数的参数进行类型转换,而不会对调用成员函数的对象进行转换。 比如:
obj.func(params);
编译器不会尝试对obj进行类型转换,它有func() 函数就调用,没有就报错。 而对于实参params, 编译器会"拼命的"将它转换为形参的类型。
4.2 为什么要用成员函数的形式重载+=
运算符重载的初衷,给类添加新的功能, 方便类的运算,它作为类的成员函数式理所当然的,是首选的。 But, 类的成员函数不能对称的处理数据,程序员必须在(参与运算)所有类型的内部都重载当前的运算符。 比如上面这个例子, 必须在Complex和double内部都重载+ 运算符,这样做不但会增加运算符重载的数目,还要在许多地方修改代码,显然不是我们希望的,所以C++为了折中, 允许以全局函数(友元函数)的方式重载运算符。
采用全局函数能使我们定义参数具有逻辑对称性的运算符,而与此相应的, 把运算符定义为成员函数能够保证在调用时,对第一个(左边)运算对象不出现类型转换。
所以呢,有一部分运算符重载既可以是成员函数也可以是全局函数,但应该优先考虑成员函数,这样更符合运算符重载的初衷。 而有一些运算符必须是全局运算符, 才能保证参数的对称性。 除了C++规定的几个运算符必须以成员函数形成重载之外(->, [], (), = ), 其他的没有必须强制。
5. C++重载各种运算符实例
5.1 重载数学运算符
这里玩的一个例子,就是对于复数进行完整的四则运算(+,-,*,/ )以及+=,-=,*=,/= ,一个原因是为了强化一下上面的理解,另外一个就是提高下动手能力,毕竟,talk is cheap! 可以在上面给出的网址里面进行操练起来了, 哈哈。
class Complex{
public:
Complex(double real=0.0, double imag=0.0): m_real(real), m_imag(imag){}
friend Complex operator+(const Complex &c1, const Complex &c2);
friend Complex operator-(const Complex &c1, const Complex &c2);
friend Complex operator*(const Complex &c1, const Complex &c2);
friend Complex operator/(const Complex &c1, const Complex &c2);
friend bool operator==(const Complex &c1, const Complex &c2);
Complex & operator+=(const Complex &c);
double real() const {return m_real;}
double imag() const {return m_imag;}
private:
double m_real;
double m_imag;
};
Complex operator+(const Complex &c1, const Complex &c2){return Complex(c1.m_real + c2.m_real, c1.m_imag + c2.m_imag);}
Complex operator-(const Complex &c1, const Complex &c2){return Complex(c1.m_real - c2.m_real, c1.m_imag - c2.m_imag);}
Complex operator*(const Complex &c1, const Complex &c2){return Complex(c1.m_real * c2.m_real - c1.m_imag * c2.m_imag, c1.m_imag * c2.m_imag + c1.m_real * c2.m_imag);}
Complex operator/(const Complex &c1, const Complex &c2){return Complex((c1.m_real*c2.m_real + c1.m_imag*c2.m_imag) / (pow(c2.m_real, 2) + pow(c2.m_imag,2)), (c1.m_imag*c2.m_real - c1.m_real*c2.m_imag) / (pow(c2.m_real, 2) + pow(c2.m_imag, 2)));}
bool operator==(const Complex &c1, const Complex &c2){return ((c1.m_real==c2.m_real) && (c1.m_imag==c2.m_imag))? true: false;}
Complex & Complex::operator+=(const Complex &c){
this->m_real += c.m_real;
this->m_imag += c.m_imag;
return *this;
}
int main()
{
Complex c1(25, 35);
Complex c2(10, 20);
Complex c3 = c1 + c2;
cout << c3.real() << " " << c3.imag() << endl;
return 0;
}
这个例子的演示, 主要是说明, 需要保留对称性的运算符+,-,*,/,== 都用全局函数的形成重载,而不需要保留对称性的,优先考虑成员函数。
5.2 重载>> 和 <<
C++中,标准库已经对左移运算<<和右移运算>>分别进行重载,使其能够用于不同数据的输入和输出,但输入和输出对象只能是C++的内置类型和标准库所包含的类类型。
如果我们自己定义了一种新数据类型,需要用输入输出运算符处理,就必须重载, 这里显示下, 对<< 和>> 重载,来输出上面定义的Complex类。当然,这个在C++标准库已经提供了相关运算。
这个例子比较简单,就是输入两个复数, 输出它们的和。
class Complex{
public:
Complex(double real=0.0, double imag=0.0): m_real(real), m_imag(imag){}
friend Complex operator+(const Complex &c1, const Complex &c2);
friend istream & operator >>(istream & in, Complex &A);
friend ostream & operator <<(ostream & out, Complex &A);
private:
double m_real;
double m_imag;
};
Complex operator+(const Complex &c1, const Complex &c2){return Complex(c1.m_real + c2.m_real, c1.m_imag + c2.m_imag);}
istream & operator >>(istream & in, Complex & A){
in >> A.m_real >> A.m_imag;
return in;
}
ostream & operator <<(ostream & out, Complex & A){
out << A.m_real << "+" << A.m_imag << "i";
return out;
}
int main()
{
Complex c1, c2;
cin >> c1 >> c2;
Complex c3 = c1 + c2;
cout << c3 << endl;
return 0;
}
5.3 重载[]
C++规定, 下标运算符[] 必须以成员函数形式重载。该重载函数在类中声明如下:
返回值类型 & operator[ ] (参数);
或者
const 返回值类型 & operator[ ] (参数) const;
使用第一种方式声明, [] 不仅可访问元素,还可以修改元素, 而用第二种声明方式,[] 只能访问而不能修改对象。
实际开发中,应该同时提供上面两种形式,这样做为了适应const对象,因为用过const对象只能调用const成员函数,如果不提供第二种形式,将无法访问const对象的任何元素。
下面一个具体例子演示如何重载[] 。我们知道,有些较老的编译器不支持变长数组,例如 VC6.0、VS2010 等,这有时候会给编程带来不便,下面我们通过自定义的 Array 类来实现变长数组。
class Array{
public:
Array(int length=0);
~Array();
int & operator[](int i);
const int & operator[](int i) const;
int length() const {return m_length;}
void display() const;
private:
int m_length;
int *m_p;
};
Array::Array(int length):m_length(length){
if (length == 0){
m_p = NULL;
}else{
m_p = new int[length];
}
}
Array::~Array(){ delete[] m_p; }
int& Array::operator[](int i){
return m_p[i];
}
const int & Array::operator[](int i) const{
return m_p[i];
}
void Array::display() const{
for (int i = 0; i < m_length; i++){
if (i == m_length - 1){
cout << m_p[i] << endl;
}else{
cout << m_p[i] << " ";
}
}
}
int main()
{
int n;
cin >> n;
Array A(n);
for (int i = 0, len = A.length(); i < len; i++){
A[i] = i * 5;
}
A.display();
const Array B(n);
cout << B[n-1] << endl;
return 0;
}
重载[] 运算符之后, 表达式arr[i] 被转成了arr.operator[](i) ;
B是const对象,如果Array类没有提供const版本的operator[], 最后B[n-1]这里会出错,因为它试图调用非const版本的operator[], 虽然没有修改对象,但编译器看到const对象调用非const成员函数, 编译器就认为会修改对象,不允许。注释掉看下:
5.3 重载++ 和--
自增++和自减–都是一元运算符,前置和后置形式都可以被重载。下面这个例子演示下:
class stopwatch{
public:
stopwatch(): m_min(0), m_sec(0){}
void setzero(){m_min = 0; m_sec = 0;}
stopwatch run();
stopwatch operator++();
stopwatch operator++(int n);
friend ostream & operator << (ostream &, const stopwatch &);
private:
int m_min;
int m_sec;
};
stopwatch stopwatch::run(){
++m_sec;
if (m_sec == 60){
m_min ++;
m_sec = 0;
}
return *this;
}
stopwatch stopwatch::operator++(){return run();}
stopwatch stopwatch::operator++(int n){
stopwatch s = *this;
run();
return s;
}
ostream &operator<<(ostream & out, const stopwatch & s){
out << setfill('0') << setw(2) << s.m_min << ":" << setw(2) << s.m_sec;
return out;
}
int main()
{
stopwatch s1, s2;
s1 = s2++;
cout << "s1: " << s1 << endl;
cout << "s2: " << s2 << endl;
s1.setzero();
s2.setzero();
s1 = ++s2;
cout << "s1: " << s1 << endl;
cout << "s2: " << s2 << endl;
return 0;
}
run() 函数一开始让秒针自增,如果此时自增结果等于60了,则应该进位,分钟加1,秒针置零。
operator++() 函数实现自增的前置形式,直接返回 run() 函数运行结果即可。operator++ (int n) 函数实现自增的后置形式,返回值是对象本身,但是之后再次使用该对象时,对象自增了,所以在该函数的函数体中,先将对象保存,然后调用一次 run() 函数,之后再将先前保存的对象返回。在这个函数中参数n是没有任何意义的,它的存在只是为了区分是前置形式还是后置形式。
6. 小总
C++进行运算符重载时, 需要注意几个问题:
- 重载后运算符的含义应该符合原有用法习惯。 例如重载
+ 运算符,别去搞减法,应该尽量保留运算符原有特性 - C++规定, 运算符重载不改变运算符优先级
- 下面运算符不能重载:
.、.*、::、? :、sizeof - 重载运算符
(), [], -> 或者赋值运算符= 时,只能重载为成员函数,不能重载为全局函数。
另外,下面的知识点要理解:
- 运算符重载的实质是将运算符重载为一个函数,使用运算符表达式被解释为重载函数的调用
- 运算符可被重载为全局函数。此时函数参数个数是运算符操作数个数,运算符操作数是函数实参
- 运算符可重载为成员函数。此时函数参数个数是运算符操作数减一,运算符操作数有一个成为函数作用的对象,其余的成为函数实参
- 必要时,需要重载赋值运算符=,避免两个对象内部的指针指向同一片存储空间
- 运算符可重载为全局函数,然后声明为类的友元
<< 和 >> 是iostream中被重载,才成为所谓的"流插入运算符"和"流提取运算符"的。- 类型的名字可以作为强制类型转换运算符,也可以被重载为类的成员函数。 它能使得对象被自动转换为某种类型
- 自增、自减运算符各有两种重载形式,用于区别前置用法和后置用法
- 运算符重载不改变运算符优先级,重载运算符时,应该尽量保留运算符原本的特性。
|