感受C++一些令人眼前一亮的语法

Posted 半岛铁盒里的猫

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了感受C++一些令人眼前一亮的语法相关的知识,希望对你有一定的参考价值。

更多博文,请看音视频系统学习的浪漫马车之总目录

C++语法重点难点:
初尝C++的世界
进一步走进C++面向对象的世界
感受C++一些令人眼前一亮的语法

上一篇进一步走进C++面向对象的世界 比较详细阐述了C++中继承和多态、虚函数相关的分析,面向对象的内容就基本结束,这一篇是C++语法的最后一篇博文,讲介绍C++一些常见的语法以及它们背后的思想和使用场景,这些语法,为后起之秀的语言们提供了深厚的语法基础。

运算符重载

我们已经接触过了很多运算符了,比如+、-、*、%、/,这些最基本的运算符号已经有了全世界最公认的作用,我们都知道1+1=2,1-1=0是怎么一回事,不过,C++为了获取更大的灵活性,提供了修改这些符号功能的方式。

你可能会有疑问,为什么要修改这些全世界最公认的符号的功能呢?因为C++的世界中不止有数字,很大一部分是类的对象,就拿上一篇进一步走进C++面向对象的世界 中动物的例子,一只猫就一只猫等于啥呢?你可能说猫不能相加呀,但是有些场景下是需要对2只猫相加的,比如2只猫相融合成为一个只大猫(有点魔幻了。。),为了程序应用编写和可读性的方便,这里就需要对猫相加进行运算符重载。

使用成员函数重载运算符

talk is cheap,let me show you the code~

假如我们有一个向量的类:

Vector.h:

/**
 * 向量
 */
class Vector 
public:
    Vector(int x, int y);
    
    int x;
    int y;
;

Vector.cpp:

Vector::Vector(int x, int y) 
    this->x = x;
    this->y = y;

既然是向量,那么进行计算总是必不可少的,如果用传统的方式处理,则会在Vector类中增加一个相加的方法:

Vector.h增加:

Vector add(Vector &other);

Vector.cpp对应的实现:

Vector Vector::add(Vector &other) 
    return Vector(x+other.x,y+other.y);

这样子当然是没问题的,但是C++的缔造者觉得应该用更优雅的方式去实现它。怎么个优雅法呢?比如2个对象相加看起来也像1+1这样直观,于是乎有了以下的“骚操作”:

Vector.h增加:

 Vector operator+(const Vector &other) const;

Vector.cpp对应的实现:

Vector Vector::operator+(const Vector &other) const 
    return Vector(x+other.x,y+other.y);

main.cpp:

Vector vector1 = Vector(1,2);
Vector vector2 = Vector(3,4);
//直接用+号操作Vector类,就像1+1
Vector result = vector1 + vector2;
std::cout << "result x:" << result.x  << ",y:" << result.y <<  std::endl;

运行下:

result x:4,y:6

我们回过头来看看具体做了什么操作,首先是在Vector中增加了一个方法,它就是前面所说的运算符重载函数:

//这里就可以看作一个返回值为Vector类型 ,operator+看作函数名,传参为const的Vector类型引用的const函数
//在遇到Vector类型相加的时候,就用这个函数调用替换原来+的功能
Vector Vector::operator+(const Vector &other) const 
	//这是数学中向量加法的操作
    return Vector(x+other.x,y+other.y);

参数之所以是const引用,是为了让外部既可以传const又可以传非const的对象,函数之所以是const函数是因为运算符重载往往是为了将2个对象进行操作,而不需要修改到当前对象的成员,所以const函数更加安全且符合语义。

在执行

Vector result = vector1 + vector2;

可以看作:

Vector result = vector1 .operator+(vector2);

是不是很简单呢?这样的功能完全可以由一个函数处理,但是通过运算符重载,让其可读性大大增加,更加符合人的思维方式。

计算的结果也可以不是Vector,比如需要计算点乘:

Vector.h增加:

int operator*(const Vector &other) const;

Vector.cpp对应的实现:

int Vector::operator*(const Vector &other) const 
    return x*other.x + y*other.y;

main.cpp:

int result1 = vector1 * vector2;
std::cout << "result x:" << result1 <<  std::endl;

运行下:

result x:11

照这样依葫芦画瓢就可以重载“-”、“*”、“/”等等符号。

使用全局函数重载运算符

注意到刚才计算向量后对结果的打印:

 std::cout << "result x:" << result.x  << ",y:" << result.y <<  std::endl;

拥有代码洁癖症的我们似乎总感觉有一丝丝不优雅,并且以后如果Vector增加成员后还要改main.cpp这里的打印,能不能就直接:

std::cout << "result:" << result <<  std::endl;

答案当然是能~

方案依然是运算符重载,这次我们要重载“<<”运算符。

事实上std::cout ,即ostream类,在标准库内部是重载过“<<”的,在我们执行“std::cout << “result x:””的时候,其实就是调用标准库中的运算符重载方法<<,传入ostream对象和要打印的参数,但是目前只是支持C++内置类型,这里如果直接传入Vector显然是会报错的,标准库压根不知道Vector是个什么东西,那只能重载<<方法让Vector得以传入,但是重载<<方法是标准库提供的,不能改,那该怎么办呢?

运算符重载当然不是成员函数的专利,全局函数同样也可以。既然不能在ostream类内部重载,那么C++又提供了全局重载的方式:

//重载<<,返回ostream的引用是为了可以支持连续打印
std::ostream& operator<<(std::ostream& ostream,const Vector &other)
	//本质上将具体打印逻辑封装在函数内部
    ostream << "x:" << other.x << ",y:" << other.y;
    return ostream;

main.cpp:

Vector vector1 = Vector(1,2);
 Vector vector2 = Vector(3,4);
 Vector result = vector1 + vector2;
 //会调用上面重载的<<方法,实现了直接打印Vector对象的功能
 std::cout << "result:" << result <<  std::endl;

运行下:

result❌4,y:6

完成打印任务,实现了一个全局的运算符重载函数~

那原来Vector的+重载是不是也可以改为全局函数呢?答案是肯定的。

main.cpp定义全局运算符重载函数:

Vector operator+(const Vector &other1,const Vector &other2)
    return Vector(other1.x + other2.x,other1.y + other2.y);

还是执行这一段:

Vector vector1 = Vector(1,2);
Vector vector2 = Vector(3,4);
Vector result = vector1 + vector2;
//std::cout << "result x:" << result.x  << ",y:" << result.y <<  std::endl;
std::cout << "result:" << result <<  std::endl;

运行结果依然不变:

result❌4,y:6

这里当执行到Vector result = vector1 + vector2;的时候,编译器会将它当做调用operator+方法传入2个Vector参数:

Vector result = operator+(vector1 + vector2);

以友元函数重载运算符

前面讲的全局函数有一个问题,就是当运算符重载需要访问到操作数里面的私有成员的时候,编译器是不允许的,但是这种需求是经常有的事,该如何处理呢?

答案就是友元函数。

友元函数就是一个不属于本类的函数,但是却拥有访问本类所有成员的访问资格,就像一般外人都不能使用你家的电视,但是因为A是你朋友,所以你让A进入家里使用你的电视,这时候A就是你的友元,为了让A成为你的友元,所以我们必须在声明指定某个函数是我自己的友元:

比如将上面的例子中的Vector的x,y修改为private的:

Vector.h:

private:
    int x;
    int y;

这时候声明的全局运算符重载函数operator+就会报访问不到Vector类的x,y成员的错误,这时候只需要在Vector类中将operator+声明为友元函数即可:

Vector.h:

class Vector 
private:
    
    int x;
    int y;

public:
    Vector(int x, int y);
    
    Vector add(Vector &other);

    //这就是将operator+声明为友元函数(注意:operator+函数不属于Vector!只是Vector同意它访问自己的所有成员)
    friend Vector operator+(const Vector &other1,const Vector &other2);
;

这样就一切ok~

由上面的例子依葫芦画瓢,我们还可以重载-、/、% ^ & | ~ ! = 等符号,甚至(函数调用运算符)() [] new new[] delete delete[]也是可以重载的。

运算符重载需要遵循的规则

事实上,当编译器遇到一个拥有运算符号的表达式的时候会遵循以下规则

  1. 如果该表达式的操作数都是基本类型,则如果该运算符是编译器可以识别的,则会根据将默认功能应用在该运算符上。如果不能是被,则编译器报错。
  2. 如果该表达式的操作数至少有一个是自定义类型的,则编译器优先寻找是否有该运算符对应的运算符重载函数,如果没有找到,编译器甚至会试图强转参数类型(运用后面要讲的转换构造函数),直到可以匹配到某个运算符重载函数,如果还是没有,则编译器报错。

虽说是运算符重载,可以自由定义,但是自由也是有限度的,以下是运算符重载的一些限制条件

  1. 虽然绝大部分的运算符都可以进行重载,但是还有一些不可以,比如长度运算符sizeof、条件运算符: ?、成员选择符.和域解析运算符::不能被重载。
  2. 只能重载已存在的运算符(这是必须的,不然就叫做创造新运算符了)
  3. 运算符重载的操作数中必须至少需要一个是自定义类型,比如你不能重载+去操作一个int和double,但是可以重载+去操作一个int和Vector。
  4. 运算符重载不能改变原来符号操作数个数,比如+的操作数是2个,你不能痴心妄想地想通过运算符改为3个。
  5. 重载不能改变运算符的优先级和结合性。比如重载了运算符+和*,Vector+ Vector3等价于Vector+ (Vector3)

关于运算符的重载,有2条温馨提示:

  1. 重载的运算符的语义要尽量和原有运算符一致,比如Vector重载+,则不能算出的结果是2个Vector的相减,这样就很违法人的正常思维模式,违背了运算符重载的初衷。
  2. 如果运算符重载之后语义和原来运算符相差较大,则不如重新写个新方法合适。

运算符重载成员和全局函数的不同

由上面可以看出,**通过成员函数重载运算符,是用左操作数作为对象调用重载函数,以右操作数作为传参完成方法调用的,而全局函数则是以所有操作数都作为方法入参来调用函数的。**前者是主体和客体的关系,后者是平等的关系(有点类似我带你去玩和2个人手牵手一起去玩的感觉),这里还是存在微妙的区别。

由于运算符重载的初衷是给类添加新功能,为类的一些操作带来方便,所以首选肯定是通过成员函数去重载运算符的。但是在某些情况下成员函数重载运算符并不合适。

在这里,首先要讲下转换构造函数概念。

转换构造函数

在“运算符重载需要遵循的规则”那一小节第二点讲到,如果该表达式的操作数至少有一个是自定义类型的,则编译器优先寻找是否有该运算符对应的运算符重载函数,如果没有找到,编译器甚至会试图强转参数类型,直到可以匹配到某个运算符重载函数,可能很多小伙伴会听不懂啥意思,现在就来解释下:

我们程序在计算不同类型数据运算的时候,如果没有指定强转,则会偏向于做隐式转换,比如:

int a = 2;
int a= 3.5 + a;

这里编译器会使用内置规则对a做隐式转换转化为Double类型2.0,和3.5相加得到5.5之后再转换为5,再赋值给a。

假如用上面的Vector加上一个整形数,比如:

Vector result = vector1 + 2;

运行报错:

no known conversion for argument 1 from ‘int’ to ‘const Vector&’

这里说的就是

int Vector::operator*(const Vector &other) const 
    return x*other.x + y*other.y;

这里Vector参数other不能转化为整数2(当然不能转化),所以这里编译器尝试过将2转化为Vector,但是实在无能为力,只能报错。可以让2转化为Vector类对象么,答案也是可以的,但需要我们告诉编译器,转换构造函数隆重登场:

Vector.h添加方法:

Vector(int x);

Vector.cpp:

Vector::Vector(int x) 
    this->x = x;
    y = 0;

我们先执行:

Vector result = 2;

运行没有报错,输出为:

result x:2,y:0

输出结果也是符合预期,说明这里编译器已经帮我们偷偷将2通过Vector的转换构造函数转化为Vector对象了!

依旧是

Vector result = vector1 + 2;

运行下:

result x:3,y:2

一切又正常了~

这里具体做了什么事情呢?

当编译器发现

Vector result = vector1 + 2;

的时候,会尝试将2转化为Vector,这时候编译器找到了Vector 中的:

Vector::Vector(int x) 
    this->x = x;
    y = 0;

编译器窃喜,找到可以支持转化的方法啦,于是偷偷将这里做了类似的替换:

Vector result = vector1 + Vector(2);

转换构造函数看起来和 普通的构造函数没什么区别,不同之处在于它只支持一个参数,并且编译器会自动根据它做转换。

另外想说下我们编译器真的为程序的正常运行操碎了心,只要在它认知范围内,它都会用尽全力对类型进行转换,比如上面例子改为:

//加上一个double类型的数
Vector result = vector1 + 2.3;

运行:

result x:3,y:2

依旧可以运行,这里编译器偷偷把2.3转换为了int即2。

再回到运算符重载的问题。,之所以谈了转换构造函数,是因为如果我们将相加的2个数换位置,则程序是不能正常运行的:

Vector result = 2 + vector1;

运行:

no match for ‘operator+’ (operand types are ‘int’ and ‘Vector’)

因为这里编译器找不到一个operator+方法的参数依次是int和Vector,那你肯定会说,不是会自动将int转为Vector,不是刚写了么?

这里就是成员函数重载运算符的局限性了,即编译器只能对传参进行转换,而不能转换调用方法的主体的类型。

为了解决这个问题,按照前面的思路,就必须在调用主体对象中添加一个转换构造函数,但是因为int是C++内置类不能修改,所以这种情况下我们无法用成员函数运重载算符方式进行操作。

但是如果使用全局函数重载运算符,则可以解决这个问题,int和Vector参数的位置可以自由换,因为对于全局函数来说,参数都是平等的,不管第几个参数是int,都将被转换为Vector。

比如依然使用之前那个全局函数:

Vector operator+(const Vector &other1,const Vector &other2)
    return Vector(other1.x + other2.x,other1.y + other2.y);

main.cpp:

Vector result = 2 + vector1;

运行:

result x:3,y:2

一切正常,如期而至~~

当然C++语法并没有太强制限定这方面的东西,大部分操作符成员函数和全局函数都是可以重载的,只是从语义来说成员函数会更优一些,C++语法仅规定了箭头运算符->、下标运算符[ ]、函数调用运算符( )、赋值运算符=只能以成员函数的形式重载。

拷贝构造函数

我们已经学习过普通构造函数和转换构造函数了,不过C++的构造函数可不止这两种,比如这里要讲到的拷贝构造函数。

观名可知其意,就是用了拷贝的,拷贝什么呢?Java开发的童鞋估计会联想到clone,没错,正是类似的功能,即拷贝一个对象。什么时候需要拷贝一个对象呢?其实前面我们已经接触过了,不过之前拷贝构造函数都在背后偷偷工作,因为每个类都有自己默认的拷贝构造函数,最常见的使用地方就是函数传参和函数返回,另外就是通过一个对象来创建另一个对象,或者将一个对象赋值给另一个对象

现在给Vector类加一个拷贝构造函数(这也是默认的拷贝构造函数的写法,即直接拷贝所有成员变量):

Vector.h增加拷贝构造函数:

Vector(const Vector &vector);

Vector.cpp实现如下:

Vector::Vector(const Vector &vector) 
    this->x = vector.x;
    this->y = vector.y;
    //为了明确拷贝构造函数被调用的打印
    std::cout << "Vector copy" << std::endl;

拷贝构造函数的使用场景

通过一个对象拷贝构造另一个对象:

main.cpp:

Vector vector1 = Vector(1,2);
//通过vector1对象拷贝构造vector2对象
Vector vector2 = Vector(vector1);
std::cout << "vector2 x:" << vector2.x  << ",y:" << vector2.y <<  std::endl;

运行:

Vector copy
vector2 x:1,y:2

说明拷贝构造函数被调用,并且确实将vector1的数据拷贝到了vector2 。

通过对象赋值创建一个对象:

Vector vector1 = Vector(1,2);
//通过vector1对象赋值创建一个对象vector2 
Vector vector2 = vector1;

运行:

Vector copy
vector2 x:1,y:2

也是同样的结果~

作为函数参数

漫谈C语言指针(一) 中的“通过指针在函数内部修改函数外部变量值”一小节说过,函数传参的时候,是拷贝了一个副本,其实这里参数都会进行拷贝,在函数中处理的是这个拷贝出来的副本,如果入参是一个类对象(直接对象本身,非引用),就会通过拷贝构造函数创建一个对象,再在函数中处理这个拷贝出来的对象

假如将刚才的打印的语句用一个函数封装起来:

void print(Vector vector)
    std::cout << "vector x:" << vector.x  << ",y:" << vector.y <<  std::endl;

main.cpp:

Vector vector1 = Vector(1,2);
print(vector1);

Vector copy
vector x:1,y:2

可以看到这里拷贝构造函数确实被调用了,为了进一步证明函数中使用的Vector已经不是原来传入的Vector,我们可以打印出地址进行对比:

void print(Vector vector)
    std::cout << "vector x:" << vector.x  << ",y:" << vector.y <<  std::endl;
    //打印函数中使用的Vector地址
    Vector* p = &vector;
    std::cout << "p print" << p <<  std::endl;

Vector vector1 = Vector(1,2);
 //打印传入函数的Vector地址
Vector* p = &vector1;
print(vector1);

运行结果:

p print0x63fe10
p main0x63fe08

可以看出并不是同一个对象,说明确实通过拷贝构造函数拷贝了一个新对象。

作为函数返回结果

这里和作为函数参数的验证是一样的,就不多说了。不过现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率,所以如果验证后发现返回的对象地址一样,也无需惊讶。

深拷贝和浅拷贝

上面的例子**Vector的拷贝构造函数都是直接复制基本类型成员数值的,这种就是“浅拷贝”,也是默认拷贝构造函数的拷贝方式。**那如果Vector中包含指针类型成员,还是按照这种方式拷贝会有什么问题呢:

Vector.h增加一个指针成员变量:

public:
    
    int x;
    int y;
    //增加一个指针成员变量
    Vector *pChild = NULL;
//构造函数也对应增加指针变量
Vector(int x, int y, Vector *pChild);

Vector.cpp对应实现:

//构造函数:
Vector::Vector(int x, int y, Vector *pChild) 
    this->x = x;
    this->y = y;
    this->pChild = pChild;


//拷贝构造函数
Vector::Vector(const Vector &vector) 
    this->x = vector.x;
    this->y = vector.y;
    //浅拷贝。直接复制指针的值
    this->pChild = vector.pChild;

main.cpp:

Vector vectorChild = Vector(1, 2);
Vector vector = Vector(2, 3, &vectorChild);
//通过拷贝初始化vector1
Vector vector1 = vector;
//修改vector1的pChild的x
(vector1.pChild)->x = 10;
//打印vector的pChild,即vectorChild的x
std::cout << "vectorChild x:" << vectorChild.x <<  std::endl;

运行如下:

Vector copy
vectorChild x:10

可以看到,代码明明只修改了vector1的pChild的x,但是发现vector的pChild,即vectorChild 的x也被修改。问题就出在这里,浅拷贝会导致拷贝出来的对象中的指针还是指向被拷贝对象对应指针指向的对象,这样一方对该对象的任何改动都会偷偷地影响另一方的数据。在大部分应用场景下,这是很有安全隐患的,所以一般需要把整个pChild对象也拷贝一次,即“深拷贝”让vector 和vector1 的pChild指向的对象是独立的2个对象。

只要修改下Vector的拷贝构造函数即可:

Vector::Vector(const Vector &vector) 
    this->x = vector.x;
    this->y = vector.y;
    //不要直接复制vector的pChild ,而是创建一个新的Vector对象,复制vector的pChild的x,y数据
    this->pChild = new Vector(vector.pChild->x,vector.pChild->y);

运行结果:

vectorChild x:1

拷贝构造函数、析构函数、重载“=”运算符之间的关系

假如把上面的例子main.cpp中的:

Vector vectorChild = Vector(1, 2);
Vector vector = Vector(2, 3, &vectorChild);
//通过拷贝初始化vector1
Vector vector1 = vector;

改为:

Vector vectorChild = Vector(1, 2);
 Vector vector = Vector(2, 3, &vectorChild);
 //先创建vector1 对象,然后把vector赋值给vector1 
 Vector vector1 = Vector(2,3);
 vector1 = vector;

运行下:

vectorChild x:10

好吧,搞了半天vectorChild 又被2个对象共用了。。

为什么呢,都是给vector1赋值,结果又不一样了呢?

验证下这个过程中拷贝构造函数有没有执行,给拷贝构造函数添加打印:

Vector::Vector(const Vector &vector) 
    this->x = vector.x;
    this->y = vector.y;
    this->pChild = new Vector(vector.pChild->x,vector.pChild->y);
    std::cout << "Vector copy" << std::endl;

运行:

vectorChild x:10

果然没有!

关键点在于,拷贝构造函数,只会在对象初始化的时候执行,什么叫做初始化?就是原来是没有这个对象的,直接通过赋值来创建对象的

Vector vector1 = vector;

而类似这种先创建对象,再赋值另一个对象给它,这种不是初始化,这种就是纯粹的赋值,这种拷贝构造函数不care。

//先创建vector1 对象,然后把vector赋值给vector1 
 Vector vector1 = Vector(2,3);
 vector1 = vector;

那这种赋值如何避免指针成员变量共享同一个对象的情况呢?

既然是赋值,那就重载赋值运算符呗。

Vector .cpp增加重载“=”运算符:

Vector &Vector::operator=(const Vector &other) 
    //1.判断是否是自己,这一步很重要,因为如果是自己,则因为在赋值前释放指针内存导致后面赋值的时候出现异常
    if (this != &other)
        this->x = other.x;
        this以上是关于感受C++一些令人眼前一亮的语法的主要内容,如果未能解决你的问题,请参考以下文章

感受C++一些令人眼前一亮的语法

初尝C++的世界

进一步走进C++面向对象的世界

令人眼前一亮的数组求和方式

令人眼前一亮的IDEA 2021

令人眼前一亮的IDEA 2021