【C++】类和对象(下)——初始化列表,类型转换,static成员,友元,内部类,匿名对象,编译器的优化拓展

🧑‍💻博主名称:鱼子星_

✅数据结构专栏:【数据结构】

✅算法竞赛专栏:【算法竞赛】

✅C++系列专栏:【C++从零开始系列】


一. 初始化列表

引入

在前面篇章讲过,C++中,引用和被 const 修饰的对象在定义时就要初始化,如果一个类中的成员变量有这两种类型的对象,当一个类被实例化的时候,那这两种对象是在什么地方初始化的呢?我们不妨猜想它在构造函数内部被初始化,场景代码如下:

cpp 复制代码
class A
{
public:
	A(int& r, int a)
	{
		_rf = f;
		_a = a
	}
private:
	int& _rf;
	const int _a;
};
int main()
{
	A aa;
	return 0;
}

类A中含有上述所说的两种成员变量,现在使用类A实例化一个对象 aa,运行,结果如下:

💻 运行结果:编译失败

-

1. 初始化列表的定义

我们发现,如果直接在构造函数里面对引用和 const 成员变量进行初始化,编译器会报错,这就代表在构造函数内部的赋值并不是对成员变量的初始化,但是这两个成员变量又严格规定需要在定义时初始化,怎么办?

这个问题可以使用初始化列表来解决。因为当一个类类型的对象被定义时,它的成员变量在初始化列表中定义初始化,所以在初始化列表中初始化引用和const对象可以满足这两种对象定义时就要初始化的条件。

初始化列表的使用格式

初始化列表的使用方式比较特殊,初始化列表夹在构造函数的函数名和 { 之间,以 : 作为初始化列表的起始位置,不同的成员变量之间使用 , 间隔。格式及代码如下:

📄初始化列表格式

复制代码
class 类名
{
	构造函数名
		/* 初始化列表 */
		:成员变量(初始化的目标值)
		,成员变量(初始化的目标值)
		,成员变量(初始化的目标值)
	{
		构造函数内部
	}
};

📄代码示例

cpp 复制代码
class A
{
public:
	A(int& r, int a)
		:_rf(r) /* 初始化列表 */
		,_a(a)
	{
		_rf = f;
		_a = a
	}
private:
	int& _rf;
	const int _a;
};

2. 初始化列表的特性

  • 初始化列表是类实例化时成员变量定义和初始化的地方
  • 初始化列表的初始化顺序和成员变量声明的顺序相同。拿上面的代码举个例子,因为成员变量_rf的声明在_a的前面,所以当初始化列表对成员变量进行初始化时,不管这两个成员变量在初始化列表中的位置如何,_rf一定会比_a先初始化。

📚️ 为了让程序的可读性更高,一般情况下尽量让初始化列表中初始化的顺序和成员变量声明的顺序保持一致

  • 引用,被 const 修饰,没有默认构造函数的类类型的成员变量必须在初始化列表初始化,其他成员变量可选择是否在初始化列表进行初始化
  • 不管成员变量是否显式的在初始化列表进行初始化,编译器都会自行走一遍初始化列表,走的顺序也是严格按照成员变量声明的顺序。
  • 当成员变量没有显式的在初始化列表中初始化时,对于内置类型,不同的编译器会有不同的值,对于自定义类型,编译器会调用它的默认构造函数

📚️ 成员变量能在初始化列表初始化的尽量都在初始化列表初始化,因为不管成员变量是否在初始化列表进行初始化,编译器都会走一个遍初始化,所以成员变量尽可能的都在初始化列表中进行初始化。

cpp 复制代码
class Time
{
public:
	Time(int a = 12)
	{
		_hour = a;
	}
private:
	int _hour;
};

class Date
{
public:
	//初始化列表的操作可以看成是 成员变量的定义和初始化
	//初始化列表初始化的顺序是按照成员的声明顺序走的
	//一般初始化列表的顺序也按照声明顺序写
	Date(int& x, int year = 1, int month = 1, int day = 1)
		:_year(year)
		,_month(month)
		,_day(day)
		,rf(x)
		,d(1)
	{
		//...
		//函数体内部一般用于初始化的收尾工作
	}
private:
	
	int _year;
	int _month;
	int _day;
	//对于引用,const修饰的变量,没有默认构造的类,都必须在初始化列表初始化
	//如果类类型的成员有默认构造,则在初始化时自动调用默认构造
	int& rf;
	const int d = 1;
	Time t;
};

3. 初始化的三种方式

既然上面说了初始化列表那么好用,那肯定会有人会问,是不是之后对于初始化,只需要写初始化列表就可以了?不可以,还是需要根据不同的情况决定。

以下列举3种不同的情况:

  1. 全部初始化操作使用初始化列表进行初始化
cpp 复制代码
class Stack
{
	/* Stack类的实现 */
};
class MyQueue
{
public:
	//如果Stack有默认构造,这样这里就不用处理两个Stack类的成员
	//如果没有呢,就需要初始化列表了
	//遇到这种情况 初始化列表需要像如下的初始化方式,这样实用性更强
	MyQueue(int n = 4)
		:stPush(n)
		,stPop(n)
		,_size(0)
	{
	}
	MyQueue(const MyQueue& q)
		:stPush(q.stPush)
		,stPop(q.stPop)
		,_size(q._size)
	{
	}
private:
	Stack stPush;
	Stack stPop;
	int _size;
};

📚️ 拷贝构造函数也是构造函数,所以可以使用初始化列表进行初始化

  1. 初始化操作需要在构造函数内部
cpp 复制代码
class A
{
public:
	A()
	{
		//像这种情况就还是需要函数体内部执行初始化操作
		memset(_a, 0, sizeof _a);
	}
private:
	int _a[10];
};

📚️ 构造函数内部的一般都是用来对初始化操作进行收尾的,最常见的情况有:对于有动态内存开辟的成员变量,需要在构造函数内部判断动态内存申请是否成功。

  1. 使用缺省值
    给成员变量一个缺省值是C++11中的一个新特性,这个缺省值相当于是给成员变量一个默认的初始值,如果该成员变量没有在初始化列表和构造函数内部被初始化,那么它的初始化的值就为缺省值
cpp 复制代码
class Date
{
public:
	Date(int year, int month, int day)
	{
		//...
		//函数体内部一般用于初始化的收尾工作
	}
private:
	int _year = 1;
	int _month = 1;
	int _day = 1;
};

📚️ 如果类中的默认构造函数是编译器生成的构造函数,因为编译器默认生成的构造函数对有缺省值的成员变量不会进行处理,所以缺省值就是那些成员变量的初始化的值



成员变量初始化逻辑示意图


二. 类型转换

在C语言中,在保证类型之间有关联的情况下,不同的内置类型之间可以进行隐式的转换,转换的逻辑由编译器完成,如下代码所示。而在C++中,类型转换的功能做了进一步的升级,在支持有关联的内置类型互相转换的同时,还支持了类类型的转换。

cpp 复制代码
double d = 3.14;
int x = d;  // 隐式类型转换 x = 3

1. 内置类型转换为类类型

类型转换的本质

那么内置类型如何转换为复杂多样的类类型呢?来看下面的场景

cpp 复制代码
class A
{
public:
	A(int a = 10)
		:_a(a)
	{
	}
private:
	int _a;
};
int main()
{
	A aa = 1;
	return 0;
}

类 A 的对象 aa 的初始化方式在构造函数篇章就已经见过了,直接使用 1 来初始化类类型的对象,这其实就是隐式类型的转换,将 int 类型转换成了 A 类型。那这个隐式类型转换的逻辑是什么呢?

其实逻辑很简单,先用 1 构造出一个 A 类型的临时对象,再将这个临时对象拷贝构造给 aa ,说到这其实反应快的人就已经发现了类型转换的关键点------构造函数

为什么可以使用 1 构造出一个 A 类型的对象呢?就是因为有一个参数为整型的构造函数,让编译器可以先使用 1 去构造出一个 A 类型的临时对象。所以说类型转换的核心还是要看构造函数。那这样来看,其实也可以设计一个构造函数让 char 类型 也可以转换为 A 类型。

cpp 复制代码
class A
{
public:
	A(int a = 10)
		:_a(a)
	{
	}
	A(char a)
		:_a(a)
	{
	}
	A(int a1, int a2)
		:_a(a1 + a2)
	{
	}
private:
	int _a;
};
int main()
{
	A aa = 'a';
	A aa1({1, 2});  //调用函数 A(int a1, int a2)
	return 0;
}

📚️ 当类型转换的目标类的函数参数有多个时,多个参数需要使用 {} 包起来


应用场景

类型转换不仅仅是在类的初始化时使用,也可以在函数传参的场景使用,如下场景,如果传参仅仅只是为了调用函数或者得到特殊的返回值,那么使用类型转换是很好的选择。

cpp 复制代码
void f(A aa)
{
	cout << "f(A aa)" << endl;
}
int main()
{
	f(1);
	return 0;
}

拓展

你知道为什么这里的引用需要使用 const 修饰吗?

cpp 复制代码
const A& aa1 = 1;

📚️ 编译器用 1 构造了一个 A 类型的临时对象,在【C+说过,临时对象具有常性,所以它的引用必须使用 const 修饰


2. 类类型转换为类类型

类类型转换成类类型也遵从内置类型转换成类类型的规律,都是需要对应的构造函数来支持类型转换,如下代码所示:

cpp 复制代码
class A
{
	// 友元
	friend class B;
public:
	A(int a = 10)
		:_a1(a)
		,_a2(a)
	{
	}
	void print()
	{ cout << _a1 << " " << _a2 << endl; }
private:
	int _a1;
	int _a2;
};
class B
{
public:
	B(A& aa)
		:_b(aa._a1 + aa._a2)
	{
	}
	void print()
	{ cout << _b << endl; }
private:
	int _b;
};

int main()
{
	A aa = 1;
	B bb = aa;
	aa.print();
	bb.print();
	return 0;
}

这里A类型可以隐式类型转换为B类型的原因为:B类型的构造函数中有B(A& aa)这种以A类型为函数参数的构造函数。

💻️ 输出结果

-

三. static成员

如何求一个类被实例化了多少次?使用一个变量统计类的构造函数和拷贝构造函数一共调用了多少次即可。那变量的种类应该如何选择呢?使用全局变量可以解决问题。

cpp 复制代码
int cnt = 0;
class A
{
public:
	A(int n = 1)
		:_a(n)
	{
		cnt++;
	}
	A(const A& aa)
		:_a(aa._a)
	{
		cnt++;
	}
private:
	int _a;
};
int main()
{
	A aa;
	A aa1 = aa;
	cout << cnt << endl;
	return 0;
}

直接使用全局变量,问题是解决了,但是这时又多了一个问题,如果我冷不伶仃在某一行来上一个 cnt++cnt-- 呢?此时算法的准确性就有待考验,既然问题是计算类被定义了多少次,那能不能使用类的成员变量来统计结果呢?

这里,就需要使用到静态成员变量来解决问题。

1. 静态成员的特性

  • 使用 static 修饰的成员变量就是静态成员变量,静态成员变量的内存在静态区上,它不单独属于任何一个对象,而是属于整个类的成员变量

📚️ 这里假设静态成员变量单独属的于每个对象(即每个类对象都对应一个相应的静态成员变量)。因为静态成员变量的生命周期是从定义起,一直到程序结束,而如果它属于某个单独的对象,就会多次的走初始化列表,又因为初始化列表的本质是定义初始化,这样就产生了同一个对象多次定义初始化的逻辑错误。

  • 静态成员变量不能通过构造函数初始化,只能在全局域初始化
  • 使用 static 修饰的成员函数就是静态成员函数,静态成员函数不能访问非静态成员变量,非静态成员函数可以访问所有的成员变量

📚️ 静态成员函数不会传递 this 指针

使用成员变量统计类的对象的个数

cpp 复制代码
class A
{
public:
	A(int n = 1)
		:_a(n)
	{
		_cnt++;
	}
	// 非静态成员函数可以访问所有的成员变量
	A(const A& aa)
		:_a(aa._a)
	{
		_cnt++;
	}
	// 静态成员函数不能访问非静态的成员变量
	// 本质上是因为静态成员函数没有传递this指针
	static int GetCount()
	{
		// 没有 this 指针,不能访问 _a
		return _cnt;
	}
private:
	int _a;
	// 静态成员不单独属于任意一个A类型的对象
	// 静态成员无法通过构造函数进行初始化,只能在全局域初始化
	// 静态成员不能给缺省值 因为缺省值还是要走初始化列表
	static int _cnt;
};
// 静态成员变量的初始化
int A::_cnt = 0;

2. 静态成员的使用

【牛客网】JZ64 求1+2+3+...+n

解法

可以定义 n 个类的对象,使用 static 成员变量记录,定义第 n 个对象时,返回值 += n

代码

cpp 复制代码
class A
{
public:
	A()
	{
		_ret += _i;
		++_i;
	}
	static int _ret;
	static int _i;
};
int A::_ret = 0;
int A::_i = 1;

class Solution {
public:
	int Sum_Solution(int n)
	{
		A aa[n];
		return A::_ret;
	}
};

结果

四. 友元

类的一大特性就是封装,可以使用访问限定符将成员变量设置成私有的,让类外不可随意访问。但是如果遇到了某些特殊的函数或者类一定需要使用类中的成员变量要怎么办?(例如:<<和>>的运算符重载)使用友元解决。

1. 友元的定义与特性

  • 要声明类 B 是类 A 的友元,只需在 A 中使用关键字 friend 来修饰 B 的声明即可,此时 B 中的任意成员函数都可以访问 A 的成员变量。

📚️ B 是 A 的友元可以理解成 B 是 A 的朋友,既然都是朋友了,那么 A 把自己的成员变量分享给 B 也没有什么问题。

  • 函数也可以声明成为某个类的友元, 一个函数或者类可以在多个类中声明友元,可以有多个朋友

  • 友元具有单向性,B 是 A 的友元不代表 A 是 B 的友元。即 B 中可以使用 A 的成员变量但是 A 中不一定可以使用 B 的成员变量

  • 友元不具有传递性,B 是 A 的友元,C 是 B 的友元,C 不是 A 的友元

cpp 复制代码
//友元尽量要少用,它会影响类的封装性
class A
{
	//友元是单向的,B是A的朋友,但是A不一定被B承认(A不一定是B的朋友)
	friend class B;  //B是A的朋友,在B中可以直接使用A的私有成员
	friend int Get(const A& aa, const B& bb);
	
private:
	int GetD() const
	{
		return 10;
	}
	int _a1;
	int _a2;
};

class B
{
	//函数也可以写友元,这样函数内部就可以使用私有成员了
	friend int GetB(const B& bb);
	friend int Get(const A& aa, const B& bb);
public:
	//友元类所有的成员函数都可以是其朋友的友元函数
	//即B中的所有成员函数都可以任意使用A的私有成员
	B(const A& aa)
	{
		_b1 = aa._a1;
		_b2 = aa._a2;
	}
	//成为一个类的友元后,其成员函数也是随便调用
	int test(const A& aa)
	{
		return aa.GetD();
	}
private:
	int _b1;
	int _b2;
};

int GetB(const B& bb)
{
	return bb._b1 + bb._b2;
}
//一个函数可以是多个函数的友元
int Get(const A& aa, const B& bb)
{
	return aa._a1 + bb._b1;
}

📚️ 友元类所有的成员函数都可以是其朋友的友元函数,即B中的所有成员函数都可以任意使用A的私有成员

五. 内部类

内部类即定义在类里面的类,也可以看成是嵌套类,代码如下:

cpp 复制代码
class A
{
public:
	//内部类 B
	class B
	{
	public:
		B(const A& aa)
		{
			_b1 = aa._a1;
			_b2 = aa._a2;
		}
	private:
		int _b1;
		int _b2;
	};

	A(int a1 = 1, int a2 = 2)
		:_a1(a1)
		,_a2(a2)
	{
	}	
private:
	int _a1 = 1;
	int _a2 = 2;
};

内部类被定义在类中,除了调用逻辑受访问限定符限制外,内部类与全局域定义的类没有什么本质上的区别。只有当两个类关系非常密切时才会定义为内部类。

📚️ 内部类默认是外部类的友元,即 B 默认是 A 的友元,所以在 B 中可以使用 A 的私有成员。

内部类的使用场景

在求 1 + 2 + ... ... + n 1 + 2 + ...... + n 1+2+......+n 的场景中,类 A 单纯是为了给类 Solution 的成员函数计算结果,它们关系密切,可以将 A 改写成内部类

cpp 复制代码
class Solution {
public:
	class A
	{
	public:
		A()
		{
			_ret += _i;
			++_i;
		}
	};
	int Sum_Solution(int n)
	{
		A aa[n];
		return _ret;
	}
private:
	static int _ret;
	static int _i;
};

int Solution::_ret = 0;
int Solution::_i = 1;

六. 匿名对象

匿名对象就是没有没有名字的对象,以 类型 + 变量名 的形式定义的对象都是有名对象。匿名对象定义时就会自动调用构造函数。

1. 匿名对象的特点

  • 匿名对象定义形式 类型 + ()
  • 匿名对象的生命周期只在当前定义行(只有一行)
  • 匿名对象定义和销毁时也会调用默认成员函数,同时它也可以随意调用成员函数
cpp 复制代码
class A
{
	//类内部逻辑
};

// 有名对象
int x = 10;
A aa;
//匿名对象
A();

2. 匿名对象的使用场景

匿名对象一般用来调用类的成员函数,进行函数传参。某些场景使用匿名对象可以增强代码的简洁性。

cpp 复制代码
class A
{
public:
	A(int a = 1)
		:_a(a)
	{
	}
	int Get(int n = 10)
	{
		cout << "Get(int n = 10)" << endl;
		return n * 10;
	}
private:
	int _a;
};
void f(A aa)
{
}
int main()
{
	//使用有名对象需要先定义,再使用成员函数
	A aa;
	int x = aa.Get(3);
	//使用匿名对象直接使用成员函数
	int x = A().Get(3);
	//使用匿名对象进行函数传参
	f(A());
	return 0;
}

📚️ 匿名对象的性质和临时对象有些相似,具有常性

七. 编译器的内部优化

现代的编译器会在保证程序正确性的情况下做一些优化,让程序减少一些没有必要的拷贝,提高效率。这里以前面讲到的类型转换进行证明。

cpp 复制代码
class A
{
public:
	A(int a = 10)
		:_a(a)
	{
	}
private:
	int _a;
};
int main()
{
	A aa = 1;
	return 0;
}

代码中, aa 的定义和初始化的逻辑是 构造函数 + 拷贝构造(使用 1 构造一个临时对象,再将这个临时对象拷贝给 aa),现在修改代码来验证猜想

cpp 复制代码
class A
{
public:
	A(int a = 10)
		:_a(a)
	{
		cout << "A(int a = 10)" << endl;
	}
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}
private:
	int _a;
};
int main()
{
	A aa = 1;
	return 0;
}

💻️ 输出

-

显然,实际结果和预期结果不同,原先的构造函数 + 拷贝构造,变成了直接构造,而这,其实也就是现代编译器 (vs 2022) 对程序进行了优化。


函数传参的优化

可以对如下三种函数传参的方案进行猜想,哪个方案构造,拷贝次数最少,效率最高。

cpp 复制代码
void f(A aa)
{
}
int main()
{
	f(1); //方案一
	f(A(1)); //方案二
	//方案三
	A aa(1);
	f(a);
	return 0;
}
  • 方案一,二:直接构造
  • 方案三:构造函数 + 拷贝构造

如果编译器没有优化效果的话,三个方案本来都应该是先构造再拷贝,但是有了编译器优化之后,使用类型转换或者匿名对象传参效率更高。

📖 结论:在不影响正确性的前提下,函数传值传参时,使用类型转换或者匿名对象比先定义对象再传参效率更高

函数返回值的优化

cpp 复制代码
A f2()
{
	A aa;
	return aa;
}
int main()
{
	//方案一
	A a1 = f2();
	//方案二
	A a2;
	a2 = f2();
	return 0;
}
  • 方案一:

  • 方案二:

为什么使用方案一编译器可以优化到直接构造呢?这里编译器认为 a1 的初始化都是直接用的函数的返回值,而函数内部又定义了这个返回值,所以干脆直接把函数内部定义的 aa 看成是 a1 的别名,这样优化即不会影响正确性,又提升了效率。

📖 结论:在不影响正确性的前提下,将函数返回值用来初始化比用来赋值效率更高。


本篇完结

下期预告:

  • 【C++】内存管理
相关推荐
王老师青少年编程9 小时前
2025年【江苏“信息与未来”编程思维】真题及题解(T2:坐标变换)
c++·题解·真题·坐标变换·编程思维·江苏·信息与未来
千寻xun10 小时前
一、理论篇-NVME协议学习笔记
笔记·学习·fpga开发·nvme ssd·nvme协议
似是燕归来11 小时前
嵌入式工程师进阶指南:适配器编程设计模式一
c语言·stm32·适配器模式·嵌入式开发
skywalk816312 小时前
设计并实现段言的 C FFI 绑定机制 @Trae
c语言·开发语言·python·编程
2023自学中13 小时前
C++ 内存追踪器
linux·c++
额鹅恶饿呃14 小时前
C语言中的数据结构和变量
c语言·数据结构·算法
Piko61415 小时前
锐捷 VSU 虚拟化堆叠配置
运维·网络·笔记
AOwhisky15 小时前
Python 学习笔记(第三期)——流程控制:条件判断与循环结构
运维·笔记·python·学习·云原生·流程控制·循环
Piko61417 小时前
锐捷交换机 DHCP Server部署
运维·网络·笔记