c++重要知识点总结

时间:2022-09-05 10:37:21

1、内存分配的方式:

分配方式有三种:

1.静态存储区,是在程序编译时就已经分配好内存,在整个运行期间都存在,如全局变量、常量。

2.栈上分配,函数内的局部变量就是从栈上分配的,但分配的内存容量有限。

3.堆上分配,也称动态分配,如我们用newmalloc分配内存,用deletefree来释放内存。

2、内存分配的注意事项

  用newmalloc分配内存时,必须要对此指针赋初值。

  用deletefree释放内存后,必须要将指针指向NULL

  不能修改指向常量的指针数据

3、Sizeof的问题

  C++无法知道指针所指对象的大小,指针的大小永远为4字节

char a[]=Hello World!

char *p=a;

count sizeof(a) end; //12字节

count sizeof(p) endl; //4字节

而且,在函数中,数组参数退化为指针,所以下面的内容永远输出为4

void fun(char a[1000])

{

count sizeof(a) endl; //输出4而不是1000

}

4、关于指针

  1、 指针创建时必须被初始化

  2、 指针在free delete后必须置为NULL

  3、 指针的长度都为4字节

  4、释放内存时,如果是数组指针,必须要释放掉所有的内存,如

char *p=new char[100];

strcpy(p,Hello World);

delete []p; //注意前面的[]

p=NULL; //置为NULL

5、关于malloc/free new /delete

malloc/free C/C+的内存分配符,new /deleteC++的内存分配符。

注意:malloc/free是库函数,new/delete是运算符

l malloc/free不能执行构造函数与析构函数,而new/delete可以

l new/delete不能在C上运行,所以malloc/free不能被淘汰

两者都必须要成对使用

l C++中可以使用_set_new_hander函数来定义内存分配异常的处理

6、头文件作用

加强安全检测

通过头文件可能方便地调用库功能,而不必关心其实现方式

#IFNDEF/#DEFINE/#ENDIF有什么作用

仿止该头文件被重复引用

7、C++程序的性能瓶颈

1)缺页:如第四章中所述,缺页往往意味着需要访问外部存储。因为外部存储访问相对于访问内存或者代码执行,有数量级的差别。因此只要有可能,应该尽量想办法减少缺页。

2)从堆中动态申请和释放内存:如C语言中的malloc/freeC++语言中的new/delete操作非常耗时,因此要尽可能优先考虑从线程栈中获得内存。优先考虑栈而减少从动态堆中申请内存,不仅仅是因为在堆中开辟内存比在栈中要慢很多,而且还与“尽量减少缺页”这一宗旨有关。当执行程序时,当前栈帧空间所在的内存页肯定在物理内存中,因此程序代码对其中变量的存取不会引起缺页;相反,从堆中生成的对象,只有指向它的指针在栈上,对象本身却是在堆中。堆一般来说不可能都在物理内存中,而且因为堆分配内存的特性,即使两个相邻生成的对象,也很有可能在堆内存位置上相隔很远。因此当访问这两个对象时,虽然分别指向它们指针都在栈上,但是通过这两个指针引用它们时,很有可能会引起两次“缺页”。

3)复杂对象的创建和销毁:这往往是一个层次相当深的递归调用,因为一个对象的创建往往只需要一条语句,看似很简单。另外,编译器生成的临时对象因为在程序的源代码中看不到,更是不容易察觉,因此尤其值得警惕和关注。本章中专门有两节分别讲解对象的构造和析构,以及临时对象。

4)函数调用:因为函数调用有固定的额外开销,因此当函数体的代码量相对较少,且该函数被非常频繁地调用时,函数调用时的固定额外开销容易成为不必要的开销。C语言的宏和C++语言的内联函数都是为了在保持函数调用的模块化特征基础上消除函数调用的固定额外开销而引入的,因为宏在提供性能优势的同时也给开发和调试带来了不便。在C++中更多提倡的是使用内联函数,本章会有一节专门讲解内联函数。

   主要是缺页(缺页后需要访问外部存储),堆中动态申请和释放内存(由于分配的内存的不连续性导致缺页),复杂对象的创建和销毁(层次深的递归调用),函数调用

8、构造函数与析构函数

构造函数和析构函数的特点是当创建对象时,自动执行构造函数;当销毁对象时,析构函数自动被执行。这两个函数分别是一个对象最先和最后被执行的函数,构造函数在创建对象时调用,用来初始化该对象的初始状态和取得该对象被使用前需要的一些资源,比如文件/网络连接等;析构函数执行与构造函数相反的操作,主要是释放对象拥有的资源,而且在此对象的生命周期这两个函数都只被执行一次。

析构函数:

创建一个对象一般有两种方式,一种是从线程运行栈中创建,也称为“局部对象”,一般语句为:

{

        ……

        Object obj;             

        ……

}                               

销毁这种对象并不需要程序显式地调用析构函数,而是当程序运行出该对象所属的作用域时自动调用。比如上述程序中在①处创建的对象obj在②处会自动调用该对象的析构函数。在这种方式中,对象obj的内存在程序进入该作用域时,编译器生成的代码已经为其分配(一般都是通过移动栈指针),①句只需要调用对象的构造函数即可。②处编译器生成的代码会调用该作用域内所有局部的用户自定义类型对象的析构函数,对象obj属于其中之一,然后通过一个退栈语句一次性将空间返回给线程栈。

另一种创建对象的方式为从全局堆中动态创建,一般语句为:

{

        ……

        Object* obj = new Object;   

        ……

        delete obj;                 

         ……

}                                   

当执行①句时,指针obj所指向对象的内存从全局堆中取得,并将地址值赋给obj。但指针obj本身却是一个局部对象,需要从线程栈中分配,它所指向的对象从全局堆中分配内存存放。从全局堆中创建的对象需要显式调用delete销毁,delete会调用该指针指向的对象的析构函数,并将该对象所占的全局堆内存空间返回给全局堆,如②句。执行②句后,指针obj所指向的对象确实已被销毁。但是指针obj却还存在于栈中,直到程序退出其所在的作用域。即执行到③处时,指针obj才会消失。需要注意的是,指针obj的值在②处至③处之间,仍然指向刚才被销毁的对象的位置,这时使用这个指针是危险的。在Win32平台中,访问刚才被销毁对象,可能出现3种情况。第1种情况是该处位置所在的“内存页”没有任何对象,堆管理器已经将其进一步返回给系统,此时通过指针obj访问该处内存会引起“访问违例”,即访问了不合法的内存,这种错误会导致进程崩溃;第2种情况是该处位置所在的“内存页”还有其他对象,且该处位置被回收后,尚未被分配出去,这时通过指针obj访问该处内存,取得的值是无意义的,虽然不会立刻引起进程崩溃,但是针对该指针的后续操作的行为是不可预测的;第3种情况是该处位置所在的“内存页”还有其他对象,且该处位置被回收后,已被其他对象申请,这时通过指针obj访问该处内存,取得的值其实是程序其他处生成的对象。虽然对指针obj的操作不会立刻引起进程崩溃,但是极有可能会引起该对象状态的改变。从而使得在创建该对象处看来,该对象的状态会莫名其妙地变化。第2种和第3种情况都是很难发现和排查的bug,需要小心地避免。

构造函数:

创建一个对象分成两个步骤,即首先取得对象所需的内存(无论是从线程栈还是从全局堆中),然后在该块内存上执行构造函数。在构造函数构建该对象时,构造函数也分成两个步骤。即第1步执行初始化(通过初始化列表),第2步执行构造函数的函数体,如下:

class  Derived  :  public Base

{

public :

        Derived() : i(10), string("unnamed")        

        {

            ...                                      

        }

        ...

   

private :

        int  i;

        string  name;

        ...

};

①步中的 “: i(10), string("unnamed")” 即所谓的“初始化列表”,以“:”开始,后面为初始化单元。每个单元都是“变量名(初始值)”这样的模式,各单元之间以逗号隔开。构造函数首先根据初始化列表执行初始化,然后执行构造函数的函数体,即②处语句。对初始化操作,有下面几点需要注意。

(1)构造函数其实是一个递归操作,在每层递归内部的操作遵循严格的次序。递归模式为首先执行父类的构造函数(父类的构造函数操作也相应的包括执行初始化和执行构造函数体两个部分),父类构造函数返回后构造该类自己的成员变量。构造该类自己的成员变量时,一是严格按照成员变量在类中的声明顺序进行,而与其在初始化列表中出现的顺序完全无关;二是当有些成员变量或父类对象没有在初始化列表中出现时,它们仍然在初始化操作这一步骤中被初始化。内建类型成员变量被赋给一个初值。父类对象和类成员变量对象被调用其默认构造函数初始化,然后父类的构造函数和子成员变量对象在构造函数执行过程中也遵循上述递归操作。一直到此类的继承体系中所有父类和父类所含的成员变量都被构造完成后,此类的初始化操作才告结束。

(2)父类对象和一些成员变量没有出现在初始化列表中时,这些对象仍然被执行构造函数,这时执行的是“默认构造函数”。因此这些对象所属的类必须提供可以调用的默认构造函数,为此要求这些类要么自己“显式”地提供默认构造函数,要么不能阻止编译器“隐式”地为其生成一个默认构造函数,定义除默认构造函数之外的其他类型的构造函数就会阻止编译器生成默认构造函数。如果编译器在编译时,发现没有可供调用的默认构造函数,并且编译器也无法生成,则编译无法通过。

(3)对两类成员变量,需要强调指出即“常量”(const)型和“引用”(reference)型。因为已经指出,所有成员变量在执行函数体之前已经被构造,即已经拥有初始值。根据这个特点,很容易推断出“常量”型和“引用”型变量必须在初始化列表中正确初始化,而不能将其初始化放在构造函数体内。因为这两类变量一旦被赋值,其整个生命周期都不能修改其初始值。所以必须在第一次即“初始化”操作中被正确赋值。

(4)可以看到,即使初始化列表可能没有完全列出其子成员或父类对象成员,或者顺序与其在类中声明的顺序不符,这些成员仍然保证会被“全部”且“严格地按照顺序”被构建。这意味着在程序进入构造函数体之前,类的父类对象和所有子成员变量对象都已经被生成和构造。如果在构造函数体内为其执行赋初值操作,显然属于浪费。如果在构造函数时已经知道如何为类的子成员变量初始化,那么应该将这些初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构造函数体中进行这些初始化。因为进入构造函数体时,这些子成员变量已经初始化一次。

9、继承与虚拟函数

虚拟函数是C++语言引入的一个很重要的特性,它提供了动态绑定机制,正是这一机制使得继承的语义变得相对明晰。

1)基类抽象了通用的数据及操作,就数据而言,如果该数据成员在各派生类中都需要用到,那么就需要将其声明在基类中;就操作而言,如果该操作对各派生类都有意义,无论其语义是否会被修改或扩展,那么就需要将其声明在基类中。

2)有些操作,如果对于各个派生类而言,语义保持完全一致,而无需修改或扩展,那么这些操作声明为基类的非虚拟成员函数。各派生类在声明为基类的派生类时,默认继承了这些非虚拟成员函数的声明/实现,如同默认继承基类的数据成员一样,而不必另外做任何声明,这就是继承带来的代码重用的优点。

3另外还有一些操作,虽然对于各派生类而言都有意义,但是其语义并不相同。这时,这些操作应该声明为基类的虚拟成员函数。各派生类虽然也默认继承了这些虚拟成员函数的声明/实现,但是语义上它们应该对这些虚拟成员函数的实现进行修改或者扩展。另外在实现这些修改或扩展过程中,需要用到额外的该派生类独有的数据时,将这些数据声明为此派生类自己的数据成员。

再考虑更大背景下的继承体系,当更高层次的程序框架(继承体系的使用者)使用此继承体系时,它处理的是一个抽象层次的对象集合(即基类)。虽然这个对象集合的成员实质上可能是各种派生类对象,但在处理这个对象集合中的对象时,它用的是抽象层次的操作。并不区分在这些操作中,哪些操作对各派生类来说是保持不变的,而哪些操作对各派生类来说有所不同。这是因为,当运行时实际执行到各操作时,运行时系统能够识别哪些操作需要用到动态绑定,从而找到对应此派生类的修改或扩展的该操作版本。

也就是说,对继承体系的使用者而言,此继承体系内部的多样性是透明的。它不必关心其继承细节,处理的就是一组对它而言整体行为一致的对象。即只需关心它自己问题域的业务逻辑,只要保证正确,其任务就算完成了。即使继承体系内部增加了某种派生类,或者删除了某种派生类,或者某某派生类的某个虚拟函数的实现发生了改变,它的代码不必任何修改。这也意味着,程序的模块化程度得到了极大的提高。而模块化的提高也就意味着可扩展性、可维护性,以及代码的可读性的提高,这也是面向对象编程的一个很大的优点。

虚拟函数的“动态绑定”特性虽然很好,但也有其内在的空间以及时间开销,每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数指针的“虚拟函数表”(virtual table)。另外每个该类生成的对象都会隐含一个“虚拟函数指针”(virtual pointer),此指针指向其所属类的“虚拟函数表”。当通过基类的指针或者引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的“对象”所隐含的虚拟函数指针。“虚拟函数指针”,然后根据这个虚拟函数的名称,对这个虚拟函数指针所指向的虚拟函数表进行一个偏移定位,再调用这个偏移定位处的函数指针对应的虚拟函数,这就是“动态绑定”的解析过程(当然C++规范只需要编译器能够保证动态绑定的语义即可,但是目前绝大多数的C++编译器都是用这种方式实现虚拟函数的),通过分析,不难发现虚拟函数的开销:

— 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,此虚拟函数表对一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个。

— 空间:通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起空间开销跟生成的对象个数成正比。

— 时间:通过支持虚拟函数的类生成的每个对象,当其生成时,在构造函数中会调用编译器在构造函数内部插入的初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。

— 时间:当通过指针或者引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。

内联函数:因为内联函数常常可以提高代码执行的速度,因此很多普通函数会根据情况进行内联化,但是虚拟函数无法利用内联化的优势,这是因为内联函数是在“编译期”编译器将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是“运行期”行为,本质上在“编译期”编译器无法知道某处的虚拟函数调用在真正执行的时候会调用到那个具体的实现(即在“编译期”无法确定其绑定),因此在“编译期”编译器不会对通过指针或者引用调用的虚拟函数进行内联化。也就是说,如果想利用虚拟函数的“动态绑定”带来的设计优势,那么必须放弃“内联函数”带来的速度优势。

因为要实现相同的程序功能(语义),已经看到,每个对象虽然没有编译器生成的虚拟函数指针(析构函数往往被设计为virtual,如果如此,仍然免不了会隐含增加一个虚拟函数指针,这里假设不是这样),但是还是需要另外增加一个type变量用来标识派生类的类型。构造对象时,虽然不必初始化虚拟函数指针,但是仍然需要初始化type。另外,图形继承体系的使用者调用函数时虽然不再需要一次间接的根据虚拟函数表找寻虚拟函数指针的操作,但是再调用之前,仍然需要一个switch语句对其类型进行识别。

综上所述,这里列举的5条虚拟函数带来的缺陷只剩下两条,即虚拟函数表的空间开销及无法利用“内联函数”的速度优势。再考虑虚拟函数表,每一个含有虚拟函数的类在整个程序中只会有一个虚拟函数表。可以想像到虚拟函数表引起的空间开销实际上是非常小的,几乎可以忽略不计。

这样可以得出结论,即虚拟函数引入的性能缺陷只是无法利用内联函数。

可以进一步设想,非虚拟函数的常规设计假如需要增加一种新的图形类型,或者删除一种不再支持的图形类型,都必须修改该图形系统所有使用者的所有与类型相关的函数调用的代码。这里使用者只有Canvas一个,与类型相关的函数调用代码也只有PaintRotateSelected两处。但是在一个复杂的程序中,其使用者很多。并且类型相关的函数调用很多时,每次对图形系统的修改都会波及到这些使用者。可以看出不使用虚拟函数的常规设计增加了代码的耦合度,模块化不强,因此带来的可扩展性、可维护性,以及代码的可读性方面都极大降低。面向对象编程的一个重要目的就是增加程序的可扩展性和可维护性,即当程序的业务逻辑发生变化时,对原有程序的修改非常方便。而不至于对原有代码大动干戈,从而降低因为业务逻辑的改变而增加出错的可能性。根据这点分析,虚拟函数可以大大提升程序的可扩展性及可维护性。

因此在性能和其他方面特性的选择方面,需要开发人员根据实际情况进行权衡和取舍。当然在权衡之前,需要通过性能检测确认性能的瓶颈是由于虚拟函数没有利用到内联函数的优势这一缺陷引起;否则可以不必考虑虚拟函数的影响。

虚拟函数可以大大提高可扩展性和可维护性,但是相比内联函数性能不是很高,但有比没有好很多

10、临时对象

对象的创建与销毁对程序的性能影响很大。尤其当该对象的类处于一个复杂继承体系的末端,或者该对象包含很多成员变量对象(包括其所有父类对象,即直接或者间接父类的所有成员变量对象)时,对程序性能影响尤其显著。因此作为一个对性能敏感的开发人员,应该尽量避免创建不必要的对象,以及随后的销毁。这里“避免创建不必要的对象”,不仅仅意味着在编程时,主要减少显式出现在源码中的对象创建。还有在编译过程中,编译器在某些特殊情况下生成的开发人员看不见的隐式的对象。这些对象的创建并不出现在源码级别,而是由编译器在编译过程中“悄悄”创建(往往为了某些特殊操作),并在适当时销毁,这些就是所谓的“临时对象”。需要注意的是,临时对象与通常意义上的临时变量是完全不同的两个概念。

 临时变量出现在源码中,临时对象不出现在源码中但又在编译时不得不出现。

a + b实际上是执行operator+(const Matrix& arg1, const Matrix& arg2),重载的操作符本质上是一个函数,这里ab就是此函数的两个变量。此函数返回一个Matrix变量,然后进一步将此变量通过Matrix::operator=(const Matrix& mt)c进行赋值。因为a + b返回时,其中的sum已经结束了其生命周期。即在operator+(const Matrix& arg1, const Matrix& arg2)结束时被销毁,那么其返回的Matrix对象需要在调用a + b函数(这里是main()函数)的栈中开辟空间用来存放此返回值。这个临时的Matrix对象是在a + b返回时通过Matrix拷贝构造函数构造,即⑤处的输出。

既然如上所述,创建和销毁对象经常会成为一个程序的性能瓶颈所在,那么有必要对临时对象产生的原因进行深入探究,并在不损害程序功能的前提下尽可能地规避它。

临时对象在C++语言中的特征是未出现在源代码中,从堆栈中产生的未命名对象。这里需要特别注意的是,临时对象并不出现在源代码中。即开发人员并没有声明要使用它们,没有为其声明变量。它们由编译器根据情况产生,而且开发人员往往都不会意识到它们的产生。

产生临时对象一般来说有如下两种场合。

1)当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配。

2)当函数返回一个对象时(这种情形下也有例外,下面会讲到)。

另外,也有很多开发人员认为当函数传入参数为对象,并且实际调用时因为函数体内的该对象实际上并不是传入的对象,而是该传入对象的一份拷贝,所以认为这时函数体内的那个拷贝的对象也应该是一个临时对象。但是严格说来,这个拷贝对象并不符合“未出现在源代码中”这一特征。当然只要能知道并意识到对象参数的工作原理及背后隐含的性能特征,并能在编写代码时尽量规避之,那么也就没有必要在字面上较真了,毕竟最终目的是写出正确和高效的程序。

可以看到C++编译器为了成功编译某些语句,往往会在私底下“悄悄”地生成很多从源代码中不易察觉的辅助函数,甚至对象。比如上段代码中,编译器生成的赋值操作符、类型转换,以及类型转换的中间结果,即一个临时对象。

很多时候,这种编译器提供的自动类型转换确实提高了程序的可读性,也在一定程度上简化了程序的编写,从而提高了开发速度。但是类型转换意味着临时对象的产生,对象的创建和销毁意味着性能的下降,类型转换还意味着编译器还需要生成额外的代码等。因此在设计阶段,预计到不需要编译器提供这种自动类型转换的便利时,可以明确阻止这种自动类型转换的发生,即阻止因此而引起临时对象的产生。这种明确阻止就是通过对类的构造函数增加“explicit”声明。

这里可以看到,与operator+不同,operator+=并没有产生临时变量,operator+则只有在返回值被用来初始化一个对象,而不是对一个已经生成的对象进行赋值时才不产生临时对象。而且往往返回值被用来赋值的情况并不少见,甚至比初始化的情况还要多。因此使用operator+=不产生临时对象,性能会比operator+要好,为此尽量使用语句:

a += b;

而避免使用:

a = a + b;

可以看到,因为考虑到后置++的语义,所以在实现中必须首先保留其原来的值。为此需要一个局部变量,如①处所示。然后值增1后,将保存其原值的局部变量作为返回值返回。相比较而言,前置++的实现不会需要这样一个局部变量。而且不仅如此,前置的++只需要将自身返回即可,因此只需返回一个引用;后置++需要返回一个对象。已经知道,函数返回值为一个对象时,往往意味着需要生成一个临时对象用来存放返回值。因此如果调用后置++,意味着需要多生成两个对象,分别是函数内部的局部变量和存放返回值的临时变量。

有鉴于此,对于非内建类型,在保证程序语义正确的前提下应该多用:

++i;

而避免使用:

i++;

同样的规律也适用于前置--和后置--(与=/+=相同的理由,考虑到维护性,尽量用前置++来实现后置++)。

11、内联函数

C++语言的设计中,内联函数的引入可以说完全是为了性能的考虑。因此在编写对性能要求比较高的C++程序时,非常有必要仔细考量内联函数的使用。

所谓“内联”,即将被调用函数的函数体代码直接地整个插入到该函数被调用处,而不是通过call语句进行。当然,编译器在真正进行“内联”时,因为考虑到被内联函数的传入参数、自己的局部变量,以及返回值的因素,不仅仅只是进行简单的代码拷贝,还需要做很多细致的工作,但大致思路如此。

开发人员可以有两种方式告诉编译器需要内联哪些类成员函数,一种是在类的定义体外;一种是在类的定义体内。

(1)当在类的定义体外时,需要在该成员函数的定义前面加“inline”关键字,显式地告诉编译器该函数在调用时需要“内联”处理。

(2)当在类的定义体内且声明该成员函数时,同时提供该成员函数的实现体。此时,“inline”关键字并不是必需的。

(3)当普通函数(非类成员函数)需要被内联时,则只需要在函数的定义时前面加上“inline”关键字,如:

   inline int DoSomeMagic(int a, int b)

{

        return a * 13 + b % 4 + 3;

}

因为C++是以“编译单元”为单位编译的,而一个编译单元往往大致等于一个“.cpp”文件。在实际编译前,预处理器会将“#include”的各头文件的内容(可能会有递归头文件展开)完整地拷贝到cpp文件对应位置处(另外还会进行宏展开等操作)。预处理器处理后,编译真正开始。一旦C++编译器开始编译,它不会意识到其他cpp文件的存在。因此并不会参考其他cpp文件的内容信息。联想到内联的工作是由编译器完成的,且内联的意思是将被调用内联函数的函数体代码直接代替对该内联函数的调用。这也就意味着,在编译某个编译单元时,如果该编译单元会调用到某个内联函数,那么该内联函数的函数定义(即函数体)必须也包含在该编译单元内。因为编译器使用内联函数体代码替代内联函数调用时,必须知道该内联函数的函数体代码,而且不能通过参考其他编译单元信息来获得这一信息。

如果有多个编译单元会调用到某同一个内联函数,C++规范要求在这多个编译单元中该内联函数的定义必须是完全一致的,这就是“ODR”(one-definition rule)原则。考虑到代码的可维护性,最好将内联函数的定义放在一个头文件中,用到该内联函数的各个编译单元只需#include该头文件即可。进一步考虑,如果该内联函数是一个类的成员函数,这个头文件正好可以是该成员函数所属类的声明所在的头文件。这样看来,类成员内联函数的两种声明可以看成是几乎一样的,虽然一个是在类外,一个在类内。但是两个都在同一个头文件中,编译器都能在#include该头文件后直接取得内联函数的函数体代码。讨论完如何声明一个内联函数,来查看编译器如何内联的。

可以看到使用内联函数至少有如下两个优点。

1)减少因为函数调用引起开销,主要是参数压栈、栈帧开辟与回收,以及寄存器保存与恢复等。

2)内联后编译器在处理调用内联函数的函数(如上例中的foo()函数)时,因为可供分析的代码更多,因此它能做的优化更深入彻底。前一条优点对于开发人员来说往往更显而易见一些,但往往这条优点对最终代码的优化可能贡献更大。

在前面章节中已经讨论,如果传入参数和返回值为对象时,还会涉及对象的构造与析构,函数调用的开销就会更大。尤其是当传入对象和返回对象是复杂的大对象时,更是如此。

因为函数调用的准备与善后工作最终都是由机器指令完成的,假设一个函数之前的准备工作与之后的善后工作的指令所需的空间为SS,执行这些代码所需的时间为TS,现在可以更细致地从空间与时间两个方面来分析内联的效果。

1)在空间上,一般印象是不采用内联,被调用函数的代码只有一份,调用它的地方使用call语句引用即可。而采用内联后,该函数的代码在所有调用其处都有一份拷贝,因此最后总的代码大小比采用内联前要大。但事实不总是这样的,如果一个函数a的体代码大小为AS,假设a函数在整个程序中被调用了n次,不采用内联时,对a的调用只有准备工作与善后工作两处会增加最后的代码量开销,即a函数相关的代码大小为:n * SS + AS。采用内联后,在各处调用点都需要将其函数体代码展开,即a函数相关的代码大小为n * AS。这样比较二者的大小,即比较(n * SS + AS)(n*AS)的大小。考虑到n一般次数很多时,可以简化成比较SSAS的大小。这样可以得出大致结论,如果被内联函数自己的函数体代码量比因为函数调用的准备与善后工作引入的代码量大,内联后程序的代码量会变大;相反,当被内联函数的函数体代码量比因为函数调用的准备与善后工作引入的代码量小,内联后程序的代码量会变小。这里还没有考虑内联的后续情况,即编译器可能因为获得的信息更多,从而对调用函数的优化做得更深入和彻底,致使最终的代码量变得更小。

2)在时间上,一般而言,每处调用都不再需要做函数调用的准备与善后工作。另外内联后,编译器在做优化时,看到的是调用函数与被调用函数连成的一大块代码。即获得的代码信息更多,此时它对调用函数的优化可以做得更好。最后还有一个很重要的因素,即内联后调用函数体内需要执行的代码是相邻的,其执行的代码都在同一个页面或连续的页面中。如果没有内联,执行到被调用函数时,需要跳到包含被调用函数的内存页面中执行,而被调用函数所属的页面极有可能当时不在物理内存中。这意味着,内联后可以降低“缺页”的几率,知道减少“缺页”次数的效果远比减少一些代码量执行的效果。另外即使被调用函数所在页面可能也在内存中,但是因为与调用函数在空间上相隔甚远,所以可能会引起“cache miss”,从而降低执行速度。因此总的来说,内联后程序的执行时间会比没有内联要少。即程序的速度更快,这也是因为内联后代码的空间“locality”特性提高了。但正如上面分析空间影响时提到的,当AS远大于SS,且n非常大时,最终程序的大小会比没有内联时要大很多。代码量大意味着用来存放代码的内存页也会更多,这样因为执行代码而引起的“缺页”也会相应增多。如果这样,最终程序的执行时间可能会因为大量的“缺页”而变得更多,即程序的速度变慢。这也是为什么很多编译器对于函数体代码很多的函数,会拒绝对其进行内联的请求。即忽略“inline”关键字,而对如同普通函数那样编译。

综合上面的分析,在采用内联时需要内联函数的特征。比如该函数自己的函数体代码量,以及程序执行时可能被调用的次数等。当然,判断内联效果的最终和最有效的方法还是对程序的大小和执行时间进行实际测量,然后根据测量结果来决定是否应该采用内联,以及对哪些函数进行内联。

如下根据内联的本质来讨论与其相关的一些其他特点。

如前所述,因为调用内联函数的编译单元必须有内联函数的函数体代码信息。又因为ODR规则和考虑到代码的可维护性,所以一般将内联函数的定义放在一个头文件中,然后在每个调用该内联函数的编译单元中#include该头文件。现在考虑这种情况,即在一个大型程序中,某个内联函数因为非常通用,而被大多数编译单元用到对该内联函数的一个修改,就会引起所有用到它的编译单元的重新编译。对于一个真正的大型程序,重新编译大部分编译单元往往意味着大量的编译时间。因此内联最好在开发的后期引入,以避免可能不必要的大量编译时间的浪费。

再考虑这种情况,如果某开发小组在开发中用到了第三方提供的程序库,而这些程序库中包含一些内联函数。因为该开发小组的代码中在用到第三方提供的内联函数处,都是将该内联函数的函数体代码拷贝到调用处,即该开发小组的代码中包含了第三方提供代码的“实现”。假设这个第三方单位在下一个版本中修改了某些内联函数的定义,那么虽然这个第三方单位并没有修改任何函数的对外接口,而只是修改了实现,该开发小组要想利用这个新的版本,仍然需要重新编译。考虑到可能该开发小组的程序已经发布,那么这种重新编译的成本会相当高;相反,如果没有内联,并且仍然只是修改实现,那么该开发小组不必重新编译即可利用新的版本。

因为内联的本质就是用函数体代码代替对该函数的调用,所以考虑递归函数,如:

[inline] int foo(int n)

{

        ...

        return foo(n-1);

}

如果编译器编译某个调用此函数的编译单元,如:

void func()

{

        ...

        int m = foo(n);

        ...

}

考虑如下两种情况。

1)如果在编译该编译单元且调用foo时,提供的参数n不能知道其实际值,则编译器无法知道对foo函数体进行多少次代替。在这种情况下,编译器会拒绝对foo函数进行内联。

2)如果在编译该编译单元且调用foo时,提供的参数n能够知道其实际值,则编译器可能会视n值的大小来决定是否对foo函数进行内联。因为如果n很大,内联展开可能会使最终程序的大小变得很大。

如前所述,因为内联函数是编译期行为,而虚拟函数是执行期行为,因此编译器一般会拒绝对虚拟函数进行内联的请求。但是事情总有例外,内联函数的本质是编译器编译调用某函数时,将其函数体代码代替call调用,即内联的条件是编译器能够知道该处函数调用的函数体。而虚拟函数不能够被内联,也是因为在编译时一般来说编译器无法知道该虚拟函数到底是哪一个版本,即无法确定其函数体。但是在两种情况下,编译器是能够知道虚拟函数调用的真实版本的,因此虚拟函数可以被内联。

其一是通过对象,而不是指向对象的指针或者对象的引用调用虚拟函数,这时编译器在编译期就已经知道对象的确切类型。因此会直接调用确定的某虚拟函数实现版本,而不会产生“动态绑定”行为的代码。

其二是虽然是通过对象指针或者对象引用调用虚拟函数,但是编译时编译器能知道该指针或引用对应到的对象的确切类型。比如在产生的新对象时做的指针赋值或引用初始化,发生在于通过该指针或引用调用虚拟函数同一个编译单元并且二者之间该指针没有被改变赋值使其指向到其他不能确切知道类型的对象(因为引用不能修改绑定,因此无此之虞)。此时编译器也不会产生动态绑定的代码,而是直接调用该确定类型的虚拟函数实现版本。

当然在实际开发中,通过这两种方式调用虚拟函数时应该非常少,因为虚拟函数的语义是“通过基类指针或引用调用,到真正运行时才决定调用哪个版本”。

从上面的分析中已经看到,编译器并不总是尊重“inline”关键字。即使某个函数用“inline”关键字修饰,并不能够保证该函数在编译时真正被内联处理。因此与register关键字性质类似,inline仅仅是给编译器的一个“建议”,编译器完全可以视实际情况而忽略之。

另外从内联,即用函数体代码替代对该函数的调用这一本质看,它与C语言中的函数宏(macro)极其相似,但是它们之间也有本质的区别。即内联是编译期行为,宏是预处理期行为,其替代展开由预处理器来做。也就是说编译器看不到宏,更不可能处理宏。另外宏的参数在其宏体内出现两次或两次以上时经常会产生副作用,尤其是当在宏体内对参数进行++--操作时,而内联不会。还有,预处理器不会也不能对宏的参数进行类型检查。而内联因为是编译器处理的,因此会对内联函数的参数进行类型检查,这对于写出正确且鲁棒的程序,是一个很大的优势。最后,宏肯定会被展开,而用inline关键字修饰的函数不一定会被内联展开。

最后顺带提及,一个程序的惟一入口main()函数肯定不会被内联化。另外,编译器合成的默认构造函数、拷贝构造函数、析构函数,以及赋值运算符一般都会被内联化。

 

 

本质上虚拟内存就是要让一个程序的代码和数据在没有全部载入内存时即可运行。运行过程中,当执行到尚未载入内存的代码,或者要访问还没有载入到内存的数据时,虚拟内存管理器动态地将这部分代码或数据从硬盘载入到内存中。而且在通常情况下,虚拟内存管理器也会相应地先将内存中某些代码或者数据置换到硬盘中,为即将载入的代码或数据腾出空间。

因为内存和硬盘之间的数据传输相对代码执行来说,是非常慢的操作,因此虚拟内存管理器在保证工作正确的前提下,还必须考虑效率因素。比如,它需要优化置换算法,尽量避免就要执行的代码或访问的数据刚被置换出内存,而很久没有访问的代码或数据却一直驻留在内存中。另外它还需要将驻留在内存的各个进程的代码或数据维持在一个合理的数量上,并且根据该进程的性能表现动态调整此数量,等等,使得程序运行时将其涉及的磁盘I/O次数降到尽可能低,以提高程序的运行性能。

12、 STL使用步骤

1.添加相应的头文件(如 #include <list> )( 注意,没有 .h )

2.添加std命名空间(用 using namespace std; )

3.赋予模板具体的使用类型(如 typedef list<string> LISTSTR; )

4.实例化模板(如 LISTSTR test; )

5.实例化游标(如 LISTSTR::iterator i; )

6.通过迭代器对象访问模板对象,例如

  // 逐个输出链表test中的元素

            for ( i =  test.begin(); i != test.end(); ++i )

                cout << *i << " ";