在 C 语言那一篇里,我们把 struct 当成一个"装数据的盒子"来用——里面只能放变量,操作数据全靠写在外面的一个个函数,每次调用都要把结构体的地址传进去。你一定已经感受到了那种别扭:一个栈,数据是 ST s,函数却是 STInit(&s)、STPush(&s, x),数据和操作它的函数是"分家"的。每写一个新类型,你都要为它单独维护一组散落在全局的函数名;函数一多,命名还得小心翼翼,生怕撞车;更糟糕的是,任何代码都能绕开你的函数,直接伸手去改 s.array[0]、s.top,数据安全完全失控。
这一篇,我们来迎接 C++ 真正区别于 C 的那个核心概念:面向对象。我们会从 struct 的升级讲起,把 class、对象、封装、作用域、this 指针这些概念逐个讲透,并且把它们背后的"为什么""底层长什么样""有哪些坑"一并掰开揉碎。学完这一篇,你会突然理解:为什么 C++ 程序员写代码时总喜欢说"这个对象"、"那个方法"——因为它们是一家人,被 class 捆在了一起。
面向对象编程思维
先建立大局观。面向对象(Object-Oriented Programming,简称 OOP)有三大特性:封装、继承、多态。这三个词你以后会反复听到,但今天我们先认识最小的那个——封装。
什么是面向对象的"对象"?你可以把它理解成:数据和操作这份数据的方法,被打包在一起的一个"自足"的单元。比如一个"栈对象",它里面既装着 array、capacity、top 这几个数据,也装着 Init、Push、Pop、Top 这几个会操作这些数据的函数。数据和函数不再散落各处,而是长在同一个类里。
这与 C 语言那种"数据是数据,函数是函数"的思想截然不同。用一句话概括两者的气质:C 语言关注的是"过程",你一步步地告诉计算机先做什么、再做什么;而面向对象关注的是"谁来做",每个对象自带操作,你只需要给它下命令。
打个生活化的比方。C 风格像"流水线工厂":你(调用者)拿着半成品,依次到"初始化工位""压栈工位""出栈工位"找不同的机器处理,每一步都要亲自把材料递进去、把成品接回来——对应到代码里,就是每次调用都要手动传 &s。而面向对象风格像"一位专属管家":你把一整件事交代给对象,对象知道自己手里有什么数据、该怎么处理,你只需说一声"Push 一个 1",它自己搞定。谁是数据的"主人"?从一开始就能说清楚。
现在不用急着把三大特性全搞懂,那是整个 C++ 课程后面的大山。这一篇,我们先把"封装"这座小土丘爬上去,顺便熟悉类和对象长什么样。剩下两个特性,等我们学完继承和多态自然就通了。
struct 的升级:C++ 对老朋友的回馈
在正式写 class 之前,必须先弄清一件事:C++ 把 struct 升级了。这一步如果不能理解透,后面很多"为什么累赘没了"的感叹你就找不到源头。
C 语言里的 struct 只能放成员变量,C++ 完全兼容这种用法(也就是你写的旧代码改成 .cpp 后缀照样能跑),但与此同时,struct 在 C++ 里被"升级成了类"——明显的变化有两点:第一,类里面可以定义函数;第二,struct 的名字本身就能代表类型,不用再 typedef 造别名。
先花点时间说说这两点背后的根因。在 C 语言标准里,struct 定义的是一个聚合类型——它只能把若干个成员变量"聚合"在一起,本质上只是一种"打包多个变量的语法糖",函数和类型绑定这件事在当时根本没有这个概念。而 C++ 之父 Bjarne Stroustrup(比雅尼·斯特劳斯特卢普)在 1979 年设计 C with Classes(C++ 的前身)时,想引入"类"(class)这个概念,但他又不想破坏 C 程序的兼容性。于是聪明的做法是:让 class 和 struct 共享同一套"类型系统",把 struct 直接升级成类。这样老 C 代码里的 struct 写法在新编译器下依然合法,同时又悄悄获得了类的能力。
至于为什么"名字本身就能代表类型"——这也是 C++ 引入的关键差异。在 C 里,struct ListNode 这个类型你必须写完整的 struct ListNode 才能使用(或在后面 typedef 一个别名);而 C++ 规定 struct、class 定义出的名字自动就是类型名,可以直接使用。这是 C++ 对 C 的一处非常体恤的改良。
看个最直白的对比:
#include <iostream>
using namespace std;
// ---------- C 风格:struct 只能存数据,还得靠 typedef 造别名 ----------
typedef struct ListNodeC
{
struct ListNodeC* next; // 在 C 里必须写 struct 前缀才能表示类型
int val; // 节点里存的值
} LTNode; // C 风格要 typedef 才能把 ListNodeC 写成 LTNode 用
// ---------- C++ 风格:struct 升级成了类,既能放数据也能写函数 ----------
struct ListNodeCPP
{
void Init(int x) // struct 里也能定义成员函数了
{
next = nullptr; // 直接写成员名,不用再绕弯
val = x; // 初始化节点
}
ListNodeCPP* next; // C++ 里类型名直接当类型用,不需要 typedef
int val;
};
int main()
{
ListNodeCPP node; // 直接用类名定义对象,无需 typedef
node.Init(10); // 直接调用类型内部的函数
return 0;
}你看,C++ 版本里 ListNodeCPP 的名字本身就能当类型用,类型里还内嵌了一个 Init 函数来给自己初始化。这就已经有点"对象自足"的味道了。
注意我刚才说 struct "升级成了类"——但我得把话说严谨:在 C++ 里,struct 和 class 基本是同一个东西,它们都能定义成员变量和成员函数,也都遵循访问限定符、this 指针等全部规则。它们的区别只有一个:默认的访问权限不同(我们下一节讲访问限定符时会重点讲)。所以你要记的准确表述是:struct 和 class 是"同一物种的两种写法",仅默认访问权限不同。
那我可不可以以后全用 struct?语法上可以,但习惯上推荐用 class 定义类。原因我们讲完下一节"访问限定符"你就明白了——struct 和 class 有个很关键的默认值差异。除此之外还有一个工程直觉:看到 struct 时,读者倾向认为"这是个单纯装数据的简单类型";看到 class 时,读者会觉得"这是个讲究封装、有完整行为的复杂类型"。用对的关键字,本身就是一种无声的文档。当然,如果你的类真的只是"装几个数据、没有私有成员",那用 struct 也完全合理——C++ 标准库和很多第三库就这么干。选择的标准是"意图",不是"能力"。
类的定义:class 关键字与类的三要素
既然 struct 都能当类用,为什么还要个专门的 class 关键字?因为它是正统的"面向对象"语法,语义更清晰。下面是定义一个类的完整格式:
class Stack
{
public:
void Init(int n = 4); // 声明成员函数
void Push(int x);
int Top();
void Destroy();
private:
int* array; // 成员变量:指针
size_t capacity; // 成员变量:容量
size_t top; // 成员变量:栈顶
};拆开看语法:class 是定义类的关键字,Stack 是类的名字,一对 {} 包起来的是类的主体,最后那个分号一定不能省——这是初学者第一个必踩的坑,漏了分号编译器直接报错。
你可能会疑惑:为什么 {} 后面要跟个分号?因为类定义本质是一条"类型声明语句"。在 C/C++ 里,只要是"声明"(不像函数体那样是可执行代码),末尾就得有分号。这跟 struct、enum 定义的结尾要分号是同一个道理——它们是给编译器"登记一个类型",而不是"执行一段代码"。记个口诀:类型定义必以分号收尾,函数定义绝不加分号(因为函数体 {} 本身就是终结符)。
类体里的内容统称为类的成员:
- 类里的变量叫属性,也叫成员变量(英文术语常写作 data member / field,对应上面的
array、capacity、top); - 类里的函数叫方法,也叫成员函数(对应 member functions、methods,比如
Init、Push、Top、Destroy)。
注意,同一个概念有两套叫法其实是为了翻译和沟通方便——中文教材里"属性/方法"来自面向对象语言(如 Java/Python)的术语,而"成员变量/成员函数"是 C++ 的直译术语。你在看 C++ 文档时看到 member variable、member function,心里要立刻翻译成"成员变量、成员函数"。
还有一个你以后天天要用的约定:为了区分成员变量和普通局部变量,习惯上会给成员变量加个特殊标识——在前面或后面加 _,或者用 m 开头。比如 _year、m_year、year_ 都行。要强调的是,这并非 C++ 强制的语法,只是行业惯例,具体用哪种看公司要求。但自己写代码时,最好统一一种并坚持用,不然时间久了连自己都分不清哪个是成员、哪个是局部变量。
这里稍微展开一下这个约定为什么如此重要。看这样一段代码,如果成员变量和形参都叫 year:
// 反例(只是示意,成员和形参同名且没有下标约定)
class Date
{
public:
void Init(int year, int month, int day)
{
// 这里 year 到底是"形参 year"还是"成员 _year"?根本说不清!
// 如果写成 year = year; 那等于自己给自己赋值,成员永远得不到值
year = year; // 编译器会把它解释成"形参=形参",成员毫无变化
_month = month; // 有 _ 前缀的才能明明白白指向成员
}
private:
int year;
int month;
int day;
};这就是命名约定的全部意义所在:它把"隐式含义"变成"显式视觉",让你一眼就看出"谁是天生的自家人(成员)、谁是外来客(参数/局部变量)"。别小看这个习惯,用它能在编译阶段就帮你拦住一大类"忘赋值的 bug"。
另外补充一个细节:定义在类里面的成员函数,默认就是内联函数(inline)。内联的意思是编译器可能直接把函数体在调用处"展开",省去函数调用的开销。这个靠编译器权衡,你现在只需要有这个印象,后面讲函数和内联时我们再深入。
关于 inline,这里先给你两个准确认知,免得以后被坑。第一,"默认 inline"不等于"一定会内联"——编译器对是否展开有最终决定权(函数体太长、有循环时往往就不展开了);它只是给编译器一个"建议"。第二,类内定义的函数被标记为 inline 有个副产品:这样在**多文件工程(.h/.cpp 分离)**里,同一个类内定义的函数即使被多个 .cpp 文件包含,也不会因为"重复定义"而链接报错——因为 inline 函数天然允许在每个编译单元各有一份定义,链接器会去重复。这也是为什么 C++ 官方资料强烈建议"简单的短函数定义在类内、复杂的函数声明到类外再用 :: 实现"。
下面用成员变量加 _ 前缀的习惯,写一个完整的 Date(日期)类,把定义语法落实一遍:
#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;
};
int main()
{
Date d; // 定义一个 Date 对象(先认识一下,下一节详细讲)
d.Init(2025, 4, 13); // 调用初始化
d.Print(); // 输出:2025-4-13
return 0;
}注意 Init 函数里那行 _year = year;——左值 _year 是成员变量,右值 year 是函数形参,它们的区别全靠那个下划线前缀。这就是为什么命名约定这么重要:如果都叫 year,你根本分不清到底是谁给谁赋值。
再补两个类定义语法上的小知识点,帮你把"类的定义"这一整块补成闭环:
第一,访问限定符可以反复出现,也可以和成员变量交错摆放。比如我可以先写几个 public 成员、又来一段 private、再回来一段 public。编译器就按"从上到下、以最近的限定符为准"来处理作用域,这给了你灵活组织类布局的空间:
class Mixed
{
public: // 第一段 public
void ShowPublic() { }
private: // 切回 private
int _a;
public: // 再次 public:下面的成员又是对外接口
void ShowPublicAgain() { }
};第二,类的成员(成员函数、成员变量)可以按任意次序声明,成员函数体内可以直接使用在它后面才声明的成员。这是因为整个类体构成一个完整的作用域,编译器在读类的时候先"扫一遍"登记全部成员,再进入函数体。这与普通函数"用前必先声明"的规则不同,是类作用域特有的宽厚。
到这里你会发现,类定义的三个基本构件已经齐了:类名(给类型一个身份)、成员(数据和方法的集合)、访问限定符(划定别人能不能碰的边界)。我们把后两样深挖清楚,类定义的图就完整了。
访问限定符与封装
现在我们要回答上一节留下的悬念:为什么推荐用 class 而不是 struct?关键就在**访问限定符(access specifier)**上。
C++ 用访问权限来实现"什么能给别人看、什么只能自己留着"这套管理,这就是封装的第一步。一共有三个访问限定符:
public:修饰的成员,在类外可以直接访问,是暴露给外界的接口。protected:修饰的成员,类外不能直接访问。它和private目前对你来说完全一样,真正的区别要等以后学到"继承"才体现出来,现在可以先不纠结。private:修饰的成员,类外不能直接访问,是隐藏的实现细节。
那 protected 和 private 到底差在哪?提前给你留个钩子,你心里有个谱就不慌:等学到继承时你会发现,private 的成员连继承它的子类都访问不到(只能通过父类的公开/受保护接口间接用);而 protected 的成员在子类(派生类)内部可以访问,但类外依然访问不到。也就是说,protected 是"专供子类使用"的中间地带。现在先记结论:这个阶段把它俩当同一个东西即可。
有两条规则要记住。
第一,访问权限的作用域是从该限定符出现的位置开始,一直到下一个访问限定符出现为止;如果后面再没有限定符了,就一直作用到类的结束 }。
你看上面的 Stack,public: 下面连着写了 Init、Push、Top、Destroy 四个函数,中间没插入别的限定符,所以它们都是 public;private: 下面三个成员变量就一直 private 到类结束。
第二,也是最关键的一条默认规则:class 定义的成员,如果没有被任何访问限定符修饰,默认是 private;而 struct 定义的成员,默认是 public。这就是为什么推荐用 class——用 class 时你不小心漏写 public,成员就天然保密,不会裸奔;而用 struct 默认全是公开的,容易被外部乱捅。C++ 官方设计里 struct 保留 C 的"全公开"语义,class 才是"默认私有、讲究封装"的正主。
为了让你彻底记住这条默认规则,我特意写一个可以自己跑的实验——注意 struct 和 class 里都故意没写任何访问限定符:
#include <iostream>
using namespace std;
struct S
{
int a; // 没写访问限定符:struct 默认 public,类外可以直接访问
};
class C
{
int b; // 没写访问限定符:class 默认 private,类外访问编译报错
};
int main()
{
S s;
s.a = 10; // 合法:a 是 public
cout << s.a << endl; // 输出:10
C c;
// c.b = 20; // 编译错误!b 默认是 private,类外无法访问
return 0;
}c.b = 20; 那一行一旦取消注释,编译器就会报 struct C::b(或 C::b)无法访问之类的主错误。这正是"默认私有"在保护你。
来做个更贴近实战的实验,亲眼看看访问限定符的威力:
#include <iostream>
using namespace std;
class Account
{
public:
// 对外暴露的操作接口:存钱
void Deposit(double money)
{
if (money > 0) // 顺手做个防御:不允许存负数/零
{
balance += money; // 修改私有成员,没问题(在类的内部)
}
}
// 对外暴露的只读接口:查余额
double GetBalance()
{
return balance;
}
private:
double balance = 0; // 私有成员变量,外部不能直接碰
};
int main()
{
Account acc;
acc.Deposit(100); // 走公开接口,合规
cout << acc.GetBalance() << endl; // 输出:100
// acc.balance = 10000; // 编译错误!balance 是 private,类外不能访问
return 0;
}注意被注释掉的那一行——如果你把注释去掉,编译器会毫不留情地报错:balance is a private member of 'Account'。这看起来像"限制",但恰恰是封装在保护你。试想一个银行账户,如果余额数据完全公开,谁都能随手改,那不乱套了?所以实际工程里的惯例非常统一:成员变量一律设成 private/protected(对外保密),凡是需要给外部用的成员函数放 public(对外提供接口)。这就是"把数据藏起来,只留接口给世界"的封装思想。
说到封装,值得把它"是什么、为什么、边界在哪"一次讲透,因为很多初学者对"封装=不让访问"有误解。
- 是什么:封装(encapsulation)指把对象的数据(状态)和操作(行为)绑定在一起,并通过访问控制,只把必要的接口暴露给外部使用。它包含两层含义:一是"打包"(把数据和函数放一块),二是"隐藏"(私有化,只留接口)。
- 为什么:因为数据的正确性,依赖于一套规则来维护。比如银行余额不能为负、栈顶不能越过容量、日期月份必须在 1~12 之间。如果数据裸奔,任何一个调用者都可能把一个非法值塞进去,破坏整套规则,产生难以排查的 bug。封装保证"改数据的唯一通道"是那些受控的成员函数——就像金库只有一扇带鉴权的门,谁进、拿什么,都有记录、有校验。
- 边界在哪:封装不是"外部完全碰不到"。它拦得住正经代码,但拦不住两种"破解":一是通过友元(friend),关键字
friend可以"点名"某个外部函数或类成为本类的朋友,特许访问私有成员(下一篇进阶会讲);二是用指针强行篡改内存/成员指针这些"不守规矩"的手段——这些属于"明知故犯",正常工程绝不使用。所以准确说法是:封装清除的是"无意/低级的错误访问",不是"恶意攻击"。
再看一个生活类比帮助你内化:手机。手机内部的电路、电池、存储芯片是 private——你无法直接去焊一根线改它的电压;而屏幕按键、充电口、摄像头是 public——那是产品给你的接口。如果整台手机的所有零件都裸露在外,手机必然三天两头坏。封装的价值,就是把"我们精心维护的内部状态"和"稳定的对外契约"清晰地切开。
说句实在话,这一阶段的封装还没那么玄妙,它更多的是一种更严格的规范管理,避免代码里出现随意乱改数据导致的 bug。等以后学完构造、析构、友元、继承,封装的含义会不断丰满。现在先把"成员私有、接口公开"这个习惯刻进肌肉记忆。
类的作用域(类域)与声明定义分离
你可能注意到了,每次用 . 调用成员函数,比如 acc.GetBalance(),都很自然。但编译器是怎么知道 GetBalance 属于 Account 的呢?这要归功于一个底层机制:类定义了一个新的作用域,叫做类域(class scope)。
你可以把 { class Account { ... }; } 看成一道无形的墙:类里所有的成员都生活在这个只属于 Account 的"小国家"里。当你在类外面写代码时,直接写 GetBalance 这个名字,编译器根本找不到它——因为它住在 Account 这个国家里,你得先报出国名。
这里要把"作用域"这个概念讲深一点,因为它太常用了。C++ 里的作用域大体有这几类,层层嵌套:
- 全局作用域(global):写在整个文件顶层的名字,哪里都能用。
- 命名空间作用域(namespace):
namespace std { ... }划定的一小块"领地"(后面讲命名空间会细说)。 - 局部作用域(local/block/code block):函数体或
{}代码块内部的名字,块外不可见。 - 类作用域(类域):
class Account { ... }内部,也是我们今天的主角。 - 成员函数体作用域:成员函数内部又是嵌套在"类域"里的一个更内层作用域。
注意嵌套关系:成员函数体作用域 ⊂ 类域 ⊂ (外层命名空间/全局)。这个嵌套关系直接决定了"名字查找(name lookup)"的顺序。当编译器在一个成员函数体里遇到一个名字(比如 year、_year),它会按由内到外的顺序找:
- 先在该函数的局部作用域(包含形参)里找;
- 找不到,再进类域里找(这就是成员能直接用名字访问的原因);
- 类域里也找不到,再去外层命名空间/全局作用域找。
这条查找链是理解后面所有"名字突然找得到/找不到"问题的地基。
这就引出一个重要用法:当你在类体外定义成员函数时,必须用作用域操作符 :: 指明这个成员属于哪个类域。
实践中,一个类往往很长,把声明和实现全塞在类体里很难读。更专业的写法是声明和定义分离——类体里只写函数声明(没有函数体的那种),实现挪到类外面去写。此时就必须靠 :: 指明归属:
#include <iostream>
#include <cstdlib> // malloc 需要
#include <cstdio> // perror 需要
using namespace std;
class Stack
{
public:
void Init(int n = 4); // 类内只有声明,没有函数体
private:
int* array;
size_t capacity;
size_t top;
};
// 类外定义成员函数:必须用 Stack:: 指明它属于哪个类域
// 注意:类内声明里的缺省参数 n=4 写在声明处,类外定义处不要再写缺省参数
void Stack::Init(int n)
{
array = (int*)malloc(sizeof(int) * n); // 申请 n 个 int 大小的堆空间
if (nullptr == array) // 申请失败时返回 NULL
{
perror("malloc申请空间失败");
return;
}
capacity = n; // 记录容量
top = 0; // 栈顶归零
}
int main()
{
Stack st;
st.Init(); // 用缺省参数 n=4,开 4 个 int 的空间
return 0;
}这里 void Stack::Init(int n) 里的 Stack:: 就是作用域操作符,意思是"这个 Init 是 Stack 的成员函数"。:: 还藏着一个查找规则的秘密:类域影响的是编译时的名字查找规则。假设我们把 Stack:: 去掉,写成 void Init(int n),那么编译器会认为这是一个全局函数 Init。当它想找 array、capacity、top 这些成员时,在全局作用域里当然找不到它们的声明——于是直接编译报错。而写上 Stack:: 之后,编译器就明白"这是 Stack 的成员函数",当前作用域找不到的 array 等名字,它会自动钻进 Stack 这个类域里去查找,就找到了。
声明和定义分离是个非常实用的工程习惯——类体清爽好读,实现文件各自独立,编译更快、多人协作也不容易打架。以后真实项目里,几乎都是 Stack.h 放声明、Stack.cpp 放实现的写法。:: 这个操作符,后面讲命名空间时还会再见到。
借这个例子给你埋两个"以后必用的细节",现在先混个脸熟:
- 缺省参数(默认实参)只能写在声明里,类外的定义处不能重复写。上面的代码里
void Init(int n = 4)写在类内声明处,而类外定义写的是void Stack::Init(int n)——如果你在类外再写int n = 4,编译器会报"默认实参重复指定"之类的错误。这是分离定义时的经典坑。 - 碰到一个特别容易懵的场景:成员函数里同时没有局部变量遮挡、名字又在类和全局都出现时,你可以用
::名字显式指定全局那个。比如成员函数里想用全局变量x,而类里也有成员_x——不会混淆(因为名字不同),但若想跳过"类域这层"直接去全局找,就写::x。先记住有这种语法,后面会用到。
再补一个类域相关的生动验证。类域之所以叫"域",意味着类外默认看不见类里的类型和函数。比如:
#include <iostream>
using namespace std;
class Calculator
{
public:
// 在类域里给 int* 定义一个"类型别名"成员:外部默认看不到它
using IntPtr = int*;
int Add(int a, int b) // 成员函数,同样住在类域里
{
return a + b;
}
};
int main()
{
Calculator calc;
cout << calc.Add(2, 3) << endl; // 输出:5
return 0;
}这里 IntPtr、Add 都住在 Calculator 的类域里。函数 Add 能通过对象 calc.Add(...) 直接调用,是因为 . 后面的名字本来就在"类域"里解析;而如果要在类外单独引用 IntPtr 这个类型别名,就必须写成 Calculator::IntPtr 这样的"点名"形式(写成 Calculator::IntPtr 会返回 int* 类型,你可以自己在编译器里试一下)。这条"类内随便用、类外要报国名"的规则,正是整个面向对象里"类型自成一体"的语法基石。
类的实例化:设计图与房子
类设计好了,它代表什么?这里必须先纠正一个新手最常见的误区:类本身不占内存、不能存数据,它只是一张"设计图"。 设计图规划了有多少个房间、每个房间多大、什么功能——但房子本身并不存在。只有按照设计图把房子盖出来,才谈得上住人。对应到代码里:用类这种类型去"盖房子"(在物理内存中真正创建对象)的过程,就叫实例化(instantiation)。
说得更精确一点:类是对一类对象的抽象描述,是一个模板;类里的成员变量此时只是"声明",并没有分配真实空间。当用类实例化出对象时,对象才会占有真实的物理内存,用来存储这些成员变量。
这句话值得展开成两张"图"来看。定义类时,int _year; 只是告诉编译器"将来每个 Date 对象里都有一块 4 字节的地方放年",此刻这 4 字节并不存在于任何地址上;只有当你写了 Date d;(在栈上)或 new Date(在堆上)之后,才真的有一块内存被分配出来,_year 才"住"进某个具体地址。这个"声明 vs 定义/占用空间"的区别,是 C 语言里函数声明与定义的延伸,也和你以后学"链接时变量分配"一脉相承。
一个类可以实例化出无数个对象,每个对象都有一份独立的成员变量空间。看这个 Date 的例子:
#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;
};
int main()
{
// 用 Date 类实例化出两个对象 d1 和 d2:此刻才在栈上分配了两块内存
Date d1;
Date d2;
d1.Init(2024, 3, 31); // d1 拥有自己独立的一份 _year/_month/_day
d1.Print(); // 输出:2024/3/31
d2.Init(2024, 7, 5); // d2 拥有另一份独立的成员变量
d2.Print(); // 输出:2024/7/5
return 0;
}d1 和 d2 是同一个类 Date 实例化出的两个不同对象。它们各自在内存里占一块空间,各自的 _year、_month、_day 各存各的值,互不干扰。改动 d1 的数据,永远影响不到 d2。这种"一人一亩三分地"的独立性,就是对象最基本的特征。
再补一个很有价值的细节:这一篇里我们用的都是"栈上对象"(直接写 Date d;)。对象其实可以被放在四种"存储期(storage duration)"中——栈上(局部对象)、堆上(new 出来)、静态区(全局/static 局部对象)、还有局部线程存储等。栈上对象的生命周期由"所在代码块"自动管理(离开块就自动销毁),而堆上对象需要手动 delete 释放。这与你 C 语言里"栈上数组 vs malloc 数组"的区别完全对应。之所以本篇用栈对象,就是想让你先把"实例化=分配空间"这个概念建起来,存储期管理的细节丢到"默认成员函数(下篇)"一并消化。
对象的大小:成员函数去哪儿了
既然每个对象都有自己独立的空间,你可能会想:对象里到底存了什么东西?成员变量一定在,那成员函数呢?
答案是:成员函数不存进对象里。 我们要把这个底层原理讲透。函数被编译后是一段机器指令,这段指令是"只读"的,它存放在内存一个专门叫**代码段(code segment)**的区域。对象里不可能把这段指令再复制一份(那也太浪费了),真要说存,最省事的也就是存一个指向函数的指针。
但问题来了:连存这个指针都没必要。 想想看,d1 和 d2 的成员变量是各自不同的,可它们调用的 Init、Print 函数却是同一段代码、同一个地址。如果每个对象都往自己里面存一份函数指针,实例化 100 个对象就重复存 100 次,纯粹浪费。
那编译器到底怎么找到函数呢?你可能会认为是"运行时靠对象里存的指针去找"——不是的。函数地址在编译链接阶段就被确定并写死进调用了,它编译成的汇编指令长这样:call 地址。也就是说,调用成员函数时,指令里直接就是那个函数在代码段的地址,根本不经过对象。只有当以后学到**动态多态(虚函数)**时,才需要运行时查找函数地址、才需要对象里存函数相关信息——那是后话,这一篇你先记住:普通情况下,对象里只存成员变量,不存成员函数,也不存函数指针。
这里再给你一个严谨的"边界说明",避免把话说死被以后的自己打脸:a. 静态成员函数(static)根本不属于任何一个对象,也没有 this,自然不在对象里;b. 当类里有虚函数(virtual)时,编译器会给每个对象额外加一个"虚表指针 vptr"(一个指向虚函数表的指针,用于运行时确定该调哪个版本的函数),这时对象大小就会比"纯成员变量之和"多出一个指针的大小。这两点本篇都不展开,但你现在已经能理解它们的"存在理由":vptr 的出现正是为了在对象里给动态多态"留个信物"。所以更准确的措辞是:对象的大小主要由成员变量决定;成员函数本身不算入对象;但在存在虚函数的类里,会额外多一个指针成员(vptr)。
所以计算一个对象的大小,起决定作用的是它的成员变量。而且 C++ 规定,类实例化出的对象和结构体一样,也要遵守内存对齐的规则。
先看一道验证题,算一算下面三个类实例化的对象分别有多大:
#include <iostream>
using namespace std;
class A
{
public:
void Print() // 成员函数不占对象空间
{
cout << _ch << endl;
}
private:
char _ch; // 1 字节
int _i; // 4 字节
};
class B
{
public:
void Print() // 只有成员函数,一个成员变量都没有
{
// 空函数体
}
};
class C
{
// 完全空类,什么都没写
};
int main()
{
A a;
B b;
C c;
cout << sizeof(a) << endl; // 输出:8
cout << sizeof(b) << endl; // 输出:1
cout << sizeof(c) << endl; // 输出:1
return 0;
}看输出结果:A 是 8,B、C 都是 1。你可能会嘀咕:A 只有 char(1字节) 加 int(4字节),一共 5 字节呀,怎么算出 8?B、C 一个成员变量都没有,怎么还占 1 字节?
这就要引出内存对齐的规则了。别急着跳过,我拆开讲给你听,这套规则以后算结构体、算对象大小、甚至分析性能都用得上:
内存对齐规则:
1. 第一个成员放在与起始地址偏移量为 0 的地方。
2. 其他成员变量要对齐到"对齐数"的整数倍的地址处。
3. 对齐数 = min(编译器默认的对齐数, 该成员变量本身的大小)。
4. 在 Visual Studio 里,默认对齐数为 8。
5. 结构体(类对象)的总大小,必须是"最大对齐数"的整数倍。
6. 如果结构体里嵌套了结构体,嵌套的结构体要对齐到自己最大对齐数的整数倍处;
整体大小是所有最大对齐数(含嵌套结构体的)的整数倍。
用这套规则算一下 A 里那两个成员的布局(假设默认对齐数是 8):
_ch(char,1 字节):放在偏移 0 处。_i(int,4 字节):对齐数 = min(8, 4) = 4。它必须放到偏移是 4 的整数倍的地方,0 之后下一个 4 的整数倍是偏移 4,所以_i落在偏移 4~7 这 4 个字节。- 于是偏移 1、2、3 这三个字节被"空出来"了——这就是所谓的内存填充(padding),不做任何事,只为了对齐。
- 最后看总大小:最大对齐数是 4(char 的对齐数是 1,int 的对齐数是 4,取最大 4),8 本来就是 4 的整数倍,所以总大小 = 8。
这就是为什么 char + int = 5 字节 的 A,sizeof 却得到 8。那 3 个字节是浪费吗?是,但这是有回报的"浪费"——对齐之后,CPU 读取 int 时能一次取到它全部 4 个字节,不用跨两次内存访问,性能更稳。
说到这,把"内存对齐到底为什么存在"这个为什么一次讲清楚,因为它绝不是 C++ 的发明(结构体对齐在 C 里就有),而是硬件层面的物理约束在语言层面的投影:
- CPU 不是按字节读取内存的,而是按"字(word)"读取的。 在 64 位平台上一个字通常是 8 字节。如果一个
int(4 字节)的起始地址是 4 的整数倍(对齐),CPU 一条指令就能把它整块读进寄存器;如果它"斜着跨"了两个字边界,CPU 就得先读第一块、再读第二块,最后在寄存器里把两半拼起来——一次访问变成两次,还有额外的位运算。这就是"对齐"最直接的性能回报。打个比方:停车位是 4 米一个,一辆 4 米的车停在某个车位正中间,一步到位;若停在两个车位之间,进退腾挪好几回才能停好。 - 某些架构根本不允许未对齐访问。 在 x86(Intel/AMD)上,未对齐访问通常只是"慢一点",硬件会容忍并自动拆成多次访问;但在 ARM、MIPS、SPARC 等精简指令集(RISC)架构上,未对齐的访问会直接触发硬件异常,若你的代码没有处理,进程直接崩溃。所以"内存对齐"不是可选项,而是保证程序跨平台正确运行的前提。
- 缓存行(cache line)的分隔。 现代 CPU 的缓存以 64 字节为一行从内存成块搬运数据。如果数据恰好"跨"在两个缓存行的交界处,一次取值可能牵动两次缓存加载;在多线程场景下,多个线程同时改同一缓存行里的不同数据还会引发"伪共享(false sharing)",造成并发性能暴跌。对齐能让数据尽量不横跨缓存行。
再补充几个工程决策点,让你对这个话题的把握"闭环":
- 不同编译器/平台默认对齐数可能不同。 教程常说"VS 默认 8";在 64 位 Linux 的 GCC/Clang 上,
double等类型的自然对齐也是 8。你可以用alignof(类型)查一个类型实际的对齐要求,用sizeof查它的大小——两者经常不同,别混。对齐数通常是 2 的正整数幂(1/2/4/8/16/32...)。 - 成员顺序会影响对象大小。 把"大成员放前面,小成员收尾"往往能减少填充。这是面试官最爱考的一个优化点。
#pragma pack可以强行改变对齐。 比如网络协议封包、二进制文件头,希望结构体紧挨着不留空洞时,用#pragma pack(1)(对齐系数压到 1)即可。代价是牺牲访问性能,属于"特殊场景才用"的手段。
下面用一组加持版代码,把"对齐"讲得看得见摸得着——特别注意只调整成员顺序,sizeof 就能从 12 变成 8:
#include <iostream>
using namespace std;
// 成员按"小-大-小"排列:填充多
struct W
{
char c1; // 偏移 0
// 偏移 1~3 填充 3 字节(为了对齐下面的 int)
int i; // 偏移 4~7
char c2; // 偏移 8
// 偏移 9~11 填充 3 字节(为了让整体是最大对齐数 4 的整数倍)
}; // sizeof(W) == 12
// 成员按"大-小-小"排列:填充最少
struct T
{
int i; // 偏移 0~3
char c1; // 偏移 4
char c2; // 偏移 5
// 偏移 6~7 填充 2 字节(让整体是 4 的倍数)
}; // sizeof(T) == 8
// 嵌套结构体:嵌套的结构体要对齐到自己最大对齐数(这里是 8)的整数倍处
struct Inner
{
char c; // 偏移 0
// 偏移 1~7 填充(为了让 double 对齐到 8)
double d; // 偏移 8~15
}; // sizeof(Inner) == 16,最大对齐数 = 8
struct Outer
{
char a; // 偏移 0
// 偏移 1~7 填充(Inner 要对齐到 8 的整数倍)
Inner in; // 偏移 8~23(占 16 字节)
char b; // 偏移 24
// 偏移 25~31 填充(整体要对齐到 8 的整数倍)
}; // sizeof(Outer) == 32
int main()
{
cout << sizeof(W) << endl; // 12
cout << sizeof(T) << endl; // 8
cout << sizeof(Inner) << endl; // 16
cout << sizeof(Outer) << endl; // 32
// alignof 查看类型对齐要求
cout << alignof(char) << endl; // 1
cout << alignof(int) << endl; // 4
cout << alignof(double) << endl; // 8
return 0;
}你看 Outer 的 32 是怎么来的:char a 占 1 字节后,Inner 要求对齐到 8 的整数倍,于是填充到偏移 8,入住 16 字节到偏移 23;char b 占偏移 24;此时用到 25 字节,但 Outer 的最大对齐数是 8,所以补到 32。亲手算一遍嵌套结构体,你对第 6 条规则的理解就落地了。
如果你希望在某些场景下不要填充(比如想精确匹配某种二进制协议的数据排布),可以这样写——注意这是"特事特办"的招,正常情况下不要滥用:
#include <iostream>
using namespace std;
// #pragma pack(1) 之后,一切成员紧挨着放,不再为对齐填充
#pragma pack(push, 1)
struct Packed
{
char c; // 偏移 0
int i; // 偏移 1(紧挨着,而不是对齐到 4)
char d; // 偏移 5
};
#pragma pack(pop) // 恢复默认对齐
int main()
{
// 默认对齐下这种结构一般是 12,pack(1) 后是 1+4+1=6
cout << sizeof(Packed) << endl; // 输出:6
return 0;
}packed 的代价是:int 可能落在非自然对齐的地址上,某些 CPU 上访问变慢甚至报错。所以只在你明确需要"无空洞的紧凑布局"(协议封包、序列化、目标结构体字节数必须固定)时才用。
接着看 B 和 C:它们一个成员变量都没有,为什么 sizeof 都是 1?这是 C++ 的一条贴心规定:给空类对象分配 1 字节,纯粹为了"占位标识对象存在"。如果 1 字节都不给,对象的大小就是 0,那编译器就没法区分"这个对象存在过"和"根本没定义对象";而且数组、指针指向这种对象时也需要有个"实体"可寻址。所以空类强制给 1 字节占位。
把空类 1 字节的内在逻辑说透:C++ 标准规定"一个完整的类对象,其大小必须非零"。为什么必须非零?因为如果你定义一个 C arr[10];,则 arr[0] 和 arr[1] 必须占据不同的地址(不然一个指针无法指向"第几个对象");同理 C* p = &obj; 需要 obj 有一个真实存在的唯一地址。如果对象大小是 0,所有对象都挤在同一个地址上,指针语义瞬间崩塌。所以编译器给空类 1 个字节作为"最小实体"。这与 C 语言的空结构体不被允许(GCC 扩展允许且大小为 0)、C++ 统一给 1 字节的差异,正是两者对"空对象能不能存在"的不同回答。
顺便说一句:这里的 1 字节是"独立对象"的情况。如果这个空类被当作基类去继承(class D : public EmptyBase {}),C++ 允许一种叫"空基类优化(EBO,Empty Base Optimization)"的技巧,让空基类不额外增加派生类大小——以后讲到继承你再细品,现在只是安个种子。
这几道结果很反直觉,但它们是 C++ 面试常客。记住三个结论:对象大小只由成员变量决定(成员函数不算入大小);对象要遵守内存对齐,可能比成员变量之和更大;空类大小是 1。 若再结合虚函数预告,第四条就是"有虚函数的对象会多一个指针成员"。
this 指针:编译器藏在成员函数里的秘密
现在有个哲学问题浮现了:d1.Init(...) 和 d2.Init(...) 调用的明明是同一个 Init 函数,函数体里也没写"我要操作 d1 还是 d2",那编译器怎么知道这次该改 d1 的成员变量、还是改 d2 的呢?
答案就是 this 指针。
机制是这样的:编译器在编译成员函数时,会偷偷在它的形参第一个位置加一个参数——一个当前类类型的指针,名字叫 this。比如 Date 类的 Init 函数,你写出来的声明是:
void Init(int year, int month, int day);但编译器编译后,它真正的原型变成了:
void Init(Date* const this, int year, int month, int day);注意这个类型是 Date* const——它是一个 const 指针,意思是**this 本身不能被修改**(你没法让它指向别的对象)。你在函数体里访问的每个成员变量,本质上都是通过 this 指针去访问的。比如 Init 里写 _year = year;,编译器实际处理成了 this->_year = year;。
而调用端呢?d1.Init(2024, 3, 31) 这行代码,编译器编译时其实也偷偷改成了 d1.Init(&d1, 2024, 3, 31)——把对象的地址作为第一个参数传进去。这样 this 就指向了 d1,函数里所有 _year 都落到 d1 头上;换成 d2.Init(...),this 就指向 d2。同一份函数代码,靠传入不同的 this 地址,精准操作不同的对象。这就是对象能够"各管各的"的底层秘密。
这里我借权威资料,把"this 的准确定义"给你补精确,免得以后在和别人讨论、或读标准文档时被绕晕。关于 this,有几个要点值得记牢:
- this 是一个"纯右值(prvalue)表达式",不是一个你可以随便存取的变量。 在标准里,
this的类型是"指向类的 cv 限定版本的指针",即普通成员函数里是Date*,const 成员函数里是const Date*。很多教材(包括大部分国内教材和 MSVC 官方文档)用Date* const this的形式来方便你理解"它不能再指向别的对象",这个讲法胜在直观;严格按标准说,this是右值,连"给它取地址(&this)"都不允许。 - 你不能改写
this,也不能对this取地址。 像this = nullptr;这种在标准下是编译错误(旧版某些编译器曾允许,但标准很早就禁止了)。 - this 指向的对象和 this 的"生与死":
this指向"正在调用这个成员函数的那个对象"。它只在非静态成员函数、构造/析构、成员初始化列表等少数上下文里可用;静态成员函数里没有 this——因为静态成员函数不依附于某个具体对象。 - this 不在对象里。 MSVC 文档明确说:
this不是对象的一部分,sizeof对象时不会把 this 算进去。这和我们前面讲的"对象只存成员变量"完全一致。
关于 this 到底存在内存的哪个区——这是面试高频题。准确说:this 是编译器作为"隐含实参"传入的,具体落在寄存器还是栈上,由平台的函数调用约定(ABI)决定。x86-64 等平台上它通常先走寄存器、需要时再压栈。所以回答答卷时可以说"通常以形参/寄存器方式传递,属于栈/寄存器这些运行时调用现场的一部分;它不在堆、不在静态区、当然更不在对象内部"。写对了"不在对象里、不在堆、不在静态区/常量区"这三个否定,再加"在栈(或寄存器)"这一肯定,基本就是满分答案。
关于 this,有一条硬性规定:你不能再在实参和形参的位置显式地写 this——因为编译器自己会补,你再写就重复了。但是!你可以在函数体内部显式使用 this 指针。看代码:
#include <iostream>
using namespace std;
class Date
{
public:
// 真实的原型是:void Init(Date* const this, int year, int month, int day)
// 你不能在形参位置写 this,编译器会自动补上
void Init(int year, int month, int day)
{
_year = year; // 等价于 this->_year = year;
this->_month = month; // 在函数体内可以显式写 this
this->_day = day; // 两者效果完全一样
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2;
// d1.Init(&d1, 2024, 3, 31); // 这一手编译器帮你做了,你不能自己写 &d1
d1.Init(2024, 3, 31); // 实际等价于 d1.Init(&d1, 2024, 3, 31)
d1.Print(); // 输出:2024/3/31
d2.Init(2024, 7, 5); // this 指向 d2,操作 d2 的成员
d2.Print(); // 输出:2024/7/5
return 0;
}注意到 Init 函数体里混用了两种写法:_year = year; 和 this->_month = month;——它们完全等价,你写哪种都对。日常开发多半直接写成员名(编译器自动加 this->),只有需要区分"成员 vs 同名参数"时,才显式写 this-> 更醒目。
this 还有三个经典用法,是"真正的程序员"会用的,现在先了解一下,拉高你的眼界:
- 返回
*this实现链式调用。 让成员函数返回对象自身的引用(Date&),这样d.f().g()就能一路写下去。这个技巧后面讲运算符重载时(比如重载=/+=)会是大规模主力,你现在先理解*this就代表"当前这个对象本身"。
#include <iostream>
using namespace std;
class Counter
{
public:
// 返回对象自身的引用,从而支持链式调用
Counter& Add(int step)
{
_value += step;
return *this; // *this 就是"当前对象本身",把它的引用交出去
}
int Get() { return _value; }
private:
int _value = 0;
};
int main()
{
Counter c;
// 等价于 c.Add(1); c.Add(2); c.Add(3); 只是把一堆调用"串"了起来
c.Add(1).Add(2).Add(3);
cout << c.Get() << endl; // 输出:6
return 0;
}- 用
this防范"自引用/自赋值"。 有些运算符(比如拷贝赋值operator=)在"自己赋值给自己"时会出问题,惯用手法是在函数里用if (&other != this)先判断一下,防止无谓的自我操作。这也是this的经典用途之一。 delete this这个特殊场景:在少数引用计数的设计里,成员函数可以在确定"这是最后一个引用"时delete this;释放自己。这是非常高级的用法,初学者不要模仿,知道存在即可。
const 成员函数这个知识点也强烈建议你现在就"种下"。如果一个成员函数让你"只读、不许改成员",可以在声明末尾加 const,此时 this 的类型从 Date* 变成 const Date*,函数内部任何修改成员的行为都会变成编译错误——这是 C++ 里一种极其常用的"只读契约":
#include <iostream>
using namespace std;
class Date
{
public:
void Set(int y, int m, int d) // 普通成员函数:this 是 Date*
{
_year = y; // 可修改成员
}
// 末尾 const:表示这是"只读"成员函数,this 在这里等价 const Date*
int Year() const
{
// _year = 0; // 编译错误!const 成员函数里不能改成员
return _year; // 只读是允许的
}
private:
int _year;
};
int main()
{
Date d;
d.Set(2025, 1, 1);
cout << d.Year() << endl; // 输出:2025
return 0;
}记住一句话就够:int Year() const 末尾那个 const,本质就是在把 this 的类型从 Date* 改成 const Date*,从而让编译器帮你在"只读函数里误改成员"时及时报错。很多 C++ 开发者为"每个只读的成员函数"都加上 const,这是标准库和现代工程里公认的好习惯——一旦养成了,你会发现编译器的报错清单里从此少了一大类"意外改坏数据"的 bug。
顺便讲清楚一个 C++ 高频考点:this 指针存在哪个内存区域? 答案:通常按调用约定落在栈(或寄存器)上——这一句里的细节上面已经充分展开。它是成员函数的第一个形参,形参是存在运行栈上的(或先用寄存器传参)。它不是存在堆里,也不存在对象里。以后你还会听到"this 是 const Date* 类型"这种话,意思就是这个 const 指针指向调用它的那个对象,且自己不能改指向。
我们再做个稍刁钻的实验,检验你对 this 的理解到底透不透:
#include <iostream>
using namespace std;
class A
{
public:
void Print() // 函数体完全不访问成员变量
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr; // 空指针
p->Print(); // 结果:运行正常,输出 A::Print()
return 0;
}直觉上你可能会觉得"用空指针调用成员函数,肯定崩溃"。但实际运行结果是正常输出,不崩。为什么?因为 Print 的函数体里根本没访问 _a 这个成员变量——它用不到 this 指向的数据,编译器也没有去解引用 this,指令里直接 call A::Print 的地址 就完事了,跟对象 p 是空还是非空没关系。
但只要函数体里访问了成员变量,比如把 Print 改成 cout << _a << endl;(等价于 cout << this->_a),那就要通过空指针 this 去解引用取值,于是真实内存访问一个空地址,程序就崩溃了。这两道题的区别,恰好完美复现了我们前面讲的核心:成员函数不存对象里,而成员变量要靠 this 解引用才拿得到。你能答对,说明 this 的精髓你已经抓住了。
注意,这里有一个容易让人误解的细节:即便函数体没访问成员变量、程序也"跑通了",调用空指针成员函数在 C++ 标准里仍属于未定义行为的灰色地带——标准并不承诺每次都能正常跑。所以实际开发中永远不要依赖这个把戏,它只适合用来加深对 this 机制的理解,绝不该出现在生产代码里当"功能"用。记住一句:能跑通 ≠ 应该这么写。
C++ 与 C 实现 Stack:形变神不变的对比
讲到这里,我们已经攒够了底气,来做一次最有说服力的前后对照:用 C 和 C++ 各实现一遍栈,看看面向对象的"封装"到底改了点什么。为了公平,两者混用 C 和 C++ 的 I/O 都可以,重点是形态对比。
先看 C 版本:
#include <stdio.h>
#include <stdlib.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 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;
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s); // 每次调用都要手动传 &s
STPush(&s, 1);
STPush(&s, 2);
printf("%d\n", STSize(&s)); // 输出:2
return 0;
}再看 C++ 版本:
#include <iostream>
#include <cstdlib>
#include <cassert>
#include <cstdio>
using namespace std;
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;
}
int Size() { return _top; }
private:
STDataType* _a; // 成员变量设成私有,外部不能直接改
size_t _capacity;
size_t _top;
};
int main()
{
Stack s;
s.Init(); // 不用传 &s,this 指针自动代劳
s.Push(1);
s.Push(2);
printf("%d\n", s.Size()); // 输出:2
return 0;
}直观的感受是:C++ 版本形态上发生了不少变化。但底层逻辑和 C 版本几乎没有区别——同样是数组、同样要手动 malloc/realloc、同样要扩容。真正的、也最重要的变化集中在两点:
第一,数据和函数被放进了类里,并通过访问限定符做了限制。在 C 版本里,外部代码可以随手去 s.a[0]、去改 s.capacity,想怎么写就怎么写,没有任何约束;在 C++ 版本里,_a、_capacity、_top 全是 private,外部想碰都碰不到,只能老老实实走 Push、Size 这些公开接口。这就是封装最核心的价值——一种更严格、更规范的管理,从源头上避免了"乱访问、乱修改"导致的 bug。
用一个具体场景感受它的威力:在 C 版本里,如果有人(包括你自己)在别处写了 s.top = 9999;,整个栈立刻坏掉,而编译器绝不提醒你;C++ 版本里 _top 是 private,这种误写根本编译不过,改数据的"唯一入口"被牢牢锁在 Push/Pop 等函数里,栈的正确性由类自己保证。把错误从"运行时爆炸"提前到"编译期拦截"——这是封装最实在的收益。
第二,C++ 提供了一些更顺手的语法糖。比如缺省参数让 Init() 不必每次都指定大小;再比如调用成员函数完全不用像 C 那样手动传 &s,this 指针默默地帮你把对象地址传进去了;还有类型名直接当类型用,摆脱了 C 里 typedef 的繁琐……这些小方便叠加起来,写起来确实舒服不少,但本质上并没有改变 Stack 的底层逻辑。
这里把 C 与 C++ 的差异用一张对比表收个口(后面讲到这些高级话题时你会不断回头对照这张表):
| 维度 | C 语言 | C++ |
|---|---|---|
| 数据的归属 | 结构体只管数据,函数在外面 | 数据和函数一起放进类 |
| 谁能改数据 | 谁都能暴力改 s.top | 只有类内接口能改(private 兜底) |
| 调用方式 | 手动 STPush(&s, x) 传地址 | s.Push(x),this 自动传地址 |
| 类型命名 | 需要 typedef 造别名 | 类名直接当类型名 |
| 初始化的责任 | 调用者必须记得 Init | 最终可用构造函数自动执行(下篇) |
要提醒你的是:现在这个 C++ 栈,"Init + Destroy" 的写法其实还挺原始——因为构造和析构要靠使用者自觉去调,容易忘。等我们下一篇学到默认成员函数(构造函数、析构函数、拷贝构造等),栈的初始化、释放就能让编译器自动完成,那才是 C++ 真正发光的地方,也会解答你此刻心里的疑问:"为什么定义了私有成员还老要手动 Init/Destroy?"
如果我再多说一句格局上的话:你在 C++ 入门阶段手写的这个 Stack,和标准库 STL 里那个 std::stack 背后采用的"适配器(adapter)"思想还差着不少设计功力——std::stack 自己并不持有底层数组,而是"包装"一份别的容器(比如 std::deque)。等你有基础之后再去读 STL 的栈实现,会再次感叹"封装"和"复用"能走到多深。现在先把脚下的第一步踩稳。
默认成员函数:下一篇的预告
沿着刚才的提问,先给你打个预防针:类里面除了我们手写的成员函数,每一个类其实还默认自带几个"看不见"的成员函数,统称默认成员函数——其中最著名的有构造函数(对象创建时自动初始化成员)、析构函数(对象销毁时自动清理资源)、拷贝构造函数(用一个对象拷贝初始化另一个)等。它们的作用是让对象的初始化和清理"自动化",不再需要像现在的 Stack 那样手写 Init 和 Destroy 还得记得调用。
这一篇我们不做展开(那是"类和对象(下)"的主场),但你先在心里挂个钩:现在你写的不带构造函数的类,编译器其实会默默补一个无参默认构造,只是它对普通成员不提供有意义的初始化。正因如此,你现在写 Date d1; 后如果忘了 Init,_year 等成员就是未初始化的垃圾值——这正是下一篇默认成员函数要解决的核心痛点。现在有个印象即可,下一篇会彻底讲透。
讲到这里,我们已经把 C++ 类的第一块地基完完整整地铺好了:从 struct 为什么升级成 class,到 class 的三大件(类名、成员、访问限定符)怎么搭起来;从访问限定符如何落实"封装",到类域如何划定名字的"国界"、:: 怎么指明归属;从"类不强内存、对象才占内存"的设计图哲学,到对象里到底放着什么(成员变量在、成员函数在代码段、只有虚函数才招来一个 vptr);再借内存对齐拆开"char+int 为什么是 8",借空类 1 字节看懂"对象要有实体"的规定;最后一把掀开 this 这只"看不见的手",看清编译器如何把一个隐藏地址传进成员函数,让同一个函数精准操作不同的对象。
现在的你,已经能自信地回答这几个问题了:对象里面到底装了什么?this 指向谁、浮在哪个区?为什么空类占 1 字节?为什么成员函数不占对象空间?为什么把成员设成 private、只留 public 接口值得推荐?这些东西凑在一起,正是 C++ 与 C 最本质的那一步跃迁——"数据自己说了算",封装让代码天生更稳、更不容易写错。
下一次,我们把 this 指针、访问限定符、类域这些已经到手的"石料"搬起来,去构造那两座新房子——构造函数和析构函数。到那时你会惊喜地发现:原来"我到底要不要手写 Init/Destroy"这件事,编译器早就替你想好了大半。面向对象的优雅,才刚刚开始。
还没有评论 — 第一条由你来留。