
如果你刚接触 C 不久准备认认真真把类与对象搞明白那这一篇可以带着你从零走一遍。我在初学阶段最大的感受是函数写熟了结构体也用过但一遇到 class 就卡住——不是看不懂语法而是不知道“为什么非要这样写”。后来在实际项目里写多了才意识到类与对象不是语法层面的花架子而是 C 用来组织真实世界逻辑的核心工具。这篇博文就把类与对象最基础也最关键的内容讲透包括类的定义、对象的创建、访问控制、构造与析构以及最常见的坑所有内容都按实际编码会遇到的顺序来展开。这篇内容适合三类人刚学完 C 语法、准备进入面向对象部分的初学者学过但没写透、想回头补基础的人以及会用结构体但不太理解封装和构造函数意义的读者。放心这一篇不会堆砌术语我会尽量用“生活类比 实际代码 经验提醒”这种方式把类与对象讲得能直接上手用。1. 为什么 C 需要类和对象从“写函数”到“描述事物”很多教学会把类与对象定义为“面向对象编程的核心概念”这句话本身没错但你得先理解它在解决什么问题。在只有函数和结构体的 C 语言时代我们写程序关心的主要是“数据”和“处理数据的函数”比如一个学生信息程序你会先定义struct Student再写void PrintStudent(struct Student s)这样的函数。这种模式在小型项目里完全够用但项目一大问题就出现了数据和操作它们的函数是分离的你很难看出一个数据结构到底有哪些行为。类与对象的本质是把“事物的属性”和“事物的行为”绑定在一起。例如一个学生他有姓名、学号、成绩这些属性也有“修改成绩”和“打印信息”的行为。在 C 里你用类把这些属性和行为写进同一个“模板”然后用这个模板去创建具体的对象。对象就是真实存在的那个学生而类就是描述“学生”这种事物应该长什么样的蓝图。我常用一个类比类就好比“户型设计图”上面标着卧室在哪、客厅在哪、门和窗户的尺寸对象则是按照这张图真正盖出来的房子。你可以用同一张设计图盖出很多套一模一样的房子每套房子住的人不同、家具不同但结构都遵循设计图。C 里的class Student是设计图Student stu1就是盖出来的一个具体对象。设计图本身不占小区土地同样类本身不占用实际对象的内存只有创建对象才会分配内存。这种把属性和行为合并在一起的思路就是我们常说的“封装”的第一步。封装不是故意把代码搞得复杂而是通过类和对象把现实事物的状态与操作归拢到一个结构里让代码的组织方式更接近人类的思维方式。当你代码里到处是age GetAge(ptr)而不是stu.GetAge()时你大概率还在用面向过程的思路写 C需要花点时间把思维模型转换过来。面向过程的思维是“我有一堆数据我要调用函数去处理它们。”面向对象的思维是“我有一个对象它有数据也有行为我可以直接吩咐它做事。”这个转变不需要很玄的理解只需要多写几个真实场景的类比如BankAccount、Date、StudentScore你会慢慢发现代码的可读性和维护成本会明显变化。2. 类与对象的起手式定义、实例化和内存视角2.1 定义类成员变量与成员函数C 中定义一个类基本格式是class StudentScore { public: void SetScore(double s) { score s; } double GetScore() const { return score; } private: double score; };这里public和private是访问限定符后面会详细展开。score是成员变量也叫数据成员SetScore和GetScore是成员函数也叫方法。类体结束之后别忘了分号——这是无数新手甚至老手最容易犯的低级错误。需要注意这里SetScore只能在类的内部访问score因为score被声明在private下面。这其实就是封装的第一层外部代码不能直接修改对象内部的成绩数据必须通过SetScore这个“合规入口”去修改。在SetScore里以后还可以加入校验逻辑比如成绩不能为负数、不能大于 100而外部代码不需要关心这些校验是怎么写的。很多从结构体转过来学类的人会习惯这样写StudentScore stu; stu.score 90; // 错误score 是 private 的外部不能访问这个错误本质上不是语法问题而是封装意识的体现。只要你希望对象自己管理自己的数据就不应该让外部直接改内部的成员变量。在public区域暴露需要给外部用的接口函数内部数据全放private这是 C 类设计里最普遍的约定。2.2 实例化对象栈内存、指针与 this创建对象最直接的方式是“栈对象”StudentScore stu; stu.SetScore(88.5); std::cout stu.GetScore() std::endl;StudentScore stu;会调用构造函数关于构造函数下一节细讲在栈上分配内存生命周期与作用域一致。它和声明一个int x;没有本质区别——简单、安全、自动释放。另一种方式是“堆对象”StudentScore* p new StudentScore(); p-SetScore(90.0); // 用完了要 delete delete p;这里new在堆上分配一块内存并返回指针用完之后必须delete p;否则内存泄漏。p-SetScore(90.0)中那个箭头符号本质上是(*p).SetScore(90.0)的简写。在实际编写类代码时还会频繁碰到一个隐藏参数this。每个成员函数在被调用时都隐含地拿到当前对象的地址这个地址就是this指针。例如SetScore内部完全可以这样写void SetScore(double s) { this-score s; }当然在成员函数内部直接写score s;是等价的this是隐式存在的。不过在出现“参数名和成员变量同名”的情况时this就是救命稻草void SetScore(double score) { this-score score; }这条代码里两个score同名左边的this-score明确表示“属于这个对象的成员变量 score”右边的score是参数。这种命名风格在很多项目里其实是被刻意使用的因为它能显著减少你起名时的纠结也清楚地表达谁是谁。2.3 对象的内存排布从内存视角看一个不含虚函数的对象它的内存布局大体上就是成员变量的顺序排布。成员函数本身不会在对象里占用内存真正存进对象的是成员变量的值。上面StudentScore对象里只有一个double score所以一个对象通常占 8 字节。如果你还定义了一个int id;和一个char name[20];那这个对象就可能是 24 字节或更多中间可能有对齐填充。理解这一点有助于你写代码时判断内存开销但入门阶段不需要深挖内存对齐的每个细节只需要记住对象是真实占用内存的类定义本身不占用内存只有实例化出对象后才分配空间。调试时可以用一个笨办法直观感受把对象的地址打印出来再打印成员变量的地址你会发现它们往往连续排列。这一步做一次你对“对象”这个概念就具体化了不会觉得它是虚无缥缈的东西。3. 访问控制与封装public、private、protected 到底在防谁3.1 三种访问级别的真实意图C 的访问控制有三个等级public公有、protected保护、private私有。很多人把这三者背成口诀“public 所有人都能访问protected 子类能访问private 只有自己能访问。”背是能背但在真实设计类时经常有人纠结到底该用哪个。我的理解方式是这样的public面向外部的接口允许任何人调用。private类自己的内部实现细节外部一律禁止访问。protected介于两者之间专门留给子类使用外部禁止访问。这个在继承一章才会真正用到。封装的核心思想是“不把秘密随便告诉别人”private就是那个保险柜。为什么要这样因为一旦类的内部数据可以被外部随意修改类就没法保证数据的合法性。设想一个BankAccount类如果允许外部直接写account.balance - 10000;那这个逻辑可能会绕过利率、透支检查等规则导致账目混乱。而如果只提供Withdraw(double amount)方法方法内部可以检查余额是否足够、每次取款有没有限额就安全得多。因此访问限定符不是简单的语法限制而是给类的设计者保留控制权。每次写类先问自己哪些操作是给外部用的哪些细节是我要自己掌控的然后把前者放 public后者放 private。别觉得成员函数全放 public 很省事等你在一个大型项目里调试一个被外部改坏的数据时就会后悔当初没加一层保护。3.2 封装的价值接口稳定与重构自由你还可以从“维护成本”的角度理解封装。假设你写了一个Person类用char name[20]存储姓名。后来需求变了要支持长名字于是你把存储方式从 C 风格字符数组改成std::string。如果外部代码直接访问了name这个成员变量那这一改所有使用方的代码都要跟着改但如果外部代码只调用你的GetName()和SetName()那么你内部怎么实现、怎么存储都与外面无关。这就是封装带来的“重构自由”类内部实现可以随便换只要公开接口不变外部世界感知不到变化。这个价值在小项目里不明显但一进入多人协作阶段它就是降低沟通成本、避免连锁修改的命脉。我自己的习惯是从第一个类开始就把所有成员变量写成private外部统一走公开的成员函数。就算有时候偷懒也会先 private 再补 getter/setter。一个类把接口定义清楚之后别人怎么调用它都是有边界、有迹可循的。protected在入门阶段可以不着急用它属于继承专题的内容。初学者只要把握住“能 private 就 private需要给子类扩展再 protected对外统一的操作方法 public”这个原则就够了。4. 构造函数对象从“生下来”到“活起来”4.1 为什么需要构造函数对象不能“生下来”就处于残缺状态。设计一个类通常希望对象一被创建就具备合理的初始状态。C 提供了一种特殊成员函数叫构造函数constructor它的名字与类名相同没有返回类型在对象创建时自动调用。以StudentScore为例class StudentScore { public: StudentScore() { score 0.0; id -1; } StudentScore(int i, double s) { id i; score s; } private: int id; double score; };第一个构造是无参构造默认构造把score初始化为 0.0把id初始化为 -1第二个构造是带参构造创建对象时直接输入学号和分数。这样你写StudentScore a; // 调用默认构造 StudentScore b(1001, 88.5); // 调用带参构造两个对象从创建那一刻起就有了完整的初始状态。如果没有构造函数创建一个对象后成员变量一律是未初始化的“垃圾值”后面读出来的数据完全不可预期这是很多诡异 bug 的源头。构造函数可以重载也就是写多个版本参数不同调用时编译器会根据实参自动选择匹配的那个。这有点像一个餐厅提供多种套餐你点套餐 A 就和吃套餐 A 的流程对上了。构造函数重载是 C 里最常见的重载形式之一后面学运算符重载时思路类似但一个是初始化行为一个是运算行为。4.2 初始化列表构造阶段就把值定好有经验的 C 程序员写构造函数时会优先使用初始化列表initializer list而不是在函数体里一个个赋值。上面的带参构造可以改写为StudentScore(int i, double s) : id(i), score(s) { }冒号后面跟的id(i), score(s)就是初始化列表表示成员变量id用参数i初始化score用参数s初始化。函数体空空的什么也不用做。初始化列表和函数体内赋值有什么区别重要区别在于时机初始化列表在对象的内存构造阶段直接用给定值初始化成员。函数体赋值成员已经被默认构造了之后再重新赋一遍值。对于int、double这种内置类型两者结果上差别不大。但对于没有默认构造函数的类类型成员或者const成员、引用成员就只能用初始化列表。此外即使两者结果一样初始化也通常更高效因为省去了一次“先默认构造再赋值”的过程。初学者最容易踩的坑是初始化列表的顺序不是你在列表里写的顺序而是成员变量在类中声明的顺序。C 标准规定成员初始化次序与声明次序一致。比如class Example { public: Example(int a) : y(a), x(y) {} int x; int y; };这里你看起来是先把y赋成a再把x用y初始化但由于类里先声明的是x所以实际初始化顺序是x(y)先执行。此时y还没有被改成a里面的值是未知的于是x拿到了一个垃圾值。这种 bug 很难一眼发现我的经验是在初始化列表里始终按照类中成员声明的顺序书写不要按自己的喜好换位置。4.3 默认构造函数与常见误区如果类里一个构造函数都没有定义编译器会生成一个隐含的默认构造函数但生成的默认构造对内置类型成员通常不做实质初始化也就是垃圾值。一旦你自己写了任何一个构造函数编译器就不再自动生成默认构造。这时候如果你还写StudentScore a;就会报“没有匹配的构造函数”错误。很多人的解决方式是手写一个默认构造函数占位比如StudentScore() {}。这类空默认构造本身没有错但你得明白它是干什么用的它让对象可以“无参创建”但内部数据不一定被初始化你得自己决定是否在函数体里赋初值。带参构造函数还有一个值得注意的细节explicit关键字。C 允许构造函数在隐式类型转换中“悄悄上场”。比如class StudentScore { public: StudentScore(int i) : id(i) {} private: int id; }; void Print(const StudentScore s) {} Print(42); // 隐式把 42 转换成一个 StudentScore 对象这在某些场景下很方便但在大多数类设计中隐式转换会带来意想不到的调用属于可读性陷阱。所以我的习惯是凡是能接收单个参数的构造函数都加上explicit明确“必须显式写出这个类名才能创建对象”。这是资深 C 项目里很常见的偏好也是新人在写类时最容易忽略的品质之一。5. 析构函数与拷贝构造对象的一生和“复制”的坑5.1 析构函数对象离开时做的事构造函数管“出生”析构函数管“善后”。析构函数的名字是类名前面加一个波浪号~也没有返回值不接受参数。它会在对象生命周期结束时被自动调用。如果你用new创建过对象delete p;时析构函数被调用如果是栈对象作用域结束时析构函数自动执行。析构函数最常见的用途是释放资源new出来的堆内存、打开的文件句柄、网络连接等。以一段简单的堆内存管理为例class IntArray { public: IntArray(int n) : size(n) { data new int[n]; } ~IntArray() { delete[] data; } private: int* data; int size; };构造函数里new了一块动态数组析构函数里delete[] data;回收。这个“构造函数获取资源析构函数释放资源”的模式在小处叫 RAII在大处是现代 C 管理资源最核心的哲学。不需要背高深的词只要你记住对象在生命中获取了什么资源最终析构时就要还回去。析构函数不能重载一个类只能有一个析构函数。还有一点如果程序里存在异常析构函数会自动调用以保证清理这也是很多人写类时不注意但实际很重要的一点。入门阶段可以不过度扩展但你至少要养成在析构函数里“认认真真写清理代码”的习惯而不是依赖系统回收。5.2 拷贝构造与浅拷贝深拷贝对象除了“创建初始化”构造还经常需要“复制”。C 中默认的复制行为是逐成员复制也就是把目标对象每个成员变量的值按位拷到新对象里。这就是“浅拷贝”。对于只有内置类型成员的对象浅拷贝没问题。但像IntArray这种内部有指针指向动态内存的类就麻烦了。默认拷贝构造会把新对象的data指针直接复制成一个“指向同一块内存”的值两个对象的data指向同一块堆内存。析构时两个对象各自调用析构函数同一块内存会被delete两次程序直接崩溃或产生未定义行为。标准解法是自定义拷贝构造函数实现“深拷贝”class IntArray { public: IntArray(const IntArray other) { size other.size; data new int[size]; std::copy(other.data, other.data size, data); } };深拷贝的含义是“新建一块独立内存再逐个把值复制过去”这样两个对象各自拥有自己的资源析构时互不影响。写不写拷贝构造函数是 C 新手和老手在类与对象阶段最明显的差距之一。什么时候你需要自己写拷贝构造总结一句话类里面只要有原始指针管理的资源如 new 出来的东西、动态分配的缓冲区就得自己操心拷贝和释放。这样的类通常还需要自定义赋值运算符拷贝赋值operator否则a b;同样会浅拷贝。赋值运算符的内容越界了属于类与对象2的常见主题但在1里至少要知道浅拷贝在涉及指针成员时是个危险的默认行为。如果你不想写这些复杂逻辑也不用慌。现代 C 中很多资源管理可以交给标准库容器比如std::vector自己会处理拷贝你不用手写new[]/delete[]。写不大不小的项目时能少用裸指针就少用能封装就用标准库封装的类型这是减少崩溃和内存泄漏最快的方法。6. 一个完整的入门案例从需求到类的落地说了这么多原理我们用一个小需求把前面内容串起来。假设要写一个简单的成绩管理模块记录每个学生的学号、姓名和成绩并提供打印功能。我用类与对象来实现先不考虑多态和更高级的容器保持逻辑清晰。定义类#include iostream #include string class StudentRecord { public: StudentRecord(int id, const std::string name, double score) : id_(id), name_(name), score_(score) { } void SetScore(double new_score) { if (new_score 0 || new_score 100) { std::cout score out of range, ignored std::endl; return; } score_ new_score; } void Print() const { std::cout ID: id_ , Name: name_ , Score: score_ std::endl; } private: int id_; std::string name_; double score_; }; int main() { StudentRecord stu1(1001, Alice, 88.5); StudentRecord stu2(1002, Bob, 91.0); stu1.Print(); stu2.Print(); stu1.SetScore(95.5); stu1.Print(); stu1.SetScore(150.0); // 超范围会被拦截 return 0; }注意几个细节构造函数使用初始化列表初始化三个成员这比在函数体里逐个赋值更严谨。SetScore里有校验逻辑这就是封装的实际作用。外部不能直接修改score_只能通过公开方法公开方法保证了数据合法性。Print() const后面的const表示这个成员函数不会修改对象的数据成员。在很多大型 C 代码规范里能够加const的成员函数都建议加上这是给调用者的一份“我保证不改内部数据”的承诺。std::string name_是一个类类型成员它在构造时会自动处理好内存如果换成const char*或裸char[]你要处理的细节就多得多。这再次说明能用标准库容器和类就别自己造轮子。运行这个程序后你会看到两次正常打印、一次校验失败被拦截的输出。这个过程是把类对外接口和内部数据保护结合起来的最小可运行示例建议新手自己敲一遍、改一改、跑一跑。比如你可以试着添加一个GetName()方法或者给StudentRecord增加一个“班级”成员感受一下“往类里加字段、改构造函数、更新打印逻辑”这件事的复杂度这就是类与对象落地后的日常操作。7. 写类时最常见的几个坑和我解决它们的经验最后一部分我把自己从入门到现在积累的、最常遇到的类和对象编码陷阱整理出来。这些坑十有八九是避不开的但提前知道了调试时间可以缩短不少。坑一类定义结尾漏写分号。别笑这是 C 社区里出现频率最高的低级错误之一。类体右花括号后面必须紧跟分号漏了之后编译器会把后面的代码错误地连接在一起报的错往往非常奇怪。我过去排查过一个让人懵圈的编译错误确认来确认去最后发现就是类定义后面少了一个分号。解决的办法是每次写完类定义养成在右花括号后立刻写分号的肌肉记忆。坑二构造函数里成员初始化顺序与声明不一致。前面已经专门提到过这里再强调一遍初始化列表执行顺序是声明顺序不是你写出的顺序。出现变量间互相引用初始值的情况时这个坑极具欺骗性。我的习惯是严格按照声明顺序摆初始化列表根治这个问题。坑三只写了delete没有写delete[]。如果你的构造函数里是new int[n]析构里就必须是delete[] data;而不是delete data;。数组版本和单对象版本不能混用混用属于未定义行为可能当时不崩但迟早出问题。如果你想完全不碰这个坑那就用std::vector代替手写的动态数组。坑四在类的内部直接std::cout输出而不考虑复用。初学者很喜欢把cout ...直接写在业务逻辑里导致类的职责变重。比如上面的StudentRecord::Print()在小型示例里没问题但在真实项目中我通常更倾向让类提供一个返回描述字符串的方法或者让外部自己读取各个字段去格式化这样不同界面命令行、GUI都能复用。并不是说Print()方法不能写而是类应该尽量与特定的输出方式解耦这会让后续扩展容易很多。坑五不加explicit的带参构造导致隐式转换。特别是只有一个参数的那个构造函数编译器会允许它参与常规类型转换这常常不是你想要的。把这类构造函数标记为explicit可以明确表达“别试图用一个整数直接变成一个对象”。不算严格错误但代码可读性和安全性都会有明显提升。坑六忘记在 struct 和 class 之间做选择。C 里的struct和class几乎一样唯一的语法差异是默认访问权限struct默认publicclass默认private。如果你习惯用struct打包多个字段那它其实更接近“只有公开成员变量的数据集合”而class更适合封装的类。选择的关键不是谁的语法高级而是你要表达的是“一个简单的数据载体”还是“一个有行为、有约束的对象”。在同样的代码里混用时这点尤其容易引起混乱。坑七只定义类不花时间设计接口。很多人一上来就写public: void func1(); void func2(); void func3();全部方法一股脑对外暴露。不管理内部状态也没有清晰的“接口”意识。这样的类写多了之后你慢慢会感觉代码越来越拧巴改一处就要连带改多处。类设计经验不丰富时一个比较实用的推进方式是先列出这个对象要对外提供哪些基本能力再考虑每个能力需要什么参数、返回什么结果最后把“外部不该关心”的中间步骤全部放进private。这个方法可以帮助你从“写类语法”过渡到“设计类”。如果你在读上面这些坑时觉得某些点似懂非懂那很正常。类与对象这一块的内容本身是递进的先会写语法再理解封装然后慢慢体会构造函数和资源管理的意义。当你实际写几十个类之后会发现自己对类该暴露多少接口、成员函数要不要加const、能不能用标准库简化资源管理都变得非常自然。我自己在带 A 同学这一类初学者的过程中观察到最容易拉开差距的不是谁记的语法多而是谁能尽早把“类”想成“有生命周期的对象”而不是“一块复杂的结构体语法”来对待。构造是出生析构是善后封装是边界接口是承诺。把这几层想清楚后面学继承、多态、模板的时候地基就会非常稳。这一篇先分享到这里。类和对象还有不少“进阶动作”——拷贝赋值运算符、移动语义、运算符重载、动态多态这些我们放到类的系列后续去讲。但如果你能把今天这篇里的代码、概念和坑都自己过一遍并且亲手写两三个类跑通你对 C 类与对象的第一层理解就可以顺利过关了。