一起来学C++:C++中的代码重用

目录

14.1 包含对象成员的类

14.1.1 valarray类简介

14.1.2 Student类的设计

14.1.3 Student类示例

1.初始化被包含的对象

2.使用被包含对象的接口

3.使用新的Student类

14.2 私有继承

14.2.1 Student类示例(新版本)

1.初始化基类组件

2.访问基类的方法

3.访问基类对象

4.访问基类的友元函数

5.使用修改后的Student类

14.2.2 使用包含还是私有继承

14.2.3 保护继承

14.2.4 使用using重新定义访问权限

14.3 多重继承

14.3.1 有多少Worker

1.虚基类

2.新的构造函数规则


本章内容包括:

  • has-a关系;
  • 包含对象成员的类;
  • 模板类valarray;
  • 私有和保护继承;
  • 多重继承;
  • 虚基类;
  • 创建类模板;
  • 使用类模板;
  • 模板的具体化。

C++的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是唯一的机制。本章将介绍其他方法,其中之一是使用这样的类成员:本身是另一个类的对象。这种方法称为包含(containment)、组合(composition)或层次化(layering)。另一种方法是使用私有或保护继承。通常,包含、私有继承和保护继承用于实现has-a关系,即新的类将包含另一个类的对象。例如,HomeTheater类可能包含一个BluRayPlayer对象。多重继承使得能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。

第10章介绍了函数模板,本章将介绍类模板——另一种重用代码的方法。类模板使我们能够使用通用术语定义类,然后使用模板来创建针对特定类型定义的特殊类。例如,可以定义一个通用的栈模板,然后使用该模板创建一个用于表示int值栈的类和一个用于表示double值栈的类,甚至可以创建一个这样的类,即用于表示由栈组成的栈。

14.1 包含对象成员的类

首先介绍包含对象成员的类。有一些类(如string类和第16章将介绍的标准C++类模板)为表示类中的组件提供了方便的途径。下面来看一个具体的例子。

学生是什么?入学者?参加研究的人?残酷现实社会的避难者?有姓名和一系列考试分数的人?显然,最后一个定义完全没有表示出人的特征,但非常适合于简单的计算机表示。因此,让我们根据该定义来开发Student类。

将学生简化成姓名和一组考试分数后,可以使用一个包含两个成员的类来表示它:一个成员用于表示姓名,另一个成员用于表示分数。对于姓名,可以使用字符数组来表示,但这将限制姓名的长度。当然,也可以使用char指针和动态内存分配,但正如第12章指出的,这将要求提供大量的支持代码。一种更好的方法是,使用一个由他人开发好的类的对象来表示。例如,可以使用一个String类(参见第12章)或标准C++ string类的对象来表示姓名。较简单的选择是使用string类,因为C++库提供了这个类的所有实现代码,且其实现更完美。要使用String类,您必须在项目中包含实现文件string1.cpp。

对于考试分数,存在类似的选择。可以使用一个定长数组,这限制了数组的长度;可以使用动态内存分配并提供大量的支持代码;也可以设计一个使用动态内存分配的类来表示该数组;还可以在标准C++库中查找一个能够表示这种数据的类。

自己开发这样的类一点问题也没有。开发简单的版本并不那么难,因为double数组与char数组有很多相似之处,因此可以根据String类来设计表示double数组的类。事实上,本书以前的版本就这样做过。

当然,如果C++库提供了合适的类,实现起来将更简单。C++库确实提供了一个这样的类,它就是valarray。

14.1.1 valarray类简介

valarray类是由头文件valarray支持的。顾名思义,这个类用于处理数值(或具有类似特性的类),它支持诸如将数组中所有元素的值相加以及在数组中找出最大和最小的值等操作。valarray被定义为一个模板类,以便能够处理不同的数据类型。本章后面将介绍如何定义模板类,但就现在而言,您只需知道如何使用模板类即可。

模板特性意味着声明对象时,必须指定具体的数据类型。因此,使用valarray类来声明一个对象时,需要在标识符valarray后面加上一对尖括号,并在其中包含所需的数据类型:

valarray<int> q_values;   // an array of int
valarray<double> weights; // an array of double

第4章介绍vector和array类时,您见过这种语法,它非常简单。这些类也可用于存储数字,但它们提供的算术支持没有valarray多。

这是您需要学习的唯一新语法,它非常简单。

类特性意味着要使用valarray对象,需要了解这个类的构造函数和其他类方法。下面是几个使用其构造函数的例子:

double gpa[5] = {3.1, 3.5, 3.8, 2.9, 3.3};
valarray<double> v1;    // an array of double, size 0
valarray<int> v2(8);    // an array of 8 int elements
valarray<int> v3(10,8); // an array of 8 int elements,
                        // each set to 10
valarray<double> v4(gpa, 4); // an array of 4 elements
                // initialized to the first 4 elements of gpa

从中可知,可以创建长度为零的空数组、指定长度的空数组、所有元素度被初始化为指定值的数组、用常规数组中的值进行初始化的数组。在C++11中,也可使用初始化列表:

valarray<int> v5 = {20, 32, 17, 9}; // C++11

下面是这个类的一些方法。

  • operator :让您能够访问各个元素。
  • size():返回包含的元素数。
  • sum():返回所有元素的总和。
  • max():返回最大的元素。
  • min():返回最小的元素。

还有很多其他的方法,其中的一些将在第16章介绍;但就这个例子而言,上述方法足够了。

14.1.2 Student类的设计

至此,已经确定了Student类的设计计划:使用一个string对象来表示姓名,使用一个valarray<double>来表示考试分数。那么如何设计呢?您可能想以公有的方式从这两个类派生出Student类,这将是多重公有继承,C++允许这样做,但在这里并不合适,因为学生与这些类之间的关系不是is-a模型。学生不是姓名,也不是一组考试成绩。这里的关系是has-a,学生有姓名,也有一组考试分数。通常,用于建立has-a关系的C++技术是组合(包含),即创建一个包含其他类对象的类。例如,可以将Student类声明为如下所示:

class Student
{
private:
    string name;             // use a string object for name
    valarray<double> scores; // use a valarray<double> object for scores
    ...
};

同样,上述类将数据成员声明为私有的。这意味着Student类的成员函数可以使用string和valarray<double>类的公有接口来访问和修改name和scores对象,但在类的外面不能这样做,而只能通过Student类的公有接口访问name和score(请参见图14.1)。对于这种情况,通常被描述为Student类获得了其成员对象的实现,但没有继承接口。例如,Student对象使用string的实现,而不是char * name或char name [26]实现来保存姓名。但Student对象并不是天生就有使用函数string operator+=()的能力。

图14.1 对象中的对象:包含


接口和实现

使用公有继承时,类可以继承接口,可能还有实现(基类的纯虚函数提供接口,但不提供实现)。获得接口是is-a关系的组成部分。而使用组合,类可以获得实现,但不能获得接口。不继承接口是has-a关系的组成部分。


对于has-a关系来说,类对象不能自动获得被包含对象的接口是一件好事。例如,string类将+运算符重载为将两个字符串连接起来;但从概念上说,将两个Student对象串接起来是没有意义的。这也是这里不使用公有继承的原因之一。另一方面,被包含的类的接口部分对新类来说可能是有意义的。例如,可能希望使用string接口中的operator<()方法将Student对象按姓名进行排序,为此可以定义Student::operator<()成员函数,它在内部使用函数string::operator<()。下面介绍一些细节。

14.1.3 Student类示例

现在需要提供Student类的定义,当然它应包含构造函数以及一些用作Student类接口的方法。程序清单14.1是Student类的定义,其中所有构造函数都被定义为内联的;它还提供了一些用于输入和输出的友元函数。

程序清单14.1 studentc.h

// studentc.h -- defining a Student class using containment
#ifndef STUDENTC_H_
#define STUDENTC_H_

#include <iostream>
#include <string>
#include <valarray>
class Student
{
private:
    typedef std::valarray<double> ArrayDb;
    std::string name; // contained object
    ArrayDb scores;   // contained object
    // private method for scores output
    std::ostream & arr_out(std::ostream & os) const;
public:
    Student() : name("Null Student"), scores() {}
    explicit Student(const std::string & s)
        : name(s), scores() {}
    explicit Student(int n) : name("Nully"), scores(n) {}
    Student(const std::string & s, int n)
        : name(s), scores(n) {}
    Student(const std::string & s, const ArrayDb & a)
        : name(s), scores(a) {}
    Student(const char * str, const double * pd, int n)
        : name(str), scores(pd, n) {}
    ~Student() {}
    double Average() const;
    const std::string & Name() const;
    double & operator[](int i);
    double operator[](int i) const;
// friends
    // input
    friend std::istream & operator>>(std::istream & is,
                                    Student & stu); // 1 word
    friend std::istream & getline(std::istream & is,
                                  Student & stu); // 1 line
    // output
    friend std::ostream & operator<<(std::ostream & os,
                                     const Student & stu);
};

#endif

为简化表示,Student类的定义中包含下述typedef:

typedef std::valarray<double> ArrayDb;

这样,在以后的代码中便可以使用表示ArrayDb,而不是std::valarray<double>,因此类方法和友元函数可以使用ArrayDb类型。将该typedef放在类定义的私有部分意味着可以在Student类的实现中使用它,但在Student类外面不能使用。

请注意关键字explicit的用法:

explicit Student(const std::string & s)
    : name(s), scores() {}
explicit Student(int n) : name("Nully"), scores(n) {}

本书前面说过,可以用一个参数调用的构造函数将用作从参数类型到类类型的隐式转换函数;但这通常不是好主意。在上述第二个构造函数中,第一个参数表示数组的元素个数,而不是数组中的值,因此将一个构造函数用作int到Student的转换函数是没有意义的,所以使用explicit关闭隐式转换。如果省略该关键字,则可以编写如下所示的代码:

Student doh("Homer", 10); // store "Homer", create array of 10 elements
doh = 5; // reset name to "Nully", reset to empty array of 5 elements

在这里,马虎的程序员键入了doh而不是doh[0]。如果构造函数省略了explicit,则将使用构造函数调用Student(5)将5转换为一个临时Student对象,并使用“Nully”来设置成员name的值。因此赋值操作将使用临时对象替换原来的doh值。使用了explicit后,编译器将认为上述赋值运算符是错误的。


C++和约束

C++包含让程序员能够限制程序结构的特性——使用explicit防止单参数构造函数的隐式转换,使用const限制方法修改数据,等等。这样做的根本原因是:在编译阶段出现错误优于在运行阶段出现错误。


1.初始化被包含的对象

构造函数全都使用您熟悉的成员初始化列表语法来初始化name和score成员对象。在前面的一些例子中,构造函数用这种语法来初始化内置类型的成员:

Queue::Queue(int qs) : qsize(qs) {...} // initialize qsize to qs

上述代码在成员初始化列表中使用的是数据成员的名称(qsize)。另外,前面介绍的示例中的构造函数还使用成员初始化列表初始化派生对象的基类部分:

hasDMA::hasDMA(const hasDMA & hs) : baseDMA(hs) {...}

对于继承的对象,构造函数在成员初始化列表中使用类名来调用特定的基类构造函数。对于成员对象,构造函数则使用成员名。例如,请看程序清单14.1的最后一个构造函数:

Student(const char * str, const double * pd, int n)
       : name(str), scores(pd, n) {}

因为该构造函数初始化的是成员对象,而不是继承的对象,所以在初始化列表中使用的是成员名,而不是类名。初始化列表中的每一项都调用与之匹配的构造函数,即name(str)调用构造函数string(const char *),scores(pd, n)调用构造函数ArrayDb(const double *, int)。

如果不使用初始化列表语法,情况将如何呢?C++要求在构建对象的其他部分之前,先构建继承对象的所有成员对象。因此,如果省略初始化列表,C++将使用成员对象所属类的默认构造函数。


初始化顺序

当初始化列表包含多个项目时,这些项目被初始化的顺序为它们被声明的顺序,而不是它们在初始化列表中的顺序。例如,假设Student构造函数如下:

Student(const char * str, const double * pd, int n)
       : scores(pd, n), name(str) {}

则name成员仍将首先被初始化,因为在类定义中它首先被声明。对于这个例子来说,初始化顺序并不重要,但如果代码使用一个成员的值作为另一个成员的初始化表达式的一部分时,初始化顺序就非常重要了。


2.使用被包含对象的接口

被包含对象的接口不是公有的,但可以在类方法中使用它。例如,下面的代码说明了如何定义一个返回学生平均分数的函数:

double Student::Average() const
{
    if (scores.size() > 0)
        return scores.sum()/scores.size();
    else
        return 0;
}

上述代码定义了可由Student对象调用的方法,该方法内部使用了valarray的方法size()和sum()。这是因为scores是一个valarray对象,所以它可以调用valarray类的成员函数。总之,Student对象调用Student的方法,而后者使用被包含的valarray对象来调用valarray类的方法。

同样,可以定义一个使用string版本的<<运算符的友元函数:

// use string version of operator<<()
ostream & operator<<(ostream & os, const Student & stu)
{
    os << "Scores for " << stu.name << ":\n";
    ...
}

因为stu.name是一个string对象,所以它将调用函数operatot<<(ostream &, const string &),该函数位于string类中。注意,operator<<(ostream & os, const Student & stu)必须是Student类的友元函数,这样才能访问name成员。另一种方法是,在该函数中使用公有方法Name(),而不是私有数据成员name。

同样,该函数也可以使用valarray的<<实现来进行输出,不幸的是没有这样的实现;因此,Student类定义了一个私有辅助方法来处理这种任务:

// private method
ostream & Student::arr_out(ostream & os) const
{
    int i;
    int lim = scores.size();
    if (lim > 0)
    {
        for (i = 0; i < lim; i++)
        {
            os << scores[i] << " ";
            if (i % 5 == 4)
                os << endl;
        }
        if (i % 5 != 0)
            os << endl;
    }
    else
        os << " empty array ";
    return os;
}

通过使用这样的辅助方法,可以将零乱的细节放在一个地方,使得友元函数的编码更为整洁:

// use string version of operator<<()
ostream & operator<<(ostream & os, const Student & stu)
{
    os << "Scores for " << stu.name << ":\n";
    stu.arr_out(os); // use private method for scores
    return os;
}

辅助函数也可用作其他用户级输出函数的构建块——如果您选择提供这样的函数的话。

程序清单14.2是Student类的类方法文件,其中包含了让您能够使用[ ]运算符来访问Student对象中各项成绩的方法。

程序清单14.2 student.cpp

// studentc.cpp -- Student class using containment
#include "studentc.h"
using std::ostream;
using std::endl;
using std::istream;
using std::string;
//public methods
double Student::Average() const
{
    if (scores.size() > 0)
        return scores.sum()/scores.size();
    else
        return 0;
}

const string & Student::Name() const
{
    return name;
}

double & Student::operator[](int i)
{
    return scores[i];   // use valarray<double>::operator[]()
}

double Student::operator[](int i) const
{
    return scores[i];
}

// private method
ostream & Student::arr_out(ostream & os) const
{
    int i;
    int lim = scores.size();
    if (lim > 0)
    {
        for (i = 0; i < lim; i++)
        {
            os << scores[i] << " ";
            if (i % 5 == 4)
                os << endl;
        }
        if (i % 5 != 0)
            os << endl;
    }
    else
        os << " empty array ";
    return os;
}

// friends
// use string version of operator>>()
istream & operator>>(istream & is, Student & stu)
{
    is >> stu.name;
    return is;
}

// use string friend getline(ostream &, const string &)
istream & getline(istream & is, Student & stu)
{
    getline(is, stu.name);
    return is;
}

// use string version of operator<<()
ostream & operator<<(ostream & os, const Student & stu)
{
    os << "Scores for " << stu.name << ":\n";
    stu.arr_out(os); // use private method for scores
    return os;
}

除私有辅助方法外,程序清单14.2并没有新增多少代码。使用包含让您能够充分利用已有的代码。

3.使用新的Student类

下面编写一个小程序来测试这个新的Student类。出于简化的目的,该程序将使用一个只包含3个Student对象的数组,其中每个对象保存5个考试成绩。另外还将使用一个不复杂的输入循环,该循环不验证输入,也不让用户中途退出。程序清单14.3列出了该测试程序,请务必将该程序与Student.cpp一起进行编译。

程序清单14.3 use_stuc.cpp

// use_stuc.cpp -- using a composite class
// compile with studentc.cpp
#include <iostream>
#include "studentc.h"
using std::cin;
using std::cout;
using std::endl;

void set(Student & sa, int n);
const int pupils = 3;
const int quizzes = 5;

int main()
{
    Student ada[pupils] =
        {Student(quizzes), Student(quizzes), Student(quizzes)};

    int i;
    for (i = 0; i < pupils; ++i)
        set(ada[i], quizzes);
    cout << "\nStudent List:\n";
    for (i = 0; i < pupils; ++i)
        cout << ada[i].Name() << endl;
    cout << "\nResults:";
    for (i = 0; i < pupils; ++i)
    {
        cout << endl << ada[i];
        cout << "average: " << ada[i].Average() << endl;
    }
    cout << "Done.\n";
    return 0;
}

void set(Student & sa, int n)
{
    cout << "Please enter the student's name: ";
    getline(cin, sa);
    cout << "Please enter " << n << " quiz scores:\n";
    for (int i = 0; i < n; i++)
        cin >> sa[i];
    while (cin.get() != '\n')
        continue;
}

下面是程序清单14.1~程序清单14.3组成的程序的运行情况:

Please enter the student's name: Gil Bayts
Please enter 5 quiz scores:
92 94 96 93 95
Please enter the student's name: Pat Roone
Please enter 5 quiz scores:
83 89 72 78 95
Please enter the student's name: Fleur O’Day
Please enter 5 quiz scores:
92 89 96 74 64
Student List:
Gil Bayts
Pat Roone
Fleur O'Day

Results:
Scores for Gil Bayts:
92 94 96 93 95
average: 94

Scores for Pat Roone:
83 89 72 78 95
average: 83.4

Scores for Fleur O'Day:
92 89 96 74 64
average: 83
Done.

14.2 私有继承

C++还有另一种实现has-a关系的途径——私有继承。使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们。

下面更深入地探讨接口问题。使用公有继承,基类的公有方法将成为派生类的公有方法。总之,派生类将继承基类的接口;这是is-a关系的一部分。使用私有继承,基类的公有方法将成为派生类的私有方法。总之,派生类不继承基类的接口。正如从被包含对象中看到的,这种不完全继承是has-a关系的一部分。

使用私有继承,类将继承实现。例如,如果从String类派生出Student类,后者将有一个String类组件,可用于保存字符串。另外,Student方法可以使用String方法来访问String组件。

包含将对象作为一个命名的成员对象添加到类中,而私有继承将对象作为一个未被命名的继承对象添加到类中。我们将使用术语子对象(subobject)来表示通过继承或包含添加的对象。

因此私有继承提供的特性与包含相同:获得实现,但不获得接口。所以,私有继承也可以用来实现has-a关系。接下来介绍如何使用私有继承来重新设计Student类。

14.2.1 Student类示例(新版本)

要进行私有继承,请使用关键字private而不是public来定义类(实际上,private是默认值,因此省略访问限定符也将导致私有继承)。Student类应从两个类派生而来,因此声明将列出这两个类:

class Student : private std::string, private std::valarray<double>
{
public:
    ...
};

使用多个基类的继承被称为多重继承(multiple inheritance,MI)。通常,MI尤其是公有MI将导致一些问题,必须使用额外的语法规则来解决它们,这将在本章后面介绍。但在这个示例中,MI不会导致问题。

新的Student类不需要私有数据,因为两个基类已经提供了所需的所有数据成员。包含版本提供了两个被显式命名的对象成员,而私有继承提供了两个无名称的子对象成员。这是这两种方法的第一个主要区别。

1.初始化基类组件

隐式地继承组件而不是成员对象将影响代码的编写,因为再也不能使用name和scores来描述对象了,而必须使用用于公有继承的技术。例如,对于构造函数,包含将使这样的构造函数:

Student(const char * str, const double * pd, int n)
   : name(str), scores(pd, n) {} // use object names for containment

对于继承类,新版本的构造函数将使用成员初始化列表语法,它使用类名而不是成员名来标识构造函数:

Student(const char * str, const double * pd, int n)
   : std::string(str), ArrayDb(pd, n) {} // use class names for inheritance

在这里,ArrayDb是std::valarray<double>的别名。成员初始化列表使用std::string(str),而不是name(str)。这是包含和私有继承之间的第二个主要区别。

程序清单14.4列出了新的类定义。唯一不同的地方是,省略了显式对象名称,并在内联构造函数中使用了类名,而不是成员名。

程序清单14.4 studenti.h

// studenti.h -- defining a Student class using private inheritance
#ifndef STUDENTC_H_
#define STUDENTC_H_

#include <iostream>
#include <valarray>
#include <string>
class Student : private std::string, private std::valarray<double>
{
private:
    typedef std::valarray<double> ArrayDb;
    // private method for scores output
    std::ostream & arr_out(std::ostream & os) const;
public:
    Student() : std::string("Null Student"), ArrayDb() {}
    explicit Student(const std::string & s)
            : std::string(s), ArrayDb() {}
    explicit Student(int n) : std::string("Nully"), ArrayDb(n) {}
    Student(const std::string & s, int n)
            : std::string(s), ArrayDb(n) {}
    Student(const std::string & s, const ArrayDb & a)
            : std::string(s), ArrayDb(a) {}
    Student(const char * str, const double * pd, int n)
            : std::string(str), ArrayDb(pd, n) {}
    ~Student() {}
    double Average() const;
    double & operator[](int i);
    double operator[](int i) const;
    const std::string & Name() const;
// friends
    // input
    friend std::istream & operator>>(std::istream & is,
                                     Student & stu); // 1 word
    friend std::istream & getline(std::istream & is,
                                  Student & stu); // 1 line
    // output
    friend std::ostream & operator<<(std::ostream & os,
                                     const Student & stu);
};

#endif

2.访问基类的方法

使用私有继承时,只能在派生类的方法中使用基类的方法。但有时候可能希望基类工具是公有的。例如,在类声明中提出可以使用average()函数。和包含一样,要实现这样的目的,可以在公有Student::average()函数中使用私有Student::Average()函数(参见图14.2)。包含使用对象来调用方法:

图14.2 对象中的对象:私有继承

double Student::Average() const
{
    if (scores.size() > 0)
        return scores.sum()/scores.size();
    else
        return 0;
}

然而,私有继承使得能够使用类名和作用域解析运算符来调用基类的方法:

double Student::Average() const
{
    if (ArrayDb::size() > 0)
        return ArrayDb::sum()/ArrayDb::size();
    else
        return 0;
}

总之,使用包含时将使用对象名来调用方法,而使用私有继承时将使用类名和作用域解析运算符来调用方法。

3.访问基类对象

使用作用域解析运算符可以访问基类的方法,但如果要使用基类对象本身,该如何做呢?例如,Student类的包含版本实现了Name()方法,它返回string对象成员name;但使用私有继承时,该string对象没有名称。那么,Student类的代码如何访问内部的string对象呢?

答案是使用强制类型转换。由于Student类是从string类派生而来的,因此可以通过强制类型转换,将Student对象转换为string对象;结果为继承而来的string对象。本书前面介绍过,指针this指向用来调用方法的对象,因此*this为用来调用方法的对象,在这个例子中,为类型为Student的对象。为避免调用构造函数创建新的对象,可使用强制类型转换来创建一个引用:

const string & Student::Name() const
{
    return (const string &) *this;
}

上述方法返回一个引用,该引用指向用于调用该方法的Student对象中的继承而来的string对象。

4.访问基类的友元函数

用类名显式地限定函数名不适合于友元函数,这是因为友元不属于类。然而,可以通过显式地转换为基类来调用正确的函数。例如,对于下面的友元函数定义:

ostream & operator<<(ostream & os, const Student & stu)
{
      os << "Scores for " << (const string &) stu << ":\n";
...
}

如果plato是一个Student对象,则下面的语句将调用上述函数,stu将是指向plato的引用,而os将是指向cout的引用:

cout << plato;

下面的代码:

os << "Scores for " << (const string &) stu << ":\n";

显式地将stu转换为string对象引用,进而调用函数operator<<(ostream &, const string &)。

引用stu不会自动转换为string引用。根本原因在于,在私有继承中,未进行显式类型转换的派生类引用或指针,无法赋值给基类的引用或指针。

然而,即使这个例子使用的是公有继承,也必须使用显式类型转换。原因之一是,如果不使用类型转换,下述代码将与友元函数原型匹配,从而导致递归调用:

os << stu;

另一个原因是,由于这个类使用的是多重继承,编译器将无法确定应转换成哪个基类,如果两个基类都提供了函数operator<<()。程序清单14.5列出了除内联函数之外的所有Student类方法。

程序清单14.5 studenti.cpp

// studenti.cpp -- Student class using private inheritance
#include "studenti.h"
using std::ostream;
using std::endl;
using std::istream;
using std::string;

// public methods
double Student::Average() const
{
    if (ArrayDb::size() > 0)
        return ArrayDb::sum()/ArrayDb::size();
    else
        return 0;
}

const string & Student::Name() const
{
    return (const string &) *this;
}

double & Student::operator[](int i)
{
    return ArrayDb::operator[](i); // use ArrayDb::operator[]()
}
double Student::operator[](int i) const
{
    return ArrayDb::operator[](i);
}

// private method
ostream & Student::arr_out(ostream & os) const
{
    int i;
    int lim = ArrayDb::size();
    if (lim > 0)
    {
        for (i = 0; i < lim; i++)
        {
            os << ArrayDb::operator[](i) << " ";
            if (i % 5 == 4)
                os << endl;
        }
        if (i % 5 != 0)
            os << endl;
    }
    else
        os << " empty array ";
    return os;
}

// friends
// use String version of operator>>()
istream & operator>>(istream & is, Student & stu)
{
    is >> (string &)stu;
    return is;
}

// use string friend getline(ostream &, const string &)
istream & getline(istream & is, Student & stu)
{
    getline(is, (string &)stu);
    return is;
}

// use string version of operator<<()
ostream & operator<<(ostream & os, const Student & stu)
{
    os << "Scores for " << (const string &) stu << ":\n";
    stu.arr_out(os); // use private method for scores
    return os;
}

同样,由于这个示例也重用了string和valarray类的代码,因此除私有辅助方法外,它包含的新代码很少。

5.使用修改后的Student类

接下来也需要测试这个新类。注意到两个版本的Student类的公有接口完全相同,因此可以使用同一个程序测试它们。唯一不同的是,应包含studenti.h而不是studentc.h,应使用studenti.cpp而不是studentc.cpp来链接程序。程序清单14.6列出列该程序,请将其与studenti.cpp一起编译。

程序清单14.6 use_stui.cpp

// use_stui.cpp -- using a class with private inheritance
// compile with studenti.cpp
#include <iostream>
#include "studenti.h"
using std::cin;
using std::cout;
using std::endl;

void set(Student & sa, int n);

const int pupils = 3;
const int quizzes = 5;

int main()
{
    Student ada[pupils] =
        {Student(quizzes), Student(quizzes), Student(quizzes)};
    int i;
    for (i = 0; i < pupils; i++)
        set(ada[i], quizzes);
    cout << "\nStudent List:\n";
    for (i = 0; i < pupils; ++i)
        cout << ada[i].Name() << endl;
    cout << "\nResults:";
    for (i = 0; i < pupils; i++)
    {
        cout << endl << ada[i];
        cout << "average: " << ada[i].Average() << endl;
    }
    cout << "Done.\n";
    return 0;
}
void set(Student & sa, int n)
{
    cout << "Please enter the student's name: ";
    getline(cin, sa);
    cout << "Please enter " << n << " quiz scores:\n";
    for (int i = 0; i < n; i++)
        cin >> sa[i];
    while (cin.get() != '\n')
        continue;
}

下面是该程序的运行情况:

Please enter the student's name: Gil Bayts
Please enter 5 quiz scores:
92 94 96 93 95
Please enter the student's name: Pat Roone
Please enter 5 quiz scores:
83 89 72 78 95
Please enter the student's name: Fleur O’Day
Please enter 5 quiz scores:
92 89 96 74 64

Student List:
Gil Bayts
Pat Roone
Fleur O'Day

Results:
Scores for Gil Bayts:
92 94 96 93 95
average: 94

Scores for Pat Roone:
83 89 72 78 95
average: 83.4

Scores for Fleur O'Day:
92 89 96 74 64
average: 83
Done.

输入与前一个测试程序相同,输出也相同。

14.2.2 使用包含还是私有继承

由于既可以使用包含,也可以使用私有继承来建立has-a关系,那么应使用种方式呢?大多数C++程序员倾向于使用包含。首先,它易于理解。类声明中包含表示被包含类的显式命名对象,代码可以通过名称引用这些对象,而使用继承将使关系更抽象。其次,继承会引起很多问题,尤其从多个基类继承时,可能必须处理很多问题,如包含同名方法的独立的基类或共享祖先的独立基类。总之,使用包含不太可能遇到这样的麻烦。另外,包含能够包括多个同类的子对象。如果某个类需要3个string对象,可以使用包含声明3个独立的string成员。而继承则只能使用一个这样的对象(当对象都没有名称时,将难以区分)。

然而,私有继承所提供的特性确实比包含多。例如,假设类包含保护成员(可以是数据成员,也可以是成员函数),则这样的成员在派生类中是可用的,但在继承层次结构外是不可用的。如果使用组合将这样的类包含在另一个类中,则后者将不是派生类,而是位于继承层次结构之外,因此不能访问保护成员。但通过继承得到的将是派生类,因此它能够访问保护成员。

另一种需要使用私有继承的情况是需要重新定义虚函数。派生类可以重新定义虚函数,但包含类不能。使用私有继承,重新定义的函数将只能在类中使用,而不是公有的。

提示: 

通常,应使用包含来建立has-a关系;如果新类需要访问原有类的保护成员,或需要重新定义虚函数,则应使用私有继承。

14.2.3 保护继承

保护继承是私有继承的变体。保护继承在列出基类时使用关键字protected:

class Student : protected std::string,
                protected std::valarray<double>
{...};

使用保护继承时,基类的公有成员和保护成员都将成为派生类的保护成员。和私有继承一样,基类的接口在派生类中也是可用的,但在继承层次结构之外是不可用的。当从派生类派生出另一个类时,私有继承和保护继承之间的主要区别便呈现出来了。使用私有继承时,第三代类将不能使用基类的接口,这是因为基类的公有方法在派生类中将变成私有方法;使用保护继承时,基类的公有方法在第二代中将变成受保护的,因此第三代派生类可以使用它们。

表14.1总结了公有、私有和保护继承。隐式向上转换(implicit upcasting)意味着无需进行显式类型转换,就可以将基类指针或引用指向派生类对象。

表14.1 各种继承方式

14.2.4 使用using重新定义访问权限

使用保护派生或私有派生时,基类的公有成员将成为保护成员或私有成员。假设要让基类的方法在派生类外面可用,方法之一是定义一个使用该基类方法的派生类方法。例如,假设希望Student类能够使用valarray类的sum()方法,可以在Student类的声明中声明一个sum()方法,然后像下面这样定义该方法:

double Student::sum() const // public Student method
{
    return std::valarray<double>::sum(); // use privately-inherited method
}

这样Student对象便能够调用Student::sum(),后者进而将valarray<double>::sum()方法应用于被包含的valarray对象(如果ArrayDb typedef在作用域中,也可以使用ArrayDb而不是std::valarray<double>)。

另一种方法是,将函数调用包装在另一个函数调用中,即使用一个using声明(就像名称空间那样)来指出派生类可以使用特定的基类成员,即使采用的是私有派生。例如,假设希望通过Student类能够使用valarray的方法min()和max(),可以在studenti.h的公有部分加入如下using声明:

class Student : private std::string, private std::valarray<double>
{
...
public:
    using std::valarray<double>::min;
    using std::valarray<double>::max;
    ...
};

上述using声明使得valarray<double>::min()和valarray<double>::max()可用,就像它们是Student的公有方法一样:

cout << "high score: " << ada[i].max() << endl;

注意,using声明只使用成员名——没有圆括号、函数特征标和返回类型。例如,为使Student类可以使用valarray的operator 方法,只需在Student类声明的公有部分包含下面的using声明:

using std::valarray<double>::operator[];

这将使两个版本(const和非const)都可用。这样,便可以删除Student::operator[] ()的原型和定义。using声明只适用于继承,而不适用于包含。

有一种老式方式可用于在私有派生类中重新声明基类方法,即将方法名放在派生类的公有部分,如下所示:

class Student : private std::string, private std::valarray<double>
{
public:
    std::valarray<double>::operator[]; // redeclare as public, just use name
    ...
};

这看起来像不包含关键字using的using声明。这种方法已被摒弃,即将停止使用。因此,如果编译器支持using声明,应使用它来使派生类可以使用私有基类中的方法。

14.3 多重继承

MI描述的是有多个直接基类的类。与单继承一样,公有MI表示的也是is-a关系。例如,可以从Waiter类和Singer类派生出SingingWaiter类:

class SingingWaiter : public Waiter, public Singer {...};

请注意,必须使用关键字public来限定每一个基类。这是因为,除非特别指出,否则编译器将认为是私有派生:

class SingingWaiter : public Waiter, Singer {...}; // Singer is a private base

正如本章前面讨论的,私有MI和保护MI可以表示has-a关系。Student类的studenti.h实现就是一个这样的示例。下面将重点介绍公有MI。

MI可能会给程序员带来很多新问题。其中两个主要的问题是:从两个不同的基类继承同名方法;从两个或更多相关基类那里继承同一个类的多个实例。为解决这些问题,需要使用一些新规则和不同的语法。因此,与使用单继承相比,使用MI更困难,也更容易出现问题。由于这个原因,很多C++用户强烈反对使用MI,一些人甚至希望删除MI;而喜欢MI的人则认为,对一些特殊的工程来说,MI很有用,甚至是必不可少的;也有一些人建议谨慎、适度地使用MI。

下面来看一个例子,并介绍有哪些问题以及如何解决它们。要使用MI,需要几个类。我们将定义一个抽象基类Worker,并使用它派生出Waiter类和Singer类。然后,便可以使用MI从Waiter类和Singer类派生出SingingWaiter类(参见图 14.3)。这里使用两个独立的派生来使基类(Worker)被继承,这将导致MI的大多数麻烦。首先声明Worker、Waiter和Singer类,如程序清单14.7所示。

图14.3 祖先相同的MI

程序清单14.7 Worker0.h

// worker0.h -- working classes
#ifndef WORKER0_H_
#define WORKER0_H_

#include <string>

class Worker // an abstract base class
{
private:
    std::string fullname;
    long id;
public:
    Worker() : fullname("no one"), id(0L) {}
    Worker(const std::string & s, long n)
            : fullname(s), id(n) {}
    virtual ~Worker() = 0; // pure virtual destructor
    virtual void Set();
    virtual void Show() const;
};

class Waiter : public Worker
{
private:
    int panache;
public:
    Waiter() : Worker(), panache(0) {}
    Waiter(const std::string & s, long n, int p = 0)
            : Worker(s, n), panache(p) {}
    Waiter(const Worker & wk, int p = 0)
            : Worker(wk), panache(p) {}
    void Set();
    void Show() const;
};

class Singer : public Worker
{
protected:
    enum {other, alto, contralto, soprano,
                    bass, baritone, tenor};
    enum {Vtypes = 7};
private:
    static char *pv[Vtypes]; // string equivs of voice types
    int voice;
public:
    Singer() : Worker(), voice(other) {}
    Singer(const std::string & s, long n, int v = other)
            : Worker(s, n), voice(v) {}
    Singer(const Worker & wk, int v = other)
            : Worker(wk), voice(v) {}
    void Set();
    void Show() const;
};

#endif

程序清单14.7的类声明中包含一些表示声音类型的内部常量。一个枚举用符号常量alto、contralto等表示声音类型,静态数组pv存储了指向相应C-风格字符串的指针,程序清单14.8初始化了该数组,并提供了方法的定义。

程序清单14.8 worker0.cpp

// worker0.cpp -- working class methods
#include "worker0.h"
#include <iostream>
using std::cout;
using std::cin;
using std::endl;
// Worker methods

// must implement virtual destructor, even if pure
Worker::~Worker() {}

void Worker::Set()
{
    cout << "Enter worker's name: ";
    getline(cin, fullname);
    cout << "Enter worker's ID: ";
    cin >> id;
    while (cin.get() != '\n')
        continue;
}

void Worker::Show() const
{
    cout << "Name: " << fullname << "\n";
    cout << "Employee ID: " << id << "\n";
}

// Waiter methods
void Waiter::Set()
{
    Worker::Set();
    cout << "Enter waiter's panache rating: ";
    cin >> panache;
    while (cin.get() != '\n')
        continue;
}

void Waiter::Show() const
{
    cout << "Category: waiter\n";
    Worker::Show();
    cout << "Panache rating: " << panache << "\n";
}

// Singer methods

char * Singer::pv[] = {"other", "alto", "contralto",
            "soprano", "bass", "baritone", "tenor"};

void Singer::Set()
{
    Worker::Set();
    cout << "Enter number for singer's vocal range:\n";
    int i;
    for (i = 0; i < Vtypes; i++)
    {
        cout << i << ": " << pv[i] << " ";
        if ( i % 4 == 3)
            cout << endl;
    }
    if (i % 4 != 0)
        cout << endl;
    while (cin >> voice && (voice < 0 || voice >= Vtypes) )
        cout << "Please enter a value >= 0 and < " << Vtypes << endl;

    while (cin.get() != '\n')
        continue;
}

void Singer::Show() const
{
    cout << "Category: singer\n";
    Worker::Show();
    cout << "Vocal range: " << pv[voice] << endl;
}

程序清单14.9是一个简短的程序,它使用一个多态指针数组对这些类进行了测试。

程序清单14.9 worktest.cpp

// worktest.cpp -- test worker class hierarchy
#include <iostream>
#include "worker0.h"
const int LIM = 4;
int main()
{
    Waiter bob("Bob Apple", 314L, 5);
    Singer bev("Beverly Hills", 522L, 3);
    Waiter w_temp;
    Singer s_temp;

    Worker * pw[LIM] = {&bob, &bev, &w_temp, &s_temp};

    int i;
    for (i = 2; i < LIM; i++)
        pw[i]->Set();
    for (i = 0; i < LIM; i++)
    {
        pw[i]->Show();
        std::cout << std::endl;
    }

    return 0;
}

下面是程序清单14.7~程序清单14.9组成的程序的输出:

Enter waiter's name: Waldo Dropmaster
Enter worker's ID: 442
Enter waiter's panache rating: 3
Enter singer's name: Sylvie Sirenne
Enter worker's ID: 555
Enter number for singer's vocal range:
0: other 1: alto 2: contralto 3: soprano
4: bass 5: baritone 6: tenor
3
Category: waiter
Name: Bob Apple
Employee ID: 314
Panache rating: 5

Category: singer
Name: Beverly Hills
Employee ID: 522
Vocal range: soprano

Category: waiter
Name: Waldo Dropmaster
Employee ID: 442
Panache rating: 3

Category: singer
Name: Sylvie Sirenne
Employee ID: 555
Vocal range: soprano

这种设计看起来是可行的:使用Waiter指针来调用Waiter::Show()和Waiter::Set();使用Singer指针来调用Singer::Show()和Singer::Set()。然后,如果添加一个从Singer和Waiter类派生出的SingingWaiter类后,将带来一些问题。具体地说,将出现以下问题。

  • 有多少Worker?
  • 哪个方法?

14.3.1 有多少Worker

假设首先从Singer和Waiter公有派生出SingingWaiter:

class SingingWaiter: public Singer, public Waiter {...};

因为Singer和Waiter都继承了一个Worker组件,因此SingingWaiter将包含两个Worker组件(参见图14.4)。

正如预期的,这将引起问题。例如,通常可以将派生类对象的地址赋给基类指针,但现在将出现二义性:

SingingWaiter ed;
Worker * pw = &ed; // ambiguous

通常,这种赋值将把基类指针设置为派生对象中的基类对象的地址。但ed中包含两个Worker对象,有两个地址可供选择,所以应使用类型转换来指定对象:

Worker * pw1 = (Waiter *) &ed; // the Worker in Waiter
Worker * pw2 = (Singer *) &ed; // the Worker in Singer

这将使得使用基类指针来引用不同的对象(多态性)复杂化。

包含两个Worker对象拷贝还会导致其他的问题。然而,真正的问题是:为什么需要Worker对象的两个拷贝?唱歌的侍者和其他Worker对象一样,也应只包含一个姓名和一个ID。C++引入多重继承的同时,引入了一种新技术——虚基类(virtual base class),使MI成为可能。

图14.4 继承两个基类对象

1.虚基类

虚基类使得从多个类(它们的基类相同)派生出的对象只继承一个基类对象。例如,通过在类声明中使用关键字virtual,可以使Worker被用作Singer和Waiter的虚基类(virtual和public的次序无关紧要):

class Singer : virtual public Worker {...};
class Waiter : public virtual Worker {...};

然后,可以将SingingWaiter类定义为:

class SingingWaiter: public Singer, public Waiter {...};

现在,SingingWaiter对象将只包含Worker对象的一个副本。从本质上说,继承的Singer和Waiter对象共享一个Worker对象,而不是各自引入自己的Worker对象副本(参见图14.5)。因为SingingWaiter现在只包含了一个Worker子对象,所以可以使用多态。

图14.5 虚基类继承

您可能会有这样的疑问:

  • 为什么使用术语“虚”?
  • 为什么不抛弃将基类声明为虚的这种方式,而使虚行为成为多MI的准则呢?
  • 是否存在麻烦呢?

首先,为什么使用术语虚?毕竟,在虚函数和虚基类之间并不存在明显的联系。C++用户强烈反对引入新的关键字,因为这将给他们带来很大的压力。例如,如果新关键字与重要程序中的重要函数或变量的名称相同,这将非常麻烦。因此,C++对这种新特性也使用关键字virtual——有点像关键字重载。

其次,为什么不抛弃将基类声明为虚的这种方式,而使虚行为成为MI的准则呢?第一,在一些情况下,可能需要基类的多个拷贝;第二,将基类作为虚的要求程序完成额外的计算,为不需要的工具付出代价是不应当的;第三,这样做有其缺点,将在下一段介绍。

最后,是否存在麻烦?是的。为使虚基类能够工作,需要对C++规则进行调整,必须以不同的方式编写一些代码。另外,使用虚基类还可能需要修改已有的代码。例如,将SingingWaiter类添加到Worker集成层次中时,需要在Singer和Waiter类中添加关键字virtual。

2.新的构造函数规则

使用虚基类时,需要对类构造函数采用一种新的方法。对于非虚基类,唯一可以出现在初始化列表中的构造函数即是基类构造函数。但这些构造函数可能需要将信息传递给其基类。例如,可能有下面一组构造函数:

class A
{
    int a;
public:
    A(int n = 0) : a(n) {}
    ...
};
class B: public A
{
    int b;
public:
    B(int m = 0, int n = 0) : A(n), b(m) {}
    ...
};
class C : public B
{
    int c;
public:
    C(int q = 0, int m = 0, int n = 0) : B(m, n), c(q) {}
    ...
};

C类的构造函数只能调用B类的构造函数,而B类的构造函数只能调用A类的构造函数。这里,C类的构造函数使用值q,并将值m和n传递给B类的构造函数;而B类的构造函数使用值m,并将值n传递给A类的构造函数。

如果Worker是虚基类,则这种信息自动传递将不起作用。例如,对于下面的MI构造函数:

SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
                  : Waiter(wk,p), Singer(wk,v) {} // flawed

存在的问题是,自动传递信息时,将通过2条不同的途径(Waiter和Singer)将wk传递给Worker对象。为避免这种冲突,C++在基类是虚的时,禁止信息通过中间类自动传递给基类。因此,上述构造函数将初始化成员panache和voice,但wk参数中的信息将不会传递给子对象Waiter。然而,编译器必须在构造派生对象之前构造基类对象组件;在上述情况下,编译器将使用Worker的默认构造函数。

如果不希望默认构造函数来构造虚基类对象,则需要显式地调用所需的基类构造函数。因此,构造函数应该是这样:

SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
                  : Worker(wk), Waiter(wk,p), Singer(wk,v) {}

上述代码将显式地调用构造函数worker(const Worker &)。请注意,这种用法是合法的,对于虚基类,必须这样做;但对于非虚基类,则是非法的。

警告: 

如果类有间接虚基类,则除非只需使用该虚基类的默认构造函数,否则必须显式地调用该虚基类的某个构造函数。

本文摘自《C++ Primer Plus(第6版)中文版》

本书在介绍C++特性的同时,还讨论了基本C语言,使两者成为有机的整体。书中介绍了C++的基本概念,并通过短小精悍的程序来阐明,这些程序都很容易复制和试验。书中还介绍了输入和输出,如何让程序执行重复性任务,如何让程序做出选择,处理数据的多种方式,以及如何使用函数等内容。另外,本书还讲述了C++在C语言的基础上新增的很多特性,包括:

  • 类和对象;
  • 继承;
  • 多态、虚函数和RTTI(运行阶段类型识别);
  • 函数重载;
  • 引用变量;
  • 泛型(独立于类型的)编程,这种技术是由模板和标准模板库(STL)提供的;

 处理错误条件的异常机制;

  • 管理函数、类和变量名的名称空间。

猜你喜欢

转载自blog.csdn.net/epubit17/article/details/108179837