C++初阶-类和对象(下)

目录

1.再探构造函数

2.类型转换

3.友元

4.static成员

5.内部类

6.匿名对象

*7.对象拷贝时的编译器优化(非必学)

8.总结


1.再探构造函数

(1)之前我们实现构造函数时,初始化成员变量主要使用函数体内赋值,构造函数初始化还有一种方式,就是初始化列表,初始化列表的使用方式是以一个冒号开始,接着是以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。

我们把这个列表放在我们自己写的构造函数的参数列表后面,加上一个:后再写成员变量(值或表达式)这样的形式,而且这个顺序是没有要求的,如:

cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public:
	Date(int year, int month, int day)
		:_year(year)
		, _month(month)
		, _day(day)
	{
		
	}
	void Print()const
	{
		cout << _year << "年" << _month << "月" << _day << "日" << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2025, 4, 21);
	d1.Print();
	return 0;
}

则结果为:

函数体内可以不写任何语句,也可以把一部分初始化放入函数体内,其次,这还可以解决:默认生成的构造对于自定义类型的成员变量只会调用它的默认构造。如之前我们写的MyQueue和Stack类:

cpp 复制代码
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
	//编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化
private:
	Stack pushst;
	Stack popst;
};

我们如果开始需要开的空间不想要为4,即我们不想调用它的构造函数,我们就可以直接在MyQueue里面改为:

cpp 复制代码
// 两个Stack实现队列
class MyQueue
{
public:
	MyQueue(int n)
		:pushst(n)
		, popst(n)
	{

	}
private:
	Stack pushst;
	Stack popst;
};

这个适用于我想给一个显式值,不想用默认值和如果栈没有提供默认构造或者不想用栈提供的默认构造,想自己显式调用时的情况。

(2)每个成员变量在初始化列表中只能出现一次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。

我们如果MyQueue q(1000);是对象的整体定义,而成员变量在类中的Stack _pushst;只是声明。但我们还得找个位置给每个成员变量定义,初始化列表即每个成员变量定义的地方。**有一些特殊的成员变量,必须在定义时初始化:没有默认构造的类类型变量、const成员变量、引用成员变量。**必须放在初始化列表位置进行初始化,否则会编译报错。引用必须在定义时初始化(语法规定);const只有一次初始化修改它的机会就是在定义的时候,后面改不了;没有默认构造,必须传参数。因为对象整体定义对这三者无效。而初始化列表就是针对这三者的。

而其他成员变量可以在初始化列表位置初始化,也可以在函数体内初始化。

(3)建议尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显式在初始化列表初始化的内置成员是否初始化取决于编译器,C++并没有规定。对于没有显式在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数;如果没有默认构造会编译错误。

(4)C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显式在初始化列表初始化的成员使用的。

cpp 复制代码
// 两个Stack实现队列
//报错的情况:
class MyQueue
{
public:
	MyQueue(int n, int a)
		:pushst(n)
		, popst(n)
		,_size(a)
	{

	}
private:
	Stack pushst;
	Stack popst;
	int _size;
};
//不报错的情况
class MyQueue
{
public:
	MyQueue(int n , int a)
		:pushst(n)
		, popst(n)
		, _size(a)
	{

	}
private:
	Stack pushst =1000 ;
	Stack popst = 1000 ;
	int _size = 0;
};
int main()
{
	MyQueue q;
	return 0;
}

实际上我们不写这个默认构造,下面这个MyQueue定义也是没有问题的

如果我们在构造函数时设置一个缺省值,那么就会根据构造函数的缺省值来进行:

cpp 复制代码
class Date
{
public:
	Date(int year = 2025 , int month = 4, int day = 21)
		:_year(year)
		, _month(month)
		, _day(day)
	{

	}
	void Print()const
	{
		cout << _year << "年" << _month << "月" << _day << "日" << endl;
	}
private:
	int _year = 10;
	int _month = 100;
	int _day = 1000;
};
class MyQueue
{
public:
	MyQueue(int a , int n = 100)
		:pushst(n)
		, popst(n)
	{
		_size = a;
	}
	void Print()const
	{
		cout << _size << endl;
	}
private:
	Stack pushst =1000 ;
	Stack popst = 1000 ;
	int _size = 0;
};
int main()
{
	MyQueue q(3);
	q.Print();
	Date d;
	d.Print();
	return 0;
}

若我们不在构造函数上写缺省值,且没在初始化列表初始化并且还没有在类实例化对象时传参,我们用的就是声明时的缺省值;若写了缺省值,且在初始化列表初始化了并且还没有在类实例化对象时传参,那么就直接用的是构造函数的缺省值;否则都用的是传的实参。

如果有个数组int* _a;那我们在初始化列表时_a((int*)malloc(40))虽然空间开好了,但是我们还是需要检查这个是否真的开好了;如果真开好了,还需要把数组进行初始化。当然,我们也可以把(int*)malloc(40)作为缺省值。

三个建议:一、全部来初始化列表显式初始化;二、全部都在声明时加缺省值;三、尽量不要在构造函数和声明时缺省值混着用。

当然如果在声明时这样写:const int _i={1};是没有问题的,也可以const int _i{1};可以用{}进行初始化。

(5)初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。

如:

cpp 复制代码
class A
{
public:
	A(int a)
		:_a1(a)
		, _a2(_a1)
	{

	}
	void Print() const
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2 = 2;
	int _a1 = 2;
};
int main()
{
	A aa1(1);
	aa1.Print();
	return 0;
}

请问,程序运行结果是什么?

A、输出1 1

B、输出2 2

C、编译报错

D、输出1 随机值

E、输出1 2

F、输出2 1

首先我们能确定:_a1一定是1的,所以B、F排除掉;

通过我们分析发现:_a2的声明顺序比_a1先的,所以是_a2=_a1,但是此时_a1已经创建起来了,但是还没赋值,是随机值,(因为空间是在实例化对象的时候就已经开辟好空间了),所以_a2是随机值。

2.类型转换

(1)C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数;

(2)构造函数前面加explicit就不再支持隐式类型转换;

(3)类类型的对象之间也可以隐式类型转换,需要对应的构造函数支持。

如:

cpp 复制代码
class A
{
public:
	A(int a1 )
		:_a1(a1)
	{

	}
	A(int a1, int a2)
		:_a1(a1)
		, _a2(a2)
	{

	}
	void Print() const
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a1 = 1;
	int _a2 = 2;
};
int main()
{
	//调用第一个
	A aa1(1);
	//调用第二个
	A aa2(1, 2);
	//类型转换
	A aa3 = 1;
	A& aa4 = 1;
	//会报错
	return 0;
}

A aa3走的是类型转换,int转换为A类型,类型转换会产生一个临时对象(调用构造函数),再用临时对象拷贝构造给了aa3。但是编译器发生了优化,会变成直接构造。

A& aa4 = 1会报错的原因是临时对象具有常性,所以可以加个cosnt在A前面 。这是单参数支持的类型转换,两个参数的(类型转换)就可以这样写:A aa5={2,2};同理,也要用引用的时候就改为:const A& aa5={2,2}这就是内置类型与自定义类型之间的类型转换。

这样有什么意义呢?

cpp 复制代码
class Stack
{
public:
	void Push(const A& aa)
	{
	}
};
int main()
{
	//以前
	Stack st1;
	A aa1(7);
	st1.Push(aa1);
	Stack st3;
	A aa3(8, 8);
	st3.Push(aa3);
	
	//现在
	Stack st2;
	st2.Push(7);
	Stack st4;
	st4.Push({ 8,8 });
    return 0;
}

假设这里的栈push(入栈)了一个aa对象,之前我们是st1和st3这种办法push的,现在我们直接用st2和st4两种方式就可以入栈了。我们如果不期望这种隐式类型转换的话,我们就可以在A的构造函数前加explicit但是它仍然支持显式类型转换。了解一下就可以了。

之前我们的内置类型也能转换,但是必须要有关联的才能转换,但是注意自定义类型之间的转换一定要借助对应的构造函数,如:

cpp 复制代码
class A
{
public:
	A(int a1 )
		:_a1(a1)
	{

	}
	A(int a1, int a2)
		:_a1(a1)
		, _a2(a2)
	{

	}
	void Print() const
	{
		cout << _a1 << " " << _a2 << endl;
	}
	int Get()const
	{
		return _a1 + _a2;
	}
private:
	int _a1 = 1;
	int _a2 = 2;
};
class B
{
public:
	B(const A& a)
		:_b(a.Get())
	{
	}
private:
	int _b;
};
int main()
{
	A aa1(1);
	B bb1 = aa1;
	return 0;
}

如果我们不加这个构造函数,或者我们不在构造函数里面加const,这都会报错的,你可以试一下。

3.友元

(1)友元提供了一种突破类访问限定符封装的方式。友元分为:友元函数和友元类,在函数声明或者类声明的前面加friend,并且把友元声明放到一个类的里面。

如,我们之前实现的流插入和流提取中,我们如果用内置成员函数的话,那么第一个参数就是自己类类型,我们如果这样写就会发现我们需要把类类型放在>>的左边(流插入)或者<<的左边,这样不符合我们的使用习惯,而且不能连续插入和提取,则我们不能用这个作为成员函数,所以必须用其他外部。但是外部没办法访问这个成员变量,这样没办法访问,所以我们就可以这样写:

cpp 复制代码
//定义Date类
class Date
{
public:
	//友元函数
	friend ostream& operator<<(ostream& out,const Date& d );
	friend istream& operator>>(istream& in, Date d);
private:
	//成员变量的定义
	int year = 1 ;
	int month = 1;
	int day = 1;
};
//<<运算符重载
ostream& operator<<(ostream& out,const Date& d)
{
	out << d.year << "年" << d.month << "月" << d.day << "日" /*<< endl*/;
	return out;
}
//>>运算符重载
istream& operator>>(istream& in, Date d)
{
	in >> d.year >> d.month >> d.day;
	return in;
}

这种方式我们就可以直接用了(这只是举个例子,没看懂之后会学的)。

(2)外部友元函数课访问类的私有和保护成员,友元函数仅仅是一种声明,它不是类的成员函数,我们也不能在函数定义时加个friend。

(3)友元函数可以在类定义的任何地方声明,不受访问限定符限制。

(4)一个函数可以是多个类的友元函数。

(5)友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员。

如果有A、B两个类,B可能要大量访问A,每个函数都作为友元声明写入A很烦,故可以在A类中加友元声明:friend class B;直接让类成为友元类,但A不是B 的友元。也就是说A可以随意访问B中的任意成员,不受访问限定符限制,而B不可以反过来访问。

(6)友元类的关系是单向的,布局有交换性,比如:A类是B类的友元,但是B类不是A类的友元。

(7)友元关系不能传递,如果A类是B类的友元,B类是C类的友元,但是A类不是C类的友元。

(8)友元有时提供了便利。但是又元会增加耦合度,破坏了封装,所以友元不宜多用。

4.static成员

(1)用static修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化。

(2)静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。

static可以改变链接属性,只在当前文件可见,如果在.h放了个文件或变量,多个.cpp包含了以后就会出现链接错误。其次,我们计算类的大小的时候,静态成员变量不包含在这里面的,因为它在静态区。如:

cpp 复制代码
class A
{
public:
	//错误
	//不能在初始化列表里面初始化
	A(int a)
		:_sout(a)
	{

	}
private:
	static int _sout;
	//错误:
	static int _s = 1;
};
//也不能不初始化
int A::_sout = 0;

我们不能在类里面初始化,只能在类里面声明,但是可以在类里面使用,且初始化形式必须和我写的擦差不多!

由于静态成员变量不会受对象是否实例化影响,所以我们可以把这个对象用于计算有多少个已经实例化的对象,在构造函数里面加一个++_sout,如果我们还想测到底还有多少个没销毁的对象,我们可以在析构函数里面加:--_sout这种方式。

(3)用static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针

因为没有this指针,所以不能访问成员变量 ,因为每个成员变量前都有this->。我们可以用静态成员函数来返回静态成员变量,只能访问静态成员变量

(4)静态成员函数可以访问其他的静态成员,但是不能访问非静态的,因为没有this指针。

**非静态的成员函数也不可以访问!**反之就可以!

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

(6)突破类域就可以访问静态成员,可以通过类名::静态成员或者对象.静态成员来访问静态成员变量和静态成员函数。

(7)静态成员也是类的成员,受访问限定符的限制。

(8)静态成员变量不能再声明位置给缺省值初始化,因为缺省值是给构造函数初始化列表的,静态成员变量不属于某一个对象,不走构造函数初始化列表。

题目:

已经有ABCD四个类的定义,程序中A,B,C,D构造函数调用的顺序是:()?

cpp 复制代码
class A
{
};
class B
{
};
class D
{
};
//类的声明
C c;
int main()
{
	A a;
	B b;
	static D d;
	return 0;
}
class C
{
};

由于C是全局变量,所以肯定先构造C,D是静态变量,由于其声明周期是全局的,但它不是在main函数之前就初始化的,而是在运行到这个地方才会调用构造,所以顺序是:CABD

如果是析构函数调用顺序呢?

有个特点:后定义的先析构,但是这是在局部里面起作用,所以是B A,其次局部静态的先析构,再全局析构,所以析构的顺序是:BADC

5.内部类

(1)如果一个类定义在另一个类的内部,这个就叫内部类。它是一个独立的类,跟定义再全局相比,它知识收到外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类。

cpp 复制代码
class A
{
//public:
	class B
	{
	public:
		void foo(const A& a)
		{
			cout << _k << a._h << endl;
		}
		int _b1 = 1;
		int _b2 = 1;
	};
//private:
	static int _k;
	int _h = 1;
};
int A::_k = 1;
int main()
{
	cout << sizeof(A) << endl;
	return 0;
}

结果为:

这也说明了A里面没有B对象,证明内部类并不是在A里面,只是定义在A里面,受类域和访问限定符的限制而已。只是访问时需要A:: B b;指定A这个类域才能实例化出对象,如果是私有的,则外部是访问不了的。

(2)内部类默认是外部类的友元。

B可以用A里面的元素,但是A不可以用B里面的私有部分,A不是B的友元。

(3)内部类本质也是一种封装。

如A类实现出来就是给B类使用,那么可以把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其他地方都用不了。

这个只要了解一下即可。

6.匿名对象

用类型(实参)定义出来的对象叫做匿名对象,相比之前我们定义的类型 对象名(实参)定义出来叫有名对象。匿名对象的生命周期只在这一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象。

如,A是一个类,我们写出的A aa1;A aa2(2);这种方式是有名对象,而我们若这样写A(1);A(2);则是匿名对象。其中匿名对象生命周期只有这一行,在下一行就会调用析构函数

临时对象时编译器自己产生的,匿名对象时我们自己创建的。

如果想要延长匿名对象的生命周期,可以:const A& ref = A();加const是因为临时对象具有常性。匿名对象跟着引用走,也就是说ref销毁,匿名对象才会销毁。这个匿名对象是开了空间的,所以能用引用!

这个东西的用法就是:

(1)我们之前打印类的一句话,之前是要实例化一个对象再调用函数的如:A a;a.Print();而现在是A().Print();注意:这里的()不能去掉,如果去掉就相当于实例化对象了,而我们也不能A a()这样写,因为会编译报错,不知道是实例化对象还是调用函数。

(2)我们若在一个函数中形参为自定义类型,但我们想给缺省值,可以:

void func2(A aa=A()){}给匿名对象作为缺省参数,但传值一般用引用,所以我们可以改为:

void func2(const A& aa=A())

匿名对象之后还会用到的,所以这里就简单介绍一下用法就可以了!

下一节是了解内容,深层太难理解了,所以了解一下就可以了,不看也没什么事!

*7.对象拷贝时的编译器优化(非必学)

(1)现代编译器为了尽可能提高程序效率,在不影响正确性的情况下会尽可能减少一些传参和传返回值的过程中可以省略的拷贝。

cpp 复制代码
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}
	A& operator=(const A& aa)
	{
		cout << "A& operator=(const A& aa)" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}
	~A()
	{
		cout << "~A()" << endl;
	}
private:
	int _a;
};

(1)如果A aa1=1;这句话就是类型转换,编译器会优化成直接构造(优化之前是构造加拷贝构造)。

而直接构造就相当于A aa1(1);虽然二者结果一样,但是过程不一样,我们可以把它写到主函数里面,则会打印出这个结果:

这是一个优化后的结果,发现是直接构造加析构。

如果想要看没有优化的结果,则需要到Linux在编译时加上g++ test.cpp -fno-elide-const ructors的方式关闭构造相关的优化。

(2)若void f1(A aa){} A aa1(1);f1(aa1);

这样是不会优化的,因为对象开始已经创建出来了,后面构造加拷贝构造(aa1对象)。但是如果

f1(1);f1(A(1));

f1(1)有类型转换,产生临时对象再构造,拷贝构造,被优化,A(1)按语法步骤先构造再拷贝构造,会被优化成直接构造,代码如下:

cpp 复制代码
void f1(A aa)
{
}
int main()
{
	f1(1);
	f1(A(1));
	return 0;
}

结果为:

若关闭优化,则在A(int a);后加了一个A(const A& aa);,并且析构函数还多调用了一次:

(3)隐式类型转换中都尝试优化,其次还有传值返回,不优化的情况下传值返回,编译器会生成一个拷贝返回对象的临时对象作为函数调用表达式的返回值,一些编译器会优化得更厉害,将构造的局部对象的拷贝构造的临时对象优化成直接构造。

cpp 复制代码
A f2()
{
	A aa;
	return aa;
}
int main()
{
	A aa2 = f2();
	return 0;
}

在返回时会产生临时对象,如果再用aa2来接收返回值,那么就又有拷贝构造,所以为:构造+2个拷贝构造,但编译器会优化成1个拷贝构造+构造;而在VS2022上就会直接优化成直接构造。

其逻辑可以见下面图:

在VS2019的debug版本就会优化一些过程,而在VS2019的release版本和VS2022上直接优化成构造了。但是如果A aa2;aa2=f2();这是赋值,会干扰编译器的优化的。但是release版本不产生临时对象的,所以结果会为:

而在VS2022则会进行优化:

不论怎么样,都要额外调用这个赋值运算符重载的函数,所以不建议这样写!

这个知识点理解起来难度比较高,主要是了解以后之后建议哪样写。

8.总结

类和对象下难度还是相对高的,知识点也是有些需要了解,有些也是需要记住的,但是只要去深究那么这种东西也会很简单的。下节将讲解:C/C++内存管理,喜欢的可以一键三连哦!

相关推荐
世事如云有卷舒4 分钟前
《C++ Primer》学习笔记(四)
c++·笔记·学习
COOCC18 分钟前
推荐系统排序阶段核心要点:多目标排序模型详解
神经网络·算法·机器学习·计算机视觉·自然语言处理
元亓亓亓14 分钟前
java后端开发day35--集合进阶(四)--双列集合:Map&HashMap&TreeMap
java·开发语言
货拉拉技术15 分钟前
AI Agent搭建神器上线!货拉拉工作流让效率翻倍!
算法·llm
李匠202422 分钟前
C++学习之游戏服务器开发十四QT登录器实现
c++·学习·游戏
寂空_39 分钟前
【算法笔记】动态规划基础(一):dp思想、基础线性dp
c++·笔记·算法·动态规划
全栈老李技术面试40 分钟前
【高频考点精讲】JavaScript中的访问者模式:从AST解析到数据转换的艺术
开发语言·前端·javascript·面试·html·访问者模式
广龙宇1 小时前
【一起学Rust】使用Thunk工具链实现Rust应用对Windows XP/7的兼容性适配实战
开发语言·windows·rust
jerry2011081 小时前
R语言之rjava版本不匹配解决方法
开发语言·r语言
拓端研究室TRL1 小时前
PYTHON用几何布朗运动模型和蒙特卡罗MONTE CARLO随机过程模拟股票价格可视化分析耐克NKE股价时间序列数据
开发语言·python