【C++ 进阶之路】(二)从内联函数到类与对象:深挖内存对齐、this 指针与三大默认成员函数

文章目录

    • [一、 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 的类型歧义)
    • [二、 初识类与对象:从结构体到面向对象封装](#二、 初识类与对象:从结构体到面向对象封装)
      • [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++ 入门笔记中,我们探讨了命名空间、缺省参数、函数重载与引用。作为本系列的第二篇,我们将完成 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 计算一个类对象的大小时,遵循以下底层存储与内存对齐原则:

  1. 成员函数不占对象空间 :对象中只存储成员变量,不存储成员函数 。因为同一个类实例化出的所有对象,它们的成员变量值各自独立,但调用的成员函数代码是完全相同的;将成员函数统一存放在公共代码区(代码段),可以极大节省内存开销。
  2. 结构体内存对齐规则 :
    • 第一个成员在与结构体偏移量为 0 的地址处。
    • 其他成员变量要对齐到某个数字(对齐数 )的整数倍的地址处(对齐数 = 编译器默认的一个对齐数与该成员自身大小的较小值 ,VS 环境下默认对齐数为 8)。
    • 结构体总大小为最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  3. 空类的大小(面试高频) :没有成员变量的类(空类)或只有成员函数的类,其对象大小是 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,第一反应是"空指针解引用导致崩溃"。但实际上:

  1. 成员函数 Print() 的地址并不在对象 p 指向的内存里,而是存放在公共代码区,编译时就已经确定了函数地址。
  2. p->Print() 的本质只是把空指针 p(即 nullptr)作为实参传给了形参 this 指针。
  3. 在 Print() 函数内部,仅仅打印了一句字符串,自始至终没有访问成员变量 _a (即没有执行 this->_a 解引用操作),因此不会触发空指针异常,程序完全正常运行!反之,如果函数内写了 cout << _a << endl;,则会因解引用空指针 this 而立刻崩溃。

四、 类的核心命脉:三大默认成员函数深度剖析

当一个类什么成员都不写时,它并不是真正空白的------C++ 编译器会自动为其生成 6 个默认成员函数 。其中最核心、最常用的前三个就是:构造函数、析构函数、拷贝构造函数。


4.1 构造函数(Constructor):对象的"出生证明"

构造函数虽然名字叫"构造",但它的核心任务不是开空间创建对象 (局部对象的空间在栈帧创建时就已分配),而是在对象实例化时自动完成对象的初始化工作 ,完美替代了 C 语言中容易忘记调用的 Init 函数。

核心特性:
  1. 函数名与类名完全相同。
  2. 无返回值 (注意:连 void 都不需要写,写了反而报错)。
  3. 自动调用:对象实例化时,编译器会自动调用对应的构造函数。
  4. 支持函数重载:构造函数可以有多个重载版本,以支持多种初始化方式。
  5. 默认生成规则 :如果类中没有显式定义构造函数,C++ 编译器会自动生成一个无参的默认构造函数;一旦用户显式定义了任何构造函数,编译器将不再自动生成。
  6. 三大"默认构造函数"与歧义避坑 :无参构造函数、全缺省构造函数、以及我们不写时编译器默认生成的构造函数,都叫做默认构造函数 (即不需要传实参就可以调用的构造函数 )。这三者在一个类中有且只能存在一个 ------如果同时写出无参构造和全缺省构造,虽然语法上构成函数重载,但在无参实例化对象(如 Date d1;)时会引发调用歧义错误。
  7. 编译器默认生成构造的行为 :如果我们不写,编译器默认生成的构造函数对内置类型 (如 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 函数,防止内存泄漏。

核心特性:
  1. 命名规则 :析构函数名是在类名前加上字符 ~(按位取反符号)。
  2. 无参数、无返回值类型。
  3. 唯一性与不可重载 :一个类只能有一个析构函数,析构函数绝对不能重载;若未显式定义,系统会自动生成默认的析构函数。
  4. 自动调用时机:对象生命周期结束(例如出作用域栈帧销毁)时,C++ 编译系统会自动调用析构函数。
  5. 内置类型与自定义类型的区分处理 :跟构造函数类似,我们不写时编译器自动生成的析构函数对内置类型 成员不做任何处理,而对自定义类型成员会自动调用它的析构函数。
  6. 何时必须手写析构 :如果类中没有在堆上申请外部资源(如 Date 类),析构函数可以不写,直接使用编译器生成的默认析构即可;但只要类中有资源申请(如 malloc / new 打开的内存或文件句柄,如 Stack 类),就一定要自己显式写析构函数释放资源,否则必然造成内存泄漏!

4.3 拷贝构造函数(Copy Constructor):深浅拷贝与无穷递归之谜

在实际开发中,我们经常需要用一个已存在的对象去初始化另一个新创建的对象(如 Date d2(d1);)。如果一个构造函数的第一个参数是自身类类型的引用 ,且任何额外的参数都有默认值,则此构造函数就叫做拷贝构造函数。

核心特性与底层机制:
  1. 构造函数的重载:拷贝构造函数本质上是构造函数的一个特殊重载版本。
  2. 参数必须是引用(重点必考) :拷贝构造函数的第一个参数必须是类类型对象的引用(常写为 const ClassName&) 。如果使用传值方式(Date(Date d)) ,编译器会直接报错,因为在语法逻辑上会引发无穷递归调用 !
    • 无穷递归原理解析 :C++ 规定自定义类型对象进行值拷贝行为时,必须调用拷贝构造函数 。如果拷贝构造参数采用传值传参 Date(Date d),那么调用拷贝构造传参时,实参传给形参 d 又是一次值拷贝,就必须先调用拷贝构造;为了调用这次拷贝构造又要传参,从而陷入死循环式的无穷递归!
text 复制代码
[调用 Date d2(d1)] 
       │
       ▼ (若采用传值传参 Date(Date d),传参本身需要拷贝构造)
[调用 Date(d1) 构造形参 d]
       │
       ▼ (传参又需要拷贝构造...)
[调用 Date(d1) 构造形参 d] ──► 陷入无穷递归!使用引用 Date(const Date& d) 则直接取别名,斩断递归!
  1. 深拷贝与浅拷贝的抉择 :
    • 若未显式定义拷贝构造,编译器会自动生成默认的拷贝构造函数。
    • 自动生成的拷贝构造对内置类型成员变量会完成值拷贝 / 浅拷贝 (即按字节一个一个原样拷贝,类似 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 日期类实战项目,敬请期待!

相关推荐
敲代码的苦132 小时前
头歌 数据结构—消防资源调度
数据结构·算法
独隅2 小时前
Claude 高效应用实战指南
android·java·开发语言·claude·claude code
叼烟扛炮2 小时前
Linux 学习笔记(三十二):线程控制——创建、等待与分离
linux·运维·服务器·笔记·学习·1024程序员节
布莱克6052 小时前
C++ 中 set 与 map 的区别详解
开发语言·c++·算法·哈希
WJ.Polar2 小时前
异常、泛型、集合、Stream流
java·数据结构
我叫洋洋2 小时前
Allegro 画布“垃圾显示“清理指南:把板子调成干净工作台(实战总结)
c语言·c++·单片机·嵌入式硬件·eda·嘉立创
Nuanyt2 小时前
LeetCode Hot 100 · 双指针 3 题 Java
java·笔记·算法·leetcode·双指针
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子互动
java·开发语言·交友
深圳老胡2 小时前
FDC2212 + STM32G030 液面探测:IIC 读数跳动导致下探深度不一致的排查与解决
笔记·stm32·单片机·代码规范·1024程序员节