写在前面
上一篇 《从零开始学 C++(二):引用进阶、inline 与 nullptr|从 C 指针思维继续过渡到 C++》 中,我们已经把引用进阶、内联函数讲透,并初步接触了 class 的基本定义与访问限定符。
从这一篇开始,我们真正进入 C++ 非常核心的一部分------类和对象。
如果之前已经用 C 写过顺序表、链表、栈、队列,就会发现一个很明显的问题:数据结构的数据和操作数据的函数通常是分开的。例如 C 语言中的栈:
cpp
typedef struct Stack {
int* data;
int top;
int capacity;
} Stack;
void StackInit(Stack* ps);
void StackPush(Stack* ps, int x);
void StackPop(Stack* ps);
int StackTop(Stack* ps);
结构体只负责保存数据,而函数全部放在外面,每次操作还要显式把 Stack* ps 传进去。
C++ 的类则向前走了一步:既然这些函数本来就是专门操作 Stack 数据的,为什么不把数据和操作数据的方法组织到一起?于是就有了:
cpp
class Stack {
// 数据
// 操作这些数据的函数
};
但"类"又绝不仅仅是"能在结构体里写函数"。它还带来了:
- public / private / protected 访问控制;
- 封装;
- 类作用域;
- 对象实例化;
- 隐含的 this 指针;
- 更清晰的接口与实现边界。
本篇就从我们熟悉的 C 语言思维出发,把这些概念逐个理清。
代码仓库
C++成长之路/chapter03_class_object · Luminous/Code_2026 - 码云 - 开源中国
一、从 C 的 struct 走向 C++ 的 class
1.1 C 语言的分离式设计
先来看一个非常简单的日期类型。在 C 语言里可以定义:
cpp
typedef struct Date {
int year;
int month;
int day;
} Date;
它只能描述 Date 里面有什么数据,如果需要初始化和打印,还得额外写函数:
cpp
void DateInit(Date* pd, int year, int month, int day);
void DatePrint(Date* pd);
数据和操作数据的函数是分离的,每次调用都要显式传入结构体指针。
1.2 C++ 类的组织方式
到了 C++,可以把它重新组织成:
cpp
#include <iostream>
using namespace std;
class Date {
public:
void Init(int year, int month, int day)
{
year_ = year;
month_ = month;
day_ = day;
}
void Print()
{
cout << year_ << "-"
<< month_ << "-"
<< day_ << endl;
}
private:
int year_;
int month_;
int day_;
};
调用方式:
cpp
int main() {
Date d;
d.Init(2026, 9, 27);
d.Print();
return 0;
}
这时候代码的组织方式已经发生了明显变化:
Date
├── 数据
│ ├── year_
│ ├── month_
│ └── day_
│
└── 操作数据的方法
├── Init()
└── Print()
数据和针对这些数据的操作,被放进了同一个类型中。
1.3 类的基本定义格式
C++ 定义类最基本的格式:
cpp
class 类名
{
public:
// 对外接口
private:
// 内部数据
};
例如:
cpp
class Student {
public:
void SetScore(int score)
{
score_ = score;
}
void PrintScore()
{
cout << score_ << endl;
}
private:
int score_;
};
这里有几个基础概念:
| 名称 | 含义 |
|---|---|
| Student | 类名 |
| score_ | 成员变量 / 数据成员 |
| SetScore() | 成员函数 |
| PrintScore() | 成员函数 |
| public | 公有访问区域 |
| private | 私有访问区域 |
还有一个很容易漏的地方:类定义结束后的分号不能省略,这一点和 struct 一样。
1.4 成员变量的命名习惯
代码中我写的是 int year_; int month_; int day_;,而不是 int year; int month; int day;。这是为了让成员变量更加容易识别。
例如:
cpp
void Init(int year, int month, int day)
{
year_ = year;
month_ = month;
day_ = day;
}
一眼就可以看出:
year→ 函数参数year_→ 当前对象的成员变量
这并不是 C++ 强制语法,而是一种命名习惯。常见写法还有 _year、m_year,不同团队可能采用不同规范。我个人更倾向 year_,因为语义清楚,也可以避免部分以下划线开头标识符的命名规则问题。
二、访问控制与封装
类和普通 C 结构体相比,一个非常重要的变化就是:类可以控制哪些成员允许外部访问。目前先认识三个关键字:public、private、protected,当前阶段重点理解前两个。
2.1 public:允许外部直接访问
例如:
cpp
class Date {
public:
void Print()
{
cout << year_ << endl;
}
private:
int year_;
};
外部可以:
cpp
Date d;
d.Print();
因为 Print() 是 public。
2.2 private:只允许类内部直接访问
如果写:
cpp
Date d;
d.year_ = 2026;
会编译失败。因为 year_ 是 private 成员。
但是类自己的成员函数 void Init(...) 可以访问它。也就是说:
- 外部代码 ↓ 不能随便访问 private
- 成员函数 ↓ 可以访问自己的 private 数据
2.3 封装的本质
刚接触面向对象时,经常会看到一句话:C++ 面向对象三大特性:封装、继承、多态。现在还没学习继承和多态,但是已经开始接触封装了。
封装并不只是"把函数和变量塞进一个 class"。更重要的是:把内部实现隐藏起来,只通过设计好的接口操作对象。
例如栈有:
cpp
private:
int data_[100];
int size_;
如果完全公开,外部代码就可以随便修改 st.size_ = 9999;,破坏栈的内部状态。但如果 size_ 是 private,外部只能通过 Push()、Pop()、Top()、Size() 操作。
这样:
- 数据怎么维护,由类自己负责;
- 使用者怎么操作,通过公开接口完成。
这就是封装真正有价值的地方。
三、class 与 struct 的联系与区别
3.1 C++ 中 struct 也是类
一个很有意思的变化是:C++ 中的 struct 也被增强成了类。例如:
cpp
struct Point {
int x;
int y;
void Move(int dx, int dy)
{
x += dx;
y += dy;
}
};
这是合法的 C++。调用方式:
cpp
Point p = { 10, 20 };
p.Move(5, 3);
也就是说,C++ 的 struct 不再只是 C 语言中单纯的数据集合。它同样可以拥有成员变量、成员函数、构造函数、访问控制、继承等其他类相关能力。
3.2 核心区别:默认访问权限
在当前阶段最重要的区别是:
- class 默认访问权限:private
- struct 默认访问权限:public
例如:
cpp
class A { int x; }; // x 默认是 private
struct B { int x; }; // x 默认是 public
3.3 使用习惯的区分
实际开发中通常:
- 单纯的数据结构、轻量聚合类型 → struct
- 需要明显封装行为的对象 → class
但这更多是一种代码设计习惯,而不是绝对限制。
3.4 结构体名直接作为类型名
在 C 中,如果没有 typedef:
cpp
struct ListNode {
int val;
struct ListNode* next;
};
使用时一般需要写 struct ListNode node;,所以我们以前经常用 typedef 包装。
但 C++ 中可以直接:
cpp
struct ListNode {
int val;
ListNode* next;
};
然后直接 ListNode node;,不需要再额外写 typedef。这也是 C++ 对 C 风格结构体使用体验的一处改进。
四、类的声明与实现分离
4.1 类内声明、类外实现
如果所有代码都写在 class Date { ... }; 内部,类稍微复杂一点就会显得非常拥挤。因此 C++ 允许:类内部声明接口,类外部完成实现。
例如:
cpp
class Date {
public:
void Init(int year, int month, int day);
void Print();
private:
int year_;
int month_;
int day_;
};
然后类外实现:
cpp
void Date::Init(int year, int month, int day)
{
year_ = year;
month_ = month;
day_ = day;
}
void Date::Print()
{
cout << year_
<< "-"
<< month_
<< "-"
<< day_
<< endl;
}
4.2 作用域解析运算符 ::
这里第一次看到一个非常重要的符号 ::,叫做作用域解析运算符。
为什么必须写 Date::?假设只写:
cpp
void Init(int year, int month, int day)
{
year_ = year;
}
编译器只会把它理解成一个普通的全局函数 Init,那么 year_ 到底是谁的成员?编译器不知道。
所以需要明确告诉编译器:void Date::Init(...),意思就是:这个 Init 属于 Date 类的作用域。此时 year_、month_、day_,编译器就会到 Date 类域中寻找。
所以 Date:: 本质是在说明:接下来这个名字属于 Date。
五、类与对象:蓝图与实例
5.1 类是类型,对象是实例
这是学习类和对象时非常重要的一组概念:类 ≠ 对象。
例如:
cpp
class Date {
private:
int year_;
int month_;
int day_;
};
此时只是定义了 Date 这种类型应该长什么样。可以把类理解成建筑设计图:设计图规定有几个房间、房间怎么排列、有哪些功能,但设计图本身不是房子。
真正执行 Date d1; Date d2; 才是在创建实际对象,这个过程叫类的实例化。
5.2 一个类可以实例化多个对象
例如:
cpp
Date d1; Date d2; Date d3;
三个对象各自拥有独立的数据:
cpp
d1 ── year_ / month_ / day_
d2 ── year_ / month_ / day_
d3 ── year_ / month_ / day_
例如:
d1.Init(2026, 1, 1);
d2.Init(2026, 6, 1);
修改 d1 不会影响 d2,因为每个对象都有自己的一份非静态成员变量。
5.3 对象的内存布局
这是一个非常值得思考的问题:假设 Date 类有 Init、Print 两个成员函数,实例化三个对象,是不是每个对象都保存一份 Init、一份 Print?
并不是。可以理解成:
bash
代码区域
├── Date::Init()
└── Date::Print()
对象 d1:只保存自己的数据
对象 d2:只保存自己的数据
对象 d3:只保存自己的数据
成员函数的机器代码没有必要随着每个对象重复存储。所以对象的大小主要与非静态数据成员以及内存对齐有关,而不是"成员函数写得越多 → 对象就越大"。
5.4 用 sizeof 验证对象大小
例如:
cpp
#include <iostream>
using namespace std;
class Data {
public:
void Print()
{
cout << "Data::Print()" << endl;
}
private:
char ch_;
int value_;
};
class OnlyFunction {
public:
void Func()
{
cout << "hello" << endl;
}
};
class Empty { };
int main() {
cout << sizeof(Data) << endl;
cout << sizeof(OnlyFunction) << endl;
cout << sizeof(Empty) << endl;
return 0;
}
在常见环境中:
- Data 的大小会受到 char、int 内存对齐影响;
- OnlyFunction、Empty 即使没有非静态数据成员,通常也不会是 0 字节,而至少需要一个非零大小,用于保证不同对象能够拥有不同地址。因此空类对象通常大小为 1 字节。
至于具体对象大小,还与编译器、平台、对齐规则以及后续会学习到的继承、虚函数等机制有关,所以不要简单地把所有情况都套成固定数字。
六、this 指针:成员函数的隐式参数
6.1 this 指向当前调用对象
现在出现一个非常关键的问题:有 Date d1; Date d2;,然后调用 d1.Print(); d2.Print();,但我们明明只写了一份 void Date::Print(),那这个函数怎么知道第一次该打印 d1、第二次该打印 d2?
答案就是:this 指针。
每个普通的非静态成员函数中,都可以使用 this,它指向当前正在调用这个成员函数的对象。
例如:
cpp
class Date {
public:
void Init(int year, int month, int day)
{
this->year_ = year;
this->month_ = month;
this->day_ = day;
}
private:
int year_;
int month_;
int day_;
};
调用 Date d1; d1.Init(2026, 9, 27);,可以从理解层面看成:
- 当前对象 = d1
- this = &d1
所以 this->year_ 就是 d1.year_。
6.2 为什么平时可以省略 this
刚才 this->year_ = year;,其实通常我们直接写 year_ = year;。因为编译器知道 year_ 是当前类的成员变量,所以可以理解成编译器帮我们补出了 this->。
也就是说:year_ = year; 从概念上相当于 this->year_ = year;。
6.3 this 的典型使用场景
当成员变量和参数名字一样时,this 就非常有用。例如:
cpp
class Date {
public:
void Init(int year, int month, int day)
{
this->year = year;
this->month = month;
this->day = day;
}
private:
int year;
int month;
int day;
};
这里 year 优先对应参数,而 this->year 明确表示当前对象中的 year 成员。
不过如果采用 year_ 这样的命名规则,就不太需要频繁显式写 this->。
6.4 关于 this 的注意事项
-
this 不是手动传进去的普通参数 理解层面可以把
d.Init(2026, 9, 27);想象成成员函数知道当前对象地址 + 真正写出的参数,但代码中不能自己写d.Init(&d, 2026, 9, 27);,也不能在成员函数参数列表中手动声明一个真正的Date* this。this 是 C++ 语言提供的特殊机制。 -
不能给 this 赋值
this = nullptr;是不允许的。 -
空指针调用成员函数是未定义行为 例如:
cppclass A { public: void Print() { cout << "A::Print()" << endl; } }; int main() { A* p = nullptr; p->Print(); return 0; }有些环境下这段程序可能"碰巧打印成功",于是很容易得出"Print 没访问成员变量,所以空指针调用没问题"的结论。这个结论是不可靠的。
通过空指针去调用非静态成员函数属于未定义行为,不能因为某一次程序恰好运行成功,就认为这是合法写法。如果函数内部访问了成员变量,就更容易直接访问非法地址而崩溃。所以实际代码中不要写这种写法。
七、实战:用 C++ 类重写栈
7.1 完整栈类实现
类和对象学到这里,最适合做的一件事,就是把之前 C 语言的数据结构重新用 C++ 组织一次。
cpp
#include <iostream>
#include <cassert>
using namespace std;
class Stack {
public:
void Init()
{
size_ = 0;
}
void Push(int x)
{
assert(size_ < 100);
data_[size_] = x;
++size_;
}
void Pop()
{
assert(size_ > 0);
--size_;
}
int Top()
{
assert(size_ > 0);
return data_[size_ - 1];
}
bool Empty()
{
return size_ == 0;
}
int Size()
{
return size_;
}
private:
int data_[100];
int size_;
};
使用方式:
cpp
int main() {
Stack st;
st.Init();
st.Push(10);
st.Push(20);
st.Push(30);
cout << "size = " << st.Size() << endl;
cout << "top = " << st.Top() << endl;
while (!st.Empty())
{
cout << st.Top() << " ";
st.Pop();
}
cout << endl;
return 0;
}
7.2 和 C 语言版本的对比
以前 C 语言:
cpp
Stack st;
StackInit(&st);
StackPush(&st, 10);
StackPush(&st, 20);
printf("%d\n", StackTop(&st));
现在 C++:
cpp
Stack st;
st.Init();
st.Push(10);
st.Push(20);
cout << st.Top() << endl;
可以明显看到:
- C:函数(Stack* ps, ...) ↓ 显式传入对象地址
- C++:对象.成员函数(...) ↓ 当前对象通过 this 被成员函数识别
但底层算法其实并没有发生神奇变化,栈顶入栈、栈顶出栈、数组存储、size 维护,这些逻辑还是以前学过的东西。改变的主要是代码的组织方式和访问规则。这也是从 C 数据结构过渡到 C++ 类特别自然的原因。
7.3 封装的工程价值
假设原来 C 语言:
cpp
Stack st;
st.top = -999;
只要结构体成员公开,调用者理论上就可以随便修改。
而 C++ 中 size_ 是 private,外部写 st.size_ = 100; 直接编译失败。调用者只能通过 Push、Pop、Top、Size 操作。
所以类把代码分成了两个层次:
- 外部关心:Push 怎么用?Pop 怎么用?Top 返回什么? → public
- 类内部关心:数组怎么存?size 怎么变化?边界怎么维护? → private
这就是一个非常重要的工程思想:接口和实现分离。以后底层实现即使从数组换成链表,只要公开接口设计不变,外部很多代码甚至完全不用修改。
八、补充细节与常见误区
8.1 类内定义的成员函数与 inline
还有一个和上一篇 inline 可以联系起来的小知识点:像这样直接定义在类内部的成员函数,具有隐式 inline 属性。
但这里仍然要注意上一篇讲过的问题:inline 不代表编译器一定把函数调用展开。是否真正内联优化,依然由编译器决定。
8.2 常见误区汇总
| 问题 | 正确认识 |
|---|---|
| 类和对象是不是一个东西? | 不是,类是类型描述,对象是实例 |
| 类定义本身就给成员变量分配对象空间了吗? | 没有,要实例化对象才分配 |
| 每个对象是否各自保存一套成员函数? | 普通成员函数代码不会为每个对象重复保存 |
| private 是不是连成员函数也访问不了? | 成员函数可以访问本类 private |
| struct 在 C++ 中只能存变量吗? | 不是,C++ struct 也可以拥有成员函数 |
| class 和 struct 完全一样吗? | 当前最明显区别是默认访问权限 |
| this 要不要自己传? | 不需要,编译器隐式提供 |
| 成员变量不写 this-> 能访问吗? | 可以,编译器自动补全 |
| 空指针调用不访问成员变量的函数安全吗? | 不安全,属于未定义行为 |
| 类里写很多函数会让对象变得特别大吗? | 普通成员函数不会增加每个对象大小 |
写在最后
第二篇还是在学习"C++ 比 C 多了哪些语法?",到了这一篇,开始真正看到 C++ 代码组织思想上的变化。
C 语言写栈时:struct Stack + StackInit() + StackPush() + StackPop() + StackTop() 到了 C++:class Stack ── private:数据 └── public:操作数据的接口
算法本身没有突然变得神秘,但是:数据属于谁、谁可以修改数据、函数应该放在哪里、外部允许看到什么、内部应该隐藏什么,开始有了更严格、更清晰的规则。这就是从 C 的"数据 + 一组操作函数",逐渐走向 C++"一个拥有状态和行为的对象"的重要一步。
目前我们的 Stack 还有一个很明显的问题:Stack st; st.Init(); 创建对象以后还必须手动调用 Init()。如果忘了呢?同样,假如内部以后使用动态内存,是不是还得记得手动释放?
C++ 显然不会让这个问题一直存在。下面会进入构造函数、析构函数以及对象的初始化与销毁以后,就可以把 Init()、Destroy() 进一步交给对象自己自动完成。这也是类和对象真正开始展现 C++ 特点的地方。