C++类和对象下篇:深入理解面向对象的核心进阶特性
在C++面向对象编程中,类和对象是核心基石。继上篇掌握类的基本定义、对象实例化和普通成员函数的使用后,本文将聚焦于构造函数进阶、静态成员、友元、内部类等关键特性,这些知识点是突破C++面向对象编程的重要关口,也是面试高频考点。
引言
当你已经熟悉了类的基本定义和对象的使用后,是否曾有过这样的困惑:
- 为什么构造函数体里的赋值不叫"初始化"?
- 如何统计一个类创建了多少个对象?
- 为什么
cout << obj不能直接作为成员函数重载? - 内部类和外部类到底是什么关系?
本文将结合实际代码案例,逐一拆解这些核心概念,帮你彻底理解并灵活运用。
一、再谈构造函数:从赋初值到真正的初始化
构造函数的核心作用是创建对象时为其成员变量设置初始值,但普通的构造函数体赋值并非真正的"初始化"。这一章节的重点就是理解初始化列表的意义,以及explicit关键字的使用。
1.1 构造函数体赋值的局限
构造函数体中的语句本质是赋初值,而非初始化。因为初始化只能对变量执行一次,而构造函数体内可以对同一个成员变量进行多次赋值:
classDate{public:Date(intyear,intmonth,intday){_year=year;// 赋初值,而非初始化_year=2024;// 可多次赋值_month=month;_day=day;}private:int_year;int_month;int_day;};上述代码中,_year被先后赋值两次,这说明构造函数体只是完成了值的修改,而非对成员变量的初始化。
1.2 初始化列表:真正的初始化方式
初始化列表是C++中对类成员变量进行真正初始化的唯一方式,它以冒号:开头,后跟逗号分隔的成员变量初始化项,每个成员变量后用括号指定初始值:
classDate{public:// 初始化列表初始化Date(intyear,intmonth,intday):_year(year),_month(month),_day(day){}private:int_year;int_month;int_day;};初始化列表的四大核心注意事项
注意1:唯一性
每个成员变量在初始化列表中只能出现一次,因为初始化只能执行一次。
注意2:强制性
以下三类成员变量必须在初始化列表中初始化,无法在构造函数体中赋初值:
- 引用成员变量
const成员变量- 自定义类型成员(且该类无默认构造函数时)
classA{public:A(inta):_a(a){}// 无默认构造函数private:int_a;};classB{public:// 必须在初始化列表初始化A对象、引用、const变量B(inta,intref):_aobj(a),_ref(ref),_n(10){}private:A _aobj;// 自定义类型,无默认构造int&_ref;// 引用constint_n;// const变量};为什么这三类成员必须在初始化列表初始化?
- 引用成员:C++语法规定,引用必须在定义时初始化,且一旦初始化不能改变指向;构造函数体是在成员定义(内存分配)后执行,无法完成引用的初始化。
- const成员:C++中const常量必须在定义时初始化,后续不能修改其值,构造函数体无法满足"定义时初始化"的要求。
- 无默认构造的自定义类型成员:自定义类型成员创建时,编译器会自动调用其构造函数;若该类无默认构造(无参/全缺省构造),编译器无法自动推导,必须在初始化列表中显式调用其带参构造完成初始化。
注意3:优先性
无论是否显式使用初始化列表,自定义类型成员变量一定会先通过初始化列表完成初始化。即使构造函数体为空,自定义类型成员也会调用其默认构造函数初始化。
注意4:顺序性
成员变量的初始化顺序由其在类中的声明次序决定,与初始化列表中的书写顺序无关。这是一个极易踩坑的点:
classA{public:A(inta):_a1(a),_a2(_a1){}voidPrint(){cout<<_a1<<" "<<_a2<<endl;}private:int_a2;// 先声明int_a1;// 后声明};intmain(){Aaa(1);aa.Print();// 输出:1 随机值}上述代码中,_a2先声明,因此先被初始化,此时_a1尚未初始化,_a2(_a1)会取到随机值,后续_a1被初始化为1,最终打印结果为1 随机值。
避坑技巧:编写初始化列表时,让书写顺序与类内声明顺序一致,避免逻辑错误。
1.3 explicit关键字:禁止构造函数的隐式类型转换
构造函数除了初始化对象,单个参数或除第一个参数无默认值,其余均有默认值的构造函数,会具备隐式类型转换的能力。这种特性虽然灵活,但会降低代码可读性,explicit关键字可禁止该特性。
隐式类型转换的场景
classDate{public:// 单参构造函数,未加explicit,支持隐式转换Date(intyear):_year(year){}Date&operator=(constDate&d){if(this!=&d){_year=d._year;}return*this;}private:int_year;};voidTest(){Dated1(2022);d1=2023;// 隐式转换:2023构造无名Date对象,再赋值给d1}上述代码的执行过程是两步操作:
- 编译器用2023调用
Date(int year)构造一个临时匿名Date对象 - 调用赋值运算符
operator=,将临时对象赋值给d1
explicit的作用
在构造函数前添加explicit,即可禁止上述隐式转换:
explicitDate(intyear):_year(year){}// 此时d1 = 2023; 编译报错,禁止了隐式类型转换扩展要点:
- explicit仅对单参构造或除第一个参数无默认值,其余全有默认值的多参构造有效
- C++11中,explicit也可修饰拷贝构造,禁止拷贝构造的隐式转换
- 显式转换不受影响:
d1 = Date(2023)是显式构造临时对象,即使加了explicit也能编译通过
二、static成员:类的共享成员
在类中,用static修饰的成员变量和成员函数,称为静态成员,其核心特性是属于类,而非某个具体的对象,为所有类对象所共享,是实现类级别的数据共享和函数调用的关键。
2.1 静态成员的核心概念
- 静态成员变量:类内声明,必须在类外初始化,初始化时不添加
static关键字 - 静态成员函数:无隐藏的
this指针,不能访问非静态成员 - 访问方式:
类名::静态成员或对象.静态成员
2.2 经典面试题:统计类对象的创建个数
静态成员的经典应用场景是统计类对象的创建/销毁个数,因为静态成员变量为所有对象共享,不会随对象销毁而释放:
classA{public:// 构造函数:创建对象,计数+1A(){++_scount;}// 拷贝构造:拷贝对象,计数+1A(constA&t){++_scount;}// 析构函数:销毁对象,计数-1~A(){--_scount;}// 静态成员函数:获取当前对象个数staticintGetACount(){return_scount;}private:staticint_scount;// 类内声明静态成员变量};intA::_scount=0;// 类外初始化,必须写!voidTestA(){cout<<A::GetACount()<<endl;// 输出0,无对象A a1,a2;Aa3(a1);cout<<A::GetACount()<<endl;// 输出3,3个对象}2.3 静态成员的五大特性
- 静态成员存放在静态区,为所有类对象共享,不属于任何单个对象
- 静态成员变量类内声明,类外定义,定义时不添加
static - 访问方式:
类名::静态成员(推荐)或对象.静态成员 - 静态成员函数无this指针,因此不能访问任何非静态成员(非静态成员需要this指针指向具体对象)
- 静态成员受访问限定符(
public/protected/private)限制
验证:
sizeof(类名)的结果不包含静态成员变量,因为静态成员不属于对象,仅计算非静态成员的内存大小。
2.4 高频面试问题
问题1:静态成员函数可以调用非静态成员函数吗?
答案:不能
非静态成员函数有一个隐藏的this指针参数(
*this),用于指向调用该函数的具体对象;而静态成员函数属于类本身,无this指针,无法确定要访问哪个对象的非静态成员,因此编译器会报错。
问题2:非静态成员函数可以调用静态成员函数吗?
答案:可以
静态成员属于类,为所有对象共享,其访问不依赖于具体对象;非静态成员函数有this指针,调用静态成员函数时,编译器会自动忽略this指针,直接通过类名访问静态成员,无需传递对象。
三、友元:突破封装的特殊机制
封装是C++面向对象的三大特性之一,而友元是一种突破封装的特殊方式,允许类外部的函数/类直接访问类的私有和保护成员。友元虽带来便利,但会增加代码耦合度、破坏封装,因此不宜多用。
友元分为友元函数和友元类两类。
3.1 友元函数
友元函数的使用场景
最典型的场景是重载流插入operator<<和流提取operator>>。因为这两个运算符的左操作数是ostream/istream对象(如cout/cin),而类的成员函数的第一个参数是隐藏的this指针,无法将cout作为左操作数,因此需要将其重载为全局函数,再通过友元让全局函数访问类的私有成员。
友元函数的实现
#include<iostream>usingnamespacestd;classDate{// 声明友元函数:无需加访问限定符,可写在类内任意位置friendostream&operator<<(ostream&_cout,constDate&d);friendistream&operator>>(istream&_cin,Date&d);public:Date(intyear=1900,intmonth=1,intday=1):_year(year),_month(month),_day(day){}private:int_year;int_month;int_day;};// 全局友元函数:直接访问Date的私有成员ostream&operator<<(ostream&_cout,constDate&d){_cout<<d._year<<"-"<<d._month<<"-"<<d._day;return_cout;}istream&operator>>(istream&_cin,Date&d){_cin>>d._year>>d._month>>d._day;return_cin;}intmain(){Date d;cin>>d;// 调用友元函数operator>>cout<<d;// 调用友元函数operator<<return0;}核心问题:为什么operator<>不能重载为成员函数?
C++中运算符重载的左操作数是函数的第一个参数:
- 若将
operator<<重载为Date的成员函数,其第一个参数是隐藏的this指针(指向Date对象),调用形式为d1 << cout,与实际使用的cout << d1相反,不符合编程习惯- 若强行按成员函数重载,会导致使用方式怪异,且无法与标准输入输出流兼容
解决方案:将其重载为全局函数,让cout(ostream对象)作为第一个参数,Date对象作为第二个参数;再通过友元让全局函数访问Date的私有成员。
友元函数的特性
- 友元函数是全局普通函数,不属于任何类
- 可直接访问类的私有/保护成员,无需通过对象/类名
- 不能用
const修饰(const修饰的是成员函数的this指针,友元函数无this) - 类内声明时不受访问限定符限制,可写在
public/private/protected任意位置 - 一个函数可作为多个类的友元函数
3.2 友元类
友元类的所有成员函数,都可以作为另一个类的友元函数,直接访问其私有/保护成员。
友元类的实现
classTime{// 声明Date为Time的友元类:Date的所有成员函数可访问Time的私有成员friendclassDate;public:Time(inthour=0,intminute=0,intsecond=0):_hour(hour),_minute(minute),_second(second){}private:int_hour;int_minute;int_second;};classDate{public:Date(intyear=1900,intmonth=1,intday=1):_year(year),_month(month),_day(day){}voidSetTimeOfDate(inthour,intminute,intsecond){// 直接访问Time的私有成员_t._hour=hour;_t._minute=minute;_t._second=second;}private:int_year;int_month;int_day;Time _t;};友元类的三大核心特性
- 单向性:友元关系是单向的,不具有交换性。比如上述
Date是Time的友元,Date可访问Time的私有成员,但Time不能访问Date的私有成员 - 不可传递性:若C是B的友元,B是A的友元,不能推出C是A的友元
- 不可继承性:友元关系无法被继承,子类不能继承父类的友元关系
四、内部类:类中的独立类
内部类是指定义在另一个类内部的类,它是一个完全独立的类,不属于外部类,外部类也无法直接访问内部类的成员。但核心特性是:内部类天生是外部类的友元类。
4.1 内部类的核心特性
- 内部类可定义在外部类的
public/protected/private任意位置 - 内部类可直接访问外部类的static静态成员,无需外部类的对象或类名
- 内部类访问外部类的非静态成员,需要通过外部类的对象参数
sizeof(外部类)的结果与内部类无关,因为内部类是独立的类
4.2 内部类的代码实现
classA{private:staticintk;// 静态成员inth;// 非静态成员public:// 内部类B,天生是A的友元类classB{public:voidfoo(constA&a){cout<<k<<endl;// 直接访问外部类的static成员cout<<a.h<<endl;// 通过外部类对象访问非静态成员}};};intA::k=10;// 外部类静态成员类外初始化intmain(){// 访问内部类:外部类名::内部类名A::B b;b.foo(A());// 传入外部类匿名对象return0;}五、匿名对象:无名字的临时对象
匿名对象是指创建时不指定对象名的临时对象,其核心特性是生命周期仅在当前行,执行完当前行后会立即调用析构函数销毁,适合临时使用的场景。
5.1 匿名对象的定义和使用
classA{public:A(inta=0):_a(a){cout<<"A(int a)"<<endl;}~A(){cout<<"~A()"<<endl;}private:int_a;};classSolution{public:intSum_Solution(intn){returnn*(n+1)/2;}};intmain(){Aaa1(1);// 普通对象A();// 匿名对象:当前行执行完立即析构Aaa2(2);// 普通对象// 匿名对象的经典使用:调用类的成员函数,无需定义命名对象cout<<Solution().Sum_Solution(10)<<endl;return0;}5.2 注意点
不能用A aa1()的方式定义对象,编译器会将其识别为函数声明(返回值为A,无参数的函数),而非对象定义。
六、编译器优化:拷贝对象的性能提升
在C++中,传参和传返回值的过程中,编译器会对对象的拷贝进行优化,减少不必要的拷贝构造,提升程序性能。这是编译器的特性,不同编译器的优化策略基本一致。
classA{public:A(inta=0):_a(a){cout<<"A(int a)"<<endl;}A(constA&aa):_a(aa._a){cout<<"A(const A& aa)"<<endl;}A&operator=(constA&aa){cout<<"A& operator=(const A& aa)"<<endl;if(this!=&aa)_a=aa._a;return*this;}~A(){cout<<"~A()"<<endl;}private:int_a;};voidf1(A aa){}// 传值传参Af2(){A aa;returnaa;}// 传值返回6.1 核心优化场景
场景1:隐式类型转换优化
f1(1);// 1构造A对象+拷贝构造给f1的形参 → 优化为直接构造形参场景2:单个表达式中的优化
f1(A(2));// 构造匿名对象+拷贝构造给形参 → 优化为直接构造形参场景3:返回值优化
A aa2=f2();// f2返回值拷贝构造+赋值给aa2 → 优化为一次拷贝构造场景4:无法优化的场景
A aa1;aa1=f2();// f2返回值拷贝构造临时对象+赋值重载给aa1 → 无优化七、再次理解封装:从现实到计算机的映射
C++的类和对象本质是对现实世界实体的抽象和建模,而封装是实现这种建模的核心手段,封装将实体的属性(成员变量)和行为(成员函数)包裹在一起,同时通过访问限定符控制外部对成员的访问,实现了数据隐藏和代码复用。
从现实世界到计算机世界的映射过程:
- 抽象:对现实实体(如洗衣机)进行分析,提取其属性(容量、品牌)和行为(洗衣、脱水)
- 描述:用C++类将抽象后的属性和行为进行定义,形成自定义类型
- 实例化:通过类创建具体的对象,计算机才能够识别并处理该实体
- 模拟:通过调用对象的成员函数,模拟现实实体的行为
简单来说:类是对现实实体的抽象描述,对象是抽象描述的具体实例,而封装则保证了抽象描述的完整性和安全性。
总结
本文详细讲解了C++类和对象下篇的七大核心知识点,其中初始化列表、static成员、友元是面试和开发中的高频考点,需要重点掌握:
| 知识点 | 核心要点 |
|---|---|
| 初始化列表 | 是类成员真正的初始化方式,三类成员(引用/const/无默认构造的自定义类型)必须在其中初始化,且初始化顺序与类内声明一致 |
| static成员 | 属于类,为所有对象共享,静态成员函数无this指针,不能访问非静态成员 |
| 友元 | 是突破封装的特殊方式,分为友元函数和友元类,使用时需权衡耦合度 |
| 内部类 | 是独立的类,天生是外部类的友元,sizeof(外部类)与内部类无关 |
| 匿名对象 | 生命周期仅一行,适合临时调用成员函数的场景 |
| 编译器优化 | 会对拷贝对象进行优化,减少不必要的拷贝构造,提升性能 |
| 封装 | 是C++面向对象的核心,实现了从现实实体到计算机对象的映射 |
这些知识点是C++面向对象编程的基础,后续的继承、多态等特性都基于此,建议结合代码案例反复练习,加深理解。
写在最后
C++面向对象编程的世界博大精深,每一个知识点背后都有其设计的历史原因和实际应用场景。希望本文能帮助你建立起对类与对象进阶特性的系统认知。如果你在学习过程中有任何问题,欢迎在评论区留言讨论。
下一篇文章我们将探讨C++的继承特性,敬请期待!