Skip to content

C++ 课程第九课:指针、内存管理和类的基础

学习目标:

  • 深入理解数组作为函数参数传递时的特性,掌握数组名作为指针的本质。
  • 熟练掌握 newnew[] 的用法,理解动态内存分配的原理和应用场景。
  • 掌握 deletedelete[] 的用法,并能正确地进行动态分配内存的释放,避免内存泄漏。
  • 深刻认识内存越界和内存泄漏的危害,学会使用工具和编程技巧避免这些问题。
  • 掌握指针安全使用的各项原则,编写健壮的 C++ 代码。
  • 熟悉 C++ 的标准输入输出流 std::cinstd::cout 的基本用法,并了解格式化输出。
  • 初步了解类的概念,理解面向对象编程的基本思想。
  • 掌握构造函数的定义和作用,理解其在对象创建过程中的重要性。
  • 掌握析构函数的定义和作用,理解其在对象销毁过程中的重要性。
  • 理解公有成员和私有成员的区别和作用,掌握访问控制的概念。

1. 数组传参的本质:指针

在 C++ 中,数组名在很多情况下会被解释为其首元素的地址。当数组作为函数参数传递时,实际上传递的是指向数组首元素的指针,而非整个数组的拷贝。这意味着在函数内部对数组元素的修改会直接影响到原始数组。

核心概念: 数组名退化为指针,传递的是地址。

代码示例:

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. 动态内存分配:newnew[]

在 C++ 中,可以使用 newnew[] 运算符在运行时动态地分配内存。动态分配的内存位于堆(heap)区,与栈(stack)区的自动分配内存不同,需要手动释放。

  • new 用于分配单个对象的内存。
  • new[] 用于分配数组的内存。

代码示例:

c++
#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 分配的内存。

重要提示: newdelete 必须成对使用,new[]delete[] 必须成对使用,否则会导致内存泄漏或程序崩溃。


3. 动态内存释放:deletedelete[]

使用 newnew[] 分配的内存,在不再使用时必须使用 deletedelete[] 进行释放。

  • delete 用于释放通过 new 分配的单个对象的内存。
  • delete[] 用于释放通过 new[] 分配的数组的内存。

错误示例(可能导致未定义行为):

c++
int *ptr = new int[5];
delete ptr; // 错误:应该使用 delete[]
c++
int *single = new int;
delete[] single; // 错误:应该使用 delete

4. 内存越界及其危害

内存越界指的是程序试图访问不属于其分配范围的内存区域。这是一种非常危险的行为,可能导致:

  • 程序崩溃: 操作系统可能会阻止程序访问非法内存。
  • 数据损坏: 覆盖其他变量或数据结构的内容,导致程序逻辑错误。
  • 安全漏洞: 在某些情况下,越界访问可能被恶意利用。

代码示例:

c++
#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. 内存泄漏及其危害

内存泄漏是指程序在动态分配内存后,忘记或未能释放不再使用的内存。随着程序的运行,泄漏的内存会越来越多,最终可能导致:

  • 程序性能下降: 可用内存减少,系统需要更频繁地进行内存管理。
  • 系统崩溃: 当所有可用内存都被耗尽时。

代码示例:

c++
#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) 匹配的 newdelete 确保每个 new 分配的内存都有对应的 delete 释放,new[] 对应 delete[]

(4) 避免重复释放: 不要释放已经释放过的内存,这会导致程序崩溃。

(5) 释放后置空: 释放内存后,立即将指针设置为 nullptr,防止产生悬挂指针(dangling pointer)。

(6) 避免悬挂指针: 悬挂指针指向的内存已经被释放,访问它会导致未定义行为。

(7) 谨慎使用原始指针: 考虑使用智能指针来管理动态分配的内存,以减少手动管理的错误。

代码示例:

c++
#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::cinstd::cout

C++ 使用流(stream)的概念进行输入输出操作。std::cin 是与标准输入设备(通常是键盘)关联的输入流对象,std::cout 是与标准输出设备(通常是屏幕)关联的输出流对象。

基本用法:

  • 输出: 使用插入运算符 << 将数据发送到输出流。
  • 输入: 使用提取运算符 >> 从输入流读取数据。

代码示例:

c++
#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 等流操纵符进行更复杂的格式化输出(将在后续课程中详细介绍)。

c++
#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. 初探类:封装数据和行为

类是面向对象编程的核心概念,它允许我们将数据(成员变量)和操作这些数据的函数(成员函数)组合在一起,形成一个独立的单元。这被称为封装。

基本结构:

c++
class ClassName {
public:
  // 公有成员(通常是方法)
private:
  // 私有成员(通常是数据)
};

代码示例:

c++
#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)。它的主要作用是在创建类的对象时自动初始化对象的状态。

特点:

  • 与类名相同。
  • 没有返回类型。
  • 在对象创建时自动调用。
  • 可以有多个重载的构造函数(参数列表不同)。

分类:

  • 默认构造函数: 没有参数的构造函数。如果类中没有显式定义构造函数,编译器会自动生成一个默认构造函数。
  • 参数化构造函数: 带有参数的构造函数,允许在创建对象时传递初始值。
  • 拷贝构造函数: 以同类型的另一个对象作为参数的构造函数,用于创建对象的副本(将在后续课程中详细介绍)。

代码示例(包含默认构造函数和参数化构造函数):

c++
#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 删除时。
  • 全局对象在程序结束时。

代码示例:

c++
#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 受保护的成员可以在类的内部以及派生类中访问(将在继承章节介绍)。

封装性: 使用私有成员是实现封装性的关键。通过将数据设为私有,我们可以防止外部代码直接修改对象的状态,并通过公有的成员函数提供受控的访问方式。

代码示例:

c++
#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::coutstd::ostream 类的一个对象。std::ostream 是一个非常复杂的类,它包含管理输出流所需的各种成员变量(例如缓冲区、格式化标志等)和成员函数(例如 operator<< 的各种重载)。

因此,sizeof(std::cout) 返回的是 std::ostream 类的一个对象实例所占用的内存大小。这个大小取决于:

  • 编译器和标准库的实现: 不同的编译器和标准库可能以不同的方式实现 std::ostream
  • 系统架构(32 位或 64 位): 指针的大小会影响对象的大小。

通常,sizeof(std::cout) 的结果会比较大,因为它包含了维护输出流状态所需的各种数据成员。它不是指输出到屏幕的内容的大小,而是 std::cout 这个对象本身的大小。

需要强调的是,理解 sizeof(std::cout) 的真正意义在于认识到 std::cout 是一个对象,它拥有自己的状态和行为。


本节课总结:

本节课我们深入学习了 C++ 中指针、动态内存管理和类的基础知识。我们了解了数组传参的特性,掌握了 newdelete 的用法,并认识到内存越界和内存泄漏的危害以及如何避免。我们学习了指针安全使用的原则,熟悉了 std::cinstd::cout 的基本用法。最后,我们初步了解了类的概念,学习了构造函数、析构函数以及公有和私有成员的概念。这些知识是掌握 C++ 编程的关键基础,希望大家认真学习和实践。

课后作业:

  1. 编写一个函数,使用动态内存分配创建一个指定大小的整数数组,并用传入的值初始化数组元素。在 main 函数中调用该函数,并释放分配的内存。
  2. 创建一个名为 Circle 的类,包含私有成员 radius 和公有成员函数 getArea()getCircumference()。提供一个构造函数来初始化 radius
  3. 解释内存泄漏可能对程序造成的危害,并列举至少三种避免内存泄漏的方法。

作业

1. 封装一个 Student 类

c++
#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

在构造函数和析构函数中添加输出语句,观察对象在复制、赋值、传参等过程中的构造和析构行为。