简介

硬件能直接支持的只有字节序列。在最底层,计算机并不知道push_back()这样复杂操作的任何信息,它所知道的只是如何一次读或写若干字节。如果对一个程序如何映射到计算机内存和操作缺乏基本的实践认识,将会在把握更高级的主题如数据结构、算法和操作系统时遇到问题。

预备知识

C++标准库中的元素编号总是从0开始。从0开始编号是很常见的,它是C++程序员的一个通用规范。如果我们要实现一个类似vector,基本上,我们需要一个数据成员来指向一组元素,这样,当需要更大空间时可以令它指向另一组元素。这个数据成员可能是第一个元素的内存地址这样的东西。在C++中,一种可以保存地址的数据类型称为指针,它在语法上使用* 来区分,因此double*表示“指向double的指针”。

内存、地址和指针

计算机的内存是一个字节序列。可以将这些字节从0开始编号。将这种“指明内存中位置的数字”称为地址。可以将一个地址看作一种整型值。内存中第一个字节的地址为0,下一个字节的地址为1,以此类推。
我们在内存中保存的任何东西都有一个地址。例如:
int var=17;
这段代码为var分配一段“int大小”的内存并将值17保存到这段代码中。我们也可以保存地址以及操作地址。保存地址的对象被称为指针。例如,用于保存int的地址的类型称为“指向int的指针”或“int指针”,其表示方式为int* :
int* ptr=&var;
“地址”运算符&用于获得一个对象的地址。因此,如果var碰巧开始于地址4096,ptr将会保存值4096。基本上,我们将计算机内存看作一个字节序列,这些字节从0到内存大小减1编号。每种类型都有对应的指针类型。例如:

    int x=17;
    int* pi=&x;                     //int指针
    double e=2.71828;
    double* pd=&e;                  //double指针

如果我们想查看指向对象的值,可以使用“内容”操作符* ,它是一种一元运算符,例如:

    cout<<"pi=="<<pi<<";contents of pi=="<<*pi<<endl;
    cout<<"pd=="<<pd<<";contents of pd=="<<*pd<<endl;

*pi的输出将是整数17,而*pd的输出是双精度浮点数2.71828。pi和pd的输出依赖于编译器在内存中分配给变量x和e的地址。指针值(地址)的表示方式也可能不同,这依赖于你的系统所使用的规范;十六进制表示法常用于指针值。
内容运算符(常称为解引用运算符)也可以被用于赋值操作的左侧:

    *pi=27;                     //正确:可以将27赋予pi指向的int
    *pd=3.14159;                //正确:可以将3.14159赋予pd指向的double
    *pd=*pi;                    //正确:可以将一个int(*pi)赋予一个double(*pd)

需要注意的是,即使指针的值可以打印为一个整数,但是指针并不是一个整数。int并不指向什么,只有指针才能指向其他实体。指针类型提供一些适用于地址的操作,而int提供适用于整数的(算术和逻辑)操作。因此,我们不能隐式地混用指针和整数:

    int i=pi;                   //错误:不能将一个int*赋予一个int
    pi=7;                       //错误:不能将一个int赋予一个int*

与此类似,一个指向char的指针(char )不是一个指向int的指针(int )。例如:

    char* pc=pi;                //错误:不能将一个int*赋予一个char*
    pi=pc;                      //错误:不能将一个char*赋予一个int*

原因在于:一个char通常比一个int更小。考虑下面代码:

    char ch1='a';
    char ch2='b';
    char ch3='c';
    char ch4='d';
    int* pi=&ch3;               //指向ch3,一段char大小的内存
                                //错误:我们不能将一个char*赋予一个int*
                                //但假装我们可以这样做
    *pi=12345;                  //写入一段int大小的内存
    *pi=67890;

假设这段代码编译通过,这肯定会改变相邻内存中的值,例如ch2或ch4.如果我们真的不走运,就会覆盖pi本身的部分!在此情况下,下次赋值* pi=67890会将67890放入内存中完全不同的地方。
在极少数情况下,可能真的需要把一个int转换成一个指针,或者将一个指针类型转换成另一种指针类型,此时可使用reinerpret_case。
但是,在给定了问题和求解约束的条件下,永远在尽可能高的抽象层次上进行程序开发。

sizeof 运算符

那么,一个int实际占用多少内存?一个指针呢?运算符sizeof()可以回答这些问题:

void sizes(char ch,int i,int* p)
{
    cout<<"the size of char is"<<sizeof(char)<<' '<<sizeof(ch)<<endl;
    cout<<"the size of int is"<<sizeof(int)<<' '<<sizeof(i)<<endl;
    cout<<"the size of int* is"<<sizeof(int*)<<' '<<sizeof(p)<<endl;
}

我们可以将sizeof用于一个类型名或表达式。对一个类型名,sizeof给出这种类型的对象的大小;对一个表达式,sizeof给出表达式结果的大小。sizeof的结果是一个正整数,其单位是sizeof(char)——被定义为1。一个char通常被保存在一个字节中,因此sizeof会报告占用的字节数。
一种类型的大小并不保证在所有C++实现中都相同。目前,sizeof(int)在台式机或笔记本电脑中通常为4字节。如果使用8比特的字节,那就意味着一个int是32比特。但是,嵌入式系统通常使用16比特的int,而高性能体系结构中常使用64比特的int。
一个vector使用多少内存?我们可以尝试下面代码:

    vector<int>v(1000);
    cout<<"the size of vector<int>(1000) is "<<sizeof(v)<<endl;

输出可能像下面这样:

    the size of vector<int>(1000) is 20

目前我们至少知道,sizeof显然不是统计元素数量。

自由空间和指针

当我们开始编写一个C++程序时,编译器为我们的代码分配内存(有时称为代码存储或文本存储),并为你定义的全局变量分配内存(称为静态存储)。编译器还会为你预留调用函数时所需的空间,函数需要用这些空间保存其参数和局部变量(称为栈存储或自动存储)。计算机中的剩余内存可用于其他用途;它是“自由的”(“空闲的”)。这种内存分配方式可图示如下:

image

C++语言用称为new的运算符将“自由空间”(又称为堆)变为可用状态。例如:

    double* p=new double[4];

这段代码要求C++运行时系统在自由空间中分配四个double,并将指向第一个double的指针返回给我们。我们使用此指针来初始化指针变量p。运算符new返回一个指向它创建的对象的指针。如果它创建了多个对象(一个数值),它返回指向第一个对象的指针。如果对象的类型是X,则new返回的指针类型是X* 。

自由空间分配

我们使用运算符new来请求系统从自由空间中分配内存:

  • 运算符new返回一个指向分配的内存的指针。
  • 指针的值是分配的内存的首字节的地址。
  • 一个指针指向一个特定类型的对象。
  • 一个指针并不知道它指向多少个元素。

运算符new可以为单个元素分配内存,也可为元素序列(数组)分配内存。例如:

    int* pi=new int;                    //分配一个int
    int* qi=new int[4];                 //分配4个int(一个包含4个int的数组)
    
    double* pd=new double;              //分配一个double
    double* qd=new double[2];           //分配2个double(一个包含n个double的数组)

注意,分配的对象数量可以通过一个变量给出。这很重要,因为这样我们就可以在运行时选择分配多少个对象。
指向不同类型变量的指针是不同类型的。例如:

    pi=pd;                              //错误:不能将一个double*赋予一个int*
    pd=pi;                             //错误:不能将一个int*赋予一个double*

为什么不可以?原因在于[ ]运算符。它依赖于元素类型的大小来计算出到哪里找到一个元素。例如,qi[2]在内存中与qi[0]相距2个int大小,qd[2]在内存中与qd[0]有2个double大小的距离。一般情况下一个int与一个double的大小不同。

通过指针访问数据

在一个指针上除了使用解引用运算符* 之外,我们还可以使用下标运算符[]。p[0]实际上与* p相同。例如:

    double* p=new double[4];            //在自由空间中分配4个double
    double x=*p;                        //读取p指向的(第一个)对象
    double y=p[2];                      //读取p指向的第三个对象

[]和* 运算符都可以被用于读写数据。一个指针指向内存中的一个对象。而当我们将[]运算符作用于一个指针时,它将内存看作一个对象(类型在指针声明时指定)序列,指针p指向其中第一个对象:

    double x=*p;                        //读取p指向的对象
    *p=8.8;                             //写入p指向的对象
    
    
    double x=p[3];                      //读取p指向的第四个元素
    p[3]=4.4;                           //写入p指向的第四个元素
    double y=p[0];                      //p[0]与*p等价

指针范围

指针带来的主要问题是一个指针并不“知道”它指向多少个元素。考虑如下代码:

    double* pd=new double[3];
    pd[2]=2.2;
    pd[4]=4.4;
    pd[-3]=-3.3;

pd是否有第三个元素pd[2]?它是否有第五个元素pd[4]?如果查看pd的定义,我们发现答案分别是“是”和“否”。但是,编译器不知道这些;它并不跟踪指针的值。这段代码只是简单地访问内存,就像我们已经分配的足够的内存一样。它甚至会访问pd[-3],就像pd指向的地址往前三个double的位置也是我们分配的内存的一部分一样。
一次越界的读取会给我们一个“随机”值。它可能依赖于某些完全无关的计算。一次越界的写入会将某些对象变成“不可能”的状态,或者简单地赋予它一个不期望的错误值。这种错误是很难发现的错误。
我们必须保证不出现这种越界的访问。我们使用vector而不是直接使用new来分配内存的原因之一是vector知道自己的大小,这样它就很容易避免越界的访问。
有一个因素令避免越界访问变得困难,那就是我们可以将一个double* 赋予另一个double* ,而不必去管每个指针指向多少个对象。一个指针实际上并不知道它指向多少个对象。例如:

    double* p=new double;               //分配一个double
    double* q=new double[1000];         //分配1000个double
    
    q[700]=7.7;                         //正确
    q=p;                                //令q与p指向相同地址
    double d=q[700];                    //越界访问!

仅仅在3行代码中,q[700]引用了两个不同的内存位置,第二次是越界的访问,并且很可能引发一场灾难。

初始化

我们希望确保指针被初始化,并且指向的对象也被初始化。考虑如下代码:

    double* p0;                         //未初始化:可能产生问题
    double* p1=new double;              //得到(分配)一个未初始化的double
    double* p2=new double{5.5};         //得到一个初始化为5.5的double
    double* p3=new double[5];           //得到(分配)5个未初始化的double

显然,声明p0但没有对它进行初始化会带来麻烦。考虑下面代码:

    *p=7.0;

这行代码将7.0赋予内存中的某个位置,但我们并不知道将会是哪部分内存。这个赋值可能是无害的,但是永远也不要这样做。我们迟早会得到与越界访问相同的结果。
对于内置类型,使用new分配的内存不会被初始化。如果想初始化单个对象,可以使用{}初始化语法。它与[]相对,后者表示“数组”。
对new分配的对象数组,我们可以指定一个初始化器列表。例如:

    double* p4=new double[5]{0,1,2,3,4};        
    double* p5=new double[]{0,1,2,3,4};         //如果提供了一组元素作为初始值,我们可以省略元素数目。

当我们定义自己的类型时,可以更好地控制初始化。如果类型X有一个默认构造函数,我们会得到:

    X* px1=new X;                       //一个默认初始化的X
    X* px2=new X[17];                   //17个默认初始化的X

如果类型Y有一个构造函数,但不是默认构造函数,我们需要显式地初始化:

    Y* py1=new Y;                       //错误:无默认构造函数
    Y* py2=new Y{13};                   //正确:初始化Y{13}
    Y* py3=new Y[17];                   //错误:无默认构造函数
    Y* py4=new Y[17]{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16};

为new指定很长的初始化器列表可能不太实用,但当我们只需要几个元素时这种方式就非常方便了,而少量元素通常是最常见的情形。

    double* p6=nullptr;                 //空指针

当0值被赋予一个指针时,它被称为空指针。我们经常通过检测指针是否为nullptr来检查一个指针是否有效(它是否指向什么东西)例如:

    if(p0)                              //认为p0有效:等价于p0!=nullptr

自由空间释放

由于一台计算机的内存是有限的,因此在使用完毕后将内存归还到自由空间通常是一个好主意。这样自由空间可以将这些内存重新用于新的分配请求。对于大型程序和长时间运行的程序来说,这种自由空间的重用是很重要的。例如:

double* calc(int res_size,int max)          //内存泄漏
{
    double* p=new double[max];
    double* res=new double[res_size];
    //用p计算结果,放入res
    return res;
}

就像注释中所写那样,每次调用calc()会导致分配给p的double“泄漏”。例如,调用calc(100,1000)会导致1000个double占用的空间在程序接下来的运行过程中无法使用。
将内存归还自由空间的运算符称为delete。我们对new返回的指针使用delete,令这些内存重新变为自由空间中的可用内存,可供未来分配。现在,这个例子变为:

double* cale(int res_size,int max)
{
    double* p=new double[max];
    double* res=new double[res_size];
    //用p计算结果,放入res
    delete[]p;                              //我们不再需要这段内存,将其释放
    return res;
}

这个例子还顺带说明了使用自由内存的一个重要原因:我们可以在一个函数中创建对象,并将它们传回调用者。
delete有两种形式:
delete p释放new分配给单个对象的内存。
delete[]p释放new分配给对象数组的内存。
两次删除一个对象是一个糟糕的错误。例如:

    int* p=new int{5};                      //正确:p指向由new创建的对象
    delete p;
    //...no use of p here...
    delete p;                               //错误:p指向的内存是由自由空间管理器所拥有的

第二个delete p带来两个问题:

  • 你已经不再拥有指针指向的对象,因此自由空间管理器可能已经改变了它的内部数据结构,导致你无法再次正确地执行delete p。
  • 自由空间管理可能已“回收”p指向的内存,因此p现在可能指向其他对象;删除这个对象(由程序的其他部分所拥有)会引起错误。

删除空指针不会做任何事,因此删除空指针是无害的。
一个需要“永远”运行的程序是无法忍受内存泄漏的。操作系统就是“永远运行的”程序的例子,大多数嵌入式系统也属于此类,库也不能出现内存泄漏的问题。如果你知道自己的程序使用的内存量不比系统可用内存量更多,你可能“合理地”决定泄漏内存,直到操作系统为你释放内存。但是,如果你决定这样做,先确认你所估计的内存消耗是正确的,否则人们有理由认为你是草率的。

析构函数

自由空间一个最常见的问题是人们会忘记delete。解决办法是令编译器知道一个可以做与构造函数相反的函数,就像它了解构造函数一样。必然地,这个函数被称为析构函数。就像一个类对象创建时会隐式调用构造函数一样,当一个对象离开其作用域时会隐式调用析构函数。构造函数确保一个对象被正确创建并初始化。与之相反,析构函数确保一个对象销毁前被正确清理。
对于那些需要首先(从某处)获取并随后归还的资源,如文件、线程、锁等,析构函数是很好的管理机制。每个“拥有”资源的类都需要一个析构函数。

生成的析构函数

如果一个类的成员拥有一个析构函数,则在包含这个成员的对象销毁时这个析构函数会被调用。如果没有定义析构函数,编译器则会生成一个什么都不做的析构函数。这种通常的实现方式正是析构函数调用应提供的显然且必要的保证。
成员和基类的析构函数被派生类的析构函数(无论是用户定义的或是编译器生成的)隐式调用。基本上,所有规则可总结为一句话:“当对象被销毁时,析构函数被调用(离开作用域,被delete释放等情况下)。”

析构函数和自由空间

析构函数概念上很简单,但它是大多数最有效的C++编程技术的基础。基本思想是:

  • 无论一个类对象需要使用哪些资源,这些资源都要在析构函数中获取。
  • 在对象的生命周期中,它可以释放资源并获得新的资源。
  • 在对象的生命周期结束后,析构函数释放对象拥有的所有资源。

作为一个经验法则:如果你有一个类带有virtual函数,则它也需要一个virtual析构函数。原因是:

  • 如果一个类有virtual函数,它很可能作为一个基类使用;
  • 且如果它是一个基类,它的派生类很可能使用new来分配;
  • 且如果派生类对象使用new来分配,并通过基类指针来操作;
  • 那么它很可能也是通过基类指针来进行delete操作的。

注意,析构函数是通过delete来隐式或间接调用的,不会直接调用,这样能省去很多麻烦的工作。

指向类对象的指针

“指针”的概念是通用的,因此我们可以指向可放置于内存的任何东西,包括类。如果使用实际的(标准库)vector,我们还可以实现:

    vector<vector<double>>*p=new vector<vector<double>>(10);
    delete p;

注意,delete p调用vector<vector<double>>的析构函数;接着,这个析构函数调用它的vector<double>元素的析构函数,所有东西被干净利落地清理,不会留下未销毁的对象和泄漏的内存。
所有类都支持对给定的对象名通过“.”(点)运算符来访问其成员。与此类似,所有类都支持对给定的对象指针通过->(箭头)运算符来访问其成员。类似,.(点),->(箭头)既可用于数据成员也可用于函数成员。由于内置类型(例如int和double)没有成员,因此->不能用于内置类型。点和箭头通常被称为成员访问运算符。

类型混用:void* 和类型转换

通常,我们不希望在没有类型系统的保护下工作,但是有时没有其他选择(例如,我们需要与无法识别C++类型的其他语言交互)。我们有时也会遇到一些不走运的情况,这时需要面对没有遵循静态安全类型设计的旧代码。在这种情况下,我们需要两样东西:

  • 一种并不了解内存中是哪种对象的指针。
  • 一个操作,告知编译器指针指向的内存中是哪种(未证实)类型的对象。

类型void* 的含义是“指向编译器不知道类型的内存空间”。当我们想在两段代码之间传输一个地址,它们确实不知道对方的类型时,就可以使用void*这方面的例子包括回调函数的“地址”参数和底层内存分配器(例如new运算符的实现)。
并不存在void类型的对象,但是正如我们看到的,我们通常用void来表示“没有返回值”:

    void v;                             //错误:不存在void类型的对象
    void f();                           //f()不返回任何东西——f()不是返回一个void类型的对象

指向任何对象类型的指针都可以赋予void* 。例如:

    void* pv1=new int;                  //正确:int*转换为void*
    void* pv2=new double[10];           //正确:double*转换为void*

由于编译器不知道void* 指向什么,因此我们必须告诉它:

void f(void* pv)
{
    void* pv2=pv;                           //正确拷贝(void*是可以进行拷贝的)
    double* pd=pv;                          //错误:不能将void*转换为double*
    *pv=7;                                  //错误:不能解引用一个void*
                                            //(我们不知道它指向的对象是什么类型)
    pv[2]=9;                                //错误:不能对void*进行下标操作
    int* pi=static_cast<int*>(pv);          //正确:显示类型转换
}

static_cast可以用于在两种相关指针类型之间进行强制转换,例如void*double* 。static_cast这样的操作称为显式类型转换或口语地称为转换。C++提供两个比static_cast更具潜在危险的转换操作:

  • reinterpret_cast可以在两个不相关的类型之间转换,例如int和double
  • const_cast可以“去掉const”。

例如:

    Register* in=reinterpret_cast<Register*>(0xff);
    
    void f(const Buffer*p)
    {
        Buffer* b=const_cast<Buffer*>(p);
        //...
    }

第一个例子是有关reinterpret_cast的必要性和使用方法的经典例子。我们告知编译器内存中的某个特定部分(开始于0xFF位置的内存)被作为一个Register(可能有特殊语义)。在你编写设备驱动程序这类代码时,采用这种方式是必要的。在第二个例子中,const_cast将const从名为p的const Buffer* 中去掉。除非你需要与硬件或者其他人的代码打交道,否则通常都会有办法避免使用转换。

指针和引用

我们可以将一个引用看作一个自动解引用的不可变指针或一个对象的替代名字。指针和引用有以下几方面不同:

  • 为一个指针赋值会改变指针自身的值(不是指针指向的值)。
  • 为了得到一个指针,你通常需要使用new或&。
  • 为了访问一个指针指向的对象,你可以使用* 或[]
  • 为一个引用赋值会改变引用指向的值(不是引用自身的值)。
  • 在初始化一个引用之后,你不能让引用指向其他对象。
  • 为引用赋值执行深拷贝(赋值给引用的对象);为指针赋值不是这样(赋值给指针自身)。
  • 注意避免空指针

例如:

    int x=10;                       //你需要&来获取指针
    int* p=&x;                      //用*通过p为x赋值
    *p=7;                           //通过p读取x
    int x2=*p;                      //获取另一个int的指针
    int* p2=&x2;                    //p2和p都指向x
    p2=p;                           //令p指向另一个对象
    p=&x2;
    
    int y=10;           
    int& r=y;                       //&在类型中,不是在初始化中
    r=7;                            //通过r为y赋值(不需要*)
    int y2=r;                       //获取另一个int的引用
    int& r2=y2;                     //将y的值赋予y2
    r2=r;                           //错误:你不能改变引用自身的值
    r=&y2;                          //(不能将一个int*赋予一个int*)

注意最后一个例子:不仅是这种语言构造会失败,而是一个引用在初始化后就无法再指向其他对象了。如果你需要指向不同的对象,请使用指针。

指针参数和引用参数

当你希望将一个变量的值改为由函数计算出的结果时,你有三种选择。例如:

    int incr_v(int x) { return x+1;}            //计算一个新值并返回
    void incr_p(int* p) { ++*p; }               //传递一个指针(解引用它并增值)
    void incr_r(int& r) { ++r; }                //传递一个引用

我们认为返回值是最明显的方法(因此最不容易出错),例如:

    int x=2;
    x=incr_v(x);                    //将x拷贝到incr_v(),然后将结果拷贝出来并赋予x

我们倾向于对小对象(例如一个int)使用这种风格。此外,如果一个“大对象”有移动构造函数,我们也可以高效地反复拷贝它。
使用指针参数警告编程者有些东西可能改变。例如:

    int x=7;                        
    incr_p(&x);                     //需要&
    incr_r(x);

在incr_p(&x)中需要使用&,这警告用户x可能改变。与之对比,incr_p(x)就是“看起来无辜的”。这导致很多人稍微偏爱使用指针。
另一方面,如果你使用指针作为函数的参数,需要小心某些人可能用空指针(即nullptr)调用函数。例如:

    incr_p(nullptr);                //崩溃:incr_p()会尝试解引用空指针
    int* p=nullptr;         
    incr_p(p);                      //崩溃:incr_p()会尝试解引用空指针

编写incr_p()的人可以防止它:

void incr_p(int* p) 
{ 
    if(p==nullptr) error("null pointer argument to incr_p()");
    ++*p;                           //解引用指针并递增指向的对象
}

如果“不传递任何东西”(不传递对象)从函数语义的角度是可接受的,那么我们就必须使用指针参数。因此,如何选择三种方式依赖于函数的性质。具体如下:

  • 对于小对象,优先使用传值参数。
  • 对于“没有对象”(用nullptr表示)是有效参数的函数,使用指针参数(记住检测nullptr)。
  • 否则,使用一个引用参数。

this指针

在每个成员函数中,标识符this都是指向调用此成员函数的对象的指针。访问成员时我们其实不必提及this。只有当我们要涉及整体对象时才需要显式指出它。注意,this具有特殊含义:它指向调用当前成员函数的对象。它不指向任何旧对象。编译器确保我们不能在成员函数中改变this的值。


孤独患者
1 声望0 粉丝