C++ 类和对象(五):初始化列表、static、友元、内部类等7大知识点全攻略

C++ 类和对象(五):初始化列表、static、友元、内部类等7大知识点全攻略
🔥个人主页:小张同学

🎬作者简介:C++研发方向学习者

📖个人专栏: 《C语言》《数据结构》《C++深度剖析:从入门到深耕》

⭐️人生格言:无视中断,不弃热枕,方得坚持之道。


前言:

前面我们学习完了6种默认构造函数以及Date类的实现。今天这篇博客是类和对象的最后一些知识,主要会涉及初始化列表、类型转换、static成员函数,友元、匿名对象、编译器的一些优化。通过本篇博文和之前的博文,类和对象就完结撒花了。那我们就进入到类和对象的收官之战吧~


目录

一、再探构造函数

1.构造函数体内赋值:

2.初始化列表(重点):

3.初始化列表小练习:

二、类型转换

三、static成员

1.练习题1:

2.练习题2:

四、友元

1.友元函数:

2.友元类:

五、内部类

六、匿名对象

七、对象拷贝时的编译器优化

一、再探构造函数

1.构造函数体内赋值:

之前我们在创建对象时,编译器通过调用我们定义的构造函数,给对象中各个成员变量赋予一个合适的初始值。

class Date { public: // 构造函数 Date(int year = 0, int month = 1, int day = 1) { //实际上是赋值,并非严格意义上的初始化 _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };

需要注意的是: 虽然通过调用上述的构造函数后,对象中的每个成员变量都有了一个初始值,但是构造函数中的语句只能将其称作为赋初值,而不能称作为初始化。因为初始化只能初始化一次,而构造函数体内可以进行多次赋值

class Date { public: // 构造函数 Date(int year = 0, int month = 1, int day = 1) { _year = year;// 第一次赋值 _year = 2022;// 第二次赋值 //... _month = month; _day = day; } private: int _year; int _month; int _day; }; 

2.初始化列表(重点):

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

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

引用成员变量const成员变量没有默认构造的类类型变量,必须放在初始化列表位置进行初始化,否则会编译报错。

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

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

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

下面我们用代码来说明每个特点:

基础用法:

class Date { public: // 构造函数 Date(int year = 1, int month = 1, int day = 1) :_year(year) , _month(month) , _day(day) { //... } private: int _year; int _month; int _day; }; 

必须在初始化列表初始化的情况:

#include<iostream> using namespace std; class Time { public: Time(int hour) :_hour(hour) { cout << "Time()" << endl; } private: int _hour; }; class Date { public: Date(int& ref, int year = 1, int month = 1, int day = 1) :_year(year) ,_month(month) ,_day(day) ,_t(12) ,_ref(ref) ,_n(1) { // error C2512: “Time”: 没有合适的默认构造函数可用 // error C2530 : “Date::_ref” : 必须初始化引用 // error C2789 : “Date::_n” : 必须初始化常量限定类型的对象 } void Print() const { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; //以下这三个就是必须要在初始化列表中初始化的 //必须初始化的都需要在初始化列表初始化,要么就给缺省值 //不给就会报上面的那些错误 Time _t; // 没有默认构造 int& _ref; // 引用 const int _n; // const }; int main() { int a = 0; Date d1(a); d1.Print(); return 0; }

用初始化列表解决自定义类型(如MyQueue类)的一些问题:

//通过上面的初始化列表,我们之前MyQueue类的一些问题也可以得到解决 //这里就大概实现一部分,仅作为演示使用 #include<iostream> using namespace std; class Stack { public: Stack(int n)//这里给的不是默认构造 { cout << "Stack(int n)" << '\n'; } }; class MyQueue { public: //当不写MyQueue的构造函数时,会自动调用Stack的构造来初始化 //但是如果Stack没有默认构造函数怎么办? //如果仍然调用MyQueue默认的构造函数会报错 //MyQueue(int n = 4) //{ // _st1 = n; // _st2 = n; // //这里如果直接这样写是错误的,无法进行赋值 //} //我们可以利用初始化列表有效解决这个问题 MyQueue(int n = 4) :_st1(n) ,_st2(n)//这里就不是赋值了,是初始化,就可以进入到自定义的构造函数 { //... } private: Stack _st1; Stack _st2; }; int main() { MyQueue q1; return 0; }

在成员变量声明时利用缺省值进行初始化:

//其实除了初始化列表以外,我们还可以这样玩 #include<iostream> using namespace std; class Time { public: Time(int hour) :_hour(hour) { cout << "Time()" << endl; } private: int _hour; }; class Date { public: Date() :_year(100) { } void Print() const { cout << _year << "-" << _month << "-" << _day << endl; } private: //注意:这里不是初始化,而是给的缺省值,这个缺省值是给初始化列表的 //每个成员变量都会走初始化列表,如果未显示在初始化列表,则会按缺省值初始化 //如果都没有,那内置类型可能就是随机值,之前提到过的那三种就是直接报错 int _year = 1; int _month = 1; int _day=1; Time _t = 1;// 没有默认构造 const int _n = 1; // const int* _ptr = (int*)malloc(40);//数组也可以 }; int main() { Date d1; d1.Print(); return 0; }

那么都有初始化列表了,为什么还留着函数体内那一部分呢?

总结就是在函数体内还要进行检查、以及将空间初始化额操作等,见如下代码

//那为什么都使用了初始化列表了,还需要那个括号呢 //我们来看看下面这个场景 #include<iostream> using namespace std; class A { public: A(int n = 10) :_a((int*)malloc(sizeof(int)* n)) , _size(0) { //上面的_a的空间是否开辟成功是不是需要在这里检查 //以及给空间初始化是不是也得在这里实现 if (_a == nullptr) { perror("malloc fail!"); exit(1); } memset(_a, 0, sizeof(int) * n); } private: int* _a; int _size; }; int main() { A aa; return 0; }
初始化列表总结:

• 无论是否显示写初始化列表,每个构造函数都有初始化列表;

• 无论是否在初始化列表显示初始化成员变量,每个成员变量都要走初始化列表初始化;

3.初始化列表小练习:

下面程序的运行结果是什么()

A. 输出 1 1

B. 输出 2 2

C. 编译报错

D. 输出 1 随机值

E. 输出 1 2

F. 输出 2 1

答案:D

解析:初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持一致,以免类似错误。

二、类型转换

我们之前接触过内置类型和内置类型之前的转换(有隐式转换、显示转换),那么我们现在再来看看内置类型和类类型类类型之间的转换:

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

• 构造函数前面加explicit就不再支持隐式类型转换。

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

下面我们依然用代码来进一步说明每个特点:

基本使用:

#include<iostream> using namespace std; class A { public: //构造函数explicit就不再支持隐式类型转换了 //explict A(int a1) //这里的构造函数参数有相关内置类型 A(int a1) :_a1(a1) { } private: int _a1 = 1; int _a2 = 2; }; void func(const A& aa = 1) { } class Stack { public: void Push(const A& a) { } }; int main() { //1.内置类型之间的隐式类型转换 int i = 1; double j = i;//其实中间有个临时对象 const double& ref1 = i;//这个我们之前也讲过,中间的临时对象具有常性 //2.内置类型和类类型之间的隐式转换 //需要有相关内置类型为参数的构造函数 //这样写是直接构造 A a1(1); //这样写是拷贝构造 A a2 = a1; //这个才涉及到隐式类型转换 A a2 = 1;//先构造临时对象,再将临时对象拷贝给a2 const A& ref2 = a1; const A& ref3 = 1;//同理 //三种都可以,因为上面的函数参数是用过const修饰的(所以第二种也ok) func(a1); func(1); func(); //我们再来看看别的使用场景 Stack s1; //这样写有点麻烦了 A a3(3); s1.Push(a3); //就可以直接这样写 s1.Push(3); return 0; }

多参数转化以及类和类之间的隐式类型转换:

//再来看看多参数转化以及类和类之间的隐式类型转换 #include<iostream> using namespace std; class A { public: // 构造函数explicit就不再支持隐式类型转换 // explicit A(int a1) A(int a1) :_a1(a1) { cout << "A(int a1)" << '\n'; } A(const A& aa) { cout << "A(const A& aa)" << '\n'; } A(int a1, int a2) :_a1(a1) , _a2(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 a1(1); // 这里的隐式类型转换其实就是,2为参数构造临时对象,临时对象拷贝构造a2 -> 优化为直接构造 A a2 = 2; //这里也是一样 const A& ref1 = 3; //C++11之后才支持多参数构造 A a3(1, 1);//为直接构造 A a4 = { 1, 1 };//隐式类型转换 const A& ref2 = { 1, 1 };//同样都是隐式类型转换 //Stack st1; //st1.Push(a4); //st1.Push({2,2});//同上 //类和类之间的隐式类型转换,跟上面原理类似 //将a3隐式转换为b1 const B& ref4 = a3; //原理就是下面的代码 B b1 = a3; const B& ref3 = b1; return 0; }
我们现在所用的编译器基本上都将这些隐式类型转换给优化了,就优化掉产生临时对象这一部分了,直接构造,这样减少了拷贝,提高了效率。


三、static成员

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

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

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

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

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

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

• 静态成员也是类的成员,受public、protected、private 访问限定符的限制。

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

下面我们用代码来进一步说明每个特点:

#include<iostream> using namespace std; class A { public: //非静态的成员函数,可以访问任意静态成员变量以及非静态的成员变量 A(int a = 0) :_a1(a) , _a2(a) { ++_count; } A(const A& t) { ++_count; } //⾮静态的成员函数,可以访问任意的静态成员变量和静态成员函数 //静态成员函数中可以访问其他的静态成员,但是不能访问⾮静态的,因为没有this指针 static int GetCount() { // _a1++; 不能访问非静态成员,因为static成员函数没有this return _count; } private: int _a1 = 1; int _a2 = 1; //public: // 在类里面声明,且这里不可以用缺省值 static int _count; //可以运用静态成员来解决统计A类型的对象创建了多少个这样的问题 }; int A::_count = 0; int main() { //静态成员不存在对象之中 A aa1; cout << sizeof(aa1) << endl;//8,说明_count不存在对象中 A* ptr = nullptr; A aa2 = 1; ////突破类域就可以访问,但是受访问限定符的约束 //cout << ptr->_count<< endl; //cout << aa1._count << endl; //cout << A::_count << endl; //变成私有的话,需要使用一下函数来获取了 cout << A::GetCount() << endl;//突破类域可以访问公有的静态成员 cout << aa2.GetCount() << endl; cout << ptr->GetCount() << endl; return 0; }

1.练习题1:

求1+2+3+...+n_牛客题霸_牛客网

class Sum { public: Sum() { _count+=_i; ++_i; } static int RetCount() { return _count; } private: static int _i; static int _count; }; int Sum::_i=1; int Sum::_count=0; class Solution { public: int Sum_Solution(int n) { Sum arr[n];//调用n次Sum的构造函数 return Sum::RetCount(); } };

2.练习题2:

 再来看个练习 #include<iostream> using namespace std; class A { public: A() { cout << " A()" << endl; } ~A() { cout << " ~A()" << endl; } }; class B { public: B() { cout << " B()" << endl; } ~B() { cout << " ~B()" << endl; } }; class C { public: C() { cout << " C()" << endl; } ~C() { cout << " ~C()" << endl; } }; class D { public: D() { cout << " D()" << endl; } ~D() { cout << " ~D()" << endl; } }; //构造顺序 //析构顺序 C c; int main() { A a; B b; static D d; return 0; }

这个题要我们求这个程序的ABCD四个对象的构造顺序和析构顺序,大家可以自己运行一下答案就出来了。(注意:静态成员不存储在对象中,而是存储在静态区)


四、友元

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

• 外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,他不是类的成员函数。

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

• 一个函数可以是多个类的友元函数。

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

• 友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元。

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

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

下面我们来用代码说明每个特点:

1.友元函数:

//友元函数 #include<iostream> using namespace std; //前置声明B类,否则A的友元函数声明那里编译器不认识B //因为只会向上找 class B; class A { // 友元声明 friend void func(const A& aa, const B& bb); private: int _a1 = 1; int _a2 = 2; }; class B { // 友元声明 friend void func(const A& aa, const B& bb); private: int _b1 = 3; int _b2 = 4; }; void func(const A& aa, const B& bb) { cout << aa._a1 << endl; cout << bb._b1 << endl; } int main() { A aa1; B bb1; func(aa1, bb1); return 0; }

2.友元类:

//友元类,声明和定义需要分离,不然就算前置声明也解决不了问题,因为找不到类里面的成员 #include<iostream> using namespace std; //xxx.h //前置声明 class D; class C { // 友元声明 friend class D; public: void func1(const D& dd); private: int _a1 = 1; int _a2 = 2; }; class D { friend class C; public: void func1(const C& aa); void func2(const C& aa); private: int _b1 = 3; int _b2 = 4; }; //xxx.cpp void C::func1(const D& dd) { cout << dd._b1 << endl; } void D::func1(const C& aa) { cout << aa._a1 << endl; cout << _b1 << endl; } void D::func2(const C& aa) { cout << aa._a2 << endl; cout << _b2 << endl; } //test.cpp int main() { C cc; D dd; cc.func1(dd); dd.func1(cc); dd.func2(cc); return 0; }

五、内部类

• 如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,跟定义在全局相比,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类。

内部类默认是外部类的友元类。

• 内部类本质也是一种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使用,那么可以考虑把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其他地方都用不了。

下面用代码详解各个特点:

#include<iostream> using namespace std; class A { private: static int _k; int _h = 1; public: // 内部类 class B // B默认就是A的友元 { public: void foo(const A& a) { cout << _k << endl; //OK cout << a._h << endl; //OK } private: int _b1; }; }; int A::_k = 0; int main() { //静态成员变量和内部类都不算 cout << sizeof(A) << endl;//4,说明静态成员和内部类不存在于对象中 // 受外部类类域限制和访问限定符限制 A::B bb; A aa; bb.foo(aa); return 0; }

我们在了解完内部类之后可以将前面的从1加到n的题优化一下:

class Solution { class Sum {//优化为内部类 public: Sum() { _count += _i; ++_i; } static int RetCount() { return _count; } private: static int _i; static int _count; }; public: int Sum_Solution(int n) { Sum arr[n];//调用n次Sum的构造函数 return Sum::RetCount(); } }; int Solution::Sum::_i = 1; int Solution::Sum::_count = 0;

六、匿名对象

• 用 类型(实参) 定义出来的对象叫做匿名对象,相比之前我们定义的 类型 对象名(实参) 定义出来的叫有名对象。

• 匿名对象生命周期只在当前一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象。

• const 引用会延长匿名对象的生命周期,使其生命周期跟const 引用一样。

下面我们用代码详解各个特点:

//匿名对象 //先拿上面的为例 #include<iostream> using namespace std; class Solution { class Sum { public: Sum() { _ret += _i; _i++; } }; public: int Sum_Solution(int n) { //Sum arr[n];//要变长数组,vs不支持 return _ret; } //加个clear和析构 void clear() { _i = 1; _ret = 0; } ~Solution() { cout << "~Solution()" << '\n'; } private: static int _ret; static int _i; }; int Solution::_i = 1; int Solution::_ret = 0; void fun(const Solution& s = Solution(), int i = 1)//缺省值直接给匿名对象 { } int main() { Solution s;//有名对象 cout << s.Sum_Solution(10) << '\n'; s.clear(); //匿名对象的生命周期只在当前行 Solution(); //匿名对象 //const 引用会延长匿名对象的生命周期,生命周期跟const 引用一样 const Solution& ref = Solution(); return 0; }

七、对象拷贝时的编译器优化

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

• 如何优化C++标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于连续一个表达式步骤中的连续拷贝会进行合并优化,有些更新更"激进"的编译器还会进行跨行跨表达式的合并优化。

• linux下可以将下面代码拷贝到test.cpp文件,编译时用 g++ test.cpp -fno-elide-constructors 的方式关闭构造相关的优化。

下面我们用代码详解:

#include<iostream> using namespace std; class A { public: A(int a = 0)//构造 :_a1(a) { cout << "A(int a)" << endl; } A(const A& aa)//拷贝构造 :_a1(aa._a1) { cout << "A(const A& aa)" << endl; } A& operator=(const A& aa) { cout << "A& operator=(const A& aa)" << endl; if (this != &aa) { _a1 = aa._a1; } return *this; } ~A() { cout << "~A()" << endl; } private: int _a1 = 1; }; void f1(A aa) { } A f2() { A aa; return aa; } int main() { // 构造+拷贝构造 优化-> 构造 // 传值传参 // 构造 A aa1 = 1; cout << "==================" << endl; // 拷贝构造 f1(aa1); cout << "==================" << endl; // 隐式类型,连续构造+拷贝构造->优化为直接构造 f1(1); cout << "==================" << endl; // ⼀个表达式中,连续构造+拷贝构造->优化为⼀个构造 f1(A(1)); cout << "==================" << endl; // 传值返回 // 不优化的情况下传值返回,编译器会⽣成⼀个拷贝返回对象的临时对象作为函数调⽤表达式的返回值 // ⽆优化 (vs2019 debug) // ⼀些编译器会优化得更厉害,将构造的局部对象和拷贝构造的临时对象优化为直接构造(vs2022 debug) f2(); cout << endl; // 返回时⼀个表达式中,连续拷贝构造+拷贝构造->优化⼀个拷贝构造 (vs2019 debug) // ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,将构造的局部对象aa和拷贝的临时对象和接收返回值对象aa2优化为⼀个直接构造。(vs2022 debug) A aa2 = f2(); cout << endl; // ⼀个表达式中,开始构造,中间拷贝构造+赋值重载->⽆法优化(vs2019 debug) // —些编译器会优化得更厉害,进⾏跨⾏合并优化,将构造的局部对象aa和拷贝临时对象合并为—个直接构造(vs2022 debug) aa1 = f2(); cout << endl; return 0; }
// https://en.cppreference.com/w/cpp/language/copy_elision.html 接上 A f2() { // NRVO /*A aa; cout << &aa << endl; return aa;*/ // URVO return A(1); } int main() { A aa1 = f2(); cout << &aa1 << endl; return 0; } //或者-> A f2() { // NRVO A aa; cout << &aa << endl; return aa; // URVO // return A(1); } int main() { //不推荐,破坏了编译器的优化 A aa1; aa1 = f2(); cout << &aa1 << endl; // 推荐 A aa2 = f2(); return 0; }

本篇博客的完整原代码:

小张同学的CPP仓库——gitee.com


往期回顾:

C++ 类和对象(四):const成员函数、取地址运算符重载全精讲-ZEEKLOG博客

C++ Date日期类的设计与实现全解析-ZEEKLOG博客

C++ 类和对象(三):拷贝构造函数与赋值运算符重载之核心实现-ZEEKLOG博客

C++ 类和对象(二):实例化、this指针、构造函数、析构函数详解-ZEEKLOG博客


结语:

本文深入讲解了C++类和对象的核心概念,包括初始化列表、类型转换、static成员、友元关系和匿名对象等特性。重点分析了初始化列表的使用规则(必须初始化引用、const成员等)、类型转换的隐式规则(explicit禁止隐式转换)、static成员的共享特性(类外初始化)、友元关系的单向性等特点,如果文章对你有帮助的话,欢迎评论,点赞,收藏加关注,感谢大家的支持。