《c++程序设计原理与实践》第13章——向量和数组

初始化

如何编写接受初始化器列表参数的构造函数?用{}限定的类型T元素的列表是以标准库initializer_list对象(即T的列表)的形式呈现给程序员的,因此我们可以写出如下代码:

class vector{
private:
    int sz;                                         //大小
    double* elem;                                   //指向元素的指针
public:
    vector(int s)                                   //构造函数(s为元素数量)
        :sz{s},elem{new double[sz]}                 //为元素分配未初始化的内存
    {
        for(int i=0;ilst)             //初始化器列表构造函数
        :sz{lst.size()},elem{new double[sz]}        //为元素分配未初始化的内存
    {
       copy(lst.begin(),lst.end(),elem);           //初始化
    }
    //...
};

我们使用了标准库算法copy。它将前两个参数(在本例中是initializer_list的起始和结束位置)指定的元素序列拷贝到从第三个参数开始的元素序列中。现在,我们可以编写如下代码:

vector v1={1,2,3};                  //三个元素1.0,2.0,3.0
vector v2(3);                       //三个元素,都具有(默认值)0.0

注意,我们是如何用()表示元素数量,以及用{}表示元素列表的。如果有多个构造函数可供选择,编译器会将{}列表中的一个值解释为一个元素值,并将它作为一个initializer_list的元素传递给初始化器列表构造函数。

拷贝

让我们尝试拷贝一个向量:

void f(int n)
{
    vector v(3);            //定义一个包含3个元素的vector
    v.set(2,2.2);           //将v[2]设置为2.2
    vector v2=v;            //会发生什么?
}

对于指针成员而言,仅仅对指针成员进行拷贝会产生问题。也就是说,v2并未拥有v的元素的副本,它只是共享了v的元素。并且,我们从f()返回时发生的事情肯定会导致一场灾难:v与v2的析构函数会先后被隐式调用,由于v与v2的elem指向同一块内存,因此两次释放这块内存很可能造成灾难性的后果。

拷贝构造函数

那么,我们应该怎么做呢?答案很明显:提供一个复制元素的拷贝操作,并确保当我们用一个vector初始化另一个vector时,这个拷贝操作会被调用。一个类的对象的初始化是由该类的构造函数实现的,在这种情况下的构造函数被称为拷贝构造函数。它应接受待拷贝对象的引用作为参数,原因是我们不希望再传递函数参数时又发生参数的拷贝,使用const引用的原因在于我们不希望函数对参数进行修改。代码如下:

vector::vector(const vector& arg)
    :sz{arg.sz},elem{new double[arg.sz]}
{
    copy(arg.elem,arg.elem.sz,elem);
}

有了这个拷贝构造函数,我们再次考虑vector v2=v,此定义会初始化v2,这是通过调用vector的拷贝构造函数并将v作为参数传递给它而完成的。这样,析构函数就能正确完成清理工作了,每个元素都会被正确释放。显然,现在两个vector是互相独立的,因此我们改变v的元素值而不会影响到v2,反之亦然。我们还可以使用vector v2{v}这种等价形式。当v(初始化器)与v2(被初始化变量)为相同类型且该类型定义了拷贝构造函数时,则这两种初始化方式完全相同。

拷贝赋值

我们可以通过构造函数拷贝(初始化)对象,但我们也可以通过赋值的方式拷贝vector。与拷贝初始化类似,默认的拷贝赋值是逐成员的拷贝。因此,对于我们目前定义的vector,拷贝赋值会造成双重释放以及内存泄漏问题。例如:

void f2(int n)
{
    vector v(3);            //定义一个包含3个元素的vector
    v.set(2,2.2);           //将v[2]设置为2.2
    vector v2(4);
    v2=v;                   //赋值:会发生什么?
    //...
}

由于我们并未给vector定义拷贝赋值函数,会出现前面所述的一样的问题,并且更为严重的是会发生内存泄漏:我们“忘记了”释放最初为v2的四个元素所分配的内存。我们应该像下面代码这样恰当定义拷贝赋值操作:

class vector{
private:
    int sz;
    double* elem;
public:
    vector& operator=(const vector&);           //拷贝赋值
    //...
};

vector& vector::operator=(const vector&a)
    //将本vector变为a的副本
{
    double* p=new double[a.sz];                 //分配新空间
    copy(a.elem,a.elem.sz,p);                   //拷贝元素
    delete[] elem;                              //释放旧空间
    elem=p;                                     //现在我们可以重置elem了
    sz=a.sz;
    return *this;                               //返回一个自引用
}

赋值比构造稍微复杂一些,因为我们必须处理旧元素,我们的基本策略是创建源vector元素的一份拷贝,然后,我们将释放目标vector的旧元素,最后,我们将elem指向新元素。
在实现拷贝赋值操作时,你可以在创建副本之前先释放旧有元素所占用的内存以简化代码,但通常更好的做法是在确信可以替代指定信息之前不要丢掉它。

拷贝术语

对于大多数程序员和大多数程序设计语言,拷贝都是一个重要问题。一个基本问题是你应该拷贝一个指针(或引用)还是应该拷贝指针指向(或引用)的数据

  • 浅拷贝只拷贝指针,因此两个指针会指向同一个对象。指针和引用类型就是进行浅拷贝。
  • 深拷贝将拷贝指针指向的数据,因此两个指针将指向两个不同的对象。vector和string都实现了深拷贝。当类对象需要深拷贝时,我们需要为其定义拷贝构造函数和拷贝赋值操作。

下面是一个浅拷贝的例子:

   int* p=new int{77};                      
   int* q=p;                                    //拷贝指针p
   *p=88;                                       //改变p和q指向的int的值

与此相对,我们也可以进行深拷贝:

    int* p=new int{77};
    int* q=new int{*p};                         //分配一个新的int,然后拷贝p指向的值
    *p=88;                                      //改变p指向的int的值

实现了浅拷贝的类型(如指针与引用)被称为具有指针语义或引用语义(它们拷贝地址)。实现了深拷贝的类型被称为具有值语义(它们拷贝指向的值)。从用户的角度看来,具有值语义的类型的行为就像没有涉及指针一样——只涉及能被拷贝的值。

移动

如果一个vector有很多元素,那么拷贝的代价会很高。因此,我们只应在必要时才拷贝vector。考虑下面这个例子:

vector fill(istream& is)
{
    vector res;
    for(double x;is>>x;) res.push_back(x);
    return res;
}

void use()
{
    vector vec=fill(cin);
    //...使用vec...
}

我们希望vec指向res指向res的元素,而不进行任何拷贝。在将res的元素指针和元素数量移动到vec后,vec就持有了元素,我们就成功地完成了将res中的元素值移出fill()并移到vec中的工作。现在,res可以被简单且高效地销毁,没有产生任何不良副作用。
我们如何用C++代码表达这种移动?我们可以定义移动操作,作为拷贝操作的补充:

class vector{
    int sz;
    double* elem;
public:
    vector(vector&& a);                         //移动构造函数
    vector& operator=(vector&&);                //移动赋值
    //...
};

&&符号被称为“右值引用”。我们用它来定义移动操作。注意,移动操作不接受const参数;即,我们应使用(vector&&)而不是(const vector&&)。移动操作的目的之一是修改源对象,令其变为“空的”。对于vector,我们有

vector::vector(vector&& a)
    :sz{a.sz},elem{a.elem}                      //拷贝a的elem和sz
{
    a.sz=0;                                     //令a变为空vector
    a.elem=nullptr;
}

vector& vector::operator=(vector&& a)           //将a移动到本vector
{
    delete[]elem;                               //释放旧空间
    elem=a.elem;                                //拷贝a的elem和sz
    sz=a.sz;
    a.elem=nullptr;                             //令a变为空vector
    a.sz=0;
    return *this;                               //返回一个自引用
}

通过定义一个移动构造函数,我们令移动大量信息(例如移动包含很多元素的向量)变得更简单更高效。再次考虑fill()函数:

vector fill(istream& is)
{
    vector res;
    for(double x;is>>x;) res.push_back(x);
    return res;
}

移动构造函数隐式地用于实现函数返回。编译器知道要返回的局部值(res)将要离开其作用域,因此可以将其值移出而非拷贝它。
移动构造函数的重要性在于我们不必为了一个函数返回大量信息而处理指针或引用。考虑下面这种有瑕疵的(但很常用的)替代方式:

vector* fill2(istream& is)
{
    vector* res=new vector;
    for(double x;is>>x;) res.push_back(x);
    return res;
}

void use2()
{
    vector* vec=fill2(cin);
    //...使用vec...
    delete vec;
}

若采用这种方式,我们就必须记得释放vector。

必要的操作

在设计类时,有七种必要的操作需要考虑:

  • 接受一个或多个参数的构造函数
  • 默认构造函数(拷贝同一类型的对象)
  • 拷贝构造函数(拷贝同一类型的对象)
  • 拷贝赋值函数(移动同一类型的对象)
  • 移动构造函数(移动同一类型的对象)
  • 移动赋值函数
  • 析构函数

如果我们希望在不指定初始化器的前提下还能构造一个类的对象,那么该类就需要默认构造函数,默认构造函数常常是有用的。
如果一个类需要获取资源,则它需要析构函数。所谓资源,就是一种你“从某处获取”,且使用完毕后必须归还的东西。一个类需要析构函数的另一个简单标志是它包含指针成员或引用成员。
一个需要析构函数的类几乎肯定也需要一个拷贝构造函数和一个拷贝赋值操作。
类似的,一个需要析构函数的类几乎肯定也需要一个移动构造函数和一个移动赋值操作。

显式构造函数

只接受一个参数的构造函数定义了一个从其参数类型向所属类的类型转换操作。这种转换是很有用的。例如:

class complex{
public:
    complex(double);                            //定义了double向complex的类型转换
    complex(double,double);
    //...
};

    complex z1=3.14;                            //正确:将3.14转换为(3.14,0)
    complex z2=complex{1.2,3.4};

但是,我们应谨慎地使用隐式转换,因为隐式转换可能会造成不可预料的后果。例如,我们目前定义的vector有一个接受int参数的构造函数。这意味着它定义了一个从int向vector的类型转换的操作。例如:

class vector{
    //...
    vector(int);
    //...

};
    vector v=10;                            //奇怪:创建了一个10个double的vector
    v=20;                                   //奇怪:将一个包含20个double的新vector赋予v
    
    void f(const vector&);
    f(10);                                  //奇怪:用一个包含10个double的新vector调用f

我们能够通过一种简单的方式禁止将构造函数用于隐式类型转换。由关键字explicit定义的构造函数(即显式构造函数)只能用于对象的构造而不能用于隐式转换。例如:

class vector{
    //...
    explicit vector(int);
    //...

};
    vector v=10;                            //错误:不存在int到vector的转换
    v=20;                                   //错误:不存在int到vector的转换
    vector  v0(10);                         //正确
    
    void f(const vector&);
    f(10);                                  //错误:不存在int到vector的转换
    f(vector(10));                          //正确

为了避免意外的类型转换,我们和标准库都将vector的单参数构造函数定义为explicit的。很遗憾,构造函数默认不是explicit的;当我们拿不定主意时,应将所有单参数的构造函数定义为explicit的。

调试构造函数和析构函数

每当类型X的一个对象被构建时,类型X的一个构造函数将被调用。每当类型X的一个对象被销毁时,类型X的析构函数将被调用。体会它们的一个好办法是向构造函数、赋值操作和析构函数添加打印语句,然后尝试运行程序,例如:

#include "std_lib_facilities.h"

using namespace std;

struct X{
    int val;

    void out(const string& s,int nv)
        { cerr< "<v(4);                                          //默认值
    XX loc4;
    X* p=new X{9};                                          //在自由空间上分配一个X
    delete p;
    X* pp=new X[5];                                         //在自由空间上分配一个X的数组
    delete[] pp;
}

你可以通过观察构造函数的调用次数与析构函数的调用次数是否相等来判断程序中是否存在内存泄漏问题。

访问vector元素

到目前为止,我们已经使用过成员函数set()和get()访问vector的元素,但这种用法冗长、不美观。我们希望能够使用习惯的下标表示方式v[i]。为此,我们需要定义一个名为operator[]的成员函数。三次改进,最终达到我们的目的:

class vector{
    int sz;
    double* elem;
public:
    //...
    double operator[](int n) { return elem[n]; }
};

    vector v(10);
    double x=v[2];                           //正确
    v[3]=x;                                  //错误:v[3]不是一个左值

这个版本只能实现对元素的读操作而并未实现写操作。

class vector{
    int sz;
    double* elem;
public:
    //...
    double* operator[](int n) { return &elem[n]; }
};

    vector v(10);
    for(int i=0;i

这种实现的问题是,在我们对元素进行访问时,不得不用* 对指针进行解引用。这与必须使用set()和get()几乎一样糟糕。从下标运算符返回元素的引用可以解决这一问题:

class vector{
    int sz;
    double* elem;
public:
    //...
    double& operator[](int n) { return elem[n]; }
};

    vector v(10);
    for(int i=0;i

我们已经实现了传统表示方法:v[i]被解释为函数调用v.operator[](i),返回v的编号为i的元素的引用。

对const向量重载运算符

到目前为止,operator[]()的定义存在一个问题:它不能用于const vector对象。例如:

void f(const vector& cv)
{
    double d=cv[1];                             //错误,但本应是正确的
    cv[1]=2.0;                                  //错误(本该如此)
}

其原因在于我们的vector::operator[]()可能会潜在地改变vector对象。即使它实际上没有改变,编译器仍会认为这是一个错误,因为我们“忘了”将这一情况告诉编译器。解决方法是再定义一个const成员函数的版本:

class vector{
    int sz;
    double* elem;
public:
    //...
    double& operator[](int n);                  //用于非const的vector
    double operator[](int n)const;              //用于const vector
};

对const版本,我们显然不能返回一个double&,而应返回一个double值。返回一个const double&的效果是一样的,但由于double只是一个很小的对象,没有必要返回引用,因此我们决定以传值方式返回它。现在,我们可以编写如下代码:

void ff(const vector& cv,vector& v)
{
    double d=cv[1];                             //正确(使用const[])
    cv[1]=2.0;                                  //错误(使用const[])
    double d=v[1];                              //正确(使用非const[])
    v[1]=2.0;                                   //正确(使用非const[])
}

数组

一个数组就是内存中连续存储的同构对象序列;也就是说,一个数组中的所有元素都具有相同的类型,并且各元素之间不存在内存间隙。数组中的元素从0开始顺序编号。实际上,数组通常可定义为

  • 全局变量(但定义全局变量通常是一个糟糕的主意)
  • 局部变量(但数组作为局部变量有严重的局限)
  • 函数参数(但一个数组不知道其自身大小)
  • 类的成员(但数组成员难以初始化)
const int max=100;
int gai[max];                                   //一个全局数组(包含100个int);“永远活跃”

void f(int n)
{
    char lac[20];                               //局部数组;“活跃”至作用域结束为止
    int lai[60];
    double lad[n];                              //错误:数组大小不是常量
    //...
}

注意数组使用的限制:一个具名数组的元素数目必须在编译时就已知。如果你希望元素的数目是一个变量,那么就必须在自由空间中分配数组,并通过指针对数组进行访问。并且要注意,数组不会进行范围检查。

指向数组元素的指针

指针可以指向数组的元素。例如:

    double ad[10];
    double* p=&ad[5];                           //指向ad[5]

现在指针p指向double型元素ad[5],我们能够对指针使用下标与解引用运算符:

    *p=7;
    p[2]=6;
    p[-3]=9;

我们既可以用整数也可以用负数作为下标。只要结果元素位于数组的范围之内,就是合法的操作。但是,通过指针访问位于数组范围之外的数据是非法的。通常,编译器不能检测数组范围之外的访问,并且这样的访问迟早会造成灾难性的后果。
当指针指向一个数组内时,我们对它进行加法和下标操作就能令它指向数组中的其他元素。例如:

    p+=2;                                       //将p向右移动2个元素
    p-=5;                                       //将p向左移动5个元素

用+、-、+=、-=移动指针称为指针运算。显然,当我们进行这种运算时必须十分小心,确保结果不超出数组的范围。不幸的是,由指针运算所造成的错误有时很难被发现。通常最好的策略是尽量避免使用指针运算。
最常见的指针运算是对指针进行自增操作(使用++)以使指针指向下一个元素,以及对指针进行自减操作(使用--)以使指针指向上一个元素。例如,我们可以通过如下方式打印ad的元素或者反向打印ad的元素的值:

    for(double* p=&ad[0];p<&ad[10];++p)         //正向打印
        cout<<*p<=&ad[0];--p)         //反向打印
        cout<<*p<

注意,指针运算在实际程序中最常用的用途是对传递给函数的指针参数进行运算。在这种情况下,编译器并不知道指针指向的数组包含元素的个数;一切要靠你自己把握。只要我们能够选择,最好能避免这种情况。
指针运算可能造成大的麻烦,而且与下标操作相比不能提供任何更多功能。例如:

    double* p1=&ad[0];
    double* p2=p1+7;            //注意:跟自增自减操作区分开,赋值不改变指针指向,即p1不会向右移动7个元素
    double* p3=&p1[7];
    if(p2!=p3) cout<<"impossible"<

指针和数组

数组的名字代表了数组的所有元素。。例如:

    char ch[100];

ch的大小(size(ch))为100.然而,数组的名字可以转化(退化)为指针。例如:

    char* p=ch;

p被初始化为&ch[0],而sizeof(p)可能为4之类的值(而非100)。
这一特征是十分有用的。例如,考虑函数strlen(),它能够统计一个以0结尾的字符数组中包含的字符总数:

int strlen(const char* p)
{
    int count=0;
    while(*p) { ++count;++p; }
    return count;
}

将数组名转化为指针的一个原因是避免意外地以传值方式传递大量数据。例如:

int strlen(const char a[])
{
    int count=0;
    while(a[count]) { ++count; }
    return count;
}

char lots[100000];

void f()
{
    int nchar=strlen(lots);
    //...
}

编译器会认为参数声明char a[]等价于char * p,而函数调用strlen(lots)等价于strlen(&lots[0])。这能帮你避免代价高昂的拷贝操作,但可能会令你感到惊讶。因为在其他的所有情况下,当你向函数传递一个对象,又未显示声明以引用方式传递它时,对象都会被拷贝。
数组名会被当作指向其第一个元素的指针处理,注意,你通过这种方式获得的指针不是一个变量而是一个值,因此你不能对它进行赋值:

    char ac[10];
    ac=new char[20];                            //错误:不能为数组名赋值
    &ac[0]=new char[20];                        //错误:不能为指针值赋值

作为数组名向指针隐式转换的一个结果,你不能通过赋值操作拷贝数组:

    int x[100];
    int y[100];
    //...
    x=y;                                        //错误
    int z[100]=y;                               //错误

如果你需要拷贝一个数组,就必须编写一些更复杂的代码来实现。例如:

    for(int i=0;i<100;++i) x[i]=y[i];           //拷贝100个int
    memcpy(x,y,100*sizeof(int));                //拷贝100*sizeof(int)个字节
    copy(y,y+100,x);                            //拷贝100个int

数组初始化

char数组可用字符串字面值常量初始化。例如:

    char ac[]="Beorn";                          //6个字符的数组

编译器会在字符串字面值常量的末尾添加一个字符0表示结束。字符串以0结尾在C语言和很多系统中是规范表示方法。我们称这种以0结尾的字符数组为C风格字符串。所有字符串字面值字符串字面值常量都是C风格字符串。注意,数值为0的char不是字符‘0’或者其他的任何字母或数字。这种结尾0的目的在于帮助函数定位字符串的结束。记住,数组并不知道自身大小。我们需要n+1个char以存储n个字符的C风格字符串。只有字符数组能用字符串字面值常量进行初始化,但所有数组都能用一个其元素类型值的列表进行初始化。例如:

    int ai[]={1,2,3,4,5,6};                     //6个int的数组
    int ai2[100]={0,1,2,3,4,5,6,7,8,9};         //后90个元素被初始化为0
    double ad[100]={};                          //所有元素被初始化为0.0
    char chars[]={'a','b','c'};                 //无结尾0!

注意,ai的元素个数为6(不是7)且chars的元素个数为3(而非4)——“在末尾加0”这一规则只适用于字符串字面值常量。

指针问题

我们主要考虑以下的问题:

  • 使用空指针进行数据访问
  • 使用未初始化的指针进行数据访问
  • 对数组结尾之后的数据进行访问
  • 对已释放对象的访问
  • 对已离开作用域的对象进行访问
不要用空指针进行数据访问
    int* p=nullptr;
    *p=7;                                       //糟糕!

特别地,向一个函数传递p然后又从函数接收p作为返回结果是很常见的。我们建议不要向函数传递空指针,但如果你不得不这么做,应在使用之前检测空指针:

    int* p=fct_that_can_return_a_nullptr();
    
    if(p==nullptr){
        //执行一些操作
    }
    else{
        //使用p
        *p=7;
    }

并且

    void fct_that_can_return_a_nullptr(int* p)
    {
        if(p==nullptr){
            //执行一些操作
        }
        else{
            //使用p
            *p=7;
        }
    }

使用引用代替指针和使用异常来报告错误是避免空指针的主要工具。

对你的指针进行初始化
    int* p;
    *p=9;                           //糟糕!

特别地,不要忘记对作为类成员的指针进行初始化。

不要访问不存在的数组元素
    int a[10];
    int* p=&a[10];
    *p=11;                          //糟糕!
    a[10]=12;                       //糟糕!

在一个循环中访问第一个元素以及最后一个元素时应特别小心,应尽量避免将数组当作指向其第一个元素的指针进行传递。我们可以用vector代替数组

不要通过一个已清除的指针访问数据
    int* p=new int{7};
    //...
    delete p;
    //...
    *p=13;                          //糟糕!

防止这一问题最有效的办法是不要使用“裸”new操作,从而就不必使用“裸”delete操作:只在构造函数和析构函数中使用new与delete,或者使用容器如Vector_ref来处理delete。

不要返回指向局部变量的指针
    int* f()
    {
        int x=7;
        //...
        return &x;
    }
    
    //...
    
    int* p=f();
    //...
    *p=15;                          //糟糕!

一个函数的局部变量在进入函数时分配内存空间(在栈中),在函数退出时被释放。特别是,如果局部变量的类具有析构函数,则析构函数会被调用。

你可能感兴趣的:(c++)