这一章在干嘛?
公有继承不是代码重用的唯一手段。本章介绍另外几件武器:包含/组合(把别的类对象当成员)、私有与保护继承(继承实现但不继承接口)、多重继承(同时继承多个基类,以及解决随之而来的”两份祖先”难题的虚基类),最后是重用界的王者——类模板:写一份泛型代码,让编译器按需生成 int 版、string 版等任意类型的类。
14.1 包含:对象成员
建模 has-a 关系(“有一个”)最常用的技术是包含(也叫组合、分层):让新类的成员本身是另一个类的对象。设计一个 Student 类,只记名字和一组测验分数——学生不是名字,也不是分数数组,所以不能用公有继承;学生有名字、有分数,用包含正合适。名字用现成的 string,分数用标准库的 valarray。
valarray 快速上手
valarray 是支持数值运算的模板类(头文件 <valarray>),可求和、求最大最小值。声明时在尖括号里给定元素类型:
double gpa[5] = {3.1, 3.5, 3.8, 2.9, 3.3};
valarray<double> v1; // 0 个元素
valarray<int> v2(8); // 8 个 int 元素
valarray<int> v3(10, 8); // 8 个 int 元素,每个都是 10
valarray<double> v4(gpa, 4); // 用 gpa 前 4 个元素初始化
valarray<int> v5 = {20, 32, 17, 9}; // C++11 初始化列表常用方法:operator[]() 访问元素,size() 元素个数,sum() 求和,max()/min() 最大最小值。
Student 类设计
// studentc.h -- 用包含定义 Student 类(节选)
#include <iostream>
#include <string>
#include <valarray>
class Student
{
private:
typedef std::valarray<double> ArrayDb; // 简化记法的别名
std::string name; // 包含的对象
ArrayDb scores; // 包含的对象
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;
friend std::istream & operator>>(std::istream & is, Student & stu);
friend std::istream & getline(std::istream & is, Student & stu);
friend std::ostream & operator<<(std::ostream & os, const Student & stu);
};
#endif
▲ 图 14.1 包含:Student 对象内部装着 string 对象和 valarray 对象
核心思想:Student 获得成员对象的实现,但不继承其接口。外界不能对 Student 对象调用 string::operator+=()——学生不该被”拼接”。类方法内部则可以随意使用成员对象的公有接口,比如:
double Student::Average() const
{
if (scores.size() > 0)
return scores.sum() / scores.size();
else
return 0;
}两个值得注意的语法点:
- explicit:单参数构造函数会充当隐式转换函数。
Student(int n)的参数是”数组元素个数”,把它当转换函数毫无意义,还可能让doh = 5;(本想写doh[0] = 5;)悄悄通过编译——用explicit关掉隐式转换,让编译器报错。编译期错误优于运行期错误,这是 C++ 处处设”约束”的动机。 - 初始化顺序:成员按类定义中的声明顺序初始化,与初始化列表里的书写顺序无关。上例就算写成
: scores(pd, n), name(str),name也先初始化。
valarray 没有 << 运算符,所以类里安排了私有辅助方法 arr_out() 输出分数,友元 operator<<() 调用它——把杂乱细节收拢在一处。
14.2 私有继承
实现 has-a 的第二种手段:私有继承。基类的公有成员和保护成员变成派生类的私有成员——基类方法不再是派生对象的公有接口,只能在派生类方法内部使用。同样是”获得实现、不获得接口”,它与包含的区别是:包含添加的是有名成员对象,私有继承添加的是无名的继承子对象。
// studenti.h -- 用私有继承定义 Student 类(节选)
class Student : private std::string, private std::valarray<double>
{
private:
typedef std::valarray<double> ArrayDb;
std::ostream & arr_out(std::ostream & os) const;
public:
Student() : std::string("Null Student"), ArrayDb() {}
explicit Student(int n) : std::string("Nully"), ArrayDb(n) {}
Student(const char * str, const double * pd, int n)
: std::string(str), ArrayDb(pd, n) {}
// ……其余接口与包含版完全相同
};注意这里同时继承了两个基类——这已经是**多重继承(MI)**了,只是这种私有 MI 不惹麻烦。

▲ 图 14.2 私有继承:Student 内部是两个无名的基类子对象
与包含版写法上有三处对应差异:
| 需求 | 包含 | 私有继承 |
|---|---|---|
| 初始化 | 初始化列表写成员名 name(str) | 写类名 std::string(str) |
| 调用基类方法 | 对象名调用 scores.sum() | 类名加作用域解析 ArrayDb::sum() |
| 拿到基类对象 | 直接用成员名 | 强制转换:(const string &) *this |
// 私有继承版的 Average() 与 Name()
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; // 转换出内嵌的 string 子对象
}友元函数不能加类名限定(友元不属于类),同样用强制转换:os << (const string &) stu;。这里必须显式转换——私有继承下基类引用不能自动引用派生对象;即便公有继承也得转换,否则 os << stu 会匹配到自己,无限递归。
两个版本的 Student 公有接口完全一致,测试程序一字不改即可互换。那到底选哪个?
包含还是私有继承?
一般选包含:更直观(成员有名有姓)、不容易踩 MI 的坑、还允许包含同一类的多个对象(继承只能继承一份)。只有两种情况选私有继承:①新类需要访问基类的 protected 成员;②新类需要重新定义基类的虚函数——这两样都是”派生类”才有的特权,包含类没有。
14.3 保护继承与 using 重新定义访问
保护继承(class Student : protected std::string, ...)是私有继承的变体:基类公有成员和保护成员都变成派生类的保护成员。区别在”下一代”:私有继承下,第三代类拿不到基类接口(已变 private);保护继承下,第三代类仍能在内部使用它。
| 属性 | 公有继承 | 保护继承 | 私有继承 |
|---|---|---|---|
| 公有成员变成 | 派生类的公有成员 | 派生类的保护成员 | 派生类的私有成员 |
| 保护成员变成 | 派生类的保护成员 | 派生类的保护成员 | 派生类的私有成员 |
| 隐式向上转换 | 允许 | 只在派生类内部允许 | 不允许 |
私有/保护继承后若想让个别基类方法重新公开,除了写一个转发的包装方法,更简洁的是 using 声明(放在派生类 public 段):
class Student : private std::string, private std::valarray<double>
{
// ……
public:
using std::valarray<double>::min; // 恢复 min() 为公有
using std::valarray<double>::max; // 恢复 max() 为公有
};
// 之后就能写:ada[i].max()using 声明只写成员名,不带括号、不带签名——会把该名的所有重载版本一并恢复。这种做法只适用于继承,不适用于包含。
14.4 多重继承:Worker 家族
多重继承(MI)指一个类有多个直接基类。公有 MI 应表达 is-a:会唱歌的服务生既是服务生又是歌手:
class SingingWaiter : public Singer, public Waiter {...};注意每个基类都要写 public——编译器默认按私有继承处理,漏写就出 bug。
MI 的两大难题:从两个基类继承了同名方法,以及经由两条路径继承了同一个类的多份拷贝。下面的例子正踩中第二条:Worker 是抽象基类,Waiter 和 Singer 都从它派生,SingingWaiter 再同时继承两者。

▲ 图 14.3 Singer 和 Waiter 都派生自 Worker,SingingWaiter 同时继承两者
// worker0.h -- 工人类(简化版)
class Worker // 抽象基类
{
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; // 纯虚析构函数
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) {}
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]; // 嗓音类型的字符串表
int voice;
public:
Singer() : Worker(), voice(other) {}
Singer(const std::string & s, long n, int v = other)
: Worker(s, n), voice(v) {}
void Set();
void Show() const;
};纯虚析构函数也必须有定义(哪怕函数体为空)——派生类析构函数总要调用它。这个单继承版本工作正常:基类指针数组 Worker * pw[LIM] 能多态地管理 Waiter 和 Singer。
14.5 虚基类:只留一份祖先
现在加 class SingingWaiter : public Singer, public Waiter,麻烦来了:Singer 和 Waiter 各带一份 Worker 拷贝,SingingWaiter 里就有两个 Worker 子对象——两份姓名、两份工号,显然不合理。连向上转换都二义了:
SingingWaiter ed;
Worker * pw = &ed; // 二义!哪个 Worker?
Worker * pw1 = (Waiter *) &ed; // 强转可以指定,但多态全乱了
▲ 图 14.4 不用虚基类:SingingWaiter 里有两份 Worker 拷贝
解法是把 Worker 声明为 Singer 和 Waiter 的虚基类:
class Singer : virtual public Worker {...}; // virtual 与 public 顺序随意
class Waiter : public virtual Worker {...};
class SingingWaiter : public Singer, public Waiter {...};这样 SingingWaiter 只含一份 Worker 子对象,Singer 和 Waiter 共享它,多态恢复可用。

▲ 图 14.5 虚基类:Singer 和 Waiter 共享同一份 Worker 子对象
为什么不把”虚基类”设为 MI 的默认行为?因为有时你确实想要多份拷贝;虚基类有额外开销;而且它要改动已有的类声明(给 Singer、Waiter 加 virtual)。
虚基类的构造函数新规则
普通继承下,初始化列表里只能出现直接基类的构造函数,信息逐层上传。虚基类下这种自动上传被禁用了——否则 wk 会沿 Waiter、Singer 两条路径传两遍。必须显式调用虚基类构造函数:
// 错误:wk 传不到 Worker 子对象
SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
: Waiter(wk, p), Singer(wk, v) {} // 有缺陷
// 正确:显式调用虚基类构造函数
SingingWaiter(const Worker & wk, int p = 0, int v = Singer::other)
: Worker(wk), Waiter(wk, p), Singer(wk, v) {} // OK常见坑:虚基类构造函数的调用规则与非虚基类相反
对非虚基类,派生类在初始化列表里直接调用间接基类构造函数是非法的;对虚基类,显式调用间接虚基类的构造函数不仅是合法的,而且是必要的(除非你只想要默认构造)。这种规则只对间接虚基类生效,直接传给中间类构造函数的那份参数会被忽略。
二义性与模块化方法
newhire.Show() 是二义的——Singer 和 Waiter 各有一份 Show()。可以用 newhire.Singer::Show(); 消歧,但更好的做法是重新定义 SingingWaiter::Show()。然而简单叠加不行:Singer::Show() 和 Waiter::Show() 都会调 Worker::Show(),姓名和工号会显示两遍。
模块化方案:把”显示一个类自己的数据”拆成独立的 protected 方法 Data()/Get(),Show/Set 负责拼装:
// workermi.h 关键结构(节选)
class Worker
{
protected:
virtual void Data() const; // 只显示 Worker 数据
virtual void Get(); // 只读入 Worker 数据
// ……
};
class Waiter : virtual public Worker
{
protected:
void Data() const; // 只显示 panache
void Get();
// ……
};
class SingingWaiter : public Singer, public Waiter
{
protected:
void Data() const { Singer::Data(); Waiter::Data(); }
void Get() { Waiter::Get(); Singer::Get(); }
public:
void Set() { cout << "Enter singing waiter's name:\n"; Worker::Get(); Get(); }
void Show() const { cout << "Category: singing waiter\n"; Worker::Data(); Data(); }
};这就是”protected 方法优于 protected 数据”的实战示范:数据仍私有,派生类通过受控的 Data/Get 协作。
常见坑:MI 的名字二义
从两个基类继承了同名方法而不加限定,编译器报错。虚基类会引入支配规则:派生类中的名字支配祖先类中的同名名字(如 C 派生自 B,C::q() 支配 B::q(),F 里写 q() 没问题);但两条独立分支上的同名函数互不支配,仍是二义。注意支配规则不看访问权限——哪怕被支配的名字是 private 的,不加限定也会二义。
14.6 类模板
容器类(栈、队列、数组)的代码除了存的类型不同几乎一模一样。typedef 只能换一种类型,类模板能”把类型当参数传进去”,按需生成任意多个类。
定义模板
// stacktp.h -- 栈模板
#ifndef STACKTP_H_
#define STACKTP_H_
template <class Type> // 也可写 template <typename Type>
class Stack
{
private:
enum {MAX = 10};
Type items[MAX]; // 原来 typedef 的 Item 换成 Type
int top;
public:
Stack();
bool isempty();
bool isfull();
bool push(const Type & item);
bool pop(Type & item);
};
template <class Type>
Stack<Type>::Stack() { top = 0; } // 类限定名是 Stack<Type>::
template <class Type>
bool Stack<Type>::push(const Type & item)
{
if (top < MAX)
{
items[top++] = item;
return true;
}
else
return false;
}
template <class Type>
bool Stack<Type>::pop(Type & item)
{
if (top > 0)
{
item = items[--top];
return true;
}
else
return false;
}
#endif模板不是类定义,而是”给编译器的配方”。模板成员函数放在单独的实现文件里无法编译,必须把模板全部信息放在头文件中。
使用模板
声明对象时给出具体类型,编译器才生成实例化(instantiation,也称专用化):
Stack<int> kernels; // 生成 int 版 Stack 类
Stack<string> colonels; // 生成 string 版 Stack 类与函数模板不同,类模板必须显式提供类型——函数模板还能靠实参类型自动推断,类模板不行。Type 这样的标识符叫类型参数:它像变量,但赋给它的是类型而非数值。
14.7 指针栈:模板用对与用错
Stack<char *> 能编译,但不代表能正确工作。三种典型错误用法:
char * po;接收输入——指针没指向任何可用空间,cin往里写就是灾难。char po[40];——数组名不是左值、不能赋值,pop()里item = items[--top];编译不过。char * po = new char[40];——最隐蔽:po永远指向同一块内存,每次 push 压进栈的都是同一个地址,弹出时拿到的永远是最后读入的那条字符串。栈毫无用处。
正确姿势:由调用程序维护一组指针,让每个指针指向不同的字符串,栈只负责管理指针的进出:
// stkoptr1.cpp -- 指针栈模拟"文件篮"(节选)
Stack<const char *> st(stacksize); // in-basket
const char * in[Num] = {" 1: Hank Gilgamesh", " 2: Kiki Ishtar", ...};
const char * out[Num]; // out-basket
while (processed < Num)
{
if (st.isempty())
st.push(in[nextin++]); // 从小车取文件放进篮
else if (st.isfull())
st.pop(out[processed++]); // 篮满,处理顶部文件
else if (std::rand() % 2 && nextin < Num) // 抛硬币
st.push(in[nextin++]);
else
st.pop(out[processed++]);
}字符串本身从不移动,栈里进出的只是地址。析构函数删除的是指针数组,不是那些字符串。
14.8 非类型参数与模板的多种玩法
非类型(表达式)参数
// arraytp.h -- 数组模板(节选)
template <class T, int n>
class ArrayTP
{
private:
T ar[n];
public:
virtual T & operator[](int i);
virtual T operator[](int i) const;
};
// 用法:
ArrayTP<double, 12> eggweights;int n 是非类型参数(表达式参数):它限定为整型、枚举、引用或指针(double m 不行,double &/double * 可以),且模板代码不能修改它或取它的地址,实例化时必须给常量表达式。
两种定长方案的对比:构造函数方案(Stack<int> eggs(12);)用堆内存,多个声明共享一个类,尺寸存为成员、更灵活;表达式参数方案(ArrayTP<double,12>)用自动栈内存、速度更快,但每种尺寸都生成一个独立的类。
模板的多彩玩法
- 递归使用:
ArrayTP< ArrayTP<int,5>, 10> twodee;等价于int twodee[10][5];(注意维度顺序相反)。 - 多类型参数:
template <class T1, class T2> class Pair {...};,Pair<string, int> ratings[4];——注意类名是Pair<string,int>,不是Pair。 - 默认类型参数:
template <class T1, class T2 = int> class Topo {...};——类模板可以给类型参数默认值,函数模板不行(非类型参数则两者都可以)。 - 模板当基类/成员/类型参数:
class GrowArray : public Array<Type>(继承)、成员里用Array<Tp> ar;(包含)、Array< Stack<int> > asi;(C++98 要求> >之间留空格,C++11 起不必)。
14.9 模板专用化
隐式实例化与显式实例化
声明对象时给类型 → 隐式实例化,编译器直到需要对象时才生成定义。用 template 关键字直接点名 → 显式实例化,即使还没对象也生成类定义:
template class ArrayTP<string, 100>; // 显式实例化显式专用化
某类型需要特殊处理时,提供覆盖通用模板的专用定义。比如按地址排序对 const char * 毫无意义,需要 strcmp() 版本:
template <> class SortedArray<const char *> { /* 用 strcmp() 比较 */ };
SortedArray<int> scores; // 用通用定义
SortedArray<const char*> dates; // 用专用定义部分专用化
只限制一部分参数:
template <class T1, class T2> class Pair {...}; // 通用
template <class T1> class Pair<T1, int> {...}; // T2 定为 int
template <> class Pair<int, int> {...}; // 全部指定:完全专用化
Pair<double, double> p1; // 通用模板
Pair<double, int> p2; // Pair<T1,int> 部分专用化
Pair<int, int> p3; // Pair<int,int> 显式专用化编译器总是选择最专用的版本。还可以对”指针”整体部分专用化:template <class T> class Feeb<T *> {...};——传 char * 时 T 被解释为 char 而不是 char *。
14.10 模板做成员、做参数与模板友元
成员模板
// tempmemb.cpp -- 模板成员(节选)
template <typename T>
class beta
{
private:
template <typename V> // 嵌套模板类成员
class hold
{
private:
V val;
public:
hold(V v = 0) : val(v) {}
void show() const { cout << val << endl; }
V Value() const { return val; }
};
hold<T> q; // 类型随 beta 的 T 而定
hold<int> n; // 固定 int 版
public:
beta(T t, int i) : q(t), n(i) {}
template<typename U> // 模板方法:U 由调用实参推断
U blab(U u, T t) { return (n.Value() + q.Value()) * u / t; }
void Show() const { q.show(); n.show(); }
};
beta<double> guy(3.5, 3);
guy.blab(10, 2.3); // U=int,返回 int,得 28
guy.blab(10.0, 2.3); // U=double,返回 double,得 28.2609在类外定义嵌套模板成员要用两层 template 前缀:template <typename T> template <typename V> class beta<T>::hold {...};。
模板作为参数
template <template <typename T> class Thing>
class Crab
{
private:
Thing<int> s1;
Thing<double> s2;
public:
bool push(int a, double x) { return s1.push(a) && s2.push(x); }
bool pop(int & a, double & x) { return s1.pop(a) && s2.pop(x); }
};
Crab<Stack> nebula; // Thing 被替换为 Stack:s1 是 Stack<int>,s2 是 Stack<double>模板参数还可以和普通类型参数混用:template <template <typename T> class Thing, typename U, typename V> class Crab,声明 Crab<Stack, int, double> nebula;。
模板类的三类友元
- 非模板友元:
friend void counts();——一个普通函数,是模板所有实例化的朋友;要访问特定实例的参数得写成friend void report(HasFriend<T> &);(约束模板友元的声明部分),并手动为每个用到的类型提供重载定义。 - 约束模板友元:先在类外声明模板函数,再在类里用
<>语法声明专用化为友元——每个类实例化获得匹配的友元实例化:
template <typename T> void counts(); // 第一步:前置声明
template <typename T> void report(T &);
template <typename TT>
class HasFriendT
{
// ……
friend void counts<TT>(); // 第二步:类内声明专用化为友元
friend void report<>(HasFriendT<TT> &);
};
// 第三步:提供模板定义- 非约束模板友元:在类内声明全新的模板,友元模板的类型参数与类模板无关——每个函数实例化都是每个类实例化的朋友:
template <typename T>
class ManyFriend
{
template <typename C, typename D> friend void show2(C &, D &);
};
template <typename C, typename D> void show2(C & c, D & d)
{
cout << c.item << ", " << d.item << endl;
}
// show2(hfdb, hfi2); 可以混合访问 ManyFriend<double> 和 ManyFriend<int> 的私有成员14.11 模板别名(C++11)
给模板专用化起短名,typedef 又长又重复;C++11 用 using = 定义模板别名,一族别名一次搞定:
template<typename T>
using arrtype = std::array<T, 12>; // arrtype<T> 即 std::array<T,12>
arrtype<double> gallons; // std::array<double, 12>
arrtype<int> days; // std::array<int, 12>using = 用于非模板时等价于普通 typedef,但把”名字”和”类型信息”分得更开,可读性更好。
通关标准:
能独立回答并演示:①包含与私有继承各自如何初始化、调用基类方法、拿到基类对象;②什么时候必须用私有继承而不是包含;③MI 出现两份祖先的原因与虚基类的解法(含构造函数显式调用规则);④类模板的定义、实例化与”模板要放头文件”的原因;⑤隐式实例化/显式实例化/显式专用化/部分专用化的区别。
14.12 本章自测
自测:包含版本与私有继承版本的 Student 类,写法上有哪三处核心差异?
①初始化列表:包含用成员名
name(str),私有继承用类名std::string(str);②方法内调用基类设施:包含用对象名scores.sum(),私有继承用ArrayDb::sum();③需要基类对象本身时:包含直接用成员名,私有继承用强制转换(const string &) *this。
自测:为什么 SingingWaiter 的构造函数必须在初始化列表里显式写
Worker(wk)?Worker 是虚基类时,C++ 禁止经由中间类(Waiter/Singer)自动向虚基类传递构造信息——否则 wk 会沿两条路径传两遍。不显式调用的话,Worker 部分只能用默认构造函数初始化。这种显式调用对间接虚基类是必要的,对间接非虚基类反而是非法的。
自测:
template <class T, int n>中的int n是什么?它和构造函数传尺寸各有什么优劣?非类型(表达式)参数,只能是整型、枚举、引用或指针,实例化时须用常量表达式,且模板内不能修改它。优点:数组用自动栈内存,快;缺点:每种尺寸生成一个独立的类。构造函数传尺寸则所有尺寸共享一个类定义、尺寸存为成员更灵活,但用堆内存稍慢。
自测:为什么"每压栈一次都压进同一个地址"让指针栈版本 3 失去意义?
char * po = new char[40];只有一块缓冲区,每次读入新字符串都覆盖同一内存,push 的永远是同一个地址。弹栈时取回的都是最后一次读入的字符串——栈根本没有保存历史数据。正确做法是让调用程序维护一组指向不同字符串的指针,栈只管理指针。
自测:
Pair<double,int>、Pair<T1,int>(部分专用化)与Pair<int,int>(显式专用化)同时存在时,三个声明各用哪个定义?
Pair<double,double>用通用模板;Pair<double,int>用部分专用化Pair<T1,int>;Pair<int,int>用显式专用化。规则:编译器选择能匹配的最专用版本。