6. 类继承(公有继承,is-a)


除非特别指出,C++中的继承默认为私有继承。

// 等效
class child : public base1, base2
{
    // ...
}
// 等效
class child : base2, public base1
{
    // ...
}
// 等效
class child : private base2, public base1
{
    // ...
}

6.1 成员初始化列表

理论上,const成员变量或基类成员变量都应该在构造函数之前初始化。C++提供了成员初始化列表来实现。

class base
{
    double _number;
public:
    base(double number);
    virtual ~base();
};

class child : public base
{
    const short _ARRSIZE;
    double* _numbers;
public:
    child(const double* arr, short size, double number = 0);
};

// 成员初始化列表
child::child(const double* arr, short size, double number) : base(number), _ARRSIZE(size)
{
    _numbers = new double[_ARRSIZE];
    for(int i = 0; i < _ARRSIZE; i++)
        _numbers[i] = arr[i];
}

6.2 虚函数(多态公有继承)

要实现多态公有继承,可以采用两种方法:

  • 在派生类中重新定义一个同名的成员函数。
  • 使用虚函数。

第一种方法在使用向上转换时会出现问题。
假设动态创建一个基类指针,用这个指针指向一个派生类对象。派生类的指针在储存时向上转换为基类指针。
如果采用第一种方法,用这个指针调用函数会使用基类版本,因为这样的实现会根据指针类型调用函数。
如果采用第二种方法,用这个指针调用函数会使用派生类版本,因为这样的实现会根据指针指向的对象的类型调用函数。

6.2.1 有关虚函数的要点

  • 声明一个函数为虚函数,只需要在首次声明该函数的类中在其声明前加上virtual关键字即可,之后的派生类中的该函数会自动变成虚的。
  • 派生类中不一定要为此函数创建创建新的定义,使用虚函数时会自动向上选择最新的版本。
  • 如果定义的类要被用作基类,应该将那些要在派生类中重新定义的函数声明为虚的。

6.2.2 有关虚函数的注意事项

  1. 构造函数

构造函数不能是虚函数,派生类都应该

  1. 析构函数

析构函数必须是虚函数。这样可以保证在使用动态内存分配+向上转换,用delete释放内存时可以调用正确的析构函数
在调用派生类析构函数之后,编译器会自动调用基类的析构函数。这样一来让使用者不用顾忌基类的实现,而来能使基类和派生类以正确的顺序被释放。

  1. 友元

友元不是成员函数,不不能声明为虚函数。不过要是想要友元具有虚函数的特性,可以让友元调用虚函数。

  1. 重新定义将隐藏方法

在派生类中重新定义函数(不管是不是虚函数)将隐藏原来的函数,而不会重载函数(即使参数列表不同)。
因此,考虑两条规则:

  • 如果重新定义继承的方法,应确保函数原型完全相同。不过基类指针或引用可以用派生类指针或引用代替,这种特性称为返回类型协变。
  • 若要重新定义,应重新定义所有的重载版本。

6.3 访问控制

protected除非遇到私有继承,否则protected成员将一直保持为protected
public成员在派生类中的访问控制与继承方式相同。
private成员在派生类中都是不可见的。

public protected private
公有继承 public protected 不可见
私有继承 private private 不可见
保护继承 protected protected 不可见

protected成员在基类和派生类(公有继承)中都类似私有成员。

私有继承就相当于在类中添加了一个未命名的私有基类对象
使用基类的成员变量或成员函数,可以使用强制类型转换

6.4 抽象基类

对于概念类似但是实现方法差别很大的两个类,用**抽象基类(Abstract Base Class, ABC)**作为它们的基类以实现多态。

语法规则:

  • 抽象基类中至少要包含一个纯虚函数
  • 由于包含纯虚函数,不能创建抽象基类的实例。

纯虚函数:

// 在虚函数声明结尾加上“= 0”即可
virtual void show() const = 0;

抽象基类的存在只是为了描述派生类的共同特点(接口),为此纯虚函数可以没有定义。但C++中也允许定义纯虚函数

ABC理念

  • 只应该将不会被用作基类的设计为具体的类(与抽象基类相对)。
  • 派生类应实现新函数覆盖纯虚函数。

6.5 类继承设计细节

6.5.1 不能继承的函数

  • 构造函数:特征标不同,正确的做法是在派生类初始化列表中初始化基类。
  • 析构函数:特征标不同,派生类的析构函数会自动调用基类析构函数。
  • operator=:返回值和参数类型不同。

6.5.2 在派生类中调用基类成员变量或成员函数

想要有两种方式:

  • 将派生类指针/引用强制转换为基类指针/引用。
  • 给函数调用附上作用域解析。
// 将派生类指针/引用**强制转换**为基类指针/引用。
// 这种方法常在友元函数中使用。
std::ostream & operator<<(std::ostream & os, const child & rs)
{
    os << (const base &)rs;
    return os;
}
// 给函数调用附上作用域解析。
// 这种方法常在成员函数中使用。
child & child::operator=(const child & rs)
{
    base::operator=(rs);
    return *this;
}

6.6 多重继承(MI)

使用多重继承会出现问题。

6.6.1 MI与向上类型转换

class worker
{
    // ...
};
class singer : public worker
{
    // ...
};
class waiter : public worker
{
    // ...
};
class singerwaiter : public singer, public waiter
{
    // ...
};
singerwaiter ed;
worker *pw = &ed;

虽然看起来没问题,但其实这样会出现二义性(ambiguous),因为singerwaiter对象分别从singer对象和waiter对象继承了一个waiter对象。
一种做法是显式指出使用从谁那里继承来的waiter对象。

singerwaiter ed;
worker *pw = (singer *)&ed;

先由(singer *)&ed&ed转换为singer *类型,再在赋值时隐式地将singer *类型转换为worker *类型。

这样虽然可以解决问题,但是将多态(用基类指针/引用可以指向不同的对象)复杂化了。
下面介绍另外一种方法。

6.6.2 虚基类

中间派生类singerwaiter)声明时为基类(worker)加上关键字virtual可以保证在最终派生类(singerwaiter)中只继承一个基类。

class worker
{
    // ...
};
class singer : virtual public worker    // 将worker声明为singer的虚基类
{
    // ...
};
class waiter : public virtual worker    // 将worker声明为waiter的虚基类
{
    // ...
};
class singerwaiter : public singer, public waiter
{
    // ...
};

可能会有这样的疑问:

  • 为什么要用关键字virtual
  • 为什么不将虚基类设为默认准则?
  • 这样是否会产生问题?

虚函数与虚基类之间的行为并不是很相似,使用virtual只是挑了个比较好的已有关键字,避免引入新的关键字。

虚基类要求进行额外的运算,会增加开销,为了不需要的的工具付出额外的代价,这种事应该尽量避免。而且有时确实需要在最终的派生类中包含多个基类。

的确会产生问题。若这样做,在初始化最终派生类时将通过多个中间派生类将基类所需输出传递给基类的构造函数,但是虚基类只有一个。
为了避免这种冲突,C++不会通过中间派生类初始化基类。若不想使用基类的默认构造函数,需另外指出。
要注意的是,这种语法能对虚基类使用,对于非虚基类,这是非法的

waiter::waiter(const worker& wk, int p) : worker(wk)
{
    // ...
}
singer::singer(const worker& wk, int v) : worker(wk)
{
    // ...
}
singwewaiter::singerwaiter(const worker& wk, int v, int p) : worker(wk), singer(wk, v), waiter(wk, p){}
singerwaiter(const singer &s, const waiter &w) : worker(s), singer(s), waiter(w){}

完整的测试代码如下:

class worker
{
    int w;
public:
    worker(int n){ w = n; }
    worker(const worker &wk){ w = wk.w; }
};
class singer : virtual public worker    // 将worker声明为singer的虚基类
{
    int v;
public:
    singer(const worker &wk, int v) : worker(wk){ this->v = v; }
};
class waiter : public virtual worker    // 将worker声明为waiter的虚基类
{
    int p;
public:
    waiter(const worker &wk, int p) : worker(wk){ this->p = p; }
};
class singerwaiter : public singer, public waiter
{
public:
    singerwaiter(const worker &wk, int v, int p) : worker(wk), singer(wk, v), waiter(wk, p){}
    singerwaiter(const singer &s, const waiter &w) : worker(s), singer(s), waiter(w){}
};

int main()
{
    worker john(10);
    singer tom(john, 6);
    waiter smith(john, 14);
    singerwaiter sam(john, 6, 14);
    singerwaiter jack(tom, smith);
}

6.6.3 间接派生类的方法的选择

如果没有在派生类中重新定义函数,则会调用基类的函数。但是现在有两个间接派生类作为基类。
为了说明要使用哪个函数,可以通过作用域解析运算符指出:

singerwaiter sam(john, 6, 14);
sam.singer::work();
sam.waiter::work();

更好的做法是在最终派生类中重新定义每一个可能发生冲突的函数(除非想用以上方式使用不同函数)(这种做法也允许选择性地使用不同函数)。

6.6.4 关于重复的问题

如果singerwaiter类中的work()函数都调用了workerwork()函数,而在singerwaiter类中的work()函数又分别调用了singerwaiter类中的work()函数。那么singerwaiter类就要重复进行两次workerwork()。一般·情况下,这显然是不合理的。

为避免这个问题,一种方法是间接派生类的函数中额外提供一个只进行间接派生类中独有的工作的函数(这里就要用到protected函数了)。
另一种方法是将基类和间接派生类中的成员变量都设置为protected。不过用第一种方法可以更严格地控制对数据的访问。

6.6.5 MI的顺序问题

注意这里的设计并不是严格按照is-a关系的,这样只是为了方便。

#include <string>
#include <iostream>
using namespace std;

class Father{
    string name;
public:
    Father(string s) : name(s) {}
    ~Father() { cout << 'F'; }
};

class Mother{
    string name;
public:
    Mother(string s) : name(s) {}
    ~Mother() { cout << 'M'; }
};

class Child : public Mother, public Father{
    string name;
    int age;
public:
    Child(string f, string m, string c, int a) : Mother(m), Father(f), name(c), age(a) {}
    ~Child() {cout << 'C'; }
};

class Girl : public Father, public Mother{
    Child child;
    string name;
    int age;
public:
    Girl(string f, string m, string c, int a, string cf, string cm, string cc, int ca)
        : Father(f), Mother(m), name(c), age(a), child(cf, cm, cc, ca){}
    ~Girl() {cout << 'G'; }
};

int main(){
    {Child grandson("Zhang", "Li", "Ming", 3);}
    cout << endl;
    {Girl daughter("Zhao", "Liu", "Li", 26, "Zhang", "Li", "Ming", 3);}
    return 0;
}
CFM
GCFMMF

GCFMMF中看出,Girl对象daughter先调用自己的析构函数,然后销毁成员,调用成员Child对象grandson的析构函数,最后调用自己的基类FatherMother的构造函数。
另外,grandsondaughter的构造函数的初始化列表初始化基类的顺序相同继承顺序不同,而grandson结尾是FMdaughter的却是MF

继承与析构函数调用顺序:

  1. 自己的析构函数
  2. 成员函数的析构函数
  3. 基类的析构函数
    • 与初始化列表中的顺序无关
    • 与继承的“列表中的顺序相反”
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章