C++相关知识点及建议总结(二)


上一篇笔记请参阅C++相关知识点及总结(一)
本文包含:

  • 语言特性
  • 内存管理不当的隐患
  • new/deletemalloc/free相关的内存管理操作
    智能指针及RAII相关的内存管理会放在下一篇

语言特性

  • 大端与小端:所谓大端就是指高位值在内存中放低位地址,所谓小端是指低位值在内存中放低位地址。C/C++中判断大小端的方法是,利用联合体Union中char数组和int可以公用同一段空间地址的特点,检查输出地址和字节的对应关系。
  • 如果是实现语言之间的混合编程
    • 一种方式就是语言对另一种语言留了接口;
    • 另一种方式就是通过比更低级的公用语言形式(如二进制目标文件,可以是动态静态链接库等)进行沟通。但是因为很多语言(JAVA,Python)的虚拟机本身就是C++写的,所以可以直接在虚拟机中用C++就完成所有的操作。
      • Python本身就是一个C库,其功能实现都是通过动态链接库实现,在C++调用时需要包含头文件进行实现。
  • C++中的结构体和类的联系和差别

    • C++中的结构体不止可以定义成员变量,还可以定义成员函数,结构体和类可以实现相类似的功能(可以说类是结构体的演化版本),同样结构体可以实现继承,多态
    • 差别一在于默认继承方式,结构体默认继承方式是private,而类的继承方式默认是Public。差别二在于是否能定义模板。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      struct Base{
      Base(int){}//定义
      Base(string)
      };
      struct Derived: public Base{
      using Base::Base;
      Derived(double){}
      };
      int main(){
      Derived(2011); //Base::Base(2011)
      Derived("C++11"); //Base::Base("C++11")
      Derived(0.33); //Derived::Derived(0.33)
      }
  • extern "C"(链接指示):C++程序优势需要调用其他语言编写的函数,最常见的是调用C语言编写的函数,像其他所有名字一样,其他语言中的函数名字也必须在C++中进行声明,并且该声明必须指定返回类型和形参列表。低于其他语言编写的函数来说,编译器检查其调用的方式与处理普通C++函数的方式相同,但是生成的代码有所区别

    • 注意:要想把C++代码和其他语言编写的代码放在一起使用,必须要求有权访问该语言的编辑器,并且该编辑器与当前C++编辑器是兼容的(GCC);
    • 声明非C++函数的方式:extern+字符串字面值常量+普通函数声明,其中字符串字面值常量代表函数所用的语言,如”Ada”;
      • 链接指示不可能出现在类定义/函数定义的内部,同样的链接指示必须在函数的每个声明中都出现;
      • 注:C++从C语言继承的标准库函数可以定义成C函数,但并非必须;决定使用C还是C++实现C标准库是每个C++实现的事情;
    • 指向extern “C”函数的指针
      • 编写函数所用的语言是函数类型的一部分(因为C++和C中对函数编译得到的结果是不同的),因此,对于使用链接指示定义的函数,它的每个声明都必须使用相同的链接指示,如extern "C" void (*pf)(int);
      • 指向C++函数的指针和指向C函数的指针是不一样的类型,因为类型不匹配,之间不能进行赋值操作;
    • 链接指示对整个声明都有效:不仅对函数有效,对作为返回类型或形参类型的函数指针也有效,如extern "C" void f1(void(*)(int));中,其传入的参数,即函数指针必须是一个指向C函数的指针。
      • 那如果我们希望给C++函数传入一个指向C函数的指针,就必须使用类型别名了,如extern "C" typedef void FC(int);void f2(FC*);;
    • 也可以将C++函数导出到其他语言,如extern "C" double calc(double dparm){/*...*/},则编译器会为函数生成适合指定语言的代码;
    • 在C和C++中编译同一个源文件,方式如下:
      • 在编译C++版本程序时定义cplusplus,在编译C++程序时做相应条件编译,如`#ifdef cplusplus\extern “C”\#endif`
    • 链接指示与重载函数的相互作用依赖于目标语言,如果目标语言支持重载函数,则为该语言实现链接指示的编译器可能也支持重载这些C++函数
      • C语言不支持重载函数,因此一个”C”链接指示只能作用于重载函数中的一个。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        //单语句链接指示
        extern "C" size_t strlen(const char *);
        //复合语句链接指示
        extern "C"{
        int strcmp(const char*, const char*);
        char *strcat(char*,const char*);
        }
        //整个头文件进行链接
        extern "C"{
        #include <string.h>
        }
  • 字符串:std::stringstd::wstring(w for wide)

    • 在C++中见到的字符串,如果是"Hello"就代表是ASCII编码,如果是L"Hello"就代表是Unicode编码;
    • 上述字符串分别对应着std::string类(一个字符占一个字节)和std::wstring类(一个字符占两个字节)。
  • 所谓闭包(closure):又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。在lua中local本地变量的值被保存在函数中,即每次进入函数时local变量会记录上次的取值。

内存管理

隐患及相关概念

  • 野指针、迷途指针(悬空指针),都是不指向任何合法对象的指针。
    • 迷途指针(dangling pointer)
      • 产生:当所指的对象被释放或收回,但是对该指针没有做任何的修改,以至于该指针仍指向已经回收的内存地址,这就是迷途指针(调用malloc()和free()库函数常产生的问题);
      • 可能后果:若操作系统将这部分已经释放的内存重新分配给另外一个进程,而原来的程序重新引用现在的迷途指针,向其中写入数据,则这部分程序内容将被破坏,而导致程序错误。这种类型的程序错误,通常会导致segment fault和一般的保护错误。
      • 其他常见错误:返回一个基于栈分配的局部变量的地址时,一旦调用的函数返回,分配给这些变量的空间将回收,此时它们拥有的是垃圾值,如return &num,如果要使它的生命周期边长,应该将其声明为static
    • 野指针(wild pointer):
      • 产生:未初始化的指针。注意static pointer不是野指针,因为静态变量会被初始化为0。
      • 可能产生与dangling pointer一样的问题,包括程序,信息泄露(指针指向一段只读的内存),或者访问权限的增加。
    • 避免迷途指针的错误:
      • C++中推荐使用智能指针(smart pointer),用RAII的方法管理资源,当对象的引用计数变为0时,将自动回收对象。
        • 或者在指针指向内存空间被释放时,立即将该指针置为空指针或者非法地址,这样在指针被引用时,程序就会立即停止。
  • 内存泄漏:内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存,或者说是在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费
    • 可能导致可用内存的数量越来愈少,从而导致计算机性能降低。
    • 在现代操作系统中,一个应用程序使用的常规内存在程序终止时被释放。这表示一个短暂运行的应用程序中的内存泄漏不会导致严重后果。但是在内存非常有限的系统中都可能导致非常严重的后果。
  • 内存溢出导致安全问题:所谓内存溢出就是内存越界,内存越界存在一种情况就是调用栈溢出(stackoverflow),还有一种情况是缓冲区溢出,这两种情况都会导致安全漏洞:
    • strcpy会一直复制直到碰到\0,很多平台的栈变量是按照地址顺序倒着分配的(高地址向低地址),所以destination溢出后会先修改先前定义的变量,这样黑客就可以把is_administrator改为true,从而造成缓冲区溢出攻击
    • 栈溢出攻击:在栈上分配length字节的空间,再往栈顶放上一个data。当Length十分大,会把data挤到栈空间之外,此时如果编译器不做越界检查的话,那么黑客只要用客户端送特定的length和data,就能改写服务器的任意内存(比如黑客可以修改服务器代码的机器码,注入一些JMP指令跳转到黑客想执行的函数)。
    • 当然以上两个例子还说明一件事是:不要相信用户的输入。
      1
      2
      3
      4
      5
      6
      7
      // 缓冲区溢出攻击
      const int MAX_LENGTH = 16;
      bool is_administrator = false;
      char destination[MAX_LENGTH];

      std::string source = read_string_from_client(); //内容存储在缓冲区
      strcpy(destination,source.c_str());
1
2
3
4
// 栈溢出攻击
int length = read_int_from_client();
char buffer[length]; //栈空间分配
int data = read_int_from_client();
  • 堆(数据结构):堆可以被看作一棵树的数组对象,用队列做任务调度时总是反复提取第一个作业并运行,但是实际情况中kn
    • 堆的实现通过构造二叉堆(binary heap)即二叉堆的一种,可以当作优先队列使用。其特点如下
      • 任意节点均小于(或大于)它的所有后裔,最小元(或最大元)在堆的根上(性),根结最大的堆是最大堆/大根堆,最小是最小堆/小根堆;
      • 堆总是一棵完全树,即除最底层外其他层的结点都被元素填满,且最底层尽可能从左到右填入。
    • 为什么要设计堆这种数据结构?
  • 栈:栈是由高地址向低地址延伸的,每个函数的每次调用,都有自己独立的一个“栈帧”,这个栈帧中维持着所需要的各种信息,就比如会有一个寄存器ebp和另一个寄存器esp分别指向当前栈帧的底部(高地址)和顶部(低地址)。当前的ebp,b for baseesp,s for stack之间的栈就是当前函数的栈帧,下一条CPU要执行的指令,其地址会存储在EIP寄存器中。
    • 栈帧:就是编译器存储在用户栈上的每一次函数调用设计的相关信息的记录单元,那么显然栈上保持了N个栈帧的实体,或者说栈帧将栈分割成立N个记录块,但记录块大小不是固定的,因为栈帧会保存如:函数入参、出参、返回地址和上一个栈帧的栈底指针等信息,还保存了函数内部的自动变量

C/C++内存管理详细(不含智能指针)

  • 内存分配:在C++中,内存分成5个区,其分布和功能如下:
    • :在执行函数时,函数内局部变量的存储单元都可以在栈上进行创建,函数执行结束时这些存储单元被自动释放。栈分配运算内置于处理器的指令集中,效率高,但是可分配内存有限(由编译器分配的空间)。
    • :由new分配的内存块,由程序员分配的空间,假如程序员没有及时释放掉空间,那么在程序结束后,操作系统会自动回收。
    • 自由存储区:由malloc分配的内存块,由free进行回收。
    • 全局/静态存储区:全局变量和静态变量被分配到同一块内存中。
    • 常量存储区:存放常量(const),不允许修改。
  • 一个例子void f(){int*p = new int[5];}中就是在堆中分配一块内存,并且将其指针存放在栈中。这个时候要释放这个数组,就应该用delete []p;而不是delete p;(因为char*char[]还是不一样的),那编译器就会根据cookie信息去进行内存释放工作。堆与栈的区别
    • 管理方式不同
      • 栈是编译器管理,堆的占用和释放都是由程序员进行控制的;
    • 空间大小不同
      • 在32位系统下,一般堆的内存可以达到4G的空间,可以说堆内存几乎是没有限制的。但是对于栈,一般都有一定空间大小(跟编译器有关),比如在VC6下默认的栈空间大小是1M
    • 能否产生碎片不同
      • 对于堆来说,频繁的new/delete操作会造成内存空间的不连续,从而造成大量碎片,使程序效率降低;
      • 但是对于栈来说,因为总是先进后出不存在内存块不连续的问题。
    • 生长方向不同
      • 堆的生长方向是向上的,即向着内存地址增加的方向;
      • 栈的生长方向是向下的,即向着内存地址减小的方向增长。
    • 分配方式不同
      • 堆总是动态分配的,需要程序员手动释放;
      • 栈存在静态分配和动态分配的:
        • 其中静态分配是由编译器完成的(比如局部变量的分配);
        • 动态分配是由alloca函数进行分配的(这个函数会在栈帧的调用处上分配一定空间,当调用alloca的函数返回到调用位置时,这些临时空间会被自动释放),栈的动态分配是由编译器自己进行释放的。
    • 分配效率不同:
      • 栈是机器系统提供的数据结构,计算机会在底层对栈提供支持,包括:分配专门的寄存器来存放栈的地址、入出栈都有专门指令,因此栈的效率会比较高。
      • 堆是C/C++函数库提供的,其机制非常复杂,比如为了分配一块内存,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果找不到(可能是因为内存碎片过多),就可能调用系统功能区(用户模式和内核模式的切换)增加程序数据段的内存空间,如此便有机会分到足够大小的内存,然后进行返回。
  • malloc/freenew/delete

    • 类型差别:前者是函数,后者是操作符;
    • 为什么有了malloc/free之后还要有new/delete的存在?
      • 因为只用malloc/free无法满足动态对象的要求,具体说来,对象在创建时要执行构造函数,在消亡之前要执行析构函数。而malloc/free是库函数,不再编译器的控制权限之内,不能够把执行构造函数和析构函数的任务强加给malloc/free。因此才需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理和内存释放工作的运算符delete
    • 应用上的差别:
      • 对于内置类型来说,new/delete和malloc/free没有什么差别;
      • 对于自定义类型来说,new/delete会自动调用构造和析构函数,而malloc/free函数则不会;
      • 对于C函数来说,只能使用malloc/free进行内存管理。
    • malloc/free的使用要点
      • void *malloc(size_t size);返回类型是void *,因此及进行赋值时需要进行显式类型转换,通常sizesizeof获得;
      • void free(void* memblock);使用起来会更加简单,因为指针的类型以及它所指的内容都是被记录的的,因此可以轻松释放内存。
    • new/delete使用要点
      • new运算符内置了sizeof、类型转换和类型安全检查的功能;
      • 对于非内置数据对象,new在创建对象的同时还完成了初始化(调用构造函数)的工作;
      • 要注意new创建对象数组时,只能使用无参数构造函数进行。
      • 对于delete,在释放对象数组时,注意不要丢了符号[],否则会引起内存泄漏。
        1
        2
        3
        4
        5
        6
        //创建内置类型数组
        int *p2 = new int[length];

        //调用不同构造函数创建对象
        Obj* a = new Obj;
        Obj *b = new Obj(1);
  • 常见的内存错误及其解决:

    • 内存分配未成功,却被使用;应提前检查内存不为NULL,比如可以加入断言机制assert(p!=NULL)进行检查。
    • 内存分配成功,但是未初始化就被引用;因此记住总是在创建附近赋初值。
    • 内存分配成功且初始化,但是操作越界(比如在进行数组操作时)。
    • 忘记释放内存,导致内存泄漏:malloc/free或者new/delete的个数必须保证相同。
    • 内存释放了却继续使用
      • 可能因为程序中对象调用关系特别复杂,实在难以搞清楚某个对象是否释放内存,此时应该重新设计数据结构。
      • return栈内存中的指针/引用,因为在脱离子函数后这些地址就会被释放,其值就无效。
      • 使用free/delete释放内存后,没有将指针设置为NULL,此时就会导致产生野指针(指向不合法对象的指针)。要避免这些错误应当:
        • 使用malloc/new分配内存时,应立即检查指针是否为NULL
        • 及时为动态内存/数组赋初值,防止未被初始化的内存作为右值被使用
        • 避免数组或者指针下标越界
        • 释放内存后要及时将指针设置为NULL
  • 指针与数组的差别
    • 创建位置:数组或者在静态存储区被创建(全局数组),或者在栈上被创建(编译器)。数组名对应的是一块内存,其地址和容量在生命周期内会保持不变。而指可以指向任意类型的内存块,其特征是“可变”,因此常用指针来操作动态内存(指针会更灵活,也更危险)。
    • 错误修改:对于常量字符串"helloWorld",存储于静态存储区,理论上其值应该是保持不变的,但是可以用char*指针指向它并且做修改,这在概念上是不当的。
    • 计算内存容量:运算符sizeof的使用,当后面跟着的是数组名,则返回数组长度,而如果是char*即char型指针,返回的就是一个指针变量的字节数(4字节,32位),并不是指针所指内存的容量(除非是在内存申请时记住它,否则要时刻直到指针所指内容的大小是不现实的)。但是,当数组名char[]作为参数传递到函数时时,数组会自动退化为同类型的指针(char*)。
  • 指针参数传递内存的方法
    • 错误方法1:对于传入的参数p,在函数中会产生其一个副本,因此函数中的malloc只是对副本内容做了改变,但是对原参数并没有修改;并且因为未做free释放,这个函数每调用一次就会泄漏一块内存,
    • 正确方法1:形参为指向指针的指针;对指向数组指针的内存进行直接修改,确保指针指向数组起始地址;
    • 正确方法2:利用函数返回值来传递在堆(heap)上分配的动态内存;
    • 错误方法2:如果是在栈(stack)上分配的动态内存会在函数运行结束时被自动释放
      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
      26
      27
      28
      29
      //错误方法1
      void GetMemory(char* p,int num)
      {
      p = (char*) malloc(sizeof(char)*num);
      }

      //正确方法1:传递指向指针的指针
      void GetMemory2(char **p, int num)
      {
      *p = (char*) malloc(sizeof(char)) *num);
      }

      //正确方法2:用函数返回值传递堆上分配的动态内存
      char* GetMemory3(int num){
      char*p = (char*) malloc(sizeof(char))*num);
      return p;
      }

      //错误方法2:无法指针传递在栈上分配的内存
      char *GetString(void){
      char p[] = "Hello World"; //栈上分配内存,将字符串内容拷贝到栈上分配的数组中
      return p;
      }

      // 概念不合适的传递方法:传递指向静态存储区的指针
      char *GetString2(void){
      char *p = "Hello World"; //静态存储区中内容尽量保证只读
      return p;
      }
文章目录
  1. 1. 语言特性
  2. 2. 内存管理
    1. 2.1. 隐患及相关概念
    2. 2.2. C/C++内存管理详细(不含智能指针)
|