C++类与对象核心知识详解:从封装到多态
带过不少刚接触C的朋友我发现大部分人在“类与对象”这一节真正遇到的障碍不是语法记不住而是思维方式还没有从C语言里转过来。写了两三年C代码觉得结构体配函数已经挺顺手为什么非要搞一套public、private、构造函数出来这一节的内容说白了就是把“数据”和“操作数据的代码”绑成一个整体让程序从“一个个函数顺序执行”变成“若干对象相互协作”。类就是用来定义这种整体形态的模板对象则是照着模板生成的具体实例。如果你是零基础或者前面刚学完数组、指针、函数这些内容这篇正好帮你把这几块知识串起来是C入门系列里承上启下、必须花时间啃下的一篇。1. 从结构体到类C语言思维到C思维的第一次转弯1.1 数据加函数为什么非要用“类”来包一层先回想一下C语言里我们是怎么处理一组相关数据的。定义一个结构体Student里面放姓名、学号、成绩然后写一堆函数InitStudent、PrintStudent、SortStudent函数参数都要带一个Student指针。看起来没毛病但实际维护的时候问题就来了这些函数之间没有任何强制约束谁都能绕过InitStudent直接给成绩赋值万一某处忘判空、忘初始化排查起来非常吃力。类的思路是反过来的把数据成员和相关操作放在同一个作用域内外部代码不能随便碰内部字段只能通过约定的接口来操作。这不是换个写法而是从“依赖所有人自觉遵守约定”变成“编译器帮你强制约束”。从代码组织上看C语言的核心单位是函数而C在函数之外增加了一个核心单位——类。类是用户自定义的类型它允许你把数据放在里面也允许把操作数据的函数放在里面。这种把数据和操作绑在一起的做法就是“封装”的起点。1.2 类与对象图纸和实体很多教材喜欢说“类就是对象”这句话不够准确容易让人误以为写一个类就完事了。更准确的理解是类是一张图纸对象是照着图纸盖出来的房子。图纸可以反复使用盖出来的每间房子都是独立的、实实在在占着一块内存的实体。代码里也是这样类的定义本身不分配存储空间除了静态成员后面会讲只有在创建对象时系统才会为对象分配内存。这里有个很容易混淆的点你写了一个class Student但程序中如果没有创建任何Student对象那么Student类在运行时不占用数据内存它只是一个编译期存在的类型。到了创建对象那一行内存才真正划出来。理解这一点对后面理解对象大小、this指针、静态成员都有帮助。1.3 写一个最简的类看看成员函数的调用过程下面写一个最基础的类不是要演示复杂功能而是确认一个最核心的机制类的成员函数如何访问成员变量。#include iostream #include string class Student { public: void setName(const std::string name) { m_name name; } void print() { std::cout m_name std::endl; } private: std::string m_name; }; int main() { Student s; s.setName(Alice); s.print(); return 0; }你可能会问print函数里写m_name它怎么知道要打印的是对象s的那份m_name还是另一个对象t的那份这里的关键是每个成员函数在调用时都隐含了一个参数——就是调用该函数的对象地址。编译器的做法是把上面main函数中的s.print()改写成类似print(s)的形式函数内部再把m_name当作“this指针指向对象的成员”来访问。这个机制在C里非常重要内存里的真相是成员函数并不存储在每个对象中所有对象共享同一份函数代码靠调用时传入的对象地址来区分操作的是谁。这个隐藏指针就是我们常说的this指针后面第4部分还会详细展开。2. 访问控制与封装private不是限制你而是保护你2.1 三个访问级别到底怎么选类里面的成员可以用三种访问修饰符来限定public、private、protected。它们的作用范围用一句话概括public成员处处可见private成员只有类内部可以访问protected介于两者之间主要用于继承场景。很多新手拿到一个类最省事的方式就是全部写成public代码也能跑但这种方式越到项目复杂期越容易出问题。选访问级别的核心逻辑不是看“哪个能跑”而是问自己外部代码真的需要直接碰这个成员吗比如学生类的成绩如果直接把成绩字段设为public那么任何地方都能写s.score 100没人能阻止它被写成一个非法值。如果你通过setScore函数来赋值就可以在函数里检查范围、记录日志、甚至在成绩变更时同步更新其他字段。接口是一种可控的入口而直接暴露字段等于把内部实现细节全部摊开。平常自己写练习代码无所谓多人协作时这种差别会被迅速放大。2.2 封装的实际收益改内部实现时外面不用跟着改封装最大的价值不是“防止别人乱改数据”而是让你可以放心地改变类的内部实现而外部调用方完全不感知。假设你要把一个用数组存储学生信息的类改成用动态容器来存储如果所有外部代码都只通过getStudentCount、getStudentByIndex这样的接口访问数据那么改动只局限在类的内部外部代码几乎不需要动。反过来如果外部代码到处直接操作数组下标改动就会蔓延到几十上百处改一个内部数据结构变成一次全项目重构。从这个角度看private到底是限制谁限制的是外部代码对内部细节产生依赖。外部依赖越少你未来调整的空间越大。很多公司代码规范里强制要求成员变量统一放到private默认对外只暴露必要的接口就是为了让类在后续维护中保持弹性和稳定。2.3 接口最小化不是每个函数都要公开封装还有一层实践准则接口尽可能少而清晰。这个“少”不是吝啬而是说每个公开函数都应该有明确的职责和不可拆分的语义。以日期类为例你可能需要一个addDays函数、一个getWeekday函数但没必要暴露“内部天数累加算法”的辅助函数。如果公开接口内部的实现细节等于邀请外部代码和你的内部实现绑定在一起。我自己在实际维护里有一个很深的体会类一旦对外发布接口就是承诺。即使是内部使用者对“某个看起来很有用的函数”产生依赖后你想在后续版本里调整参数、改返回值类型、移除废弃接口都会遇到很大阻力。所以入门阶段就养成习惯默认私有把需要公开的接口一个个想清楚是对未来自己的一种照顾。3. 构造函数、析构函数与初始化列表管理好对象的生老病死3.1 构造函数对象一出生就得保证它是可用的结构体变量在C语言里完全可以不初始化就使用后果由程序员负责。C的类引入了构造函数正是为了把“初始化”这件事固定下来。构造函数是一种特殊的成员函数名字与类名相同没有返回值在对象创建时自动调用。你不需要手动调用构造函数也不能手动调用它只要创建对象编译器就会保证构造函数被执行。写构造函数时最需要注意一点它并不仅仅是给成员变量赋初值而是要让对象在创建完成后进入一个“可用的状态”。什么是可用状态比如一个网络连接对象构造函数里如果没有完成握手外界就调用sendData连数据发不出去一个文件对象如果构造函数没打开文件后面所有读写操作都会失败。构造函数的职责是建立完整的前提条件。如果你写类的时候没有定义任何构造函数编译器会生成一个默认构造函数。但只要你定义了任何一个带参数的构造函数默认构造函数就不会自动生成了这时候想写Student s;就会编译失败。这点很多教材讲过但实际写代码时踩到的人依然很多。3.2 使用初始化列表而不只是在函数体里赋值构造函数有两个为成员赋初值的地方一个是初始化列表位于参数列表后的冒号之后另一个是函数体。很多初学者会问这两者有什么差别差别非常大。这里的核心机制是所有成员变量在进入构造函数函数体之前已经构造完成初始化列表是在成员构造的初始阶段给初值而函数体里的赋值是在成员已经构造完成后再次赋值。两者相差一次构造加一次赋值操作对于像std::string、std::vector这样的类型代价尤其明显。看一个例子class Student { public: Student(const std::string name, int age) : m_name(name), m_age(age) // 初始化列表 { // 函数体里不再执行成员初始化 } private: std::string m_name; int m_age; };初始化列表的另一个重要理由是有些成员只能在初始化时设定初值不能先构造再赋值。典型的就是const成员和引用成员它们一旦创建就不允许改绑定因此必须在初始化列表里完成。如果你在函数体里写m_ref something编译器会直接报错。所以我现在写构造函数的基本习惯是所有成员优先在初始化列表里初始化函数体只处理复杂逻辑。3.3 析构函数与RAII资源释放不能靠“记得”有生就有死。析构函数在对象生命周期结束时自动调用用于释放对象占用的资源。最常见的资源是堆内存、文件句柄、网络连接等。析构函数的名字是类名前加“~”没有参数也没有返回值因为析构时不需要额外信息。很多新手对析构函数的理解停留在“和构造函数对应用来清理”但这低估了它的价值。C社区有一个非常核心的设计理念叫RAIIResource Acquisition Is Initialization直译是“资源获取就是初始化”但实际含义是把资源的生命周期和对象的生命周期绑定在一起资源在构造函数中获得在析构函数中释放。这样做的巨大好处是不管程序是正常退出、提前return还是异常抛出导致栈展开只要对象被销毁它的析构函数就一定会被执行资源就一定会被释放。你必须“记得”释放资源而RAII是让机制去兜底不必“记得”。反过来如果只在某个函数体里手动写了new和手动delete中间任何一个return语句或异常都可能让delete永远执行不到这就会造成内存泄漏或其他资源泄漏。养成在析构函数里收拾资源的好习惯是C从入门走向工程实践的关键一步。3.4 拷贝与移动浅拷贝是新手第一个内存事故编译器会为你生成拷贝构造函数和赋值函数。这是好事也是坑。默认的拷贝行为是“逐成员复制”学名叫浅拷贝。假如你的类里有一个指针成员并且构造函数里用new分配了堆内存那么默认浅拷贝会让两个对象的指针成员指向同一块内存。结果就是两个对象析构时同一块内存会被释放两次程序崩溃。解决办法是自定义拷贝构造函数和赋值运算符让它们根据指针内容分配新的内存这叫深拷贝。如果再进一步要把对象转移到另一个变量而不是复制C11起还可以定义移动构造函数和移动赋值函数转移资源的持有权而不是拷贝多一份。心有这个概念后面学左值、右值、std::move时就不会觉得突兀。这里只是提醒只要类里有指针成员、文件句柄、锁这类“非普通”资源就必须考虑深拷贝或者移动语义不能默认那一套浅拷贝能蒙混过关。4. this指针与静态成员每个对象各有一份所有对象共享一份4.1 成员函数不占对象空间this帮你找到是哪块内存前面提到过成员函数代码是所有对象共享的对象内存里只存放成员变量。那编译器是怎么让s.print()打印出s的m_name而t.print()打印出t的m_name呢答案就是this指针。系统在调用对象成员函数时会自动把对象的地址传进去相当于成员函数多了一个隐藏参数。函数里访问成员变量m_name实际上是在访问this-m_name。在绝大多数情况下你不需要显式写出this但有两个场景一定会用到。一是成员函数的参数名和成员变量同名时比如void setName(const std::string name) { // 左侧是成员变量右侧是参数 this-name name; }二是需要返回当前对象自身的时候比如链式调用Logger info(const std::string msg) { // ... return *this; }这样就能写出log.info(start).info(end)这样的链式代码。this指针的本质就是一个const指针它保存在对象的内存布局之外由编译器在调用时传参因此不占用对象本身的空间。4.2 静态成员变量属于类不属于任何一个对象对象里每个成员变量都是各放一份的两个Student对象各自拥有自己的m_name。但静态成员变量是反过来的它属于类本身整个程序里只有一份所有对象共享这一份。比如要统计这个类一共创建了多少个对象就得用静态成员class Student { public: Student() { s_count; } ~Student() { --s_count; } static int count() { return s_count; } private: static int s_count; // 声明 }; // 在某个.cpp文件中定义一次 int Student::s_count 0;注意这里有一个很隐蔽的坑静态成员变量在类内只是声明使用前必须在某个编译单元中进行定义并初始化。这是链接器的要求初学者经常漏掉这一句class外面的定义导致链接错误。C17之后如果静态成员变量被声明为inline或者constexpr就可以直接在类内部给出初值算是省掉了一点麻烦。4.3 静态成员函数只能碰静态成员静态成员函数也是类级别的它不依赖具体对象因此没有this指针。这意味着它只能直接访问静态成员变量和其他静态成员函数不能访问普通成员变量。用static int count() { return s_count; }这个函数来获取对象总数就非常合适即便一个对象都没创建也能调用因为count()不需要对象地址。静态成员函数在实际项目中常被用来提供工厂接口或全局配置读取入口。很多人纠结“静态成员函数和全局函数有什么区别”区别就在于它仍然位于类的命名空间内能访问类的静态私有成员而全局函数不可以。所以当你需要某个函数和某个类的内部状态强相关但又不需要对象实例时优先考虑设计成静态成员函数。5. 继承与多态入门前最容易理解偏的两件事5.1 继承要表达的是“is-a”关系不要为了复用而继承类与对象讲到最后绕不开继承。继承的语法不难难点在于判断“什么时候该用继承”。一句重要的判断标准是只有当你能够说清“派生类是一种基类”的时候继承才成立。比如一个Student类继承Person类是合理的因为学生是一种人一个Car类继承Vehicle类是合理的因为汽车是一种交通工具。但如果只是“有些代码可以重复利用”就把类A继承类B往往会把程序的结构搞乱让外层代码不得不理解原本不相干的层次关系。代码复用还有替代方案比如组合也就是在类里放一个其他类的成员对象。能说清“整体包含部分”的时候优先考虑组合。Car类里放一个Engine对象比Car继承Engine要自然得多。这个原则业界概括为“优先使用对象组合而不是类继承”。入门阶段记住一句话继承是建立类型关系不是抄代码的工具。5.2 虚函数与多态同一句话不同行为继承本身带来的价值有限真正让C面向对象大放异彩的是多态。实现多态的关键是在基类函数前加上virtual关键字把它声明为虚函数。当通过基类指针或引用调用虚函数时程序会依据对象的实际类型去调用正确版本的函数。看一段经典示例class Shape { public: virtual void draw() const { std::cout Drawing a shape std::endl; } }; class Circle : public Shape { public: void draw() const override { std::cout Drawing a circle std::endl; } }; class Square : public Shape { public: void draw() const override { std::cout Drawing a square std::endl; } }; void render(const Shape s) { s.draw(); } int main() { Circle c; Square q; render(c); // 输出 Drawing a circle render(q); // 输出 Drawing a square return 0; }这里的render函数只认Shape引用但传入不同对象时调用的函数完全不同。这背后的机制是编译器为含虚函数的类生成一张虚拟表vtable每个对象里多一个指向虚表的指针vptr函数调用时先查虚表再跳转到实际的函数地址。理解到这一层即可细节在后面的进阶课中会展开。5.3 构造函数不能是虚函数析构函数却建议是虚的有一个常被新人问倒的点为什么构造函数不能声明为virtual因为虚函数机制依赖虚表指针而虚表指针本身是在构造函数执行期间设置好的如果构造函数是虚函数程序在初始化对象之前根本拿不到正确的虚表指针这样设计在逻辑上就站不住脚。另一个更容易踩的坑是析构函数。如果一个类作为基类被继承并且你有可能通过基类指针去delete派生类对象那么基类析构函数最好声明为virtual。否则编译器只调用基类析构函数派生类的析构函数永远不会执行这会导致派生类持有的资源得不到释放。比如一个基类Base、一个派生类DerivedDerived里new了一块内存你用Base* p new Derived();然后delete p如果Base析构不是virtualDestructor末端的清理代码就没有机会运行。规则很直白只要有继承关系而且会通过基类指针释放对象基类析构函数一定要写virtual。6. 类与对象实操中的常见错法我亲眼见过的几类问题6.1 头文件重复定义与链接错误初学类时很多人习惯把类的完整定义写进头文件然后多个源文件都包含这个头文件。如果头文件没有加好头文件保护宏编译器会把类的定义展开到多个编译单元中等链接时发现同一符号出现了多份定义就会报我们常见的重复定义错误。这个问题的标准解是在头文件最开头加上include guard现在主流的IDE在新建头文件时通常会自带但手写代码依然要注意。工作多年后回头看这个报错信息看似唬人绝大多数原因就是头文件保护缺失或者静态成员变量忘了在某个源文件中定义一次。6.2 有指针成员的类没写拷贝构造双重释放这个坑前面已经提到也是实战中出现频率极高的一类。举一个最简单的链表结点类作为例子Node里面有一个int* value成员在构造函数里new出来了析构函数里delete掉。如果有一天别人把Node A拷贝给Node B编译器默认浅拷贝让A.value和B.value指向同一块堆内存。程序结束时A先析构delete了那块内存B再析构时又去delete同一块内存此时内存已经释放行为未定义多数情况下直接程序崩溃。解决办法是在Node里显式定义深拷贝的拷贝构造函数和赋值运算符或者用智能指针持有资源彻底避开手工delete。注意这类问题在单文件小程序里不一定复现但在稍大规模代码里几乎必然爆发。6.3 用“”直接比较两个对象新手很容易写出这样的代码if (student1 student2) 判断两个学生对象是否相等。但类不像整型变量编译器默认并不知道你定义的“相等”是什么。如果不提供operator运算符重载这种写法甚至不能通过编译。实际情况是每一个有实际语义的类都需要自己定义比较规则是比较所有字段还是比较学号一个字段甚至一个且只能有一个。所以当你的程序需要比较两个同类型的对象时请明确为类提供operator或者写一个专门的equals函数而不是期望编译器替你猜意图。{/* 内容已足够这是一个完整主体的收尾 */}最后说点自己的体会。很多人学C时最容易犯的战略性错误就是把类与对象当成“一个语法块”来记背关键字、背访问控制符号、背构造函数写法然后用的时候却不知道为什么要这样设计。类与对象解决的其实是一件事让程序更符合人的思维方式。数据和方法封装在一起权限可以控制资源能自动释放行为可以通过继承扩展这才是它真正的价值。如果你在练习中写了一个类却不理解构造函数为什么比赋值效率高、析构函数为什么是虚函数、浅拷贝为什么会崩那就说明还没吃透这一节建议回到代码里逐个验证。带着这些机制去写下一段代码你会明显感觉到C不是语法复杂而是每一个复杂的背后都有足够充分的理由。