从零开始搞定C++类和对象(下)
类型转换
- C++⽀持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数。
- 构造函数前⾯加explicit就不再⽀持隐式类型转换
- 类类型的对象之间也可以隐式转换,需要相应的构造函数⽀持
内置类型与类类型的隐式转换
我们之前说过,内置类型中的整型和浮点型可以互相转换,如:
int i=9;
double j=i;
整型和浮点型之间能够进行隐式转换,根本原因是它们都是用于表示数值的算术类型,编译器内置了它们之间转换的规则,目的是为了让表达式能够正常求值。
在进行隐式转换时,会产生一个目标类型的(这里是double类型)临时变量,然后j接收的是临时变量的值。
内置类型与自定义类型之间也可以互相转换,但是需要有一定的关联支持,这里的关联就是以内置类型为参数的构造函数。
话不多说,我们来上代码感受一下:
#include<iostream>
using namespace std;
class A
{
public:A(int a=10):_a1(a),_a2(a){}
private:int _a1 = 1;int _a2 = 2;
};
int main()
{
//调用拷贝构造函数A aa1(1);
//隐式类型转换:会产生一个A类型的临时对象,调用构造函数去初始化这个临时对象,构造函数的参数是2,这个临时对象再对aa1进行赋值拷贝aa1 = 2;
//隐式类型转换:首先会产生一个A类型的临时对象,调用构造函数去初始化这个临时对象,构造函数的参数是3,这个临时对象再对aa1进行拷贝构造A aa2 = 3;const A&ref=3;return 0;
}
解释上面的代码:
1.
A aa1(1);
这是直接初始化过程
这直接调用了
A(int a=10)
这个构造函数来创建aa1
对象。_a1
和_a2
都被初始化为1
。这是最直接的初始化方式,不涉及隐式类型转换。
2.
aa1 = 2;
赋值操作(隐式类型转换 + 运算符重载)
这行代码可以分解为两个步骤:
隐式类型转换:编译器发现等号右边是
int
型,而左边是A
型。它试图寻找能将int
转换为A
的方法。它找到了A(int a=10)
这个转换构造函数。于是,编译器使用这个构造函数,隐式地创建了一个临时的、匿名的A
对象。这个临时对象可以表示为A(2)
。赋值:现在,表达式变成了
aa1 = A(2)
。编译器接着寻找如何将一个A
对象赋值给另一个A
对象。因为没有显式定义重载operator=
,所以编译器会使用它自动生成的默认赋值运算符,将临时对象A(2)
的每个成员的值逐个拷贝(赋值)给aa1
的对应成员。结论:这行代码发生了隐式类型转换(
int
->A
),然后进行了赋值操作。它没有调用拷贝构造函数。3.
A aa2 = 3;
初始化(拷贝初始化)
这行代码可以分解为两个步骤:
隐式类型转换:编译器发现等号右边是
int
型,而左边需要构造一个A
型对象。它试图寻找能将int
转换为A
的方法。它找到了A(int a=10)
这个转换构造函数。于是,编译器使用这个构造函数,隐式地创建了一个临时的、匿名的A
对象。这个临时对象可以表示为A(3)
。初始化:现在,表达式变成了
A aa2 = A(3)
。编译器接着寻找如何用一个A
对象(临时对象)来初始化另一个新创建的A
对象(aa2
)。因为没有自定义拷贝构造函数,所以编译器会使用它自动生成的默认拷贝构造函数,将临时对象A(3)
的每个成员的值逐个拷贝(初始化)到aa2
的对应成员中。这行代码发生了隐式类型转换(
int
->A
),然后使用了拷贝构造函数进行初始化。虽然理论上代码的执行过程是这样的,但实际上编译器会对代码进行优化,如第三个语句实际上会被优化成:直接调用
A(3)
来构造aa2
,以避免创建临时对象带来的性能开销。所以最终效果和A aa2(3);
是一样的。4.
const A& ref = 3;
类型:引用绑定(带隐式类型转换的初始化)
过程:这行代码可以分解为两个步骤:
隐式类型转换:编译器发现等号右边是
int
型,而左边是一个对const A
的引用。它试图寻找能将int
转换为A
的方法。它找到了A(int a=10)
这个转换构造函数。于是,编译器使用这个构造函数,隐式地创建了一个临时的、匿名的A
对象。这个临时对象可以表示为A(3)
。引用绑定与生命周期延长:现在,编译器需要将一个引用绑定到一个对象。它找到了这个临时对象。根据C++标准,一个常量左引用可以绑定到一个临时对象,并且此举会将该临时对象的生命周期延长至与引用的生命周期相同。因此,引用
ref
被成功绑定到这个临时对象上,并且这个临时对象会持续存在,直到ref
离开其作用域。(注意哦,与前面的3不同,这里是引用,不会产生拷贝构造的)结论:这行代码发生了隐式类型转换(
int
->A
),然后完成了常引用的绑定。它不调用拷贝构造函数,但依赖于临时对象生命周期延长规则来保证程序正确性。
上面的代码构造函数中只有一个参数,如果构造函数有多个参数,格式应该怎么写?
#include<iostream>
using namespace std;
class A
{
public:A(int a=10,int b=20):_a1(a),_a2(b){}A(const A& aa){_a1 = aa._a1;_a2 = aa._a2;}
private:int _a1 = 1;int _a2 = 2;
};
int main()
{A aa1(1,2);aa1 = { 3,4 };A aa2 = { 5,6 };const A&ref = { 7,8 };return 0;
}
如代码所示,直接用大括号即可进行多参数传参。
explicit关键字
如果我们不想进行内置类型和自定义类型通过构造函数关联的隐式类型转换,我们可以在构造函数前加上explicit:
如图,加上explicit后,就无法进行隐式类型转换了。
类类型之间的隐式转换
类类型的对象之间也可以隐式转换,需要相应的构造函数⽀持。
如以下代码:
#include<iostream>
using namespace std;
class A
{
public:A(int a=10,int b=20):_a1(a),_a2(b){}A(const A& aa){_a1 = aa._a1;_a2 = aa._a2;}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 = 0;
};int main()
{A aa1(1,2);aa1 = { 3,4 };A aa2 = { 5,6 };B b1 = aa1;const B& ref = aa2;return 0;
}
解释:
B b1 = aa1;
(不考虑优化)
类型:拷贝初始化
过程:这行代码可以分解为两个步骤:
隐式类型转换:编译器发现等号右边是
A
型对象aa1
,而左边需要构造一个B
型对象。它试图寻找能将A
转换为B
的方法。它找到了B
类的构造函数B(const A& a)
。于是,编译器使用这个构造函数,隐式地创建了一个临时的、匿名的B
类对象。可以把这个临时对象看作B(aa1)
。拷贝构造:现在,编译器需要用一个
B
类型的对象(临时对象)来初始化一个新创建的B
类型对象(b1
)。编译器会调用B
类的拷贝构造函数B(const B&)
,将临时对象的值拷贝到b1
中。结论:这行代码发生了隐式类型转换(
A
->B
),创建了一个临时B
对象,然后调用了拷贝构造函数来完成b1
的初始化。
B b1 = aa1;(考虑优化)
拷贝初始化
这行代码可以分解为两个步骤:
寻找转换路径:编译器发现等号右边是
A
型对象aa1
,而左边需要构造一个B
型对象。它试图寻找能将A
转换为B
的方法。它找到了B
类的构造函数B(const A& a)
。这个构造函数接受一个const A&
参数,与提供的aa1
类型匹配。初始化:编译器直接使用
aa1
作为参数,调用B
类的构造函数B(const A& a)
来初始化b1
。在构造函数内部,通过a.Get()
获取到值,并用它来初始化成员_b
。这行代码直接调用了
B
的构造函数完成初始化。它没有发生隐式类型转换(因为类型A
到B
是显式通过构造函数定义的),也没有调用拷贝构造函数(因为是用aa1
直接构造b1
,而不是用一个B
对象来构造另一个B
对象)。
const B& ref = aa2;
引用绑定(直接初始化引用)
这行代码可以分解为两个步骤:
创建临时对象:编译器发现等号右边是
A
型对象aa2
,而左边是一个对const B
的引用。它需要找到一个B
对象来让ref
绑定。它通过B
类的构造函数B(const A& a)
,隐式地用aa2
作为参数创建了一个临时的、匿名的B
类对象。引用绑定与生命周期延长:现在,编译器有了一个临时
B
对象。根据C++标准,一个常量左引用可以绑定到一个临时对象,并且此举会将该临时对象的生命周期延长至与引用的生命周期相同。因此,引用ref
被成功绑定到这个临时B
对象上。这行代码发生了隐式类型转换(
A
->B
),创建了一个临时B
对象,然后完成了常引用的绑定。它不调用拷贝构造函数,但依赖于临时对象生命周期延长规则来保证程序正确性。
static成员
- 用
static
修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化。 - 静态成员变量为所有当前类的类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。
- 用
static
修饰的成员函数,称之为静态成员函数,静态成员函数没有this
指针。 - 静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,因为没有
this
指针。 - 非静态的成员函数,可以访问任意的静态成员变量和静态成员函数。
- 静态成员也是类的成员,受
public
、protected
、private
访问限定符的限制。 - 突破类域和访问限定符就可以访问静态成员,如果静态成员变量不是私有的,就可以通过
类名::静态成员
或者对象.静态成员
来访问静态成员变量和静态成员函数。 - 静态成员变量不能在声明位置给缺省值初始化,因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。
我们结合代码来感受由static修饰的类的成员的特点:
#include<iostream>
using namespace std;
class A
{
public:A(int n = 3):_a( n){}
private:int _a;//静态成员变量的声明,一定注意这不是定义!!static int _i;
};
//类中静态变量的定义和初始化
//注意要指定类域int A::_i=10;//1.
int main()
{//我们来计算一下A类的大小cout << sizeof(A) << endl;return 0;
}
在上面代码的A类中,_i就是静态成员变量,需要注意的是,上面我们在类中写的是_i的声明而不是定义哦,静态变量只能在类外进行初始化,在上面的代码中,初始化语句就是语句1.
我们再来看一下类A的大小,查看运行结果:
我们可以看到,A的大小是4个字节,但是A中明明是有两个整型成员变量啊,为什么会这样呢?
这是因为A中的一个成员变量是静态变量,静态成员变量是不会存储在类的对象中的,而是存储在静态区。静态成员变量为所有当前类的类对象所共享,也就是说,如果我们定义了两个A类型的对象,如果我们要利用这两个对象去访问其中的静态变量,访问的静态变量是同一个变量,这跟“类中的成员函数不是存储在类中的”是一个道理。我们可以验证一下:
我们创建了两个A类型的变量,在调试过程中取出其中静态变量的地址,发现它们地址相同,这就说明了类中的静态变量变量不是在类的对象中存储的,类的所有对象共用同一个静态变量。
那请问小伙伴们,下面的代码是否会在运行时出错?
#include<iostream> using namespace std; class A { public:A(int n = 3):_a(n){}int _a;//静态成员变量的声明,一定注意这不是定义!!static int _i; };//类中静态变量的定义和初始化 //注意要指定类域 int A::_i = 10; int main() {A* ptr = nullptr;cout << ptr->_i << endl;return 0; }
答案:代码正常运行,虽然ptr是空指针,但是我们在这里使用ptr只是为了指定类域,并没有真的用ptr访问静态变量_i,所以我们并没有对空指针进行解引用操作,所以代码正常运行。
静态成员变量的访问:如果静态变量在类中不是私有的,我们就可以通过类名或者类的对象在类外对静态成员变量进行访问。比如:
#include<iostream> using namespace std; class A { public:A(int n = 3):_a(n){}int _a;//静态成员变量的声明,一定注意这不是定义!!static int _i; };//类中静态变量的定义和初始化 //注意要指定类域 int A::_i = 10; int main() {cout << A::_i << endl;A aa;cout << aa._i;return 0; }
如果静态成员变量在类中声明时是私有的,那我们除了在类外定义初始化静态变量时可以使用静态变量,其余情况下不能在类外对静态成员变量进行访问,否则会发生报错,这和非静态成员变量是一样的:
我们之前在初始化列表部分说了,成员变量在类中声明时可以给定缺省值,但是静态成员变量在类中声明时就不可以给定缺省值,这是因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。如以下代码:
静态成员函数:静态成员函数中是没有隐含的this指针的,所以静态成员函数的访问既可以通过直接指定类域进行调用,也可以使用对象进行调用,而非静态成员函数中具有隐含的this指针,所以再调用非静态成员变量时只能使用对象实例化后进行调用。
也正是因为静态成员函数中没有this指针,所以静态成员函数内部只能使用类中的静态成员变量,而不能使用其他非静态成员变量。
#include<iostream> using namespace std; class A { public:A(int n = 3):_a(n){}static int Get(){return _i;} private:int _a=10;//静态成员变量的声明,一定注意这不是定义!!static int _i; };
上面代码中的Get函数就是一个静态成员函数,他不能使用类中的非静态成员变量。否则就会报错:
讲了这么多,我们来写一道题应用一下静态成员变量吧:
题目链接:https://www.nowcoder.com/share/jump/801179881757949307781
题目描述就已经限定了这一道题的方法:不能使用循环、等差数列公式等。
那这一道题应该怎么做呢?
这一道题的解题思路还是比较巧妙的,我们先来看以下代码,再来讲一下具体思路:
class Sum
{
public://构造函数Sum(){ret+=i;i++; }static int GetRet(){return ret;}private:static int ret;static int i;
};int Sum::ret=0;int Sum::i=1;class Solution {public:int Sum_Solution(int n) {Sum a[n];return Sum::GetRet();}
};
解释:
在这道题的解题过程中,我们又创建了一个类,在这个类中有两个静态成员变量ret和i,静态变量的特点是,当整个项目结束时,操作系统才会回收他们的空间。
然后在 Sum_Solution(int n) 函数中创建了一个Sum类的数组,数组的大小为n,那么就会调用n次Sum的构造函数,在构造函数中ret的值不断地进行累加,而i也不断的进行累加,最后就得到了我们想要的结果。
设已经有 A,B,C,D 4 个类的定义,程序中 A,B,C,D 构造函数调用顺序为?()
设已经有 A,B,C,D 4 个类的定义,程序中 A,B,C,D 析构函数调用顺序为?()A: D B A C
B: B A D C
C: C D B A
D: A B D C
E: C A B D
F: C D A BC c;int main(){A a;B b;static D d;return 0; }
构造顺序分析
1. 全局对象
C c
(在所有函数之外)
最先构造:全局对象在
main
函数执行之前就开始构造顺序:按照定义顺序,但所有全局对象都在
main
之前构造2.
main
函数中的对象
A a
:第一个局部自动对象
B b
:第二个局部自动对象
static D d
:静态局部对象关键规则:
局部对象的构造顺序按照定义顺序
静态局部对象只在第一次执行到其定义时初始化
构造顺序结论
构造顺序为:C → A → B → D
详细过程:
C c
(全局对象,在main之前构造)进入main函数
A a
(第一个局部对象)
B b
(第二个局部对象)
static D d
(静态局部对象,第一次执行到此处时构造)析构顺序分析
析构顺序与构造顺序相反
1. 局部对象(后进先出)
static D d
:静态局部对象,在程序结束时析构
B b
:第二个局部对象,在main函数返回时析构(在d之前)
A a
:第一个局部对象,在main函数返回时析构(在b之前)2. 全局对象
C c
最后析构:在所有函数执行完毕后析构
析构顺序结论
析构顺序为:B → A → D → C
详细过程:
B b
析构(局部对象,后定义先析构)
A a
析构(局部对象,先定义后析构)
static D d
析构(静态局部对象,程序结束时析构)
C c
析构(全局对象,最后析构)最终答案
构造函数调用顺序:C → A → B → D
析构函数调用顺序:B → A → D → C因此正确答案是:
构造函数顺序:E: C A B D
析构函数顺序:B: B A D C