C++ 类与对象(上):从 struct 到 class 的蜕变
封装不是束缚,是秩序。
为什么需要类
在 C 语言中,数据和操作数据的函数是分开的。以栈为例:
// C 版本:数据和操作分离
struct Stack {
int* a;
int top;
int capacity;
};
void StackInit(Stack* ps); // 必须传指针
void StackPush(Stack* ps, int x);
int StackTop(Stack* ps);
这带来三个问题:
- 数据暴露——任何人都能直接修改
ps->top = 0,绕过StackInit; - 函数与数据割裂——调用者需要记住"这个函数对应这个结构体";
- 命名污染——不得不给每个函数加
Stack前缀,否则全局命名空间被Init、Push这类通用名称挤占。
C++ 的类(Class)将数据和操作捆绑在一起,通过访问控制(Access Control)决定哪些对外可见、哪些内部隐藏。本质上,类是一种封装(Encapsulation)机制——面向对象三大特性中排第一的那个。
💡 背景补充:C 中
struct只能聚合数据;C++ 将struct升级为类,可以在里面定义函数。但正式场景推荐用class关键字。
类的定义
定义格式
#include <iostream>
class Stack {
public:
// 成员函数(Member Function / Method)
void Init(int n = 4) {
_array = (int*)malloc(sizeof(int) * n);
if (nullptr == _array) {
perror("malloc 申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
void Push(int x) {
_array[_top++] = x; // 此处省略扩容逻辑,后面完整版会补上
}
int Top() {
assert(_top > 0);
return _array[_top - 1];
}
void Destroy() {
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
// 成员变量(Member Variable / Attribute)
int* _array;
size_t _capacity;
size_t _top;
}; // 分号不能省略
几点值得注意:
class后跟类名,花括号内是类体(Class Body),类定义结束后的分号不可省略——这是 C 结构体时代就有的遗产语法。- 成员变量命名常用
_前缀或m前缀(如m_year),这并非 C++ 强制要求,而是行业惯例。所在团队有规范就优先遵循。 - 定义在类内部的成员函数默认是 inline 的——编译器会将函数体直接在调用处展开(当然这只是建议,编译器有权忽略)。
- C++ 中
struct也能定义类了。与class的唯一区别:struct的成员默认是public,class默认是private。推荐用class来定义类,语义更明确。
// struct 升级为类:不再需要 typedef,类型名直接可用
struct ListNodeCpp {
void Init(int x) {
next = nullptr;
val = x;
}
ListNodeCpp* next;
int val;
};
int main() {
ListNodeCpp node; // 不需要 typedef,直接用类名
node.Init(42);
return 0;
}
💡 背景补充:C 中必须
typedef struct ListNodeC { ... } ListNodeC;才能省略struct关键字。C++ 直接把类型名当作可用标识符,清爽不少。
访问限定符
C++ 通过三个访问限定符(Access Specifier)实现封装:
| 限定符 | 类外访问 | 派生类访问 | 默认(class) | 默认(struct) |
|---|---|---|---|---|
| public | ✅ 可以 | ✅ 可以 | — | struct 默认 |
| private | ❌ 不能 | ❌ 不能 | class 默认 | — |
| protected | ❌ 不能 | ✅ 可以 | — | — |
📖 参考:《Effective C++》条款22:将数据成员声明为 private。protected 并不比 public 更有封装性——一旦修改,所有派生类都要重新编译。
访问限定符的作用范围:从该限定符出现的位置开始,直到下一个限定符出现或类结束(})为止。一个类可以有多个 public: / private: 段,但成员变量通常全部放入 private——这是封装的底线。
class Date {
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year; // 外部不能直接访问 d._year——编译器报错
int _month;
int _day;
};
类域
类定义了一个新的作用域(Class Scope)。当成员函数在类外定义时,必须用 ::(作用域解析运算符,Scope Resolution Operator)指明归属:
class Stack {
public:
void Init(int n = 4); // 类内只声明
private:
int* _array;
size_t _capacity;
size_t _top;
};
// 类外定义:Stack:: 告诉编译器"Init 是 Stack 的成员"
void Stack::Init(int n) {
_array = (int*)malloc(sizeof(int) * n);
if (nullptr == _array) {
perror("malloc 申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
编译器查找规则是这样的:如果 Init 不指定 Stack::,编译器把它当全局函数处理,自然找不到 _array、_capacity 这些成员——报错。加上 Stack:: 之后,编译器就知道:在自己域内找不到的标识符,去 Stack 类域里继续找。
补充一个细节:不同类可以有同名成员函数,互不干扰。这是类域带来的天然命名隔离。
实例化:图纸与房子
实例化概念
类是对一类对象的抽象描述,它规定了"这类东西有哪些变量",但并不为这些变量分配内存。只有实例化(Instantiation)——用类类型创建出具体对象——才会在物理内存中分配空间。
一个比喻:类是建筑设计图,对象是按图建造的房子。图纸可以复制多次、建造多栋房子;每栋房子有独立的墙体(独立的成员变量),但共用同一套图纸上的施工规范(成员函数)。
class Date {
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
std::cout << _year << "/" << _month << "/" << _day << std::endl;
}
private:
int _year; // 只是声明,还没有分配空间
int _month;
int _day;
};
int main() {
Date d1; // 实例化:为 d1 的 _year/_month/_day 分配空间
Date d2; // 实例化:为 d2 的 _year/_month/_day 分配空间
d1.Init(2024, 3, 31); // d1 的成员变量被赋值为 2024/3/31
d1.Print(); // 输出: 2024/3/31
d2.Init(2024, 7, 5); // d2 的成员变量被赋值为 2024/7/5
d2.Print(); // 输出: 2024/7/5
return 0;
}
d1 和 d2 各有自己的 _year / _month / _day,互不影响。但 Init 和 Print 函数只有一份——如果每个对象都存一份函数副本,100 个对象就浪费 100 次内存。
📖 参考:《C++ Primer Plus》第10章 — 对象只存储成员变量,成员函数被编译成代码段中的指令,所有对象共享。
对象大小的秘密
那么,一个不包含任何成员变量的类,实例化的对象有多大?
#include <iostream>
class A {
public:
void Print() { std::cout << _ch << std::endl; }
private:
char _ch;
int _i;
}; // 按内存对齐规则,sizeof(A) 通常是 8
class B {
public:
void Print() { /* ... */ }
}; // 没有成员变量,sizeof(B) = ?
class C {}; // 空类,sizeof(C) = ?
int main() {
A a;
B b;
C c;
std::cout << sizeof(a) << std::endl; // 8(受内存对齐影响)
std::cout << sizeof(b) << std::endl; // 1
std::cout << sizeof(c) << std::endl; // 1
return 0;
}
B 和 C 没有任何成员变量,但 sizeof 返回 1 而非 0。原因很直接:如果一个字节都不给,编译器拿什么来区分内存中两个不同的 B 对象?这 1 字节纯粹是占位,用来标识这个对象确实存在。
关于有成员变量的类——对象大小遵循与 C 结构体相同的内存对齐(Memory Alignment)规则:
| 规则 | 说明 |
|---|---|
| 首个成员 | 在偏移量为 0 的地址处 |
| 后续成员 | 对齐到"对齐数"的整数倍地址 |
| 对齐数 | min(编译器默认值, 成员类型大小) |
| VS 默认对齐数 | 8 |
| 结构体总大小 | 最大对齐数的整数倍 |
| 嵌套结构体 | 对齐到自身最大对齐数的整数倍 |
this 指针:隐式的主角
看看这段代码:
d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);
Init 函数体内只有一行行 _year = year,没有任何地方区分 “谁调用的我”。那编译器怎么知道 d1.Init 应该操作 d1 的成员,而 d2.Init 操作 d2 的成员?
答案是一个隐式参数——this 指针。
编译器在编译成员函数时,会在参数列表的第一个位置悄悄插入一个当前类类型的 const 指针:
// 你写的:
void Date::Init(int year, int month, int day);
// 编译器实际生成的:
void Date::Init(Date* const this, int year, int month, int day);
this 是 const 指针——你不能修改它指向哪个对象(this = nullptr; 会编译报错)。但通过 this 可以访问对象成员:
void Date::Init(int year, int month, int day) {
// this = nullptr; // 编译报错:左操作数必须为左值
_year = year; // 等效于 this->_year = year;
this->_month = month; // 显式使用 this,等效写法
this->_day = day;
}
this 不能在形参列表中显式写出(那是编译器的工作),但函数体内可以显式使用 this。
调用端也发生了隐式转换:
// 你写的:
d1.Init(2024, 3, 31);
// 编译器实际生成的:
d1.Init(&d1, 2024, 3, 31);
📖 参考:《C++ Primer Plus》第10章 — this 指针是指向调用对象的指针,
*this即调用对象本身。
两个经典的 this 指针测试题:
第一题:
class A {
public:
void Print() {
std::cout << "A::Print()" << std::endl;
}
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 输出: A::Print() — 正常执行!
return 0;
}
p 虽然是空指针,但 Print 函数体内没有访问任何成员变量——也就是说没有解引用 this。p->Print() 只是把 p 的值(nullptr)作为 this 传给 Print,函数没用到 this,自然不会崩溃。能不能跑,取决于函数体有没有通过 this 访问成员,而不是看指针空不空。
第二题:
class A {
public:
void Print() {
std::cout << "A::Print()" << std::endl;
std::cout << _a << std::endl; // 访问成员变量 → 解引用 this
}
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 运行崩溃!访问 _a 等价于 this->_a,this 是 nullptr
return 0;
}
this 存储在内存的哪个区域?——栈区。this 本质上是成员函数的形参(编译器隐式传入),形参自然放在栈上。
C vs C++ Stack 实现对比
封装到底改变了什么?下面是同一个 Stack 的两种实现,放在一起看最直观。
C 版本:
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>
typedef int STDataType;
typedef struct Stack {
STDataType* a;
int top;
int capacity;
} ST;
void STInit(ST* ps) {
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps) {
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x) {
assert(ps);
if (ps->top == ps->capacity) {
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a,
newcapacity * sizeof(STDataType));
if (tmp == NULL) {
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
// ... STPop, STTop, STEmpty, STSize 省略
int main() {
ST s;
STInit(&s); // 每步操作都要显式传 &s
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s)) {
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s);
return 0;
}
C++ 版本:
#include <iostream>
#include <cassert>
typedef int STDataType;
class Stack {
public:
void Init(int n = 4) { // 缺省参数,默认容量为 4
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a) {
perror("malloc 申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
void Push(STDataType x) {
if (_top == _capacity) {
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a,
newcapacity * sizeof(STDataType));
if (tmp == NULL) {
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
void Pop() {
assert(_top > 0);
--_top;
}
bool Empty() { return _top == 0; }
int Top() {
assert(_top > 0);
return _a[_top - 1];
}
void Destroy() {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
}; // 分号不能省略——这是我的规则。
int main() {
Stack s;
s.Init(); // 不再需要传 &s,this 自动处理
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
while (!s.Empty()) {
std::cout << s.Top() << std::endl;
s.Pop();
}
s.Destroy();
return 0;
}
对比下来,变化集中在三点:
| 维度 | C | C++ |
|---|---|---|
| 数据保护 | 任何代码都能改 s.top |
private 成员只能通过成员函数访问 |
| 传参方式 | 每次显式传 &s |
this 隐式传递,调用端零负担 |
| 类型使用 | typedef ... ST |
类名直接当类型用 |
| 缺省参数 | 不支持 | Init(int n = 4) 一行搞定默认容量 |
底层逻辑完全一样——malloc、realloc、内存布局都没有变。但在接口层面,C++ 少掉了"显式传对象地址"的噪音,把数据关进了 private,用户不能再绕过 Push 直接往 _top 上写字。
📖 参考:《C++ Primer Plus》第10章:封装不仅改变了代码组织方式,更改变了使用代码的思维模式——从"操作数据"到"向对象发送请求"。
常见误区
误区一:分号漏写
class A {
int _x;
} // 编译报错:expected ';' after class definition
// 类定义末尾分号不可省——编译器需要它来判断类体结束。
误区二:把成员变量声明为 public
class Bad {
public:
int _value; // 外部代码可以直接修改,封装形同虚设
};
// 任何时候想要修改 _value 的类型或约束,所有引用该成员的代码都要改。
误区三:误认为"每调用一次成员函数,对象里就存一份函数"
对象只存成员变量。成员函数在代码段有一份,通过 this 指针找到操作的是哪个对象。1000 个对象,也是一份函数。
本节要点
- 类 = 数据 + 操作 + 访问控制。封装的核心是把能暴露的和不能暴露的分开管理。
- class 默认 private,struct 默认 public——除此之外没有区别,但推荐用 class 定义类。
- 类域用
::操作符。类外定义成员函数时,不写ClassName::编译器就把它当全局函数。 - 对象只存成员变量,不存成员函数。空类占 1 字节纯粹是为了占位标识对象存在与否。
- this 指针是编译器隐式传入的形参,类型是
ClassName* const。函数体内可以不写,但心里要有。
📖 参考:《C++ Primer Plus》第10章 —— 对象和类 / this 指针 / 对象数组;《Effective C++》条款22 —— 将数据成员声明为 private;《高质量C++/C编程指南》第9章 —— 类的基本规范。
*迪亚波罗学编程 *
更多推荐



所有评论(0)