C/C++ 的入口程序就是函数,函数需要传入参数,详细了解参数分类、传递规则、传递过程对写出正确且高效的程序起着至关重要的作用。笔者就曾因为传错了一个参数而导致程序崩溃,最后费了非常多的时间来查找原因,最后找出的原因是取址符(&)用错了,这让我下定决心彻底搞明白参数是怎么回事。
3.4.1、函数参数详解
参数分为输入参数、输入输出参数、输出参数、返回参数四种,分别适用于用于不同的场景,其作用和值得关注的细节如下:
参数类型 | 核心用途 | 黄金建议 |
输入参数 | 从函数外部传递数据给函数内部 | const typename |
输入输出参数 | 用于传递数据也接收数据 | 先行初始化 |
输出参数 | 用于往函数外传递数据 | 无需初始化 |
返回参数 | 用于函数返回数据(return),C/C++函数都有返回参数 | 1、const typename 2、禁止返回函数内局部对象的指针和引用 |
在输入参数和返回参数添加常量修饰符const 是一个非常好的编程习惯,能显著的预防很多错误,因为我们不知道编译器自动生成的参数入栈代码和参数出栈代码的具体模样,亦不知它何时何地执行,只能最大化的防范它的风险。
参数传递顺序有从左到右传递、从右到左传递两种,由于参数也是一个表达式,关注参数的求值顺序对写出正确的程序非常关键,例如:calc (origin++, origin+inc),如果不清楚参数表达式求值顺序就无法正确理解程序;
概念名称 | 简要说明 | 备注(案例) |
从左到右传递 | 对函数的多个输入参数从左到右求值并压入栈 | 入栈为在堆栈中分配内存 出栈为释放参数所占内存 |
从右到左传递 | 对函数的多个输入参数从右到左求值并压入栈 |
参数传递方式有值传递、引用传递、指针传递三种,三种参数本质上都是【值传递】,基本类型由于地址所指即为真实数据,传递时会生成真实数据的拷贝,将会消耗更多的堆栈内存,而引用传递和指针传递乃间接指向对象,传递时只是生成地址的拷贝,堆栈内存消耗比较少;我们首先对各个概念做一个简要回顾:
概念名称 | 简要说明 | 备注(案例) |
值传递 | 对参数求值后把其所指数据生成一份拷贝再压入栈 | 堆栈内存消耗大户 |
引用传递 | 对参数求值后把它的引用地址压入栈 | 平台字节宽度,例如32位占4字节,64位占8字节。 |
指针传递 | 对参数求值后把它的引用地址压入栈 |
很多函数调用阶段的细微错误就是忽略了参数传递的细节造成的,例如参数求值顺序、值传递还是引用传递等因素,堆栈溢出有很大一部分因素是因为错误的把结构和类对象以值传递方式传给函数导致的;
3.4.2、函数参数约定
参数传递顺序和传递形式组合形成了几种不同的函数调用约定,下面我们一起回顾一下编程实践中常见的几种约定:function resize(void ** p)
传递方式 | 简要说明 | 编译约定 |
cdecl | C调用约定,参数从右到左求值并入栈,调用函数清理堆栈;实现可变参数的函数(如printf)只能使用该调用约定 | C: _resize C++: ?resize@@YA*@Z |
thiscall | C++ 成员函数调用约定,this指针存放于ECX寄存器中,参数从右到左求值入栈,被调函数在退出时清理堆栈 |
|
stdcall | Windows API的缺省调用方式,参数以值传递方式从右到左求值入栈,被调函数在退出时清理堆栈
| C:_resize@4 C++: ?resize@@YG*@Z |
fastcall | 前两个参数是DWORD类型或更小的数据则传入ECX、EDX,其它参数以从右到左的方式求值入栈,被调函数退出时清理堆栈 | C:@resize@4 C++: ?resize@@YI*@Z |
clrcall | 从左到右加载参数到CLR expression stack | 与thiscall 合用 |
pascal | 不再使用 | 参见 stdcall |
syscall | 不再使用 |
|
fortran | 不再使用 |
|
【注】VC++对函数的省缺声明是"__cedcl",将只能被C/C++调用,C++调用约定中的符号(*)需要根据参数填充;
由于函数调用清理代码由编译器自动生成,例如C/C++ 函数调用由调用函数清理堆栈,编译器会把清理代码生成在紧挨着被调用函数位置还是在函数退出前位置?对于我们来说是未知的,这里产生了潜在的未知风险;
3.4.2、函数参数能效
参数如何传递才最安全、最有效率?
Windows平台的调用栈空间是在链接时固定分配并写入二进制文件,UNIX类平台则可以通过环境变量设置,他们的默认栈初始空间情况如下:
平台 | 默认堆栈 | 最大堆栈 | 说明 | |
SunOS/Solaris | 8192KB | 无上限 | 可通过环境变量配置 | |
Unix/Linux | 8192KB | ??? |
| |
Windows | x86/x64 | 1024KB | 32768K | 链接时修改 |
Itanium | 4096KB | 32768K | ||
cygwin | 2048KB | ??? |
| |
|
|
|
|
指针传递、引用传递都是传递对象的地址,传给函数时都是把这个地址压入栈,32位平台为四个字节,64位平台为八个字节,除结构实例、类实例外的标量类型,其数据长度均固定,可以精确的计算参数所需空间;我们做个简单的计算:取平台位宽为参数空间平均长度,以平均每个函数三个参数、1000级函数调用来计算,他们的占用空间如下:
32位平台(固定长度参数):1000 * 3 * 4Byte/ 1024Byte = 11.71875KB 64位平台(固定长度参数):1000 * 3 * 8Byte/ 1024Byte = 23.4375KB |
由此可以看出,标量类型、指针、引用等数据类型长度都小于等于机器字长,占用的空间小,入栈、出栈速度都是非常块的,一般情况下默认栈空间足够使用,不会出现堆栈溢出的问题;
哪些数据类型会潜在的降低程序效率呢?答案是结构类型、类类型,他们是程序低效率的潜在幕后黑手;由于标量类型、指针、引用占用的空间等都是机器字长,标量类型无论使用哪种方式传递,和指针、引用都是同样的速度;结构类型、类类型的值传递方式呢?
咱们需要了解一下结构类型、类类型的值传递过程:调用参数类的复制构造函数生成新的类型实例并入栈,复制构造函数编译器自动生成,用户亦可以自己编写一个;我们可先看一个案例:
struct TestClass { public: ~TestClass() {AfxMessageBox("~TestClass()");} TestClass() {AfxMessageBox("TestClass()");} TestClass(INT32 publicData, INT32 privateData, const CString & strName, constCString & strValue) : m_PublicData(publicData), m_PrivateData(privateData), m_DataName(strName), m_DataValue(strValue) { m_PublicWindow = new CWnd(); m_PrivateWindow = new CWnd(); AfxMessageBox("TestClass(INT32, INT32, CString, CString)"); }
explicit TestClass(const TestClass & obj) {AfxMessageBox("TestClass(const TestClass & obj)");} void operator=(const TestClass & obj) {AfxMessageBox("void operator=(const TestClass & obj)");} void Click() { CString strText(""); strText.AppendFormat("Click(%d, %d, %s, %s, %p, %p)", m_PublicData, m_PrivateData, m_DataName, m_DataValue, m_PublicWindow, m_PrivateWindow); AfxMessageBox(strText); } public: INT32 m_PublicData; CString m_DataName; CWnd * m_PublicWindow; private: INT32 m_PrivateData; CString m_DataValue; CWnd * m_PrivateWindow; };
void DoValueArgs( TestClass obj ) { obj.Click(); }
TestClass object(10000, 99999, "10001", "88888"); DoValueArgs(object); object.Click(); |
案例代码运行图谱,从左到右从上到下顺序摆放 | |||
| | ||
| |||
| | ||
| |
这段代码在 Visual C++ 编译器下运行的结果如上所示,其中多次执行最后三行代码,第三个窗口表现一致,由此我们可以判断出结构、类的复制构造函数不会深度复制对象,用值传递时会丢失数据。结构、类由于包含多个成员,逐个复制会倍数于标量和指针操作,带来了速度的降低;
前面的试验探讨了值传递、指针传递、引用传递,标量类型、指针、引用传递参数长度固定,安全高效,但结构、类的值传递方式带来诸多问题,例如堆栈溢出、数据丢失、效率低下等,建议结构、类完全使用指针或者引用传递;
安全隐患 | 简要说明 | 备注 |
堆栈溢出 | 如果结构和类都是很大,创建其副本会消耗大量的空间和时间,最终产生溢出错误 |
|
数据丢失 | 类对象创建副本时,会受到类实现的影响而无法完全复制,参见文档《Effective C++》第二章 | 效率降低 |
|
|
|
3.5、变量生命周期
变量声明了,是不是直接使用就万事大吉了呢?我们当然希望就是这么简单,动态语言和托管类型语言确实实施了严格初始化机制:变量只要声明就初始化为用户设置的初始值或者零值;然而 C/C++ 不是这种实施了保姆级初始化机制的语言,透彻了解 C/C++ 的初始化规则对帮助我们写出健壮的程序大有裨益;
3.5.1、变量内存分配
C / C++ 支持声明静态变量(对象)、全局变量(对象)、局部变量(对象)、静态常量等,这些变量在分配时机、内存分配位置、初始化等方面上有些细微上的差别,熟悉并掌握他们对于写出正确的程序非常有帮助,请看下表:
生命周期 | 变量类型 | 分配时机 | 初始化 |
全局生命周期 (Global lifetime) (C: Static) | 函数 | 编译时, 虚拟方发表 |
|
全局变量 | 编译时, | 首次执行,默认置零或赋值 | |
全局对象 | 编译时, | 首次执行,构造函数 | |
全局静态变量 | 编译时, | 首次执行,默认置零或赋值 | |
全局静态对象 | 编译时, | 首次执行,构造函数 | |
局部静态变量 | 编译时, | 首次执行,默认置零或赋值 | |
局部静态对象 | 编译时, | 首次执行,构造函数 | |
局部生命周期 (Local lifetime) (C: Automatic) | 局部变量 | 执行时,栈(Stack) | 可选:赋值操作 |
局部对象 | 执行时,堆(Heap) | 构造函数 |
对象创建后的成员数据取决于构造函数及其参数,系统自动生成的构造函数是不会初始化成员变量的;
对于函数、结构实例、类实例中的变量,编译器不会自动初始化,其值是不确定的,故直接使用会导致不确定的行为,这就是实践中经常碰到的程序行为表现莫名其妙的根源所在;
对于动态分配的内存(new/delete、new[]/delete[]、malloc/free),默认是不会置初值的,需要显式的初始化;对于结构和类型实例,new/new[]操作会自动调用构造函数初始化内存,详情请参见【对象初始化】;
【注】使用 VirtualAlloc/VirtualAllocEx 分配的虚拟内存会自动化初始化为零值;
【注】使用 HeapAlloc 分配的堆内存可以通过参数设置初始化为零值
3.5.2、变量初始化
从前面的变量初始化中得知结构实例、类实例、函数中声明的变量是不会自动初始化的,需要用户显式的初始化;值类型相对比较安全,可以声明时即初始化,这是最安全的作法;
数据类型 | 声明即初始化 | 备注 |
标量类型 | int data = 10; double cost = 999.22; | 所有算数类型和指针类型 |
聚合类型 | int x[ ] = { 0, 1, 2 }; char s[] = {'a', 'b', 'c', '\0'}; POINT stPoint = {0, 0};
| 数组、结构、联合类型 |
字符串类型 | char code[ ] = "abc"; char code[3] = "abcd"; | Microsoft C/C++ 支持最长2048字节的字符串 |
C/C++ 提供了两种初始化的机制可以完成结构实例和类实例的初始化,他们是:
初始化机制 | 简要说明 | 备注 |
构造函数 | 1、用户使用 new/new[] 操作时自动调用 2、构造函数顺序:从基类到子类逐层调用 3、成员变量可在构造函数主体执行前初始化 | 编译器会自动安插基类构造函数调用代码 |
用户函数 | 用户自定义并显式调用完成实例对象初始化, 例如:Initialize(); | 容易忘记调用 |
子类的构造函数被 new/new[] 操作时自动触发,它首先调用最底层基类的构造函数对其成员进行初始化,以此类推直到子类构造函数完成整个初始化过程;编译器会自动在子类构造函数的最前面中插装基类的默认构造函数以完成基类数据的初始化,如需要传递特别参数,则需要显示的调用基类构造函数。
由于类存在继承关系,基类和子类的构造函数调用存在着先后顺序关系,这意味着新对象的内存空间初始化会因为构造函数的调用顺序而呈现不同的状态:即这个对象内存块是一部分一部分的初始化; 由于这个特点,缺陷的幽灵就有了可乘之机,我们先看一个案例:
0001 class Base { 0002 public: 0003 Base():m_IntData(0){Initialize();} 0004 ~Base(){} 0005 virtual Initialize() {m_IntData = 10;} 0006 private: 0007 int m_IntData; 0008 } 0009 0010 class Derived : public Base { 0011 public: 0012 Derived() {m_pBuffer = malloc(4096);} 0013 ~Derived() {free(m_pBuffer);} 0014 virtual Initialize() {strncpy(m_pBuffer, "Testing...", _TRUNCATE);} 0015 0016 private: 0017 void* m_pBuffer; 0018 } 0019 0020 Derived * pDerived = new Derived(); 0021 Base * pBase = dynamic_cast<Base *>(pDerived); 0022 delete pBase; 0023 |
上述代码由于继承关系和内存初始化的特点而产生了两处缺陷:
代码位置 | 缺陷说明 | 备注 |
Line 20 | 由于 Initialize 函数是虚拟的并且在子类中覆盖了子类的定义,当基类构造函数调用 Initialize 时,它使用了子类未分配的内存; | 产生崩溃 |
Line 22 | delete 操作调用Base类的析构函数,然后释放对象所占用的内存,导致未释放分配的内存; | 局部释放 |
【经验总结】
构造函数中要避免调用虚函数;
析构函数中要避免抛出异常;
3.5.3、变量多态与切片
在我们深入探讨这个问题前我们先看一个代码案例,然后我们基于这个案例讲解本节:
class Shape { public: virtual ~Shape(); virtual void Draw() const {} protected: uint32 m_lineWidth; uint32 m_lineColor; };
class Rectangle : public Shape { public: virtual ~Rectangle(); virtual void Draw(); protected: uint32 m_width; uint32 m_height; };
class Trapezium : public Rectangle { public: virtual ~Trapezium(); virtual void Draw(); private: uint32 m_widthUp; }; |
图(三)类(Trapezium)实例内存空间分布图
类继承关系带来了两个全新的概念:多态(类透视)和对象切片;这两类应用在面向对象编程(OOP)语言中都很常见两个技术;
多态常见的应用情况是对象泛化,即已基类视图操作对象。它的典型构成是基类数据结构视图 + 基类成员方法视图,从字面意思我们可以解读透视图只是视野范围的改变,即用户只能看到并调用基类定义视图中的数据和方法,而非数据和方法的改变,所以函数调用的依然是当前对象的方法。如图(四)所示展示的Shape透视图所示;
图(四)类(Shape)多态透视图
下面我们来举例为您演示一下多态类透视效果,通过基类指针指向同一个对象实例,只是透过基类的结构视图来调用相关方法,由于虚拟方法表指针指向同一个虚拟方法表,所以调用的还是同一个类的函数。
// 创建对象 Trapezium objTrapezium; objTrapezium.Draw();
// 演示多态(类透视) Shape * pShape = dynamic_cast<Shape *>(&objTrapezium); if (pShape) { pShape->Draw(); }
// 演示切片 Shape objShape = (Shape)objTrapezium; objShape.Draw(); |
对象切片很好理解,相当于32位整数转换为16位整数时会根据目标类型裁减丢弃一部分数据,对象切片亦会裁减对象数据,它的变换过程是:分配目标类对象空间 è 复制源对象等长内存 è 设置虚拟方法表指针【如果有】,类对象切片与普通数据类型唯一的不同是它会切换对应的函数视图,如果有虚方法则还会切换虚拟方法表指针以确保调用正确的虚函数;
3.5.4、变量对象释放
自动分配的对象在离开其生命周期时会自动释放,这是由编译器自动保证的,一般情况下无需我们担忧;
我们需要关注的是对象指针所指的对象释放情况,尤其是跨越函数的对象值得关注,由于它的 new/delete、new[]/delete[]、malloc/free 等匹配性不明确,很容易被遗落而导致内存泄漏;比如模块A创建一个结构对象通过消息传递给模块B,模块B需要复制对象后即刻释放或者使用完毕后释放;
多态类型是我们需要着重关注的设计案例,它的析构函数在没有标记为虚函数和标记为虚函数的表现截然不同:
未标记为虚函数时它只会析构当前类实例,从对象指针类型开始向基类逐层析构,子类析构函数不会调用,会导致子类分配并持有的资源未释放,造成内存泄漏;
标记为虚函数时会按照对象指针所指对象类型往基类逐层调用其析构函数;
在图(三)所示案例中,如果基类 Shape 的析构函数未标记为虚函数,下面的代码会导致啥结果:
Shape * pNewShape = new Trapezium(); pNewShape->Draw(); delete pNewShape; |
是的,会发生内存泄漏!!!
释放对象导致内存泄漏的另一个典型案例是对象数组释放不匹配导致的,为了解释清楚这个问题,我们先看一看 delete 操作是如何实现的:
Complex * pc = new Complex(1,2); ...... delete pc;
// 编译器将 delete pc 编译为如下代码: pc->~Complex(); // 先析构 ::operator delete(pc); // 释放对象内存 |
编译器释放对象的过程分两步:调用其析构函数释放持有的资源,然后释放对象占用的内存;由于对象数组用普通对象释放操作来释放,其结果是只有第一个对象的析构函数被调用,其它对象都未调用析构函数,导致其它对象持有的内存资源未释放;我们先看一个具体的案例:
string * pNameArray = new string[3];
// 此处省略 N 行代码
delete pNameArray; // 内存泄漏 |
您或许会问:字符串对象数组本身是否完全释放?根据技术分析来看,Visual C++ 编译器会完全释放,其它编译器不确定。由于它使用普通对象释放操作,第二个、第三个字符串对象未调用其析构函数,字符串对象持有的资源未释放,导致内存泄漏。