C++类和对象完全指南(上
C++类和对象完全指南(上)——从面向过程到面向对象
前言
学C++绕不开"类和对象"这个坎。很多从C语言转过来的同学,一开始看到满屏的 class、public、private 会觉得莫名其妙——C语言里一个 struct 搞定的事,C++为什么要搞这么复杂?
这篇文章就从头到尾把C++类和对象的核心知识梳理一遍。内容分上下两篇,上篇讲类的基础概念、对象模型、构造函数、析构函数和拷贝构造,下篇讲运算符重载、初始化列表进阶、static成员、友元等内容。
一、从C到C++:封装思想的诞生
面向对象有三大特性:封装、继承、多态。封装是第一个接触到的,也是最基础的。
什么叫封装?简单说就是:把数据和方法绑在一起,对外暴露接口,隐藏实现细节。
拿栈(Stack)来举例。用C语言实现一个栈,数据(数组、top、capacity)和操作(Init、Push、Pop)是分离的,谁都能直接去改栈里面的数据:
// C语言版本
typedef struct Stack
{
int* a;
int top;
int capacity;
}ST;
void STInit(ST* ps);
void STPush(ST* ps, int x);
void STPop(ST* ps);
用户完全可以直接 s.top = -1,把栈搞乱。C++的做法是把数据和方法放到一起,用访问限定符控制谁能碰:
// C++版本
class Stack
{
public:
// 对外开放的接口
void Init(int n = 4);
void Push(int x);
void Pop();
int Top();
private:
// 外部不能直接访问
int* _a;
size_t _capacity;
size_t _top;
};
封装的本质是一种更严格的管理方式,防止外部代码随意篡改内部数据。你不需要关心栈内部怎么存数据,只需要调 Push 和 Pop 就行。
除了封装,C++在语法上也做了一些便利性改进:
- 成员函数不需要手动传对象地址(
this指针隐含传递) - 构造函数支持缺省参数,省去手动调
Init struct名直接代表类型,不需要typedef
二、类的基础概念
2.1 类的定义格式
用 class 关键字定义类,后面跟类名,花括号里写成员,类定义结束时后面的分号不能省略:
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
}; // 分号!
类里面的内容分两种:
- 成员变量(属性):存储数据的变量
- 成员函数(方法):操作数据的函数
关于成员变量的命名,有个惯例:在变量名前面加 _ 或者后面加 _,或者用 m 开头,比如 _year、year_、m_year。这不是C++语法强制要求的,但实际开发中几乎所有人都这么做,主要是为了和函数参数区分开。具体用哪种风格,看公司或团队的规范。
C++中 struct 也能定义类,它兼容了C语言 struct 的用法,同时升级为类——可以在里面定义函数,struct 名直接代表类型。struct 和 class 的唯一区别是默认访问权限不同:class 默认 private,struct 默认 public。
还有一个细节:定义在类内部的成员函数默认是 inline 的。不过 inline 只是对编译器的建议,编译器不一定会真的内联展开。
2.2 访问限定符
C++用访问限定符来实现封装,有三个关键字:
| 限定符 | 类外能否访问 | 说明 |
|---|---|---|
public |
✅ 可以 | 对外开放的接口 |
private |
❌ 不行 | 仅类内部可访问 |
protected |
❌ 不行 | 和private类似,区别在继承时体现 |
访问权限从限定符出现的位置开始生效,到下一个限定符出现为止。如果后面没有限定符,就到类结束的 } 为止。
一般的设计原则是:成员变量放 private,需要给外部用的成员函数放 public。
2.3 类域
类定义了一个新的作用域。如果在类外面定义成员函数,需要用 ::(作用域解析运算符)指明它属于哪个类:
class Stack
{
public:
void Init(int n = 4); // 声明
private:
int* _a;
size_t _capacity;
size_t _top;
};
// 在类外定义,必须用 Stack:: 指定类域
void Stack::Init(int n)
{
_a = (int*)malloc(sizeof(int) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
如果不加 Stack::,编译器会把 Init 当成全局函数,编译时找不到 _a 等成员就会报错。指定了类域之后,编译器就知道去类的成员里找这些变量了。
三、对象的实例化与内存模型
3.1 什么是实例化
用类类型创建对象的过程叫实例化。类本身只是一个抽象描述(就像建筑图纸),不占用存储数据的空间。只有实例化出对象,才会在内存中分配空间。
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year; // 这里只是声明,没有分配空间
int _month;
int _day;
};
int main()
{
Date d1; // 实例化对象,此时才分配空间
Date d2; // 可以实例化多个对象
d1.Init(2024, 3, 31);
d2.Init(2024, 7, 5);
return 0;
}
一个类可以实例化出多个对象,每个对象都有自己独立的数据空间。
3.2 对象的大小
对象里到底存了什么?答案是:只存成员变量,不存成员函数。
成员函数编译后是一段指令,存在代码段里。所有同类的对象共享同一份函数代码,没必要每个对象都存一份。调用成员函数时,编译器直接 call 函数地址,不需要通过对象去找。
class A
{
public:
void Print() { cout << _ch << endl; }
private:
char _ch;
int _i;
};
class B
{
public:
void Print() { }
};
class C {};
int main()
{
cout << sizeof(A) << endl; // 8(内存对齐后)
cout << sizeof(B) << endl; // 1
cout << sizeof(C) << endl; // 1
return 0;
}
没有成员变量的类,对象大小是 1字节。这不是为了存数据,纯粹是占位——至少给一个字节,表示这个对象确实存在过。
3.3 内存对齐规则
C++对象的大小要遵循和C语言结构体一样的内存对齐规则:
- 第一个成员在偏移量为0的地址处
- 其他成员对齐到 对齐数 的整数倍地址处
- 对齐数 = 编译器默认对齐数 与 该成员大小 的较小值(VS默认对齐数为8)
- 结构体总大小为最大对齐数的整数倍
class A
{
public:
void Print() { cout << _ch << endl; }
private:
char _ch; // 1字节,对齐数1
int _i; // 4字节,对齐数4
};
// _ch占1字节(偏移0),_i需要4字节对齐(偏移4-7),总大小8字节
内存对齐主要是为了提高CPU的访问效率。虽然会浪费一些空间,但在性能上的收益更大。
3.4 this指针
来看一个问题:Date 类有 Init 和 Print 两个成员函数,d1 和 d2 都调用了 Init,函数体里并没有区分是哪个对象。那函数怎么知道该修改 d1 的数据还是 d2 的数据?
答案就是 this指针。
编译器在编译成员函数时,会隐含地在第一个参数位置加一个当前类类型的指针:
// 你写的代码
void Init(int year, int month, int day)
{
_year = year; // 实际上是 this->_year = year;
_month = month;
_day = day;
}
// 编译器实际处理的
void Init(Date* const this, int year, int month, int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
调用 d1.Init(2024, 3, 31) 时,编译器实际处理成 Init(&d1, 2024, 3, 31),this 就指向 d1。
关于this指针的几个要点:
- 类型:
类类型* const,指针本身不能修改(this = nullptr会编译报错) - 不能在实参和形参位置显式写this,但可以在函数体内显式使用
- 存储位置:栈上(作为函数形参),有些编译器会用寄存器优化
经典面试题:空指针能调用成员函数吗?
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print(); // 能运行吗?
return 0;
}
答案是能正常运行。因为 Print 函数里没有访问成员变量 _a,不涉及对 this 指针的解引用。p->Print() 本质是 A::Print(p),虽然 p 是空指针,但函数体内根本没用到 this。
但如果 Print 里访问了 _a(即 this->_a),那就会对空指针解引用,运行崩溃。
四、构造函数
4.1 为什么需要构造函数
以前我们写C++类,都要手动调一个 Init 函数来初始化:
Stack st;
st.Init(); // 忘了调就出bug
构造函数就是为了解决这个问题——对象实例化时编译器自动调用,不需要你手动去初始化。
4.2 构造函数的特点
构造函数虽然名字叫"构造",但它不是创建对象(栈上的对象在栈帧创建时空间就已经开好了),而是初始化对象。
特点如下:
- 函数名与类名相同
- 无返回值(不需要写
void,什么都不写就行) - 对象实例化时自动调用
- 可以重载
class Date
{
public:
// 无参构造
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
// 带参构造
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // 调无参构造
Date d2(2024, 7, 5); // 调带参构造
return 0;
}
4.3 默认构造函数
不传实参就能调用的构造函数叫默认构造函数。有三种:
- 无参构造函数
- 全缺省构造函数
- 没有显式定义时编译器自动生成的构造函数
这三者只能存在一个。无参和全缺省虽然构成重载,但调用时会歧义(Date d; 编译器不知道调哪个),所以不能同时存在。
class Date
{
public:
// 全缺省构造——推荐写法,既能无参调用也能带参调用
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // OK,用缺省值
Date d2(2024, 7, 5); // OK,用传入的值
return 0;
}
4.4 编译器默认生成的构造函数做了什么
如果你不写构造函数,编译器会自动生成一个。但它的行为可能和你想的不一样:
- 内置类型(
int、char、double、指针等):是否初始化取决于编译器,C++标准没有规定,VS下一般不初始化(随机值) - 自定义类型:调用该成员的默认构造函数
class Stack
{
public:
Stack(int n = 4) // Stack有构造函数
{
_a = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
private:
int* _a;
size_t _capacity;
size_t _top;
};
class MyQueue
{
// 不写构造函数,编译器自动生成
// 会自动调用 pushst 和 popst 的构造函数
private:
Stack pushst;
Stack popst;
size_t _size; // 内置类型,不初始化(随机值)
};
注意:如果自定义类型成员没有默认构造函数(比如 Stack 只有一个带参的构造函数),编译器就会报错。这时需要用初始化列表来解决(下篇讲)。
4.5 一个容易踩的坑
用无参构造创建对象时,对象后面不要加括号:
Date d1(); // 编译器把它当成函数声明了!
Date d1; // 正确,调用无参构造
Date d1() 这行代码在编译器看来是一个函数声明——返回类型是 Date,函数名是 d1,参数列表为空。这是C++的一个经典陷阱。
五、析构函数
5.1 析构函数的作用
析构函数和构造函数相反,在对象生命周期结束时自动调用,完成资源清理。
注意,析构函数不是销毁对象本身。栈上的对象在函数结束、栈帧销毁时自动释放,堆上的对象在 delete 时释放。析构函数做的是释放对象持有的资源(比如动态分配的内存)。
5.2 析构函数的特点
- 函数名是
~加类名(如~Stack()) - 无参数、无返回值
- 一个类只能有一个析构函数
- 对象生命周期结束时自动调用
- 不写的话编译器自动生成,行为和构造函数类似:内置类型不处理,自定义类型调它的析构
- 后定义的对象先析构(栈的后进先出特性)
class Stack
{
public:
Stack(int n = 4)
{
_a = (int*)malloc(sizeof(int) * n);
_capacity = n;
_top = 0;
}
~Stack()
{
free(_a); // 释放动态分配的内存
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _capacity;
size_t _top;
};
5.3 什么时候需要自己写析构
- 不需要写:像
Date这种没有动态资源的类,编译器生成的就够了 - 不需要写:像
MyQueue这种内部全是自定义类型的类,编译器生成的会自动调用成员的析构 - 必须写:像
Stack这种申请了动态内存的类,不写就会内存泄漏
判断标准很简单:如果一个类显式实现了析构并释放资源,那它通常也需要自己写拷贝构造和赋值运算符重载。这个"三法则"后面会反复遇到。
5.4 C++ vs C:括号匹配问题
来看一个实际例子——括号匹配。用C语言写的话,每个 return false 之前都要记得调 STDestroy,否则就会内存泄漏:
// C语言版本——每个return前都要记得Destroy
bool isValid(const char* s) {
ST st;
STInit(&st);
while (*s) {
if (*s == '(' || *s == '[' || *s == '{') {
STPush(&st, *s);
} else {
if (STEmpty(&st)) {
STDestroy(&st); // 别忘了!
return false;
}
char top = STTop(&st);
STPop(&st);
if (...) {
STDestroy(&st); // 别忘了!
return false;
}
}
++s;
}
bool ret = STEmpty(&st);
STDestroy(&st); // 别忘了!
return ret;
}
C++版本就清爽多了,析构函数自动调用,不用担心资源泄漏:
// C++版本——析构函数自动清理
bool isValid(const char* s) {
Stack st; // 构造函数自动调用
while (*s) {
if (*s == '[' || *s == '(' || *s == '{') {
st.Push(*s);
} else {
if (st.Empty()) return false; // st自动析构
char top = st.Top();
st.Pop();
if (...) return false; // st自动析构
}
++s;
}
return st.Empty(); // st自动析构
}
这就是构造函数和析构函数带来的便利——资源管理自动化。
六、拷贝构造函数
6.1 什么是拷贝构造
拷贝构造是一个特殊的构造函数,第一个参数是自身类类型的引用,用于用已有对象创建新对象:
class Date
{
public:
// 拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 7, 5);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 也是拷贝构造(不是赋值!)
return 0;
}
拷贝构造的参数必须是引用。如果写成传值 Date(Date d),编译器会报错——因为传值本身就需要调用拷贝构造,这就形成了无穷递归。
6.2 浅拷贝 vs 深拷贝
这是拷贝构造里最重要的概念。
编译器自动生成的拷贝构造做的是浅拷贝(值拷贝),按字节逐个复制。对于 Date 这种全是内置类型且没有指向资源的类,浅拷贝就够了:
// Date类的浅拷贝完全OK
Date d1(2024, 7, 5);
Date d2(d1); // _year/_month/_day各自复制一份,没问题
但对于 Stack 这种有动态内存的类,浅拷贝就出大问题了:
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2 = st1; // 浅拷贝:st2._a = st1._a,两个指针指向同一块内存!
浅拷贝之后,st1._a 和 st2._a 指向同一块内存。当两个对象析构时,同一块内存被 free 了两次,程序直接崩溃。而且修改其中一个的数据,另一个也会被影响。
解决方案是深拷贝——自己实现拷贝构造,为 st2 分配独立的内存空间:
Stack(const Stack& st)
{
// 开辟和st一样大的空间
_a = (int*)malloc(sizeof(int) * st._capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
// 把数据拷贝过来
memcpy(_a, st._a, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
判断标准:如果一个类显式实现了析构并释放资源,那它就需要自己写拷贝构造。这条规则几乎不会错。
6.3 拷贝构造的调用场景
C++规定,自定义类型对象进行拷贝行为必须调用拷贝构造。常见场景:
- 传值传参:
void Func(Date d)调用时,实参拷贝给形参 - 传值返回:
Date Func()返回时,返回值拷贝给接收对象
void Func1(Date d) { } // 传值传参,调拷贝构造
Date& Func2() {
Date tmp(2024, 7, 5);
return tmp; // 危险!tmp是局部对象,函数结束就销毁了
}
int main()
{
Date d1(2024, 7, 5);
Func1(d1); // d1拷贝给形参d
Date ret = Func2(); // ret引用的是已销毁的tmp,未定义行为
return 0;
}
传引用返回可以减少拷贝,但前提是返回的对象在函数结束后仍然存在。返回局部变量的引用是未定义行为,相当于野指针。
6.4 什么时候不需要自己写拷贝构造
Date类:全是内置类型,没有资源指向,编译器生成的浅拷贝就够用MyQueue类:内部主要是自定义类型Stack成员,编译器生成的拷贝构造会自动调用Stack的拷贝构造
class MyQueue
{
private:
Stack pushst;
Stack popst;
size_t _size;
};
MyQueue mq1;
MyQueue mq2 = mq1; // 编译器自动生成的拷贝构造会调用Stack的拷贝构造
只要 Stack 自己实现了深拷贝,MyQueue 就不需要额外写。
七、本篇知识点速查表
| 序号 | 知识点 | 要点 |
|---|---|---|
| 1 | 类的定义 | class + 类名 + {} + 分号;成员变量命名加 _ 前缀 |
| 2 | 访问限定符 | public 类外可访问,private/protected 不可;class默认private |
| 3 | 类域 | 类外定义成员用 :: 指定类域 |
| 4 | 实例化 | 类是抽象描述(声明),对象才分配空间 |
| 5 | 对象大小 | 只存成员变量,不存成员函数;空类1字节(占位) |
| 6 | 内存对齐 | 对齐数 = min(编译器默认值, 成员大小);总大小为最大对齐数的整数倍 |
| 7 | this指针 | 隐含形参,类型 类类型* const;存在栈上;空指针调用需看是否解引用 |
| 8 | 构造函数 | 函数名=类名,无返回值,自动调用;默认构造三选一(无参/全缺省/编译器生成) |
| 9 | 析构函数 | ~类名,无参无返回值,自动调用;后定义先析构 |
| 10 | 拷贝构造 | 参数必须是引用;浅拷贝vs深拷贝;有资源必须深拷贝 |
| 11 | 三法则 | 实现了析构释放资源 → 需要自己写拷贝构造和赋值重载 |
下篇将深入讲解运算符重载、初始化列表、类型转换、static成员、友元、内部类、匿名对象以及编译器的拷贝优化等内容。这些是C++类和对象中更进阶但也更实用的知识点,我们下篇见。
更多推荐



所有评论(0)