文章目录
-
- [一、 C++ 基础收尾:`inline` 内联函数与 `nullptr`](#一、 C++ 基础收尾:
inline内联函数与nullptr) -
- [1.1 为什么用 `inline` 代替 C 语言宏函数?](#1.1 为什么用
inline代替 C 语言宏函数?) - [1.2 `inline` 的两大使用禁忌](#1.2
inline的两大使用禁忌) - [1.3 `nullptr`:彻底终结 `NULL` 的类型歧义](#1.3
nullptr:彻底终结NULL的类型歧义)
- [1.1 为什么用 `inline` 代替 C 语言宏函数?](#1.1 为什么用
- [二、 初识类与对象:从结构体到面向对象封装](#二、 初识类与对象:从结构体到面向对象封装)
-
- [2.1 类的定义格式与命名规范](#2.1 类的定义格式与命名规范)
- [2.2 访问限定符与 `class` / `struct` 的区别](#2.2 访问限定符与
class/struct的区别) - [2.3 类域(`::`)与类的实例化](#2.3 类域(
::)与类的实例化)
- [三、 深入底层:对象的内存布局与 `this` 指针](#三、 深入底层:对象的内存布局与
this指针) -
- [3.1 对象的大小怎么算?](#3.1 对象的大小怎么算?)
- [3.2 隐秘的幕后推手:`this` 指针详解](#3.2 隐秘的幕后推手:
this指针详解) - [3.3 经典面试题:空指针调用成员函数会崩溃吗?](#3.3 经典面试题:空指针调用成员函数会崩溃吗?)
- [四、 类的核心命脉:三大默认成员函数深度剖析](#四、 类的核心命脉:三大默认成员函数深度剖析)
-
- [4.1 构造函数(Constructor):对象的"出生证明"](#4.1 构造函数(Constructor):对象的“出生证明”)
- [4.2 析构函数(Destructor):资源的"清道夫"](#4.2 析构函数(Destructor):资源的“清道夫”)
- [4.3 拷贝构造函数(Copy Constructor):深浅拷贝与无穷递归之谜](#4.3 拷贝构造函数(Copy Constructor):深浅拷贝与无穷递归之谜)
-
- 核心特性与底层机制:
- [代码实战 1:`Date` 类的拷贝构造(浅拷贝即可满足)](#代码实战 1:
Date类的拷贝构造(浅拷贝即可满足)) - [代码实战 2:`Stack` 类的完整实现(含构造、析构与手写深拷贝构造)](#代码实战 2:
Stack类的完整实现(含构造、析构与手写深拷贝构造))
- [4.4 三大默认成员函数总结对比表](#4.4 三大默认成员函数总结对比表)
- [一、 C++ 基础收尾:`inline` 内联函数与 `nullptr`](#一、 C++ 基础收尾:
在上一篇 C++ 入门笔记中,我们探讨了命名空间、缺省参数、函数重载与引用。作为本系列的第二篇,我们将完成 C++ 基础语法的最后一块拼图(inline 与 nullptr),并正式迈入面向对象编程(OOP)的核心殿堂------类与对象 ,深入剖析对象的内存布局、this 指针的底层机制以及三大默认成员函数(构造、析构、拷贝构造)。
一、 C++ 基础收尾:inline 内联函数与 nullptr
1.1 为什么用 inline 代替 C 语言宏函数?
在 C 语言中,为了避免小函数频繁调用产生栈帧开销,我们常使用宏函数(Macro),但宏函数存在三大致命缺陷:
- 编写极易出错:需要频繁加括号处理运算符优先级问题。
- 无法调试:宏在预处理阶段直接进行文本替换,调试器无法单步追踪。
- 缺乏类型安全检查:不管传入什么类型都盲目替换,存在严重的安全隐患。
为了解决上述痛点,C++ 引入了 inline 关键字修饰内联函数,在编译阶段直接在调用处展开函数体,既具备宏函数"无栈帧开销"的效率,又保留了普通函数的类型检查与可调试性。
| 对比维度 | C 语言宏函数 (#define) |
C++ 内联函数 (inline) |
|---|---|---|
| 处理阶段 | 预处理阶段(直接文本替换) | 编译阶段(在调用处展开指令) |
| 类型安全检查 | ❌ 无类型检查,存在安全隐患 | ✅ 具备完整的编译器类型检查 |
| 调试支持 | ❌ 无法打断点调试 | ✅ Debug 模式下默认不展开,支持调试 |
| 编写复杂度 | ❌ 括号极易漏写,容易出错 | ✅ 与普通函数编写方式完全一致 |
1.2 inline 的两大使用禁忌
- 代码不能太长 :
inline对编译器而言只是一个建议,如果函数体内代码过长(通常超过 10 行)或包含递归、复杂循环,编译器会自动忽略内联请求,将其当作普通函数处理,以防止生成的可执行程序出现"代码膨胀"。 - 不能将声明和定义分离 :
inline函数绝不能在.h头文件中声明、在.cpp源文件中定义。因为内联函数需要在调用点直接展开,编译器不会为其生成独立的外部符号地址;若分离定义,在编译阶段符号表中找不到该函数地址,最终会导致链接错误(Linker Error) 。正确的做法是直接在.h头文件中完成内联函数的定义。
cpp
// 正确做法:直接在头文件或当前文件中定义内联函数
inline int Add(int x, int y) {
return x + y;
}
1.3 nullptr:彻底终结 NULL 的类型歧义
在 C++ 中使用空指针时,强烈推荐使用 nullptr 而非传统的 NULL:
- 历史遗留问题 :在 C++ 标准头文件中,
NULL实际上被宏定义为字面量0(即#define NULL 0)。 - 重载匹配歧义 :当遇到整型与指针类型的函数重载时,传入
NULL会优先匹配到int类型的重载版本,而不是我们预期的指针版本,而nullptr作为真正意义上的空指针字面量(类型为nullptr_t),完美解决了这一问题。
cpp
#include <iostream>
using namespace std;
void Func(int x) {
cout << "调用 Func(int)" << endl;
}
void Func(int* p) {
cout << "调用 Func(int*)" << endl;
}
int main() {
Func(0); // 输出:调用 Func(int)
Func(NULL); // 输出:调用 Func(int) -> NULL 被当成了字面量 0!
Func(nullptr); // 输出:调用 Func(int*) -> 精准匹配指针重载!
return 0;
}
二、 初识类与对象:从结构体到面向对象封装
面向对象编程具有三大核心特性:封装、继承、多态 。在类与对象的起步阶段,我们首先接触的就是封装(Encapsulation)。
2.1 类的定义格式与命名规范
C++ 的封装体现在两方面:一是将数据(属性)和方法(函数)都放到类里面;二是通过访问限定符限制类外对成员的随意访问。
- 关键字与语法 :
class为定义类的关键字,紧随其后的是类的名字,{}中为类的主体,注意类定义结束时后面的分号;绝对不能省略。 - 类的成员构成 :类体中的变量称为类的属性或成员变量 ;类中的函数称为类的方法或成员函数。
- 成员变量命名惯例 :为了区分成员变量与函数形参,工程上习惯为成员变量加上特殊标识,例如在变量名前面或后面加
_,或者以m_开头(如_year、m_year)。这在 C++ 语法中并非强制要求,但属于良好的行业编码规范。 - 默认内联特性 :直接定义在类里面(类体内部)的成员函数,编译器默认将其视为
inline内联函数处理。
2.2 访问限定符与 class / struct 的区别
C++ 提供了三种访问限定符来控制成员的暴露程度:public(公有)、protected(保护)和 private(私有)。
- 作用域范围 :访问权限作用域从该访问限定符出现的位置开始,直到下一个访问限定符出现时为止;如果后面没有其他访问限定符,作用域就一直延续到
}(即类结束)。 struct升级为类 :C++ 完全兼容 C 语言中struct的结构用法,同时将struct升级成了类------最明显的变化是struct内部也可以定义成员函数。- 默认访问权限差异 :
class定义成员没有被访问限定符修饰时,默认访问权限是private;而struct默认访问权限是public(为了兼容 C 语言)。在实际工程开发中,我们通常推荐使用class来定义类。
cpp
#include <iostream>
#include <cstdlib>
using namespace std;
class Stack {
private:
// 成员变量(属性):通常设为私有,防止外部直接篡改 _top 导致越界
int* _a;
int _top;
int _capacity;
public:
// 成员函数(方法):设为公有,暴露安全的交互接口
void Init(int n = 4) {
_a = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
void Push(int x) {
// 内部可安全访问私有成员
_a[_top++] = x;
}
};
int main() {
Stack st;
st.Init();
st.Push(10);
// st._top = 100; // 编译报错!private 成员在类外不可直接访问
return 0;
}
2.3 类域(::)与类的实例化
- 类域(Class Scope) :类定义了一个全新的作用域,类的所有成员都在类的作用域中。当我们在类体外定义成员函数时,必须使用
::作用域操作符指明该成员属于哪个类域,否则编译器会将其当成全局函数,从而无法访问类内的成员变量。 - 类的实例化(Instantiation) :用类类型在物理内存中创建对象的过程,称为类实例化出对象。类本身只是一个模型或建筑图纸 ,它仅限定了类有哪些成员变量,并没有分配实际的内存空间;只有实例化出具体的对象时,才会在栈或堆上开辟物理内存来存储成员变量。
cpp
// 声明与定义分离时,需通过 Stack:: 指明类域
void Stack::Init(int n) {
_a = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
三、 深入底层:对象的内存布局与 this 指针
3.1 对象的大小怎么算?
当我们用 sizeof 计算一个类对象的大小时,遵循以下底层存储与内存对齐原则:
- 成员函数不占对象空间 :对象中只存储成员变量,不存储成员函数 。因为同一个类实例化出的所有对象,它们的成员变量值各自独立,但调用的成员函数代码是完全相同的;将成员函数统一存放在公共代码区(代码段),可以极大节省内存开销。
- 结构体内存对齐规则 :
- 第一个成员在与结构体偏移量为
0的地址处。 - 其他成员变量要对齐到某个数字(对齐数 )的整数倍的地址处(对齐数 = 编译器默认的一个对齐数与该成员自身大小的较小值 ,VS 环境下默认对齐数为
8)。 - 结构体总大小为最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 第一个成员在与结构体偏移量为
- 空类的大小(面试高频) :没有成员变量的类(空类)或只有成员函数的类,其对象大小是
1字节 ,而不是0。这 1 个字节不存储任何有效数据,纯粹是为了占位,用来标识该对象在物理内存中真实存在过,确保不同实例对象拥有独一无二的内存地址。
cpp
class A1 {
public:
void f1() {}
private:
char _ch; // 偏移量 0,占 1 字节
int _i; // 对齐数 4,偏移量 4~7,占 4 字节
}; // 总大小:8 字节
class A2 {
public:
void f2() {}
}; // 只有成员函数,大小:1 字节(占位标识)
class A3 {}; // 空类,大小:1 字节(占位标识)
3.2 隐秘的幕后推手:this 指针详解
既然成员函数存放在公共代码区,那么当 d1.Print() 和 d2.Print() 调用同一个函数时,函数内部是如何区分应该打印 d1 还是 d2 的数据呢?答案就是 C++ 编译器暗中植入的 this 指针。
- 编译器自动注入形参 :编译器编译后,类的非静态成员函数默认都会在形参列表的第一个位置 ,增加一个当前类类型的常指针,叫做
this指针。例如Date类的Init函数,其底层真实原型为:void Init(Date* const this, int year, int month, int day)。 - 成员变量访问本质 :类的成员函数中访问任何成员变量,本质上都是通过
this指针解引用访问的,例如_year = year;在编译后会被转化为this->_year = year;。 - 语法书写规范 :C++ 明确规定,不能在实参和形参的位置显式地写出
this指针 (这部分由编译器在编译时自动处理),但是可以在成员函数体内显式使用this指针。
3.3 经典面试题:空指针调用成员函数会崩溃吗?
观察下面这段源自笔记的经典测试代码,运行结果会是编译报错、运行崩溃,还是正常运行?
cpp
#include <iostream>
using namespace std;
class A {
public:
void Print() {
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 这里会发生什么?
return 0;
}
💡 硬核解析:答案是【正常运行】!
很多初学者看到
p->Print()且p为nullptr,第一反应是"空指针解引用导致崩溃"。但实际上:
- 成员函数
Print()的地址并不在对象p指向的内存里,而是存放在公共代码区,编译时就已经确定了函数地址。p->Print()的本质只是把空指针p(即nullptr)作为实参传给了形参this指针。- 在
Print()函数内部,仅仅打印了一句字符串,自始至终没有访问成员变量_a(即没有执行this->_a解引用操作),因此不会触发空指针异常,程序完全正常运行!反之,如果函数内写了cout << _a << endl;,则会因解引用空指针this而立刻崩溃。
四、 类的核心命脉:三大默认成员函数深度剖析
当一个类什么成员都不写时,它并不是真正空白的------C++ 编译器会自动为其生成 6 个默认成员函数 。其中最核心、最常用的前三个就是:构造函数、析构函数、拷贝构造函数。
4.1 构造函数(Constructor):对象的"出生证明"
构造函数虽然名字叫"构造",但它的核心任务不是开空间创建对象 (局部对象的空间在栈帧创建时就已分配),而是在对象实例化时自动完成对象的初始化工作 ,完美替代了 C 语言中容易忘记调用的 Init 函数。
核心特性:
- 函数名与类名完全相同。
- 无返回值 (注意:连
void都不需要写,写了反而报错)。 - 自动调用:对象实例化时,编译器会自动调用对应的构造函数。
- 支持函数重载:构造函数可以有多个重载版本,以支持多种初始化方式。
- 默认生成规则 :如果类中没有显式定义构造函数,C++ 编译器会自动生成一个无参的默认构造函数;一旦用户显式定义了任何构造函数,编译器将不再自动生成。
- 三大"默认构造函数"与歧义避坑 :无参构造函数、全缺省构造函数、以及我们不写时编译器默认生成的构造函数,都叫做默认构造函数 (即不需要传实参就可以调用的构造函数 )。这三者在一个类中有且只能存在一个 ------如果同时写出无参构造和全缺省构造,虽然语法上构成函数重载,但在无参实例化对象(如
Date d1;)时会引发调用歧义错误。 - 编译器默认生成构造的行为 :如果我们不写,编译器默认生成的构造函数对内置类型 (如
int、char、指针等)成员变量的初始化没有强制要求(取决于编译器实现,通常是随机值);而对于自定义类型成员变量,则会去调用该成员自身的默认构造函数进行初始化。
cpp
#include <iostream>
using namespace std;
class Date {
public:
// 推荐使用【全缺省构造函数】,既能无参调用,也能传参调用
Date(int year = 2026, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1; // 对象实例化时自动调用默认构造(使用缺省值)
Date d2(2026, 4, 3); // 对象的自定义传参构造调用
d1.Print();
d2.Print();
return 0;
}
4.2 析构函数(Destructor):资源的"清道夫"
与构造函数功能相反,析构函数不是完成对对象本身的销毁 (局部对象的销毁工作是由栈帧销毁自动完成的),而是在对象生命周期结束时,自动完成对象中动态申请资源的清理工作 ,彻底替代了 C 语言栈实现中的 Destroy 函数,防止内存泄漏。
核心特性:
- 命名规则 :析构函数名是在类名前加上字符
~(按位取反符号)。 - 无参数、无返回值类型。
- 唯一性与不可重载 :一个类只能有一个析构函数,析构函数绝对不能重载;若未显式定义,系统会自动生成默认的析构函数。
- 自动调用时机:对象生命周期结束(例如出作用域栈帧销毁)时,C++ 编译系统会自动调用析构函数。
- 内置类型与自定义类型的区分处理 :跟构造函数类似,我们不写时编译器自动生成的析构函数对内置类型 成员不做任何处理,而对自定义类型成员会自动调用它的析构函数。
- 何时必须手写析构 :如果类中没有在堆上申请外部资源(如
Date类),析构函数可以不写,直接使用编译器生成的默认析构即可;但只要类中有资源申请(如malloc/new打开的内存或文件句柄,如Stack类),就一定要自己显式写析构函数释放资源,否则必然造成内存泄漏!
4.3 拷贝构造函数(Copy Constructor):深浅拷贝与无穷递归之谜
在实际开发中,我们经常需要用一个已存在的对象去初始化另一个新创建的对象(如 Date d2(d1);)。如果一个构造函数的第一个参数是自身类类型的引用 ,且任何额外的参数都有默认值,则此构造函数就叫做拷贝构造函数。
核心特性与底层机制:
- 构造函数的重载:拷贝构造函数本质上是构造函数的一个特殊重载版本。
- 参数必须是引用(重点必考) :拷贝构造函数的第一个参数必须是类类型对象的引用(常写为
const ClassName&) 。如果使用传值方式(Date(Date d)) ,编译器会直接报错,因为在语法逻辑上会引发无穷递归调用 !- 无穷递归原理解析 :C++ 规定自定义类型对象进行值拷贝行为时,必须调用拷贝构造函数 。如果拷贝构造参数采用传值传参
Date(Date d),那么调用拷贝构造传参时,实参传给形参d又是一次值拷贝,就必须先调用拷贝构造;为了调用这次拷贝构造又要传参,从而陷入死循环式的无穷递归!
- 无穷递归原理解析 :C++ 规定自定义类型对象进行值拷贝行为时,必须调用拷贝构造函数 。如果拷贝构造参数采用传值传参
text
[调用 Date d2(d1)]
│
▼ (若采用传值传参 Date(Date d),传参本身需要拷贝构造)
[调用 Date(d1) 构造形参 d]
│
▼ (传参又需要拷贝构造...)
[调用 Date(d1) 构造形参 d] ──► 陷入无穷递归!使用引用 Date(const Date& d) 则直接取别名,斩断递归!
- 深拷贝与浅拷贝的抉择 :
- 若未显式定义拷贝构造,编译器会自动生成默认的拷贝构造函数。
- 自动生成的拷贝构造对内置类型成员变量会完成值拷贝 / 浅拷贝 (即按字节一个一个原样拷贝,类似
memcpy),对自定义类型成员变量则会调用该成员自身的拷贝构造。 - 何时用默认浅拷贝 :像
Date这样的类,成员变量全是内置类型且没有指向任何堆区资源,编译器自动生成的浅拷贝完全能满足需求,无需我们显式实现。 - 何时必须手写深拷贝 :像
Stack这样的类,虽然_a也是内置指针类型,但_a指向了堆区申请的内存资源。如果使用默认的浅拷贝,会导致拷贝出的新对象st2._a与原对象st1._a指向同一块堆内存 !这不仅会导致两个栈压栈数据时互相覆盖,更致命的是在出作用域析构时,同一块堆空间会被free释放两次,直接引发程序崩溃(Double Free) 。因此,持有资源的类必须由我们自己实现深拷贝(为新对象独立开辟一块同样大小的资源空间,并拷贝数据内容)。
代码实战 1:Date 类的拷贝构造(浅拷贝即可满足)
cpp
class Date {
public:
Date(int year = 2026, int month = 4, int day = 3) {
_year = year;
_month = month;
_day = day;
}
// 拷贝构造函数:参数必须使用引用,建议加上 const 防止误改原对象
Date(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2026, 4, 3);
// 以下两种写法完全等价,均调用拷贝构造函数
Date d2(d1);
Date d3 = d1;
return 0;
}
代码实战 2:Stack 类的完整实现(含构造、析构与手写深拷贝构造)
cpp
#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;
class Stack {
public:
// 1. 全缺省构造函数:实例化时自动分配堆内存
Stack(int n = 4) {
_a = (int*)malloc(sizeof(int) * n);
if (nullptr == _a) {
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// 2. 深拷贝构造函数:独立开辟堆空间并拷贝数据,彻底解决 Double Free 问题!
Stack(const Stack& st) {
// 为新对象独立申请一块与原对象容量相同的新堆区空间
_a = (int*)malloc(sizeof(int) * st._capacity);
if (nullptr == _a) {
perror("malloc申请空间失败");
return;
}
// 将原对象堆空间中的有效数据拷贝到新空间中
memcpy(_a, st._a, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
void Push(int x) {
if (_top == _capacity) {
int newCapacity = _capacity * 2;
int* tmp = (int*)realloc(_a, sizeof(int) * newCapacity);
if (nullptr == tmp) {
perror("realloc扩容失败");
return;
}
_a = tmp;
_capacity = newCapacity;
}
_a[_top++] = x;
}
// 3. 析构函数:生命周期结束时自动释放各自的堆内存,杜绝内存泄漏
~Stack() {
if (_a) {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
}
private:
int* _a;
int _top;
int _capacity;
};
int main() {
Stack st1;
st1.Push(1);
st1.Push(2);
st1.Push(3);
// 调用深拷贝构造函数:st2 拥有独立的 _a 数组空间,析构时互不干扰!
Stack st2(st1);
Stack st3 = st1;
return 0;
}
4.4 三大默认成员函数总结对比表
| 默认成员函数 | 内置类型成员(编译器默认行为) | 自定义类型成员(编译器默认行为) | Date 类是否需要手写? |
Stack 类是否需要手写? |
|---|---|---|---|---|
| 构造函数 | 不做确定处理(通常为随机值) | 调用该成员的默认构造函数 | ✅ 需要(初始化年月日) | ✅ 需要(申请堆内存) |
| 析构函数 | 不做任何处理 | 调用该成员的析构函数 | ❌ 不需要(无动态资源) | ✅ 必须写(防止内存泄漏) |
| 拷贝构造 | 按字节完成值拷贝(浅拷贝) | 调用该成员的拷贝构造函数 | ❌ 不需要(浅拷贝足够) | ✅ 必须写(需实现深拷贝) |
掌握了类的封装、this 指针以及构造、析构、拷贝构造这三大核心成员函数后,我们已经搭起了 C++ 面向对象编程的坚固骨架。在下一篇系列文章中,我们将继续攻克运算符重载 、赋值运算符重载 以及完整的 Date 日期类实战项目,敬请期待!