资讯动态

C++类与对象深度解析:从Stack案例看封装、this指针与构造析构

发布时间:2026/10/21 17:39:47 来源:尧图企业网站定制
1. 从结构体到类先聊明白为什么要封装1.1 为什么Stack是所有教材都绕不开的案例很多从C语言转过来的朋友第一次看C的类定义都会有一个困惑这不就是结构体里多放几个函数吗我一开始学的时候也有这种感觉直到后来用Stack这个数据结构把两种写法完整对比了一遍才真正明白类到底在解决什么问题。Stack栈这个数据结构足够简单一块连续内存、一个栈顶下标、一个容量最多再加一个扩容逻辑。难吗一点都不难一个下午就能写出来。可恰恰是这种简单让数据与操作分离的毛病暴露得特别清楚。你想一想如果用C语言实现数组指针、栈顶下标、容量这三个东西是分开管理的它们之间的约束关系全靠你脑子里记着。比如扩容的时候你记得重新分配数组却忘了把新容量写回去程序不会立刻报错而是等到下一次Push把栈撑爆了才出问题。这种排查起来极其痛苦因为错误发生的地方和真正翻车的地方隔了十万八千里。而Stack的生命周期也足够典型——创建时要分配堆内存使用时要Push、Pop结束时要释放内存。这刚好覆盖了对象从出生到死亡的全过程。所以用Stack做例子讲类和对象成本最低收益却最高。后面你学模板、学STL接触到的容器本质上也在处理同样的问题只是包装得更好。1.2 struct与class差别从来不只是默认访问权限C里保留了struct关键字完全是为了向后兼容C。单看语法struct和class唯一的区别就是默认访问权限struct默认publicclass默认private。你可以验证一下struct S { int value; // public }; class C { int value; // private };S s; s.value 10;能编译通过而C c; c.value 10;会报错说value是private的。这就是那个著名的struct和class只差一个public。但实际工程中这个差别远没有使用习惯重要。大多数团队的约定是struct用来定义纯数据聚合比如坐标点、配置项、一系列的返回值class用来定义有行为的数据结构比如Stack、队列、管理器。你可以给struct写成员函数语法合法但没必要。当你看一个头文件时struct传递的信息是这里就是些数据你自己随便读写而class传递的信息是这玩意儿有内部状态你得通过我的接口来操作。这种心照不宣的约定比语言本身强制的内容要重要得多。我还见过一个更隐蔽的坑有人把class当成struct用所有成员都堆在public下面。这样一来_top可以被外部随便改成-100_data可以被赋成一个野指针Stack内部的一切约束瞬间失效。之前C语言版本里那些手动记得传指针的烦恼确实没了但新的烦恼来了——你精心设计的逻辑外部的任何一段代码都可以破坏。访问限定符不是装饰品它划定了使用者和实现者的边界使用者不需要知道你内部怎么存数据只需要知道调用哪个接口实现者则必须在private里保证内部状态永远合法。2. 类的定义与实例化从图纸到活的对象2.1 一份典型的类定义成员变量与成员函数先给出一份完整的类定义后面所有的讨论都会基于它。注意我把成员函数声明放在类内定义放在类外这是工程里比较常见的组织方式class Stack { public: void Init(int capacity 4); void Push(int x); bool Empty() const; void Destroy(); private: int* _data; int _top; int _capacity; }; void Stack::Init(int capacity) { _data new int[capacity]; _top 0; _capacity capacity; } void Stack::Push(int x) { if (_top _capacity) { // 扩容逻辑后面再补 } _data[_top] x; } bool Stack::Empty() const { return _top 0; } void Stack::Destroy() { delete[] _data; _data nullptr; _top _capacity 0; }先说明一个基本概念类里的变量叫成员变量函数叫成员函数访问限定符public表示对外公开private表示内部私有。上面的类公开给外部的是Init、Push、Empty、Destroy这四个接口内部持有的是_data、_top、_capacity三个状态。外部代码只能通过接口操作Stack不能直接摸到底层的数组指针。有两个细节值得展开。第一Stack::这个写法叫类外定义成员函数。它的意思是这个函数属于Stack这个类型。为什么要这么写而不是把函数全塞进类体内因为成员函数如果定义在类内编译器会默认把它当成inline函数处理。小函数比如Empty放类内没问题但Push里如果有几十行的扩容逻辑每次包含这个头文件的源文件都会编译一遍这个函数拖慢编译速度。所以工程上的习惯是两三行的简单函数放类内复杂函数放类外头文件里只留声明。这一点很多人一开始不会注意等项目大了就知道编译时间有多宝贵。第二bool Empty() const;里面这个const的作用。它修饰的是成员函数意思是这个函数不修改对象的内容。编译器会检查你在这个函数里有没有对成员变量做写操作写了就报错。对一个只读查询函数来说加const是给使用者的一个承诺你调用Empty不会改变栈的内容。这玩意儿在传参时非常有用后面讲到const引用和重载时会占很大篇幅现在先埋个种子。2.2 实例化的三种形态栈对象、堆对象、对象数组类的定义本身不占内存它只是一张图纸。真正占内存的是对象——按照类这张图纸创建出来的具体实例。创建对象的过程叫实例化在C里最常见的写法是这三种Stack st1; // 栈上的对象 st1.Init(); // 初始化 Stack st2(10); // st2.Init(10) 的另一种写法留给构造函数那一节展开 Stack* st3 new Stack; // 堆上的对象需要delete Stack arr[3]; // 对象数组每个元素都是一个独立对象注意区分Stack st1;是在栈上创建了一个Stack对象它的生命周期跟随作用域函数结束时自动销毁new Stack是在堆上创建对象返回的是指向它的指针这个对象不会自动销毁必须手动delete。两者在内存分布上一个在栈区、一个在堆区但对象这个概念本身是统一的只是你持有它的方式不同。还有个常见问题sizeof(Stack)是多少按我上面那个类的成员变量来算在64位环境下int*是8字节两个int是8字节所以sizeof(Stack)等于16字节也可能因对齐规则略有出入但一般就是16。注意成员函数不占对象的空间。对象的内存里只有成员变量不装函数。函数代码在编译之后放在代码段所有对象共享那一份。这就引出一个关键推论Stack对象的内存里并没有一个字段装我属于哪个类——没有虚函数的时候对象就是普普通通的一块内存函数地址在编译期就已经确定。这也解释了为什么用malloc分配出来的类对象不能直接调用成员函数Stack* p (Stack*)malloc(sizeof(Stack)); // 只分配了内存 p-Init(); // 致命错误_data是野指针malloc只负责给你一片内存不会调用构造函数不会帮你把_data置空。而C里new做的事是分配内存 调用构造函数两步malloc只做了第一步。所以在C里管理对象老老实实用new/delete别拿malloc凑合除非你非常清楚自己在干什么。2.3 多个对象之间数据各自独立代码共享一份理解了上面的内存模型再看对象之间的关系就很简单了。创建两个Stack对象Stack st1; Stack st2; st1.Init(4); st2.Init(16);st1和st2各自拥有一份独立的内存st1的_data指向一块4个int大小的堆空间st2的_data指向一块16个int大小的堆空间各自的_top都是0。你对st1做Push操作完全不影响到st2。这就是对象数据层面的独立性。但它们的成员函数是同一份代码。st1.Push(5)和st2.Push(5)在底层调用的其实是同一个函数地址。那同一个函数怎么知道这次该操作st1还是st2呢靠的就是类里面那个隐藏的指针——this。这正是下一节的主角。3. this指针编译器悄悄塞进来的隐形第一参数3.1 成员函数怎么知道在操作谁this的由来先想一个问题上面的Push函数体里只有_data[_top] x;这里用到了_data和_top。可编译器怎么知道_data是st1的还是st2的答案很简单成员函数里所有对成员变量的访问本质上都被编译器改写成了 this-_data、this-_top。这个this就是调用这个成员函数的那个对象的地址。你用 st1.Push(5)this就等于st1你用 st2.Push(5)this就等于st2。所以Push函数的逻辑等价于void Stack::Push(int x) { this-_data[this-_top] x; }只不过C语法把这些this-给省略了写起来更干净。this是成员函数内部可以直接使用的一个指针它指向当前对象。如果你想拿到当前对象本身用*this注意这是一个引用到对象的表达式不是指针。这个设计其实非常朴素既然所有对象共用一份函数代码那调用函数时就必须额外告诉函数你这次要操作哪个对象。C的选择是把它做成一个隐式的参数由编译器在背后传递不需要你手动写在参数列表里。3.2 从调用约定看this本质就是普通函数的第一个参数不知道你有没有看过C成员函数反汇编出来的样子。在x86-64平台的System V调用约定下st.Push(5)编译出来大致等价于lea rdi, [st] ; 第一个参数对象的地址 - rdi mov esi, 5 ; 第二个参数数字5 - esi call Stack::Push ; 调用成员函数看到了吗对象的地址被放进了rdi寄存器这就是普通函数第一个参数的位置。换句话说成员函数在机器码层面就是一个第一个参数为对象指针的普通函数Stack::Push(Stack* this, int x)。C只是把这个过程藏在了语法后面让你写代码时不用每次手动传st同时还能保证这个参数一定是合法类型不会像C语言那样传错指针类型。理解这一点对后面很多问题都有帮助。比如成员函数为什么不能在没有对象的情况下调用——因为连this都没有函数里的成员变量访问全都会变成对野地址的读写。再比如为什么说C对象模型是基于类的函数共享基于对象的数据隔离一句话就讲完了。3.3 this指针的三个经典坑坑一空指针调用成员函数不一定立刻崩溃。这是很多人迷惑的地方。看这个例子class Printer { public: void Print() { printf(hello\n); } }; Printer* p nullptr; p-Print(); // 在很多编译器上居然能正常输出hello原因就是上面说的Print函数内部没有访问任何成员变量也就是说函数体里从头到尾没有解引用this。编译器生成的代码只是把nullptr作为第一个参数传给了Print然后Print打印完就返回了。整个过程没有对this指向的内存做任何读写所以不崩。但注意这是未定义行为标准不保证它一定不崩。你只要在Print里加一句访问成员变量比如count_马上就会因为对空地址解引用而崩溃。所以别依赖这种侥幸能跑的行为写出这种代码本身就是bug。坑二返回*this实现链式调用。如果成员函数想要支持连续调用比如obj.add(1).add(2).add(3)就需要在函数里返回 *this。这种写法在赋值运算符重载、流式配置、建造者模式里非常常见class Counter { public: Counter Add(int n) { count_ n; return *this; // 返回对象本身的引用 } private: int count_ 0; }; Counter c; c.Add(3).Add(4).Add(5); // 每次Add都返回同一个c的引用注意这里返回的是*this的引用不是指针。如果返回指针就变成c.Add(3)-Add(4)代码风格会很奇怪。如果用值返回又会拷贝对象浪费性能还可能在多态场景下丢失行为。常规做法是返回Type。坑三区分this和*this。this是一个Stack* const类型的指针指向当前对象*this是对象本身类型是Stack。两者经常混着用但在返回值场景下必须搞清楚你需要调用者拿到的是一份拷贝、一个引用还是一个指针。搞错了就是编译错误或者意外的性能开销。4. C语言实现Stack数据与操作分离的真实代价4.1 C版本完整代码结构体全局函数理论讲了半天现在来看具体的代码对比。先上C语言版本的Stack这是很多教科书里都会出现的经典写法typedef struct Stack { int* data; int top; int capacity; } Stack; void StackInit(Stack* ps, int initCapacity) { ps-data (int*)malloc(sizeof(int) * initCapacity); ps-top 0; ps-capacity initCapacity; } void StackPush(Stack* ps, int x) { if (ps-top ps-capacity) { int newCapacity ps-capacity * 2; int* tmp (int*)realloc(ps-data, sizeof(int) * newCapacity); if (tmp NULL) { return; // 扩容失败避免原指针丢失 } ps-data tmp; ps-capacity newCapacity; } ps-data[ps-top] x; } int StackTop(Stack* ps) { return ps-data[ps-top - 1]; } void StackDestroy(Stack* ps) { free(ps-data); ps-data NULL; ps-top 0; ps-capacity 0; }这段代码逻辑本身没错甚至算得上规范——每个函数都接收Stack*指针都做了解引用。但你看它的使用方式问题立刻就浮现出来了int main() { Stack st; StackInit(st, 4); // 1. 手动初始化 StackPush(st, 10); // 2. 每次都手动传st // ... StackDestroy(st); // 3. 手动释放 return 0; }三个步骤三个手动每一条都是可以省略的约定而编译器对这些约定一无所知。4.2 数据与函数分离的代价所有约束都是自觉把上面的C代码在真实项目里用上一段时间你会遇到下面这些状况忘了调用StackInit。结构体st里的data、top、capacity全是随机的野值。然后你还能通过编译因为从C语言的角度看这只不过是一个未被初始化的局部变量而已。运行到StackPush时要么写进一个被篡改过的野地址要么capacity可能是负数触发realloc。结果就是段错误或者更糟——内存被静默破坏过了好几个函数才发现不对劲。忘了调用StackDestroy。这倒不会立刻崩溃只是每次这个Stack使用完那堆内存就再也找不回来了。一次两次无所谓要是在一个循环里反复创建销毁内存就像水龙头没关紧一样漏。C语言编译器不会因为你没free而警告一切靠自觉。可以随意绕过封装。结构体的字段全是public的任何代码都可以直接写st.top -1;或者st.data NULL;。别人调用了StackPush但内存已经被你从外面改了到底是Stack实现的问题还是调用方的问题查起来能吵一宿。传指针靠自觉。所有的函数都要求传Stack* ps但你完全可以传一个NULL进去然后在函数开头加判断如果每个函数都加代码越来越啰嗦如果不加崩了都不知道在哪。C语言里这类约定多如牛毛全靠写代码的人记得。一旦项目规模上来参与的人一多有人不守约定就不再是极端情况而是常态。我印象很深的一次排查某位同学我们叫他A同学在一个模拟项目中用C写了Stack某天出现了程序运行一会儿后栈数据莫名损坏。我让他把用到的代码全部列出来发现他Push之前没有调用StackInit——top是个随机大数Push时ps-data[ps-top] x直接写飞到堆外。他在那里查了整整两天。如果你也在做类似的排查先想想是不是所有初始化动作都真的执行了这是C风格代码最常见的翻车点。5. C实现Stack把约定变成语法5.1 C版本完整代码构造、析构、成员函数下面用C完整实现一遍同样的功能。为了对比公平我保留了Init、Destroy这些接口但你会发现有了构造和析构之后它们根本不会被外部主动调用class Stack { public: // 构造函数对象创建时自动调用完成初始化 Stack(int capacity 4) : _data(new int[capacity]), _top(0), _capacity(capacity) {} // 析构函数对象销毁时自动调用完成资源释放 ~Stack() { delete[] _data; _data nullptr; _top 0; _capacity 0; } void Push(int x) { if (_top _capacity) { Expand(); } _data[_top] x; } int Top() const { return _data[_top - 1]; } bool Empty() const { return _top 0; } private: void Expand() { int newCapacity _capacity * 2; int* tmp new int[newCapacity]; for (int i 0; i _top; i) { tmp[i] _data[i]; } delete[] _data; _data tmp; _capacity newCapacity; } int* _data; int _top; int _capacity; };注意这段代码里的几个关键点Stack(int capacity 4)是构造函数名字和类名相同没有返回值。它前面的:后面是初始化列表用来给成员变量赋初值。构造函数的好处是对象一创建就把它初始化好。~Stack()是析构函数同样没有返回值负责回收对象占用的资源。这里把new[]申请来的堆内存用delete[]释放。Expand()被放进了private外部不能随便触发扩容只能通过Push间接触发。Top和Empty被标记为const成员函数表示这两个操作不会修改对象内容。然后使用它int main() { Stack st; // 自动调用构造函数capacity默认4 st.Push(10); st.Push(20); int topValue st.Top(); // 20 return 0; } // st在离开作用域时自动调用析构函数和C版本对比最直观的变化是没有手动Init没有手动Destroy没有手动传st。但这不仅是少打几个字而是底层机制完全变了。5.2 构造函数和析构函数如何消灭手动失误C语言版本的Stack从定义结构体到释放内存中间的路全靠程序员自己走。而C版本的Stack把这几步都绑在了对象的生命周期上构造开车点火。构造函数在对象创建时自动执行。你写Stack st;编译器就已经保证构造函数会被调用没有偷懒的机会。A同学那个忘调Init的问题在C里根本不存在——除非你故意不写构造函数让类退化成裸结构体。析构停车熄火。析构函数在对象销毁时自动执行。对于栈上的对象无论你是正常return还是异常跳转只要是离开了作用域析构函数一定会被调用。这是语言层面保证的不是靠程序员remember。后面学到RAII资源获取即初始化的时候你就明白构造函数和析构函数这套机制是C管理资源的核心。文件句柄、锁、网络连接都可以封装进一个类的构造函数和析构函数里让资源的生命周期和对象的生命周期完全绑定想泄漏都难。有人可能会质疑那我就是写一个Init然后在main里调用效果不也一样吗不一样。Init只是你提供的一个普通函数调用不调用全看调用者的心情构造函数是编译器强制执行的。这就像红绿灯和请自觉过马路看车的区别——前者是规则后者是提醒。5.3 一张表看清C与C的Stack差异为了避免看起来差不多的错觉我列一个对比表你可以直接存着看维度C语言版本C类版本初始化手动调用StackInit漏调只有运行时才知道构造函数自动执行对象创建即初始化完成释放手动调用StackDestroy漏调造成内存泄漏析构函数自动执行栈对象作用域结束即释放操作方式每个函数都要传Stack* ps指针成员函数自动携带this无需手动传对象地址访问控制结构体字段全公开外部可随意修改top/dataprivate字段外部不可见只能通过公开接口操作编译器参与几乎为零全靠程序员记性编译器强制执行构造/析构访问级别有硬约束扩容细节靠realloc失败时需自己处理封装在private的Expand中外部无感知我见过一个说法C和C实现同一个Stack代码量差不多所以类只是语法糖。这个结论我不同意。代码量确实差不多差的不是量是错误发生的概率和排查的路径。C版本忘初始化、忘释放、误改内部字段每一个都是运行时才爆雷而且报错的位置往往离真正的原因很远。C版本初始化、释放、字段访问全被语言机制接管了你可以把精力集中在业务逻辑上而不是跟野指针搏斗。6. 很多人学类和对象时踩过的认知坑6.1 类不是结构体函数这么简单如果你理解了上面的对比应该能明白类的本质是把数据和对数据的操作绑定在一起并且用访问限定符划清边界。封装的意义不是把代码放在一起好看而是让类的使用者和实现者之间有一个明确的契约。使用者的视角是我只需要Push、Pop、Top、Empty这四个接口底层是数组还是链表扩容用realloc还是new跟我没关系。实现者的视角是我把内部状态保护在private里只要保证所有公开接口的行为正确内部怎么改都不会影响外部代码。这就是抽象。这种思维方式和你写C时的函数结构体是有本质区别的。C里你总要想下次调用这个函数我要记得传哪个指针C里你更多在想这个对象的职责是什么外部需要它提供什么能力。刚开始转变时确实会别扭但这是值得的习惯。6.2 新手常见的几个认知误区我梳理了几个特别典型的误区你可以对照一下有没有踩过误区一把类当结构体用成员全部public。那和结构体没有区别封装成了空壳。至少要把需要保护的状态放private。误区二觉得this指针是多余的。你用不到this只是因为语法帮你省略了所有this-。凡是访问成员变量本质上都经过了this你躲不开它。误区三所有成员函数都写在类内。短小函数没问题复杂函数会让编译时间变长而且每次读头文件都要翻过一大段函数体。学会类外定义Class::func()。误区四以为new出来的对象会在某个时刻被自动回收。C没有自动垃圾回收堆上的对象不用delete析构函数永远等不到调用。栈对象自动析构堆对象必须手动delete。误区五为了面向对象而疯狂封装。一个只有两个字段的Point也要搞出一堆getter/setter这是过度设计。封装的价值在于保护有约束的状态和复杂的生命周期纯数据对象用struct就好。我在实际带人过程中有个深刻的体会这个阶段最好的练习就是同时用C和C各写一遍Stack然后故意犯几个错误——比如C版本不初始化C版本忘记delete堆对象。把错误的行为和错误的结果对应起来比背一百遍概念都有用。等你哪天写代码时下意识地思考这个对象的生命周期应该怎么管理这些字段应不应该被外部看到就算真正入门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑