如果你刚接触 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默认public,class默认private。如果你习惯用struct打包多个字段,那它其实更接近“只有公开成员变量的数据集合”,而class更适合封装的类。选择的关键不是谁的语法高级,而是你要表达的是“一个简单的数据载体”还是“一个有行为、有约束的对象”。在同样的代码里混用时,这点尤其容易引起混乱。
坑七:只定义类,不花时间设计接口。很多人一上来就写public: void func1(); void func2(); void func3();,全部方法一股脑对外暴露。不管理内部状态,也没有清晰的“接口”意识。这样的类写多了之后,你慢慢会感觉代码越来越拧巴,改一处就要连带改多处。类设计经验不丰富时,一个比较实用的推进方式是:先列出这个对象要对外提供哪些基本能力,再考虑每个能力需要什么参数、返回什么结果,最后把“外部不该关心”的中间步骤全部放进private。这个方法可以帮助你从“写类语法”过渡到“设计类”。
如果你在读上面这些坑时,觉得某些点似懂非懂,那很正常。类与对象这一块的内容本身是递进的:先会写语法,再理解封装,然后慢慢体会构造函数和资源管理的意义。当你实际写几十个类之后,会发现自己对类该暴露多少接口、成员函数要不要加const、能不能用标准库简化资源管理,都变得非常自然。
我自己在带 A 同学这一类初学者的过程中观察到,最容易拉开差距的不是谁记的语法多,而是谁能尽早把“类”想成“有生命周期的对象”而不是“一块复杂的结构体语法”来对待。构造是出生,析构是善后,封装是边界,接口是承诺。把这几层想清楚,后面学继承、多态、模板的时候,地基就会非常稳。
这一篇先分享到这里。类和对象还有不少“进阶动作”——拷贝赋值运算符、移动语义、运算符重载、动态多态,这些我们放到类的系列后续去讲。但如果你能把今天这篇里的代码、概念和坑都自己过一遍,并且亲手写两三个类跑通,你对 C++ 类与对象的第一层理解就可以顺利过关了。