如果你已经写了不少 C 语言,或者刚刚从 Python 切过来,第一次在 C++ 里看到 class 这个关键字,多半会在心里嘀咕一句:这不就是 struct 吗?把几个变量包在一起,然后加点函数进去,好像也没多新鲜。我当初也这么想过,直到真正开始用类写项目,才意识到“类和对象”远不只是语法层面的升级,它直接影响你怎么组织代码、怎么控制数据、怎么让出错的地方少一个数量级。这篇就是针对“C++学习——类和对象(上)”这个主题写的,适合刚学完函数、指针、结构体,准备进入面向对象阶段的人。我会把类的定义、封装、构造与析构、this 指针、内存布局这些核心概念拆开讲,并且把初学阶段最容易翻车的那些细节也一并说清楚。
很多人学类和对象,上来就背“封装、继承、多态”,背完还是一脸懵。其实这三个词背后是很具体的问题:数据怎么藏起来不被乱改?怎么在已有代码上扩展?怎么让同一种操作对不同类型表现出不同行为?这篇先解决第一个问题,也就是封装,以及对象从诞生到销毁的整个生命周期管理。等这一篇吃透了,再去碰继承和多态,你会觉得一切顺理成章。
1. 结构体已经够用,为什么非要引入类和对象?
1.1 先看一段“失控”的 C 语言代码
假设你要管理一个班级的学生信息,在 C 语言里,你多半会定义一个结构体,然后用若干个函数去操作它:
#define MAX_NAME_LEN 32 typedef struct { char name[MAX_NAME_LEN]; int age; double score; } Student; void InitStudent(Student* s, const char* name, int age, double score); void PrintStudent(const Student* s); int GetAge(const Student* s); void UpdateScore(Student* s, double newScore);看起来还好,对吧?但问题跟着就来了。这些函数散落在不同文件里,谁都可以直接访问s->age = -5,你没法阻止某个粗心的调用方把年龄改成负数。随着项目变大,这种“结构体 + 一堆操作它的函数”的写法会越来越失控:参数列表越来越长,函数命名越来越随意,维护的人根本分不清哪些函数是配套的,哪些只是名字碰巧相似。
类要解决的,就是这件事。它把数据和操作这些数据的函数放到同一个“盒子”里,并且明确告诉你:这个盒子里哪些东西是外部可以碰的,哪些是内部私有的,不许乱动。
1.2 类和对象到底是一组什么关系
打个比方。类是“户型图”,对象是“按这张图盖出来的房子”。你可以在一个户型图的基础上盖出 100 套一模一样的房子,每套房子里住的家庭不同、家具摆放不同,但结构完全一致。
class Student { public: void Init(const char* name, int age, double score); void Print() const; int GetAge() const; private: char name[MAX_NAME_LEN]; int age; double score; };这个Student就是一个类,描述了一种数据结构以及对它能做哪些操作。当你写出下面这行代码时,才真正“盖”出了一个对象:
Student s;Student是类型,s是该类型的一个实例。类定义不占内存,对象才占内存。这个区别看起来简单,但很多初学者在计算“类的大小”、理解“类里面函数存哪里”时被绕晕,根源就是没分清类型和实例。
1.3 为什么每个成员函数都要带上“对象状态”
C 语言里,函数和它操作的数据是分离的。你要PrintStudent(&s),必须手动把结构体指针传进去。类则把这种绑定关系写进了语法:s.Print()表面上没传参数,实际上编译器替你把s的地址传进去了,这就是后面要讲的 this 指针。
绑定带来的好处是,你看代码时能立刻知道“这个函数会改哪些数据”。比如const修饰的成员函数,意思就是“调用这个函数不会修改对象内部状态”。这在 C 语言里是没有任何机制保证的。所以说,类不是给你多了一种写结构体的姿势,而是给数据加上了“行为边界”。
2. 类的第一块积木:成员、封装和访问控制
2.1 类的成员到底有哪些
类的成员分两大类:数据成员(也叫成员变量)和函数成员(也叫成员方法)。数据成员负责存储状态,函数成员负责定义行为。写一个最朴素的类:
#include <iostream> #include <string> class BankAccount { public: // 成员函数:公开的接口 void Deposit(double amount) { m_balance += amount; } void Print() const { std::cout << m_id << ": " << m_balance << std::endl; } private: // 数据成员:私有,外部不能直接访问 std::string m_id; double m_balance = 0.0; };注意这里我在类里面直接写函数体,这叫类内定义,适合简单函数。复杂函数一般只放声明,定义放到类外面,用类名::函数名的方式给出。后面我会细说。
2.2 public、protected、private 到底管什么
访问限定符是 C++ 里第一个真正让你感受到“封装”存在的语法。三种限定的行为用一张表就能看明白:
| 访问限定符 | 类内部 | 子类内部 | 类外部 |
|---|---|---|---|
| public | 可访问 | 可访问 | 可访问 |
| protected | 可访问 | 可访问 | 不可访问 |
| private | 可访问 | 不可访问 | 不可访问 |
初学者最容易踩的坑是:protected 和 private 的区别,直到你写继承时才真正体现出来。当前这个阶段,你只需要先记住两条:对外提供的接口用 public,内部数据一律用 private。至于什么时候用 protected,等学到派生类自然会明白。
封装说到底就两个词:隐藏和约束。隐藏是指内部细节别人看不见,约束是指想改内部数据必须走你规定的接口。为什么这样好?举一个简单例子:如果你允许外部直接改m_balance,那就可能出现account.m_balance = -100这种操作。但如果你强制别人调用Deposit(100),你就可以在函数里检查金额是否为正,非法操作直接拒绝。这种防御性编程,在 C 语言里只能靠自觉,在 C++ 里是靠语法强制。
2.3 class 和 struct 的那点区别
很多人第一次发现 struct 也可以有成员函数、可以有 private,都挺惊讶。确实,C++ 的 struct 和 class 几乎没有本质区别,唯一的不同是默认访问权限。
struct Point1 { int x; // 默认 public int y; }; class Point2 { int x; // 默认 private int y; };也就是说,struct Point1 { int x; int y; };里 x、y 都是公开的,而class Point2 { int x; int y; };里 x、y 是私有的。如果你在 class 里忘了写public:,那么后面所有的成员统统是私有的,调用方啥都碰不到。这个细节看着小,实际每个初学者都会遇到至少一次。我建议初期统一用 class,并且把访问限定符写在每个访问区域的最前头,不要依赖默认值。
2.4 成员函数在类外定义时的注意点
当函数体超过三五行,或者类的声明要放进头文件时,通常只在类内部写函数声明,实现放到 .cpp 文件里:
// BankAccount.h class BankAccount { public: void Deposit(double amount); void Print() const; private: std::string m_id; double m_balance = 0.0; }; // BankAccount.cpp #include "BankAccount.h" #include <iostream> void BankAccount::Deposit(double amount) { m_balance += amount; } void BankAccount::Print() const { std::cout << m_id << ": " << m_balance << std::endl; }注意BankAccount::Deposit这种写法,::是作用域解析运算符,意思是“这个函数属于 BankAccount 这个类”。没有它,编译器会把函数当成普通全局函数,访问不了 m_balance。这种错误编译时会直接报错,但报错信息比较绕,新手容易看不懂。提前告诉你,心理有个底。
还有一个细节:如果类内定义的函数体很短,编译器可能直接把它当作内联函数处理,减少函数调用开销。但你不要因此就去背“类内定义等于 inline”这种结论。现代编译器对 inline 的处理很灵活,你只需要把“简单函数留在类内,复杂函数放类外”当成语感来掌握就行。
3. 构造函数和析构函数:对象的生老病死
3.1 构造函数:对象一出生就要有的“初始化”
类里可以定义一种特殊成员函数,名字和类名相同,没有返回值,创建对象时自动调用,这叫构造函数。
class BankAccount { public: BankAccount(const std::string& id, double initialBalance) : m_id(id), m_balance(initialBalance) { if (initialBalance < 0) { m_balance = 0; // 防御:不允许负数余额 } } // ... private: std::string m_id; double m_balance; };构造函数解决的是“对象从哪来”的问题。在 C 语言里,你定义一个结构体变量时,它里面的内容是不确定的,全看栈上残留了什么垃圾数据。在 C++ 里,只要写了构造函数,对象一创建就被强制初始化。想跳过初始化都不行——除非你故意写了一个什么都不做的构造函数,那是另一种坑。
构造函数的调用时机有两个最容易理解的:
- 在栈上定义对象:
BankAccount acc("1001", 5000); - 用 new 在堆上创建:
new BankAccount("1001", 5000);
每次创建对象,编译器都会找到匹配的构造函数去调用。
3.2 初始化列表:不是花架子,有些成员离开它就不行
刚才代码里BankAccount(...) : m_id(id), m_balance(initialBalance) {}这种写法,冒号后面那一串就是初始化列表。很多新手刚见到会问:我在花括号里写m_id = id; m_balance = initialBalance;不也一样吗?
区别在于“初始化”和“赋值”的先后问题。数据成员在进入构造函数函数体之前已经构造完成了,花括号里做的是赋值,是对已经构造好的成员再打一次补丁。对于普通 int、double 来说,结果差不多;但对于 const 成员、引用成员这类“必须初始化、不能赋值”的家伙,你只能在初始化列表里动手:
class Config { public: Config(int id, const std::string& path) : m_id(id), // const 成员,必须在初始化列表里给值 m_path(path), // 引用成员,同理 m_count(0) { // m_id = id; // 编译错误:m_id 是 const,不能赋值 } private: const int m_id; const std::string& m_path; int m_count; };我的建议是:即使成员都是普通类型,也养成用初始化列表的习惯。一是因为能写 const 成员,二是因为它更直接地表达了“构造”的语义:每个成员从一开始就有确定值,而不是先构造出默认值再赋一次值。对 int、double 这种内置类型,性能差异可以忽略,但等你去用复杂类类型作为成员时,差距就出来了。
3.3 缺省构造函数:什么时候可以写下Student s;
如果构造函数有默认参数,或者你定义了一个没有参数的构造函数,那么写Student s;就能成功创建对象。这类构造函数叫缺省构造函数(default constructor)。
class Student { public: Student() : m_age(0), m_score(0.0) {} // 缺省构造 private: std::string m_name; int m_age; double m_score; };C++11 起,你还可以直接用= default让编译器生成一个默认版本:
class Student { public: Student() = default; // ... };这里注意一个容易踩的坑:只要你自己写出了任何一个构造函数,编译器就不会再自动生成缺省构造函数。比如你写了一个Student(const std::string& name),那么Student s;就会编译报错。原因很简单:你已经表明了“这个对象创建时需要传入 name”,编译器不确定你还要不要无参的对象,干脆不给你生成。如果你想保留,就得自己补一个Student() = default;或者写成带默认参数的构造函数。
3.4 析构函数:对象离开时的“收尾手续”
与构造函数相对的是析构函数,名字是~类名,没有参数,也没有返回值。对象生命周期结束时自动调用。
| 对象创建方式 | 析构时机 |
|---|---|
| 栈对象 | 离开所在作用域 |
| new 创建的堆对象 | 手动 delete 时 |
| 全局对象 | main 退出前 |
绝大多数情况,你需要析构函数,是因为对象内部申请了堆内存。比如一个字符串类,它在构造函数里new[]了一块缓冲区,那么析构函数里必须delete[]释放,否则每次创建和销毁这个对象,内存就漏一点。好在这一块在学智能指针之后可以用 C++ 的机制规避,但裸指针版的原理必须懂。
3.5 编译器默认生成的那些成员函数
有一个所有初学者都会问的问题:我没写构造函数、析构函数、拷贝构造函数、赋值运算符,编译器是不是会帮我们生成?答案是会。一个空类,编译器至少会隐式生成:
- 缺省构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
这一整套“默认三件套”。它们的默认行为其实就一句话:简单成员逐个做值拷贝。对于 int、double、指针这类内置类型,就是直接复制字节。这带来一个很致命的问题,我们接下来深挖一下。
3.6 浅拷贝引发的事故:一个指向同一块内存的两个对象
看这个极简的“字符串容器”:
class StringBuffer { public: StringBuffer(const char* str) { m_size = strlen(str); m_data = new char[m_size + 1]; strcpy(m_data, str); } ~StringBuffer() { delete[] m_data; // 释放自己申请的内存 } private: char* m_data; size_t m_size; };现在如果写出这样的代码:
StringBuffer a("hello"); StringBuffer b(a);b 的构造靠的是编译器默认生成的那个拷贝构造函数。默认拷贝构造做的事情,是把 a 的m_data指针值直接复制给 b。结果就是 a 和 b 的m_data指向了同一块堆内存。
这还不是最糟的。等 a 和 b 超出作用域,析构函数会执行两次delete[] m_data,同一块内存被释放两次,程序直接崩溃。这个现象,业内俗称“双重释放”,是浅拷贝的典型问题。
解决办法是自己写出正确的拷贝构造函数,做深拷贝,也就是为 b 另开一块内存,把字符串内容复制过去。关于深拷贝的完整实现,等学到运算符重载和字符串封装时自然会展开。这里先让你建立起一个概念:只要你的类里有指针成员且用 new 申请了资源,就绝对不能依赖编译器隐式生成的拷贝构造函数。
4. this 指针:成员函数是怎么“认人”的
4.1 三个对象调用同一个函数,编译器怎么区分
写一个简单的日期类:
class Date { public: void Set(int year, int month, int day) { m_year = year; m_month = month; m_day = day; } void Print() const { std::cout << m_year << "-" << m_month << "-" << m_day << std::endl; } private: int m_year; int m_month; int m_day; };然后你定义三个对象:
Date d1, d2, d3; d1.Set(2024, 1, 1); d2.Set(2024, 6, 1); d3.Set(2024, 12, 31);Set函数体只有一份,所有对象共用。那编译器怎么知道d1.Set(2024, 1, 1)里的m_year = year改的是 d1 的成员,而不是 d2 的?答案就是 this 指针。
4.2 编译器暗中帮你做的那件事
d1.Set(2024, 1, 1)括号里看着只有三个参数,实际上编译器偷偷改写成类似这样:
Date::Set(&d1, 2024, 1, 1);这里&d1就是 this。在成员函数内部,m_year = year对编译器来说其实是this->m_year = year。this 永远指向“调用这个函数的那个对象”。所以三条语句虽然共享同一个函数代码,但操作的内存区域完全不同,结果自然不同。
this 指针有两个关键特性,建议记牢:
- this 是一个指针常量,你不能让它指向别的对象,也就是不能
this = &xx;但你当然可以修改 this 指向的对象内部数据。 - 在非常量成员函数里,this 的类型是
ClassName*;在常量成员函数里,this 类型变成了const ClassName*,这意味着 const 成员函数内部不能修改任何数据成员。
4.3 什么时候要显式写 this->
如果你已经明白了 this 的存在,你可能会想:既然编译器会自动加,我是不是永远不用管它?大部分情况下确实可以不管,但有两种情况需要你主动写。
第一种,参数名和成员名冲突。比如:
class Person { public: void SetName(const std::string& name) { this->name = name; // 必须显式写 this->,否则 name 指向形参 } private: std::string name; };第二种,对象要调用自己内部的另一个成员函数,如果想要强调“当前对象”,显式写明白更清晰。比如在Set里调用另一个成员函数Reformat时,写this->Reformat()和直接写Reformat()没有区别,但显式写出来,读代码的人立刻知道这是当前对象的方法,而不是某个全局函数。
4.4 const 成员函数为什么能“读”却“不能写”
看一下上面 Date 类里的Print() const,后面那个 const 修饰的是整个函数,表示这个成员函数承诺不会修改对象状态。这个承诺是编译期强制检查的。你在const成员函数里给m_year = 2025;会直接编译报错。
为什么要这个机制?因为它可以让调用方放心地把对象以 const 引用或 const 指针的方式传进函数,同时又不需要复制一份完整对象。比如:
void ShowDate(const Date& d) { d.Print(); // 编译通过,因为 Print 是 const 成员函数 }如果Print没加 const,上面这行就无法编译,因为用 const 引用指向了一个“可能被修改”的对象,编译器不放心。理解这个细节之后,你自己写类时也会慢慢形成习惯:只读的操作一律标const,需要改状态的操作不标。这不是装模作样,而是把“哪些函数不改状态”直接写进类型系统里。
5. 站在内存里看类:实例化、大小与对齐
5.1 对象能活在哪些地方
对象和普通变量一样,根据创建方式不同,活在内存里不同的区域。你可以画一张简单的图来理解:
- 全局定义的对象,活在全局区(静态区),整个程序运行期间都存在。
- 函数内部直接定义的对象,活在当前调用栈上,函数退出就销毁。
- 用 new 创建的对象,活在堆上,只能靠 delete 手动释放,或者交给智能指针管理。
很多刚学 C++ 的人觉得“栈区”“堆区”这些概念虚无缥缈,其实当你写出BankAccount acc("1001", 5000);就知道它一定在栈上,而auto* p = new BankAccount(...);一定在堆上。判断对象活了多久,看它在哪里,比看函数逻辑更直接。
5.2 sizeof 一个类,到底算的是谁的大小
这是一个经典面试题,也是理解对象模型的关键。看这段代码:
class Empty {}; class HasInt { public: void Set(int v) { m_val = v; } int Get() const { return m_val; } private: int m_val; };sizeof(Empty)是 1,sizeof(HasInt)一般是 4。为什么空类是 1?因为编译器需要为每个对象分配独一无二的地址,如果大小是 0,那么数组里两个连续的对象地址会相同,没法区分。所以编译器给空类硬性塞了 1 个字节作为占位。
更重要的一个结论:成员函数不占对象内存。HasInt 里那两个函数的地址存在代码区,对象本身只保存数据成员。这一点和 C 语言的 struct 其实一脉相承。计算类大小时,你把所有非静态数据成员的大小加起来,然后做内存对齐,函数成员一概不算。静态数据成员也一概不算,因为它属于类,不属于某个具体对象。
5.3 内存对齐:为什么类的大小不按成员大小简单相加
看这个类:
class Example { char c; int i; };char是 1 字节,int是 4 字节,直觉相加结果是 5。但你用sizeof(Example)去测,通常会得到 8。
原因在于编译器要让成员按自然边界对齐。int 通常要落在 4 的倍数地址上,c 占了第 0 个字节,i 就不能从第 1 个字节开始,只好跳到第 4 个字节,于是中间空出了 3 个字节。
| 成员 | 大小 | 起始偏移 |
|---|---|---|
| char c | 1 | 0 |
| 填充字节 | 3 | 1-3 |
| int i | 4 | 4 |
这 3 个填充字节就是对齐的代价。想要减少填充浪费,最简单的经验是:类里成员按从大到小排列。虽然这不是万灵药,但在大多数平台上能显著减少浪费。如果你之后做通信协议、内存映射文件,这个点的价值会放得非常大。
5.4 类的内存布局不是语言标准强制的
最后补充一个很多课件不会提的点:类的内存布局,比如成员在对象里按什么顺序排列,C++ 标准的约束是“按声明顺序前面的在前面”,但具体填几个填充字节、如何对齐,由编译器实现决定。不同编译器、不同优化选项下,结果可能有出入。所以写跨平台代码时,不要手动去计算一个类对象在内存里精确是多少字节再来,更不要假设两个成员之间没有填充。真要精确控制布局,未来你会学到alignas、#pragma pack这些工具,到时候再操心不迟。
6. 初学类和对象最容易翻车的六个细节
6.1 类定义的末尾少了一个分号
class Student { // ... } // 报错:缺少 ';'没有分号,编译器会以为你在接着写别的,报错信息五花八门,经常让你一头雾水。这个错误的报错位置往往不指向真正出错的地方,而是指向文件最后。我见过有人排查了很久,最后发现就是类结束的大括号后面少了一个分号。唯一有效的办法是把它当成肌肉记忆:每次写完类定义,立刻在}后补上;。
6.2 默认访问权限带来的“私有化”
class默认的访问权限是 private,这意味着:
class Student { std::string m_name; public: void Print() const; };这个m_name是私有的,调用方不能通过s.m_name访问。如果你没写public:,那么连Print也变私有,外部根本不能调用成员函数。很多新人写了一个“没问题”的类,结果 main 里面一个函数都调不了,就是因为这个原因。建议在类定义里把 private、public 分段写得清清楚楚,不要依赖默认。
6.3 类外定义成员函数时漏写了类名::
void Student::Print() const { ... } // 正确 void Print() const { ... } // 错误,这不是成员函数漏写Student::的后果是,你定义了一个全局函数,里面想访问私有成员 m_name,编译器立刻报错,而且报错信息会说 “m_name is a private member”。这时候如果你以为是访问权限问题,去把 private 改成 public,方向就完全错了。先检查一下函数定义处有没有写类名::。
6.4 初始化列表顺序陷阱:声明的顺序才是真正的初始化顺序
class Demo { public: Demo(int n) : m_b(n), m_a(m_b) {} private: int m_a; int m_b; };表面上看,初始化列表里先写 m_b,再写 m_a,你会以为 m_b 先被赋值。但标准规定:成员的初始化顺序由它们在类中的声明顺序决定,和初始化列表的书写顺序无关。于是上面这个例子,实际执行顺序是m_a(m_b)先,而此刻 m_b 还没被初始化,m_a 拿到的是一个未定义值。这是一类非常隐蔽的 bug,编译不报错,运行结果还偶尔时对时错。解决方案只有一个:让初始化列表的顺序和成员声明顺序保持一致。
6.5 对象数组碰上“没有缺省构造函数”的类
class Student { public: Student(const std::string& name) : m_name(name) {} private: std::string m_name; }; Student arr[10]; // 编译错误因为数组里的每个元素都要被构造,而 Student 只有一个带参构造函数,编译器不知道拿什么参数去填这 10 个元素。解决方式有两种:要么给 Student 增加一个缺省构造函数,要么在数组里用显式初始化:
Student arr[3] = { Student("a"), Student("b"), Student("c") };理解了这一点,以后看到class Foo里有好几个构造函数时,也能快速判断哪些场景会出问题。
6.6 想禁掉拷贝却不知道怎么禁
某些类的对象不应被拷贝。比如文件句柄类,如果你允许拷贝,复制出来的对象可能持有同一个句柄,析构时就会发生“双重释放”。C++11 之前,这一步很麻烦,要么把拷贝构造函数私有化,要么把赋值运算符私有化。C++11 之后有了一行终极方案:
class FileHandle { public: FileHandle(const FileHandle&) = delete; FileHandle& operator=(const FileHandle&) = delete; };被标记为 deleted 的函数,只要有人在代码里试图调用它,编译直接报错。这是把一个“运行时才会爆的雷”提前到编译期,就算你现在用不到,也要知道有这么个工具。
我个人学这一段时,感受最深的一点是:类和对象最重要的不是那些新语法,而是它逼着你换一种思维方式。以前写 C 代码,我眼里只有“数据”和“函数”;写了几百行类以后,我开始习惯性地问自己:这段数据归谁管?谁允许修改它?它什么时候出生、什么时候销毁?想清楚这些,代码的清晰度和稳定性都会上一个台阶。学习这块不要急,先把封装、构造、析构、this 这些基础打得越扎实越好。下一阶段你会碰到继承、多态、静态成员和友元,与其急着往前赶,不如把这一篇里的代码亲手敲一遍,再故意把成员设成 private 试试编译报错,把有 const 和没有 const 的成员函数各调一次,观察编译器分别有什么反应。多踩几次坑,你对类的理解会远比背十遍概念更深刻。