文章目录
- 一、再探构造函数
- 二、隐式类型转换
- 三、类中的静态成员
- 1.静态成员变量
- 2.静态成员函数
- 四、友元函数与友元类
- 五、内部类
- 六、匿名对象
一、再探构造函数
在之前的文章中我们大致将构造函数讲完了,但是还有一个比较重要的知识点当时没有讲到,因为如果把这部分内容加上去,会让前面的内容难度变得非常高,所以这部分内容放到这里来讲,之前讲构造函数的文章是:【C++】揭秘类与对象的内在机制(核心卷之构造函数与析构函数的奥秘)
接下来进入今天的正题,我们今天要补充的内容就是构造函数中的“初始化列表”,它可以帮我们对成员变量进行初始化,我们还是先来看看它的语法格式(刚开始学可能看起来怪怪的,熟悉了就好了),如下:
class Date
{
public:
//不使用初始化列表
Date(int year = 2025, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//使用初始化列表
Date(int year = 2025, int month = 1, int day = 1)
:_year(year)//在花括号(函数体)前以冒号为开始
,_month(month)//从第二行开始用英文逗号分隔
,_day(day)
{}//在花括号中(函数体中)可以正常执行其它代码,这里就不演示了
private:
int _year;
int _month;
int _day;
};
在上面的代码中,有两个构造函数,一个不使用初始化列表和一个使用了初始化列表,在这里它们的作用都是一样的,就是根据用户传来的年月日对当前对象进行初始化
那么既然上面的代码中,无论是否使用了初始化列表,两个构造函数的作用都相同,而且这个初始化列表还这么丑,它到底有什么不同之处呢?接下来我们就正式介绍初始化列表
初始化列表非常重要,有3类成员变量必须使用初始化列表进行初始化,如果不使用初始化列表就会报错,它们分别是cosnt成员变量、引用成员变量以及自定义类型成员变量,那么为什么这3类成员变量就必须使用初始化列表进行初始化呢?
其中cosnt成员变量以及引用成员变量比较特殊,它们都只能初始化一次,如果我们将其写在构造函数内部进行初始化会有一定的歧义,比如const成员变量只能初始化一次,往后这个成员变量的值就不能修改了,如果它在函数体出现两次,该选择哪个值作为它的结果
而引用对象也是,只能初始化一次,往后只能修改引用对象的值,而不能修改引用对象的指向,如果在构造函数函数体中出现了两次,最终该选择哪个变量作为它的引用对象呢?我们来看如下伪代码:
class A
{
public:
//构造
A(int& a, int& b)
{
//如果写在函数体内,编译器最终应该选择a还是选择4这个值
_a = a;
_a = 4;
//_b又应该是谁的引用?会产生歧义
_b = b;
_b = a;
}
private:
const int _a;
int& _b;
};
所以针对这两类比较特殊的成员变量,C++就设计了初始化列表,供这种只能初始化一次的成员变量进行初始化,如果使用上面的方式进行初始化编译器就会直接报错,所以初始化列表确保了初始化的唯一性,如下:
class A
{
public:
A(int& a, int& b)
:_a(a)
, _b(b)
{}
private:
const int _a;
int& _b;
};
这就是为什么const成员变量和引用成员变量必须使用初始化列表进行初始化的原因,那么为什么自定义类型也要使用初始化列表进行初始化呢?其实跟上面两个的原因差不多,我们慢慢来分析,首先在有默认构造的情况下,编译器会自动调用这个自定义类型的默认构造,如下:
class B
{
public:
B(int b = 10)
{
_b = b;
}
private:
Date _d;
int _b;
};
int main()
{
B b;
return 0;
}
在这个例子中,类B中既有内置类型,又有自定义类型,B中只对内置类型作了处理,编译器也不会报错,因为编译器默认会去调用这个自定义类型的默认构造对这个自定义类型的成员变量作初始化,我们调试来看看:
可以看到编译器确实帮我们去调用了日期类的默认构造,完成了对成员_d的初始化,那么如果日期类没有默认构造呢?是不是这个时候必须要我们手动初始化了,我们先来看看吧日期类的默认构造干掉来运行一下程序会怎么样,如下:
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
{}
可以看到编译器确实报错了,因为日期类的默认构造被我们改掉了,接下来我们就需要手动去初始化自定义类型日期类成员_d,但是由于编译器会确保类的所有成员变量在构造函数执行之前已经被初始化
这意味着在进入构造函数的函数体之前,自定义类型成员变量的构造函数必须已经被调用,所以如果自定义类型没有默认构造,我们也需要在初始化列表对其进行初始化,如下:
B(int b = 10, int year = 2025, int month = 1, int day = 1)
:_b(b)
,_d(year, month, day)
{}
我们来看看调试结果:
可以看到这才没有问题,最后我们小小总结一下哪些成员变量必须在初始化列表进行初始化,它们分别是const成员变量、引用成员变量以及自定义类型成员变量
那么关于初始化列表还有一些相关的知识我们就一并说了,在上面的描述中我们不难猜出,其实无论初始化列表中有没有显示写东西,编译器都会去走一遍初始化列表,调试一下也看得出来,所以我们可以尽量在初始化列表初始化所有的成员变量
如果仅仅在初始化列表初始化不了这个成员,比如需要开空间,我们才在函数体对这个成员变量进行初始化,其它的成员变量仍然在初始化列表进行初始化,这样做的好处是:不再需要记忆哪些成员变量必须在初始化列表初始化,因为我所有成员变量都在初始化列表初始化
其次,C++11⽀持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使⽤的,也就是说它算是给初始化列表备用的,有时候这一点非常重要,在后面STL容器的实现中会体现出来,我们到时候讲到再说,我们来看看它怎么使用,如下:
class C
{
private:
const int _c = 5;
int* _x = nullptr;
Date _d = { 2025, 1, 1 };
};
这样也可以完成const成员变量和自定义类型成员变量的初始化,引用就不行,因为它需要指定引用哪个对象,需要传参,而这种在成员变量声明的位置给缺省值的做法还是比较常见,建议初始化列表和这个位置最好都写一下默认值,这样就能万无一失了
但是我们要强调的是,初始化列表和在成员变量声明的位置给缺省值它们两个是有优先级的,如果有初始化列表就会直接走初始化列表,然后跳过这里的默认值,没有初始化列表才会走这里的默认值,具体顺序优先级如下图:
总结下来的优先级就是:初始化列表 > 在声明位置给默认值 > 构造函数函数体内的初始化
最后我们来总结一下初始化列表:⽆论是否显示写初始化列表,每个构造函数都有初始化列表,⽆论是否在初始化列表显⽰初始化,每个成员变量都要走初始化列表初始化,所以最好所有成员变量都走初始化列表初始化
二、隐式类型转换
在C++中有一个很神奇并且很实用的东西,就是隐式类型转换,它⽀持内置类型隐式类型转换为类类型对象,只不过需要这个类类型有相关内置类型为参数的构造函数,这么一说可能有点懵,我们来举一个例子:
class Date
{
public:
Date(int year)
:_year(year)
{}
private:
int _year = 2025;
int _month = 1;
int _day = 1;
};
int main()
{
//这就是隐式类型转换
Date d = 2026;
return 0;
}
在上面我们看到了一个奇怪的写法,就是用一个整型初始化这个日期类,它们两个之间似乎并没有联系,那我们来运行代码看看会不会出错,如下:
经过调试我们发现代码不仅没有错误,还得到了似乎正确的结果,这是为什么呢?这就涉及到C++的隐式类型转换了,它支持一个内置类型转化为一个类类型,要求就是这个类类型有包含这个内置类型的构造函数
比如上面的例子中,看起来日期类和整型毫无关系,实际上日期类通过它的构造函数和整型有了联系,在构造函数中,我们只需要传一个整型参数就可以构造一个日期类对象,相当于就是用一个整型就可以构造日期类对象
语法上的规定是这样的,调用日期类的构造函数,将2025传给了日期类的构造函数,构造出来了一个临时对象,然后再用这个临时对象拷贝构造出来d,最后销毁这个临时对象,这个完整的过程就被称为隐式类型转换,因为看起来就像是从一个内置类型直接转换成了日期类这样的类类型,但实际上是调用了日期类的构造函数
当然,我们还需要理解的一个点就是,我们的构造只初始化了年,为什么月和日也被初始化为了1呢?这个就要涉及到上面我们才讲过的知识,那就是一个成员变量如果走了初始化列表就不会走后面,而如果一个成员变量没有走初始化列表就会走后面的逻辑
年出现在了初始化列表,所以年这个成员变量直接初始化为了2025,不再走后面的逻辑,而月和日都不再初始化列表,所以它们都要走后面的逻辑,后面的逻辑就是有默认值走默认值就走默认值,没有默认值才走函数体内的初始化,上面的月和日有默认值,所以走了默认值1,最终d就是2025年1月1日
现在我们基本上把简单的隐式类型转换讲明白了,我们接下来再学习稍微复杂一丢丢的隐式类型转换,但是原理都是差不多的,就是如果这个构造函数有多个内置类型参数怎么办,如下:
Date(int year, int month)
:_year(year)
,_month(month)
{}
这个时候我们就不能像刚刚那样去写了,因为刚刚那样写相当于永远都只能当作构造的一个参数,解决方法也很简单,就是使用{},将对应的参数一一写上,如下:
Date d = { 2025, 2 };
我们来看代码调试结果:
可以看到这里也成功进行了隐式类型转换,本质就是将2025和2这两个参数传给日期类的构造,构造出来一个临时对象,然后这个临时对象再拷贝构造给日期类对象d,然后销毁这个临时对象
这就是如果类类型的构造有多个参数的解决办法,我们的隐式类型转换也就差不多这些内容,在后面它有着非常重要的应用,比如传参时的应用,我们在后面的匿名对象部分一起同时类类型和类类型直接也可以隐式类型转换,也是需要另一方提供对应的构造函数,原理和上面差不多,这里就不再举例说明了
在最后我其实想说一个编译器优化的问题,如果编译器严格按照语法实现隐式类型转换其实是不好的,因为我们会先构造临时对象,再发生拷贝构造,影响了效率,所以大部分编译器都会做一个优化,就是不再产生临时对象,而是根据参数直接构造出我们需要的对象,如图:
可以看到实际编译器进行优化之后,整个过程变得简单多了,提高了效率
三、类中的静态成员
类的static成员比较特殊,分为static成员变量以及static成员函数,它们出现的也不多,所以我们简单讲一下它们的使用即可,接下来我们分别进行解析
1.静态成员变量
static修饰的成员变量称为静态成员变量,它的特点是属于当前类域,受三个域访问限定符的限定,但是它只能在类外部初始化,因为static成员变量不属于这个类的对象,它存放在静态区,所有对象都共享这个成员,也就导致了它只能在类外部初始化
如果在类内部初始化就会发生多个对象初始化这个静态成员变量的情况,但是静态成员变量我们一般是有特殊作用的,只初始化一次,所以就只能在类外部初始化,初始化列表以及声明位置加上默认值都是不行的,会直接报错
就像成员函数一样,也是写在类中,但是却并不属于任何一个对象,它跟普通静态变量的区别就是它被写在了类中,作用域被类限制了,生命周期仍然是全局的,接下来我们写一个简单的例子来使用一下静态成员变量,需求就是统计这个类实例化出来了多少个对象,如下:
class Date
{
public:
//调用了构造或拷贝构造说明实例化出了一个日期类对象,直接++
Date(int year = 2025, int month = 1, int day = 1)
:_year(year)
,_month(month)
,_day(day)
{
_count++;
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
_count++;
}
//调用析构说明少了一个对象,--即可
~Date()
{
_count--;
}
int GetCount()
{
return _count;
}
private:
//受类域访问限定符的限制
static int _count;
int _year;
int _month;
int _day;
};
//在类外部初始化,不能再加static并且要指定类域
int Date::_count = 0;
int main()
{
Date d1;
Date d2 = d1;
cout << d2.GetCount() << endl;
return 0;
}
在上面的代码示例中,我们在日期类中添加了一个静态成员变量_count,它的作用就是帮我们计算日期类现在一共实例化出来了多少个对象,我们来看看代码运行结果,如下:
可以看到代码达到了我们的预期效果,也许会有人说为什么这里必须用静态成员变量,而不是直接使用普通成员变量,因为普通成员变量是属于某个具体对象的,相互之间是独立的,所以它不能做全局数据的统计
而静态成员变量的作用域虽然属于这个类,但是生命周期是全局的,对它的操作是共享于所有对象的,这样才能统计实例化出对象的个数
2.静态成员函数
由static修饰的成员函数就是静态成员函数,它的特点是没有this指针,相当于一个受static修饰的普通函数被类域限制了,因为它连this指针都没了,this指针是一个成员函数的基本特征,然后由于它没有this指针也就导致它不能访问普通成员变量,要记住成员函数访问普通成员变量的本质是编译器帮我们传参了this指针
所以静态成员函数没有this指针也就不能访问普通的成员变量,但是它可以访问静态成员变量,因为访问静态成员变量不需要this指针,可以直接访问,原理我们也讲过了,就是因为静态成员变量相当于只是被类域限制住的普通静态变量,只要在类域内就可以随意访问,跟有没有this指针没关系
况且使用this指针只能访问普通的成员变量,或者说就算不在类域内也可以使用静态成员变量,只要我们使用域访问限定符(::)突破类域即可,照样能访问这个静态成员变量,所以总结下来就是由于静态成员函数没有this指针,所以只能访问静态成员变量,而不能访问非静态成员变量
这里为了避免误会,我们还是得提一句,就是普通的成员函数既可以访问普通成员变量,也可以访问静态成员变量,因为有this指针,所以可以访问普通成员变量,又因为访问静态成员变量跟this指针没关系,所以也可以访问静态成员变量
总结下来就是,静态成员变量和静态成员函数,跟全局的静态变量和静态函数的唯一区别只是它们被类域限定住了,作用域被改变了,仅此而已
四、友元函数与友元类
友元函数和友元类比较简单,我们之前在日期类的实现中也用过一次,就是有一个类想访问我的私有成员变量,一般来说不被允许,但是有一种方法就是让这个类成为我的“朋友”,这样我的私有成员变量就可以给它使用了,我完全信任了它,接下来我们举一个简单的例子:
class A
{
//友元声明
friend void func(const A& aa);
private:
int _a1 = 1;
int _a2 = 2;
};
void func(const A& aa)
{
cout << aa._a1 << endl;
cout << aa._a2 << endl;
}
int main()
{
A aa;
func(aa);
return 0;
}
上面我们将函数func声明为了A类的友元函数,所以func函数就直接可以访问A的私有成员变量,我们来看看代码运行结果:
可以看到代码符合我们的预期,当然,除了上面例子中的友元函数,还有友元类,就是将另一个类声明为当前类的友元类,那么当前这个类就可以访问另一个类的私有成员变量了,与友元函数作用差不多,这里就不多解释了
最后我们来说说友元的一些缺点:
1. 友元类的关系是单向的,不具有交换性,如果A类是B类的友元,但是B类不是A类的友元
2. 友元类关系不能传递,如果A是B的友元,B是C的友元,但是A不是C的友元
3. 友元有时提供了很大的便利,但是友元过多的友元会增加耦合度,破坏了封装,所以友元不宜多⽤,当然一般来说也不会经常使用,如果需要经常使用我们就要考虑过多的友元是否会破坏耦合度了
五、内部类
如果⼀个类定义在另⼀个类的内部,这个内部类就叫做内部类。内部类是⼀个独⽴的类,跟定义在全局相⽐,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类
也就是说内部类只是定义在当前类中,当前类实例化出来的对象不会包含内部类,并且这个内部类默认是外部类的友元类,内部类本质也是⼀种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使⽤,那么可以考虑把A类设计为B的内部类,如果放private/protected位置,那么A类就是B类的专属内部类,其他地⽅都⽤不了
这个概念也比较简单,这里举一个例子就好了,如下:
class A
{
public:
class B //B默认就是A的友元,B可以访问A类的成员变量
{
public:
void foo(const A& a)
{
//B在类的作用域中,就可以使用_k
cout << _k << endl;
cout << a._h << endl;
}
};
private:
static int _k;
int _h = 1;
};
int A::_k = 1;
int main()
{
cout << sizeof(A) << endl;
A::B b;
A aa;
b.foo(aa);
return 0;
}
代码运行结果:
根据结果我们可以看出来,B类虽然定义在A类中,但是并不会占据对象的空间,同时我们之前讲过,静态成员变量不属于任何一个对象,所以这里静态成员变量也不会占空间,只有一个整型变量_k要占据空间,所以sizeof(A)的结果为4字节
后米娜打印的也是我们自己设置的k和h的值,没有问题,符合我们的预期
六、匿名对象
匿名对象就是没有名字的对象,它的生命周期只有一行,超出这一行这个匿名对象就析构了,看起来它很鸡肋,实际上在传参的领域它很好用,和隐式类型转换可以结合使用,我们先来看看匿名对象如何创建,如下:
class Date
{
public:
//调用了构造或拷贝构造说明实例化出了一个日期类对象,直接++
Date(int year = 2025, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
void Func(const Date& d)
{
//对日期类的处理
}
int main()
{
//以下两个都是匿名对象
Date();
Date({ 2025, 2, 15 });
return 0;
}
这里我们就创建了两个匿名对象,它们都没有名字,在类型后面紧跟这就是一个括号,可以根据里面的括号获取构造的参数,然后通过隐式类型转换来构造出来一个匿名对象,比如第一个对象就是无参构造出来的
匿名对象一般可以用来传参,或者说用来调用一些特殊的成员函数,我们这里以传参作为例子来讲,调用成员函数我们在以后的文章会提到,到对应场景我们再讲解,我们先来看看没有隐式类型转换和匿名对象我们怎么传参,如下:
class Date
{
public:
//调用了构造或拷贝构造说明实例化出了一个日期类对象,直接++
Date(int year = 2025, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
void Func(const Date& d)
{
//对日期类的处理
}
int main()
{
Date d1;
Date d2(2025, 2, 15);
Func(d1);
Func(d2);
return 0;
}
在上面例子中,我们写了一个伪代码,里面需要对日期类进行传参,可以看到我们使用函数的时候很别扭,因为d1和d2对象我们可能并不需要,但是我们为了传参不得不创建它们,然后用于传参,如果我们有隐式类型转换和匿名对象就要好多了,如下:
class Date
{
public:
//调用了构造或拷贝构造说明实例化出了一个日期类对象,直接++
Date(int year = 2025, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
void Func(const Date& d)
{
//对日期类的处理
}
int main()
{
//使用匿名对象传参(可以利用默认构造)
Func(Date());
Func(Date({ 2025, 2, 15 }));
//使用隐式类型转换传参(不能利用默认构造)
Func({ 2025, 2, 15 });
return 0;
}
可以看到,如果我们可以使用匿名对象和隐式类型转换传参,事情就变简单多了,隐式类型转换似乎还要更简单一些,只是不能利用默认构造了,但是如果我们把它们结合起来使用肯定是嘎嘎香的,在后面的STL部分我们也会经常用到的
那么今天类和对象的尾卷就到此结束啦,连着好几篇的类和对象终于结束了,我们后面再简单学学C++的内存管理和模板就进入STL部分了,那才是库库爽,那么今天就到这里
bye~