赞
踩
通过前面两节课的内容,我带领大家熟悉了一下 Visual Studio C++ 开发环境的必备知识,虽然还有很多关于 Visual Studio 的重要知识没有介绍,但为了让你尽快进入 C++ 开发环节,及早获得开发程序的愉悦,我们暂时只介绍这些必备知识。
C++ 创建至今已有近 40 年的历史,经历了数次重大的变革,本贾尼老爷子自己也说现在的 C++ 就像一门全新的语言。不过 C++ 终究是基于 C 发展而来的,时至今日 C++ 还是和 C 保持着最大的兼容性。
当前的软件世界还有很多项目都是基于 C 或者早期版本的 C++ 构建而来的,随着时间的发展它们已经成为了当今计算机软件世界的基础设施,比如 Windows 操作系统、 SDK 、SQLite 、FFmpeg 等。我们使用现代 C++ 开发软件项目往往要依赖这些基础设施,如果只使用现代 C++ 特性而不了解底层原理,那么可能无法很好地使用这些基础设施。
从本节开始,我将带领你学习现代 C++ 语言及相关知识。首先介绍 C++ 的编译流程,了解这些知识之后你就知道 C++ 的头文件和源码文件是如何被编译成二进制可执行文件的了。接着会讲解一个应用程序运行期的内存布局,了解了这些知识之后,你就知道在代码中的那些变量到底存储在用户内存的哪个区域了。
这些知识都是开发 C++ 应用程序的必备知识,掌握了这些知识再去学习 C++ 的语法就会更加从容。
如果看过 C++ 项目的代码,你就会发现,项目中分为两种文件:头文件(.h)
和源码文件(.cpp)
。不像 JavaScript 项目,只有 js 文件,为什么 C++ 项目要把代码写在两种不同的文件中呢?
这里不讨论 .mm、.hpp 等扩展名的 C++ 文件。
这要从 C++ 的编译流程说起。在 C++ 项目中,头文件和源码文件往往是成对儿出现的,我们把一对儿 .h 和 .cpp 文件称为一个代码单元,源码文件通过#include
指令引入与自己对应的头文件,头文件和源码文件也可以通过#include
指令引用另一个代码单元的头文件,以获得另一个代码单元的能力。
无论是头文件还是源码文件,最终都会被编译到可执行文件中,整个过程如下图所示:
在上图中,Class2 的头文件引入了(#include
) Class1 的头文件,Class2 的源文件引入了 Class3 的头文件。
在 Visual Studio 编译程序时,第一个环节:预处理
环节,就是用来处理这些引用关系的,在这个环节 Visual Studio 会把 #include "Class1.h"
这行的代码替换成Class1.h
文件中的全部内容。
注意:Class1.h
中往往只包含类型、方法、变量的声明而不包含实现逻辑。不过 C++ 规定只要给出类型、方法、变量的声明,就可以使用它们,所以这里的作用只是让 Class2 得到 Class1 在头文件中声明的内容,得到了这些声明之后,编译器就不会出现语法错误(比如:使用了未定义的变量或方法)。此时的 Class2 是不知道 Class1 的具体实现的。
预处理环节除了完成
#include
指令的替换工作之外,还完成了条件编译指令#if #elif #endif
的处理工作、常量的替换工作等。
完成预处理环节的工作之后,Visual Studio 开始执行编译环节的工作,在这个环节编译器经过词法分析、语法分析、语义分析、代码优化、汇编等过程把各个类(或程序单元)编译成机器指令。
在这个过程中,如果你的程序有一些编译器能发现的错误,Visual Studio 则会提示编译异常。
最终生成的机器指令被存放在一系列的 .obj 文件中,你可以在[YourSolutionDir]\x64\Debug
目录下找到这些文件。
完成编译环节的工作之后,Visual Studio 开始执行链接环节的工作,链接器会把上一个环节生成的所有 obj 文件,还有标准库的 lib 文件、第三方库的 lib 文件链接到一起,最终生成可执行文件或动态链接库(.exe 文件或.dll 文件)。
只有链接工作执行完成之后,Class1 的实现逻辑才和 Class2 的实现逻辑绑定到一起,Class2 才可以真正地访问 Class1 的方法。
由此可见,C++ 使用头文件和源码文件一定程度上起到了隐藏实现细节、控制访问权限的目的。之所以 C++ 要在两个文件中完成这项工作,主要是为了适应配编译器的要求。
#pragma once
现代 C++ 头文件中往往会用到了一个预处理指令:#pragma once
。这个指令告诉预处理器这个文件只会被处理一次。
比如,Class1.h 引入了 Class2.h 和 Class3.h ,而 Class3.h 的头文件也引入了 Class2.h 的头文件,如下图所示:
前面我们说了,#include
语句会被替换成被包含的文件,那么如果不加处理的话,最终 Class1.h 中将包含两份 Class2.h 的内容。
如果 Class2.h 中包含#pragma once
这个预处理器指令,则在预处理器处理 Class1.h 的头文件时,则只会包含一次 Class2.h 的内容,不会因为 Class3.h 也包含 Class2.h 的引用就会在 Class1.h 中创建两份 Class2.h 的内容。
这是现代 C++ 编译器新增的一个指令,这个指令出现之前,C++ 开发者都是通过如下方式来保证头文件不会被重复编译的。
- #ifndef _FileA
- #define _FileA
- // code
- #endif
这种方式书写起来繁琐,编译时也低效,所以推荐使用#pragma once
指令。一般情况下,C++ 头文件中都会使用和加入 #pragma once
指令。
一个应用程序在操作系统中运行时,它占用的内存一般分为以下几个区域。
如下图所示:
一个进程真正的内存使用情况并不像上图中描述的这样规整,每个区块的大小差异巨大,而且不同类别的内存可能会交叉出现,不同的内存区间也可能是不连续的、碎片化的。你应该关注数据段、堆和栈这三个内存区域,后文中我们还会反复提到这些概念。
JavaScript 的解释引擎 V8 也遵循这个内存布局约定,但 JavaScript 并不遵循这个约定,因为 JavaScript 是运行在 V8 之上的,由 V8 定义 JavaScript 的内存布局模型。
我们先来简单介绍一下栈与栈帧的用途,如下图所示:
当你的程序进入 main 方法后,程序将在栈
空间中创建一个栈帧
,变量 a 和 b 保存在这个栈帧
中;当 main 方法调用 method1 时,程序将在栈
空间中创建第二个栈帧
,变量 c 和 d 保存在第二个栈帧
中;当 method1 调用 method2 时会执行类似的工作,当 method2 方法返回时,method2 的栈帧
会被销毁,栈帧
上保存的变量也会被销毁。method1 返回时也会执行同样的销毁工作。
如果你在 method1 中调用 method2 时传递了方法参数,很多时候这些参数也会被拷贝到 method2 的栈帧
中,在 method2 中修改这些参数,只是在修改 method2 栈帧上的参数副本,并不会影响 method1 中的对应变量。
栈帧
把栈
空间切分成了很多片,每个方法享有独立的内存空间,除非专门的设置(后文会讲:引用参数),一个方法不会更改另一个方法的栈帧
内存。
在栈
空间中分配内存的变量不需要程序员手动销毁,栈帧
销毁时栈帧
上的变量会被自动销毁。
栈的总内存大小是固定的,而且非常小,在 Windows 操作系统中默认大小为 1M,当在栈空间中申请的内存超过栈的剩余空间时,将提示内存溢出错误。这也是为什么开发者要关注递归调用引发栈溢出的原因(递归调用会创建非常多的栈帧)。
操作系统会专门分配寄存器存放栈的地址,入栈、出栈都有专门的指令执行,所以操作栈上的内存效率非常高。
如果想让某个变量在函数调用结束之后仍然可用,那么可以在堆
空间中为变量分配内存,使用 new 操作符就可以完成这项工作,new 操作符返回堆空间的地址(就是指针)。除非专门的设置(后文会讲:智能指针),开发者必须自己完成堆内存的释放工作,使用 delete 关键字可以完成这项工作(C++ 中的 delete 关键字与 JavaScript 中的 delete 关键字差异巨大)。
堆内存的大小与计算机系统中有效虚拟内存大小有关,比栈空间要大得多,大部分时候开发者都会把大对象或数组放到堆中。
堆内存没有专门的优化,使用效率较低,且容易产生内存碎片。
本节我们首先介绍了 C++ 的编译流程,编译工具在预处理环节替换了所有的 #include
指令,编译工具在链接环节把各个编译单元的二进制代码链接到一起,这样 C++ 的头文件和源码文件就正式被编译成二进制可执行文件了。
接着我们讲解了应用程序的内存布局,其中最重要的是:数据段、堆和栈。数据段用于存储全局变量和静态变量;堆用于存储应用程序运行过程中使用 new 或 malloc 申请的内存空间;栈用于存储函数的局部变量、参数、返回值及调用者的上下文信息。
这些知识是 C++ 开发的基础知识,掌握了这些知识之后我们再开启 C++ 编码之旅就会少一些疑惑,多一些勇往直前的勇气。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。