[C++ Primer] : 第15章: 面向对象程序设计
OOP: 概述
面向对象程序设计的核心思想是数据抽象, 继承和动态绑定. 通过数据抽象, 我们可以实现类的接口与实现的分离; 使用继承, 可以定义相似的类型并对其相似关系建模; 使用动态绑定, 可以在一定程度上忽略相似类型的区别, 而以统一的方式使用它们的对象.
继承
基类负责定义在层次关系中所有类共同拥有的成员, 而每个派生类定义了各自特有的成员.
虚函数: 对于某些函数, 基类希望它的派生类定义适合自身的版本, 此时基类就将这些函数声明成虚函数.
类派生列表: 派生类必须通过派生类列表指出它是从哪个(那些)基类继承而来的. 派生列表的形式是: 首先是一个冒号, 后面紧跟以逗号分隔的基类列表, 每个基类前面可以有访问说明符.
派生类必须在其内部对所有重新定义的虚函数进行声明. 派生类可以在这样的函数之前加上virtual, 也可以不加. C++11允许派生类显式地用override关键字注明它将使用那个成员函数改写成基类的虚函数.
动态绑定
动态绑定是指程序在运行时确定虚函数的调用版本.
函数的运行版本由实参决定, 即在运行时选择函数的版本, 所以动态绑定有时又被称为运行时绑定.
在C++语言中, 当我们使用基类的引用或指针调用一个虚函数时将发生动态绑定. 基类和派生类均有各自版本的虚函数的定义, 而且由于存在从派生类向基类的类型转换, 导致传入一个派生类的指针或引用也可行, 它会转换为基类的引用或指针, 这导致了动态绑定.
double print_total(ostream &os, const Quote &item, size_t n)
{
//根据传入item形参的对象类型调用Quote::net_price还是Bulk_quote::net_price
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() << " # sold: " << n << " total due : " << ret << endl;
return ret;
}
// basic的类型是Quote, bulk的类型是Bulk_quote
print_total(cout, basic, 20); // 调用Quote的net_price
print_total(cout, bulk, 20); // 调用Bulk_quote的net_price
定义基类和派生类
15.2.1 定义基类
class Quote
{
public:
Quote() = default;
Quote(const std::string &book, double sales_price): bookNo(book), price(sales_price) { }
std::string isbn() const { return bookNo; };
virtual double net_price(std::size_t n) const { return n * price; }
virtual ~Quote() = default; // 对析构函数进行动态绑定
protected:
double price = 0.0; // 普通状态下不打折的价格
private:
std::string bookNo; // 书籍的isbn编号
};
基类通常都应该定义一个虚析构函数, 即使该函数不执行任何实际操作也是如此.
派生类可以继承其基类的成员, 然而遇到如net_price这种与类型相关的操作时, 派生类必须对其重新定义. 派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义.
基类必须把两种成员函数区分开来: 一种是基类希望其派生类进行覆盖的函数, 另一种是基类希望派生类直接继承而不要改变的函数. 对于前者, 基类通常将其定义为虚函数.
关键字virtual只能出现在类内部的声明语句之前而不能用于类外部函数的定义. 如果基类把一个函数声明成虚函数, 则它在派生类中隐式地也是虚函数.
成员函数如果不是虚函数, 则其解析过程发生在编译时而非运行时.
派生类可以继承定义在基类中的成员, 但是派生类的成员函数却不一定有权访问从基类继承而来的成员. 派生类也是基类的用户, 和其他用户一样, 派生类可以访问公有成员, 但不能访问私有成员. 如果基类希望它的派生类有权访问该成员, 同时禁止其他用户访问, 可用受保护的(protected)访问运算符说明这样的成员.
15.2.2 定义派生类
派生类必须通过派生类列表指出它是从哪个(那些)基类继承而来的. 派生列表的形式是: 首先是一个冒号, 后面紧跟分隔的基类列表, 其中每个基类前面可以有以下三种访问说明符中的一个: public, protected或者private.
派生类必须将其继承而来的成员函数中的需要覆盖(override)那些虚函数重新声明.
class Bulk_quote : public Quote
{
public:
Bulk_quote() = default;
Bulk_quote(const std::string, double, std::size_t, double);
// 覆盖基类的函数版本以实现基于大量购买的折扣政策
double net_price(std::size_t) const override;
private:
std::size_t min_qty; // 适用于折扣政策的最低购买量
double discount = 0.0; // 以小数表示的折扣额
};
如果一个派生是公有的, 则基类的公有成员也是派生类接口的组成部分. 此外, 我们能将公有派生类型的对象绑定到基类的引用或指针上(注意条件是public继承).
派生类经常需要(但不总是)覆盖它继承的虚函数. 如果派生类没有覆盖其基类中的某个虚函数, 则该虚函数的行为类似于其它的普通函数, 派生类会直接继承其在基类中的版本.
C++11新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数, 方法是加上override关键字.
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分: 一个含有派生类自己定义的(非静态)成员的子对象, 以及一个与该派生类继承的基类对应的子对象, 如果有多个基类, 那么这样的子对象有多个.
C++并没有明确规定派生类的对象在内存中如何分布. 继承自基类的部分和派生类自定义的部分不一定是连续存储的.
派生类对象中含有与其基类对应的组成部分, 这一事实是继承的关键所在. 也正是因为如此我们能把派生类的对象当成基类来使用, 而且也能将基类的指针或引用绑定到派生类对象中的基类部分上. 这种转换通常称为派生类到基类的类型转换, 编译器会隐式执行该转换.
Quote item; // 基类对象
Bulk_quote bulk; // 派生类对象
Quote *p = &item; // p指向Quote对象
p = &bulk; // p指向bulk中的Quote部分
Quote &r = bulk; // r绑定到bulk中的Quote部分
派生类构造函数
派生类必须使用基类的构造函数来初始化它的基类部分, 而不能直接初始化这些成员.
每个类控制它自己的成员的初始化过程.
派生类的构造函数是通过构造函数初始化列表来将实参传递给基类构造函数的.
Bulk_quote(const std::string &book, double price, std::size_t qty, double disc): Quote(book, price), min_qty(qty), discount(disc){ }
除非特别指出, 否则派生类对象的基类部分会像数据成员一样执行默认初始化.
首先初始化基类的部分, 然后按照声明的顺序依次初始化派生类的成员.
派生类使用基类的成员
派生类成员可以访问基类的公有成员和受保护的成员
double Bulk_quote::net_price(std::size_t cnt) const
{
if(cnt >= min_qty)
return cnt * (1 - discount) * price;
else
return cnt * price; // 可以直接访问基类protected成员price
}
关键概念: 遵循基类的接口
必须明确一点: 每个类负责定义各自的接口. 派生类也是基类的用户, 要想与类的对象交互必须像所有用户一样使用该类的接口, 即使这个对象是派生类的基类部分也是如此.
因此派生类对象不能直接初始化基类的成员, 尽管从语法上来讲我们可以在派生类构造函数的函数体内给它的公有或受保护的成员赋值, 但最好不要这样做. 和使用基类的其他场合一样, 派生类应该遵循基类的接口, 并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员.
继承与静态成员
如果基类定义了一个静态成员, 则在整个继承体系中只存在该成员的唯一定义. 不论从基类派生出来多少个派生类, 对于每一个静态成员来说都只存在唯一的实例.
静态成员遵循通用的访问控制规则, 如果基类中的成员是private的, 则派生类无权访问它. 如果是静态成员是可访问的, 则我们既可以通过基类使用它, 也能通过派生类来使用它. 如:
class Base{
public:
static void statmem();
};
class Derived : public Base {
void f(const Derived &);
};
void Derived::f(const Derived &obj)
{
Base::statmem(); // 正确, Base定义了statmem
Derived::statmem(); // 正确, Derived继承了statmem并且statmem是可访问的
obj.statmem(); // 通过Derived对象来访问
statmem(); // 通过this对象来访问
}
派生类的声明
声明中包含类名, 但是不能包含它的派生列表. 声明与定义分开, 定义时需要加上派生列表.
声明语句的目的在于令程序知晓某个名字的存在, 以及该名字表示一个什么样的实体, 如一个类, 一个函数或一个变量等. 派生列表以及与定义有关的其他细节必须与类的主体一起出现.
class Bulk_quote : public Quote; // 错误: 派生列表不能出现在这里
class Bulk_quote; // 声明派生类的正确方式
被用作基类的类
如果想将一个类用作基类, 则该类必须已经定义而非仅仅声明.
class Quote; // 声明但未定义
class Bulk_quote : public Quote {...}; // 错误, Quote必须被定义
其原因显而易见: 派生类包含并且可以使用它从基类中继承而来的成员, 为了使用这些成员, 派生类当然需要知道它们是什么.因此该规定还有一层隐含的意思: 一个类不能用来派生它自己.
一个类是基类, 同时它也可以是一个派生类:
class Base { /*.....*/ };
class D1: public Base {/*......*/};
class D2: public D1 { /*.....*/ }
Base是D1的直接基类, 同时是D2的间接基类.
防止继承的发生
C++11新标准提供了一种继承发生的方法, 即在类后跟一个关键字final.
class NoDerived final {/* */} // NoDerived不能作为基类
class Last final : public Base {/* */} // Last不能作为基类
15.2.3 类型转换与继承
理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在.
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义: 当使用基类的引用或指针时, 实际上我们并不清楚该引用或指针所绑定的对象的真实类型. 该对象可以是基类的对象, 也可能是派生类的对象.
智能指针也支持派生类向基类的类型转换, 这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内.
静态类型与动态类型
表达式的静态类型在编译时总是已知的, 它是变量声明时的类型或表达式生成的类型.
动态类型则是变量或表达式表示的内存中的对象的类型. 动态类型直到运行时才可知.
如果表达式既不是指针也不是引用, 则它的动态类型永远与静态类型一致.
基类的指针或引用的静态类型可能与其动态类型不一致, 一定要理解其中的原因.
即使静态类型与动态类型可能不一致, 但是我们能使用那些成员仍然是由静态类型决定的.
不存在从基类向派生类的隐式类型转换... ...
因为一个基类的对象可能是派生类对象的一部分(通过继承), 也可能不是(基类对象独立存在), 所以不存在从基类向派生类的自动类型转换. 如
Quote base;
Bulk_quote *bulkp = &base; // 错误, 不能将基类转换成派生类
Bulk_quote &bulkRef = base; // 错误
如果上述赋值合法, 则我们可能会使用bulkp或bulkRef访问base中本不存在的成员.
还有一种情况显得比较特别: 即使一个基类指针或引用绑定到一个派生类对象上, 我们也不能执行从基类向派生类的转换.
Bulk_quote bulk;
Quote *itemP = &bulk; // 正确: itemP的静态类型是Quote, 动态类型是Bulk_quote, 存在派生类向基类的隐式转换.
Bulk_quote *bulkP = itemP; // 错误, 不能将基类转换成派生类. 静态类型不匹配
Bulk_quote *bulkP = dynamic_cast<Bulk_quote *>(itemP); // 正确, 运行时检查
Bulk_quote *bulkP = static_cast<Bulk_quote *>(itemP); // 正确, 编译时检查
编译器在编译时无法确定某个特定的转换在运行时是否安全, 这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法.
如果在基类中含有一个或多个虚函数, 我们可以使用dynamic_cast请求一个类型转换, 该转换的安全检查将在运行时执行. 同样如果我们已知某个基类向派生类的转换是安全的, 则我们可以使用static_cast来强制覆盖掉编译器的检查工作.
... ...在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用类型有效, 在派生类类型和基类类型之间不存在这样的转换.
注意: 拷贝构造函数, 拷贝赋值运算符均接受一个类类型的const版本的引用. 因为这些成员接受引用作为参数, 所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类对象, 这些操作不是虚函数. 当我们给基类的构造函数传递一个派生类对象时, 实际运行的构造函数时基类的构造函数, 显然该构造函数只能处理基类自己的成员. 类似的, 如果我们将一个派生类对象赋值给一个基类对象, 则实际运行的赋值运算符也是基类中的拷贝赋值运算符, 该运算符同样只能处理基类自己的成员.
Bulk_quote bulk; // 派生类对象
Quote item(bulk); // 使用Quote::Quote(const Quote&)拷贝构造函数
item = bulk; // 使用Quote::operator=(const Quote&)
在这个过程中, 会忽略掉派生类对象中派生类的那一部分, 而只有基类部分会被拷贝, 赋值和移动, 所以可以说派生类对象中的派生类部分被切掉了.
当我们用一个派生类对象为一个基类对象初始化或赋值时, 只有该派生类对象中的基类部分会被拷贝, 移动或赋值, 它的派生类部分将被忽略掉.
关键概念: 存在继承关系的类型之间的转换规则
- 从派生类向基类的转换只对指针或引用类型有效.
- 基类向派生类不存在隐式类型转换.
- 和任何其他成员一样, 派生类向基类的转换也可能会由于访问受限而变得不可行(必须为公有继承).
尽管自动类型转换只对指针或引用类型有效, 但是继承体系中, 绝大多数类仍然(显式或隐式的)定义了拷贝控制成员. 因此, 我们能够将一个派生类对象拷贝, 赋值或移动给一个基类对象, 不过需要注意的是, 这种操作只处理派生类对象中的基类部分.
虚函数
动态绑定是指在运行时确定虚函数函数的调用版本. 要想实现动态绑定必须要通过指针或引用来调用一个虚成员函数, 因为当使用指针或引用会发生隐式的类型转换, 这使得一个指向派生类的指针或引用可以指向一个基类的对象, 而基类和派生类中的虚函数是不同的版本, 这便出现了动态绑定, 在运行时确定调用的函数版本.
当我们通过一个普通类型的表达式(非引用非指针)调用虚函数时, 则在编译时就已经确定了将要调用的虚函数的版本.
由于我们直到运行时才能直到到底调用了那个版本的虚函数, 所以所有的虚函数都必须有定义. 通常如果一个函数没有用到可以不用定义, 但是虚函数则不同, 每一个虚函数都必须有定义, 因为编译器也不知道到底会使用那个版本的虚函数.
面向对象(OOP)的核心思想是多态性. 我们把具有继承关系的多个类型称为多态类型, 因为我们能使用这些类型的多种形式而无须在意他们的差异. 引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在.
对非虚函数的调用在编译时进行绑定, 通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定, 对象的类型是确定不变的, 无论如何也不能令对象的动态类型和其静态类型不一致,
因此通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上.
当且仅当对通过指针或引用调用虚函数时, 才会在运行时解析该调用, 也只有在这种情况下对象的动态类型才有可能与静态类型不一致.
派生类中的虚函数
派生类中可以用virtual关键字指出该函数的性质, 也可不用. 一旦某个函数被声明成虚函数, 则在所有派生类中它都是虚函数.
派生类如果覆盖了某个继承而来的虚函数, 则它的形参类型必须与被覆盖的基类函数完全一致. 通常来说返回类型也要一样, 但是有一个例外: 当虚函数的返回类型是类本身的指针或引用时, 上述规则无效. 但是要求从派生类向基类的类型转换是可访问的. 该情况即如下面的代码所示:
class Base
{
public:
virtual ~Base() {};
virtual Base* func()
{
cout << "Base's virtual function" << endl;
return this;
}
};
class Derived : public Base
{
public:
virtual ~Derived() {};
virtual Derived* func()
{
cout << "Derived's virtual function" << endl;
return this;
}
};
Derived d;
Base *pb = &d;
Base *pret = pb->func(); // 调用子类的func函数, 返回值是父类指针类型, 但实质是子类指针类型
final和override说明符
派生类如果定义一个函数与基类中的虚函数的名字相同但是形参列表不同, 这仍然是合法的行为. 这时派生类的函数并没有覆盖掉基类中的版本, 这往往意味着发生了错误, 因为我们原本希望覆盖掉基类中的虚函数, 却不小心把形参列表写错了. 这可用override关键字来预防.
如果我们使用override关键字标记某个函数, 而该函数并没有覆盖掉已存在的虚函数, 此时编译器会报错.
还可以把函数定义成final, 则之后任何尝试覆盖掉该函数的操作都将引发错误.
final和override出现在形参列表(包括任何const和引用修饰符)以及尾置返回类型之后.
虚函数与默认实参
虚函数也可以拥有默认实参, 如果某次函数调用使用了默认实参, 则该实参值由本次调用的静态类型决定. 即如果我们通过基类的引用或指针调用函数, 则使用基类中定义的默认实参,即使运行的是派生类中的函数版本也是如此.
如果虚函数使用默认实参, 则基类和派生类中定义的默认实参最好一致.
class Base
{
public:
virtual void print(int x = 10)
{ cout << "base " << x << endl; }
virtual ~Base() {};
};
class Derived : public Base
{
public:
virtual void print(int x = 20)
{ cout << "derived " << x << endl; }
virtual ~Derived() {};
};
Derived d;
Base *pb = &d;
pb->print(); // 输出derived 10, 调用子类的虚函数, 使用的却是父类的默认实参.
回避虚函数的机制
使用作用域运算符可以指定特定的虚函数版本.
Derived d;
Base *pb = &d;
pb->Base::print(); // 强制调用基类中定义的函数版本而不管pb的动态类型到底是什么. 该调用将在编译时完成解析.
通常情况下, 只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制.
什么时候需要回避虚函数机制呢?通常是当一个派生类中的虚函数调用被它覆盖掉的基类的虚函数版本时. 这种情况下, 基类的版本通常完成继承层次中所有类型都要做的共同任务,
而派生类中定义的版本需要执行一些与派生类本身密切相关的操作.
如果一个派生类虚函数需要调用它的基类版本, 但是又没有使用作用域运算符, 则在运行时该调用将被解析为对派生类自身的调用, 从而导致无限递归.
抽象基类
在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数声明为纯虚函数, 其中=0只能出现在类内部的虚函数声明语句处. 和普通虚函数不一样, 纯虚函数无须定义.
也可以为纯虚函数提供定义, 不过必须在类的外部, 不能在类的内部为一个=0的函数提供函数体.
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类, 抽象基类负责定义接口, 而后续的其他类可以覆盖该接口. 不能创建抽象基类的对象.
可以定义抽象基类的派生类对象, 前提是派生类覆盖了纯虚函数, 若未覆盖, 则派生类仍然是抽象基类.
关键概念: 重构
重新设计程序以便将一些相关的部分搜集到一个单独的抽象中, 然后使用新的抽象替换原来的代码. 通常情况下, 重构类的方式是将数据成员和函数成员移动到继承体系的高级别节点当中, 从而避免代码冗余.
在Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例. 重构负责重新设计类的体系以便将操作/或数据从一个类移动到另一个类中. 对于面向对象的应用程序来说, 重构是一种很普遍的现象.
值得注意的是, 即使我们改变了整个继承体系, 那些使用了Bulk_quote或Quote的代码也无需进行任何改动. 不过一旦类被重构, 就意味着我们必须重新编译含有这些类的代码了.
// 代码重构前
class Quote
{
public:
Quote() = default;
Quote(const std::string &book, double sales_price): bookNo(book), price(sales_price) { }
std::string isbn() const { return bookNo; };
virtual double net_price(std::size_t n) const { return n * price; }
virtual ~Quote() = default; // 对析构函数进行动态绑定
protected:
double price = 0.0; // 普通状态下不打折的价格
private:
std::string bookNo; // 书籍的isbn编号
};
class Bulk_quote : public Quote
{
public:
Bulk_quote() = default;
Bulk_quote(const std::string &book, double price, std::size_t qty, double disc): Quote(book, price), min_qty(qty), discount(disc){ }
// 覆盖基类的函数版本以实现基于大量购买的折扣政策
double net_price(std::size_t) const override;
private:
std::size_t min_qty; // 适用于折扣政策的最低购买量
double discount = 0.0; // 以小数表示的折扣额
};
// 重构后
class Quote
{
public:
Quote() = default;
Quote(const std::string &book, double sales_price): bookNo(book), price(sales_price) { }
std::string isbn() const { return bookNo; };
virtual double net_price(std::size_t n) const { return n * price; }
virtual ~Quote() = default; // 对析构函数进行动态绑定
protected:
double price = 0.0; // 普通状态下不打折的价格
private:
std::string bookNo; // 书籍的isbn编号
};
// 用来支持不同的折扣策略
class Disc_quote : public Quote
{
public:
Disc_quote() = default;
Disc_quote(const std::string &book, double price, std::size_t qty, double disc): Quote(book, price), min_qty(qty), discount(disc){ }
virtual double net_price(std::size_t n) const = 0; // 纯虚函数
protected:
std::size_t min_qty; // 适用于折扣政策的最低购买量
double discount = 0.0; // 以小数表示的折扣额
};
// 某一种折扣策略, 可能存在多种折扣策略, 每种折扣策略实质是net_price()不同
class Bulk_quote : public Disc_quote
{
public:
Bulk_quote() = default;
Bulk_quote(const std::string &book, double price, std::size_t qty, double disc): Disc_quote(book, price, qty, disc){ }
// 覆盖基类的函数版本以实现基于大量购买的折扣政策
double net_price(std::size_t) const override;
};
访问控制与继承
受保护的成员:
- 与私有成员类似, 对于类的用户来说不可访问.
- 与公有成员类似, 对于派生类的成员和友元来说是可访问的.
- 派生类中的成员或友元只能通过派生类对象来访问基类的受保护成员, 派生类对于一个基类对象中的受保护成员没有任何访问特权. 即派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员, 对于普通的基类对象中的成员不具有特殊的访问权限.
class A {
protectd:
int val;
};
class B : public A {
friend func(B&); // 可以访问B::val
friend func(A&); // 不能访问A::val
int i; // i默认是private
};
void func(B &b) // 正确, func可以访问B对象的protected和private成员.
{
b.j = b.val = 0;
}
void func(A &a) // 错误, func不能访问A对象(其基类对象)的protected成员.
{
a.val = 0;
}
如果派生类(及其友元)能够访问基类对象中的受保护成员, 那么上面第二个func函数将是合法的, 该函数不是A的友元, 却能够改变一个A对象的内容, 照这样的思路, 只需要定义一个A的派生类就可以规避protected提供的访问保护了.
公有, 私有和受保护继承:
某个类对其继承而来的成员的访问权限受到两个因素影响: 1.在基类中该成员的访问说明符. 2.在派生类的派生列表中的访问说明符(派生访问说明符).
派生访问说明符对于派生类的成员(及其友元)能否访问其直接基类的成员没有什么影响, 对基类成员的访问权限只与基类中的访问说明符有关.
派生访问说明符的目的是控制派生类的用户(包括派生类的派生类在内)对于基类成员的访问权限.
public继承: 对于派生类的用户来说, 对基类成员的访问遵循基类中的访问说明符, 即公有继承不改变基类成员的访问权限.
protected继承: 对于派生类的用户来说, 基类的public和protected成员是受保护的.
private继承: 对于派生类的用户来说, 基类的所有成员均为private.
派生访问说明符限制了派生类用户(包括派生类的派生类)对于派生类中基类部分的最大访问权限.
私有成员只有类自身可以访问, 类用户不可访问, 派生类也算是类的用户.
如果基类声明了私有成员, 那么任何派生类都是不能访问它们的, 若希望在派生类中能访问它们, 应当把它们声明为保护成员. 如果在一个类中声明了保护成员, 就意味着该类可能要用作基类, 在它的派生类中会访问这些成员.
在定义一个派生类时将基类的继承方式指定为protected的, 称为保护继承, 用保护继承方式建立的派生类称为保护派生类(protected derived class), 其基类称为受保护的基类(protected base class), 简称保护基类.
保护继承的特点是: 保护基类的公用成员和保护成员在派生类中都成了保护成员, 其私有成员仍为基类私有. 也就是把基类原有的公用成员也保护起来, 不让类外任意访问.
派生类向基类转换的可访问性:
派生类向基类的转换是否可访问由使用该转换的代码决定, 同时派生类的派生访问说明符也会有影响. 假定D继承自B:
- 只有公有继承时, 用户代码才能使用派生类向基类的转换. 私有和受保护继承均不能.
- 不论D以什么方式继承B, D的成员函数和友元均能使用派生类向基类的转换, 派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的.
- 如果D继承B的方式是public和protected, 则D的派生类的成员和友元可以使用D向B的类型转换, 如果是private继承, 则不能使用.
对弈代码中的某个给定节点来说, 如果基类的共有成员是可访问的, 则派生类向基类的类型转换也是可访问的. 反之则不行.
可以认为类有3种不同的用户: 类的实现者, 普通用户和派生类.
普通用户只能访问类的公有(接口)成员.
类的实现者则负责编写类的成员和友元的代码, 成员和友元既能访问类的公有部分, 也能访问类的私有部分.
派生类可以访问公有和受保护的成员.
基类应该将其接口成员声明为公有的; 同时将实现部分分为两组: 一组可供派生类访问, 声明为protected, 另一组只能由基类及基类的友元访问, 声明为private.
友元与继承
友元关系不能传递, 也不能继承, 基类的友元在访问派生类成员时不具有特殊性. 每个类负责控制各自成员的访问权限.
使用using声明改变个别成员的可访问性:
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
// 注意是private, 对于派生类的用户来说, 派生类的基类部分的所有成员都是不可访问的. 基类部分是派生类的私有成员.
class Derived : private Base {
public:
using Base::size; // 保持size()成员的的public访问级别, 注意不写括号
protected:
using Base::n;
};
通过使用using声明语句, 可以将该类的直接或间接基类中的任何可访问成员标记出来. using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定.
派生类只能为那些它可以访问的名字提供using声明.
如果没有指明派生访问说明符, 则默认继承保护级别与派生类所使用的关键字是class还是struct有关.
默认情况下, 使用class关键字定义的派生类是私有继承的, 而使用struct关键字定义的派生类是公有继承的.
class Base {};
struct D1 : Base {}; // 默认public继承
class D2 : Base {}; // 默认private继承
继承中的类作用域
派生类的作用域嵌套在其基类的作用域之内, 如果一个名字在派生类的作用域内无法正确解析, 则编译器将继续在外层的基类作用域中寻找该名字的定义.
即使静态类型与动态类型可能不一致, 但是我们能使用那些成员仍然是由静态类型决定的.
静态类型与动态类型不一致虽然会导致动态绑定, 但是其调用的成员函数却是一致的, 都是虚函数, 只不过是在父类和子类中的不同版本.
class B : public A{
public:
void funcb();
};
B b;
B * bp = &b; //静态类型与动态类型一致
A * ap = &b; //静态类型与动态类型不一致, B类型的对象隐式转换成A类型的对象, 派生类对象隐式转换成基类对象.
bp->funcb(); //正确, bp的类型是B*
ap->funcb(); //错误, ap的类型是A*
尽管b中确实存在funcb的成员, 但是该成员对于ap来说却是不可见的, ap的类型是A的指针, 意味着对funcb的搜索将从A开始, 显然A不包含funcb成员, 而且A是基类, B是派生类, A的作用域范围在B之外, 不会去搜索B内的作用域.
内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字. 派生类的成员将隐藏同名的基类成员.
可以通过作用域运算符来使用被隐藏的基类成员. 类名::变量名
除了覆盖继承而来的虚函数之外, 派生类最好不要重用其他定义在基类中的名字.
关键概念: 名字查找和继承(假设调用p->mem()):
- 首先确定p的静态类型.
- 名字查找: 在p的静态类型对应的类中查找mem. 如果找不到, 则依次在直接基类中不断查找直至到达继承链的顶端, 如果找遍了该类及其基类仍然找不到, 则编译器报错.
- 类型检查: 一旦找到了mem, 就进行常规的类型检查(参数对不对等等)以确认对于当前找到的mem, 本次调用是否合法.
- 假设调用合法, 则编译器将根据调用的是否是虚函数而产生不同的代码:
- 如果mem是虚函数且我们是通过引用或指针进行的调用, 则编译器产生的代码将在运行时确定到底运行该虚函数的那个版本, 依据的是对象的动态类型.
- 反之如果mem不是虚函数或者我们通过对象(而非引用或指针)进行的调用, 则编译器将产生一个常规的函数调用.
如果是虚函数且通过引用或指针调用后, 则看其动态类型, 如果动态类型是派生类, 则看派生类有没有覆盖从基类继承而来的虚函数, 如果有覆盖, 则调用派生类中的虚函数的版本, 如果没有覆盖, 那么调用直接从基类继承而来的虚函数的版本.
名字查找先于类型检查. 如果派生类的成员与基类的某个成员同名, 则派生类将在其作用域内隐藏该基类成员, 即使派生类成员和基类成员的形参列表不一致, 基类成员仍然会被隐藏掉.
struct Base {
int memfcn();
};
struct Derived : Base {
int memfcn(int); // 隐藏基类memfcn, 非重载
};
Derived d;
d.memfcn(); // 错误, 参数列表为空的memfcn被隐藏掉了
d.Base::memfcn(); // 正确
虚函数与作用域:
声明在内层作用域的函数不会重载声明在外层作用域的函数. 因此定义在派生类中的函数也不会重载其基类中的成员函数, 而是会隐藏掉.
class Base{
public:
virtual int fcn();
};
class D1 : public Base{
public:
//隐藏基类的fcn, 这个fcn不是虚函数.
//D1继承了Base::fcn()的定义
int fcn(int); //形参列表与Base中的fcn不一致
virtual void f2(); //是一个新的虚函数, 在Base中不存在
};
class D2 : public D1 {
public:
int fcn(int); //是一个非虚函数, 隐藏了D1::fcn(int)
int fcn(); //覆盖了Base中的虚函数fcn()
void f2(); //覆盖了D1的虚函数f2
};
Base bobj; D1 d1obj; D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); //虚调用, 将在运行时调用Base::fcn. bp1的静态类型和动态类型一致
bp2->fcn(); //虚调用, 将在运行时调用Base::fcn. D1没有覆盖掉基类中的虚函数, 而是直接从基类继承而来
bp3->fcn(); //虚调用, 将在运行时调用D2::fcn. D2覆盖了基类中的虚函数版本.
D1 *d1p = &d1obj;
D2 *d2p = &d2obj;
bp2->f2(); //错误, bp2的静态类型是Base, Base没有名为f2的成员
d1p->f2(); //虚调用, 将在运行时调用D1::f2()
d2p->f2(); //虚调用, 将在运行时调用D2::f2()
成员函数无论是否是虚函数都能被重载. 派生类可以覆盖重载函数的0个或者多个实例. 如果派生类希望所有重载版本对于它来说都是可见的, 那么它需要覆盖所有的版本, 或者一个也不覆盖.
构造函数与拷贝控制
15.7.1 虚析构函数
继承关系对基类拷贝控制最直接的影响就是基类通常应该定义一个虚析构函数. 这样我们就能动态分配继承体系中的对象了.
当delete一个动态分配的对象的指针是将执行析构函数. 如果该指针指向继承体系中的某个类型, 则有可能出现指针的静态类型与被删除对象的动态类型不符合的现象. 例如delete一个指向Base *的指针, 但是该指针可能指向派生类的对象, 如果析构函数不是虚函数, 则析构的对象时错误的. 和其他函数一样, 我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本.
Quote *itemP = new Quote; // 静态类型与动态类型一致
delete itemP; // 调用Quote的析构函数
itemP = new Bulk_quote; // 静态类型与动态类型不一致, Bulk_quote继承Quote
delete itemP; // 调用Bulk_quote的析构函数
和其他虚函数一样, 析构函数的虚属性也会被继承. 因此无论Quote的派生类使用合成的析构函数还是自己定义的析构函数, 将都是虚析构函数. 只要基类的析构函数是虚函数, 就能确保当我们delete基类指针时将运行正确的析构函数版本.
如果基类的析构函数不是虚函数, 则delete一个指向派生类对象的基类指针将产生未定义行为.
前面讲类拷贝控制时有一个3/5法则, 讲到一个类需要析构函数, 那么它同时也需要拷贝和赋值操作, 这点在基类的虚析构函数上不适用, 它是一个重要的例外.** 一个基类总是需要一个虚析构函数**.
虚析构函数将阻止合成移动操作: 如果一个类定义了析构函数, 即使它通过=default的形式使用了合成的版本, 编译器也不会为这个类合成移动操作. 定义析构函数不能拥有合成的移动操作, 因此当我们移动对象时实际使用的是合成的拷贝操作, 并且它的派生类也没有移动操作.
15.7.2 合成拷贝与控制
class A;
class B : public A;
class C : public B;
C的合成默认构造函数的执行过程: 合成的C的默认构造函数会运行B的默认构造函数, 而B又会运行A的默认构造函数. A的默认构造函数完成后, 继续执行B的构造函数, B的构造函数完成后继续执行C的构造函数.
C的析构函数先执行C的析构, 再执行B的析构, 最后执行A的析构. 与构造的顺序相反.
成员的析构顺序与构造顺序是相反的.
对于派生类来说, 它除了销毁派生类自己的成员之外, 还负责销毁派生类的直接基类, 该直接基类又销毁它自己的直接基类, 一次类推直至继承链的顶端.
派生类中删除的拷贝控制与基类的关系:
- 如果基类中的默认构造函数, 拷贝构造函数, 拷贝赋值运算符或析构函数时被删除的函数或者不可访问, 则派生类中对应的成员将是被删除的, 原因是编译器不能使用基类成员来执行派生类对象基类部分的构造, 赋值或销毁操作.
- 如果在基类中有一个不可访问或者删除掉的析构函数, 则派生类中合成的默认和拷贝构造函数将是被删除的, 因为编译器无法销毁派生类对象的基类部分.
- 编译器将不会合成一个删除掉的移动操作. 当我们使用=default请求一个移动操作时, 如果基类中的对应操作时删除或不可访问的, 则派生类中该函数将是被删除的, 因为派生类对象的基类部分不可移动.
移动操作与继承:
大多数基类都会定义一个虚析构函数, 因此在默认情况下, 基类通常不含有合成的移动操作, 而且在它的派生类中也没有合成的移动操作.
以为基类缺少移动操作会阻止派生类拥有自己的合成移动操作, 所以当我们确实需要移动操作时应该首先在基类中进行定义. 一旦基类定义了自己的移动操作, 那么它必须同时显示的定义拷贝操作.
定义了移动构造函数/移动赋值运算符的类必须定义自己的拷贝操作, 否则, 这些成员默认地定义为删除的.
class Base
{
public:
Base() = default;
Base(const Base&) = default;
Base(Base&&) = default;
Base& operator=(const Base&) = default;
Base& operator=(Base&&) = default;
virtual ~Base() = default;
};
15.7.3 派生类的拷贝控制成员
派生类的构造函数在其初始化阶段不但要初始化派生类自己的成员, 还要负责初始化派生类对象的基类部分. 因此, 派生类的拷贝以及移动构造函数在拷贝和移动自己成员的同时, 也要拷贝和移动基类部分的成员, 类似的, 派生类的赋值运算符也必须为其基类部分的成员赋值.
与构造函数即赋值运算符不同, 析构函数只负责销毁派生类自己分配的资源.
当派生类定义了拷贝或移动操作时, 该操作负责拷贝或移动包括基类部分成员在内的整个对象.
定义派生类的拷贝或移动构造函数
class Base { /*...*/ };
class D : public Base{
public:
// 默认情况下, 基类的默认构造函数初始化对象的基类部分
// 要想使用拷贝或移动构造函数, 我们必须在构造函数初始化列表中显式的调用该构造函数
// 显示的调用构造函数
D(const D &d) : Base(d) // 拷贝基类成员
/*D的成员的初始值*/ { /*...*/ };
D(D &&d) : Base(std::move(d)) // 移动基类成员
/*D的成员的初始值*/ { /*...*/ };
};
Base(D)一般会匹配Base的拷贝构造函数. D类型的对象d将被绑定到该构造函数的Base&形参上. Base的拷贝构造函数负责将d的基类部分拷贝给要创建的对象.
如果没有提供基类的初始值的话:
// D的这个拷贝构造函数很可能不是正确的定义
// 基类部分被默认初始化, 而不是拷贝
D(const D&d) // 成员初始值, 但是没有提供基类初始值
{
/*....*/
}
这个新构建的对象的配置将会非常奇怪: 它的Base成员被赋予了默认值, 而D成员的值则是从其他对象拷贝得来的.
默认情况下, 基类默认构造函数初始化派生类对象的基类部分. 如果我们想拷贝/移动基类部分, 则必须在派生类的构造函数初始值列表中显示的使用基类的拷贝/移动构造函数.
派生类赋值运算符
与拷贝和移动构造函数一样, 派生类的赋值运算符也必须显式地为其基类部分赋值:
// Base::operator=(const Base&)不会自动调用
D &D::operator=(const D &rhs)
{
Base::operator(rhs); // 为基类部分赋值
// 按照过去的方式为派生类成员赋值, 酌情处理自赋值及释放已有资源等情况
return *this;
}
首先显式地调用基类赋值运算符, 令其为派生类对象的基类部分赋值. 随后继续进行其他为派生类成员的赋值工作.
派生类的析构函数
和构造函数及赋值运算符函数不同, 派生类的析构函数只负责销毁有派生类自己分配的资源:
class D : public Base{
public:
// Base::~Base()被自动执行
~D() { /* 该处由用户定义清除派生类成员的操作 */ }
};
对象的销毁顺序正好与其创建顺序相反: 派生类析构函数首先执行, 然后是基类的析构函数, 以此类推, 沿着继承体系的反方向直至最后.
在构造函数和析构函数中调用虚函数
当执行基类的构造函数时, 该对象的派生类部分是未被初始化的状态, 当执行基类的析构函数时, 派生类部分已经被销毁掉了. 由此可知, 当执行上述基类成员的时候, 对象处于未完成的状态.
编译器认为对象的类型在构造或析构的过程中仿佛发生了改变一样(即构造过程中变化为: 空-->基类-->派生类, 析构过程中的变化为: 派生类-->基类-->空). 也就是说, 当我们构建一个对象时, 需要把对象的类和构造函数的类看做是同一个(即当前调用基类的构造函数, 就认为对象的类是基类, 如果当前正在调用派生类的构造函数, 就认为对象时派生类对象); 对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看成同一个的要求; 对于析构函数也是同样的道理. 上述绑定不但对直接调用虚函数有效, 对间接调用也有效, 这里的间接调用指通过构造函数或析构函数来调用另一个函数.
为理解上述行为, 考虑当基类的构造函数调用虚函数的派生类版本时会发生什么情况. 虚函数可能会访问派生类的成员, 毕竟如果它不需要访问派生类成员的话, 则派生类直接使用基类的虚函数版本就行了. 但是当执行基类构造函数时, 他要用到的派生类成员尚未初始化, 如果允许这样的访问, 则程序很可能会崩溃.
如果构造函数或析构函数调用了某个虚函数, 则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本.
15.7.4 继承的构造函数
C++新标准中, 派生类能够重用其直接基类定义的构造函数. 一个类只初始化它的直接基类, 同样, 一个类也只继承其直接基类的构造函数. 类不能继承默认, 拷贝和移动构造函数. 如果派生类没有直接定义这些构造函数, 则编译器将为派生类合成它们.
派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句. 如
class Bulk_quote : pblic Disc_quote{
public:
using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数
/*其他成员*/
};
using声明语句通常是令某个名字在当前作用域内可见, 而当作用于构造函数时, using声明语句将令编译器生成代码. 对于基类的每个构造函数, 编译器都将生成一个与之对应的派生类构造函数, 换句话说, 对于基类的每个构造函数, 编译器都在派生类中生成一个形参列表完全相同的构造函数. 这些编译器生成的构造函数形如:
derived(parms) : base(args) { }
其中derived是派生类的名字, base是基类的名字, parms是构造函数的形参列表, args将派生类构造函数的形参传递给基类的构造函数. 在Bulk_quote类中, 继承的构造函数等价于:
Bulk_quote(const std::string &book, double price, std::size_t qty, double disc): Disc_quote(book, price, qty, disc){ }
继承的构造函数的特点
和普通成员的using声明不一样, 一个构造函数的using声明不会改变该构造函数的访问级别.
当基类构造函数含有默认实参时, 这些实参并不会被继承. 相反, 派生类将获得多个构造函数, 其中每个构造函数分别省略掉一个含有默认实参的形参.
如果基类含有几个默认构造函数, 则除了两种例外情况, 大多数时候派生类会继承所有这些构造函数.
- 派生类可以继承部分构造函数, 而为其他构造函数定义自己的版本. 如果派生类定义的构造函数与基类的构造函数具有相同的参数列表, 则该构造函数不会被继承.
- 默认构造, 拷贝/移动构造函数不会被继承, 这些构造函数按照正常规则被合成. 继承的构造函数不会被作为用户定义的构造函数使用, 因此, 如果一个类只含有继承的构造函数, 则它拥有一个合成的默认构造函数.
容器的继承
我们使用容器存放继承体系中的对象时, 通常必须采用间接存储的方式, 因为不允许在容器中存放不同类型的元素, 所以不能把具有继承关系的多种类型的对象直接存放在容器当中.
如class A 是 class B的父类, 我们想定义一个vector来保存A 和 B. 显然不能有vector来保存B, 因为不能将A对象转换成B, 所以无法将A对象放在vector中. 同样, 也不能用vector 来保存A, 虽然可以把B的对象放在vector中, 但是这些对象只保留的基类部分, 派生类部分被“切割”掉了.
当派生类对象被赋值给基类对象时, 其中的派生类部分将被“切掉”, 因此容器和存在继承关系的类型无法兼容.
在容器中放置指针而非对象
当我们希望在容器中存放具有继承关系的对象时, 我们实际上存放的通常是基类的指针(更好的选择是智能指针). 与以往一样, 这些指针所指的对象的动态类型可能是基类类型, 也可能是派生类类型.
vector<shared_ptr<A>> vec;
vec.push_back(make_shared<A>(args));
vec.push_back(make_shared<B>(args));