生命周期与作用域的区别
作用域(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++中)。
堆对象的分配和销毁开销大。如果管理不当,容易产生内存碎片或内存泄漏(即对象不用了但没被清理,一直占着仓库位置)。
| |
Caution 如果对于堆对象不进行delete,则在其作用域结束后,p所指的heap object仍然存在,但指针p的生命却结束了,作用域外再也看不到p,也就没机会delete p,造成内存泄漏.
new与delete
new关键字先分配内存,在调用构造函数
| |
如上new一个对象的流程,在编译器中会被转化为以下代码(不标准,但是思路类似):
| |
delete关键字先调用析构函数,再释放内存
| |
Caution 对于动态内存分配对象而言array new一定要搭配array delete使用,否则会导致内存泄漏
同时建议:即使是不需要动态分配内存的无指针变量类,array new也要搭配array delete使用,这是一种安全规范的写法.
Example
举一个两者对比的栗子:
| |
运行该代码会发现:
栈小王在函数结束时自动打印了“被销毁了”。堆大强没有被销毁!如果你不手动delete heapPlayer,他在程序运行期间会一直占用内存,这就是内存泄漏。
但在 C++ 中,你有绝对的控制权——你可以决定一个对象是死在栈上,还是活在堆里。
为什么要用堆对象?
既然栈又快又安全,为什么 C++ 还要用堆?
- 生存期需求:有时候你需要一个对象在函数结束后依然活着(例如:在函数里加载一张地图数据,交给其他函数使用)。
- 空间限制:栈通常很小(默认 1MB~8MB)。如果你要处理一个 $1000 \times 1000$ 的大型高清贴图,直接放栈上会触发 Stack Overflow。
- 动态大小:如果你在运行时才知道需要创建多少个对象(比如玩家输入的数量),堆可以根据需要动态申请内存。
智能指针是一个栈对象(受作用域控制),它在自己的析构函数里去执行 delete。
这样,你就把堆对象那不受控的生命周期,强行绑定到了一个栈对象的作用域上。这种技术就叫 RAII (Resource Acquisition Is Initialization) 思想(资源获取即初始化),是现代 C++ 能够摆脱内存泄漏噩梦的法宝。
现代 C++ (C++11及以后) 极力推荐使用智能指针,它能让你像用栈一样安全地使用堆:
| |
这行代码实际上在内存中创造了这种结构:
- 栈上:有一个名为
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++中,深拷贝需要程序员手动实现(写拷贝构造函数)。它不仅复印钥匙,还去盖一个一模一样的仓库,并把里面的东西全搬进去。
栈上: 有两个指针,存储不同的地址。堆上: 有两份完全独立的数据。后果则是: 两个对象完全独立,互不干扰,各自管理各自的生命周期。
| |
全局/静态存储区
果说栈是“快餐盒”(用完即丢),堆是“租来的仓库”(需手动还钥匙),那么全局/静态存储区就是“自有房产”。
这块区域专门存储那些从程序启动到程序结束一直存在的数据。一般指:全局变量:定义在所有函数外部的变量。静态变量 (static):包括全局静态变量、局部静态变量以及类的静态成员。常量 (某些情况下):比如被 const 修饰的全局变量(有时会被优化到只读数据区)。
Static object静态对象
如果想让对象的声明周期突破作用域限制,可以使用static修饰符,static local objects的生命在其作用域结束之后依然存在,直到整个程序结束.
| |
global objects全局变量 写在main函数的外面,可以将其视为一种特殊的static object,其作用域也是整个程序
| |
在程序运行期间,静态变量只初始化一次,并且没有指针,但对于静态局部变量和静态成员变量的情况还有所不同,如下面例子所示:
局部静态变量
| |
第二次调用||第N次调用时:程序会直接“跳过”初始化那一行,直接去访问已经在静态存储区里待命的那个 count。因为编译器会在后台设置一个“隐藏的标志位”。每次进入函数都会检查这个标志位,如果发现已经初始化过了,就直接取值,不再调用构造函数。
全局静态变量 / 类的静态成员
| |
这里的全局静态变量和类的静态成员在 main 函数执行之前,程序加载时就会完成初始化。所有类实例共享同一个静态变量,可以通过类名或实例访问。
static 关键字
static 是 C++ 中一个多用途关键字,根据使用上下文有不同的含义。static修饰的变量的作用域可能不同,但是统一存储在全局/静态存储区(非堆非栈).而static修饰的函数存储在代码段,与普通成员函数存储位置相同.
静态成员函数
静态成员函数属于“类”本身,而不属于任何具体的“对象”实例。
该函数没有 this 指针 (最本质区别);只能访问静态成员变量和其他静态成员函数;可以通过类名直接调用;不能是虚函数(因为与实例无关)。
和普通函数一样,静态函数的二进制指令存储在程序的 代码区 (Text Segment)。
生命周期:它在程序加载时就存在了。即使你一个该类的对象都没有创建,你依然可以调用它的静态成员函数。
| |
**没有 this 指针 **
普通成员函数:当你调用 obj.func() 时,编译器会隐式地传递一个 this 指针给函数,指向 obj 的地址。
静态成员函数:它没有 this 指针。因为它不属于任何对象,所以它不知道、也无法访问具体的某个对象的成员。
由于没有 this 指针,静态成员函数在访问成员时非常“挑食”:
- 只能访问静态成员:它只能调用类里的
static变量或其他static函数。 - 不能访问非静态成员:它不能直接访问普通的成员变量或成员函数,因为这些变量需要具体的对象实例才能存在。
比喻: 静态成员函数就像是学校的“校规”(属于全校),它只能引用学校的公共设施(静态成员);而它不能直接说“把那个学生的书包拿来”,因为它不知道你在指哪一个具体的学生。
| 特性 | 普通成员函数 | 静态成员函数 (static) |
|---|---|---|
| 所属对象 | 具体的对象实例 | 整个类共用 |
this 指针 | 有 (指向当前对象) | 无 |
| 访问非静态成员 | 可以直接访问 | 不可以 |
| 访问静态成员 | 可以访问 | 可以访问 |
| 调用方式 | 必须通过对象名 obj.f() | 通过类名 Class::f() 或对象名 |
| 虚函数 (Virtual) | 可以是虚函数 | 不能是虚函数 |
静态全局变量和函数
| |
静态与单例模式(Meyers Singleton)
什么是单例模式?
单例模式(Singleton Pattern)是一种创建型设计模式,它的核心目标是:保证一个类在整个程序运行期间,有且仅有一个实例,并提供一个全局访问点。
想象一下,在一个 App 中:
- 日志系统 (Logger):你不需要到处创建日志对象,只需要一个全局的来记录所有信息。
- 配置管理器 (Config):整个 App 的设置(比如深色模式、语言)应该是统一的一份。
- 数据库连接池:为了节省资源,通常只维护一个连接池。
单例模式和 static 关键字有着“血缘关系”。在 C++ 中,静态(Static)是实现单例模式的技术基石。
它们的关系主要体现在以下三个方面:
静态成员函数是“访问入口”
单例模式要求构造函数私有化(外部不能 new),那么外部如何获取对象呢? 必须依靠一个 静态成员函数(如 getInstance())。因为静态函数不依赖对象存在,你可以直接通过 类名::getInstance() 来调用它。
静态成员变量(或局部静态变量)是“唯一容器”
为了保证实例的唯一性,这个实例必须存储在 全局/静态存储区,而不是栈或堆。
- 如果存放在栈上,函数结束对象就销毁了。
- 如果存放在堆上,需要手动管理指针,容易出错。
- 静态变量 生命周期与程序同步,且由编译器保证只初始化一次。
静态初始化保证“线程安全”
在现代 C++ (C++11及以后) 中,静态局部变量的初始化是线程安全的。这意味着即使多个线程同时调用 getInstance(),编译器也能确保唯一的实例只被创建一次。
Example
迈耶斯单例:
| |
逻辑:
- 当你第一次调用
getInstance()时,编译器在全局/静态存储区划分了一块房产,把instance放了进去,然后返回一个该对象别名。 - 因为它在静态区,所以即使函数执行完了,它也不会消失(生命周期直到程序结束)。
- 当你第二次调用时,编译器看了一眼标志位:“哦,房产已经盖好了”,直接把上次那个房子的地址返回给你。
内存表现:对象直接住在静态存储区。
优点:最简单,代码少,系统自动回收内存,不用担心泄漏。
静态指针单例
| |
Caution 类的
static成员变量在类内只是声明,必须在类外(通常在 .cpp 文件里)分配一次内存。如果不写这一行,编译器会报错说找不到这个变量。
逻辑:
- 程序启动时,在静态区分配了一个 8 字节的小格子
instance,里面填的是nullptr(空地址)。 - 当你第一次调用
getInstance(),它发现是空的,于是执行new,在堆 (Heap) 上买了一块地,盖了房子。 - 把堆内存的地址存进那个 8 字节的小格子。
- 下次调用,直接看小格子里的地址,直接去堆里找。
内存表现:指针在静态区,真正的对象在堆区。
缺点:如果你不手动写代码销毁它,程序结束时堆内存可能不会被干净回收(虽然现代系统会强制回收,但在复杂程序中这是隐患)。