【C++类和对象(上)】从类的定义、封装与对象模型到内存对齐和 this 指针

🔥 本文定位 :本文从 C++ 类的基本语法出发,系统讲清成员变量、成员函数、访问限定符、类域、实例化、对象大小、内存对齐与 this 指针,并通过 C/C++ 两种 Stack 写法理解封装究竟解决了什么问题。

💡 学习目标 :不仅要会写 class,还要真正理解 类为什么只是类型描述、对象为什么各自保存一份状态、成员函数为什么不逐对象复制、sizeof 为什么包含填充字节、this 如何把一次成员调用与具体对象关联起来,以及空指针调用成员函数为什么不能用"这次没崩"来证明安全

📌 阅读说明:本文聚焦"类和对象"的上半部分,暂不展开构造函数、析构函数、拷贝控制、运算符重载等内容。文中的对象布局图用于建立主流实现下的心智模型;具体布局、寄存器传参与 ABI 细节不是 C++ 标准固定规则,应以目标编译器和平台为准。


文章目录


一、先建立全局视角:从数据集合走向自定义类型

1.1 为什么需要类

在 C 语言中,我们可以使用结构体把一组有关联的数据组织起来:

c 复制代码
struct Date {
    int year;
    int month;
    int day;
};

这解决了"数据如何放在一起"的问题,但围绕日期的操作通常仍以全局函数存在:

c 复制代码
void DateInit(struct Date* d, int year, int month, int day);
void DatePrint(const struct Date* d);

调用者既可以通过函数修改日期,也可以绕过函数直接写字段:

c 复制代码
struct Date d;
d.month = 99;  // 语法允许,但这个状态通常没有业务意义

C++ 类把相关数据和操作放入同一个类型,并允许类型自己规定哪些内容对外开放:

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day);
    void Print() const;

private:
    int _year;
    int _month;
    int _day;
};

此时,Date 不只描述"有三个整数",还规定了对象能够提供什么操作、哪些状态不能被外部随意破坏。这正是类最直接的价值:把数据、操作和访问规则组织成一个完整类型。

1.2 本篇知识地图

本篇可以沿着七条主线理解:

  1. 类定义:成员变量和成员函数如何声明;
  2. 访问控制publicprotectedprivate 如何划分边界;
  3. 类域:编译器如何知道某个名字属于哪个类;
  4. 实例化:类型描述如何变成实际对象;
  5. 对象布局:对象中保存哪些内容,为什么会有内存对齐;
  6. this 指针:同一份成员函数如何操作不同对象;
  7. 封装:如何让对象的合法操作成为稳定入口。

这些概念并不是彼此孤立的。访问限定符只有放在类定义中才有意义;成员函数通过 this 访问实例化对象的状态;对象大小又由类中非静态数据成员及其布局共同决定。

1.3 面向对象三大特性与本篇重点

面向对象通常会提到三大特性:

  • 封装:把数据与操作组织起来,并控制外部访问边界;
  • 继承:在已有类型基础上表达复用与"是一个"的关系;
  • 多态:通过统一接口表现不同对象的动态行为。

本篇重点是封装。继承和多态会在后续章节展开,所以此时 protected 看起来与 private 很像:它们都不允许普通类外代码直接访问。二者真正的差异,需要放到派生类访问规则中理解。

1.4 C++ 中的 struct 也是类

一个很常见的误解是:"struct 只能放数据,class 才能放函数。"这在 C++ 中并不成立。

cpp 复制代码
struct Point {
    int x;
    int y;

    void Move(int dx, int dy) {
        x += dx;
        y += dy;
    }
};

C++ 的 struct 可以拥有成员函数、访问限定符、构造函数、继承关系等类机制。就本篇范围而言,classstruct 最明显的语法差异是:

写法 未写访问限定符时的默认成员权限 未写继承方式时的默认继承权限
class private private
struct public public

工程上常见的约定是:

  • 纯数据聚合、字段就是公开接口时,倾向使用 struct
  • 需要维护不变量、隐藏实现或提供稳定行为接口时,倾向使用 class

这只是设计约定,不是语言强制要求。


二、类的定义:把状态与行为放进同一个类型

2.1 类定义的基本格式

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }

    void Print() const {
        std::cout << _year << '/' << _month << '/' << _day << '\n';
    }

private:
    int _year;
    int _month;
    int _day;
};

可以把这段定义拆成四部分:

text 复制代码
class Date     → class 是关键字,Date 是类名
{ ... }        → 类体,描述成员与访问规则
成员变量       → 对象需要保存的状态
成员函数       → 对象能够执行的操作

类定义末尾的分号不能省略:

cpp 复制代码
class Date {
    // ...
};  // 必须有分号

原因是类定义在语法上可以紧接声明器。虽然日常很少这样写,但下面的形式在语法上成立:

cpp 复制代码
class Date {
    // ...
} today, tomorrow;

末尾分号用于结束整个声明。

2.2 成员变量与成员函数

类体中的内容统称为类成员:

  • 保存对象状态的变量称为数据成员成员变量
  • 定义类型行为的函数称为成员函数方法
  • 后续还会遇到类型成员、静态成员、成员模板等形式。

成员变量常采用某种命名标识,目的是让形参、局部变量和成员变量更容易区分:

cpp 复制代码
int _year;    // 前置下划线风格
int year_;    // 后置下划线风格
int m_year;   // m_ 前缀风格

语言不强制使用哪一种。团队真正需要的是一致的命名规范。还要注意,某些以下划线开头的标识符在全局命名空间或特定组合下保留给实现使用,不应随意模仿标准库内部命名。

2.3 类内定义的成员函数与 inline

在普通类定义中,直接在类体内给出函数体的成员函数通常是隐式 inline 的:

cpp 复制代码
class Counter {
public:
    int Value() const { return _value; }

private:
    int _value = 0;
};

这里的 inline 主要涉及多翻译单元中的定义规则,并不等于"编译器一定把函数调用展开"。是否进行机器码层面的内联优化,由优化器根据函数体、调用位置、优化级别等因素决定。

因此应当分清两句话:

  • 类内定义可以让短小成员函数方便地放在头文件中;
  • inline 不是一条强制优化指令。

2.4 类定义中只是声明成员布局

定义 Date 时,编译器获得了一个新类型,知道它包含哪些成员、访问规则是什么、对象通常需要多大空间。但这一步并没有创建名为 d1d2 的日期对象。

cpp 复制代码
class Date {
private:
    int _year;
    int _month;
    int _day;
};

此时 _year_month_day 描述的是"每个 Date 对象应当具有的状态"。真正的对象空间要等到声明对象时才出现:

cpp 复制代码
Date d1;
Date d2;

类是类型,d1d2 才是该类型的对象。


三、访问限定符与封装:把边界写进类型

3.1 public、protected 与 private

C++ 提供三个访问限定符:

  • public:类外代码可以通过对象、引用或指针访问;
  • private:只有该类的成员以及被授权的友元等可以访问;
  • protected:普通类外代码不能访问,但它与派生类规则有关。

最小示例:

cpp 复制代码
class Date {
public:
    void SetYear(int year) {
        _year = year;
    }

    int GetYear() const {
        return _year;
    }

private:
    int _year = 1970;
};

int main() {
    Date d;
    d.SetYear(2024);           // 正确:SetYear 是 public
    std::cout << d.GetYear();  // 正确:GetYear 是 public

    // d._year = 2025;         // 编译错误:_year 是 private
}

private 不是说成员"神秘地消失"了,而是说类外普通代码不能绕过接口直接访问它。

3.2 访问限定符的作用范围

一个访问限定符从出现位置开始生效,直到下一个访问限定符或类定义结束:

cpp 复制代码
class Example {
public:
    void F1();  // public
    void F2();  // public

private:
    int _x;     // private
    int _y;     // private

public:
    int Sum() const;  // public
};

限定符可以重复出现。工程上常把公开接口放在前面,私有实现放在后面,让使用者先看到"这个类型能做什么"。

3.3 class 与 struct 的默认权限

cpp 复制代码
class A {
    int value;  // 默认 private
};

struct B {
    int value;  // 默认 public
};

即使知道默认规则,也建议在教学代码和重要工程代码中显式写出权限。显式边界比依赖读者记忆更清楚。

3.4 封装的核心不是"把变量藏起来"

如果只是把字段改成 private,再为每个字段提供一个毫无限制的 setter,可能并没有真正维护任何规则:

cpp 复制代码
class Date {
public:
    void SetMonth(int month) {
        _month = month;  // 仍然允许 99
    }

private:
    int _month;
};

更有价值的封装,是让公开操作维护对象的不变量:

cpp 复制代码
class Date {
public:
    bool SetMonth(int month) {
        if (month < 1 || month > 12) {
            return false;
        }

        _month = month;
        return true;
    }

    int Month() const {
        return _month;
    }

private:
    int _month = 1;
};

这里的核心规则是:

text 复制代码
1 ≤ _month ≤ 12

外部只能通过 SetMonth 改变月份,类就有机会拒绝非法状态。封装由此带来三层价值:

  1. 保护不变量:对象不会轻易进入无意义状态;
  2. 降低耦合:外部依赖公开接口,而不是依赖内部字段布局;
  3. 保留演化空间:类可以修改内部实现,只要公开语义保持稳定。

3.5 private 不是安全边界

访问控制主要是编译期的类型设计机制,不应把它误解为密码学或进程隔离意义上的安全机制。拥有程序内存访问能力的底层代码、调试器或未定义行为,都不受"业务接口私有化"这种抽象边界保护。

正确的理解是:private 帮助正常 C++ 代码遵守模块边界,降低误用概率,并让编译器参与检查。


四、类域:成员名字为什么能被正确找到

4.1 类定义引入新的作用域

类会引入一个类作用域。类体中的成员函数可以直接使用同一类中的成员名:

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
    }

private:
    int _year;
    int _month;
    int _day;
};

Init 的函数体位于 Date 的成员函数上下文中,所以 _year 等名字可以在 Date 类域中被找到。

4.2 类外定义必须使用作用域限定符

声明和定义可以分开:

cpp 复制代码
// Date.h
class Date {
public:
    void Init(int year, int month, int day);
    void Print() const;

private:
    int _year;
    int _month;
    int _day;
};

类外定义成员函数时,要使用 Date:: 表明函数属于哪个类:

cpp 复制代码
// Date.cpp
#include "Date.h"
#include <iostream>

void Date::Init(int year, int month, int day) {
    _year = year;
    _month = month;
    _day = day;
}

void Date::Print() const {
    std::cout << _year << '/' << _month << '/' << _day << '\n';
}

如果写成:

cpp 复制代码
void Init(int year, int month, int day) {
    _year = year;  // 找不到 _year
}

编译器会把它看作一个普通的全局函数定义,而不是 Date 的成员函数。全局作用域中没有 _year,自然无法通过编译。

4.3 :: 解决的是名字归属

Date::Init 可以读成"Date 作用域中的 Init"。同一个程序中完全可以存在多个同名成员:

cpp 复制代码
class Date {
public:
    void Print() const;
};

class Time {
public:
    void Print() const;
};

void Date::Print() const {
    // 打印日期
}

void Time::Print() const {
    // 打印时间
}

Date::PrintTime::Print 属于不同类域,不会因为名字相同就变成同一个函数。

4.4 头文件中的命名空间污染

类通常声明在头文件中。不要在头文件的全局作用域写:

cpp 复制代码
using namespace std;

因为每个包含该头文件的源文件都会被迫引入这条 using 指令,增加名字冲突风险。更稳妥的写法是显式限定:

cpp 复制代码
class Logger {
public:
    void Write(const std::string& text);
};

在很小的 .cpp 文件或教学示例中是否使用 using 可以按语境决定,但头文件应尽量保持干净。


五、从类到对象:实例化到底发生了什么

5.1 类是设计图,对象是实体

类描述一组对象共同具有的结构和操作,对象则是这份描述的一次具体实例。

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day);
    void Print() const;

private:
    int _year;
    int _month;
    int _day;
};

Date d1;
Date d2;

可以用"设计图与房屋"帮助理解:

  • 类像设计图,规定房间结构和功能;
  • 对象像按设计图建成的房屋;
  • 一张设计图可以建造多栋房屋;
  • 每栋房屋有自己的实际空间与状态。

比喻只是辅助。真正要记住的是:类是一种类型,实例化会创建该类型的对象。

5.2 每个对象拥有独立的非静态数据成员

cpp 复制代码
Date d1;
Date d2;

d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);

d1.Print();  // 2024/3/31
d2.Print();  // 2024/7/5

d1d2 都有自己的 _year_month_day。修改 d1 不会自动覆盖 d2,因为两者是不同对象。

对象之间共享的是类型规则和普通成员函数代码,而不是非静态成员变量的值。

5.3 声明指针不等于创建 Date 对象

cpp 复制代码
Date* p = nullptr;

这会创建一个指针对象 p,但不会创建 Date 对象。p 当前没有指向任何有效 Date 实例。

下面的写法才真正创建了 Date 对象:

cpp 复制代码
Date d;
Date array[3];

动态创建对象会涉及 new 与对象生命周期,后续章节再系统展开。此处先把"类型名、对象和指向对象的指针"分清:

代码 含义
class Date { ... }; 定义类型
Date d; 定义一个 Date 对象
Date array[3]; 定义三个连续的 Date 对象
Date* p = nullptr; 定义一个指针,没有创建 Date 对象
Date& r = d; 定义引用,让 r 表示已有对象 d

5.4 . 与 ->

已有对象或对象引用时,使用点运算符访问公开成员:

cpp 复制代码
Date d;
d.Init(2024, 3, 31);

拥有指向有效对象的指针时,使用箭头运算符:

cpp 复制代码
Date* p = &d;
p->Print();

在这个例子中:

cpp 复制代码
p->Print();

可以理解为:

cpp 复制代码
(*p).Print();

前提是 p 指向一个仍在生命周期内的有效对象。-> 并不会自动检查空指针或悬空指针。

5.5 实例化与存储位置不是同一个问题

"实例化"说明创建了对象,但对象可能具有不同存储期:

cpp 复制代码
Date local;          // 通常是自动存储期对象
static Date shared;  // 静态存储期对象

未来还会遇到动态存储期对象、临时对象、成员子对象等。不要把"对象"简单等同于"栈上的变量",也不要把"类"简单等同于"堆上的数据结构"。类描述类型,对象的存储期由具体定义方式决定。


六、对象大小与内存对齐

6.1 普通对象通常保存哪些内容

先看一个类:

cpp 复制代码
class A {
public:
    void Print() const {
        std::cout << _ch << ' ' << _i << '\n';
    }

private:
    char _ch;
    int _i;
};

每个 A 对象需要保存自己的 _ch_i,但没有必要在每个对象里重复保存一份 Print 的机器指令。普通非虚成员函数的代码由同类型对象共享,调用时再通过当前对象访问各自状态。

因此,一个基础心智模型是:

text 复制代码
对象内部:非静态数据成员 + 为布局产生的填充 + 实现可能需要的其他机制
对象外部:普通成员函数的机器指令

"实现可能需要的其他机制"非常重要。例如含虚函数的多态类型,主流实现通常会在对象中加入虚表指针,但 C++ 标准不强制规定字段名称和精确布局。本篇示例没有虚函数,可以先忽略这部分。

6.2 为什么成员函数不逐对象复制

假设 Date 创建一百个对象。如果每个对象都重复保存 InitPrint 的函数代码或函数地址,会产生不必要的空间开销。

编译和链接阶段通常已经能够确定非虚成员函数的调用目标。真正随对象变化的是成员数据,而不是函数体。不同对象调用同一个成员函数时,this 指针告诉函数"本次应该操作哪一个对象"。

这正好把对象大小与下一节的 this 指针连接起来。

6.3 内存对齐的可移植理解

对象的大小通常不等于成员类型大小的简单相加。每种类型都有对齐要求,可以使用 alignof 查询:

cpp 复制代码
#include <cstddef>
#include <iostream>

struct A {
    char ch;
    int value;
};

int main() {
    std::cout << sizeof(char) << '\n';
    std::cout << sizeof(int) << '\n';
    std::cout << alignof(A) << '\n';
    std::cout << sizeof(A) << '\n';
}

在许多常见平台上:

text 复制代码
sizeof(char) = 1
sizeof(int)  = 4
alignof(A)   = 4
sizeof(A)    = 8

常见布局是:

text 复制代码
偏移 0:char ch       1 字节
偏移 1~3:padding    3 字节
偏移 4~7:int value  4 字节
总大小:8 字节

但不要把"int 永远是 4 字节""默认对齐永远是 8""这个类在所有平台上都是 8 字节"写进可移植逻辑。具体结果受类型实现、目标 ABI、编译选项和显式对齐控制等因素影响。

6.4 为什么需要填充字节

对齐的主要目标是让对象及其成员位于实现要求或偏好的地址边界上。可以先掌握三条实用规律:

  1. 每个成员会被放在满足其对齐要求的位置;
  2. 成员之间可能插入填充字节;
  3. 对象尾部也可能填充,使数组中下一个对象仍满足对齐要求。

例如:

cpp 复制代码
A items[2];

如果 sizeof(A) 不是 alignof(A) 的合适倍数,那么 items[1] 的起始地址可能无法继续满足对齐。尾部填充让数组中的每个元素都按同一布局排列。

6.5 成员顺序会影响对象大小

比较下面两种布局:

cpp 复制代码
struct BadLayout {
    char a;
    double b;
    char c;
};

struct BetterLayout {
    double b;
    char a;
    char c;
};

在许多平台上,BetterLayout 会更紧凑,因为对齐要求较高的成员被集中放置,减少了内部填充。但这不是让所有代码都机械地按成员大小排序的理由。还要考虑:

  • 成员的语义顺序与可读性;
  • ABI 兼容要求;
  • 缓存访问模式;
  • 二进制序列化协议;
  • 并发中的伪共享;
  • 编译器与平台的真实测量结果。

对于普通业务类,先保证语义清楚;只有当内存确实成为瓶颈时,再基于数据和测量优化布局。

6.6 空类为什么不为 0 字节

cpp 复制代码
class Empty {};

std::cout << sizeof(Empty) << '\n';

常见结果是 1。C++ 必须让两个不同的完整对象能够拥有不同地址:

cpp 复制代码
Empty e1;
Empty e2;

std::cout << (&e1 == &e2) << '\n';

如果完整空对象完全不占空间,就很难维持对象身份和数组步长等基本规则。一个占位字节让独立对象能够获得不同地址。

不过在特定子对象布局中,编译器可能应用空基类优化;C++20 的 [[no_unique_address]] 也允许某些空成员不占额外空间。这些是更细的布局规则,不应反过来得出"所有空类对象都是 0 字节"的结论。

6.7 静态数据成员不属于每个对象

静态数据成员与类关联,而不是每个对象各持一份:

cpp 复制代码
class Counter {
public:
    static int total;

private:
    int _value;
};

sizeof(Counter) 主要考虑 _value 及对象布局,不会为每个对象重复加入一份 total。这与普通非静态成员变量不同。

6.8 不要用 sizeof 猜完整对象模型

sizeof 很有用,但它只能告诉你对象占用的字节数,不能直接告诉你:

  • 每个成员的精确偏移;
  • 是否存在尾部填充;
  • 虚表指针如何布局;
  • 基类子对象怎样排列;
  • 编译器是否复用了某些空子对象空间。

学习阶段可以用 sizeofalignof、调试器和编译器布局报告验证推断,但工程代码不应依赖未被接口或 ABI 明确保证的内部布局。


七、this 指针:成员函数如何识别当前对象

7.1 同一份函数如何操作不同对象

cpp 复制代码
Date d1;
Date d2;

d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);

d1d2 调用的是同一份 Date::Init 代码。函数体中只写了 _year_month_day,它如何知道本次应当修改谁?

答案是:非静态成员函数在调用时与一个当前对象关联,函数体可以通过 this 指针访问该对象。

可以用下面的"解释模型"理解:

cpp 复制代码
d1.Init(2024, 3, 31);

概念上类似:

cpp 复制代码
Date::Init(&d1, 2024, 3, 31);

成员函数中的:

cpp 复制代码
_year = year;

概念上类似:

cpp 复制代码
this->_year = year;

这是一种帮助理解调用机制的模型,不是说程序员可以真的把 this 写进普通成员函数的显式形参列表。

7.2 this 不能显式声明,也不能改指向

下面的写法不合法:

cpp 复制代码
class Date {
public:
    // 错误:不能显式把 this 声明成形参
    // void Init(Date* this, int year, int month, int day);
};

函数体中可以使用 this,但不能让它改为指向另一个对象:

cpp 复制代码
void Date::Init(int year, int month, int day) {
    // this = nullptr;  // 错误

    this->_year = year;
    this->_month = month;
    this->_day = day;
}

常见教材会把它描述为"Date* const this"。这有助于表达"指针自身不能改指向",但仍应记住它只是理解模型,语言规则中的 this 有专门定义,并不是一个由程序员声明的普通参数变量。

7.3 this 不是对象的数据成员

如果 this 真是每个对象内部保存的一根指针,那么普通类对象大小通常还要额外增加一个指针宽度。但实际对象大小不会因为存在普通成员函数就自动增加一根 this 指针。

this 与一次非静态成员函数调用关联,它不是普通对象的数据成员。具体调用约定可能把它放在寄存器、栈上传参位置或采用其他 ABI 方案,这些都属于实现细节。

所以面对"this 指针存在哪个内存区域"这种选择题,更准确的回答是:

标准不要求它固定存放在栈、堆、静态区或对象内部;它是成员调用语义中的当前对象指针,具体传递方式由实现和 ABI 决定。

在某些特定编译器、架构和优化级别下观察到寄存器或栈,只能说明那一次构建的实现方式。

7.4 成员名冲突时显式使用 this

当形参与成员同名时,显式写 this-> 很清楚:

cpp 复制代码
class Date {
public:
    void SetYear(int year) {
        this->year = year;
    }

private:
    int year;
};

如果项目采用 _year 之类的成员命名风格,也可以直接写:

cpp 复制代码
_year = year;

两种写法都可以。关键是避免读者分不清左侧和右侧分别代表成员还是形参。

7.5 const 成员函数中的 this

cpp 复制代码
void Date::Print() const {
    std::cout << _year << '/' << _month << '/' << _day << '\n';
}

成员函数末尾的 const 表示该函数不能通过普通成员访问修改当前对象的逻辑状态。理解上,可以把其中的 this 看作指向 const Date 的指针。

因此,Print 可以通过 const Date 引用或对象调用:

cpp 复制代码
Date source;
source.Init(2024, 3, 31);

const Date& d = source;
d.Print();  // 正确:Print 是 const 成员函数

而一个会修改成员的普通成员函数不能直接用于 const 对象。const 成员函数是接口契约的一部分:它告诉调用者"这个操作不会改变可观察状态"。

7.6 静态成员函数没有 this

静态成员函数属于类级别调用,不绑定某个具体对象,因此没有 this 指针:

cpp 复制代码
class Math {
public:
    static int Add(int left, int right) {
        return left + right;
    }
};

int result = Math::Add(1, 2);

静态成员函数不能在没有对象的情况下直接访问普通非静态成员,因为 _year 之类的成员必须依赖某个具体对象。

7.7 空指针调用成员函数为什么是未定义行为

课件和面试题经常给出两段代码:

cpp 复制代码
class A {
public:
    void Print() {
        std::cout << "A::Print()\n";
    }
};

int main() {
    A* p = nullptr;
    p->Print();
}

以及:

cpp 复制代码
class A {
public:
    void Print() {
        std::cout << _a << '\n';
    }

private:
    int _a;
};

有时第一段在某个未优化构建中"恰好输出了文字",第二段则在读取 _a 时崩溃。由此得出"只要不访问成员,空指针调用就安全"是错误的。

对空指针使用 -> 调用非静态成员函数,无法为该调用提供有效对象,标准层面属于未定义行为。未定义行为可能表现为:

  • 看似正常运行;
  • 立即崩溃;
  • 在优化构建中出现不同结果;
  • 修改函数体后行为变化;
  • 产生任何其他不可依赖的现象。

"没有崩溃"只是某次运行的表面现象,不是正确性证明。正确写法是在调用前确保指针指向有效对象:

cpp 复制代码
if (p != nullptr) {
    p->Print();
}

更进一步,接口设计应尽量减少不必要的可空裸指针,让类型本身表达"必有对象"还是"对象可缺失"。


八、C 与 C++ 实现 Stack:封装带来了什么

8.1 两种写法的核心差异

在 C 版本中,数据通常放在结构体中,操作写成一组接收结构体指针的函数:

c 复制代码
typedef struct Stack {
    int* data;
    int top;
    int capacity;
} Stack;

void StackPush(Stack* stack, int value);
void StackPop(Stack* stack);
int StackTop(const Stack* stack);

调用形态是:

c 复制代码
StackPush(&stack, 42);

C++ 版本把操作放入类中:

cpp 复制代码
class Stack {
public:
    void Push(int value);
    void Pop();
    int Top() const;

private:
    int* _data;
    int _top;
    int _capacity;
};

调用形态变成:

cpp 复制代码
stack.Push(42);

表面上只是少传了一个 &stack,实质上是当前对象通过 this 隐式参与了调用。

8.2 教学版 C++ Stack

下面保留课件"手动 Init / Destroy"的阶段性写法,用于观察封装与 this,暂不引入构造函数和析构函数:

cpp 复制代码
#include <cassert>
#include <cstdlib>
#include <iostream>

class Stack {
public:
    bool Init(std::size_t capacity = 4) {
        _data = static_cast<int*>(
            std::malloc(sizeof(int) * capacity)
        );

        if (_data == nullptr) {
            _size = 0;
            _capacity = 0;
            return false;
        }

        _size = 0;
        _capacity = capacity;
        return true;
    }

    bool Push(int value) {
        if (_size == _capacity) {
            std::size_t newCapacity =
                _capacity == 0 ? 4 : _capacity * 2;

            void* raw = std::realloc(
                _data,
                sizeof(int) * newCapacity
            );

            if (raw == nullptr) {
                return false;
            }

            _data = static_cast<int*>(raw);
            _capacity = newCapacity;
        }

        _data[_size] = value;
        ++_size;
        return true;
    }

    void Pop() {
        assert(!Empty());
        --_size;
    }

    int Top() const {
        assert(!Empty());
        return _data[_size - 1];
    }

    bool Empty() const {
        return _size == 0;
    }

    std::size_t Size() const {
        return _size;
    }

    void Destroy() {
        std::free(_data);
        _data = nullptr;
        _size = 0;
        _capacity = 0;
    }

private:
    int* _data = nullptr;
    std::size_t _size = 0;
    std::size_t _capacity = 0;
};

int main() {
    Stack stack;

    if (!stack.Init()) {
        std::cerr << "stack init failed\n";
        return 1;
    }

    stack.Push(1);
    stack.Push(2);
    stack.Push(3);
    stack.Push(4);

    while (!stack.Empty()) {
        std::cout << stack.Top() << '\n';
        stack.Pop();
    }

    stack.Destroy();
}

这段代码能展示四个本篇重点:

  1. _data_size_capacity 被放在 private 区域;
  2. 外部通过 PushPopTop 等公开接口操作;
  3. 成员函数不必显式接收 Stack*this 隐式关联当前对象;
  4. 每个 Stack 对象都有自己的指针、大小和容量。

8.3 封装维护 Stack 的不变量

一个有效栈至少应维护:

text 复制代码
0 ≤ _size ≤ _capacity

以及:

text 复制代码
_capacity > 0 时,_data 指向足够存放 _capacity 个 int 的空间

如果字段公开,调用者可以轻易破坏这些规则:

cpp 复制代码
// 假设字段公开
stack._size = 1000000;
stack._capacity = 4;

下一次 TopPush 就可能越界。字段私有后,所有状态变化必须经过成员函数,类可以在单一位置检查和维护规则。

8.4 C 与 C++ 的系统对比

维度 C 风格实现 C++ 类实现
数据组织 struct 保存字段 类保存非静态数据成员
操作组织 一组全局函数 成员函数形成类型接口
对象传递 显式传 Stack* 通过 this 隐式关联
字段访问 常见写法中字段公开 可用 private 限制直接访问
名字组织 依赖前缀避免冲突 类域自然区分同名成员
不变量维护 依赖调用者遵守函数约定 类型可以强制合法入口
资源生命周期 常见为手动 Init/Destroy 后续可用构造/析构与 RAII

底层动态数组和扩容逻辑可能非常接近,但 C++ 类提供了更强的组织与约束能力。

8.5 手动 Init/Destroy 仍然有明显缺陷

上面的教学版并不是理想的现代 C++ 设计,因为调用者仍然可能:

  • 忘记调用 Init
  • 重复调用 Init 导致资源泄漏;
  • 忘记调用 Destroy
  • 在异常或提前返回路径漏掉 Destroy
  • 复制对象后让两个对象错误地共享同一裸指针。

这些问题将由后续的构造函数、析构函数、拷贝控制和 RAII 继续解决。真实工程中,如果目标只是保存一组可增长的 int,通常直接使用 std::vector<int>,而不是自己管理 malloc/realloc/free

本节最重要的不是"以后就照抄这份 Stack",而是借它看见演进路线:

text 复制代码
数据 + 全局函数
        ↓
类 + 访问控制 + this
        ↓
构造/析构 + 拷贝控制
        ↓
RAII + 标准容器

九、把类写得更稳:初学阶段就该建立的习惯

9.1 先设计不变量,再设计接口

不要先机械地给每个字段生成 getter 和 setter。先问三个问题:

  1. 对象的合法状态是什么?
  2. 哪些操作可以改变状态?
  3. 每次操作后必须继续满足哪些规则?

以 Stack 为例,不变量是 _size <= _capacity;以 Date 为例,月份、日期需要位于合法范围。接口设计的目标,是让非法状态难以产生。

9.2 public 应表达行为,不只是暴露字段

下面的接口暴露了实现细节:

cpp 复制代码
class Stack {
public:
    int* Data();
    std::size_t& Size();
    std::size_t& Capacity();
};

调用者可以任意改写大小和容量,类几乎失去了维护不变量的能力。更好的公开接口直接表达业务操作:

cpp 复制代码
Push(value)
Pop()
Top()
Empty()
Size()

接口描述"能做什么",私有成员描述"内部怎样做到"。

9.3 struct 和 class 按语义选择

适合 struct 的典型场景:

cpp 复制代码
struct Point {
    double x;
    double y;
};

Point 是一个简单值聚合,直接读写 xy 很自然。

适合 class 的典型场景:

cpp 复制代码
class BankAccount {
public:
    bool Deposit(long long cents);
    bool Withdraw(long long cents);
    long long Balance() const;

private:
    long long _balance = 0;
};

账户需要维护余额规则,公开行为比公开字段更合理。选择依据应是类型语义,而不是"struct 低级、class 高级"。

9.4 短函数类内定义,复杂函数类外定义

简单访问器放在类内通常很清楚:

cpp 复制代码
int Year() const { return _year; }

复杂业务逻辑全部塞进类体,会让头文件臃肿、依赖扩散、增量编译变慢。可以把声明放在 .h,定义放在 .cpp

cpp 复制代码
// Date.h
class Date {
public:
    bool Set(int year, int month, int day);
};
cpp 复制代码
// Date.cpp
bool Date::Set(int year, int month, int day) {
    // 完整校验逻辑
}

模板、内联定义和可见性规则还有更多细节,但"公开声明清晰、复杂实现适度分离"是很好的起点。

9.5 成员要有可预测的初始状态

本篇暂未系统讲构造函数,但不要让成员处于不可预测状态。C++11 之后可以使用类内成员初始化器提供默认值:

cpp 复制代码
class Date {
private:
    int _year = 1970;
    int _month = 1;
    int _day = 1;
};

这比声明后依赖调用者"记得先 Init"更稳。后续学习构造函数时,会进一步把"对象一出生就满足不变量"落实到类型生命周期中。

9.6 不要把实现观察写成语言保证

学习对象模型时,经常会观察到:

  • 某个类在当前平台上是 8 字节;
  • this 被放在某个寄存器;
  • 空指针调用某个函数暂时没崩;
  • 类中的函数地址看起来固定。

这些实验很有价值,但结论必须分层:

层次 可以得出的结论
C++ 语言规则 可移植语义与允许/禁止的行为
ABI 与平台 参数传递、对象布局等平台约定
编译器版本与选项 某次构建的具体实现
单次运行现象 只能说明那次运行发生了什么

把这四层分开,是学习 C++ 底层问题时非常重要的能力。


十、常见误区、高频面试题与练习

10.1 常见误区

误区一:C++ 的 struct 不能定义函数

错误。C++ 中 struct 也可以定义成员函数。它与 class 的核心语法差异主要是默认成员访问权限和默认继承权限。

误区二:类定义出来就已经有对象空间

错误。类定义产生类型描述;声明 Date d; 等对象时才为对象提供存储。

误区三:每个对象都保存一份成员函数

错误。普通成员函数代码由同类型对象共享;对象主要保存自己的非静态数据成员和布局所需内容。

误区四:对象大小就是成员 sizeof 之和

错误。成员之间和对象尾部都可能出现对齐填充,还可能存在虚函数机制、基类子对象等额外布局因素。

误区五:空类对象大小为 0

错误。完整空对象仍需要具有对象身份和可区分地址,常见 sizeof(Empty) == 1

误区六:inline 表示编译器一定展开调用

错误。inline 首先影响定义规则;是否进行机器码层面的内联优化由编译器决定。

误区七:private 成员在类内也不能访问

错误。类自己的成员函数可以访问本类 private 成员。限制主要针对未经授权的类外访问。

误区八:protected 与 private 永远一样

错误。对普通类外代码而言它们都不可直接访问,但派生类访问规则不同。

误区九:this 存在每个对象内部

错误。this 与一次非静态成员调用关联,不是普通数据成员。

误区十:this 一定保存在栈上

错误。具体传递方式属于 ABI 和编译器实现细节,可能使用寄存器,也可能采用其他方式。

误区十一:不访问成员时,空指针调用成员函数是安全的

错误。对空指针使用 -> 调用非静态成员函数仍然是未定义行为;一次"看似正常"不能形成保证。

误区十二:封装就是把所有字段改成 private

不完整。真正的封装还要设计稳定接口、维护不变量、隐藏易变实现,并让合法操作成为主要入口。

10.2 高频面试题

问题 1:class 与 struct 有什么区别?

在 C++ 中二者都可以定义类。最直接的区别是:class 默认成员权限和默认继承方式为 privatestruct 默认为 public。工程上还常用 struct 表示简单数据聚合,用 class 表示需要维护不变量的类型。

问题 2:类和对象有什么区别?

类是类型描述,规定成员、接口和访问规则;对象是该类型的具体实例,拥有实际存储和独立状态。

问题 3:什么是实例化?

使用类类型创建具体对象的过程称为实例化。例如 Date d; 创建一个 Date 对象。

问题 4:对象中是否保存成员函数?

普通成员函数的机器代码通常不逐对象保存。对象保存自己的非静态数据成员;调用成员函数时通过当前对象信息访问对应状态。

问题 5:为什么类定义末尾必须有分号?

类定义属于声明语法的一部分,末尾分号用于结束声明;类定义后还可能紧跟对象声明器。

问题 6:private 与 protected 有什么区别?

普通类外代码都不能直接访问它们。protected 允许在相应继承规则下被派生类成员访问,而 private 不提供这种派生类直接访问权限。

问题 7:为什么对象大小可能大于成员大小之和?

因为成员需要满足对齐要求,编译器可能在成员之间或对象尾部加入填充字节;复杂类还可能包含基类子对象、虚函数实现相关数据等。

问题 8:空类为什么通常占 1 字节?

为了让不同完整对象能够拥有不同地址,并让对象数组具有有效步长。具体优化场景下的空子对象可能不额外占空间。

问题 9:this 指针的作用是什么?

它让非静态成员函数与本次调用的当前对象关联,成员函数据此访问该对象的非静态成员。

问题 10:this 指针存在哪里?

C++ 标准不规定固定内存区域。它可能按目标 ABI 通过寄存器或栈等方式传递,不能把某个平台观察当作可移植规则。

问题 11:静态成员函数有 this 吗?

没有。静态成员函数不绑定具体对象,因此不能在没有对象的情况下直接访问普通非静态成员。

问题 12:空指针调用一个不访问成员的函数,为什么有时能运行?

因为未定义行为不保证一定崩溃。某次生成的机器码可能暂时没有解引用该地址,但优化、平台、函数体或编译器变化都可能改变结果,程序不能依赖它。

10.3 三道自测题

练习一:判断对象数量
cpp 复制代码
class Date {};

Date d1;
Date* p = &d1;
Date& r = d1;
Date array[3];

问题:这里创建了多少个 Date 对象?

答案:d1 一个,array 三个,共四个。p 是指针对象,r 是已有对象的引用,都没有创建新的 Date 对象。

练习二:计算常见布局
cpp 复制代码
struct Layout {
    char a;
    int b;
    short c;
};

先在纸上推导可能的偏移和填充,再用下面的程序验证当前平台:

cpp 复制代码
#include <cstddef>
#include <iostream>

int main() {
    std::cout << alignof(Layout) << '\n';
    std::cout << sizeof(Layout) << '\n';
    std::cout << offsetof(Layout, a) << '\n';
    std::cout << offsetof(Layout, b) << '\n';
    std::cout << offsetof(Layout, c) << '\n';
}

注意:offsetof 只应按其适用条件用于标准布局类型。

练习三:找出设计问题
cpp 复制代码
class Stack {
public:
    int* data;
    int size;
    int capacity;
};

尝试回答:

  1. 调用者能构造出哪些非法状态?
  2. 哪些字段应当私有?
  3. 公开接口至少需要哪些操作?
  4. 如何保证 0 <= size <= capacity
  5. 如果申请内存失败,接口如何表达失败?

这道题的重点不是立刻写出完美容器,而是学会从"不变量"反推接口。

10.4 排查清单

写完一个初阶类后,可以快速检查:

  • 类定义末尾是否有分号;
  • 成员权限是否明确;
  • 公开接口是否比直接暴露字段更能维护规则;
  • 类外成员定义是否带 ClassName::
  • 只读成员函数是否应当加 const
  • 成员是否具有可预测的初始值;
  • 是否把 struct / class 的默认权限记反;
  • 是否误以为指针声明创建了目标对象;
  • 是否把当前平台的 sizeof 结果当成跨平台保证;
  • 是否把 this 当作对象中的普通字段;
  • 是否存在空指针或悬空指针成员调用;
  • 是否依赖了未定义行为的"偶尔正常"。

10.5 参考资料


总结

把本篇压缩成四条主线:

第一条:类型主线

text 复制代码
类定义 → 成员声明 → 类域 → 实例化 → 具体对象

类是类型描述,对象才拥有实际存储和独立状态。

第二条:封装主线

text 复制代码
public 接口 + private 状态 + 不变量维护

封装不只是隐藏字段,而是让合法操作成为稳定入口,让实现可以演化。

第三条:对象模型主线

text 复制代码
对象保存非静态数据成员
成员函数代码由同类型对象共享
对齐与填充共同影响 sizeof

对象布局受语言规则、ABI 和编译器实现共同影响,不应把单个平台结果绝对化。

第四条:调用主线

text 复制代码
object.Member(args...)
        ↓
当前对象通过 this 与非静态成员函数关联
        ↓
this->member 访问该对象状态

this 不是对象中的普通字段,空指针也不能提供合法当前对象。记住最后一句:

类把规则写进类型,对象承载具体状态,this 把共享行为连接到当前对象,而封装让状态只能沿着合法路径变化。

相关推荐
Json____19 分钟前
宿舍安全卫生检查系统:Node 全栈开发实战
java·前端·数据库·毕业设计·课程设计·毕设·wwwoop.com
吃着火锅x唱着歌20 分钟前
Effective C++ 学习笔记 条款45 运用成员函数模板接受所有兼容类型
c++·笔记·学习
阿里嘎多学长21 分钟前
2026-08-30 GitHub 热点项目精选
开发语言·程序员·github·代码托管
晴天1624 分钟前
HTML canvas标签使用指南-Day32
前端·html
默辨24 分钟前
我用Java后端的视角读了一遍JoyAgent-JDGenie
java·开发语言
常山云栈26 分钟前
如何理解python中的鸭子模(类)型?
开发语言·python
weixin1997010801627 分钟前
《alibaba.idle.isv.order.ship 接入实录:闲鱼订单发货回传的5个隐式约束》(附Python源码)
服务器·前端·python
y = xⁿ30 分钟前
DeepSeek harness工具系统什么设计的?
开发语言·python