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

默认使用向量。 --Alex Stepanov



C++标准库中最有用的是向量。一个向量提供一系列指定类型的元素。你可以通过它的索引(下标)找到一个元素,使用push_back()来扩展向量,使用size()来获得一个向量中的元素数量,以及防止对超出范围的向量元素的访问。标准库向量是一个方便的、灵活的、有效的(在时间和空间上)、静态的、类型安全的元素容器。


向量的基本知识


我们开始对向量的循序渐进的设计,考虑一个非常简单的用途。

vector<double> age(4);
age[0] = 0.23;
age[1] = 22.0;
age[2] = 27.2;
age[3] = 54.2;

很明显,我们创建一个有四个double类型元素的向量,并且给这四个元素分别赋值为0.33,22.0,27.2和54.2。将这四个元素编号为0、1、2和3。对于C++标准库容器中的元素编号只能从0开始。从0开始编号是很常用的,它是C++程序员之间的普遍约定。一个向量中的元素数量称为它的大小。因此,age的大小为4。一个向量中的元素被编号(索引)为0到size-1。例如,age中的元素被编号为0到age.size()-1。鉴于此,我们可以定义自己的第一个版本的vector类:

class vector
{
    int sz;
    double * elem;
public:
    vector(int s);
    int size() const {return sz;};
}


运算符sizeof


那么,一个int实际占用多少内存?一个指针?操作符sizeof会回答这些问题:

cout << "the size of char is " << sizeof(char) << ' ' << sizeof(‘a') << "\n";
cout << "the size of int is " << sizeof(int) << ' ' << size(2+2) << '\n';
int * p = 0;
cout << "the size of int * is " << sizeof(int *) << ' ' << sizeof(p) << '\n';

正如你看到的,我们可以将sizeof用于一个类型名或表达式。对于一个类型名来说,sizeof给出这种类型对象大小;对于一个表达式来说,sizeof给出表达式结果的大小。sizeof的结果是一个正整数,sizeof(char)单元的大小被定义为1。在情况下,一个char被保存在一个字节中,因此sizeof会报告占用的字节数。每个C++实现并不保证一种类型的大小相同。


自由空间分配


我们要求使用new操作符从空闲空间中分配内存:

(1)new操作符返回一个指向被分配内存的指针。

(2)一个指针指向一个特定类型的对象。

(3)一个指针并不知道它指向多少个元素。

(4)new操作符可以为单个元素或一系列(数组)元素分配内存。

new操作符可以为单个元素或一系列(数组)元素分配内存。分配的对象数量可能是变化的。由于允许我们在运行时选择分配多少个对象,因此它是很重要的。

这是“实际的解释”。理论上的解释是“允许为指针分配不同类型将导致类型错误。


我们永远要保证使用对象之前为它赋一个值,也就是说,我们希望确认指针被初始化,并且指针指向的对象被初始化。思考下面的代码:

double * p0;
double * p1 = new double;
double * p2 = new double(5.5);
double * p3 = new double[5];


空指针


如果你没有其他指针用于初始化一个指针,那么使用0:

double * p0 = 0;

当0被赋给一个指针时,0被称为空指针。我们经常通过检测指针是否是0,以判断一个指针是否有效(如它是否指向什么东西)。


自由空间释放


new操作符会从自由空间中分配内存。由于一台计算机的内存是有限的,因此在使用结束后将内存释放回自由空间通常是个好主意。这样自由空间可以将这些内存重新用于新的分配。对于大型的程序和长时间运行的程序来说,这种自由空间的重新使用是很重要的。例如:

double * calc(int res_size, int max)
{
   double * p = new double[max];
   double * res = new double[res_size];
   return res;
}


double * r = calc(100,1000);

在写操作时,每次调用calc()会造成分配给p的double数组“泄漏”。将内存返回自由空间的操作符称为delete。我们对于一个指针使用delete返回new分配的内存,以使这些内存可以用于未来的分配。现在,这个例子变为:

double * calc(int res_size, int max)
{
    double * p = new double[max];
    double * res = new double[res_size];
    delete [] p;
    return res;
}
double * r = calc(100,1000);
delete[] r;

顺便说一句,这个例子证明使用自由内存的一个主要原因:我们可以在一个函数中创建对象,并将它们传送给函数的调用者。这里有两种形式的delete:

(1)delete p 释放由new分配给单个对象的内存。

(2)delete[] p释放由new分配给数组对象的内存。

对于程序员来说,使用正确的方式是一件乏味的工作。删除一个对象两次是一个糟糕的错误。

int * p = new int(5);
delete p;
delete p;

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

(1)因此自由空间管理器可能会改变它的内部数据结构,导致无法再次正确地执行delete p,你已不再拥有指针所指向的对象。

(2)自由空间管理可能已“回收”p指向的内存,因此p现在可能指向其他对象;删除其他对象(由程序的其他部分所拥有)将会在你的程序中引起错误。

这两个问题都发生在实际的程序中;它们不只是在理论上有可能性。

删除空指针不会做任何事(因为空指针不指向一个对象),因此删除空指针是无害的。

int * p = 0;

delete p;

delete p;

为什么我们都会被自由内存所困扰?编译器不能指出我们什么时候不需要一段内存,并在没有人工干预的情况下将它回收吗?它可以。这个过程称为自动垃圾收集或垃圾收集。不幸的是,自动垃圾收集并不是免费的,并且不是对所有应用都是理想的。如果你实际需要自动垃圾收集,你可以将一个自动垃圾收集器插入你的C++程序。好的垃圾收集器是有效的。


在什么时候不泄漏内存是重要的?一个“永远”运行的程序不能承受泄漏内存。一个不能有内存泄漏的操作系统是“永远运行的”程序的例子,大多数的嵌入式系统也是这样。很多程序使用库作为系统的一部分,因此库也不能出现内存泄漏的问题。一般来说,所有程序都不产生内存泄漏是个好主意。很多程序将泄漏的原因归结于马虎,但是,这并没有切中要点。当你一种操作系统上运行程序,在程序结束时会将所有内存自动返回给系统。这会带来一个问题,如果你知道你的程序不会使用比可用更多的内存,你可能“合理的”决定泄漏内存直一以操作系统为你释放内存。但是,你如果你决定要这样做,确定你所估计的内存消耗是正确的,否则人们将有好的理由认为你是草率的。


析构函数


在一个对象的类创建时会隐式调用构造函数,当一个对象离开作用域时会隐式调用析构函数。构造函数用于确认一个对象是否被正确创建和初始化。与之相反,析构函数用于确认一个对象是否被正确销毁。

class vector
{
       int sz;
       double * elem;
public:
    vector(int s):sz(s),elem(new, double[s])
    {
       for(int i = 0; i < s; ++i) elem[i] = 0;
    }
    ~vector(){delete [] elem;}
}


有了这个定义,我们就可以这样使用vector了:

void f3(int n)
{
    int * p = new int [n];
    vector v(n);
    delete [] p;
}


delete[]看起来相当繁琐并且容易出错。对于vector,我们不必使用new分配内存,以及在函数结束时使用delete[]释放内存。vector已经做了这些工作,而且做得更好。特别是,vector不能忘记调作它的析构函数释放元素使用的内存。处理资源来说很重要,这些资源需要使用前申请和使用后释放缓冲区空间等。这些工作由它们的析构函数完成,每个“拥有”资源的类都需要一个析构函数。如果一个类的成员拥有一个析构函数,则在包含这个成员的对象销毁时调用这个析构函数。

struct Customer{
string name;
vector<string> addresses;
// ...
};
void some_fct()
{
   Customer fred;
}


成员和基类的析构函数在从派生类的析构函数(无论用户定义或编译器生成)中被隐式调用。基本上,所有的规则可以被总结为:“当对象被销毁时,析构函数被调用”(当离开作用域时,当delete被调用时等)。


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

    (1)无论一个类对象需要使用哪种资源,这种资源都要在构造函数中获得。

    (2)在对象的生命期中,它可以释放资源和获得新的资源。

    (3)在对象的生命期结束后,析构函数释放对象拥有的所有资源。

     作为一个经验法则:如果你有一个带有虚函数功能的类,则它需要一个虚的析构函数。具体原因是:

    1)如果一个类有虚函数功能,它经常作为一个基类使用。

    2)如果它是一个基类,它的派生类经常使用new来分配。

   3)如果一个派生类对象使用new来分配,并且通过一个指向它的基类的指针来控制,那么它经常通过一个指向它的基类的指针来删除它。

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


访问向量元素


为了使vector可以使用,我们需要一种读和写元素的方法。对于初学者来说,我们可以提供简单的get()和set()成员函数:

class vector
{
   private:
       int sz;
   double * elem;
   public:
       vector(int s):sz(s),elem(new double[s]){}
      ~vector() {delete [] elem;}
       int size() const{return sz;}
   
       double get(int n){return elem[n];}
       void set(int n, double v) {elem[n] = v;}
};


get()和set()都可以访问元素,在 elem指针上使用[]操作符:elem[n]。

    现在,我们可以生成一个double型的vector并使用它:

vector v(5);
for(int i = 0; i < v.size(); ++i)
{
    v.set(i, 1.1*i);
    cout << "v[" << i << "]==" << v.get(i) << '\n';
}

这里将会输出:

v[0] == 0
v[1] == 1.1
v[2] == 2.2
v[3] == 3.3
v[4] == 4.4

这仍是一个相当简单的vector,相对于常用的下标符号来说,使用get()和set()的代码是很难看的。但是,我们的目的是从小的和简单的程序开始,沿着这个方式逐步测试和扩充我们的程序。这种发展和反复测试的策略可以减少错误和调试过程。

vector *f(int s) { vector * p = new vector(s); return p;}
void ff() {vector * q = f(4); delete p;}


注意,当我们删除一个vector时,它的析构函数会被调用。

vector * p = new vector(s);
delete p;

在自由空间中创建vector, new操作符:

   首先为一个vector分配内存。

   然后,激活vector的构造函数初始化vector;构造函数为vector的元素分配内存,并初始化这些元素。


删除vector, delete操作符:

  首先激活vector的析构函数;这个构构函数激活这些元素的析构函数(如果它们有析构函数),然后释放这些元素使用的内存。

  然后,释放vector使用的内存。


  类型混用:无类型指针和指针类型转换

  在使用指针自由空间分配的数组时,我们非常接近硬件层面。基本上,我们对指针的操作(初始化、分配、*和[])直接映射为机器指令。在这个层次,语言只提供一点描述上的便利,以及由类型系统提供的编译时的一致性。偶尔,我们不得放弃这最后一点保护。

  通常,我们不希望在没有类型系统的保护下工作,但是有时没有其他选择。我们有时也会遇到一些情况,我们需要面对没有根据安全类型设计的老的代码。在这种情况下,我们需要两样东西:

  1)一种指向不知道自己保存的何种对象的内存的指针。

  2)一种操作,对于这类指针,它告诉编译器指针指向哪种(未证实)的操作类型。 

    类型void *的含义是“指向编译器不知道类型的那些内存”。当我们想在两段代码之间传输一个地址,并且不知道每段代码实际的类型时,就可以使用void *。


指针和引用


我们可以将一个引用看做是一个自动解引用的、不可改变的指针或是一个对象的别名。指针和引用在以下几个方面不同:

(1)为一个指针赋值为改变指针的值(不是指针指向的值)。

(2)为了得到一个指针,你通常需要使用new或&。

(3)为了访问一个指针指向的对象,你可以使用*或[]。

(4)为一个引用赋值会改变引用指向的值(不是引用自身的值)。

(5)在初始化一个引用之后,你不能让引用指向其他对象。

(6)为引用赋值执行深度复制(赋值给引用的对象);为指针赋值不是这样(赋值给指针自身)。

(7)注意空指针。


引用和指针都是通过使用内存地址来实现的。它们只是在地址的使用上不同,为编程人员提供稍有不同的功能。


我们如何选择使用引用参数还是指针参数呢?不幸的是,每种方法都有自己的优点和缺点,因此在这方面仍没有明确的答案。你需要根据程序和可能的用途来决定。因此,实际的答案是“选择依赖于函数的性质”:

(1)对于小的对象,倾向于传递值。

(2)对于“没有对象”(用0表示)是有效参数的函数,使用一个指针参数(记着对0进行测试)。

(3)否则,使用一个引用参数。

C++提供了相应的机制:在每个成员函数中,标识符this就是指向用户调用成员函数所用对象的指针。


你可能感兴趣的:(C++,实践,程序设计原理)