C++ Primer CH15 面向对象编程

面向对象变量建立三大概念上:数据抽象(data abstraction)、继承(inheritance)和动态绑定(dynamic binding)。继承和动态绑定在两个方面影响如何写程序:使得定义类似但不相同的类更加容易,使得用户代码可以相同的方式调用它们而忽略其中的差异。

很多应用包含相关但是有略微不同的概念。面向对象编程(OOP)刚好非常适合这种应用。

15.1 面向对象:介绍

面向对象编程(object-oriented programming)的关键思想在于数据抽象、继承和动态绑定。使用数据抽象,可以将类的接口和实现进行分离。通过继承,可以定义概念上相互关联且类型相似的类。通过动态绑定,可以在使用这些对象时忽略它们的细节上的不同。

继承

通过继承关联起来的类组成了层级。通常层级的顶端是一个基类(base class),其它类直接或间接的继承之,这些继承的类称之为派生类(derived classes)。基类定义层级中所类型都共通的成员,每个派生类定义特定于派生类自己的成员。如 Quote.cc 中的 isbn() 函数在基类中定义,因为,这是在整个层级都共通的成员,而派生类定义自己的 net_price(size_t) 函数,因为每个类有其自己的不同策略,需要 Quote 和 Bulk_qute 类定义自己的版本。

在 C++ 中,基类区分了每个类具有不同实现的函数与希望派生类只继承而不能做出改变的成员函数。基类将希望派生类定义自己的版本的函数为 virtual 的。

派生类需要指定其继承的类,指定方式使用类继承列表(class derivation list),即冒号后跟着一列由逗号分隔的基类,每个基类有一个可选的访问说明符(access specifier)。如:

class Bulk_quote : public Quote {
public:
    double net_price(std::size_t) const override;
};

由于 Bulk_quote 在派生列表中使用 public ,就可以像使用 Quote 一样使用 Bulk_quote ,派生类必须在其类体中声明所有其想定义自己版本的基类虚函数。派生类可以在这些函数上包含 virtual 关键字,但不是必需的。新标准允许派生类通过在参数列表后包含 override 关键字,来显式说明成员函数是覆盖其继承的一个虚函数。

动态绑定

通过动态绑定,可以使用相同的代码来平滑处理基类和派生类对象,在这里是 Quote 和 Bulk_quote 。示例代码:

double
print_total(std::ostream &os, const Quote &item, std::size_t n)
{
    double ret = item.net_price(n);
    os << "ISBN: " << item.isbn()
       << " # sold: " << n << " total due: " << ret << std::endl;
    return ret;
}

由于 item 是 Quote 的引用,调用函数时既可以传递 Quote 对象也可以传递 Bulk_quote 对象,并且由于 net_price 是虚函数,由于调用 net_price 函数是通过引用,具体调用哪个版本的函数,将依据传入对象的类型决定。如果传入 Bulk_quote 的对象则调用 Bulk_quote 的版本,如果传入 Quote 类的对象,则调用 Quote 的版本。

由于调用哪个版本是由实参的类型决定的,而实参类型只有在调用时才能知道。因而,动态绑定有时也被称为运行时绑定(run-time binding)。

在 C++ 中,动态绑定发生在虚函数通过基类的引用或指针调用时。

15.2 定义基类和子类

在绝大多数情况下,定义基类和派生类的方式与其它类是差不多的。

15.2.1 定义基类

作为继承层次的根类总是定义虚析构函数。基类通常应该定义虚析构函数,即便不做任何工作,虚析构函数依然是需要的。

成员函数和继承

派生类从其基类中继承成员,然而,派生类需要为特定于类型的操作提供自己的函数定义。派生类需要覆盖(override)掉其从基类继承来的定义,并提供自己的定义。

C++ 的基类必须明确区分希望派生类覆盖的函数和希望派生类继承而不改变的函数。基类将希望派生类覆盖的函数定义为 virtual 的。通过指针或引用调用虚函数,这个调用将是动态绑定的。根据引用或指针绑定的不同对象类型,基类或者其中之一的派生类的虚函数版本将被调用。

基类通过在声明前加上关键字 virtual 来指明成员函数是动态绑定的。所有的非静态成员函数(除了构造函数)都可以是 virtual 的。virtual 关键只出现在类体内的函数声明处,而不会被用于类体外的函数定义处。在基类中被定义为 virtual 的函数,其在派生类中隐式也是 virtual 的。

没有被定义为 virtual 的成员函数将在编译时确定下来,而不是运行时。如:isbn 函数只有一份定义,不论是以引用、指针还是对象值进行调用,都可以在编译时确定调用哪个函数,即 Quote 中的版本。

访问控制和继承

派生类继承基类中的所有成员,但这并不意味着派生类可以访问基类中的所有成员。与其它使用基类的代码一样,派生类可以使用基类的 public 成员,但是不能访问基类的 private 成员。基类将只允许派生类访问,而不允许其它用户代码访问的成员定义为 protected。

15.2.2 定义子类

派生类必须指定从哪个类继承,这是通过类继承列表(class derivation list),即冒号后的一列由逗号分隔的类名字,类必须是在之前定义过的(可以是未完成类型 incomplete type)。每个基类名字前可以放置可选的访问说明符,必须是 public 、 protected 或 private 中的一个。

派生类必须将所有要覆盖的继承来的成员函数进行类内声明。因而,Bulk_quote 类必须包含 net_price 的成员函数声明。

继承列表中的访问说明符将决定派生类的用户代码是否可以知道派生类从哪个基类继承而来。当继承是 public 的,基类的 public 成员变成了派生类的接口的一部分。并且,可以将公共派生的类型对象绑定到基类的指针或引用。

绝大多数类只会直接从一个基类继承,这种形式的继承称之为单继承(single inheritance),在 18 章将描述继承列表中包含多于一个基类的继承。

派生类中的虚函数

派生类经常但不总是覆盖其继承的虚函数。如果派生类不覆盖其基类的虚函数,那么与别的成员一样,派生类将继承基类中定义的版本。

派生类将在其覆盖的函数上包含 virtual 关键字,但是不是必须这么做。新标准允许派生类显式告知它将覆盖一个继承自基类的虚函数。它是通过在参数列表后指定 override 关键字,或者如果成员函数是 const 的或者有引用修饰符,那么就放在 const 或引用修饰符后。

派生类对象和派生类到基类的转换

一个派生类对象包含多个部分:包含派生类自己定义的成员的子对象,加上每一个基类的子对象。如:Bulk_quote 类的对象包含两个部分:自己定义的成员组成的子对象,与基类 Quote 子对象。由于派生类对象包含每个基类对应的子对象,可以把派生类对象当作基类对象一样使用,特别是,可以将基类对象的引用或指针绑定到派生对象的基类部分。如:

Quote item;
Bulk_quote bulk;
Quote *p = &item;
p = &bulk;
Quote &r = bulk;

这种转换称为派生类到基类的转换(derived-to-base conversion),这种转换是由编译器隐式执行的。由于这种转换是隐式的,可以将派生类对象或派生对象的引用用于需要基类对象引用的地方。同样的,可以将派生类对象的指针用于需要基类指针的地方。

派生类对象包含其基类的子对象是理解继承如何工作的关键。

派生类构造函数

尽管派生对象包含从基类继承来的成员,它不能直接初始化这些成员。与任何别的创建基类对象的代码一样,派生类必须使用基类构造函数来初始化基类部分。

每个类的构造函数控制其成员如何进行初始化。

对象的基类部分与派生类的数据成员一起在构造函数的初始化阶段进行初始化。与初始化成员一样,派生类构造函数使用构造初始值列表来传递参数给基类构造函数。如:

Bulk_quote(const std::string &book, double p, std::size_t qty, double disc):
    Quote(book, p), min_qty(qty), discount(disc) { }

当基类函数的整个函数体执行完之后,将执行数据成员的初始化,然后执行派生构造函数体。

与数据成员一样,除非是另外指定,派生类的基类部分是默认初始化的。为了调用不同的基类构造函数,可以在构造初始值中使用基类名字跟上一个参数列表,这些参数被用于选择使用哪个基类构造函数用于初始化派生对象的基类部分。

基类对象总是先初始化,然后是派生类的数据成员根据在类体中声明的顺序进行初始化。

在派生类中使用基类成员

派生类可以访问基类的 public 和 protected 成员。派生类的作用域被嵌套在基类的作用域中,那么在派生类成员函数中使用派生类自己定义的成员和使用基类中定义的成员没有区别。

关键概念:尊重基类的接口

理解每个类定义自己的接口是很重要的,与类对象进行交互时应该使用那个类的接口,即便那个对象是派生对象的基类部分。因而,派生类构造函数不会直接初始化基类的成员。派生构造函数的函数体可以给 public 和 protected 基类成员进行赋值。尽管它可以这样做,通常不应该这样做。与任何别的使用基类的用户代码一样,派生类应该尊重其基类的接口,所以应该使用基类的构造函数来初始化其继承的成员。

继承和静态成员

如果在基类中定义了静态成员,那么整个继承层级中只有此成员的唯一定义。不管从一个基类中派生了多少类,每个静态成员只存在一份实例。如:

class Base {
public:
    static void statmem();
};
class Derived : public Base {
    void f(const Derived &);
};

静态成员遵循常规的访问控制。如果一个成员在基类中是 private 的,那么派生类将无法访问它。如果成员是可访问的,则可以基类或派生类中使用此 static 成员。如:

void Derived::f(const Derived &derived_obj)
{
    //可以通过基类访问
    Base::statmem();
    //可以通过派生类访问
    Derived::statmem();
    //可以通过派生对象访问基类中的静态成员
    derived_obj.statmem();
    //可以通过当前对象访问
    statmem();
}

声明派生类

派生类的声明与常规类的声明是一样的。声明中包含类的名字,但不包含派生列表。如:

class Bulk_quote : public Quote; //错误:声明类中不能出现派生列表
class Bulk_quote; //正确的声明派生类的方式

声明的目的在于让程序知晓某个名字的存在以及它表示什么类型的实体,如:类、函数或变量。派生列表和所有其他的定义细节必须一起出现在类体中。

被用作基类的类

一个类在被用作基类之前必须定义而不能仅仅只声明。原因在于,每个派生类都包含并且可能使用其从基类继承来的成员。为了使用这些成员,派生类必须知道它们具体是什么。这也隐式说明一个类不能派生它本身。

一个类是基类,同时它是一个派生类。如:

class Base {};
class D1 : public Base {};
//D1 既是基类也是派生类
class D2 : public D1 {};

在这个层级中,Base 是 D1 的直接基类(direct base),是 D2 的间接基类(indirect base)。直接基类被放在派生列表中,间接基类是派生类通过其直接基类继承来的。

每个类都继承其直接基类的所有成员。最具体的派生类将继承其直接基类的所有成员,直接基类中的成员包含它自己从它的基类中继承来的成员,以此类推到整个继承链的顶端。所以,最具体的派生对象将包含其直接基类的子对象以及每个间接基类的子对象。

阻止继承

有时我们不希望一个类被继承,在新标准中可以通过在类名后加上 final 来阻止类被当作基类。如:

class NoDerived final {};
class Base {};
class Last final : public Base {}

15.2.3 转换和继承

理解派生类到基类的转换时理解 C++ 中面向对象编程的关键。通常,只能将引用和指针绑定到有相同类型的对象上,或者绑定到可以进行 const 转换的对象上。存在继承关系的类时一个例外:可以将基类的指针或引用绑定到这个类的派生对象上。

这个事实有很重要的暗示:当使用基类的引用或指针时,不知道绑定的对象的真实类型是什么,这个对象可能是基类对象,也可能是派生类对象。而且与内置指针一样,智能指针也支持派生类到基类的转换,可以将指向派生对象的指针存储到基类的智能指针。

静态类型和动态类型

当使用存在继承关系的类时,应该区分变量或表达式的静态类型(static type)以及其背后的动态类型(dynamic type)。表达式的静态类型在编译时就是已知的,它是变量声明时的类型或者表达式的结果类型。动态类型是变量或表达式所表示的在内存中的真正对象的类型,这个类型必须到运行时才能知道。如:

double ret = item.net_price(n);

item 的静态类型是 Quote&,动态类型则依据绑定到 item 的参数类型,这个类型直到运行时才能知道。如果传递 Bulk_quote 那么 item 的静态类型与动态类型将不一样,此时,item 的静态类型是 Quote& ,而其动态类型是 Bulk_quote 。

既不是引用也不是指针的表达式的静态类型和动态类型是一样的。理解基类的指针或引用的静态类型和动态类型不一样是至关重要的。

没有从基类到派生类的隐式转换

从派生类到基类的转换是因为每个派生对象都包含基类部分,这个部分可以被基类的指针或引用绑定。基类对象可以独立存在,也可以作为派生对象的一部分。一个非派生类一部分的基类对象只包含基类定义的成员,并不包含派生类定义的成员。所以,并不存在从基类到派生类的自动转换。如:

Quote base;
Bulk_quote *bulkP = &base; //错误:不能将基类转为派生类
Bulk_quote &bulkRef = base; //错误:不能将基类转为派生类

如果以上赋值是合法的,那么将在 bulkP 或 bulkRef 中使用 base 中不存在的成员。有一点令人惊奇的是,即便基类指针或引用绑定到派生对象,其依然不能转为派生类。如:

Bulk_quote bulk;
Quote *itemP = &bulk;
Bulk_quote *bulkP = itemP; //错误:不能将基类转为派生类

编译器没有在编译时以任何方式知道从基类转为派生类是否是安全的。编译器只能依据指针或引用的静态类型来判断转换是否是合法的。如果基类有一个或多个虚函数,可以使用 dynamic_cast 来请求带运行时检查的转换。同样,在确实知道从基类到派生类的转换是安全的,可以使用 static_cast 类覆盖掉编译器的规则。

对象间不存在转换

派生类到基类的自动转换只发生于引用或指针类型。从派生对象到基类对象是不存在转换的。然而,从表现上看经常可以将派生对象转为基类,只是这和引用、指针的那种派生类到基类的转换是不一样的。

当初始化一个类对象时,将调用构造函数。当赋值时将调用赋值操作符。这些成员函数通常具有一个该类对象的 const 引用的参数。由于其参数接收引用,派生类到基类的转换允许我们传递一个派生对象给基类的拷贝/移动操作成员函数。这些操作并不是 virtual 的。当传递派生对象给基类构造函数时,基类中定义的构造函数将被执行。那个构造函数只能识别基类自己定义的成员,类似的,如果将一个派生对象赋值给基类对象,基类中定义的赋值操作符将被执行。那个操作符只能识别基类中定义的成员。由于派生类部分被忽略了,所以派生类部分被裁剪(sliced down)掉了。

当用派生对象去初始化或赋值基类对象时,只有基类部分被拷贝、移动或赋值,派生类部分将被忽略。

15.3 虚函数

C++ 的动态绑定发生在虚成员函数通过基类的引用或指针调用时发生。由于直到运行时才知道哪个函数版本被调用,虚函数必须总是被定义。通常,不使用的函数时不需要提供定义的。然而,必须给每个虚函数提供定义而不管它有没有被使用,因为编译器无法知道一个虚函数是否被使用。

关键概念:存在继承关系的类之间的转换

理解存在继承关系的类之间的转换需要理解三点:

  • 派生类到基类之间的转换仅被运用于指针或者引用类型;
  • 没有隐式的从基类到派生类之间的转换;
  • private 或 protected 继承的派生类有时不能执行派生类到基类的转换;

由于自动转换只发生于指针和引用,绝大多数继承层级中的类隐式或显式地定义拷贝控制成员,因而,可以将派生对象拷贝、移动或赋值给基类对象。然而,这种拷贝、移动或赋值仅仅只处理派生对象中的基类部分,称之为裁剪(sliced down)。

调用虚函数将在运行时解析

当通过引用或指针调用虚函数时,编译器将生成代码可以在运行时决定具体调用哪个函数。被调用的函数是与指针或引用绑定的对象的动态类型一致的版本。如:

Quote base("0-201-82470-1", 50);
print_total(cout, base, 10);
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10);

第一个调用中 item 绑定到 Quote 对象上,从而 print_total 中的 net_price 调用 Quote 中的版本。第二调用 item 绑定到 Bulk_quote 对象,在这个调用中将调用 Bulk_quote 中定义的 net_price

理解动态绑定只发生在虚函数通过指针或引用被调用的过程中是至关重要的。当虚函数在 plain 对象上(非引用非指针)调用时,调用的解析发生在编译期。如:

Quote base = derived;
base.net_price(20);

上面的 base 类型就是 Quote,在编译期就可以决定调用 Quote 中定义的 net_price

关键概念:C++ 中的多态

OOP 的关键思想是多态。指针或引用的静态类型和动态类型可以不一样的事实是 C++ 支持多态的基石。当通过基类的引用或指针调用函数时,无法知道到底调用的对象的类型是什么。对象可以是基类对象也可以是派生类对象。如果调用的函数是 virtual 的,那么决定调用哪个函数将推迟到运行时。调用的虚函数版本是指针或引用绑定的对象的类所定义的。

另一方面,调用非 virtual 函数将在编译期进行解析。同样,在 plain 对象上调用任何函数(virtual or not)都是在编译期进行解析的。对象的类型是固定不可变的,不能做任何事从而使得其动态类型和静态类型不一样。因而,在 plain 对象上进行调用时在编译期决定调用对象的类所定义的版本。

虚函数在运行时进行解析只发生于当通过引用或指针进行调用时。只有在这种情况下,对象的动态类型才可能与其静态类型不一样。

派生类中的虚函数

当派生类覆盖一个虚函数时,可以但不是必须提供 virtual 关键字,一旦一个函数被声明为 virtual ,它将在所有派生类中保持 virtual 。覆盖继承的虚函数的派生类函数必须在派生类中进行参数列表完全一致的声明。派生类中的覆盖的虚函数可以返回一个基类中的返回类型的子类型的指针或引用,如果不是则必须完全匹配。如:D 从 B 派生而来,B 中的虚函数返回 B* ,那么 D 中的覆盖的虚函数可以返回 D* ,这种返回类型需要可以执行派生类到基类的转型。如果 D 从 B private 派生而来,那么这种转换将不可见。

final 和 override 说明符

在派生类中可以定义与基类中的虚函数同名的函数,但其参数列表不一样。编译器将这个函数认为与基类中的函数是独立的。在这种情况下派生类中的版本并不覆盖基类中的版本。在实践中,这种声明通常是错误的,类作者希望覆盖一个基类中的虚函数,但是写错误了参数列表。

查找这种 bug 是十分困难的,在新标准中可以在派生类的虚函数声明中指定 override 来明确表示是覆盖基类的虚函。这样就要求编译器帮助我们检查是否是真的覆盖了一个基类的虚函数,如果不是,编译器将拒绝编译。如:

struct B {
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D1 : B {
    void f1(int) const override; //ok
    void f2(int) override; //错误:B 中没有 f2(int) 函数
    void f3() override; //错误:f3 不是虚函数
    void f4() override; //错误:B 中没有名为 f4 的函数
};

D1 中的 f2 是一个新的函数,只是恰巧其名字与 B 中的函数同名。由于显式给出了 override 关键字,但是没有覆盖一个基类虚函数,编译将出错。

由于只有虚函数可以被覆盖,编译器将拒绝 D1 中的 f3,这是由于 B 中的 f3 不是虚函数。

除此之外,可以将函数指定为 final 的,任何尝试覆盖一个被声明为 final 的函数将被认为是编译错误。如:

struct D2 : B {
    void f1(int) const override final ;
};
struct D3 : D2 {
    void f2();  //从非直接基类中覆盖 f2 函数
    void f1(int) const; //错误,直接基类将其声明为 final 的
};

final 和 override 说明符需要放在参数列表(包括 const 和引用限定符)和后置返回类型之后。

虚函数和默认参数

与常规成员函数一样,虚函数可以有默认参数。当一个调用使用默认实参时,使用的默认值是调用此函数的对象的静态类型中所定义的默认值。也就是说,当通过基类的指针或引用进行调用时,使用基类中定义的默认实参。即使是绑定到派生类对象并且派生类的覆盖虚函数被调用,这个基类默认实参依然会被使用。派生类中的覆盖的虚函数被传递基类中定义的默认参数,如果派生函数依赖于不同参数,那么程序的行为可能会不一致。

如果虚函数使用了默认实参,通常应该总是在派生类和基类中使用相同的实参。

绕过虚函数机制

在一些情况下,我们希望阻止虚函数调用的动态绑定。通过使用作用域操作符可以调用虚函数的特定版本,这个函数调用解析发生在编译期。如:

//调用基类的版本,忽略 baseP 的动态类型
double undiscounted = baseP->Quote::net_price(42);

通常,只有成员函数或友元函数中的代码应当使用作用域操作符来绕过虚函数机制。

需要这种绕过的最常见的场景是:当一个派生类虚函数调用基类的版本,基类的版本做了继承层级中所有类型都需要做的通用工作。派生类中定义的版本只需要做特定于该派生类的额外工作。如果派生虚函数在调用其基类版本时忽略了作用域操作符,这个调用将被解析为调用其本身,从而将是无限递归。

15.4 抽象基类

参考代码:Disc_Quote.cc 中的 Quote 、Disc_quoteBulk_quote 类。

纯虚函数

在上面的代码中 Disc_quote 类是通用的打折书的概念,而不是具体的策略。所以,应当阻止用户实例化此类的对象。通过将 net_price 定义为纯虚函数(pure virtual function)来实现这个设计目标,并且明确告知 net_price 函数没有任何含义。与通常的虚函数不同,纯虚函数是可以不定义的。通过在函数参数列表后,分号前写上 = 0 来表明一个虚函数是纯虚函数。= 0 只能出现在类体内的虚函数声明处。如:

double net_price(std::size_t) const = 0;

尽管 Disc_quote 不能被直接创建对象,其派生类的构造函数依然需要使用 Disc_quote 的构造函数来构建 Disc_quote 类部分。值得一提的是,可以给纯虚函数提供一个定义,然而,函数体必须在类外进行定义,不能类内给一个纯虚函数提供定义。

有纯虚函数的类是抽象基类

包含或者继承但没有覆盖纯虚函数的类是一个抽象基类(abstract base class)。抽象基类可以定义接口给派生类去覆盖。不能直接创建抽象基类的对象。这里由于 net_price 是纯虚函数,所以,不能直接定义 Disc_quote 的对象。

从抽象基类继承的派生类必须定义所有纯虚函数,否则派生类也是抽象的。

派生类构造函数只能初始化其直接基类

此版本中的 Bulk_quote 有一个直接基类 Disc_quote,和一个非直接基类 Quote,每个 Bulk_quote 对象有三个子对象:Bulk_quote 部分,Disc_quote 部分和 Quote 类的子对象。

由于每个类控制本类对象的初始化过程,因而,即便 Bulk_quote 类没有任何数据成员,它依然需要 4 个构造函数参数。构造函数先调用直接基类 Disc_quote 的构造函数进行初始化,这个构造函数先调用它自己的直接基类 Quote 的构造函数进行初始化,但 Quote 的构造函数执行完返回时,Disc_quote 构造函数将继续执行,并最终返回到 Bulk_quote 构造函数,这个构造函数没有任何额外的事需要做,直接返回。

关键概念:重构

重构(refactoring)需要重新设计类继承体系,并将操作或数据从一个类移动到另一个类。重构在面向对象编程中十分常见。值得一提的是即便改变了继承层次,使用 Bulk_quote 和 Quote 类的代码将保持不变,但需要重新编译整个代码。

15.5 访问控制与继承

正如每个控制其自动成员的初始化。每个类也会控制其成员是否对派生类可见。

protected 成员

类使用 protected 修饰那些对派生类可见,但是对客户代码不可见的成员。protected 可以认为是 private 和 public 的混合:

  • 与 private 一样,protected 成员对类的用户是不可见的;
  • 与 public 一样,protected 成员对派生类的成员和友元是可见的;

还有一条很重要的关于 protected 特性是:派生类的成员和友元只能通过派生对象访问基类的 protected 成员。派生类不能访问独立的基类对象的 protected 成员。如:

class Base {
protected:
    int prot_mem;
};
class Sneaky : public Base {
    friend void clobber(Sneaky &); //可以访问 Sneaky::prot_mem
    friend void clobber(Base &); //不能访问 Base::prot_mem
    int j;
};
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
//错误:clobber 不能访问 Base 中的 protected 成员
void clobber(Base &b) { s.prot_mem = 0; }

第二个函数不是 Base 的友元,因而,它不能访问 Base 对象的受保护成员。为了防止第二种用法,派生类的成员或友元只能访问嵌套在派生对象中的基类子对象中的受保护成员。对于独立的基类对象并不具有特殊的访问权限。

public, private 和 protected 继承

访问一个类继承来的成员是由基类中的访问说明符和派生列表中的访问说明符共同决定的。参考代码:protected_access.cc

派生访问说明符并不影响派生类的成员和友元对其直接基类的成员访问权限。访问直接基类的成员是由基类自身的访问说明符决定的。public 继承和 private 继承的派生类都可以访问基类的 protected 成员,而都不能访问基类的 private 成员。

派生访问说明符的作用在于控制派生类的用户对从基类继承来的成员的访问权限,这些用户包括从这个派生继承的其它派生类。如:

Pub_Derv d1; //从基类中继承来的成员是 public 的
Priv_Derv d2; //从基类中继承来的成员是 private 的
d1.pub_mem(); //pub_mem 在 d1 中是可见的
d2.pub_mem(); //错误:pub_mem 在 d2 中是不可见的

Pub_Derv 和 Priv_Derv 都从基类中继承 pub_mem 函数。当继承是 public 的,成员将保持其可见性,所以 d1 可以调用 pub_mem。而 Priv_Derv 中基类的成员是 private 的,此类的用户不能调用 pub_mem 。

派生类的派生访问说明符还会控制从此派生类进行继承的类对其基类的访问权限。如:

struct Derived_from_Public : public Pub_Derv {
    //Base::prot_mem 在 Pub_Derv 中保持 protected
    int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Priv_Derv {
    //错误:Base::prot_mem 在 Priv_Derv 中是 private 的
    int use_base() { return prot_mem; }
};

对于 Priv_Derv 的派生类来说,Priv_Derv 从基类中继承来的所有成员都是私有的。

如果使用 protected 继承,Base 中的 public 成员在派生类中将变成 protected 的,其用户将不能访问继承来的成员,但是其子类可以访问这些成员。

派生类到基类的转换的可见性

派生类到基类的转换是否可见取决于哪些代码尝试使用这些转换以及派生列表的访问说明符。假设 D 继承自 B:

  • 仅当 D 是公有继承 B 时,用户代码可以使用派生类到基类的转换,若以 protected 或 private 继承则不可以;
  • D 的成员函数或友元可以使用派生类到基类的转换,而忽略 D 是如何继承 B 的,派生类到直接基类的转换对于派生类的成员和友元来说总是可访问的;
  • D 的派生类的成员或友元,当 D 是 public 或 protected 继承 B 时,可以使用派生类到基类的转换。如果 D 是私有继承 B 则这种转换不可使用;

在给定代码的任何位置,如果基类的 public 成员是可见的,那么派生类到此基类的转换就是可见的。

关键概念:类设计和 protecte 成员

在没有继承时,类只有两种用户:普通用户和实现者。普通用户书写使用类对象的代码;这种代码只能访问类的 public 成员(接口)。实现者书写类的成员和友元中的代码,类的成员和友元可以访问类的 public 和 private 部分(实现)。

在继承的情况下,有第三种用户:派生类。基类使得派生类可访问的实现部分为 protected 。protected 成员对于普通用户来说不可见,private 成员对于派生类和派生类的友元来说不可见。

与别的类一样,基类使其接口成员为 public 的。作为基类的类会将其实现分为可被派生类访问和保留给基类自身和其友元访问的部分。如果提供给派生类用于其实现的操作或数据应该设为 protected 的,否则,实现成员应该设为 private 的。

友元关系和继承

正如友元不具有传递性质(一个类是另一个类的友元并不意味着这个类自己的友元可以访问那个类),友元关系不会被继承。基类的友元对于派生类成员没有特殊的访问权限,派生类的友元对于基类成员没有特殊访问权限。如:

class Base {
    friend class Pal; //Pal 对 Base 的派生类没有特殊访问权限
};
class Pal {
public:
    int f(Base b) { return b.prot_mem; }
    //错误:Pal 不是 Sneaky 的友元,不能访问私有成员
    int f2(Sneaky s) { return s.j; }
    //对基类的访问有基类自己控制,即便基类内嵌在派生对象中
    //即便要访问的是 private 成员
    int f3(Sneaky s) { return s.pri_mem; }
};

f3 是合法的确有点奇怪,但是这与每个类控制自己的成员的访问权限并不冲突。Pal 是 Base 的友元,所以 Pal 可以访问 Base 对象的私有成员,这种访问权限即便是当基类对象是其派生类对象的一部分时依然成立。

如果一个类是另一个类的友元,那么仅仅是那个类的友元,那个类的基类或派生类都不是这个类的友元。如:

//D2 不能访问 Base 类的 protected 和 private 成员
class D2 : public Pal {
public:
    int mem(Base b)
    { return b.prot_mem; }
};

友元关系不能继承;每个类控制自己的成员的访问级别。

改变单个成员的访问权限

有时我们需要改变特定的派生类继承来的名字的访问级别。可以通过使用 using 声明来指定。如:

class Base {
public:
    std::size_t size() const { return n; }
protected:
    std::size_t n;
};
class Derived : private Base {
public:
    using Base::size;
protected:
    using Base::n;
};

这里值得注意的是派生类不能改变基类的私有成员的访问级别,原因在于它们对派生类根本不可见。

由于 Derived 使用了 private 继承,所以,size 和 n 默认是 Derived 的私有成员。using 声明调整了这些成员的可见性。Derived 的用户代码可以访问其 size 成员,Derived 的派生类可以访问 n 成员。类内的 using 声明可以将其直接或间接基类的可访问名字指定为任何访问级别。如果 using 出现在 private 部分,则只能被类的成员或友元访问。如果出现在 public 部分,则可以被所有用户代码访问。如果出现在 protected 部分,则名字可以被成员、友元和派生类访问。

派生类只能给它可以访问的基类名字使用 using 声明。

默认继承的派生访问说明符

C++ 中可以使用 struct 和 class 关键字定义类,并且具有不同的默认访问说明符。类似的,默认派生说明符依赖于使用哪个关键字定义派生类。用 class 定义的派生类默认是 private 继承;用 sturct 定义的派生类默认是 public 继承。如:

class Base {};
struct D1 : Base {}; //默认共有继承
class D2 : Base {}; //默认私有继承

任何用关键字 struct 或 class 关键字定义的类之间有什么更深层次的不同是一个普遍的错误。它们之间唯一的区别就是成员的默认访问说明符和默认的派生访问说明符之间的不同。除此之外更无别的区别了。

最佳实践

私有派生的类应该显式给出 private 而不是依赖于默认访问说明符。通过显式指出让其它程序员可以知道私有继承是有意为之,而不是由于错误导致的。

15.6 继承下的类作用域

每个类定义其自己的作用域,在其中它的成员被定义。在继承下,派生类的作用域被嵌套在基类的作用域中。如果一个名字在派生类中无法被解析,那么将继续查找基类作用域。由于继承嵌套了类作用域,所以派生类的成员可以使用基类的成员就像是其自己的成员一样。

名称查找发生在编译期间

对象、引用、指针的静态类型决定了哪些对象的成员是可见的。即便静态类型和动态类型不一样,也是静态类型决定哪些成员是可以使用的。假设 Disc_quote 有一个名为 discount_policy 的成员,如下代码示例:

Bulk_quote bulk;
Bulk_quote *bulkP = &bulk;
Quote *itemP = &bulk;
bulkP->discount_policy();
//错误:itemP 的类型是 Quote* ,它没有 discount_policy 成员
itemP->discount_policy();

名称冲突和继承

与其它内嵌作用域一样,派生类可以复用其直接或间接基类的名字。与往常一样,定义在内部作用域的名字将隐藏定义在外部作用域的名字。如:

struct Base {
    Base() : mem(0) {}
protected:
    int mem;
};
struct Derived : Base {
    Derived(int i) : mem(i) {}
    //Base::mem 将被隐藏,返回的是本类自己定义的 mem
    int get_mem() { return mem; }
protectd:
    int mem;
};

在派生类中定义与基类同名的成员,将隐藏基类成员的直接使用。

使用作用域操作符来访问被隐藏的成员

通过使用作用域操作符可以访问被隐藏的基类成员,如:

struct Derived : Base {
    int get_base_mem() { return Base::mem; }
};

作用域操作符覆盖了常规的名称查找规则,编译器将从 Base 类的作用域开始查找名字 mem 。

最佳实践

除了覆盖继承的虚函数外,派生类通常不应该复用基类中的名字。

关键概念:名称查找和继承

理解函数调用在 C++ 中是如何解析的对于理解 C++ 的继承非常重要。例如:p->mem() 的调用过程将发生以下步骤:

  • 首先查看 p 的静态类型,调用成员函数的对象必须是类类型;
  • 在静态类型的类中查找 mem 成员,如果没有找到,继续从其直接基类向上查找,直到找到或者最后一个基类被查找过,如果依然没有找到则产生编译错误;
  • 一旦 mem 被找到了,执行常规的类型检查,查看函数原型与调用是否匹配,以及重载函数解析;
  • 如果 mem 是虚函数,并且调用是通过引用或指针发生的,那么编译器将产生代码,其将执行在运行时根据被绑定的对象的动态类型调用不同的 mem 版本;
  • 否则,如果 mem 是非虚函数,或者调用是通过对象实体(非引用或指针)发生的,编译器产生常规的函数调用代码;

通常,名称查找发生在类型检查前

声明在内部作用域的函数将不会重载定义在外部作用域的函数。因而,定义在派生类中的函数不会重载基类中的成员函数。如果派生类中的成员与基类成员同名,那么派生成员将隐藏基类成员。即便是函数拥有不同的形参列表基类成员也会被隐藏。即便在派生类中同名的成员是数据成员,也会隐藏基类中的同名函数。如:

struct Base {
    int memfcn();
};
struct Derived : Base {
    int memfcn(int);
};
Derived d;
Base b;
b.memfcn();
d.memfcn(10);
d.memfcn(); //错误:Base::memfcn() 被隐藏
d.Base::memfcn(); //调用 Base::memfcn()

Derived 中声明的 memfcn 隐藏了 Base 中声明的 memfcn。第三个调用之所以是无法完成的,原因在于,为了解析这个调用,编译器将在 Derived 中查找名字 memfcn,并且找到了。此时将不再继续往上查找。而 Derived 中的 memfcn 是一个接收一个 int 参数的函数,此调用与之函数不匹配,所以无法调用完成。

虚函数和作用域

如果基类和派生类的同名成员的参数不一样,那么将无法通过基类的指针或引用调用派生类的版本。如:

class Base {
public:
    virtual int fcn();
};
class D1 : public Base {
public:
    int fcn(int);
    virtual void f2();
};
class D2 : public D1 {
public:
    int fcn(int);
    int fcn();
    void f2();
};

以上 D1 中的 fcn 并没有覆盖 Base 中的虚函数 fcn,因为他们具有不同的参数列表。相反却隐藏了基类中的 fcn。D1 有两个名为 fcn 的函数:基类的虚函数 fcn,和自己定义的非虚函数 fcn(int) ,但基类的虚函数被隐藏了,因而,无法直接调用。

通过基类调用隐藏的虚函数

以下有几个复杂的调用过程,如果理解了这几个对于理解 C++ 的隐藏特性有帮助:

Base bobj;
D1 d1obj;
D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); // virtual call, will call Base::fcn at run time
bp2->fcn(); // virtual call, will call Base::fcn at run time
bp3->fcn(); // virtual call, will call D2::fcn at run time
D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); // error: Base has no member named f2
d1p->f2(); // virtual call, will call D1::f2() at run time
d2p->f2(); // virtual call, will call D2::f2() at run time
Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); // error: Base has no version of fcn that takes an int
p2->fcn(42); // statically bound, calls D1::fcn(int)
p3->fcn(42); // statically bound, calls D2::fcn(int)

上面 p1, p2, p3 恰巧都指向 D2 类型的对象。然后,由于 fcn(int) 并不是虚函数,所以调用时解析是由指针所指对象的静态类型决定的。

覆盖重载函数

与其它函数一样,不管成员函数是否为虚函数都可以进行重载。派生类可以覆盖其继承的零个或多个重载函数。如果派生类想让所有继承来的重载函数都能够通过派生类访问,就需要覆盖基类中的所有重载函数,或者一个都不覆盖。

有时派生类仅想覆盖一部分但不是所有基类的重载函数。如果为了这个目标而必须覆盖所有的基类函数就不划算了。除了覆盖所有基类的重载函数,派生类可以使用 using 声明来使得所有基类重载函数可以被派生类的用户代码访问。在引入了派生类的所有的重载函数之后,派生类只需要定义特定于本类的行为的函数,并且可以使用基类定义的其它函数。

之前谈过的 using 声明的规则依然适用于现在的重载函数;只有派生类可以访问的基类成员才能被 using 声明导入;派生类的用户代码是否可以访问这些继承过来的名字取决于 using 声明所在的位置。

15.7 构造函数与拷贝控制

与其它类一样,继承层次中的类控制本类对象如何进行创建、拷贝、移动和赋值或析构。与任何别的类一样,如果基类或派生类本身没有定义自己的拷贝控制操作,编译器会合成这些操作。同样,在某些情况下,合成的版本可能是被删除的函数。

15.7.1 虚析构函数

继承对于基类的拷贝控制的最大最直接的影响就是基类必须定义虚析构函数。析构函数是 virtual 的,将允许继承层级中的对象可以被动态析构。由于使用 delete 对动态对象的指针进行删除会调用其析构函数,指针指向对象的静态类型可能与动态类型不一样。如 Quote* 指针可能指向 Bulk_quote 对象,如果要让编译器成功调用 Bulk_quote 的析构函数,就必须让 Quote 的析构函数是虚函数。如:

class Quote {
public:
    //如果基类指针指向派生对象,
    //当被删除时,需要析构函数是虚函数
    virtual ~Quote() = default;
};

析构函数的 virtual 属性是可以继承的。因而,Quote 的派生类的析构函数也是虚函数,而不管析构函数是合成的还是用户提供的。只要基类的析构函数是虚函数,那么 delete 基类的指针,将会调用正确的析构函数。

如果在基类析构函数不是虚函数的情况下调用实际上指向派生对象的基类指针,行为将是未定义的。

基类的析构函数是第十三章中的规则“如果一个需要析构函数那么它同样需要拷贝和赋值操作”的例外。基类几乎总是需要析构函数,这样才能使得析构函数是 virtual 的。如果基类的空析构函数仅仅是为了使其为虚函数,那么类具有析构函数并不意味着其需要赋值操作或拷贝构造函数。

虚析构函数将关闭合成的移动操作

基类需要虚析构函数对基类和派生类的定义有一个重大的间接影响:如果一个类定义了析构函数,即便使用的是 = default 来使用合成版本的,编译器也不会为这个合成任何移动操作。

15.7.2 合成拷贝控制和继承

在基类和派生类中合成的拷贝控制成员与任何别的合成的构造函数、赋值操作符和析构函数是一样的:它们将逐成员初始化、赋值、销毁类的成员。另外,合成的成员将使用基类的对应操作初始化、赋值或销毁其直接基类子对象。如:合成的 Bulk_quote 默认构造函数将调用 Disc_quote 的默认构造函数,而 Disc_quote 则继续调用 Quote 的默认构造函数;

同样的,合成的 Bulk_quote 拷贝构造函数使用合成的 Disc_quote 拷贝函数,而 Disc_quote 拷贝构造函数则继续调用 Quote 的拷贝构造函数。基类的成员是合成的还是用户定义的都是不要紧的,关键在于基类的对应成员是可访问的,并且不是被删除的函数。

析构函数除了析构其自己的成员,在析构阶段还会销毁其直接基类,这个过程是通过调用直接基类自己的析构函数完成的。然后一直向上调用直到继承层次的根。

正如所见,Quote 没有合成的移动操作,这是由于它定义了析构函数。当任何时候需要用到 Quote 的移动操作时,就会用拷贝操作来替换它。 Quote 没有移动操作意味着它的派生类也没有移动操作。

基类和派生类中被删除的拷贝控制成员

除了在第十三章中说明的将导致合成的拷贝控制成员为被删除的函数的原因外,这里将说明额外的原因:基类的定义方式将导致派生类的拷贝控制成员是被删除的函数。

  • 如果基类的默认构造函数、拷贝构造函数或拷贝赋值操作符或析构函数是被删除的或者不可访问的,那么派生类的对应成员也被定义为被删除的函数;
  • 如果基类有一个被删除的或不可访问的析构函数,那么派生类合成的默认和拷贝构造函数将是被删除的函数;
  • 与往常一样,编译器不会合成被删除的移动操作。当使用 = default 来请求移动操作时,如果基类的对应操作是被删除的或者不可访问的,或者基类的析构函数是被删除的或不可访问的;

参考代码:delete_derived_copy_control.cc

在实践中,如果基类没有默认或拷贝或移动构造函数,派生类也不应该有对应的成员。

移动操作和继承

由于基类缺少移动操作将抑制派生类合成移动操作,那么在确实需要移动操作时应该在基类中定义移动操作。即便是使用合成版本,也是需要显式定义的。一旦显式定义了移动操作,也就必须显式定义拷贝操作,因为,当定义了移动构造函数或移动赋值操作符时,其合成拷贝构造函数和拷贝赋值操作符将被定义为被删除的。

15.7.3 子类拷贝控制成员

当派生类定义拷贝、移动操作,这些操作需要拷贝、移动整个对象,包括基类成员。

定义派生拷贝、移动构造函数

当定义派生类的拷贝、移动构造函数,通常需要调用基类对应的构造函数来初始化对象的基类部分。如果不调用基类的构造函数,那么编译器将隐式调用基类的默认构造函数,但这肯定是不正确的。如果想要拷贝、移动基类部分,需要在构造函数初始值列表中显式调用基类对象的拷贝、移动构造函数。

定义派生赋值操作符

派生类的赋值操作符必须显式对基类部分进行赋值。如:

D &D::operator=(const D &rhs)
{
    Base::operator=(rhs);
    return *this;
}

此操作从显式调用基类的赋值操作符来对派生对象的基类部分进行赋值开始,基类操作符可以正确处理基类对象的赋值,如:自赋值,并在合适的时机释放左操作数中的资源,并在将 rhs 的值赋值给左操作数。一旦基类操作符完成后,将继续执行派生类自己的赋值操作。

派生类的析构函数

派生类的成员和基类部分都会在析构函数后的析构阶段隐式销毁。因而,与构造函数和赋值操作符不一样,派生析构函数只需要销毁派生类自己分配的资源。对象的销毁顺序与构造顺序刚好相反:派生析构函数先执行,然后基类构造函数被调用,沿着继承链一直网上执行析构。

在构造函数和析构函数中调用虚函数

派生对象中的基类部分先构建,当基类构造函数执行时,其对象的派生部分还没有初始化。而,派生对象的析构则是反方向的,所以,当基类的析构函数执行时,其派生部分已经被销毁了。所以,当基类的这两个成员执行时,对象是不完全的。

为了兼容这种不完全,当对象正在构造时,其类型被认为与构造函数所在类是一样的;调用虚函数会被解析为构造函数所在类的那个版本。这对于析构函数来说也是一样的。调用虚函数可以是直接调用,也可以是构造或析构函数调用的函数间接调用了这个虚函数。

如果在基类的构造函数中调用了派生类的虚函数版本,此时,派生部分的成员还没有被初始化,如果允许这样做将导致程序无法正确运行。

当构造函数或析构函数调用一个虚函数,所调用的版本是与构造函数或析构函数在同一个类中的版本。

15.7.4 继承的构造函数

在新标准中,派生类可以复用直接基类的构造函数。尽管,这不是常规意义上的继承。一个只能继承来自直接基类的构造函数,并且派生类不会继承它的默认、拷贝和移动构造函数,原因是编译器会其合成这些构造函数。

通过 using 声明可以让派生类继承基类的构造函数。如:

class Bulk_quote : public Disc_quote {
public:
    using Disc_quote::Disc_quote;
    double net_price(std::size_t) const;
};

常规的 using 声明只是让名字可见而已。当其运用到构造函数时,using 声明将导致编译器生成代码。编译器将生成与基类一一对应的构造函数,这些编译器生成的构造函数有如下形式:derived(params) : base(params) {} ,如果派生类有自己的成员,要么执行类内初始化,要么就是默认初始化的。

继承的构造函数的特质

using 声明的构造函数不会随着 using 所在的位置改变继承来的构造函数的访问级别。不管 using 身处何处,基类中的 private 构造函数依然是 private 的;protected 和 public 构造函数也是一样。

另外 using 声明不能指定 explicit 或 constexpr,如果基类构造函数是 explicit 的,派生构造函数具有一样的性质。如果基类构造函数具有默认参数,这些参数不会被继承,相反,派生类将有多个继承而来的构造函数,每一个会将连续省略一个默认实参。

在继承基类的构造函数的同时,派生类可以定义自己的构造函数版本。如果定义的参数列表与基类中的一样,那么基类中的版本将不继承。继承的构造函数不会被认为是用户提供的构造函数,所以,如果一个类只有继承而来的构造函数,那么它还会有合成的默认构造函数。

15.8 容器和继承

当使用容器存储来自继承层次的对象时,通常得使用间接的方式存储对象。原因,不能在容器中持有不同类型的元素。由于对象被赋值给基类对象时是裁剪(sliced down)的,容器与有继承关系的类型不能很好的混合使用。

在容器中放入指针或智指针。当使用容器来存储有继承关系的类型时,通常将容器定义为存储基类的指针或智能指针。而且,存储智能指针是一种更加推崇的方案。

关键术语

  • 抽象基类(abstract base class):带有一个或多个纯虚函数的类,不能创建一个抽象基类的对象;
  • 可访问的(accessible):可以通过派生对象访问的基类成员。可见性取决于派生列表中的访问说明符以及基类的成员的访问级别;
  • 类派生列表(class derivation list):基类的列表,每个可以带一个可选的访问说明符;
  • 动态绑定(dynamic binding):推迟到运行时决定选择执行哪个函数;在 C++ 中,动态绑定指的是基于引用或指针所绑定的对象的类型,从而,在运行时决定选择运行哪个虚函数;
  • 多态(polymorphism):运用于面向对象编程,表示根据引用或指针所绑定对象的动态类型来获取类型特定的行为;
  • 动态类型(dynamic type):运行时中的对象类型。指针或引用的对象的动态类型可能与其静态类型不一样。基类的指针或引用可以绑定到派生类对象上,在这种情况下静态类型是基类,但动态类型是派生类;
  • 静态类型(static type):定义变量时提供的类型或表达式求值时得到结果类型,静态类型可以在编译期知道;
  • 直接基类(direct base class):派生类直接继承的基类,直接基类出现在派生类的派生列表中,直接基类自身可以是一个派生类;
  • 间接基类(indirect base class):不出现在派生类的派生列表中的基类。此类由直接基类(直接或间接)继承,是派生类的间接基类;
  • 虚函数(virtual function):定义类型特定行为的成员函数。通过引用或指针调用虚函数将在运行时进行解析,这是基于引用或指针实际上所绑定的对象的类型;
  • 裁剪(sliced down):当派生类对象被用于初始化或赋值给一个基类对象时发生的事。对象的派生部分将被裁剪掉,只留下基类部分给基类对象;
  • 运行时绑定(run-time binding):与动态绑定一样;
  • 纯虚函数(pure virtual):虚函数在函数体内使用 = 0 进行声明。纯虚函数不需要定义(可以进行定义)。有纯虚函数的类时抽象类,如果一个派生类不定义自己的继承来的纯虚函数版本,那么派生类也是抽象的;

Leave a Reply

Your email address will not be published. Required fields are marked *