C++之类和对象(下)

类和对象

前言

本篇我们讲解类和对象下

一、接上篇讲解默认成员函数

我们知道,类有六个默认成员函数,而我们在前面讲解了四个,接下来我们继续补充

const成员函数

我们知道,在类的成员函数中,除了静态成员函数之外,每个成员函数都有一个隐藏形参,this指针,而我们前面讲过,this就是Date* const this ,类型为用const修饰的类类型指针,此处的const修饰的是this指针本身,并非this指针指向的对象,这代表this指针不可以改变指向哪个对象,只能固定指向原本的对象,但是this指针指向的内容可以被修改

而我们知道,如果是const修饰的常量,我们使用指针是无法指向它的,因为如果指向它,代表我们可以通过指针去修改它,这是权限的放大,权限只能平移或者缩小

如果我们使用const修饰的指针呢

此时属于权力的平移,是可以编译通过的,那么基本类型的指针如此,自定义类型的指针也是如此

如果我们想要定义一个const修饰的对象,去调用成员函数,会怎么样呢?

我们可以看到,当我们通过const修饰的对象d2去调用普通成员函数Print()时,编译器报错

这是因为我们的this指针只是无法改变指向,但是可以改变指向对象的内容,而this指针又是隐式形参,我们是无法显式传递和接收 的,这就代表我们无法写成const Date* const this,无法用const去修饰this指针指向的对象 ,此时将const对象d2传递过去,是用Date * const this去接收的 ,一个是const修饰的对象,const Date d2,另一个是Date类型的无法改变指针指向但是可以改变指针指向对象的内容的Date* const this指针,这就像我们前面的用const修饰的整型变量a,去用普通的整型指针去指向的问题,这属于权力的放大

那么想要解决这个问题,该怎么做呢?如何使用const修饰this指针呢?

欸,祖师爷想到了一个办法,你不让我显式传递,那我再设定一个规则,在形参列表和函数体之间加一个const,用于修饰this指针指向的内容 ,如:

我们void Print()使用const修饰,改写为void Print() const,这个const,就是用于this指针,将其变为const Date* const this ,此时我们的对象d1去调用普通的成员函数print(),而const修饰的对象d2去调用const成员函数print(),此时属于权力的平移,此时两个print函数相当于构成了函数重载

并且,当我们把普通成员函数const注释掉后,我们的d1对象会去调用const成员函数 ,如:

此时属于权力的缩小,是允许的

const对象,只能调用const成员函数

此外,const放在函数形参列表和函数体之间的方式只适用于类的成员函数成员函数中,静态成员函数除外 (我们后面会讲,静态成员函数没有this指针),即类中有this指针的成员函数才可以使用这种方式,如果在类外部使用,也是不可以的

该函数,const成员函数,就是类的六个默认成员函数之一

取地址及const取地址运算符重载

如图,这两个默认成员函数一般不需要我们自己实现,编译器会默认生成,只有当我们想让别人获取到指定的内容的时候才需要自己实现,后面会讲到

二、再谈构造函数

1.初始化列表

在前面我们讲到过**,当我们实例化创建对象之后,编译器会自动调用类中的构造函数去初始化对象,也就是说,构造函数的作用就是初始化新创建的对象**

但是在这里,我们要说的细一点,构造函数的作用是初始化,但是在构造函数内给类的成员变量赋初始值这一步并不属于初始化,也就是说我们只是给了它一个初始值而已,这并不能叫做传统的初始化,只能称为赋初值,也不属于定义

我们以const成员变量为例,我们知道,const修饰的变量必须在定义的时候就完成初始化,并且只能初始化一次,后续无法再赋值,那么我们如果声明了一个const成员变量呢,它在哪儿定义呢?该在哪儿进行初始化呢?是在构造函数的函数体内吗?

我们可以看到,此时是报错的,也就是说const成员变量的定义并不在此处,我们无法在构造函数的函数体内完成对const成员变量的初始化

有同学说,那我们直接在声明的时候就赋初值,不可以吗?

这是可以的,而且编译器也不会报错,你赋初值也是成功的,但是我们前面讲过,在类里的成员变量,永远只是声明,并不属于定义,只是声明而已,我们就算写成下图那样,也只是声明,并不算定义

如图,我们可以看到,不显式写初始化列表,只赋缺省值,这时候编译器并没有报错,我们前面说,此处不算定义,只算声明,但是在C++11之后的编译器中,编译器会自动将这个缺省值提取,并且为该const成员变量生成一个隐式的初始化列表,来完成对该变量的初始化

也就是说,此处编译器为我们生成了一个隐式的初始化列表,完成了对它的初始化,所以编译器不会报错

祖师爷将类的成员变量的定义放在了执行构造函数时,但它是在进入构造函数体之前,初始化,只能初始化一次,而在构造函数的函数体中可以赋值多次

我们用于初始化成员变量(定义成员变量)的地方称为初始化列表,初始化列表由:引出第一个初始化,后面初始化由逗号隔开,初始化由()内的内容决定 ,如:

可以看到,我们将构造函数内部的赋初值语句注释掉,依然初始化成功

我们改为const成员试一试

我们可以发现,此时编译器不会报错,并且输出结果表明我们初始化成功了

同时,我们可以从图上得知,初始化列表是在构造函数体内的语句执行之前就执行完毕了,也就是说,我们先执行初始化列表,执行完毕之后,再执行构造函数体

同时,我们发现,对于基本类型int,如果我们不在初始化列表中初始化,编译器也是不会报错的,这和我们之前编译器默认生成的构造函数的规则相同,编译器会自动在初始化列表中对内置类型进行操作,但不一定会处理(比如默认生成的构造函数中会给内置类型一个相同的随机值)


因此对于内置类型,不需要初始化时,初始化列表里写不写都可以,编译器会自动帮你走一遍,如果我们想要初始化的话在初始化列表和构造函数体内两个地方任选其一即可,当然,两个都写会被函数体内的覆盖,因为那相当于二次赋值

而对于初始化列表,祖师爷规定了,对于const成员变量、自定义类型且没有默认构造函数的成员变量、引用类型成员变量,必须通过初始化列表进行初始化

我们可以看到,当类Test里没有默认构造函数的时候,自定义类型成员变量必须在初始化列表里显式定义

当我们的Test类里有默认构造函数时,我们无需显式地在初始化列表里定义自定义类型成员变量_d,编译器会在初始化列表里自动调用它的默认构造函数

也就是说,初始化列表里会把所有成员变量都走一遍,因为这是它们定义的地方,只是初始化的处理方式不同而已,同时遵守各种数据类型的规则 ,比如,引用类型和const修饰的变量 ,它们在定义的时候必须要初始化,那就必须在初始化列表显式进行初始化 ,而对于没有默认构造函数的自定义类型,我们必须在初始化列表里显式调用它的构造函数,只有有默认构造函数的时候,我们才可以不在初始化列表显式定义自定义类型成员变量 ,此外,对于内置类型变量,你在初始化列表中显式写了,可以,初始化列表会走一遍,如果不写,也可以,因为编译器会隐式的帮你写,也会走一遍

此外,在初始化列表中,每个成员仅能出现一次,即不可以重复定义

并且,在初始化列表中,它的初始化执行顺序并非从上而下地按照代码顺序执行,而是按照在类中的声明顺序执行 ,如:

根据我们的习惯来看,初始化列表写作这样,那我们应该先用1初始化_b,再用_b去初始化_a,应该打印出_a和_b结果都是1,但是我们却发现,打印_a是随机值,只有_b才是1

这就是我们前面说的,初始化列表的执行顺序是按照声明顺序来执行的,我们在类中先定义了_a,那么就会先用未初始化的随机值_b去初始化_a,再用1去初始化_b

讲到这里,有同学会想,哇,初始化列表这么好用,而且和函数体内赋值的作用重复了,那我们岂不是可以用初始化列表来替代构造函数体内赋值了?事实真的是这样吗?

不,每个事物都有自己存在的价值,函数体内赋值,自然也有它自己存在的道理

赋值,只是它的一部分作用,我们演示的都是日期类,如果我们改为堆栈这种需要动态申请内存资源的类,或者说需要动态开辟一个数组或者指针,那我们只用初始化列表,该怎么写呢?

非要说写,我们也是可以写出来的,如:

那么问题来了,我们动态申请完空间,需不需要检查一下是否申请成功呢?怎么写呢?

这时候,单靠初始化列表是无法完成的,我们必须借助构造函数体来完成

如图,既然如此,那我们倒不如将其直接一起写在函数体内了,那样也更加连贯、美观,至于指针会在初始化列表被隐式定义为什么,我们以后会讲,可能是野指针,也可能是0(比如宏定义里的0,具体请看前面讲过的C++入门篇中nullptr的由来)

2.static成员

我们之前讲过,在普通的全局函数中定义一个static修饰的变量,该变量的生命周期会和全局变量相同,并且存储位置也变为了和全局变量相同的静态区,不再和局部变量一样

那么如果我们在类中定义了静态成员变量和静态成员函数,它们和普通成员变量、成员函数又有何不同呢?

我们在前面讲到过,静态成员变量和静态成员函数在类里是一种特殊的存在,有许多特殊情况,我们先以静态成员变量为例

当我们在类中声明了静态成员变量之后,该变量必须在类外部定义 ,因为静态成员变量是存储在静态区中的,属于类,并不依赖对象进行内存分配,而我们的非静态成员函数是存储在对象中的,是对象的专属数据,与对象一起存储在堆或栈中,它们都是通过构造函数的初始化列表来定义的,因此我们想要定义静态成员函数就必须在类外部进行定义,如:

当我们不在外部定义的时候,会出现链接错误,而不是编译错误 ,因为我们的编译器通过类中的声明知道有这个变量存在,但是声明并不会开辟内存,我们不定义,就找不到地址,所以链接不上

如图,我们在类中的static int _c只是声明,声明_c这个变量属于类,但我们没有为他开辟内存空间,所以必须定义

并且在C++标准中,只有用const或者constexpr修饰的静态成员常量才能在类里直接定义,否则都是不可以的

因为静态成员变量存储在静态区,与全局变量类似,因此我们在类外部定义的时候,只需要使用类名+域作用限定符进行访问、定义即可,且不需要再加static关键字

我们来看下面的代码,数一数到每一行共创建了多少个对象?

cpp 复制代码
class A
{
public:
	A()
	{
		count++;
	}
	A(const A& a)
	{
		count++;
	}
	~A()
	{
		count--;
	}
	static int count;
};
int A::count = 0;
void Test()
{
	static A a3;
	cout << __LINE__ << ":" << A::count << endl;
	A a4;
	cout << __LINE__ << ":" << A::count << endl;
}
A a5;
int main()
{
	cout << __LINE__ << ":"  << A::count << endl;
	static A a1;
	cout << __LINE__ << ":" << A::count << endl;
	A a2;
	cout << __LINE__ << ":" << A::count << endl;
	Test();
	cout << __LINE__ << ":" << A::count << endl;
	Test();
	cout << __LINE__ << ":" << A::count << endl;
	return 0;
}

结果如下:

对于静态成员函数,它的存储区域与非静态成员函数类似,都是存储在代码段中,存储在公共区域,等待使用,但是不同的是,它属于类,并不单独属于某个对象

比如构造函数,也是在代码段中,创建对象后通过this指针来访问,构造函数的逻辑相同,但是内容值会随对象的地址不同而不同(this指针),this指针会随着函数调用结束而销毁,但是构造函数依然可以被访问,这是因为对象还没有被销毁,对象销毁后,this指针指向的内存也会被释放,等待重新利用,此时对象的地址已经是无效的了,我们无法再通过this指针去访问构造函数

但是静态成员函数不同,它没有this指针,因此它并不依附于对象,即只通过类就可以进行访问,并且每个对象都可以调用相同的静态成员函数,因为它在公共区域,不受this指针的影响,不受生命周期的影响,譬如:

如图,静态成员函数和静态成员变量既可以通过对象去访问,也可以通过类名+访问限定符去进行访问,并且静态成员也会受到访问限定符的限制,规则与非静态成员相同

除此外,我们的非静态成员函数可以访问静态成员函数、静态成员变量、非静态成员函数、非静态成员变量,但是静态成员函数只可访问静态成员变量和静态成员函数,因为非静态成员变量和函数都是依托于对象的,依托this指针,而静态成员则是依托于类,不属于某个对象,且静态成员函数没有this指针,故而无法访问

3.友元

友元提供了一种突破封装的方式,提供了便利,但是却增加了耦合度,破坏了封装,因此还是要尽量少用

我们之前讲到过友元函数,是在讲解流插入流提取运算符重载的时候用到,因为当我们将流插入流提取操作符重载写为类的成员函数时,隐式形参this指针会默认占据形参列表的第一个位置,导致我们使用流插入流提取操作符重载时不符合我们的日常习惯,因此将其写为全局函数,并声明为日期类的友元函数,保证这两个函数可以对类里的成员变量进行访问

而除了友元函数之外,还有一个友元类,友元类与友元函数的声明方式类似,比如在A类中声明friend class Date,这代表Date类是A类的友元类,Date类可以随意访问A类中的成员,但是A类如果不声明为Date类的友元类,便无法访问Date类中的私有、保护成员,并且,这代表Date类中的所有成员函数都是A类的友元函数

此外,我们从之前的讲解可以知晓,友元关系是单向的,不具有交换性,并且不受访问限定符的限制,因为它只是一个声明,友元关系也不能传递,比如A是B的友元类,B是C的友元类,但是A不是C的友元类,同时,友元关系也不能继承,我们在讲解继承的时候会给大家讲

一个函数可以是多个类的友元函数,一个类也可以是多个类的友元类

友元函数并不是类的成员函数,且不可以用const修饰,调用原理与普通函数相同

4.内部类

概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。

注意:内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。

特性:

  1. 内部类可以定义在外部类的public、protected、private都是可以的。
  2. 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
  3. sizeof(外部类)=外部类,和内部类没有任何关系。

至于如何深入类和对象,我们前面已经举过许多例子,此处不再赘述

5.有名对象or匿名对象

我们在前面讲解的时候,提到过实例化对象的时候,可以通过多种形式去创建,如下:

cpp 复制代码
class Date1
{
public:
	Date1(int year,int month = 11,int day = 28)
		:_year(year)
		,_month(month)
		,_day(day)
	{
		cout << "Date1(int year,int month,int day)" << endl;
	}
	Date1(const Date1& d)
		:_year(d._year)
		,_month(d._month)
		,_day(d._day)
	{
		cout << "Date1(const Date1& d)" << endl;
	}
	Date1& operator=(const Date1& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		return *this;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

如上图与代码,当我们的构造函数仅需传递一个参数时(即单参构造函数或是半缺省的构造函数),我们可以通过211行的方式去创建对象d1,也可以通过212行的方式去创建对象d2,并且通过打印来看,对象d1与对象d2之间并没有什么区别

但我们之前讲到,Date1 d2 = 2025这种实例化对象的方式,其实是隐式类型转换,即先创建一个临时对象,并传入2025这个数字去初始化这个临时对象,再将临时对象拷贝构造给对象d2,至此结束

在这其中,我们的编译器会优化,因为他检测到我们要先调用构造函数去创建一个临时对象,再调用拷贝构造将临时对象拷贝给对象d2,并且这两个过程是在同一行完成的,那么编译器就会觉得麻烦,会直接将这个过程优化为调用拷贝构造去实例化对象,因此我们的调用结果是只调用了一次构造函数而不是构造+拷贝构造

同时,在这个过程中,我们的对象d2,就叫做有名对象,而最初用于为对象d2拷贝构造进行初始化而被创建出的临时对象,被称为匿名对象

匿名对象,不一定是临时对象,但是临时对象,一定是匿名对象

顾名思义,有名,就是有对象名,匿名,就是没有对象名,因此,我们还可以这样去创建对象,如:

我们可以看到,匿名对象也是通过调用构造函数去创建一个临时对象,并且它有一个特性,那就是即用即销毁,如图,我们在调用构造函数之后紧接着就又调用了一次析构函数,也就是说,匿名对象的生命周期只在当前行,而有名对象的生命周期在当前函数局部域

有同学可能会说,那这个匿名对象创建之后就立刻销毁,有什么用呢?

如下:

可以看到,我们可以用匿名对象去调用类中的成员函数,这在我们仅需要使用一次或是极少次类里的public成员函数时,可以简洁代码,因为我们通常调用成员函数都是先创建对象,再通过对象去调用(如之前的代码)

但现在,我们可以分开使用,当我们需要多次调用成员函数时,就使用有名对象,当我们仅需要调用极少次成员函数时,就使用匿名对象

同时,匿名对象是具有常性的,也就是说,匿名对象与临时对象具有相同的性质 ,如:

我们无法去引用匿名对象,但是如果我们用const去修饰的话,就会出现很神奇的事情,如:

此时的引用并不属于"野引用"

当我们使用const去修饰引用,此时我们发现编译器编译通过,匿名对象的生命周期与有名对象相同,不再是即用即销毁的当前行

因此,我们可以认为const修饰延长了匿名对象的生命周期,生命周期在当前函数局部域(可以简单理解为,单独创建的匿名对象,以后无法使用、没人使用了,因此我们可以直接销毁,但是被引用的匿名对象,还是有人用到的,因此我们会延长它的生命周期,避免被立刻销毁而找不到导致无法使用),匿名对象的生命周期会和该引用的生命周期相同

同样,当我们需要调用的函数的形参是const+类类型+引用,如const Date&的时候,我们传参可以直接传匿名对象 ,如:

如图,我们可以直接传匿名对象,并且两种方式都可以,第二种更加简洁,这比我们先调用构造函数创建临时对象再传递的代码要简洁多了

总结

提示:这里对文章进行总结:

例如:以上就是今天要讲的内容,本文仅仅简单介绍了pandas的使用,而pandas提供了大量能使我们快速便捷地处理数据的函数和方法。

相关推荐
superman超哥9 小时前
惰性求值(Lazy Evaluation)机制:Rust 中的优雅与高效
开发语言·后端·rust·编程语言·lazy evaluation·rust惰性求值
9号达人9 小时前
AI最大的改变可能不是写代码而是搜索
java·人工智能·后端
Wiktok9 小时前
关于Python继承和super()函数的问题
java·开发语言
七夜zippoe9 小时前
数据库事务隔离级别与Spring传播行为深度解析
java·数据库·spring·mvcc·acid·myslq
liulilittle9 小时前
rinetd 端口转发工具技术原理
linux·服务器·网络·c++·端口·通信·转发
古城小栈9 小时前
Rust IO 操作 一文全解析
开发语言·rust
李日灐9 小时前
C++STL:stack,queue,详解!!:OJ题练手使用和手撕底层代码
开发语言·c++
Stecurry_309 小时前
Springmvc理解从0到1 完整代码详解
java·spring boot·spring
fy zs9 小时前
应用层自定义协议和序列化
linux·网络·c++