C++类通过封装将数据与操作结合,用访问权限控制数据修改规则,解决数据边界问题。对象生命周期涵盖构造、析构、拷贝及资源管理,涉及成员变量私有化、构造函数与Init函数区别、析构必要性及拷贝构造与赋值重载的资源管理机制。
在复习 C++ 类和对象时,许多初学者最初只记住了一个粗略的说法:类就是将变量和函数放到一起。这个说法不算错误,但仅适合入门阶段。

长期稳定更新的攒劲资源: >>>点此立即查看<<<
后续学习构造、析构、拷贝等内容时,才发现类和对象的难点不仅仅在于 class 怎么写,而是对象在整个生命周期中到底发生了什么。例如:
private 中?Init 函数之间有什么区别?static、友元等语法分别解决什么问题?如果只用 C 语言风格编写代码,通常会将数据和函数分开。
例如编写一个学生信息:
struct Student {
char name[32];
int age;
int score;
};
void PrintStudent(const Student* stu);
void SetScore(Student* stu, int score);
这种写法可以使用,但数据边界几乎完全依赖调用者的自觉性,外部代码可以直接修改结构体中的字段。
Student stu; stu.age = -10; stu.score = 999;
从语法角度看没有问题,但从业务含义来看明显不合理。年龄不应该是负数,分数也不应该随意越界。
C++ 的类将数据和操作整合在一起,并通过访问权限控制“谁能修改、如何修改”。理解类时,可以先抓住这一点:
类不是单纯将变量和函数包裹起来,而是给数据加上使用规则。
这是封装最基础的意义。
先通过一个小类来看封装的写法:
#include#include class Student { public: void SetInfo(const std::string& name, int age, int score) { name_ = name; age_ = age; SetScore(score); } void SetScore(int score) { if (score < 0) { score_ = 0; } else if (score > 100) { score_ = 100; } else { score_ = score; } } void Print() const { std::cout << name_ << ", age: " << age_ << ", score: " << score_ << 'n'; } private: std::string name_; int age_ = 0; int score_ = 0; };
这段代码中有三个访问限定符需要先分清:
| 访问限定符 | 类外能否直接访问 | 作用 |
|---|---|---|
public | 可以 | 对外提供的接口 |
protected | 不可以 | 主要留给派生类使用 |
private | 不可以 | 类自己的实现细节 |
Student 的成员变量放在 private 中,外部代码不能直接写入:
// stu.score_ = 999; // 编译不通过
外部只能通过 SetScore 修改分数,而 SetScore 内部可以实现范围控制。封装不是单纯将成员隐藏起来,而是将修改数据的入口收回来,让对象按照设计好的规则运行。
还有一个容易忽略的细节:class 默认访问权限是 private,struct 默认访问权限是 public。在 C++ 中,struct 也可以编写成员函数,只是习惯上更常用 class 来表达具有封装规则的类型。
类本身只是一个类型,对象才是这个类型创建出来的具体变量。
int main() {
Student s1;
Student s2;
s1.SetInfo("Li Hua", 18, 92);
s2.SetInfo("Wang Ming", 19, 105);
s1.Print();
s2.Print();
}
s1 和 s2 都是 Student 对象,它们各自保存一份成员变量。s1 的分数和 s2 的分数不会混淆。
成员函数不需要每个对象都保存一份。对象中主要保存成员变量,成员函数是一份公共代码。那么成员函数如何知道自己当前操作的是 s1 还是 s2 呢?
关键就在于隐藏的 this 指针。
例如我们编写:
s1.SetScore(80);
可以粗略理解为:编译器将当前对象的地址传了进去。
SetScore(&s1, 80);
真实语法并非如此编写,但这样理解比较顺畅。成员函数内部访问成员变量时,本质上是通过 this 找到当前对象。
void SetScore(int score) {
this->score_ = score;
}
大多数情况下 this-> 可以省略。只有在形参名和成员变量名冲突,或者需要返回当前对象本身时,this 才会比较常用。
初学者容易误以为:类中包含成员函数,对象大小也应该将函数计算在内。
实际情况并非如此。在不考虑虚函数等机制时,对象大小主要与成员变量相关,成员函数不会放入每个对象中。
#includeclass Empty {}; class Example { private: char c_; int i_; }; int main() { std::cout << sizeof(Empty) << 'n'; std::cout << sizeof(Example) << 'n'; }
空类对象的大小通常是 1。因为对象需要有唯一地址,如果大小为 0,多个对象的地址就难以区分。
Example 的大小也不一定是 1 + 4 = 5,因为还要考虑内存对齐。可以先这样理解:编译器为了让 CPU 更便捷地访问数据,会在成员之间补充一些空字节。
刚开始不需要死记所有对齐计算细节,先记住两个结论就足够:
学习到类和对象中间部分时,会遇到“默认成员函数”这个说法。
它指的是:当不编写时,编译器可能自动生成的成员函数。基础阶段先重点看以下几个:
| 默认成员函数 | 作用 |
|---|---|
| 默认构造函数 | 不传实参时初始化对象 |
| 析构函数 | 清理对象管理的资源 |
| 拷贝构造函数 | 用已有对象创建新对象 |
| 赋值运算符重载 | 已存在对象之间赋值 |
| 取地址运算符重载 | 对对象取地址 |
| const 取地址运算符重载 | 对 const 对象取地址 |
这里先按基础阶段常见的六个默认成员函数来理解,不建议只背诵数量,更重要的是问一句:
编译器默认生成的行为,对这个类来说是否足够?
如果类中只有普通成员变量,默认生成的函数在很多情况下是够用的。但如果类自己管理资源,例如申请堆空间、打开文件、管理锁,就需要谨慎对待。默认拷贝可能只是浅拷贝,默认析构也不一定能完成资源释放。
后面几个小节,主要围绕构造、析构、拷贝构造和赋值重载展开。
在没有构造函数时,我们可能会编写一个 Init 函数:
Student s;
s.SetInfo("Li Hua", 18, 92);
问题在于,如果忘记调用 SetInfo,对象就可能处于一个没有正确初始化的状态。
构造函数正是用来处理这件事的:对象创建时自动调用,用于完成初始化。
class Student {
public:
Student(const std::string& name, int age, int score)
: name_(name), age_(age) {
SetScore(score);
}
void SetScore(int score) {
if (score < 0) {
score_ = 0;
} else if (score > 100) {
score_ = 100;
} else {
score_ = score;
}
}
void Print() const {
std::cout << name_ << ", age: " << age_
<< ", score: " << score_ << 'n';
}
private:
std::string name_;
int age_;
int score_;
};
使用时直接写成:
Student s("Li Hua", 18, 92);
s.Print();
构造函数的几个基本特点:
void 也不写。默认构造函数也需要单独分清。只要“不传实参就能调用”的构造函数,都可以称为默认构造函数。
class Point {
public:
Point() : x_(0), y_(0) {}
Point(int x, int y) : x_(x), y_(y) {}
private:
int x_;
int y_;
};
这里的 Point() 是默认构造函数。
如果写成全缺省参数,也属于不传参就能调用:
class Point {
public:
Point(int x = 0, int y = 0) : x_(x), y_(y) {}
private:
int x_;
int y_;
};
注意不要同时编写一个无参构造和一个全缺省构造,否则 Point p; 调用哪个函数会产生歧义。
构造函数负责初始化,析构函数负责对象销毁前的清理。
如果一个类只保存 int、double、std::string 这类成员,通常不需要自己编写析构函数。编译器生成的默认析构就足够使用。
但如果类中自己申请了资源,例如手动 new[] 了一块空间,就必须考虑释放问题。
#include#include class IntBuffer { public: explicit IntBuffer(std::size_t size) : data_(new int[size]{}), size_(size) {} ~IntBuffer() { delete[] data_; } private: int* data_; std::size_t size_; };
析构函数的特点:
~类名。这里最需要分清的是:析构函数不是用来销毁对象本身的内存,而是用来清理对象内部管理的资源。
局部对象在函数结束时,它所在的栈帧会被释放;析构函数真正负责的是将对象内部申请的堆空间、文件句柄、锁等资源处理干净。
拷贝构造函数处理的是“用一个同类型对象初始化另一个新对象”。
Student s1("Li Hua", 18, 92);
Student s2(s1);
Student s3 = s1;
s2(s1) 和 s3 = s1 都是在创建新对象,因此调用的是拷贝构造。
如果类中没有自己管理资源,编译器默认生成的拷贝构造通常足够使用。例如 Student 中的 std::string 自己知道如何拷贝。
但 IntBuffer 这种类不能直接依赖默认拷贝构造。
IntBuffer b1(10); IntBuffer b2 = b1; // 如果使用默认拷贝,会有问题
默认拷贝只会将指针值复制一份,也就是两个对象的 data_ 指向同一块堆空间。当两个对象析构时,就可能释放同一块空间两次。
这就是浅拷贝容易出现问题的地方。
更合适的做法是自己编写深拷贝:不仅复制指针变量,还要重新申请一块空间,将内容复制过去。
class IntBuffer {
public:
explicit IntBuffer(std::size_t size)
: data_(new int[size]{}), size_(size) {}
IntBuffer(const IntBuffer& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
~IntBuffer() {
delete[] data_;
}
private:
int* data_;
std::size_t size_;
};
判断是否需要编写拷贝构造时,可以先看这一点:
如果这个类自己管理资源,就要检查默认拷贝是否只拷贝了资源句柄。
如果只是普通成员变量,不一定需要自己编写;如果有裸指针管理堆空间,就要认真考虑自己实现。
拷贝构造和赋值看起来很像,但场景不同。
IntBuffer b1(10); IntBuffer b2 = b1; // 拷贝构造:创建 b2 IntBuffer b3(5); b3 = b1; // 赋值:b3 已经存在
赋值运算符重载通常可以这样编写:
class IntBuffer {
public:
explicit IntBuffer(std::size_t size)
: data_(new int[size]{}), size_(size) {}
IntBuffer(const IntBuffer& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
IntBuffer& operator=(const IntBuffer& other) {
if (this == &other) {
return *this;
}
int* newData = new int[other.size_];
std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
return *this;
}
~IntBuffer() {
delete[] data_;
}
private:
int* data_;
std::size_t size_;
};
这段代码中有几个细节值得注意:
operator= 返回 IntBuffer&,是为了支持连续赋值,例如 a = b = c。a = a 时将自己的资源先释放掉。这也引出一个常见判断:如果一个类需要自己编写析构函数释放资源,通常也要认真检查拷贝构造和赋值运算符。
这三个函数经常需要放在一起考虑:
| 函数 | 什么时候调用 | 主要任务 |
|---|---|---|
| 析构函数 | 对象生命周期结束 | 清理资源 |
| 拷贝构造 | 用旧对象创建新对象 | 初始化新对象 |
| 赋值重载 | 已存在对象之间赋值 | 替换当前对象内容 |
运算符重载不是为了令语法看起来花哨,而是为了让类对象的使用方式更符合直觉。
例如二维点 Point,判断两个点是否相等,使用 == 通常比编写 IsEqual 更自然。
#includeclass Point { public: Point(int x = 0, int y = 0) : x_(x), y_(y) {} bool operator==(const Point& other) const { return x_ == other.x_ && y_ == other.y_; } Point operator+(const Point& other) const { return Point(x_ + other.x_, y_ + other.y_); } void Print() const { std::cout << "(" << x_ << ", " << y_ << ")n"; } private: int x_; int y_; };
使用时:
Point p1(1, 2);
Point p2(3, 4);
Point p3 = p1 + p2;
if (p1 == p2) {
std::cout << "samen";
}
p3.Print();
运算符重载有几条边界:
operator@。例如 Point + Point 比较自然,但给 Student 重载 + 就不一定有意义。
++ 有一个容易混淆的地方:前置 ++ 和后置 ++ 都叫 operator++,C++ 使用一个 int 形参来区分后置版本。
class Counter {
public:
Counter(int value = 0) : value_(value) {}
Counter& operator++() {
++value_;
return *this;
}
Counter operator++(int) {
Counter old(*this);
++value_;
return old;
}
private:
int value_;
};
前置 ++ 返回自增后的自身,后置 ++ 返回自增前的临时副本。这个行为需要与内置类型保持一致。
如果希望这样输出对象:
std::cout << p1 << 'n';
通常会将 operator<< 写成全局函数,而不是成员函数。
#includeclass Point { public: Point(int x = 0, int y = 0) : x_(x), y_(y) {} int X() const { return x_; } int Y() const { return y_; } private: int x_; int y_; }; std::ostream& operator<<(std::ostream& out, const Point& p) { out << "(" << p.X() << ", " << p.Y() << ")"; return out; }
原因在于 << 左边是 std::cout,右边才是 Point 对象。如果写成成员函数,左操作数默认会变成当前对象,也就不符合 std::cout << p 这种使用习惯。
这里使用 X()、Y() 这样的公开接口来读取私有成员。另一种做法是将 operator<< 声明为友元函数,后面会讲到。
成员函数后面加上 const,表示这个函数不会修改当前对象。
class Point {
public:
Point(int x = 0, int y = 0)
: x_(x), y_(y) {}
int X() const {
return x_;
}
int Y() const {
return y_;
}
private:
int x_;
int y_;
};
const 实际修饰的是隐藏的 this 指针。普通成员函数中,this 可以理解为:
Point* const this
const 成员函数中,this 可以理解为:
const Point* const this
也就是说,不能通过这个 this 修改成员变量。
这点在编写只读接口时很重要。例如 Print()、Size()、Empty()、X() 这类函数,只要不修改对象,就应该考虑加上 const。这样 const 对象也能调用它们。
刚学构造函数时,很多人容易将初始化列表看成“另一种赋值写法”:
Point(int x, int y)
: x_(x), y_(y) {}
后续再看引用成员、const 成员和类类型成员时,才发现初始化列表不是装饰,它表示成员变量真正被初始化的位置。
有些成员必须在初始化列表中初始化,例如:
const 成员变量。看一个例子:
#includeclass FileName { public: explicit FileName(const std::string& name) : name_(name) {} private: std::string name_; }; class LogRecord { public: LogRecord(int id, int& level, const std::string& file) : id_(id), level_(level), file_(file) {} private: const int id_; int& level_; FileName file_; };
id_ 是 const,level_ 是引用,file_ 的类型 FileName 没有默认构造函数,所以它们都必须在初始化列表中处理。
还有一个细节:成员变量的初始化顺序不是看初始化列表中写的顺序,而是看它们在类中声明的顺序。
class Demo {
public:
Demo(int x) : b_(x), a_(b_) {}
private:
int a_;
int b_;
};
这段代码看起来先初始化 b_,再初始化 a_,但实际上 a_ 声明在前,会先初始化。这样的代码很容易埋下隐患。
所以编写初始化列表时,最好让顺序和成员声明顺序保持一致。
单参数构造函数可能触发隐式类型转换。
class Id {
public:
Id(int value) : value_(value) {}
private:
int value_;
};
void PrintId(Id id) {}
int main() {
PrintId(10); // int 被隐式转换成 Id
}
有时这种转换很方便,但很多时候会让代码不够明确。加上 explicit 后,就必须显式构造对象。
class Id {
public:
explicit Id(int value) : value_(value) {}
private:
int value_;
};
void PrintId(Id id) {}
int main() {
// PrintId(10); // 不允许隐式转换
PrintId(Id(10)); // 明确创建 Id 对象
}
可以将 explicit 理解为一个提醒:这个类型不是随便从 int 变过来的,调用者需要明确表达自己的意图。
普通成员变量是每个对象一份,而 static 成员变量是所有对象共享一份。
例如可以用它统计当前创建了多少个对象:
#includeclass ObjectCounter { public: ObjectCounter() { ++count_; } ObjectCounter(const ObjectCounter&) { ++count_; } ~ObjectCounter() { --count_; } static int Count() { return count_; } private: static int count_; }; int ObjectCounter::count_ = 0; int main() { std::cout << ObjectCounter::Count() << 'n'; ObjectCounter a; ObjectCounter b; std::cout << ObjectCounter::Count() << 'n'; }
静态成员有几个特点:
this 指针。因此,static 成员适合表示“这个类整体共享的一份信息”,例如对象计数、全局配置、工厂函数等。
友元可以让一个外部函数或另一个类访问当前类的私有成员。
以前面 Point 的输出函数为例,如果不想编写 X()、Y() 这种访问接口,也可以将其声明为友元:
#includeclass Point { public: Point(int x = 0, int y = 0) : x_(x), y_(y) {} friend std::ostream& operator<<(std::ostream& out, const Point& p); private: int x_; int y_; }; std::ostream& operator<<(std::ostream& out, const Point& p) { out << "(" << p.x_ << ", " << p.y_ << ")"; return out; }
友元函数不是成员函数,只是被允许访问这个类的私有和保护成员。
友元的好处是方便,代价是会削弱封装。如果到处都开放友元,类的私有成员就不再真正私有,后续维护会比较困难。
可以这样记:
友元是给特殊关系开的门,不是为了图省事随便开后门。
如果一个类型只服务于另一个类,可以考虑将其定义为内部类。
class LinkedList {
private:
struct Node {
int value;
Node* next;
};
public:
LinkedList() : head_(nullptr) {}
private:
Node* head_;
};
这里的 Node 只给 LinkedList 内部使用,放在 private 中可以减少对外暴露。
内部类本质上还是一个独立的类型,只是它被放到了另一个类的作用域中,并且受访问限定符控制。
内部类的意义主要是表达“这个类型和外部类关系很近,外部不需要直接关心它”。
匿名对象就是没有名字的临时对象。
#includeclass Task { public: void Run() { std::cout << "run taskn"; } }; int main() { Task().Run(); }
Task() 创建了一个临时对象,使用完这一行后基本就结束生命周期。
匿名对象在一些临时调用中很方便,但不要为了节省一个变量名,把代码写得难以阅读。
再看拷贝优化。按语法理解,函数传参、返回对象时可能会发生构造、拷贝构造、赋值等步骤。但现代编译器会在不改变程序结果的情况下省略一些拷贝。
Point MakePoint() {
return Point(1, 2);
}
int main() {
Point p = MakePoint();
}
从学习角度看,需要知道这里可能涉及临时对象和拷贝;从实际运行看,编译器可能直接将返回值构造到 p 的位置上。
这里不必一开始就纠结“到底调用了几次拷贝构造”。不同编译器、不同标准、不同优化选项下结果可能不同。更重要的是先理解对象生命周期,再知道编译器可能帮我们省掉一些中间步骤。
如果将类和对象这部分内容压缩成一条主线,可以这样记:
类负责封装数据和规则,对象负责承载具体状态。 对象创建时构造,使用中可能拷贝和赋值,结束时析构。 如果类自己管理资源,就要特别小心拷贝构造、赋值重载和析构函数。
再细一点,可以按下面这张表复习:
| 知识点 | 先记什么 |
|---|---|
| 类和对象 | 类是类型,对象是实例 |
| 封装 | 成员变量放私有,通过接口控制访问 |
this 指针 | 成员函数靠它知道当前对象是谁 |
| 构造函数 | 对象创建时自动初始化 |
| 析构函数 | 对象结束前清理资源 |
| 拷贝构造 | 新对象用旧对象初始化 |
| 赋值重载 | 已存在对象之间赋值 |
| 运算符重载 | 让类对象按自然语义使用运算符 |
| 初始化列表 | 成员真正初始化的位置 |
static 成员 | 属于类,所有对象共享 |
| 友元 | 有控制地突破访问限制 |
| 内部类 | 把强相关的辅助类型收进类域 |
C++ 类和对象这部分知识点不少,但主线可以收束到两件事:
如果只是会写 class、public、private,还只能算知道语法。更需要逐步建立的是对象意识:这个对象什么时候初始化?谁负责释放资源?拷贝时是复制值,还是共享了同一块资源?接口有没有把内部状态保护好?
这些问题理顺后,再往后学习继承、多态、模板和 STL,很多设计就不会显得那么突兀。它们本质上仍然在围绕一个问题展开:如何定义一个好用、可靠、边界清楚的类型。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述