C++ 课程第九课:指针、内存管理和类的基础
学习目标:
- 深入理解数组作为函数参数传递时的特性,掌握数组名作为指针的本质。
- 熟练掌握
new
和new[]
的用法,理解动态内存分配的原理和应用场景。 - 掌握
delete
和delete[]
的用法,并能正确地进行动态分配内存的释放,避免内存泄漏。 - 深刻认识内存越界和内存泄漏的危害,学会使用工具和编程技巧避免这些问题。
- 掌握指针安全使用的各项原则,编写健壮的 C++ 代码。
- 熟悉 C++ 的标准输入输出流
std::cin
和std::cout
的基本用法,并了解格式化输出。 - 初步了解类的概念,理解面向对象编程的基本思想。
- 掌握构造函数的定义和作用,理解其在对象创建过程中的重要性。
- 掌握析构函数的定义和作用,理解其在对象销毁过程中的重要性。
- 理解公有成员和私有成员的区别和作用,掌握访问控制的概念。
1. 数组传参的本质:指针
在 C++ 中,数组名在很多情况下会被解释为其首元素的地址。当数组作为函数参数传递时,实际上传递的是指向数组首元素的指针,而非整个数组的拷贝。这意味着在函数内部对数组元素的修改会直接影响到原始数组。
核心概念: 数组名退化为指针,传递的是地址。
代码示例:
#include <iostream>
void modifyArray(int arr[], int size) { // 实际上 arr 被当作 int* 处理
std::cout << "函数内部 sizeof(arr): " << sizeof(arr) << std::endl; // 输出指针的大小,而非数组大小
for (int i = 0; i < size; ++i) {
arr[i] *= 2;
}
}
int main() {
int arr[5] = {1, 2, 3, 4, 5};
std::cout << "函数外部 sizeof(arr): " << sizeof(arr) << std::endl; // 输出整个数组的大小
modifyArray(arr, 5);
std::cout << "修改后的数组:";
for (int i = 0; i < 5; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl; // 输出:2 4 6 8 10
return 0;
}
解释:
- 在
main
函数中,sizeof(arr)
返回的是整个数组占用的内存大小(5 * sizeof(int)
)。 - 在
modifyArray
函数中,arr
实际上是一个int*
类型的指针,因此sizeof(arr)
返回的是指针本身的大小(通常是 4 或 8 字节)。 - 函数内部通过指针操作数组元素,修改会反映到
main
函数中的原始数组。
重要提示: 由于数组传参会退化为指针,因此在函数内部无法直接获取数组的长度。需要显式地传递数组的大小信息。
2. 动态内存分配:new
和 new[]
在 C++ 中,可以使用 new
和 new[]
运算符在运行时动态地分配内存。动态分配的内存位于堆(heap)区,与栈(stack)区的自动分配内存不同,需要手动释放。
new
: 用于分配单个对象的内存。new[]
: 用于分配数组的内存。
代码示例:
#include <iostream>
int main() {
// 使用 new 分配一个整数
int *p = new int;
*p = 10;
std::cout << "*p: " << *p << std::endl;
delete p; // 释放单个对象的内存
// 使用 new[] 分配一个包含 5 个整数的数组
int *arr = new int[5];
for (int i = 0; i < 5; ++i) {
arr[i] = i * 2;
}
std::cout << "动态分配的数组:";
for (int i = 0; i < 5; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl; // 输出:0 2 4 6 8
delete[] arr; // 释放数组内存
return 0;
}
解释:
int *p = new int;
在堆上分配一块足以存储一个int
的内存,并返回该内存的地址,赋值给指针p
。int *arr = new int[5];
在堆上分配一块足以存储 5 个int
的连续内存,并返回首元素的地址。
C 语言中的对应操作:
malloc()
: 用于动态分配内存。calloc()
: 用于动态分配并初始化为零。realloc()
: 用于重新分配内存。free()
: 用于释放malloc
,calloc
, 或realloc
分配的内存。
重要提示: new
和 delete
必须成对使用,new[]
和 delete[]
必须成对使用,否则会导致内存泄漏或程序崩溃。
3. 动态内存释放:delete
和 delete[]
使用 new
或 new[]
分配的内存,在不再使用时必须使用 delete
或 delete[]
进行释放。
delete
: 用于释放通过new
分配的单个对象的内存。delete[]
: 用于释放通过new[]
分配的数组的内存。
错误示例(可能导致未定义行为):
int *ptr = new int[5];
delete ptr; // 错误:应该使用 delete[]
int *single = new int;
delete[] single; // 错误:应该使用 delete
4. 内存越界及其危害
内存越界指的是程序试图访问不属于其分配范围的内存区域。这是一种非常危险的行为,可能导致:
- 程序崩溃: 操作系统可能会阻止程序访问非法内存。
- 数据损坏: 覆盖其他变量或数据结构的内容,导致程序逻辑错误。
- 安全漏洞: 在某些情况下,越界访问可能被恶意利用。
代码示例:
#include <iostream>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
for (int i = 0; i <= 5; ++i) { // 错误:循环条件应为 i < 5
std::cout << arr[i] << " ";
}
std::cout << std::endl; // 最后一个访问 arr[5] 越界
return 0;
}
避免内存越界的关键:
- 仔细检查数组的索引范围。
- 使用容器(如
std::vector
),它们会自动管理内存并提供边界检查。 - 在处理动态分配的内存时,确保访问在已分配的范围内。
5. 内存泄漏及其危害
内存泄漏是指程序在动态分配内存后,忘记或未能释放不再使用的内存。随着程序的运行,泄漏的内存会越来越多,最终可能导致:
- 程序性能下降: 可用内存减少,系统需要更频繁地进行内存管理。
- 系统崩溃: 当所有可用内存都被耗尽时。
代码示例:
#include <iostream>
void memoryLeaker() {
int *ptr = new int[1000];
// 忘记 delete[] ptr;
}
int main() {
for (int i = 0; i < 100; ++i) {
memoryLeaker(); // 每次调用都会泄漏 4000 字节的内存 (假设 int 为 4 字节)
}
// 程序结束时,泄漏的内存由操作系统回收,但长期运行的程序可能会耗尽内存
return 0;
}
避免内存泄漏的关键:
- 坚持 “谁分配,谁释放” 的原则。
- 使用 RAII (Resource Acquisition Is Initialization) 原则,例如使用智能指针 (
std::unique_ptr
,std::shared_ptr
) 来自动管理内存。 - 养成良好的编程习惯,确保每个
new
都有对应的delete
。 - 使用内存泄漏检测工具,例如 Valgrind。
6. 指针的安全使用原则
指针是 C++ 中强大但容易出错的特性。安全使用指针至关重要:
(1) 初始化: 指针在声明时应初始化为一个有效的地址或空指针 (nullptr
)。
(2) 空指针检查: 在解引用指针之前,务必检查它是否为 nullptr
,避免访问无效内存。
(3) 匹配的 new
和 delete
: 确保每个 new
分配的内存都有对应的 delete
释放,new[]
对应 delete[]
。
(4) 避免重复释放: 不要释放已经释放过的内存,这会导致程序崩溃。
(5) 释放后置空: 释放内存后,立即将指针设置为 nullptr
,防止产生悬挂指针(dangling pointer)。
(6) 避免悬挂指针: 悬挂指针指向的内存已经被释放,访问它会导致未定义行为。
(7) 谨慎使用原始指针: 考虑使用智能指针来管理动态分配的内存,以减少手动管理的错误。
代码示例:
#include <iostream>
int main() {
int *ptr = nullptr; // 初始化为空指针
if (ptr != nullptr) {
*ptr = 10; // 不会执行,避免了访问空指针
}
ptr = new int;
if (ptr != nullptr) {
*ptr = 20;
std::cout << "*ptr: " << *ptr << std::endl;
delete ptr;
ptr = nullptr; // 释放后置空
}
// ... 后续不再使用已释放的 ptr
return 0;
}
7. C++ 输入输出流:std::cin
和 std::cout
C++ 使用流(stream)的概念进行输入输出操作。std::cin
是与标准输入设备(通常是键盘)关联的输入流对象,std::cout
是与标准输出设备(通常是屏幕)关联的输出流对象。
基本用法:
- 输出: 使用插入运算符
<<
将数据发送到输出流。 - 输入: 使用提取运算符
>>
从输入流读取数据。
代码示例:
#include <iostream>
#include <string>
int main() {
int age;
std::string name;
std::cout << "请输入你的姓名:";
std::cin >> name;
std::cout << "请输入你的年龄:";
std::cin >> age;
std::cout << "你好," << name << "!你今年 " << age << " 岁。" << std::endl;
return 0;
}
格式化输出(简单介绍):
可以使用 std::endl
插入换行符,还可以使用 std::setw
, std::setprecision
等流操纵符进行更复杂的格式化输出(将在后续课程中详细介绍)。
#include <iostream>
#include <iomanip> // 需要包含此头文件
int main() {
double pi = 3.1415926535;
std::cout << "Pi 的值为:" << std::setprecision(5) << pi << std::endl; // 输出:3.1416
std::cout << std::setw(10) << 123 << std::endl; // 设置输出宽度为 10,右对齐
return 0;
}
8. 初探类:封装数据和行为
类是面向对象编程的核心概念,它允许我们将数据(成员变量)和操作这些数据的函数(成员函数)组合在一起,形成一个独立的单元。这被称为封装。
基本结构:
class ClassName {
public:
// 公有成员(通常是方法)
private:
// 私有成员(通常是数据)
};
代码示例:
#include <iostream>
#include <string>
class Dog {
public:
std::string name;
int age;
// 构造函数
Dog(const std::string& dogName, int dogAge) : name(dogName), age(dogAge) {
std::cout << "小狗 " << name << " 被创建了!" << std::endl;
}
// 析构函数
~Dog() {
std::cout << "小狗 " << name << " 离开了。" << std::endl;
}
void bark() const {
std::cout << "汪汪!我是 " << name << ", " << age << " 岁了!" << std::endl;
}
private:
// 可以添加一些私有成员,例如内部状态
bool is_sleeping = false;
};
int main() {
Dog myDog("Buddy", 3); // 调用构造函数
myDog.bark(); // 调用公有成员函数
// 可以访问公有成员变量
std::cout << "小狗的名字是:" << myDog.name << std::endl;
std::cout << "小狗的年龄是:" << myDog.age << std::endl;
// 无法直接访问私有成员变量
// std::cout << myDog.is_sleeping << std::endl; // 编译错误
return 0; // myDog 对象在这里会被销毁,调用析构函数
}
解释:
class Dog { ... };
定义了一个名为Dog
的类。public:
标记后面的成员可以在类的外部访问。private:
标记后面的成员只能在类的内部访问。std::string name;
和int age;
是Dog
类的成员变量,用于存储小狗的名字和年龄。Dog(const std::string& dogName, int dogAge) : name(dogName), age(dogAge) { ... }
是构造函数,它在创建Dog
对象时被自动调用,用于初始化对象的成员变量。冒号后面的部分是初始化列表,用于高效地初始化成员变量。~Dog() { ... }
是析构函数,它在Dog
对象即将被销毁时被自动调用,用于执行清理操作(例如释放资源)。void bark() const { ... }
是一个成员函数,表示小狗的行为。const
关键字表示该函数不会修改对象的状态。
9. 构造函数:对象的初始化
构造函数是一种特殊的成员函数,其名称与类名相同,没有返回类型(甚至没有 void
)。它的主要作用是在创建类的对象时自动初始化对象的状态。
特点:
- 与类名相同。
- 没有返回类型。
- 在对象创建时自动调用。
- 可以有多个重载的构造函数(参数列表不同)。
分类:
- 默认构造函数: 没有参数的构造函数。如果类中没有显式定义构造函数,编译器会自动生成一个默认构造函数。
- 参数化构造函数: 带有参数的构造函数,允许在创建对象时传递初始值。
- 拷贝构造函数: 以同类型的另一个对象作为参数的构造函数,用于创建对象的副本(将在后续课程中详细介绍)。
代码示例(包含默认构造函数和参数化构造函数):
#include <iostream>
#include <string>
class Cat {
public:
std::string name;
int age;
// 默认构造函数
Cat() : name("Unknown"), age(0) {
std::cout << "一只未命名的小猫被创建了。" << std::endl;
}
// 参数化构造函数
Cat(const std::string& catName, int catAge) : name(catName), age(catAge) {
std::cout << "小猫 " << name << " 被创建了!" << std::endl;
}
void meow() const {
std::cout << "喵!我是 " << name << ", " << age << " 岁了!" << std::endl;
}
};
int main() {
Cat cat1; // 调用默认构造函数
cat1.meow();
Cat cat2("Tom", 2); // 调用参数化构造函数
cat2.meow();
return 0;
}
10. 析构函数:对象的清理
析构函数也是一种特殊的成员函数,其名称与类名相同,并在前面加上波浪号 ~
。它没有参数,也没有返回类型。析构函数在对象即将被销毁时自动调用,用于执行清理工作,例如释放对象占用的资源(如动态分配的内存、打开的文件等)。
特点:
- 名称与类名相同,前加
~
。 - 没有参数。
- 没有返回类型。
- 在对象生命周期结束时自动调用。
- 一个类只能有一个析构函数。
何时调用析构函数:
- 局部对象在离开其作用域时。
- 动态分配的对象通过
delete
删除时。 - 全局对象在程序结束时。
代码示例:
#include <iostream>
class ResourceHolder {
public:
ResourceHolder() {
resource = new int[10];
std::cout << "资源已分配。" << std::endl;
}
~ResourceHolder() {
delete[] resource;
std::cout << "资源已释放。" << std::endl;
}
private:
int* resource;
};
int main() {
{
ResourceHolder holder; // 在此代码块结束时,holder 的析构函数会被调用
}
ResourceHolder* dynamicHolder = new ResourceHolder();
delete dynamicHolder; // 手动删除动态分配的对象,触发析构函数
return 0;
}
11. 公有成员和私有成员:访问控制
在类中,我们可以使用访问修饰符(access specifiers)来控制类成员的访问级别。C++ 中主要的访问修饰符有:
public
: 公有成员可以在类的内部和外部访问。通常用于定义类的接口。private
: 私有成员只能在类的内部访问。用于隐藏类的内部实现细节,实现数据封装。protected
: 受保护的成员可以在类的内部以及派生类中访问(将在继承章节介绍)。
封装性: 使用私有成员是实现封装性的关键。通过将数据设为私有,我们可以防止外部代码直接修改对象的状态,并通过公有的成员函数提供受控的访问方式。
代码示例:
#include <iostream>
#include <string>
class BankAccount {
public:
// 公有成员函数,用于操作私有成员
void deposit(double amount) {
balance += amount;
}
void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
std::cout << "余额不足!" << std::endl;
}
}
double getBalance() const {
return balance;
}
private:
// 私有成员变量,存储账户余额
double balance = 0.0;
};
int main() {
BankAccount myAccount;
myAccount.deposit(1000);
myAccount.withdraw(200);
std::cout << "账户余额:" << myAccount.getBalance() << std::endl;
// 无法直接访问私有成员变量
// myAccount.balance = -100; // 编译错误
return 0;
}
总结:
public
成员定义了类的外部接口。private
成员隐藏了类的内部实现,提高了代码的健壮性和可维护性。
sizeof(std::cout)
的深入理解
与之前的简单提及不同,现在我们对类有了初步了解,可以更深入地探讨 sizeof(std::cout)
。
std::cout
是 std::ostream
类的一个对象。std::ostream
是一个非常复杂的类,它包含管理输出流所需的各种成员变量(例如缓冲区、格式化标志等)和成员函数(例如 operator<<
的各种重载)。
因此,sizeof(std::cout)
返回的是 std::ostream
类的一个对象实例所占用的内存大小。这个大小取决于:
- 编译器和标准库的实现: 不同的编译器和标准库可能以不同的方式实现
std::ostream
。 - 系统架构(32 位或 64 位): 指针的大小会影响对象的大小。
通常,sizeof(std::cout)
的结果会比较大,因为它包含了维护输出流状态所需的各种数据成员。它不是指输出到屏幕的内容的大小,而是 std::cout
这个对象本身的大小。
需要强调的是,理解 sizeof(std::cout)
的真正意义在于认识到 std::cout
是一个对象,它拥有自己的状态和行为。
本节课总结:
本节课我们深入学习了 C++ 中指针、动态内存管理和类的基础知识。我们了解了数组传参的特性,掌握了 new
和 delete
的用法,并认识到内存越界和内存泄漏的危害以及如何避免。我们学习了指针安全使用的原则,熟悉了 std::cin
和 std::cout
的基本用法。最后,我们初步了解了类的概念,学习了构造函数、析构函数以及公有和私有成员的概念。这些知识是掌握 C++ 编程的关键基础,希望大家认真学习和实践。
课后作业:
- 编写一个函数,使用动态内存分配创建一个指定大小的整数数组,并用传入的值初始化数组元素。在
main
函数中调用该函数,并释放分配的内存。 - 创建一个名为
Circle
的类,包含私有成员radius
和公有成员函数getArea()
和getCircumference()
。提供一个构造函数来初始化radius
。 - 解释内存泄漏可能对程序造成的危害,并列举至少三种避免内存泄漏的方法。
作业
1. 封装一个 Student 类
#include <iostream>
#include <string>
// Student 类的定义
int main() {
Student s1("张三", 80);
Student s2("李四", 90);
Student* s3 = new Student("王五", 75);
std::cout << "sizeof(s1): " << sizeof(s1) << std::endl;
std::cout << "sizeof(s2): " << sizeof(s2) << std::endl;
std::cout << "sizeof(s3): " << sizeof(s3) << std::endl; // 指针大小,通常是4或8字节
double average = (s1.getScore() + s2.getScore() + s3->getScore()) / 3.0;
std::cout << "平均成绩: " << average << std::endl;
delete s3;
return 0;
}
TIP
在构造函数和析构函数中添加输出语句,观察对象在复制、赋值、传参等过程中的构造和析构行为。