【C++】作用域与内存管理

Friday, Jan 2, 2026 | 5 minute read | Updated at Friday, Jan 2, 2026

@

生命周期与作用域的区别

作用域(Scope)是空间/代码维度的概念:指你的变量名在代码的哪个范围内“可见”(编译器能认出这个名字)。

生命周期(Lifetime)是时间/内存维度的概念:指变量在程序运行过程中,从“分配内存”到“释放内存”的这段物理时间。

对于栈对象,两者几乎是绑定的,栈对象的作用域决定了其生命周期;作用域结束的地方,就是生命周期终结的地方。

对于堆对象,其作用域和生命周期则是分离的;一般来说堆对象的的名字(指针)有作用域,但对象本身没有。

Object的生命周期

在 C++ 中,对象的生命周期是指从对象被创建到被销毁的整个过程。

CPP中的内存布局主要分为五个区域,其中最核心的是栈内存和堆内存。

区域存储内容生命周期
栈 (Stack)局部变量、函数参数、返回地址。自动管理,进入作用域创建,离开销毁。
堆 (Heap)通过 new 分配的对象。手动管理,直到 delete 或程序结束。
全局/静态存储区全局变量、static 变量。程序启动时分配,程序结束时释放。
常量存储区字符串常量(如 "Hello")。整个程序运行期间。
代码区 (Text)程序的机器指令(二进制代码)。只读,整个程序运行期间。

栈 (Stack) 与 栈对象

什么是栈?

栈是一块连续的内存区域。它的运作模式是 LIFO (Last In, First Out,后进先出)。类似于你在往弹夹里压子弹,最后压进去的子弹,最先被打出来。

什么是栈对象 (Stack Objects)?

通常指值类型 (Value Types)基本数据类型。一般包含:局部变量(如 int a = 10)、函数参数、函数返回地址等。

栈对象的生命周期非常短。当一个函数(方法)开始执行时,系统会在栈上“压入”一个栈帧 (Stack Frame),里面装着这个函数需要的所有局部变量。当函数执行结束(return),这个栈帧直接被“弹出”,所有数据瞬间销毁。

栈对象不需要担心内存泄漏,速度极快,CPU 甚至有专门的指令来处理栈指针。

Note

在 C++ 或 Swift (Struct) 中,复杂的对象也可以完全存在栈上。但在 Java 或 C# 中,通常只有基本类型(int, boolean)和对象的引用在栈上。

堆 (Heap) 与 堆对象

什么是堆?

堆是一块巨大的、不连续的内存区域。它允许你动态地分配内存。它的内存分配是随机的,就像你在大仓库里随便找个空地放箱子。

什么是堆对象 (Heap Objects)?

通常指引用类型 (Reference Types)。主要包含通过 new 关键字创建的类的实例(如 new User(), new Bitmap()),以及数组、长字符串等。

堆对象的生命周期不受函数结束的限制。即使创建它的函数执行完了,堆上的对象依然存在,直到它被垃圾回收器 (GC) 回收,或者被手动释放(C++中)。

堆对象的分配和销毁开销大。如果管理不当,容易产生内存碎片内存泄漏(即对象不用了但没被清理,一直占着仓库位置)。

1
2
3
4
5
6
class Complex{...};
{
    Complex* p=new Complex;
    ...
    delete p;
}

Caution

如果对于堆对象不进行delete,则在其作用域结束后,p所指的heap object仍然存在,但指针p的生命却结束了,作用域外再也看不到p,也就没机会delete p,造成内存泄漏.

new与delete

new关键字先分配内存,在调用构造函数

1
Complex* pc= new Complex(1,2);

如上new一个对象的流程,在编译器中会被转化为以下代码(不标准,但是思路类似):

1
2
3
4
5
Complex *pc;//创建一个指向新对象的指针

void* mem=operator new(sizeof(Complex));//分配类大小的内存,new方法内部会调用C语言中的动态内存分配方法malloc(n);
pc=static_cast<Complex*>(mem); //转型
pc->Complex::Complex(1,2);     //调用构造函数

delete关键字先调用析构函数,再释放内存

1
2
Complex*pc =new Complex(1,2);
delete pc;

Caution

对于动态内存分配对象而言array new一定要搭配array delete使用,否则会导致内存泄漏

同时建议:即使是不需要动态分配内存的无指针变量类,array new也要搭配array delete使用,这是一种安全规范的写法.

Example

举一个两者对比的栗子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <string>

class Player {
public:
    std::string name;
    Player(std::string n) : name(n) { std::cout << name << " 进入了内存\n"; }
    ~Player() { std::cout << name << " 被销毁了\n"; }
};

void createPlayers() {
    // 1. 栈对象 (Stack Object)
    // 直接声明,不需要 new。函数结束时,它会自动触发析构函数销毁。
    Player stackPlayer("栈小王"); 

    // 2. 堆对象 (Heap Object)
    // 使用 new 关键字。它会一直存在,直到你手动调用 delete。
    Player* heapPlayer = new Player("堆大强");

} // <--- 这里的右大括号是“栈对象”的终点

int main() {
    createPlayers();
    return 0;
}

运行该代码会发现:

  1. 栈小王 在函数结束时自动打印了“被销毁了”。
  2. 堆大强 没有被销毁!如果你不手动 delete heapPlayer,他在程序运行期间会一直占用内存,这就是内存泄漏

但在 C++ 中,你有绝对的控制权——你可以决定一个对象是死在栈上,还是活在堆里。


为什么要用堆对象?

既然栈又快又安全,为什么 C++ 还要用堆?

  1. 生存期需求:有时候你需要一个对象在函数结束后依然活着(例如:在函数里加载一张地图数据,交给其他函数使用)。
  2. 空间限制:栈通常很小(默认 1MB~8MB)。如果你要处理一个 $1000 \times 1000$ 的大型高清贴图,直接放栈上会触发 Stack Overflow
  3. 动态大小:如果你在运行时才知道需要创建多少个对象(比如玩家输入的数量),堆可以根据需要动态申请内存。

智能指针是一个栈对象(受作用域控制),它在自己的析构函数里去执行 delete

这样,你就把堆对象那不受控的生命周期,强行绑定到了一个栈对象的作用域上。这种技术就叫 RAII (Resource Acquisition Is Initialization) 思想(资源获取即初始化),是现代 C++ 能够摆脱内存泄漏噩梦的法宝。

现代 C++ (C++11及以后) 极力推荐使用智能指针,它能让你像用栈一样安全地使用堆

1
2
3
4
5
6
7
#include <memory>

void modernCpp() {
    // 这里的 unique_ptr 在栈上,但它指向的对象在堆上
    // 当 smartPlayer 离开作用域,它会自动 delete 指向的堆对象
    std::unique_ptr<Player> smartPlayer = std::make_unique<Player>("智能强");
}

这行代码实际上在内存中创造了这种结构:

  • 栈上:有一个名为 smartPlayer 的小盒子,里面装着堆内存的地址(比如 0x55AA)。
  • 堆上:有一个真正的 Player 对象实例。

关键点: 当程序运行到当前大括号 } 结束时,栈上的 smartPlayer 会被系统弹出销毁。在它死之前,它的析构函数会自动调用 delete 来释放堆上的 Player 对象。

Caution

程序中使用new/ malloc()分配的堆内存一定要手动释放:delete/free()


浅拷贝与深拷贝

深拷贝(Deep Copy)和浅拷贝(Shallow Copy)之所以存在,根本原因就是因为“堆内存”的存在。

栈上的数据是“值”存储。如果你拷贝一个简单的 int a = 10;int b = a;,系统会在栈上开辟新空间并填入 10。这是天然的“完全拷贝”,两个变量互不影响。堆上的对象则必须通过指针(存放在栈上)来访问。

当你拷贝一个含有指针的对象时,默认情况下编译器只会拷贝那个“指针的值”(也就是堆地址),而不会去拷贝指针指向的“堆内存里的内容”。

浅拷贝 (Shallow Copy)

浅拷贝就是C++编译器的默认行为,它就像是你有一把仓库的钥匙(指针),你复印了一把给朋友。

此时栈上: 多了一个指针变量,但里面的地址是一样的。堆上: 只有一份原始数据。会造成以下可能的后果:

修改同步: 你朋友进仓库改了东西,你看到的也变了。 2. 崩溃风险 (Double Free): 如果你朋友用完把仓库拆了(delete),你再去用你的钥匙开门,程序就会因为访问非法内存而崩溃。或者当你也要拆仓库时,发现仓库已经没了,导致重复删除报错。

深拷贝 (Deep Copy)

在C++中,深拷贝需要程序员手动实现(写拷贝构造函数)。它不仅复印钥匙,还去盖一个一模一样的仓库,并把里面的东西全搬进去。

栈上: 有两个指针,存储不同的地址。堆上: 有两份完全独立的数据。后果则是: 两个对象完全独立,互不干扰,各自管理各自的生命周期。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class MyData {
public:
    int* heapPtr; // 指向堆内存的指针

    MyData(int val) {
        heapPtr = new int(val); // 在堆上分配内存
    }

    // 默认的拷贝构造函数(浅拷贝)长这样:
    // MyData(const MyData& other) { heapPtr = other.heapPtr; }

    // 手动实现深拷贝
    MyData(const MyData& other) {
        heapPtr = new int(*other.heapPtr); // 1. 开新堆 2. 考内容
    }

    ~MyData() { delete heapPtr; } // 释放堆内存
};

全局/静态存储区

果说是“快餐盒”(用完即丢),是“租来的仓库”(需手动还钥匙),那么全局/静态存储区就是“自有房产”。

这块区域专门存储那些从程序启动到程序结束一直存在的数据。一般指:全局变量:定义在所有函数外部的变量。静态变量 (static):包括全局静态变量、局部静态变量以及类的静态成员。常量 (某些情况下):比如被 const 修饰的全局变量(有时会被优化到只读数据区)。

Static object静态对象

如果想让对象的声明周期突破作用域限制,可以使用static修饰符,static local objects的生命在其作用域结束之后依然存在,直到整个程序结束.

1
2
3
4
class Complex{...};
{
    static Complex c2(1,2);
}

global objects全局变量 写在main函数的外面,可以将其视为一种特殊的static object,其作用域也是整个程序

1
2
3
4
5
6
class Complex{...};
Complex c3(1,2);
int main()
{
    ...
}

在程序运行期间,静态变量只初始化一次,并且没有指针,但对于静态局部变量和静态成员变量的情况还有所不同,如下面例子所示:

局部静态变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void counter() {
    static int count = 0;  // 只初始化一次
    count++;
    std::cout << count << std::endl;
}

int main() {
    counter();  // 输出1
    counter();  // 输出2
    counter();  // 输出3
}

第二次调用||第N次调用时:程序会直接“跳过”初始化那一行,直接去访问已经在静态存储区里待命的那个 count。因为编译器会在后台设置一个“隐藏的标志位”。每次进入函数都会检查这个标志位,如果发现已经初始化过了,就直接取值,不再调用构造函数。

全局静态变量 / 类的静态成员

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static int global_val = 100; // 全局静态

class MyClass {
public:
    static int sharedValue;  // 声明
};

int MyClass::sharedValue = 42;  // 定义和初始化

int main() {
    MyClass a, b;
    a.sharedValue = 10;
    std::cout << b.sharedValue;  // 输出10,因为所有实例共享
}

这里的全局静态变量和类的静态成员在 main 函数执行之前,程序加载时就会完成初始化。所有类实例共享同一个静态变量,可以通过类名或实例访问。

static 关键字

static 是 C++ 中一个多用途关键字,根据使用上下文有不同的含义。static修饰的变量的作用域可能不同,但是统一存储在全局/静态存储区(非堆非栈).而static修饰的函数存储在代码段,与普通成员函数存储位置相同.

静态成员函数

静态成员函数属于“类”本身,而不属于任何具体的“对象”实例。

该函数没有 this 指针 (最本质区别);只能访问静态成员变量和其他静态成员函数;可以通过类名直接调用;不能是虚函数(因为与实例无关)。

和普通函数一样,静态函数的二进制指令存储在程序的 代码区 (Text Segment)

生命周期:它在程序加载时就存在了。即使你一个该类的对象都没有创建,你依然可以调用它的静态成员函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyClass {
public:
    static void printMessage() {
        std::cout << "Static method" << std::endl;
        // 不能访问非静态成员
    }
};
int main() {
    MyClass::printMessage();  // 不需要实例
}

**没有 this 指针 **

普通成员函数:当你调用 obj.func() 时,编译器会隐式地传递一个 this 指针给函数,指向 obj 的地址。

静态成员函数:它没有 this 指针。因为它不属于任何对象,所以它不知道、也无法访问具体的某个对象的成员。

由于没有 this 指针,静态成员函数在访问成员时非常“挑食”:

  • 只能访问静态成员:它只能调用类里的 static 变量或其他 static 函数。
  • 不能访问非静态成员:它不能直接访问普通的成员变量或成员函数,因为这些变量需要具体的对象实例才能存在。

比喻: 静态成员函数就像是学校的“校规”(属于全校),它只能引用学校的公共设施(静态成员);而它不能直接说“把那个学生的书包拿来”,因为它不知道你在指哪一个具体的学生。

特性普通成员函数静态成员函数 (static)
所属对象具体的对象实例整个类共用
this 指针 (指向当前对象)
访问非静态成员可以直接访问不可以
访问静态成员可以访问可以访问
调用方式必须通过对象名 obj.f()通过类名 Class::f() 或对象名
虚函数 (Virtual)可以是虚函数不能是虚函数

静态全局变量和函数

1
2
3
4
5
6
// file1.cpp
static int hiddenVar = 42;  // 只在当前文件可见

static void hiddenFunc() {  // 只在当前文件可见
    std::cout << hiddenVar << std::endl;
}

静态与单例模式(Meyers Singleton)

什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式,它的核心目标是:保证一个类在整个程序运行期间,有且仅有一个实例,并提供一个全局访问点。

想象一下,在一个 App 中:

  • 日志系统 (Logger):你不需要到处创建日志对象,只需要一个全局的来记录所有信息。
  • 配置管理器 (Config):整个 App 的设置(比如深色模式、语言)应该是统一的一份。
  • 数据库连接池:为了节省资源,通常只维护一个连接池。

单例模式和 static 关键字有着“血缘关系”。在 C++ 中,静态(Static)是实现单例模式的技术基石

它们的关系主要体现在以下三个方面:

静态成员函数是“访问入口”

单例模式要求构造函数私有化(外部不能 new),那么外部如何获取对象呢? 必须依靠一个 静态成员函数(如 getInstance())。因为静态函数不依赖对象存在,你可以直接通过 类名::getInstance() 来调用它。

静态成员变量(或局部静态变量)是“唯一容器”

为了保证实例的唯一性,这个实例必须存储在 全局/静态存储区,而不是栈或堆。

  • 如果存放在栈上,函数结束对象就销毁了。
  • 如果存放在堆上,需要手动管理指针,容易出错。
  • 静态变量 生命周期与程序同步,且由编译器保证只初始化一次。

静态初始化保证“线程安全”

在现代 C++ (C++11及以后) 中,静态局部变量的初始化是线程安全的。这意味着即使多个线程同时调用 getInstance(),编译器也能确保唯一的实例只被创建一次。

Example

迈耶斯单例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Singleton {
public:
    static Singleton& getInstance() {
        // static 保证了:1. 只有第一次执行这行会初始化 2. 存在静态区 3. 线程安全
        static Singleton instance; 
        return instance;
    }
private:
    Singleton() {} // 构造私有,防止外部创建
};

逻辑

  1. 当你第一次调用 getInstance() 时,编译器在全局/静态存储区划分了一块房产,把 instance 放了进去,然后返回一个该对象别名。
  2. 因为它在静态区,所以即使函数执行完了,它也不会消失(生命周期直到程序结束)。
  3. 当你第二次调用时,编译器看了一眼标志位:“哦,房产已经盖好了”,直接把上次那个房子的地址返回给你。

内存表现:对象直接住在静态存储区

优点:最简单,代码少,系统自动回收内存,不用担心泄漏。

静态指针单例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Singleton {
private:
    static Singleton* instance; // 静态成员声明
    Singleton() {}
public:
    static Singleton* getInstance() {
        if (instance == nullptr) {
            instance = new Singleton();
        }
        return instance;
    }
};
// 静态成员必须在类外定义
Singleton* Singleton::instance = nullptr;

Caution

类的 static 成员变量在类内只是声明,必须在类外(通常在 .cpp 文件里)分配一次内存。如果不写这一行,编译器会报错说找不到这个变量。

逻辑

  1. 程序启动时,在静态区分配了一个 8 字节的小格子 instance,里面填的是 nullptr(空地址)。
  2. 当你第一次调用 getInstance(),它发现是空的,于是执行 new,在堆 (Heap) 上买了一块地,盖了房子。
  3. 把堆内存的地址存进那个 8 字节的小格子。
  4. 下次调用,直接看小格子里的地址,直接去堆里找。

内存表现:指针在静态区,真正的对象在堆区

缺点:如果你不手动写代码销毁它,程序结束时堆内存可能不会被干净回收(虽然现代系统会强制回收,但在复杂程序中这是隐患)。

© 2021 - 2026 古月月仔的博客

🌱 Powered by Hugo with theme Dream.

关于我
  • 我是古月月仔
  • Ethan Hu
  • 分享技术学习笔记与生活点滴
  • 现居: 上海 中国
  • 家乡: 平遥 山西
在用的学习工具
  • 📝 Typora —— 极致简洁的 Markdown 编辑器,助力沉浸式文档撰写与知识记录。
  • 📓 Notion —— 一站式工作空间,用于搭建个人知识库、项目管理与深度协作。
  • 🔗 N8N —— 强大的基于节点的自动化工作流工具,轻松实现不同应用间的逻辑联动。
  • 🤖 Gemini —— 智能 AI 助手,在代码辅助、创意激发与信息检索中提供强力支撑。
我的爱好
  • 🚀 喜欢折腾各种好玩的技术
  • 📸 业余摄影爱好者
  • 🎮 各类游戏玩家
  • 💻 数码产品折腾爱好者
  • 📚 阅读:赫尔曼·黑塞 & 阿尔贝·加缪
  • 🎞️ 追番中:《电锯人:蕾塞篇》
  • 🎬 经典重温:《命运石之门》
最近正在学