类继承

类继承

  • 本章内容包括:
    • is-a 关系的继承。
    • 如何以公有方式从一个类派生出另一个类。
    • 保护访问。
    • 构造函数成员初始化列表。
    • 向上和向下强制转换。
    • 虚成员函数。
    • 早期(静态)联编与晚期(动态)联编。
    • 抽象基类。
    • 纯虚函数。
    • 何时及如何使用公有继承。

类继承——它能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法。

  • 可以在原有类的基础上添加功能。例如,对于数组类,可以添加数学运算。
  • 可以给类添加数据。例如,对于字符串类可以派生出一个类,并添加指定字符串显示颜色的数据成员。
  • 可以修改类方法的行为。例如,对于代表提供给飞机乘客的服务的passenger类,可以派生出提供给头等舱服务的FirstPassenger类。

13.1 一个简单的基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TableTennisPlayer
{
private:
string firstname;
string lastname;
bool hasTable;
public:
TableTennisPlayer (const string & fn = "none",
const string & ln = "none", bool ht = false);
};
/*
这将首先为firstname调用string的默认构造函数,再调用string的赋值运算符将firstname设置为fn
*/
TableTennisPlayer::TableTennisPlayer (const string & fn,
const string & ln, bool ht) {
firstname = fn;
lastname = ln;
hasTable = ht;
}
/*初始化列表语法
初始化列表语法可减少一个步骤,它直接使用string的复制构造函数将firstname 初始化为fn
TableTennisPlayer::TableTennisPlayer (const string & fn,
const string & ln, bool ht) : firstname(fn),
lastname(ln), hasTable(ht) {}
*/

给一个类派生出另一个类时,原始类称为基类,继承类成为派生类。

  • 公有派生(public)

    派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类的公有成员;基类的私有部分(private)也将成为派生类的一部分,但只能通过基类的公有和保护方法访问

    • 派生类对象存储了基类的数据成员(派生类继承了积累的实现)
    • 派生类对象可以使用基类的方法(派生类继承了基类的接口)
    • 派生类需要自己的构造函数
    • 派生类可以根据需要添加额外的数据成员和成员函数
    • 构造函数必须给新成员(如果有的话)和继承的成员提供数据

13.1.2构造函数:访问权限的考虑

  • 派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。
    派生类不能直接设置继承的成员,而必须使用基类的公有方法来访问私有的基类成员。具体地说,派生类构造函数必须使用基类构造函数。

  • 创建派生类对象时,程序首先创建基类对象。

    从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C+ +使用成员初始化列表语法来完成这种工作。

  • 非构造函数不能使用成员初始化列表语法,但派生类方法可以调用公有的基类方法

    例如,派生类构造函数在初始化基类私有数据时,采用的是成员初始化列表语法

    1
    2
    3
    4
    5
    6
    // RatedPlayer 继承于 TableTennisPlayer
    // class RatedPlayer : public TableTennisPlayer
    RatedPlayer: : RatedPlayer (unsigned int r, const string & fn, const string & 1n, bool ht) : TableTennisPlayer(fn, 1n, ht)
    {
    rating = r;
    }

    如果省略成员初始化列表(不调用基类构造函数),程序将使用默认的基类构造函数

  • 有关派生类构造函数的要点如下:

    • 先创建基类对象
    • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数,根据继承顺序初始化,而不是初始化列表中的顺序
    • 派生类构造函数应初始化派生类新增的数据成员。根据声明顺序初始化,而不是初始化列表中的顺序
    • 释放对象的顺序与创建对象的顺序相反,首先执行派生类的析构函数,然后自动调用基类的析构函数。
  • 派生类并不能直接访问基类的私有数据,必须使用基类的公有方法才能访问这些数据

再次总结

  1. 创建派生类对象时,程序首先调用基类构造函数

    基类构造函数负责初始化继承的数据成员

  2. 然后再调用派生类构造函数。

    派生类构造函数主要用于初始化新增的数据成员。

  3. 派生类的构造函数总是调用一个基类构造函数。

    可以使用初始化器列表语法指明要使用的基类构造函数,否则将使用默认的基类构
    造函数。

  4. 派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。

  • 虛基类外(参见第14章),类只能将值传递回相邻的基类,但后者可以使用相同的机制将信息传递给相邻的基类,依此类推。如果没有在成员初始化列表中提供基类构造函数,程序将使用默认的基类构造函数。成员初始化列表只能用于构造函数。

13.1.3派生类和基类之间的特殊关系

  • 派生类对象可以使用基类的方法,条件是不是私有的
  • 基类指针可以在不进行显式类型转换的情况下指向派生类对象
  • 基类引用可以在不进行显示类型转换的情况下引用派生类对象
  • 基类指针或引用只能用于调用基类方法,因此,不能来调用派生类的方法。
  • 不可以将基类对象和地址赋给派生类引用和指针
1
2
3
4
5
6
7
8
9
10
11
class A{};
class B:public A{};
B temp_1(1,2,3);
A temp_2(temp_1);
/*
要初始化temp_2,匹配的构造函数的原型如下:
A (const B&);
类定义中没有这样的构造函数,但存在隐式复制构造函数
A(const A&);
形参是基类引用,因此他可以引用派生类
*/

13.2继承: is-a关系

公有继承

13.3多态公有继承

实现多态公有继承方法

  • 在派生类中重新定义基类的方法
  • 使用虚方法virtual

如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。

  • 如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class A{
    public:
    void print();
    };
    class B:public A{
    public:
    void print();
    };
    A temp_1();
    B temp_2();

    A* P_1 = & temp_1;
    P_1 -> print(); // 调用 A::print()
    A* P_2 = & temp_2;
    P_2 -> print(); // 调用 A::print()
  • 如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class A{
    public:
    virtual void print();
    };
    class B:public A{
    public:
    virtual void print();
    };
    A temp_1();
    B temp_2();

    A* P_1 = & temp_1;
    P_1 -> print(); // 调用 A::print()
    A* P_2 = & temp_2;
    P_2 -> print(); // 调用 B::print()

方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。然而,在派生类声明中使用关
键字virual来指出哪此函数县虎函数也不失为一个好办法。(派生类不一定要加virtual,基类一定要;virtual只需要在原型中写出,但定义中不需要写,和friend一样

只要派生类的函数与基类的同原型(函数返回类型、函数名和形参列表),自动转为虚函数,不需要声明virtual,如果不同的话就叫作函数重载了;虚函数只能是类中成员函数且不能是静态,有隐藏的this指针

  • 基类声明虚析构函数。这样做是为了确保释放派生对象时,按正确的顺序调用析构函数。
    • 使用delete释放由new分配的对象时,如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。如果析构函数是虚的,将调用相应对象类型的析构函数。因此,如果基类指针指向的是派生类对象,将调用派生类 的析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以确保正确的析构函数序列被调用
  • 注意:如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。

13.4.1指针和引用类型的兼容性

  • 在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。

  • 将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a 关系的一部分。 BrassPlus 对象都是Brass对象,因为它继承了Brass对象

  • 相反的过程——将 基类指针或引用转换为派生类指针或引用——称 为向下强制转换。如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a 关系通常是不可逆的。派生类可以新

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Employee
    {
    private:
    char nane[401;
    public:
    void show nane();
    };
    class Singer : public Employee{
    public:
    void range();
    };
    Employee veep;
    Singer trala;
    Employee pe= &trala; // 允许向上隐式类型转换
    Singer* ps = (Singer *) &veep; // 必须向下显式类型转换
    pe->show nane(); // 向上转换带来安全操作,因为Singer是Employee(每个singer都继承姓名)
    ps->range(); //向下转换可能带来不安全的操作,因为Employee并不是Singer(Eaployee没有有range()方法)
  • 如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

13.4.3有关虚函数注意事项

1. 构造函数

构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。

2. 析构函数

析构函数应当是虚函数,除非类不用做基类。例如,假设Employee是基类,Singer 是派生类,并添加一个char *成员,该成员指向由new分配的内存。当Singer对象过期时,必须调用~Singer( )析构函数来释放内存。

1
2
Employee * pe = new Singer; 
delete pe;
  • 如果使用默认的静态联编,delete 语句将调用~Employee( )析构函数。这将释放由Singer 对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。
  • 如果析构函数是虚的,则上述代码将先调用~Singer析构函数释放由Singer组件指向的内存,然后,调用~Employee()析构函数来释放由Employee组件指向的内存。
    这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认析构函数,而应提供虚析构函数,即使它不执行任何操作: virtual ~BaseClass() { }
  • 给类定义一个虚析构函数并非错误,即使这个类不用做基类:这只是一个效率方面的问题。
    通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

3. 友元

友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。可以通过让友元函数使用虚成员函数来解决。

4. 没有重新定义

如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。

5. 重新定义将隐藏方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   class Dwelling{
public:
virtual void showperks(int a) const;
};
class Hovel : public Dweling{
public:
virtual void showperks() const;
};
Hovel trump;
trump.showperks() ;// valid
trump.showperks(5) ;// invalid
trump.Dwelling::showperks(5) ;// valid

/*
新定义将showperks( )定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。
*/
  • 重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
  • 如果派生类的函数与基类的函数同名,但是参数不同。此时,无论有无virtual关键字,积累的函数将被隐藏(注意别与重载混淆)。
  • 如果派生类的函数与积累的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。

这引出了两条经验规则:

  • 第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针(这种例外是新出现的)。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而变化

这种例外只适用于返回值,而不适用于参数。

  • 第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。

如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。注意,如果不需要修改,则新定义可只调用基类版本

1
void Hovel: : showperks() const {Dwelling::showperks() ;}

13.5访问控制: protected

1
2
3
4
5
class test{
protected:
int n;
int get_n(){}
}
  • 关键字protected 与private 相似,在类外只能用公有类成员来访问protected部分中的类成员。

  • private 和protected之间的区别只有在基类派生的类中才会表现出来。

  • 派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。

    • 对于外部世界来说, 保护成员的行为 ≈ 私有成员的行为

    • 对于派生类来说, 保护成员的行为 ≈ 公有成员的行为

最好对类数据成员采用私有访问控制,不要使用保护访问控制

同时通过基类方法使派生类能够访问基类数据。
然而,对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。

13.6抽象基类(不一定要虚函数) 纯虚函数

C+ +通过使用纯虚函数(pure vitual function)提供未实现的函数。纯虚函数声明的结尾处为=0

1
2
3
4
5
6
7
8
9
10
class BaseEllipse{
private :
double x;
double y;
public:
BaseEllipse(double x0 = 0, double y0 = 0) : x(x0) ,y(y0) {}
virtual ~BaseEllipse() {}
void Move(int nx, ny) { X= nx; y= ny; }
virtual double Area() const = 0;
}

当类声明中包含纯虚函数时,则不能创建该类的对象。包含纯虚函数的类只用作基类。

要成为真正的ABC(抽象基类),必须至少包含一个纯虚函数。即原型中的=0使虚函数成为纯虚函数,比如这里的方法Area()没有定义。

C++甚至允许纯虚函数有定义。例如,也许所有的基类方法都与Move()一样,可以在基类中进行定义,但您仍需要将这个类声明为抽象的。在这种情况下,可以将原型声明为虚的:

1
void Move(int nx,ny) = 0;

这将使基类成为抽象的,但您仍可以在实现文件中提供方法的定义:

1
void BaseEllipse: :Move(int nx,ny) { x = nx;y= ny; }

总之,在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。

13.7 继承和动态内存分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class baseDMA
{
private:
char * label;
int rating;
public:
baseDMA(const char * l = "null", int r = 0);
baseDMA(const baseDMA & rs);
virtual ~baseDMA();
baseDMA & operator=(const baseDMA & rs);
friend std::ostream & operator<<(std::ostream & os, const baseDMA & rs);
};
class lacksDMA :public baseDMA
{
private:
enum { COL_LEN = 40};
char color[COL_LEN];
public:
lacksDMA(const char * c = "blank", const char * l = "null", int r = 0);
lacksDMA(const char * c, const baseDMA & rs);
friend std::ostream & operator<<(std::ostream & os, const lacksDMA & rs);
};
  • 复制类成员或继承的类组件时,则是使用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函数将使用显式baseDMA复制构造函数baseDMA (const baseDMA & rs)来复制lacksDMA对象的baseDMA部分。

  • 对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的赋值运算符baseDMA & operator= (const baseDMA & rs) 来对基类组件进行赋值。

  • 类的默认析构函数会将自动使用基类的析构函数~baseDMA()来对类成员或继承的类组件析构。故其自身的职责是对派生类构造函数执行工作的部分进行清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class hasDMA :public baseDMA
{
private:
char * style;
public:
hasDMA(const char * s = "none", const char * l = "null",
int r = 0);
hasDMA(const char * s, const baseDMA & rs);
hasDMA(const hasDMA & hs);
~hasDMA();
hasDMA & operator=(const hasDMA & rs);
friend std::ostream & operator<<(std::ostream & os,
const hasDMA & rs);
};

复制构造函数

1
2
3
4
5
6
7
/*
hasDMA复制构造函数只能访问hasDMA的数据,因此它必须调用baseDMA复制构造函数来处理共享的baseDMA数据:
*/
hasDMA: : hasDMA (const hasDMA & hs) : baseDMA (hs){
style = new char [std: :strlen (hs.style) + 1];
std: :strcpy(style,hs.style) ;
}

需要注意的一点是, 成员初始化列表将一个hasDMA引用传递给baseDMA构造函数

没有参数类型为hasDMA引用的baseDMA构造函数,也不需要这样的构造函数。

因为复制构造函数baseDMA有一个baseDMA引用参数,而基类引用可以指向派生类型。

因此,baseDMA 复制构造函数将使用 const hasDMA & hs参数的baseDMA部分来构造新对象的baseDMA部分。

赋值运算符重载

1
2
3
4
5
6
7
8
9
10
hasDMA & hasDMA: : operator= (const hasDMA & hs)
{
if (this == &hs}
return *this;
baseDMA: :operator=(hs); // 通过使用函数表示法,而不是运算符表示法,可以使用作用域解析运算法
delete [] style;
style = new char [std: :strlen(hs.sty1e) + 1] ;
std: :strcpy (style,hs.style) ;
return this;
}

友元

1
2
3
4
5
std: :ostream & operator<< (std: :ostream & os,const hasDMA & hs){
os << (const baseDMA &) hs;
os << "Style: ”<< hs.style << endl;
return os;
}