第14章 近乎自动地管理内存——句柄类

accelerated-cpp学习笔记,参考电力出版社的《Accelerated C++》翻译,这里是第14章内容。本章的内容主要在于定义一个句柄类,用于管理基类和它的派生类,使得它们可以像数值一样使用。介绍了一个通用的句柄类和一个计数引用对象,并结合起来,定义了一个可以自己决定什么时候复制对象的句柄,使用make_unique函数,并且改进了该句柄在复制对象时的操作。


第14章 近乎自动地管理内存——句柄类

我们写了一个StudentInfo句柄函数,在那里我们把两个独立的抽象概念综合在一个类中。StudentInfo类不仅提供了对一个学生记录进行操作的接口,还可以用来操纵一个指向实际实现对象的指针。把两个独立的概念综合在一个类,这种做法通常不是很好的习惯。

于是,我们想定义一个类似于StudentInfo的类,不过它从严格的意义上来说只是一个接口类。这种接口类在C++中是十分常见的,特别是在继承树中尤为常见。我们要设法使我们的接口类把实现细节交给另一个类,这另一个类像一个指针一样工作,不过它是用来管理底层的内存的,我们把它叫作类指针类。一旦我们把StudentInfo类分成一个接口类和一个类指针类,我们就可以把一个类指针类与几个接口类放在一起使用。在合适的地方通过使用几个类指针的对象指向一个底层的对象,我们可以避免对某些对象进行不必要的复制操作。

在复制一个对象的时候程序实际上做了什么?乍一看,这个问题好像十分简单:复制出来的新对象是一个独立的对象,它与源对象有着相同的数据。但是,在复制的时候也可能会得到一个仅仅是源对象的一个引用的对象,这么一来问题就变得复杂起来:如果对象x是另一个对象y的引用,那么复制x的时候会不会使y也被复制。显然,如果yx的一个成员,那么答案是肯定的,如果x只不过是一个指向y的指针,那么答案是否定的。本章我们将定义三个不同版本的类指针类,它们在对复制的定义上彼此不同。

1 用来复制对象的句柄

在第一个解决方案中,我们用指针来储存混合类型对象的集合。每一个指针有可能指向一个Core类型对象,也有可能指向一个Core类的派生类的对象。用户必须在代码中负责为对象动态分配内存,还要记住为对象释放内存。这个程序因为要程序员亲自处理管理指针的细节而变得混乱不堪,充满着安全隐患。

之所以产生这样的问题,是因为指针是C++中最原始、最底层的数据结构之一、对指针进行编程充满了可能引发错误的隐患。很多指针引发的问题都是因为不同指针指向的对象之间不是互相独立的,从而导致产生错误:

  • 复制一个指针不会导致对指针所指对象的复制,当无意中使两个指针指向同一个对象
    时,常常会产生莫名其妙的结果。
  • 删除一个指针不会释放指针所指对象所占用的内存,这常常导致内存泄漏。
  • 删除一个对象但是没有删除指向该对象的指针会产生一个空悬指针(dangling pointer),在程序中使用这些指针的时候会导致未定义操作。
  • 如果定义一个指针但是不对它进行初始化,会使该指针没有指向任何地方,这时候如果程序中用到该指针,会导致未定义操作。

我们再次解决了成绩问题,这一次我们在程序中用到了 Student_info句柄类。因为这个类是操纵指针的,所以用户代码只需要处理StudentInfo类型对象,而不需要直接与指针打交道。但是,StudentInfo类马上被Core类继承的问题束缚着:它包含着从Core类公共接口定义映射而来的操作。现在我们要做的是把这些抽象的概念分开来放进不同的类中。我们还是用StudentInfo类来提供接口,但是想通过另一个类來控制“句柄”。这也就是说,这另一个类将用来管理指向实现对象的指针。这个新的类与类句柄匹配对象所属的类是彼此独立的。

1.1 一个通用的句柄类

因为我们希望我们的类与它所操作的对象的类之间彼此独立,所以这个类必须是一个模板。因为我们希望用它来封装句柄行为,所以我们把这个类叫做Handle。在这个类中将具有以下儿点性质:

  • 一个Handle类型对象是一个指向某对象的值。
  • 我们可以对Handle类型对象进行复制。
  • 我们可以通过检测一个Handle类型对象来判断它是否指向另一个对象。
  • 如果一个Handle类型对象指向继承关系树中某个类的一个对象,我们可以用Handle类对象来触发多态行为。也就是说,如果通过Handle类来调用一个虚拟函数,我们希望程序在运行的时候能够动态地选择调用哪一个函数,就像是我们在通过一个真实的指针调用这个虚拟函数那样。

我们的Handle类将会有一个规定的接口:一旦你使一个Handle类型对象指向一个对象,这个Handle类型对象将负责为那个对象进行内存管理。对一个对象我们只能为其匹配一个Handle类型对象,在此之后就不应该在随后直接通过指针来访问该对象;所有的访问都要通过这个Handle类型对象来进行。这一限制使得Handle避免了使用C++自带的指针时候固有的问题。在复制一个Handle类型对象的时候,我们为该对象生成一个新的复件,这样每个Handle类型对象都指向各自的复件、在删除一个Handle类型对象的时候,它会删除相应的对象,而这也是删除该对象的惟一途径。我们还允许用户生成一个没有指向任何对象的Handle类型对象,不过在用户试图通过这样一个对象来访问它所指向的对象时程序会抛出一个异常。用户可以通过检测Handle是否有效来避免抛出这个异常。

这些性质有点像我们在实现StudentInfo类的时候提出的要求。StudentInfo类的拷贝构造函数和赋值运算符函数调用clone函数来复制相关的Core类型对象。它的析构函数也会删除Core类型对象。这个对底层对象进行的操作会在删除对象之前先检査一下StudentInfo类型对象是否指向一个实际的对象。现在我们需要一个类来封装类似的行为,不过我们是用它来管理所有类型的对象的:

template <class T> class Handle {
public:
    Handle() : p(0) {}
    Handle(const Handle& s) : p(0) { if (s.p) p = s.p->clone(); }
    Handle&operator=(const Handle&);
    ~Handle() { delete  p; }
    Handle (T* t) : p(t) {}
    operator bool () const { return p; }
    T&operator*() const;
    T*operator->() const;

private:
    T* p;
};

Handle类是一个模板类,所以我们可以用它来为所有类型生成相应的Handle类。每一个Handle<T>类型对象都封装有一个指向T类型对象的指针;类中的其他操作都是用来管理这个指针的。除了函数名有些变化以外,Handle类的前四个函数与StudentInfo类中的相应函数是一样的。默认构造函数把指针初始化为0以表明该指针是无效的。复制构造函数(根据条件判断)调用相关对象的clone函数来生成该对象的个新的复件。Handle析构函数删除该对象并释放对象占用的内存空间。赋值运算符函数和复制构造函数一样,也是(根据条件判断)调用clone函数来生成对象的一个新的复件:

template <class T> Handle<T>& Handle<T>::operator=(const Handle &rhs) {
    if (&rhs != this) {
        delete p;
        p = rhs.p ? rhs.p->clone() : 0;
    }
    return *this;
}

像其他地方一样,Handle模板类中的赋值运算符函数先检査是否是在进行自我赋值,如果是的话就不进行任何操作。如果不是,就先释放我们操作着的对象的内存。然后把右操作数对象的内容复制到左操作数对象里。进行复制的程序语句中使用条件运算符来判断调用clone函数是否安全。如果rhs.p有效,则调用rhs.p->clone()并使结果指针指向p,否则就把p设为0。

因为Handle类模拟指针的行为,所以我们需要找到一种办法使指针与一个实际的对象相关联,我们在带有一个T*类型参数的构造函数中进行这一操作。这个构造函数从参数中得到一个指针T,然后使Handle类与T所指的对象关联在一起。例如,如果我们定义

Handle<Core> student(new Grad);

也就构造了一个名为studentHandle类型对象,它封装了一个Core*指针,我们对该指针初始化,使它指向我们刚才生成的一个Grad类型对象。

最后,我们定义了三个运算符函数。第一个是operator bool函数,它可以让用户在一个条件语句中检测一个Handle类型对象的值,在Handle类型对象与一个实际的对象关联的时候该函数返回真值,否则返回假值。另外两个算符函数是operator*operator->函数,它们是用来访问与Handle相关联的对象的:

template <class T> T& Handle<T>::operator*() const {
    if (p) return *p;
    else throw std::runtime_error("unbound Handle");
}
template <class T> T* Handle<T>::operator->() const {
    if (p) return p;
    else throw std::runtime_error("unbound Handle");
}

把一个C++内建的一元运算符*作用在一个指针上将得到该指针指向的对象。这里我们定义了我们自己的*运算符,对一个Handle类型对象作用*运算符,其结果相当于对Handle类型对象中的指针成员作用C++内建的*运算符。如果有一个student类型对象,那么*student将得到与*(studnet.p)相同的结果(假设我们有权访问p的数据成员)。换句活说,*student的结果事实上是指向一个Grad类型对象的引用,这个对象是在对student进行初始化时生成的。
->运算符比前面的运算符稍微复杂一点。粗看上去,->运算符像是一个二元算符,但是事实上它与普通的二元算符不同。像一个范围运算符或者一个点运算符一样,运算符用来访问左操作数对象中的一个成员,这个成员名称由->运算符的右操作数来表示。因为成员名不是表达式,所以我们不能通过一个成员名来直接访问用户指定的成员。C++语法要求我们定义一个->运算符来返回一个可以看作是指针的值。

在定义operator->函数的时候,如果x是定义operator->函数的类的一个对象,那么x->y就等价于(x.operator->())->y。在这里operator->函数返回x对象封装的指针。因此,对于一个student对象,student->y()就等价于(student.operator->())->y,根据我们对operator->函数的定义,上式又等同于student.p->y。(忽略了一个事实,那就是自保护机制一般不允许我们直接访问student.p)。因此,->运算 符会把一个Handle类型对象对它的函数调用交给Handle类型对象的指针成员,由这一指针成员调用该运算符。

我们的目标之一是使Handle类具有与C++自带指针相关的多态性,看看已经写好的operator*operator->定义。这两个运算符函数生成一个引用或者一个指针 , 通过这个引用或者指针可以获得动态绑定特性 。 例如,如果我们运行student->grade(),实际上是通过student对象里的p指针来调用grade函数,在运行的时候系统会根据p所指对象的实际类型决定实际调用的是哪一个版本的grade函数。同样,因为operator*得到一个引用,所以如果运行(*student).grade(),那么我们是在通过一个引用调用grade函数,在运行的时候系统才会决定调用哪一个版本的grade函数。

1.2 使用一个通用的句柄类

我们可以用Handle类重写基于StudentInfo类指针的成绩主程序:

int main() {
    vector<Handle<Core> > students;
    Handle<Core> record;
    char ch;
    string::size_type maxlen = 0;

    //读取并储存数据
    while (cin >> ch) {
        if (ch == 'U')
            record = new Core;
        else
            record = new Grad;
        record->read(cin);  //先调用Handle<T>:然后调用虚拟的read函数
        maxlen = max(maxlen, record->name().size()); //调用 Handlc<T>:: >
        students.push_back(record);
    }
    //重写compare函数使之可以用const Handle<Core>&类型作参数
    sort(students.begin(), students.end(), compare_Core_handles);
    //输出姓名与成绩
    for(vector<Handle<Core> >::size_type i=0; i!=students.size(); ++i) {
        //students[i]是一个Handle类型对象,对之间接引用以调用函数
        cout << setw(maxlen+1) << students[i]->name();
        try{
            double final_grade = students[i]->grade();
            streamsize prec = cout.precision();
            cout << setprecision(3) << final_grade << setprecision(prec) << endl;
        } catch(domain_error e) {
            cout<<e.what()<<endl;
        }
        //没有delete语句
    }
    return 0;
}

这个程序保存的是Handle<Core>类型对象而不是Core*类型对象,因此,我们要写一个非重载的比较操作函数以用来对传递给sort函数的const Handle<Core>&类型参数进行比较:

bool compare_handles(const Handle<Core>& c1, const Handle<Core>& c2) {
    return c1->name() < c2->name();
}
bool compare_core_handles(const Handle<Core>* cp1, const Handle<Core>* cp2) {
    return compare_handles(*cp1, *cp2);
}

与以前的版本相比,这个版本还有另外一个不同之处,那就是输出循环部分。我们对students中得到的值进行间接引用,得到一个Handle类型对象,然后调用Handle<T>::operator->运算符函数,通过底层的Core*来访问name函数与grade函数。例如,在students[i]->grade()中调用重载运算符->,我们实际上运行的是students[i].p->grade()。因为grade函数是虚拟的,所以会在运行的时候根据students[i].p所指对象的类型来决定并调用一个版本的grade函数。更进一步说,因为Handle类会替我们管理内存,所以我们不需要另外调用delete函数来删除students中的指针元素所指向的对象。

更为重要的是,我们可以重新编写StudentInfo类,现在我们可以让这个类成为一个纯接口类,让它从管理指针的工作中解脱出来:

class StudentInfo {
public:
    Students() {}
    Students(std::istream& is) { read(is); }
    
    //现在不需要复制构造函数,也不再需要赋值操作函数与析构函数了
    std::istream& read(std::istream&);
    std::string name() const{
        if(cp) return cp->name();
        else throw std::runtime_error("uninitialized Student");
    }
    double grade() const {
        if (cp) return cp->grade();
        else throw std::runtime_error("uninitialized Student");
    }
    static bool compare(const Students& s1, const Students& s2) {
        return s1.name() < s2.name();
    }
    
private:
    Handle<Core> cp;
};

在这个版本的StudentInfo中,cp的类型已经不再是Core*,而是Handle<Core>。由于Handle可以很好地管理它所操纵的底层对象,我们也就不需要再去实现那些用于控制复制的函数。另外那个构造函数则没有改变。函数namegrade看起来也没有改变,但它们的执行依赖于cpbool转换得到的结果和Handle类中被重载的operator->运算符(它被用来调用底层对象的函数的)。

现在,还需要实现read函数:

istream& Students::read(istream& is){
    char ch;
    is >> ch; //获取记录类型

    //为适当的类型分配新的对象
    //使用Handle<T>(T*)來为指向那个对象的指针构造一个Handle<Core>的对象
    //调用Handle<T>: zopeartor来将Handle<Core>赋值给左边的对象
    if (ch == 'U') cp = new Core(is);
    else cp = new Grad(is);
    return is;
}

上面的代码看上去很像前面写的StudentInfo::read函数,不过运行起来的时候两者不一样。最明显的区别是,这里去掉了delete语句,因为对cp进行赋值会释放对象占用的内存。为了理解这些代码,我们需要对它进行仔细的研究。例如,如果执行new Core(is),我们将得到一个Core*类型的对象,然后隐式地调用Handle(T*)构造函数把它转换成一个Handle<Core>类型对象。然后调用Handle类的赋值运算符函数把这个Handle类的值赋给cp,在调用赋值运算符函数的时候会自动删除Handle此前所指的对象。在这个删除操作过程中,我们构造和删除了一个我们生成的Core类型对象的一个复件。

2 引用计数句柄

现在,我们已经把管理指针的工作从接口类中分离出来,生成了一个新的类指针类。我们可以用Handle类来实现不同的接口类,在使用它们的时候不必考虑内存管理操作。但是,我们的Handle类在对底层数据进行复制或者赋值操作的时候仍然存在问题,即使在它们不需要这么做的时候也不例外。原因是Handle类总是会复制Handle类型对象指向的对象。一般来说,我们希望亲自决定是否对对象进仃复制。

例如,我们可能希望一个对象是另一个对象的复件,但是它们的值共用同一块内存,这时候我们不需要它们像是一个值一样工作,有些时候一旦类的对象生成以后,其他类没有办法改变对象的状态。在这些时候,没有理由要对基层的对象进行复制,复制这些对象只会浪费CPU时间与内存空间。为了支持这些类型的类,我们希望有种Handle类,它在Handle类型对象本身复制的时候不对底层的对象进行实际的复制。当然,即使我们允许几个Handle类型对象指向同一个底层对象,也还是要在某个时候释放这个对象占用的内存。显然,释放对象内存的时机应该选在指向该对象的最后一个Handle类型对象被刪除的时候

最后,我们将用到引用计数(reference count),这是一个对象,它用来记录有多少个对象指向某个底层对象。每个目标对象都有一个与该对象关联的引用计数。在每次生成一个新的指向目标对象的句柄的时候,我们让引用计数加一,在有一个句柄对象被删除的时候让引用计数减一。在最后一个句柄对象被删除的时候,引用计数将变为零。在引用计数为零的时候,我们就可以安全地删除目标对象并释放其内存。这一技术省去了大量不必要的内存管理与数据复制。我们首先建立一个名为Ref_handle的新类,这个类将定义如何往我们的Handle类型对象中加入引用计数

为了増加对某个类型对象的计数功能,我们必须分配一个计数器,然后修改对象的创建、复制与删除操作,以使它们可以正确地更改计数器的值。每个与Ref_handle类型对象关联的对象都有一个与对象相关的引用计数。现在惟一的问题是把计数器存放在什么地方。一般来说,我们没法得到任何一种类的源代码,所以不能通过往类中加入一个计数器来实现计数。实际上,我们是在Ref_handle类中加入另外一个指针来跟踪计数。每一个与一个Ref_handle类型对象关联的对象都有一个相应的引用计数,这个引用计数用来表示我们生成了该对象的多少个复件:

template <class T> class RefHandle {
public:
    // 像管理指针一样管理引用计数
    RefHandle():refptr(new size_t(1)), p(0) { }
    RefHandle(T* t):refptr(new size_t(1)), p(t) { }
    RefHandle(const RefHandle& h):refptr(h.refptr), p(h.p) {
        ++*refptr;
    }
    RefHandle& operator=(const RefHandle&);
    ~RefHandle();
    //同前
    operator bool() const { return p; }
    T& operator*() const {
        if (p)
            return *p;
        throw std::runtime_error("unbound Ref_handle");
    }
    T* operator->() const {
        if (p)
            return p;
        throw std::runtime_error("unbound Ref_handle");
    }

private:
    T* p;
    size_t* refptr; //新加部分
};

程序的执行过程:我们在RefHandle类中新增加了一个数据成员,并且对构造函数稍加改动了一下以对该数据成员进行初始化。默认构造函数与带有一个T*参数的构造函数被用来生成一个新的RefHandle类型对象,所以它们要为一个新的引用计数(size_t类型)分配内存,然后把该计数器的值设为1。拷贝构造函数不用生成一个新的对象,它对参数给出的RefHandle<T>类型对象进行指针复制,然后使计数器加1,以表示指向这个T类型对象的指针又比以前多了一个。因此,新的RefHandle<T>类型对象指向同一个T对象与同一个引用计数。

在赋值运算符函数中也使用了这个引用计数,而不是直接复制底层对象:

template <class T> RefHandle<T>& RefHandle<T>::operator=(const RefHandle &rhs) {
    ++*rhs.refptr;
    // 释放左操作数对象,如果必要的话删除指针
    if (--*refptr == 0) {
        delete refptr;
        delete p;
    }
    // 复制右操作数对象的值
    refptr = rhs.refptr;
    p = rhs.p;
    return *this;
}

像以前一样,我们要注意进行自我赋值的情况,我们通过在对左操作数对象的引用计数减一之前先对操作数对象的引用计数加一来解决自我赋值的问题。如果两个操作数都指向同一个对象,那么最终的效果是该对象的引用计数保持不变,这可以确保引用计数不会被无意归零。

如果在为引用计数减一的时候使它达到零值,那么左操作数就是与底层对象关联的最后一个RefHandle类型对象。因为我们要抹去左操作数对象的值,所以要调用delete函数释放对象的最后一个引用。因此,我们必须在重置refptrp的值之前删除这个对象以及该对象的引用计数。我们必须同时对prefptr调用delete函数,因为这两个都是动态分配内存的对象,为了避免内存泄漏,我们必须删除它们并为它们释放内存。 在删除指针之后,如果有必要,我们还要把右操作数对象的值复制到左操作数对象中,然后一般返回一个指向左操作数的引用。

析构函数和赋值算符函数一样,先检査要删除的RefHandle对象是否是指向T对象的最后一个引用。如果是的话,就删除指针所指向的对象并释放其占用的内存:

template <class T> RefHandle<T>::~RefHandle() {
    if (--*refptr == 0) {
        delete refptr;
        delete p;
    }
}

新的问题:对于可以在一个对象的不同复件之间共享同一块内存数据的类,这个版本的RefHandle类可以正常工作,但是对于像StudnetInfo这样的,想要使类型对象像一个值一样被操作的类又怎么样呢?例如,如果我们把RefHandle类应用到StudentInfo类上,那么在运行下面的语句的时候:

StudentInfo s1(cin);
StudentInfo s2 = s1;

尽管看上去s2s1的一个复件,但实际上s1s2这两个对象指向同一个底层对象。在我们的新的RefHandle类中并没有调用过clone函数,由此可以证实上面的论断。因为RefHandle从未调用过clone函数,这个类型的句柄从来就没有真正复制过这些对象。另一方面,RefHandle类的这一版本也可以避免对数据进行没有必要的复制。问题是,无论需要与否,它都不会对数据进行复制。这下我们该怎么办呢?

3 自己决定什么时候共享数据的句柄

我们已经讲过这个通用句柄类的两个可能的定义。第一个版本总是对底层对象进行复制;第二个版本从来不会真正地复制底层对象。一种更好的办法是写一个类,这个类允许由程序来决定什么时候需要复制目标对象,而什么时候又不需要复制目标对象。下面我们将使用最后写出来的句柄类Ptr,并由此证明它是C++自带的指针的一个很好的替代品。一般来说,Ptr类必须在我们打算改变一个对象的值的时候复制该对象,当然,这要求同时有另一个句柄指向同一个对象。幸运的是,引用计数可以告诉我们一个句柄是不是指向某对象的最后一个引用。

这里的Ptr类的基本原理与前面的RefHadle类相同。我们要做的只不过是新加入一个成员函数:

template <class T> class Ptr {
public:
    // 新加入一个成员函数,用于在有需要的时候有条件的复制对象
    void make_unique() {
        if (*refptr != 1) {
            --*refptr;
            refptr = new size_t(1);
            p = p ? p->clone() : 0;
        }
    }
    // 同前
    Ptr():refptr(new size_t(1)), p(0) { }
    Ptr(T* t):refptr(new size_t(1)), p(t) { }
    Ptr(const Ptr& h):refptr(h.refptr), p(h.p) {
        ++*refptr;
    }
    Ptr& operator=(const Ptr&);
    ~Ptr();

    operator bool() const { return p; }
    T& operator*() const {
        if (p)
            return *p;
        throw std::runtime_error("unbound Ref_handle");
    }
    T* operator->() const {
        if (p)
            return p;
        throw std::runtime_error("unbound Ref_handle");
    }

private:
    T* p;
    size_t* refptr; //新加部分
};

程序执行过程:这个新加入的make_unique函数完成了我们想做的工作:如果引用计数的值是1,该函数不执行任何工作;否则,它就调用句柄所指对象的类的clone函数,生成该对象的一个复件,然后使p指向这个复件。如果引用计数不是1,那么至少还有另一个Ptr类型对象指向初始的对象。因此,我们要把与初始对象关联的引用计数减1 (这么做可能使其减小为1但不会减为0)。然后,我们为句柄,为其他所有可能在以后进行复制的对象生成一个新的引用计数。因为到现在为止只有一个Ptr类型对象与我们的这个复件关联,所以把计数器设为1。在调用clone函数之前,先检査指向将要复制的对象的指针是不是指向一个真实的对象。如果是的话,就调用clone函数来复制该对象。完成以上操作之后,这个Ptr类型对象是惟一一个与p所指对象关联的对象。这个对象可能与初始的对象是同一对象(在初始对象的引用计数为1的时候),也可能只是初始对象的一个复件(在初始对象的引用计数大于1的时候)。

现在我们可以在StudentInfo类中使用这个最新版本的Ptr类。这么做完全不需要对StudentInfo类的应用程序做任何改变、因为StudentInfo类的任何一个操作都不会不覆盖而改变对象的值。惟一一个会改变对象的值的操作是read函数,但是这个函数总是把一个新生成的值赋给对象的Ptr成员数据。在这么做的时候,Ptr类的赋值运算符函数可能删除旧值,也可能保留旧值,这要取决于是不是还有其他的对象指向旧值。在任何一种情况中, 可控句柄的一个改进我们将要读取的对象都有一个新的Ptr类型对象,这也是该对象的惟一使用者。如果运行下面的程序:

StudentInfo s1;
read(cin, s1);
StudentInfo s2 = s1;
read(cin, s2);

那么s2的值将在read函数的调用中被重置,而s1的值保持不变。

如果我们把前面写的virtual版本的regrade函数加入Core类的继承树中,并且假设在StudentInfo类中已经给出一个相应的接口函数,那么为了调用make_unique函数,regrade函数要做些改动:

void StudentInfo::regrade(double final, double thesis) {
    // 在改变对象之前先得到自己的复件
    cp.make_unique();
    if (cp) cp->regrade(final, thesis);
    else throw runtime_error("regrade of unknown student");
}

4 可控句柄的一个改进

尽管我们的可控句柄十分有用,可是它并不能总是按照我们希望的方式工作。例如,现在假设我们想用它来实现Str类。我们通过连接两个已有的Str类型对象,隐式地复制一大串的字符,生成一个新的Str类型对象。通过对Str类型对象加入引用计数,我们希望至少能省去一些复制操作:

// 这个版本能运行吗?
class Str {
    friend std::istream& operator>>(std::istream&, Str&);
public:
    typedef Vec<char>::size_type size_type;
	// 重写生成Ptr类型对象的构造函数
    Str():data(new Vec<char>) {}
    Str(size_type n, char c) : data(new Vec<char>(n, c)) {}
    Str(const char* cp):date(new Vec<char>) {
        std::copy(cp, cp+std::strlen(cp), std::back_inserter(*data));
    }
    template <class In> Str(In i, In j):data(new Vec<char>) {
        std::copy(i, j, std::back_inserter(*data));
    }
	// 必要的时候调用make_unique函数
    char& operator[](size_type i) { 
        date.make_unique();
        return (*data)[i]; 
    }
    const char& operator[](size_type i) const { return (*data)[i]; }
    Str& operator+=(const Str& s) {
        data.make_unique();
        std::copy(s.data.begin(), s.data.end(), std::back_inserter(*data));
        return *this;
    }
    size_type size() const { return data->size(); }

private:
    // 保存一个指向向量的Ptr
    Ptr<Vec<char> > data;
};
std::ostream& operator<<(std::ostream&, const Str&);
Str operator+(const Str&, const Str&);

我们保留了Str类的接口,但是从根本上改变了接口的实现。我们不是在每一个Str类型对象中直接储存一个向量类型对象,而是使用了一个指向向量的Ptr对象。**这种办法可以允许多个Str类型对象共用同一底层字符数据。**构造函数通过为一个新的向量类型对象分配内存,并用适当的值对向量类型对象初始化来初始化该Ptr类型对象。读取(而不是改写)data数据的操作代码,与以前的版本相同。当然,这些操作现在是作用在一个Ptr类型对象上,也就是说这个操作是间接地通过Ptr类型对象中的指针来获得Str类型对象中的底层字符数据的。真正有趣的是那些用来改变Str类型对象的操作,譬如输入运算符,混合连接运算符和非常量版本的下标运算符等等。

例如,我们来看一看Str::operator+=函数的实现代码。这个函数要把数据追加到底层的向量类型对象中,所以它调用data.make_unique函数。这么做之后,Str类型对象就有了自己的一个底层数据的复件,它可以对这个复件自由地进行修改。

4.1 复制我们不能控制的类型

不幸的是,make_unique函数的定义中存在一个严重的问题:在运行p = p ? p->clone() : 0;语句对对象进行复制时,因为使用了Ptr<vector<char> >,所以它将试图调用vector<char>类中的clone函数,但该类中并没有clone函数。

因为clone函数是一个虚拟函数,所以它必须是一个与Ptr类关联的类的成员函数。换句话说,为了使Ptr有可能适用于一个继承树上所有的类,把clone函数定义成为一个成员函数是至关重要的;当然,这么做是不可能的,因为我们不能改变Vec类的定义。这个类被设计来实现标准向量类的接口函数的一个子集接口。如果我们向其中加入一个clone成员函数,那么就是加入了一个向量类所没有的成员,这样得到的Vec类其接口函数就不是向量类接口函数的子集了。那我们该怎么做呢?

解决这一类难题的办法通常都要用到一个思想,这个思想我们常常戏称为软件工程中的基本定理:所有的问题都可以通过引入一个额外的间接层来解决。现在的问题是,我们试图调用一个实际上并不存在的函数,但是我们又没有办法令这个函数存在。那么解决的办法是,不要直接去调用这个成员函数,而是定义一个既可以直接调用又可以创建的中间的全局函数。这个函数我们仍然叫它clone函数:

template <class T> T* clone(const T* tp) {
    return tp->clone();
}

适当修改make_unique成员函数以调用clone函数:

void Ptr<T>::make_unique() {
    if (*refptr != 1) {
        --*refptr;
        refptr = new size_t(1);
        p = p ? clone(p) : 0;	// 调用clone的全局版本而不是成员函数版本
    }
}

中间函数:很显然,引入这样一个中间函数不会影响make_unique函数。它仍然调用clone函数,而这样调用的是实际上被复制的对象的clone成员函数。不过,现在make_unique是通过一个间接的途径工作的:它先调用非成员函数版本的clone函数,而由这一非成员版本的clone函数调用p所指向的对象的clone成员函数。对于像StudentInfo这样的定义了 clone成员函数的类来说,这一间接的调用是多余的。但是对于像Str这种类,它储存着Ptr类型对象,而在Ptr类中没有定义clone函数,这种情况下这一间接调用就显得很重要了。对于后面这种类型的类,我们也可以定义另一个中间函数:

template<> Vec<char>* clone(const Vec<char>* vp) {
    return new Vec<char>(*vp);
}

特化模板:在该函数的开头使用template<>表明这个函数是一个模板特化。这种特化为特定的参数类型定义了一个特殊版本的模板函数。通过定义它为特殊化,clone函数在带一个指向Vec<char>类型对象的指针作为参数的时候,会与带其他类型的指针作为参数时具有不同的功能。在向clone函数传递一个Vec<char>*类型的参数时,编译器调用一个特殊版本的clone 函 数 。在向clone函数传递其他类型的指针参数时,编译器将对clone函数的通用模板进行实例化,这一实例化使得编译器实际调用的是指针所指对象的clone成员函数。clone函数的特化版本调用Vec<char>类的拷贝构造函数,用参数提供的指针所指对象来构造一个新的Vec<char>类型对象。虽然这个特化的clone函数没有提供虚拟特性,但是因为Vec类没有派生类,我们也不需要这么做。
总结:现在我们知道clone不一定非要作为一个类的成员函数而存在,这在一定程度上减少了我们对clone函数的依赖性。通过引入一个中间函数,我们可以特化一个clone模板,让这个特化的clone函数可以在复制一个特殊类(该类中没有定义clone成员函数)对象的时候被调用,从而调用一个clone成员函数,调用一个复制构造函数或者你想要调用的任何函数。在不调用这个特殊化的clone函数的时候,我们调用相应类的clone函数,不过这要求同时凋用make_unique函数、换句话说

  • 如果你使用了 Ptr<T>类但是没有调用Ptr<T>::make_unique函数,那么是否定义T::clone函数都没有关系。
  • 如果你调用了Ptr<T>::make_unique函数,而且定义了T::clone函数,那么make_unique函数将调用T::clone
  • 如果你调用了Ptr<T>::make_unique函数,而且不想调用T::clone函数(也许它根本就不存在),那么你可以通过特化一个clone<T>模板来做你想做的工作。这个额外的中间函数使得我们可以很细微地定义Ptr类的功能。

现在只剩下一个问题了, 这也是最难的部分——决定是否真的要进行复制操作。

4.2 复制在什么时候是必要的

看一看我们定义的两个不同版本的operator[]函数。其中一个调用了data.make_unique函数;而另一个版本没有。这一差别与函数是否是一个常量成员有关。operator[]函数的第二个版本是一个常量成员函数,这意味着该函数不会改变对象的内容,因为它返回一个const char&类型值给调用者。因此,即使让几个Str类型对象共享同一底层的Vec<char>类型对象也没有什么坏处,毕竟,用户不能通过一个常量返回值来改变Str类型对象的值。
做法:不同的是,operator[]函数的第一个版本返回一个char&值,这意味着用户可以用这一返回值改变Str类型对象的值。如果用户果真这么做,我们希望把对Str类型对象值的改变只限制这个对象上,不让这一改变影响到共享同一底层Vec类型对象的其他对象。为了实现这一目的,我们在定义中规定,在返回一个指向Vec类型对象的字符的引用之前,不能通过调用Ptr类型对象的make_unique函数来改变其他Str类型对象的值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值