C++初阶:类和对象

本篇文章是对C++学习前期的一些基础部分的学习分享

本文有点长,但我相信一定会对你有所帮助~

那咱们废话不多说,直接开始吧!


1. 类的定义

1.1 类的定义形式

使用class关键字来定义类,例如class Date,大括号{}中为类的主体,且类定义结束时后面的分号不能省略。

1.1.1 类的成员

类体中的内容称为类的成员,其中类中的变量称为类的属性或成员变量;类中的函数称为类的方法或成员函数。为了区分成员变量,一般习惯上会在成员变量前面或者后面加_或者以m开头,但在 C++ 中这并非强制要求,具体可参照公司规定~

class Date
{
public:
 void Init(int year, int month, int day)
 {
     _year = year;
     _month = month;
     _day = day;
 }

private:
 // 为了区分成员变量,⼀般习惯上成员变量 
 // 会加⼀个特殊标识,如_ 或者 m开头 
 int _year; // year_  m_year
 int _month;
 int _day;
};

1.1.2 struct 的特殊情况

C++ 中struct也可以定义类,C++ 既兼容 C 中struct的用法,同时struct升级成了类,其明显变化是struct中可以定义函数。不过,一般情况下,还是推荐使用class来定义类。

1.1.3 成员函数特性

定义在类内的成员函数默认为inline

1.2 类的封装机制与访问权限

在 C++ 中,封装是⼀种重要的特性,以下是关于 C++ 中封装以及访问权限的相关内容:

1.2.1 封装的概念

C++ 通过类来实现封装,即将对象的属性(成员变量)与⽅法(成员函数)结合在⼀起,使对象更加完善,并通过访问权限,有选择性地将其接⼝提供给外部⽤户使⽤。

1.2.2 访问权限类型及特点

public(公有):被public修饰的成员在类外可以直接被访问。

protected(保护):被protected修饰的成员在类外不能直接被访问。

private(私有):被private修饰的成员在类外不能直接被访问。在类的继承相关内容中,protectedprivate会体现出区别。

1.2.3 访问权限作⽤域

访问权限的作⽤域从该访问限定符出现的位置开始,直到下⼀个访问限定符出现时为⽌。如果后⾯没有访问限定符,作⽤域就到类定义结束的}处。

1.2.4默认访问权限

使⽤class定义类时,若成员没有被访问限定符修饰,默认访问权限为private

使⽤struct定义类时,若成员没有被访问限定符修饰,默认访问权限为public

1.2.5 访问权限的⼀般应⽤原则

⼀般将成员变量的访问权限设置为privateprotected,以对数据进⾏保护;⽽将需要给外部使⽤的成员函数设置为public,作为类对外提供的接⼝。

1.3 类的作用域及其影响

1.3.1 类定义的作用域

类定义了一个新的作用域,类的所有成员都处于类的这个作用域之中。

1.3.2 类体外成员的定义规则

当在类体外定义成员时,需要使用 :: 作用域操作符来指明成员属于哪个类域。

void Stack::Init(int n)
{
 array = (int*)malloc(sizeof(int) * n);
 if (nullptr == array)
 {
 perror("malloc申请空间失败");
 return;
 }
 capacity = n;
 top = 0;
}

那么Init函数便是定义在Stack这个类外部的成员函数

1.3.3 类域对编译查找规则的影响

类域会影响编译器的查找规则。以一个具体程序为例,若在类体外的函数(如 Init)不指定类域(如 Stack),编译器会将该函数当成全局函数处理。

当把该函数当成全局函数时,在编译过程中,对于函数中使用到的如 array 等成员,由于编译器不知道它们属于哪个类,也就找不到它们的声明或定义,因此会报错。

而当使用 :: 作用域操作符指明函数属于某个类域(如 Stack)时,编译器会将该函数认定为成员函数。此时,对于在当前函数所在的局部域中找不到的成员(如 array),编译器会自动到相应的类域中去查找。

2. 类的实例化

2.1 类实例化的概念

用类类型在物理内存中创建对象的过程被称为类实例化出对象。

2.1.1 类与对象的关系

类是对对象的一种抽象描述,类似于一个模型。

它只是限定了会拥有哪些成员变量,但这些成员变量在类的定义中仅仅是声明,并不会分配实际的物理空间。

一个类可以作为模板,用于实例化出多个对象。

2.1.2 类与对象在内存方面的区别

实例化出的对象会占用实际的物理空间,其主要目的是存储类的成员变量。

形象地说,类实例化出对象的过程可以类比为现实中的建筑施工过程。

类就像建筑设计图,它规划了诸如房间的数量、大小、功能等信息,但它本身并不是一个实际的建筑,不具有实体,也无法被使用(例如住人)。

只有依据该设计图修建出房子(即类实例化出对象),房子才能住人,也就是只有对象才能存储数据,而类只是定义了存储数据的结构,自身并不存储数据。

#include<iostream>
using namespace std;

class Date
{
public:
 void Init(int year, int month, int day)
 {
     _year = year;
     _month = month;
     _day = day;
 }
 void Print()
 {
     cout << _year << "/" << _month << "/" << _day << endl;
 }

private:
 // 这⾥只是声明,没有开空间 
     int _year;
     int _month;
     int _day;
};

int main()
{
 // Date类实例化出对象d1和d2 
     Date d1;
     Date d2;

 d1.Init(2024, 3, 31);
 d1.Print();

 d2.Init(2024, 7, 5);
 d2.Print();
 return 0;
}

2.2 类对象成员的分析

2.2.1 类对象的成员构成

当类实例化出对象时,每个对象都拥有独立的数据空间。

因此,对象中必然包含成员变量,因为成员变量用于存储对象的具体数据,不同对象的数据通常是相互独立的,例如 Date 类实例化出 d1 和 d2 两个对象,它们会有各自独立的 _year_month_day 成员变量来存储各自的数据。

2.2.2 关于成员函数是否包含在对象中

函数被编译后是一段指令,对象中无法存储这段指令。这些指令存储在一个单独的区域,即代码段。

理论上,如果非要在对象中存储成员函数,只能存储成员函数的指针。

2.2.3 对象中存储成员函数指针的必要性分析

考虑 Date 类实例化出 d1 和 d2 两个对象,虽然它们的成员变量不同,但它们的成员函数 Init 和 Print 的指针却是一样的。

如果在每个对象中都存储成员函数指针,当 Date 类实例化 100 个对象时,成员函数指针就会被重复存储 100 次,这会造成极大的空间浪费。

实际上,存储函数指针是不必要的。因为函数指针是一个地址,调用函数时被编译成汇编指令 [call 地址],而编译器在编译链接阶段就会找到函数的地址,并非在运行时查找(只有动态多态的情况下是在运行时找,此时才需要存储函数地址,关于动态多态会在后续讲解)。

2.2.4 成员变量内存

上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对⻬的规则。

这部分内容在我的另一篇博客C语言修炼秘籍之——结构体、位段、联合体、枚举_联合体按位定义-优快云博客

中的 1.4结构体内存对齐 有说到,对这个规则还不太了解的可以直接点击链接穿越了解~

#include<iostream>
using namespace std;

// 计算⼀下A/B/C实例化的对象是多⼤? 
class A
{
public:
 void Print()
 {
     cout << _ch << endl;
 }
private:
     char _ch;
     int _i;
};

class B
{
public:
void Print()
{
   //...
}
};

class C
{};

int main()
{
 A a;
 B b;
 C c;
 cout << sizeof(a) << endl;
 cout << sizeof(b) << endl;
 cout << sizeof(c) << endl;
 return 0;
}

上⾯的程序运⾏后,我们会发现没有成员变量的B和C类对象的⼤⼩是1

为什么没有成员变量还要给1个 字节呢?

因为如果⼀个字节都不给,怎么表⽰对象存在过呢!所以这⾥给1字节,纯粹是为了表示对象存在。

3. 类的this指针

3.1 问题引入

我们看回刚刚的那段代码:

#include<iostream>
using namespace std;

class Date
{
public:
 void Init(int year, int month, int day)
 {
     _year = year;
     _month = month;
     _day = day;
 }
 void Print()
 {
     cout << _year << "/" << _month << "/" << _day << endl;
 }

private:
     int _year;
     int _month;
     int _day;
};

int main()
{
     Date d1;
     Date d2;

 d1.Init(2024, 3, 31);
 d1.Print();

 d2.Init(2024, 7, 5);
 d2.Print();
 return 0;
}

在 Date 类中,存在 Init 和 Print 两个成员函数,并且这些函数体中没有明确区分不同对象的信息。那么当 d1 调用 Init 和 Print 函数时,函数如何知道应该访问的是 d1 对象还是 d2 对象呢?

3.2 this 指针的出现

C++ 提供了一个隐含的 this 指针来解决上述问题。

3.3 this 指针的特性和规则

3.3.1 函数原型添加

编译器在编译类的成员函数时,会默认在形参的第一个位置添加一个当前类类型的指针,这个指针被称为 this 指针。

例如,对于 Date 类的 Init 函数,其真实原型是 void Init(Date* const this, int year, int month, int day)

3.3.2 成员变量访问机制

类的成员函数中访问成员变量,本质上都是通过 this 指针进行访问的。

例如,在 Init 函数中给 _year 赋值时,实际上是通过 this->_year = year; 来实现的。

3.3.3 使用限制

C++ 规定不能在实参和形参的位置显式地写出 this 指针,因为在编译时编译器会自动处理 this 指针的相关事宜。

然而,可以在函数体内显式地使用 this 指针,以增强代码的清晰度或进行一些特殊操作(如处理指针比较、链式调用等)。

比如上面的init函数就可以改写为下面的样子:

 void Init(int year, int month, int day)
 {
     this->_year = year;
     this->_month = month;
     this->_day = day;
 }

4. 类的默认成员函数

4.1 引入

默认成员函数,指的是在用户没有显式实现的情况下,由编译器自动生成的成员函数。在一个类中,如果用户未编写相关函数,编译器会默认生成以下 6 个默认成员函数:

初始化和清理相关

  • 构造函数:主要完成初始化工作。
  • 析构函数:主要完成清理工作。

拷贝复制相关

  • 拷贝构造:使用同类对象初始化创建对象。
  • 赋值重载:把一个对象赋值给另一个对象。

取地址重载

主要用于普通对象const 对象取地址,这两个函数很少需要用户自己实现。

4.2 构造函数

4.2.1 构造函数介绍

构造函数虽名为 “构造”,但其主要任务并⾮开辟空间创建对象。以常见的局部对象为例,在栈帧创建时,对象所需空间便已开辟好。构造函数的主要职责是在对象实例化时对其进⾏初始化。

在像 Stack 和 Date 这样的类中,以往在C语言中我们可能会编写 Init 函数来完成对象的初始化⼯作。⽽构造函数凭借其⾃动调⽤的特点,能够完美地替代 Init 函数的功能。

4.2.2 构造函数的特点

4.2.2.1 函数定义相关
  1. 函数名:函数名与类名相同。这是构造函数在定义上的⼀个显著特征,通过这种命名⽅式,编译器能够识别出该函数为类的构造函数。
  2. 返回值:⽆返回值,且不需要写 void。这是 C++ 的规定,在编写构造函数时需遵循此规则,不必对此进⾏过多纠结。
  3. 重载特性:构造函数可以重载。通过重载不同参数形式的构造函数,可以实现对对象的多种初始化⽅式,以满⾜不同的需求。
4.2.2.2 调⽤机制相关
  1. ⾃动调⽤:对象实例化时,系统会⾃动调⽤对应的构造函数。这种⾃动调⽤机制确保了对象在创建时能够及时得到初始化。
  2. 默认构造函数的⽣成与规则
  • 若类中没有显式定义构造函数,C++ 编译器会⾃动⽣成⼀个⽆参的默认构造函数。但⼀旦⽤⼾显式定义了构造函数,编译器将不再⾃动⽣成。
  • ⽆参构造函数、全缺省构造函数以及编译器默认⽣成的构造函数,都被称为默认构造函数。这三种默认构造函数有且只有⼀个能存在,不能同时存在。因为⽆参构造函数和全缺省构造函数虽构成函数重载,但调⽤时会存在歧义。简单来说,不传实参就可以调⽤的构造函数就叫默认构造函数。
4.2.2.3 成员变量初始化相关

C++ 将类型分为内置类型(基本类型)和⾃定义类型

前者如 int、char、double、指针等,是语⾔提供的原⽣数据类型;

后者则是使⽤ class、struct 等关键字⾃⼰定义的类型。

  1. 内置类型成员变量:当我们不写构造函数,由编译器默认⽣成构造函数时,对内置类型成员变量的初始化没有要求,其是否初始化取决于编译器,具有不确定性。
  2. ⾃定义类型成员变量:编译器默认⽣成的构造函数,要求调⽤⾃定义类型成员变量的默认构造函数进⾏初始化。若该成员变量没有默认构造函数,则会报错。此时,我们需要使⽤初始化列表来初始化这个成员变量(关于初始化列表的内容日后学完了再和大家分享~)

4.2.3 代码实例

#include<iostream>
using namespace std;

class Date
{
public:
	/*Date()  名字和类的名字相同且没有返回值类型也不需要写void
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}*/
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	d1.Print();

	return 0;
}

上面有其中没参数的以及全缺省的两种构造函数的写法,都能成功初始化创建的对象:

当然我们也可以纯靠编译器然后自己什么都不写,只不过这样初始化出来的年月日是随机值:

还有一种情况,我们并没有显式写出构造函数,但是依旧能够初始化类中的成员:

#include<iostream>
#include<string>
using namespace std;

class Date
{
public:
	/*Date()  名字和类的名字相同且没有返回值类型也不需要写void
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}*/
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};
////////////////
//加了个baby类
class Baby
{
public:

private:
	int _age;
	Date birthday;
	int _ID;
	string name;
};

int main()
{
	Date d1;
	d1.Print();

	Baby b1;
	
	return 0;
}

在这里我们多加了个Baby类,我们并没有对他的成员有任何的初始化操作

但是在运行后我们会发现他的birthday变量却被初始化了:

想必眼尖的你已经发现了,我们的birthday成员的类型是Date,然而Date我们可是已经初始化了的,于是birthday就直接用了它的构造函数

关于构造函数部分还有一小部分初始化列表的内容还无法在这里展开,我们将会在后面的文章中再次探索其中的奥妙。

4.3 析构函数

4.3.1 析构函数的介绍

在 C++ 中,析构函数和构造函数有着相反的功能。不过,析构函数并不负责销毁对象本身,析构函数的主要任务是清理和释放对象内部的资源。

以局部对象为例,它们存储在栈帧中,当函数运行结束时,栈帧会自动销毁,相应的对象也随之释放,这一过程并不需要我们手动干预。C++ 有一个规定,即当对象要被销毁时,系统会自动调用析构函数。这与我们之前在实现 Stack 类时使用的 Destroy 功能相似。

对于 Date 类而言,它没有类似 Destroy 的操作,这是因为该类并不存在需要清理和释放的资源,所以从严格意义上讲,Date 类并不需要析构函数。

4.3.2 析构函数的特点

1. 析构函数名是在类名前加上字符 ~。

2. ⽆参数⽆返回值。(与构造类似,也不需要加void)

3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会⾃动⽣成默认的析构函数。

4. 对象⽣命周期结束时,系统会⾃动调⽤析构函数。

5. 跟构造函数类似,我们不写编译器⾃动⽣成的析构函数对内置类型成员不做处理,⾃定类型成员会 调⽤他的析构函数。

6. 还需要注意的是我们显⽰写析构函数,对于⾃定义类型成员也会调⽤他的析构,也就是说⾃定义类 型成员⽆论什么情况都会⾃动调⽤析构函数。

7. 如果类中没有申请资源时,析构函数可以不写,直接使⽤编译器⽣成的默认析构函数,如上述的Date类;

当然也有不同情况,以用桟实现队列的大概解法代码为例:

#include<iostream>
using namespace std;

typedef int STDataType;

class Stack
{
public:

 Stack(int n = 4)//熟悉吗?构造函数来的
 {
     _a = (STDataType*)malloc(sizeof(STDataType) * n);

 if (nullptr == _a)
 {
     perror("malloc申请空间失败");
     return;
 }
 _capacity = n;
 _top = 0;
 }

 ~Stack()//析构函数~
 {
     cout << "~Stack()" << endl;
     free(_a);
     _a = nullptr;
     _top = _capacity = 0;
 }

private:
 STDataType* _a;
 size_t _capacity;
 size_t _top;
};

// 两个Stack实现队列 
class MyQueue
{
public:
 //编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源 

 // 显⽰写析构,也会⾃动调⽤Stack的析构 
 /*~MyQueue()
 {}*/
private:
 Stack pushst;
 Stack popst;
};

int main()
{
 Stack st;
 MyQueue mq;
 return 0;
}

如果默认⽣成的析构就可以⽤,也就不需要显⽰写析构,如MyQueue;

但是有资源申请时,⼀定要 ⾃⼰写析构,否则会造成资源泄漏,如Stack。

8. ⼀个局部域的多个对象,C++规定后定义的先析构。

4.4 拷贝构造函数

4.4.1 拷贝构造函数的定义

如果一个构造函数的第一个参数自身类类型的引用,且任何额外的参数都有默认值,则此构造函数叫做拷贝构造函数,它是一种特殊的构造函数。

class Date
{
public:
	//构造函数
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//拷贝构造函数
	Date(Date& d1)
	{
		_year = d1._year;
		_month = d1._month;
		_day = d1._day;
	}

private:
	int _year;
	int _month;
	int _day;
};

4.4.2 拷贝构造函数的特点

1. 函数重载:拷贝构造函数是构造函数的一个重载形式。

2. 参数要求:

  • 拷贝构造函数的第一个参数必须是类类型对象的引用。若使用传值方式,编译器会直接报错,因为传值会引发无穷递归调用。

(如上图所示,若显式拷贝构造函数采用传值的方式,那么在将d1的值传给d2之前,需要先再调用一次拷贝构造将d1的值传给d,但在传之前又需要重复上一轮次的操作,将d1的值传给下一次拷贝构造函数的d,如此陷入无限递归......)

  • 拷贝构造函数可以有多个参数,但第一个参数必须是类类型对象的引用,后续参数必须有缺省值。

3. 调用场景:C++ 规定,自定义类型对象进行拷贝行为时必须调用拷贝构造函数。因此,自定义类型传值传参和传值返回时,都会调用拷贝构造函数完成拷贝操作。

4.编译器自动生成规则

  • 若未显式定义拷贝构造函数,编译器会自动生成。
  • 自动生成的拷贝构造函数对内置类型成员变量会进行值拷贝(浅拷贝,即一个字节一个字节的拷贝);对自定义类型成员变量,则会调用其拷贝构造函数。

5. 显式定义需求分析

  • 对于成员变量全是内置类型且不指向任何资源的类(如 Date 类),编译器自动生成的拷贝构造函数就能满足拷贝需求,无需显式实现拷贝构造函数。

(我们将显式构造函数注释掉,依旧可以完成目标操作)

  • 对于成员变量虽为内置类型,但有指针指向资源的类(如 Stack 类),编译器自动生成的拷贝构造函数仅进行浅拷贝,无法满足需求,需要自己实现深拷贝(对指向的资源也进行拷贝)。
  • class Stack
    {
    public:
    	Stack(int n = 4)
    	{
    		_a = (STDataType*)malloc(sizeof(STDataType) * n);
    		if (nullptr == _a)
    		{
    			perror("malloc申请空间失败");
    			return;
    		}
    		_capacity = n;
    		_top = 0;
    	}
        //这个拷贝构造函数对应的便是深拷贝操作
    	Stack(const Stack& st)
    	{
    		// 需要对_a指向资源创建同样⼤的资源再拷⻉值 
    		_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
    		if (nullptr == _a)
    		{
    			perror("malloc申请空间失败!!!");
    			return;
    		}
    		memcpy(_a, st._a, sizeof(STDataType) * st._top);
    		_top = st._top;
    		_capacity = st._capacity;
    	}
    	void Push(STDataType x)
    	{
    		if (_top == _capacity)
    		{
    			int newcapacity = _capacity * 2;
    			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
    				sizeof(STDataType));
    			if (tmp == NULL)
    			{
    				perror("realloc fail");
    				return;
    			}
    			_a = tmp;
    			_capacity = newcapacity;
    		}
    		_a[_top++] = x;
    	}
    	~Stack()
    	{
    		cout << "~Stack()" << endl;
    		free(_a);
    		_a = nullptr;
    		_top = _capacity = 0;
    	}
    private:
    	STDataType* _a;
    	size_t _capacity;
    	size_t _top;
    };
    
    int main()
    {
    	Stack st1;
    	st1.Push(1);	
    	st1.Push(2);
    
    	 Stack st2 = st1;
    
    	return 0;
    }

    (如上述代码所示,Stack类中的拷贝构造函数为拷贝对象又申请了一块新的内存空间,是的拷贝对象与被拷贝对象无需共用一片内存,这样就不会出现因为对同一块内存进行了两次析构操作从而出错。)

  • 对于内部主要是自定义类型成员的类(如 MyQueue 类,其内部主要是 Stack 成员),编译器自动生成的拷贝构造函数会调用成员的拷贝构造函数,无需显式实现该类的拷贝构造函数。

  • class MyQueue
    {
    public:
    private:
    	Stack pushst;
    	Stack popst;
    };
    
    int main()
    {
    	Stack st1;
    	st1.Push(1);	
    	st1.Push(2);
    
    	 Stack st2 = st1;
    
    	 MyQueue mq1;
    	 // MyQueue⾃动⽣成的拷⻉构造,会⾃动调⽤Stack拷⻉构造完成pushst/popst的拷⻉,只要Stack拷⻉ 
         //构造⾃⼰实现了深拷⻉就没问题 
    	 MyQueue mq2 = mq1;
    
    	return 0;
    }
  • 小技巧:如果一个类显式实现了析构函数并释放资源,那么通常需要显式编写拷贝构造函数;反之,则不需要。

6. 返回值类型影响

  • 传值返回:会产生一个临时对象,并调用拷贝构造函数。
  • 传引用返回:返回的是返回对象的别名(引用),不会产生拷贝。但如果返回对象是当前函数局部域的局部对象,函数结束后该对象会被销毁,此时使用引用返回会产生野引用,类似野指针。传引用返回可以减少拷贝,但必须确保返回对象在当前函数结束后仍然存在,才能使用引用返回。

5. 赋值运算符重载

5.1 运算符重载

函数特性:运算符重载是具有特殊名字的函数,其名字由operator和要定义的运算符共同构成。与其他函数一样,具有返回类型、参数列表和函数体。

class Date
{
public:
    // 重载 == 运算符,将函数声明为 const 成员函数
    bool operator==(const Date& x1) const
    {
        return _year == x1._year
            && _month == x1._month
            && _day == x1._day;
    }

    // 构造函数,使用初始化列表初始化成员变量
    Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}

private:
    int _year;
    int _month;
    int _day;
};

参数数量:重载运算符函数的参数个数和该运算符作用的运算对象数量一致。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

若重载运算符函数是成员函数,它的第一个运算对象默认传给隐式的this指针,因此作为成员函数时,参数比运算对象少一个。

优先级和结合性:运算符重载后,其优先级和结合性与对应的内置类型运算符保持一致。

符号限制:不能通过连接语法中没有的符号来创建新的操作符,例如operator@是不允许的。

不可重载运算符.*::sizeof?:.这 5 个运算符不能重载,这在选择题中常考,需要牢记 

这里的后面四个大家应该都很熟悉,但是第一个 “ .* ”是c++中的成员指针解引用运算符,也被称为指向成员的指针运算符,在这里简单做一个介绍:


还记得C语言中的函数指针吗?

举个例子:

typedef void(*PF2)(); 

这行代码的作用是定义了一个新的类型别名 PF2,它代表一种指向不接受任何参数且返回值为 void 的函数的指针类型。

这是普通函数指针类型,那如果是成员函数指针类型会是什么样子的?

typedef void(A::*PF1)();

那就需要在中间加上限定域(假设A是一个已经定义好的类的名字)。

定义完后,如果我们想要对PF2进行解引用操作,就应该是这样:

(*pf2)();

但是对PF1解引用就有些不一样,毕竟是个成员函数,我们就需要先创建一个对象,然后通过这个对象来完成对PF1的解引用操作:

A obj;//定义ob类对象temp
(obj.*pf1)();

那么第二句中间的“ .* ”便是上面提到的第一个运算符了


参数类型限制:重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如int operator+(int x, int y)这种针对内置int类型的重载是不允许的。

重载必要性:一个类需要重载哪些运算符,取决于哪些运算符重载后有实际意义。例如,Date类重载operator-可能有意义,而重载operator+可能没有意义。

前置与后置运算符重载区分:重载++运算符时,有前置++和后置++,二者的运算符重载函数名都是operator++。为作区分,C++ 规定后置++重载时,增加一个int形参,以此与前置++构成函数重载。

<< 和 >>运算符重载:重载<<>>时,需要重载为全局函数。因为若重载为成员函数,this指针默认抢占第一个形参位置(而第一个形参位置应为左侧运算对象),调用时会变成对象<<cout,不符合使用习惯和可读性。重载为全局函数时,把ostream/istream放到第一个形参位置,第二个形参位置放类类型对象即可。

5.2 赋值运算符重载

5.2.1 赋值运算符的基本概念

赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象之间的拷贝赋值,这一点需要和拷贝构造函数区分开来。拷贝构造函数用于将一个对象拷贝初始化给另一个正在创建的对象。

class Date
{
public:

    // 传引⽤返回减少拷⻉ 
    //d1=d2
    Date& operator=(const Date& d)
    {
        // 要检查⾃⼰给⾃⼰赋值的情况 
        if (this != &d)
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
        }
        // d1 = d2表达式的返回对象应该为d1,也就是*this 
        return *this;
    }

private:
    int _year;
    int _month;
    int _day;
};

可以看到在这里我们还进行了是否是对象自己给自己赋值的判断,因为像是Stack这种类,一个对象再给另外一个对象赋值的之后是可能会有先把被赋值对象的空间先释放掉,再开一个和赋值对象一样大的空间,但在这种情况下自己给自己赋值就会因为内存已经被释放无法找回从而导致生成随机值的问题。

如果是像Date这样成员都是内置类型的类,虽然不会有上述问题出现因为不需要额外开资源,但是来三次毫无意义的赋值操作未尝不是一种浪费呢?

5.2.2 赋值运算符重载的特点

必须重载为成员函数:作为运算符重载,赋值运算符重载规定必须重载为成员函数。其参数建议写成const 当前类类型引用,否则传值传参时会产生拷贝。

有返回值:返回值建议写成当前类类型引用,这样做不仅可以提高效率,而且有返回值的目的是为了支持连续赋值场景。

打个比方:如果我们仅仅将赋值重载函数写成没有返回值的情况:

void operator=(const Date& d)
{
    _year = d._year;
    _month = d._month;
    _day = d._day;
}

基于连续赋值操作的本质是从右向左依次赋值:

我们便无法连续完成赋值操作:

 

编译器自动生成:当没有显式实现时,编译器会自动生成一个默认赋值运算符重载。其行为与默认拷贝构造函数类似,对于内置类型成员变量,会完成值拷贝 / 浅拷贝(一个字节一个字节地拷贝);对于自定义类型成员变量,会调用其赋值重载函数。

5.2.3 不同类的赋值运算符重载实现情况

Date 类:像 Date 这样的类,其成员变量全是内置类型且没有指向任何资源,编译器自动生成的赋值运算符重载就可以完成所需的拷贝,因此不需要我们显式实现。

Stack 类:Stack 类虽然成员也都是内置类型,但其中的_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝 / 浅拷贝不符合需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。

MyQueue 类:MyQueue 类内部主要是自定义类型 Stack 成员,编译器自动生成的赋值运算符重载会调用 Stack 的赋值运算符重载,所以也不需要我们显式实现 MyQueue 的赋值运算符重载。

一个小技巧:与上述说过的拷贝构造函数相似,如果一个类显式实现了析构并释放资源,那么它就需要显式编写赋值运算符重载,否则就不需要。

6. 取地址运算符重载

6.1 const成员函数

使用const修饰对象能保证数据的完整性与稳定性,防止对象状态被意外修改,避免因误操作导致数据不一致或逻辑错误。但是在C++中,用const修饰的对象是无法调用类中函数的,因为这其中涉及一个权限放大的问题,如下图所示:

对象在调用类的成员函数时需要将对象自身的地址传给this指针(*this),d1作为一个被const修饰的对象,它的地址类型为const Date*,但此时类中的 this指针的类型为Date *,将const Date*传给Date *就会导致权限放大的发生。

此时我们就需要在成员函数后加上一个const,this指针的类型就变为了const Date *,再让d1调用该成员函数便是一个权限平移的过程,是合法的。

当然还可以让普通没有被const修饰的对象调用const成员函数,这是一个权限缩小的过程,也是合法的。

6.2 取地址运算符重载

在 C++ 中,取地址运算符重载包含普通取地址运算符重载和 const 取地址运算符重载。

通常情况下,编译器自动生成的这两个函数就能满足我们的使用需求,无需手动显式实现。

不过,在某些特殊场景下,我们可能需要自行实现。例如,当我们不希望外部获取当前类对象的真实地址时,就可以自定义取地址运算符重载函数,使其返回一个无意义的地址。

class Date
{ 
public :
 Date* operator&()
 {
 return this;
 // return nullptr;
 }
 
//受const修饰后的this指针类型为const Date*,因此返回值类型也应该统一
 const Date* operator&()const
 {
 return this;
 // return nullptr;
 }
private :
 int _year ; // 年 
 int _month ; // ⽉ 
 int _day ; // ⽇ 
};

假如我们将return的值改为nullptr,那么别人在访问对应的对象的地址时就只会得到0000000,我们甚至可以返回一个(Date*)0x12345678:

class Date
{ 
public :
 Date* operator&()
 {
 return (Date*)0x12345678;
 }
 
 const Date* operator&()const
 {
 return (Date*)0x12345678;
 }
private :
 int _year ; // 年 
 int _month ; // ⽉ 
 int _day ; // ⽇ 
};

这样别人每次查这个对象的地址都只会出来0x12345678,怎么调都不会改变~

那很坏了

7. 再论构造函数

7.1 初始化列表的基本语法

初始化列表以冒号(:)开始,后跟一个以逗号分隔的成员变量列表,每个成员变量后跟一个放在括号中的初始值或表达式。例如:

class MyClass {
public:
    // 初始化列表
    MyClass(int a, int b) 
        : _a(a)
        , _b(b) 
        {} 
private:
    int _a;
    int _b;
};

7.2 初始化列表的特点
  • 每个成员变量只能在初始化列表中出现一次

  • 初始化列表可以理解为成员变量定义和初始化的地方。

  • 初始化顺序:成员变量的初始化顺序与其在类中的声明顺序一致,与初始化列表中的书写顺序无关。建议将初始化列表的顺序与成员变量的声明顺序保持一致,以避免混淆。

让我们来练练手,思考下面这段代码最后会打印出什么结果呢?

  • #include<iostream>
    using namespace std;
    
    class A
    {
    public:
     A(int a)
     :_a1(a)
     , _a2(_a1)
    {}
    
     void Print() {
     cout << _a1 << " " << _a2 << endl;
     }
    
    private:
     int _a2 = 2;
     int _a1 = 2;
    };
    
    int main()
    {
     A aa(1);
     aa.Print();
    }
    

因为初始化的顺序是与声明的顺序一致的,所以在初始化a2时就无法给他一个准确的_a1.


7.3 必须使用初始化列表的情况

以下类型的成员变量必须在初始化列表中进行初始化,否则会导致编译错误:

  • 引用成员变量:引用必须在定义时初始化。

  • const成员变量:常量必须在定义时初始化。

  • 没有默认构造函数的类类型成员:如果成员是类类型且没有默认构造函数,则必须通过初始化列表显式初始化。


7.4 C++11 的缺省值支持
  • C++11 允许在成员变量声明时提供缺省值。例如:

    class MyClass {
    private:
        int _a = 10; // 缺省值
    };
  • 如果成员变量没有在初始化列表中显式初始化,则会使用其缺省值进行初始化。

  • 如果没有提供缺省值:

    • 对于内置类型(如intdouble等),是否初始化取决于编译器,C++标准未作规定。

    • 对于自定义类型,会调用其默认构造函数。如果没有默认构造函数,则会导致编译错误。


7.5 初始化列表的优先级

A. 无论是否显式编写初始化列表,每个构造函数都有初始化列表。

B. 无论是否在初始化列表中显式初始化,每个成员变量都会通过初始化列表进行初始化。

C. 如果成员变量在初始化列表中没有显式初始化:

            C.a 如果有缺省值,则使用缺省值初始化。

            C.b 如果没有缺省值:内置类型的值未定义(取决于编译器)。

                                               自定义类型会调用默认构造函数。


7.6 使用初始化列表的好处
  • 效率更高:对于自定义类型成员,使用初始化列表可以直接调用其构造函数,避免先调用默认构造函数再赋值的额外开销。

  • 避免未定义行为:对于内置类型,显式初始化可以避免未定义的值。

  • 代码更清晰:初始化列表将成员变量的初始化集中在一处,便于阅读和维护。


7.7 初始化列表的注意事项
  • 初始化顺序:成员变量的初始化顺序与其在类中的声明顺序一致,与初始化列表中的书写顺序无关。与上面那道题目类似:

    class MyClass {
    public:
        MyClass(int a) : _b(a), _a(_b) {} // 错误:_a 先初始化,但 _b 还未初始化
    private:
        int _a;
        int _b;
    };

    在这里的例子中,_a会先于_b初始化,因此_a(_b)会导致未定义行为。


7.8 总结
  • 尽量使用初始化列表:初始化列表是初始化成员变量的推荐方式,尤其是对于引用、const成员和没有默认构造函数的类类型成员。

  • 注意初始化顺序:成员变量的初始化顺序与其声明顺序一致,与初始化列表中的书写顺序无关。

  • 结合缺省值:C++11 的缺省值机制可以与初始化列表配合使用,简化代码并提高可读性。

通过合理使用初始化列表,可以提高代码的效率、清晰度和可维护性。

8. 类型转换

8.1 内置类型隐式转换为类类型

  • 条件:如果类定义了以内置类型为参数的构造函数,则支持从该内置类型隐式转换为类类型。

  • 示例

class MyClass {
public:
    MyClass(int x) : _value(x) {} // 允许从 int 隐式转换为 MyClass
private:
    int _value;
};

void func(MyClass obj) {}

int main() {
    func(10); // 隐式调用 MyClass(int) 构造函数
    return 0;
}
  • 在上面的例子中,int类型的值10可以隐式转换为MyClass类型的对象。


8.2 禁止隐式类型转换

  • 方法:在构造函数前添加explicit关键字,可以禁止隐式类型转换。

  • 作用explicit关键字要求必须显式调用构造函数,避免意外的隐式转换。

  • 示例

    class MyClass {
    public:
        explicit MyClass(int x) : _value(x) {} // 禁止隐式转换
    private:
        int _value;
    };
    
    void func(MyClass obj) {}
    
    int main() {
        // func(10); // 错误:不能隐式转换
        func(MyClass(10)); // 正确:显式调用构造函数
        return 0;
    }


8.3 类类型之间的隐式转换

  • 条件:如果类定义了以另一个类类型为参数的构造函数,则支持从该类类型隐式转换为当前类类型。

  • 示例

    class A {};
    
    class B {
    public:
        B(const A& a) {} // 允许从 A 隐式转换为 B
    };
    
    void func(B& bb) {}
    
    int main() {
        A a;
        func(a); // 隐式调用 B(const A&a) 构造函数
        return 0;
    }

8.4  隐式类型转换的底层原理

以上述类类间的转换为例:

a. 调用func(a)

  • 编译器发现func需要一个B类型的参数,但传入的是A类型的对象a
  • 由于B类定义了B(const A& a)构造函数,编译器会尝试使用这个构造函数来创建一个临时的B类型对象。

b. 创建临时对象

  • 编译器调用B(const A& a)构造函数,传入a作为参数,生成一个临时的B类型对象。

c. 绑定临时对象

  • 临时对象被绑定到func的参数bb上。如果bbconst B&类型,则可以成功绑定;如果是B&类型,则会导致编译错误。

d. 函数调用完成

  • 函数func执行完毕后,临时对象会被销毁。

8.5 总结

a. 隐式类型转换

内置类型可以隐式转换为类类型,前提是类定义了以该内置类型为参数的构造函数。

类类型之间也可以隐式转换,前提是类定义了以另一个类类型为参数的构造函数。

b. 禁止隐式转换

使用explicit关键字可以禁止隐式类型转换,避免意外的转换行为。

c. 建议

对于单参数构造函数,建议使用explicit关键字,除非确实需要隐式转换。

隐式转换虽然方便,但可能引入难以发现的错误,因此应谨慎使用。

9. static成员

9.1 静态成员变量

A. 定义:用static修饰的成员变量称为静态成员变量。

B. 特点

    B.a 静态成员变量为所有类对象共享,不属于某个具体的对象。

    B.b 静态成员变量存储在静态区(全局数据区),而不是对象的内存空间中。

    B.c 初始化:

静态成员变量必须在类外进行初始化,且初始化时不加static关键字。

静态成员变量不能在声明位置给缺省值,因为缺省值是用于构造函数初始化列表的,而静态成员变量不属于某个对象,不通过构造函数初始化。

C. 访问

  • 可以通过类名::静态成员变量对象.静态成员变量来访问。

  • 静态成员变量受publicprotectedprivate访问限定符的限制。

示例

class MyClass {
public:
    static int count; // 静态成员变量声明
};

int MyClass::count = 0; // 静态成员变量初始化

int main() {
    MyClass::count = 10; // 通过类名访问
    MyClass obj;
    obj.count = 20; // 通过对象访问
    return 0;
}

9.2 静态成员函数

A. 定义:用static修饰的成员函数称为静态成员函数。

B. 特点

静态成员函数没有this指针,因此不能访问类的非静态成员(包括非静态成员变量和非静态成员函数)。

静态成员函数可以访问其他静态成员(静态成员变量和静态成员函数)。

C. 访问

可以通过类名::静态成员函数对象.静态成员函数来调用。

静态成员函数受publicprotectedprivate访问限定符的限制。

示例

class MyClass {
public:
    static void PrintCount() { // 静态成员函数
        cout << "Count: " << count << endl;
    }
    static int count; // 静态成员变量
};

int MyClass::count = 0; // 静态成员变量初始化

int main() {
    MyClass::PrintCount(); // 通过类名调用
    MyClass obj;
    obj.PrintCount(); // 通过对象调用
    return 0;
}

9.3 静态成员与非静态成员的关系

  • 非静态成员函数

        可以访问任意静态成员(静态成员变量和静态成员函数)。

        可以访问非静态成员。

  • 静态成员函数

        只能访问静态成员,不能访问非静态成员(因为没有this指针)。


9.4 静态成员的访问权限

  • 静态成员(包括静态成员变量和静态成员函数)是类的成员,受publicprotectedprivate访问限定符的限制。

  • 如果静态成员是privateprotected的,则只能在类内或友元中访问。


9.5 总结

A. 静态成员变量

  • 必须在类外初始化。

  • 为所有类对象共享,存储在静态区。

  • 可以通过类名::静态成员变量对象.静态成员变量访问。

B. 静态成员函数

  • 没有this指针,只能访问静态成员。

  • 可以通过类名::静态成员函数对象.静态成员函数调用。

C. 访问权限

  • 静态成员受访问限定符的限制。

D. 使用建议

  • 静态成员适合用于共享数据或工具函数,但应谨慎使用,避免滥用。

10. 友元

10.1 友元的基本概念

A. 声明方式

  • 在类内部声明函数或类时,在其前方添加friend关键字。

  • 友元声明不受类访问限定符(public/private/protected)的限制,可以写在类的任意位置。

B. 作用

  • 允许友元函数或友元类访问当前类的私有和保护成员。


10.2 友元函数

A. 定义:声明为友元的非成员函数。

B. 特点

  • 友元函数不是类的成员函数,但可以访问类的所有成员(包括私有和保护成员)。

  • 一个函数可以同时是多个类的友元函数。

  • 友元函数需要在类内声明,但无需在类外再次声明

C. 示例

class MyClass {
// 声明友元函数
friend void PrintSecret(const MyClass& obj);
public:
...
private:
    int _secret = 42;
};

// 友元函数定义(可以直接访问私有成员)
void PrintSecret(const MyClass& obj) {
    cout << obj._secret << endl; // 允许访问私有成员
}

10.3 友元类

A. 定义:声明为友元的另一个类。

B. 特点

  • 友元类的所有成员函数都可以访问当前类的私有和保护成员。

  • 单向性:友元关系是单向的。若AB的友元类,B不会自动成为A的友元类。

  • 不可传递性:若AB的友元类,BC的友元类,A不能直接访问C的私有成员。

C. 示例

class B; // 前向声明

class A {
private:
    int _value = 10;

public:
    friend class B; // B 是 A 的友元类
};

class B {
public:
    void AccessA(A& a) {
        cout << a._value << endl; // 允许访问 A 的私有成员
    }
};

// B 可以访问 A 的私有成员,但 A 不能访问 B 的私有成员。

10.4 友元的注意事项

A. 优点

  • 提供灵活性,方便某些特殊场景(如运算符重载、跨类协作)。

B. 缺点

  • 破坏封装性:友元直接访问私有成员,违背面向对象的封装原则。

  • 增加耦合度:友元关系使类与外部代码紧密耦合,影响代码维护性。

C. 使用建议

  • 尽量少用友元,优先通过公有接口(public方法)访问数据。

  • 如果必须使用友元,应严格限制友元函数或友元类的范围。


10.5 总结

特性友元函数友元类
声明方式friend void Func();friend class FriendClass;
访问权限可访问类的私有和保护成员所有成员函数均可访问私有和保护成员
关系方向单向单向
作用域不属于类独立类
典型用途运算符重载、工具函数紧密协作的类

11. 内部类

11.1 内部类的定义

  • A. 概念:在另一个类内部定义的类称为内部类(Nested Class)。

  • B. 特性

    • 内部类是一个独立的类,其作用域受外部类限制。

    • 内部类默认是外部类的友元类,可以访问外部类的所有成员(包括私有和保护成员)。

    • 外部类的对象中不包含内部类的成员(内部类独立存储)。


11.2 内部类的访问权限

A. 访问控制

  • 内部类的访问权限由外部类的访问限定符(public/private/protected)决定。

  • 若内部类声明在外部类的private区域,则它成为外部类的“专属工具”,外部代码无法直接使用。

B. 外部类的访问

  • 外部类不能直接访问内部类的私有成员,除非显式声明友元关系。


11.3 内部类的核心特性
  • 默认友元关系

    class A {
    private:
        static int _k;
        int _h = 1;
    public:
        class B { // B 默认是 A 的友元类
        public:
            void f(const A& a) {
                cout << _k << endl;    // 允许访问 A 的私有静态成员
                cout << a._h << endl;  // 允许访问 A 的私有成员
            }
        };
    };
    int A::_k = 1;

内部类 B 可以直接访问外部类 A 的私有成员 _k 和 _h

  • 独立存储

    int main() {
        cout << sizeof(A) << endl; // 输出:4(仅包含 int _h,静态成员 _k 不占用对象空间)
        A::B b; // 内部类的实例化
        A aa;
        b.foo(aa);
        return 0;
    }

sizeof(A) 的结果为 4,证明外部类对象中不包含内部类成员。


11.4 内部类的使用场景

  • 紧密协作的类:当两个类高度耦合,且内部类主要用于服务外部类时。

  • 封装工具类:若某个类仅作为外部类的辅助工具,可将其设计为内部类。

  • 访问控制:通过将内部类声明为 private,限制其仅在外部类内部使用。


11.5 内部类与友元类的对比

特性内部类友元类
默认访问权限是外部类的友元,可访问所有成员需显式声明友元关系
作用域受外部类作用域限制独立作用域
存储关系不占用外部类对象内存独立存储
典型用途实现与外部类紧密相关的功能模块跨类协作,但需访问私有成员

12. 匿名对象

先看一段代码:

class A {
public:
    A(int a = 0) : _a(a) { cout << "A(int a)" << endl; }
    ~A() { cout << "~A()" << endl; }
private:
    int _a;
};

class Solution {
public:
    int Sum_Solution(int n) { return n; }
};

int main() {
    A aa1;          // 有名对象:构造函数调用,析构函数在main结束时调用
    A();            // 匿名对象:构造函数和析构函数均在本行触发
    A(1);           // 同上
    A aa2(2);       // 有名对象:析构函数在main结束时调用
    Solution().Sum_Solution(10); // 匿名对象调用函数后立即销毁
    return 0;
}

12.1 匿名对象的定义

A. 语法:通过类型(实参)直接创建对象,不指定对象名称。

B. 对比

  • 有名对象类型 对象名(实参),例如 A aa1(10);

  • 匿名对象类型(实参),例如 A(10); 或 Solution().Sum_Solution(10);


12.2 匿名对象的生命周期

  • 生命周期:匿名对象的生命周期仅持续到当前语句结束,之后立即调用析构函数。

  • 验证示例

    A();          // 创建匿名对象,本行结束时调用析构函数
    A(1);         // 同上
    Solution().Sum_Solution(10); // 匿名对象调用函数后立即销毁

    上述代码运行后,每行匿名对象的析构函数~A()会在本行结束时触发。


12.3 匿名对象的使用场景

  • 临时操作:需要临时创建一个对象执行某个操作,无需保留对象。

    // 直接调用成员函数
    Solution().Sum_Solution(10); // 匿名对象执行完函数后立即销毁
  • 避免歧义:解决函数声明与对象定义的语法冲突。

    // 错误:编译器无法区分这是函数声明还是对象定义
    // A aa1();  
    
    // 正确:使用匿名对象直接初始化
    A(); 

12.4 匿名对象与有名对象的对比

特性有名对象匿名对象
语法A aa(10);A(10);
生命周期持续到作用域结束(如函数结束)仅当前语句结束
内存管理需手动控制生命周期自动销毁,无需手动管理
典型用途需要重复使用的对象一次性操作或临时计算

12.5 注意事项

A. 构造函数歧义

  • 若类的构造函数支持无参调用(如默认参数),A aa1();会被编译器解析为函数声明而非对象定义。

  • 使用匿名对象可避免此问题。

B. 性能优化

  • 匿名对象适用于轻量级临时操作,频繁创建可能影响性能。

C. 作用域限制

  • 匿名对象无法跨语句使用,例如:

    A().Print();   // 合法:匿名对象在本行使用
    // A a = A();  // 合法:但此时会触发拷贝构造(可能被编译器优化)


13. 对象拷⻉时的编译器优化

13.1 编译器优化的核心原则

  • 目标:在保证程序正确性的前提下,尽可能减少不必要的对象拷贝操作。

  • 优化范围:主要针对构造函数、拷贝构造函数、赋值运算符的调用。

  • 标准约束:C++标准未严格规定优化细节,不同编译器策略可能不同(如VS2019与VS2022行为差异)。


13.2 优化场景分类与示例分析

后续的示例基于以下类定义:

class A {
public:
    A(int a = 0) 
        : _a1(a) 
    { 
        cout << "A(int a)" << endl; 
    }
    A(const A& aa) 
        : _a1(aa._a1) 
    { 
        cout << "A(const A& aa)" << endl; 
    }
    A& operator=(const A& aa) 
    {
        cout << "A& operator=(const A& aa)" << endl;
        if (this != &aa) _a1 = aa._a1;
        return *this;
    }
    ~A() 
    { 
        cout << "~A()" << endl;  
    }
private:
    int _a1 = 1;
};

场景 1:函数传参时的优化

(1) 显式传递对象(无优化)
void f1(A aa) {}

int main() {
    A aa1;
    f1(aa1); // 触发一次拷贝构造
}

输出

A(int a)      // aa1构造
A(const A& aa) // 拷贝构造传给f1参数
~A()          // f1参数析构
~A()          // aa1析构
(2) 隐式类型转换(构造+拷贝合并优化)
f1(1); // 隐式转换:int -> A

优化后输出

A(int a)      // 直接构造临时对象
~A()          // 临时对象析构
(3) 显式构造临时对象(构造+拷贝合并优化)
f1(A(2)); // 显式构造临时对象

优化后输出

A(int a)      // 直接构造临时对象
~A()          // 临时对象析构

场景 2:函数返回值优化(RVO/NRVO)

(1) 纯返回值场景(可能跨行优化)
A f2() {
    A aa;
    return aa;
}

int main() {
    f2(); // 可能优化为直接构造临时对象
}

不同编译器行为

  • VS2019 Debug

    A(int a)      // aa构造
    A(const A& aa) // 拷贝构造返回值
    ~A()          // aa析构
    ~A()          // 返回值析构

  • VS2022 Debug(更激进优化):

    A(int a)      // 直接构造返回值
    ~A()          // 返回值析构

(2) 返回值用于初始化对象(直接构造)
A aa2 = f2(); // 可能优化为直接构造

优化后输出

A(int a)      // 直接构造aa2
~A()          // aa2析构

场景 3:无法优化的场景

返回值赋值给已有对象(必须拷贝)
aa1 = f2(); // 无法优化构造+拷贝+赋值

输出

A(int a)            // f2内部构造
A(const A& aa)      // 拷贝构造返回值
~A()                // f2内部对象析构
A& operator=()      // 赋值给aa1
~A()                // 返回值析构

13.3 编译器优化策略对比

场景VS2019 DebugVS2022 Debug
f1(A(2))合并构造+拷贝为直接构造同上
A aa2 = f2()可能保留一次拷贝直接构造目标对象
aa1 = f2()保留拷贝构造+赋值同上

13.4 优化原理总结

  1. 连续操作合并
    同一表达式中的构造+拷贝构造会被合并为直接构造。

  2. 返回值优化(RVO)
    函数返回局部对象时,可能直接在调用处构造目标对象。

  3. 赋值操作限制
    赋值运算符(operator=)无法与构造函数合并优化。


13.5 注意事项

  • 避免过度依赖优化:不同编译器的优化策略可能不同,关键代码需测试验证。

  • 优先使用初始化A aa = f2(); 比 A aa; aa = f2(); 更易被优化。

  • 谨慎使用隐式转换f1(1) 虽然高效,但可能降低代码可读性。


phew~那么本次关于类和对象部分的知识分享就此结束了~

本文章说句实话真的很长 很感谢你能够看到这里~

如果感觉对你有些许的帮助也请给我三连 这会给予我莫大的鼓舞!

之后依旧会继续更新C++学习分享

那么就让我们

下次再见~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值