码迷,mamicode.com
首页 > 编程语言 > 详细

《C++程序设计原理与实践》读书笔记(五)

时间:2015-05-20 18:46:31      阅读:149      评论:0      收藏:0      [点我收藏+]

标签:《c++程序设计原理与实践》   读书 笔记


拷贝


我们的vector类型具有如下形式:

class vector
{
    private:
    int sz;
    double * elem;
public:
    vector(int s):sz(s),elem(new double[s]){}
    ~vector() {delete [] elem;}
};


让我们试图拷贝其中的一个向量:

void f(int n)
{
    vector v(3);
    v.set(2, 2.2);
    vector v2 = v;
    //...
}

对于一种类型而言,拷贝的默认含义是“拷贝所有的数据成员”。对于对象v与v2而言,由于指针elem指向同一块内存,因此重复两次释放这一块内存将会造成灾难性的后果。那么,我们应该怎么做呢?我们需要显示地进行拷贝操作:当用一个vector对象初始化另一个vector对象时,应拷贝所有的向量元素并且保证这一拷贝操作确实被调用了。某一类型的对象的初始化是由该类型的构造函数实现的。所以,为实现拷贝操作,我们需要实现一种特定类型的构造函数。这种类型的构造函数称为 拷贝构造函数。C++定义拷贝构造函数的参数应该为一个对被拷贝对象的引用。因此,对于类型vector而言,它的拷贝构造函数如下形式:vector(const vector&);这一拷贝构造函数将在我们试图使用一个vector对象初始化另一个vector对象时被调用。拷贝构造函数使用对象引用作为参数的原因在于我们不希望在传递函数参数时又发生参数的拷贝,而使用const引用的原因在于我们不希望函数对参数进行修改。因此,我们按如下形式重新定义vector类型:

class vector
{
         int sz;
         double *elem;
         void copy(const vector& arg){ for(int i = 0; i < arg.sz; ++i) elem[i] = arg.elem[i];}
    public:
         vector(const vector&):sz(arg.sz),elem(new double[arg.sz]) { copy(arg);}
        //...
};

拷贝赋值


我们可以通过构造函数拷贝(初始化)对象,但我们也可以通过赋值的方式进行vector对象的拷贝。与拷贝初始化类似,拷贝赋值默认只进行对象成员的拷贝。因此,对于我们目前定义的vector类型而言,拷贝赋值会造成数据重复删除以及内存泄漏等问题。例如:

void f2(int n)
{
     vector v(3);
     v.set(2, 2.2);
     vector v2(4);
     v2 = v;
     // ...
}

我们希望对象v2成为对象v的副本(标准库的vector类型按这种方式实现,但由于我们并未定义vector类型的拷贝赋值操作,因此将执行默认的拷贝赋值操作,即赋值操作将进行成员拷贝)。我们应按如下方式定义拷贝赋值操作:

class vector
{
     int sz;
     double * elem;
     void copy(const vector& arg);
public:
     vector& operator=(const vector&);
}
vector& vector::operator=(const vector& a)
{
     double * p = new double[a.sz];
     for(int i = 0;i < a.sz; ++i) p[i] = a.elem[i];
     delete [] elem;
     elem = p;
     sz = a.sz;
     return * this;
}

由于拷贝赋值操作需要考虑对一个对象原有元素的处理,因此拷贝赋值操作比拷贝构造操作稍微复杂一些。


在实现拷贝赋值操作时,我们可以在创建副本之前首先释放原有元素所占用的内存以简化代码,但这不是一个好的做法。更好的做法是,我们应一直保留原有元素直到我们确信原有元素能够被安全释放。如果我们不这么做,那么将一个对象赋值给它自身时将有可能产生奇怪的结果。


拷贝术语


对于大多数的程序以及编程语言而言,拷贝带来了很多的问题。一个基本问题是你应该拷贝一个指针(或引用)还是应该拷贝指针指向(或引用)的数据:

     (1)浅拷贝只拷贝指针,因此两个指针可能指向同一个对象。

     (2)深拷贝将拷贝指针指向的数据,因此两个指针将指向两个不同的对象。当我们需要为某一类型的对象实现深拷贝时,我们需要显式地为该类型定义自己的拷贝构造函数与拷贝赋值函数。

实现了浅拷贝的类型(如指针与引用)称为具有指针语义或引用语义(它们拷贝地址)。实现了深拷贝的类型称为具有值语义(它们拷贝指向的值)。从用户角度看来,具有值语义类型的拷贝操作像没有涉及指针一样--仅仅只有值被拷贝了。也可以说,在进行拷贝时,具有值语义的类型表现得就好像它自己是整数类型一样。


必要的操作


一个类型应该选择哪些构造函数、该类型是否应定义析构函数、类型是否应定义拷贝赋值函数。

     (1)具有一个或多个参数的构造函数;

     (2)默认构造函数;

     (3)拷贝构造函数(拷贝同一类型的对象);

     (4)拷贝赋值函数(拷贝同一类型的对象);

     (5)析构函数

通常,我们需要一个或多个构造函数以采用不同的参数实现对象实始化。实始值的含义/用途完全取决于构造函数。通常我们使用构造函数来建立不变式。如果我们不能定义一个类的构造函数能够建立的好的不变式,那么很可能我们使用了一个糟糕的类设计或者简单的数据结构。具有参数的构造函数的形式随它们所在类型的不同而不同。而与之相比,其他的操作则具有更加规则的形式。我们如何知道一个类型是否需要默认构造函数呢?如果我们希望在不指定初始值的前提下构造该类型的对象,那么该类型就需要默认构造函数。如果一个类型需要获取系统资源,则该类型需要析构函数。类型需要析构函数的另一个特征是该类型具有指针成员或引用成员。如果一个类型具有指针成员或引用成员,则该类型通常需要实现析构函数以及拷贝操作。通常,一个实现了析构函数的类型同时也需要实现拷贝构造函数与拷贝赋值函数。其原因很简单,如果该类型的一个对象获取了资源(或者具有指向资源的指针成员),那么只进行默认拷贝(浅拷贝)几乎肯定会带来错误。另外,对于一个基类而言,如果它的派生类具有析构函数,则该基类的析构函数应为虚函数。


显示构造函数:只具有一个参数的构造函数定义了一个从其参数类型向该函数所秘史类型的转换。这种转换是十分重要的。例如:

class complex
{
     public:
         complex(double);
         complex(double, double);
         //...
};
complex z1 = 3.14;
complex z2 = complex(1.2, 3.4);


尽管如此,我们应谨慎地使用隐式转换,因为隐式转换可能会造成不可预料的后果。幸运的是,我们能够通过一种简单的方式禁止将构造函数用于类型的隐式转换。由关键字explicit修饰的构造函数(即显式构造函数)只能用于对象的构造而不能用于隐式转换。例如:

class vector
{
     // ...
     explicit vector(int);
     // ...
};
vector v = 10;  // error
v = 20;   //error
vector v0(10);   //ok


调试构造函数与析构函数


在程序的执行过程中,构造函数与析构函数都将在明确的、可预计的时间点上被调用。尽管如此,我们并不是总是需要采用显式的方式来调用这些函数。我们在做某些事的时候也会调用这些函数。构造函数与析构函数的调用可能会造成人们对语法的混淆。下面是常见的构造函数与析构函数被调用的场合:

     (1)每当类型X的一个对象构建时,类型X的一个构造函数将被调用。

     (2)每当类型X的一个对象被销毁时,类型X的析构函数将被调用。

每当类型的一个对象被销毁时,该类型的析构函数将被调用;这种情况可能发生在变量的作用域结束时、程序结束时或者delete作用于一个指向对象的指针时。每当类型的一个对象被构建时,该类型的构造函数将被调用;这种情况可能发生在变量被初始化时,通过new构建对象(除了内建类型)时以及拷贝对象时。为了对这个问题进行体会,我们在构造函数,赋值函数以及析构函数内加入了打印语句。

struct X
{
     int val;
 void out(const string&s, int nv) { cerr << this << "->" << s << ":" << "(" << nv << ")\n";}
 X() { out("X()", 0); val = 0;}
 X(int v) {out("X(int)", v); val = v;}
 X(const X& x) {out("X(X&)", x.val); val = x.val;}
 X& operator(const X& a) {out("X::operator=(), a.val); val = a.val; return * this;}
 ~X() {out("~X()", 0);}
};

X glob(2);
X copy(X a) {return a;}
X copy2(X a) { X aa = a; return aa;}
X& ref_to(X& a) {return a;}
X* make(int i) {X a(i); return new X(a);}
X* make(int i) {X a(i); return new X(a);}

struct XX {X a; X b;};

int main()
{
     X loc(4);
     X loc2 = loc;
     loc = X(5);
     loc2 = copy(loc);
     loc2 = copy2(loc);
     X loc3(6);
     X& r =ref_to(loc);
     delete make(7);
     delete make(8);
     vector <X> v(4);
     XX loc4;
     X * p =new X(9);
     delete p;
     X* p = new X(9);
     delete p;
     X* pp = new X[5];
     delete [] pp;
}


访问向量元素

class vector
{
     double& operator[](int n) { return elem[n];}
     double operator[](int n) const; //对const对象重载运算符
};

上述实现使得对象vector的下标操作符具有与常规下标操作符相似的含义:v[i]被解释为函数调用v.operator[](i),且调用返回对象v的编号为i的元素的引用。


数组


我们已经通过使用数组来引用在自由存储区中顺序排列的对象。与命名变量一样,我们也可以在其他的地方分配数组。实际上,数组可以作为:

     (1)全局变量(但定义全局变量通常是一个糟糕的主意)

     (2)局部变量(但数组作为局部变量时会受到严格的限制)

     (3)函数成员(但一个数组不知道其自身大小)

     (4)类的成员(但数组成员难于初始化)

现在,你可能会发觉我们更赞成使用vector类型而不是数组。我们应当尽可能地用vector类型取代数组。尽管如此,数组在vector对象出现之前就已经存在了很长的时间,并且它与其他编程语言中(如C语言)的数组提供的功能大致相同,因此我们必须学会如何使用数组,以便我们能够处理那些很久以前编写的代码,或者那些由不能使用vector类型的人编写的代码。


数组是在内存空间中顺序排列的同类型对象的集合;也就是说,数组的所有元素都具有相同的类型,并且各元素之间不存在内存空隙。数组中的元素从0开始顺序编号的。数组可以用“方括号”表示:

const int max = 100;
int gai[max];
void f(int n)
{
    char lac[20];
    int lai[60];
    double lad[n];
    // ...
}

注意,数组的使用存在一个限制:对于一个命名数组而言,在程序编译时必须知道该数组包含元素的数目。如果你希望元素的数目是一个变量,那么你必须在自由存储中分配数组,并通过指针对数组进行访问。vector类型就是这么做的。像在自由存储区存放的数组一样,我们通过下标与解引用操作符([]和*)访问命名数组。


指向数组元素的指针

double ad[10];
double * p = &ad[5];


我们可以使用正数或负数作为指针的下标操作数。只要元素位于数组的范围之内,那么这样的操作就是正确的。然而,通过指针访问位于数组之外的数据是非法的。通常,编译器不能监测对数组范围之外数据的访问,并且这样的访问很可能是灾难性的。当指针指向一个数组时,加操作与下标操作能够改变指针,使得指针指向数组中的其他元素。例如:

p+=2; p-=5;

通过操作符+、-、+=、-=移动指针只能在数组的范围内进行移动。不幸的是,由指针运算所造成的错误有时很难被发现。通常最好的策略是尽量避免使用指针运算。指针运算最常见的操作是对指针进行自增操作(使用++)以使指针指向下一个元素,以及对指针进行自减操作(使用--)以使指针指向上一个元素。例如,我们可以通过如下方式打印ad元素取值:

for(double * p = &ad[0]; p < &ad[10]; ++p) cout << *p << ‘\n‘;

或者反向打印:

for(double * p = &ad[9]; p >= &ad[0]; --p) cout << *p << ‘\n‘;

注意,指针元素另一种常用的方式是将指针作为函数的参数进行传递。C++允许指针运算主要是因为历史原因。还有部分原因在于,在一些低层次的应用中,使用指针运算更为便利。


数组的名字代表了数组的所有元素。例如: char ch[100]; ch的大小sizeof(ch)为100。然而,数组的名字可以转化(退化)为指针。例如:char * p = ch;


回文


使用string实现回文。使用标准库的string类型以及int类型的索引跟踪字符比较的进度:

bool is_palindrome(const string& s)
{
     int first = 0;
     int last = s.length() -1;
     while (first < last)
    {
         if (s[fisrt] != s[last] return false;
         ++ first;
         --last;
     }
     return true;
}


使用数组实现回文

bool is_palindrome(const char s[], int n)
{
    int first = 0;
    int last = n-1;
    while (first < last)
    {
       if (s[fist] != s[last] return false;
       ++first;
       --last;
    }
    return true;
}


使用指针实现回文

bool is_palindrome(const char * first, const char * last)
{
    while (first < last)
   {
        if (*first != *last) return false;
        ++first;
        --last;
   }
   return true;
}


注意,实际上我们可以对指针进行自增操作或自减操作。自增操作使指针指向数组中的下一个元素,而自减操作使指针指向上一个元素。如果指针指向的区域超出了数组的实际范围,那么将会产生严重的越界错误。这是使用指针可能会产生的问题。

《C++程序设计原理与实践》读书笔记(五)

标签:《c++程序设计原理与实践》   读书 笔记

原文地址:http://hthinker.blog.51cto.com/5611549/1653249

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!