C++ 类和对象基础:从封装到对象生命周期
前言
复习 C++ 类和对象时,我一开始只记住了一个很粗的说法:类就是把变量和函数放到一起。这个说法不能算错,但它只能算入口。
后面再看构造、析构、拷贝这些内容,才发现类和对象的难点不只是 class 怎么写,而是对象在整个生命周期里到底发生了什么。比如:
- 成员变量为什么一般要放到
private里? - 构造函数和普通
Init函数到底有什么区别? - 为什么有的类不用写析构,有的类必须写?
- 拷贝构造和赋值重载为什么经常和资源管理绑在一起?
- 初始化列表、
static、友元这些语法分别解决什么问题?
文章目录
[1. 类到底在解决什么问题](#1. 类到底在解决什么问题)
[2. 类的定义、访问限定符和封装](#2. 类的定义、访问限定符和封装)
[3. 对象、成员函数和 this 指针](#3. 对象、成员函数和 this 指针)
[4. 对象大小和内存对齐](#4. 对象大小和内存对齐)
[5. 默认成员函数是什么](#5. 默认成员函数是什么)
[6. 构造函数:让对象一出生就是可用的](#6. 构造函数:让对象一出生就是可用的)
[7. 析构函数:对象结束前清理资源](#7. 析构函数:对象结束前清理资源)
[8. 拷贝构造:新对象用旧对象初始化](#8. 拷贝构造:新对象用旧对象初始化)
[9. 赋值运算符重载:已经存在的对象互相赋值](#9. 赋值运算符重载:已经存在的对象互相赋值)
[10. 运算符重载:让类对象按自然方式使用](#10. 运算符重载:让类对象按自然方式使用)
[11. 流插入为什么常写成全局函数](#11. 流插入为什么常写成全局函数)
[12. const 成员函数:承诺不修改对象](#12. const 成员函数:承诺不修改对象)
[13. 初始化列表:不是语法装饰](#13. 初始化列表:不是语法装饰)
[14. explicit:挡住不想要的隐式转换](#14. explicit:挡住不想要的隐式转换)
[15. static 成员:属于类,不属于某个对象](#15. static 成员:属于类,不属于某个对象)
[16. 友元:方便,但不要滥用](#16. 友元:方便,但不要滥用)
[17. 内部类:把强相关类型收进类域](#17. 内部类:把强相关类型收进类域)
[18. 匿名对象和拷贝优化](#18. 匿名对象和拷贝优化)
[19. 复习时怎么串起来](#19. 复习时怎么串起来)
一、类到底在解决什么问题
如果只用 C 语言风格写代码,我们通常会把数据和函数分开。
比如写一个学生信息:
cpp
struct Student {
char name[32];
int age;
int score;
};
void PrintStudent(const Student* stu);
void SetScore(Student* stu, int score);
这种写法能用,但数据边界几乎全靠调用者自觉,外部代码可以直接修改结构体里的字段。
cpp
Student stu;
stu.age = -10;
stu.score = 999;
从语法上看没有问题,但从业务含义上看明显不合理。年龄不应该是负数,分数也不应该随便越界。
C++ 的类把数据和操作放到一起,并且用访问权限控制"谁能改、怎么改"。理解类时,可以先抓住这一点:
text
类不是单纯把变量和函数包起来,而是给数据加上使用规则。
这是封装最基础的意义。
二、类的定义、访问限定符和封装
先用一个小类看封装的写法:
cpp
#include <iostream>
#include <string>
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 里,外部代码不能直接写:
cpp
// stu.score_ = 999; // 编译不过
外部只能通过 SetScore 修改分数,而 SetScore 里面可以做范围控制。封装不是单纯把成员藏起来,而是把修改数据的入口收回来,让对象按设计好的规则工作。
还有一个容易忽略的细节:class 默认访问权限是 private,struct 默认访问权限是 public。在 C++ 中,struct 也可以写成员函数,只是习惯上更常用 class 表达有封装规则的类型。
三、对象、成员函数和 this 指针
类本身只是一个类型,对象才是这个类型创建出来的具体变量。
cpp
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 指针。
比如我们写:
cpp
s1.SetScore(80);
可以粗略理解成:编译器帮我们把当前对象的地址传了进去。
text
SetScore(&s1, 80);
真实语法不是这样写,但这样理解比较顺。成员函数内部访问成员变量时,本质上是在通过 this 找到当前对象。
cpp
void SetScore(int score) {
this->score_ = score;
}
大多数时候 this-> 可以省略。只有在形参名和成员变量名冲突,或者需要返回当前对象本身时,this 才会比较常见。
四、对象大小和内存对齐
我之前容易误以为:类里面有成员函数,对象大小应该也要把函数算进去。
实际并不是这样。在不考虑虚函数等机制时,对象大小主要和成员变量有关,成员函数不会放进每个对象里。
cpp
#include <iostream>
class 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 对象取地址 |
这里先按基础阶段常见的六个默认成员函数理解,不建议只背数量,更重要的是问一句:
text
编译器默认生成的行为,够不够这个类使用?
如果类里只是普通成员变量,默认生成的函数很多时候够用。但如果类自己管理资源,比如申请堆空间、打开文件、管理锁,就要谨慎。默认拷贝可能只是浅拷贝,默认析构也不一定能完成资源释放。
后面几个小节,主要围绕构造、析构、拷贝构造和赋值重载展开。
六、构造函数:让对象一出生就是可用的
在没有构造函数时,我们可能会写一个 Init 函数:
cpp
Student s;
s.SetInfo("Li Hua", 18, 92);
问题在于,如果忘了调用 SetInfo,对象就可能处在一个没有正确初始化的状态。
构造函数正是用来处理这件事的:对象创建时自动调用,用来完成初始化。
cpp
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_;
};
使用时直接写成:
cpp
Student s("Li Hua", 18, 92);
s.Print();
构造函数的几个基本特点:
- 函数名和类名相同。
- 没有返回值,连
void也不写。 - 创建对象时自动调用。
- 可以重载。
默认构造函数也需要单独分清。只要"不传实参就能调用"的构造函数,都可以叫默认构造函数。
cpp
class Point {
public:
Point() : x_(0), y_(0) {}
Point(int x, int y) : x_(x), y_(y) {}
private:
int x_;
int y_;
};
这里的 Point() 是默认构造函数。
如果写成全缺省参数,也属于不传参就能调用:
cpp
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[] 了一块空间,就必须考虑释放问题。
cpp
#include <algorithm>
#include <cstddef>
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_;
};
析构函数的特点:
- 名字是
~类名。 - 没有参数,也没有返回值。
- 对象生命周期结束时自动调用。
这里最需要分清的是:析构函数不是用来销毁对象本身的内存,而是用来清理对象内部管理的资源。
局部对象在函数结束时,它所在的栈帧会被释放;析构函数真正负责的是把对象内部申请的堆空间、文件句柄、锁等资源处理干净。
八、拷贝构造:新对象用旧对象初始化
拷贝构造函数处理的是"用一个同类型对象初始化另一个新对象"。
cpp
Student s1("Li Hua", 18, 92);
Student s2(s1);
Student s3 = s1;
s2(s1) 和 s3 = s1 都是在创建新对象,因此调用的是拷贝构造。
如果类里没有自己管理资源,编译器默认生成的拷贝构造通常够用。比如 Student 里面的 std::string 自己知道怎么拷贝。
但 IntBuffer 这种类不能直接依赖默认拷贝构造。
cpp
IntBuffer b1(10);
IntBuffer b2 = b1; // 如果用默认拷贝,会有问题
默认拷贝只会把指针值复制一份,也就是两个对象的 data_ 指向同一块堆空间。等两个对象析构时,就可能释放同一块空间两次。
这就是浅拷贝容易出问题的地方。
更合适的做法是自己写深拷贝:不仅复制指针变量,还要重新申请一块空间,把内容复制过去。
cpp
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_;
};
判断要不要写拷贝构造时,可以先看这一点:
text
如果这个类自己管理资源,就要检查默认拷贝是不是只拷贝了资源句柄。
如果只是普通成员变量,不一定需要自己写;如果有裸指针管理堆空间,就要认真考虑自己实现。
九、赋值运算符重载:已经存在的对象互相赋值
拷贝构造和赋值看起来很像,但场景不同。
cpp
IntBuffer b1(10);
IntBuffer b2 = b1; // 拷贝构造:创建 b2
IntBuffer b3(5);
b3 = b1; // 赋值:b3 已经存在
赋值运算符重载一般可以这样写:
cpp
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 更自然。
cpp
#include <iostream>
class 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_;
};
使用时:
cpp
Point p1(1, 2);
Point p2(3, 4);
Point p3 = p1 + p2;
if (p1 == p2) {
std::cout << "same\n";
}
p3.Print();
运算符重载有几条边界:
- 不能自己创造 C++ 没有的运算符,比如
operator@。 - 运算符的优先级和结合性不会因为重载而改变。
- 至少有一个操作数要是类类型,不能重载两个内置类型的运算。
- 不是所有运算符都适合重载,关键要看语义是否自然。
比如 Point + Point 比较自然,但给 Student 重载 + 就不一定有意义。
++ 有一个容易混的地方:前置 ++ 和后置 ++ 都叫 operator++,C++ 用一个 int 形参区分后置版本。
cpp
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_;
};
前置 ++ 返回自增后的自己,后置 ++ 返回自增前的临时副本。这个行为要和内置类型保持一致。
十一、流插入为什么常写成全局函数
如果希望这样输出对象:
cpp
std::cout << p1 << '\n';
一般会把 operator<< 写成全局函数,而不是成员函数。
cpp
#include <iostream>
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_;
};
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 成员函数:承诺不修改对象
成员函数后面加 const,表示这个函数不会修改当前对象。
cpp
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 可以理解成:
text
Point* const this
const 成员函数里,this 可以理解成:
text
const Point* const this
也就是说,不能通过这个 this 修改成员变量。
这点在写只读接口时很重要。比如 Print()、Size()、Empty()、X() 这类函数,只要不修改对象,就应该考虑加 const。这样 const 对象也能调用它们。
十三、初始化列表:不是语法装饰
刚学构造函数时,我容易把初始化列表看成"另一种赋值写法":
cpp
Point(int x, int y)
: x_(x), y_(y) {}
后面再看引用成员、const 成员和类类型成员时,才发现初始化列表不是装饰,它表示成员变量真正被初始化的位置。
有些成员必须在初始化列表中初始化,比如:
- 引用成员变量。
const成员变量。- 没有默认构造函数的类类型成员。
看一个例子:
cpp
#include <string>
class 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 没有默认构造函数,所以它们都必须在初始化列表中处理。
还有一个细节:成员变量的初始化顺序不是看初始化列表里写的顺序,而是看它们在类中声明的顺序。
cpp
class Demo {
public:
Demo(int x) : b_(x), a_(b_) {}
private:
int a_;
int b_;
};
这段代码看起来先初始化 b_,再初始化 a_,但实际上 a_ 声明在前,会先初始化。这样的代码很容易埋下隐患。
所以写初始化列表时,最好让顺序和成员声明顺序保持一致。
十四、explicit:挡住不想要的隐式转换
单参数构造函数可能触发隐式类型转换。
cpp
class Id {
public:
Id(int value) : value_(value) {}
private:
int value_;
};
void PrintId(Id id) {}
int main() {
PrintId(10); // int 被隐式转换成 Id
}
有时这种转换很方便,但很多时候会让代码不够明确。加上 explicit 后,就必须显式构造对象。
cpp
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 成员:属于类,不属于某个对象
普通成员变量是每个对象一份,而 static 成员变量是所有对象共享一份。
比如可以用它统计当前创建了多少个对象:
cpp
#include <iostream>
class 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() 这种访问接口,也可以把它声明成友元:
cpp
#include <iostream>
class 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;
}
友元函数不是成员函数,只是被允许访问这个类的私有和保护成员。
友元的好处是方便,代价是会削弱封装。如果到处都开友元,类的私有成员就不再真正私有,后面维护会比较难。
可以这样记:
text
友元是给特殊关系开的门,不是为了图省事随便开后门。
十七、内部类:把强相关类型收进类域
如果一个类型只服务于另一个类,可以考虑把它定义成内部类。
cpp
class LinkedList {
private:
struct Node {
int value;
Node* next;
};
public:
LinkedList() : head_(nullptr) {}
private:
Node* head_;
};
这里的 Node 只给 LinkedList 内部使用,放在 private 里可以减少对外暴露。
内部类本质上还是一个独立的类型,只是它被放到了另一个类的作用域里,并且受访问限定符控制。
内部类的意义主要是表达"这个类型和外部类关系很近,外部不需要直接关心它"。
十八、匿名对象和拷贝优化
匿名对象就是没有名字的临时对象。
cpp
#include <iostream>
class Task {
public:
void Run() {
std::cout << "run task\n";
}
};
int main() {
Task().Run();
}
Task() 创建了一个临时对象,用完这一行基本就结束生命周期。
匿名对象在一些临时调用里很方便,但不要为了省一个变量名,把代码写得难读。
再看拷贝优化。按语法理解,函数传参、返回对象时可能会发生构造、拷贝构造、赋值等步骤。但现代编译器会在不改变程序结果的情况下省略一些拷贝。
cpp
Point MakePoint() {
return Point(1, 2);
}
int main() {
Point p = MakePoint();
}
从学习角度看,需要知道这里可能涉及临时对象和拷贝;从实际运行看,编译器可能直接把返回值构造到 p 的位置上。
这里不必一开始就纠结"到底调用了几次拷贝构造"。不同编译器、不同标准、不同优化选项下结果可能不同。更重要的是先理解对象生命周期,再知道编译器可能帮我们省掉一些中间步骤。
十九、复习时怎么串起来
如果把类和对象这块内容压缩成一条主线,可以这样记:
text
类负责封装数据和规则,对象负责承载具体状态。
对象创建时构造,使用中可能拷贝和赋值,结束时析构。
如果类自己管理资源,就要特别小心拷贝构造、赋值重载和析构函数。
再细一点,可以按下面这张表复习:
| 知识点 | 先记什么 |
|---|---|
| 类和对象 | 类是类型,对象是实例 |
| 封装 | 成员变量放私有,通过接口控制访问 |
this 指针 |
成员函数靠它知道当前对象是谁 |
| 构造函数 | 对象创建时自动初始化 |
| 析构函数 | 对象结束前清理资源 |
| 拷贝构造 | 新对象用旧对象初始化 |
| 赋值重载 | 已存在对象之间赋值 |
| 运算符重载 | 让类对象按自然语义使用运算符 |
| 初始化列表 | 成员真正初始化的位置 |
static 成员 |
属于类,所有对象共享 |
| 友元 | 有控制地突破访问限制 |
| 内部类 | 把强相关的辅助类型收进类域 |
小结
C++ 类和对象这部分知识点不少,但主线可以收束到两件事:
- 怎么把数据和操作组织成一个可靠的类型。
- 怎么管理对象从创建到销毁的整个生命周期。
如果只是会写 class、public、private,还只能算知道语法。更需要慢慢建立的是对象意识:这个对象什么时候初始化?谁负责释放资源?拷贝时是复制值,还是共享了同一块资源?接口有没有把内部状态保护好?
这些问题理顺后,再往后学继承、多态、模板和 STL,很多设计就不会显得那么突然。它们本质上仍然在围绕一个问题展开:如何定义一个好用、可靠、边界清楚的类型。