一、这一章在讲什么
第九章的主题是:类和对象。
如果说前面几章主要是在练 C++ 的基本语法,那么这一章开始真正进入面向对象编程的核心区域。它回答的是下面这些问题:
- 类到底是什么,和对象是什么关系
- 为什么成员变量和成员函数要被放进同一个“壳”里
public和private到底在保护什么- 构造函数、析构函数为什么重要
- 为什么带原始指针的类会遇到“浅复制”问题
- 复制构造函数和移动构造函数分别解决什么问题
this、friend、struct、union、聚合初始化分别扮演什么角色
这一章真正训练的,不只是“会写 class”,而是开始建立一种更接近工程代码的思维:对象如何诞生、如何被访问、如何被复制、如何被销毁。
二、类和对象的关系
2.1 类是蓝图,对象是实例
类可以理解成一种“模板”或“蓝图”,它描述一种事物应该具备哪些数据和哪些行为。
例如书里用 Human 来模拟一个人:
- 数据:姓名、年龄、出生地
- 行为:说话、自我介绍
类本身只是定义,不会自动运行。真正进入程序运行阶段的是对象。
Human firstMan;
这里 firstMan 才是对象,也叫这个类的一个实例。
2.2 类把“数据”和“操作数据的函数”放在一起
这是类最重要的意义之一。
过去用过程式写法时,数据和函数很容易分散;而类强调把:
- 成员数据
- 成员函数
放进同一个逻辑单元里。
这就是封装的起点。
2.3 对象既可以在栈上创建,也可以在堆上创建
Human firstMan;
Human* firstWoman = new Human();
delete firstWoman;
第一种是普通局部对象,通常放在栈上;第二种是动态分配的对象,通过指针持有。
这一点会直接影响后面成员访问方式和生命周期管理方式。
三、成员访问:. 和 ->
3.1 句点运算符访问普通对象成员
Human firstMan;
firstMan.age = 30;
firstMan.IntroduceSelf();
如果你手里拿的是对象本身,就使用 .。
3.2 指针运算符访问对象指针成员
Human* firstWoman = new Human();
firstWoman->age = 28;
firstWoman->IntroduceSelf();
如果你手里拿的是“指向对象的指针”,就使用 ->。
它和下面写法等价:
(*firstWoman).IntroduceSelf();
3.3 这里的核心不是语法,而是“你手里到底是什么”
很多人把 . 和 -> 当成单纯语法记忆,但本质区别在于:
.:你拿到的是对象->:你拿到的是对象地址
这一点和第八章的指针知识是直接连起来的。
四、public 和 private 真正解决什么问题
4.1 public 是对外接口,private 是内部实现
类设计时,不是所有成员都应该暴露给外部。
例如年龄 age 如果直接公开:
eve.age = -100;
那外部代码就可以随意写入错误状态。
如果把它改成私有:
class Human
{
private:
int age;
public:
void SetAge(int humansAge)
{
if (humansAge > 0)
age = humansAge;
}
};
那么类就能控制这个值如何被修改。
4.2 这就是封装和抽象
封装强调的是:把相关数据和行为装进一起。
抽象强调的是:对外只暴露必要接口,隐藏内部细节。
这意味着:
- 外部不必知道对象内部怎么存
- 外部只需要知道该怎么用
4.3 getter / setter 的重点不是“形式感”
很多初学者会觉得 GetAge()、SetAge() 只是绕一圈。
但真正价值在于:
- 可以校验输入
- 可以改变对外暴露方式
- 可以在不改调用方的前提下重构内部实现
也就是说,类不是把数据“锁死”,而是给数据访问加规则。
五、构造函数:对象出生时的规则
5.1 构造函数的基本特点
构造函数:
- 与类同名
- 没有返回类型
- 在对象创建时自动调用
class Human
{
public:
Human();
};
5.2 构造函数最重要的用途:初始化
成员变量尤其是 int、指针这样的成员,如果不初始化,很容易变成垃圾值。
所以构造函数最适合做的事,就是让对象一出生就处于合理状态。
class Human
{
private:
int age;
public:
Human()
{
age = 1;
}
};
5.3 默认构造函数
能够在不传参数情况下调用的构造函数,通常就叫默认构造函数。
例如:
Human person;
如果类只提供了一个必须传参数的构造函数,那么这种写法就不成立了。
5.4 构造函数可以重载
同一个类可以有多个构造函数,只要参数列表不同:
Human();
Human(int age);
Human(string name, int age);
这让对象创建方式更灵活。
5.5 没有默认构造函数的类
如果你写了带参数构造函数,但没有提供默认构造函数:
class Human
{
public:
Human(int age);
};
那么 Human h; 就会编译失败。
这是第九章里很容易踩的点。
5.6 带默认值的构造函数参数
如果构造函数的参数都有默认值,那么这个构造函数也可以承担默认构造函数的角色:
Human(int age = 1);
于是:
Human h;
Human h2(20);
都成立。
5.7 初始化列表比“函数体里赋值”更接近对象真实初始化
Human(string name, int age) : name(name), age(age) {}
初始化列表尤其适合这些场景:
- 常量成员
- 引用成员
- 成员对象本身也需要构造
虽然书里只是引入,但这一点在后面章节会越来越重要。
六、析构函数:对象离场时的规则
6.1 析构函数的特点
析构函数:
- 名字是
~类名 - 没有参数
- 没有返回类型
- 一个类只能有一个析构函数,不能重载
~Human();
6.2 析构函数最重要的用途:释放资源
如果构造函数里申请了资源,析构函数就应该负责清理资源。
最典型的例子就是动态内存:
class MyString
{
private:
char* buffer;
public:
~MyString()
{
delete[] buffer;
}
};
6.3 没写析构函数不一定立刻出错,但很容易留下泄漏
如果类里只有普通值类型成员,编译器生成的默认析构通常没问题。
但如果类自己拥有:
- 动态内存
- 文件句柄
- socket
- 其他外部资源
那“默认析构”往往就不够了。
七、复制构造函数:为什么对象复制会出问题
7.1 对象也会被复制
对象不是只有你写 = 才复制。
这些场景也会触发复制:
- 按值传参
- 按值返回
- 用一个同类对象初始化另一个对象
MyString b = a;
UseMyString(a);
return temp;
7.2 浅复制的问题
如果类里有原始指针成员,编译器默认复制往往只是“把指针值复制过去”。
也就是说:
- 两个对象拿到的是同一个地址
- 但它们以为自己各自拥有资源
一旦析构,就可能出现:
- 重复释放
- 悬空指针
- 程序崩溃
7.3 深复制才是拥有资源类真正需要的复制方式
深复制不是复制地址,而是:
- 重新申请一份资源
- 把源对象内容复制过来
MyString(const MyString& copySource)
{
buffer = new char[strlen(copySource.buffer) + 1];
strcpy(buffer, copySource.buffer);
}
这样两个对象才真正互不影响。
7.4 复制构造函数参数为什么是 const ClassName&
这是经典面试点。
原因有两个:
- 按引用传递,避免复制构造函数调用自己造成无限递归
const保证复制过程中不修改源对象
7.5 复制构造函数和复制赋值不是一回事
MyString b = a; // 复制构造
b = a; // 复制赋值
这两个动作发生的时机不同,语义也不同。
书里重点讲的是复制构造,但它已经明显提示了后面会进入复制赋值运算符。
八、移动构造函数:为了性能,不必总是深复制
8.1 为什么需要移动构造
有些对象只是临时值。
如果对这种临时对象也做深复制,成本可能很高,尤其对象内部资源很大时。
8.2 移动构造的核心不是“复制”,而是“接管”
移动构造函数的核心思想是:
- 直接接管源对象手里的资源
- 把源对象置为空或无害状态
MyString(MyString&& moveSource)
{
buffer = moveSource.buffer;
moveSource.buffer = NULL;
}
8.3 它解决的是“短命临时对象还被昂贵复制”的问题
这就是 C++11 引入移动语义的根本动机之一。
第九章只是开个头,但已经把一个很重要的性能方向点出来了。
九、构造函数和析构函数还能控制对象怎么存在
这一章有个很有意思的地方:构造函数和析构函数不仅能做初始化和清理,还能参与限制对象存在方式。
9.1 禁止复制的类
书里的传统写法是:
- 把复制构造函数声明为私有
- 把复制赋值运算符声明为私有
这样外部就不能复制对象。
这适用于“不应该被复制”的资源类。
9.2 单例类
单例模式的核心组合是:
- 私有构造函数
- 私有复制控制
- 静态实例访问函数
它的目标是:全局只允许存在一个实例。
9.3 禁止在栈上实例化
如果把析构函数设成私有,那么局部对象离开作用域时编译器无法正常调用析构函数,因此对象不能直接创建在栈上。
这种技巧的本质是:通过“销毁规则”反向限制“创建方式”。
9.4 构造函数也可能触发类型转换
单参数构造函数允许这种写法:
Human kid = 10;
因为编译器会把 10 当作构造参数,隐式创建一个 Human。
如果不想允许这种隐式转换,就使用:
explicit Human(int age);
这在工程里通常是更稳妥的写法。
十、其他必须掌握的几个关键点
10.1 this 指针
在非静态成员函数里,this 表示当前对象地址。
this->age = humansAge;
它常见于:
- 区分成员和同名参数
- 返回当前对象
- 强调“当前对象正在操作自己”
静态成员函数没有 this,因为它不绑定某个具体对象。
10.2 sizeof(class) 只统计数据成员占用
sizeof 用在类上时,关注的是对象数据布局,而不是成员函数代码大小。
它通常会受到:
- 数据成员本身大小
- 对齐
- padding
的影响。
所以它不等于“把每个成员简单相加”。
10.3 struct 和 class 的核心差异很小
在 C++ 里,struct 和 class 非常像。
常见差别只有两个默认值:
struct默认成员访问权限是publicclass默认成员访问权限是private
也就是说,语义上不是两个世界,更多是默认约定不同。
10.4 friend 是受控开口
friend 函数或 friend 类可以访问类的私有成员。
它不是“取消封装”,而是“由类作者明确授权的例外通道”。
该用时可以用,但应该克制。
10.5 union 适合“同一块内存按不同方式解释”
共用体一次只应有一个有效成员处于活动状态。
它的关键特点是:
- 多个成员共享同一块内存
sizeof(union)通常等于最大成员大小
这是一种节省空间、但也更容易误用的机制。
10.6 聚合初始化
当类或结构满足聚合类型条件时,可以整体初始化:
Aggregate1 a1{2017, 3.14};
但如果类里有私有成员、用户定义构造函数等,就可能不再是聚合类型。
10.7 constexpr 类和对象
如果构造函数和成员函数满足要求,可以让编译器在编译期完成计算:
constexpr Human somePerson(15);
这有助于提升性能,也让“对象”不再只是运行时概念。
十一、本章最容易混淆的点
- 类不是对象,类是定义,对象才是运行时实例
.和->的区别不在符号本身,而在你手里拿的是对象还是指针private不是“完全不能访问”,而是“不能从类外直接访问”- 有带参构造函数不代表还有默认构造函数
- 复制构造和复制赋值不是同一件事
- 原始指针成员一旦参与默认复制,极容易触发浅复制问题
- 析构函数负责资源释放,但不能代替正确的复制控制
explicit解决的是“你没想转换,编译器却帮你转换了”sizeof(class)不是成员简单相加,还受对齐影响friend是例外授权,不是常规接口设计
十二、这一章学完后应该真正掌握什么
如果这一章掌握到位,至少应该能清楚说出下面这些事:
- 为什么类能把数据和操作数据的方法组织在一起
- 什么时候应该把成员声明为
private - 构造函数和析构函数分别最适合做什么
- 为什么带原始指针的类需要警惕浅复制
- 复制构造和移动构造分别解决什么问题
explicit为什么是单参数构造函数的高频关键字struct、friend、union、聚合初始化各自解决什么场景
这一章表面上是在讲“类怎么写”,但更深一层其实是在讲:
你如何设计一个对象,让它从创建到销毁都保持语义清晰、状态合理、资源安全。
这才是第九章真正的核心。