15.面向对象程序设计

文章目录

面向对象程序设计

面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。

15.1OOP:概述

继承

通常在层次关系的根部有一个基类,继承得到的类称为派生类
对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数

// 不同定价策略的建模
class Quote {
public:
	// 返回书籍的ISBN编号,因为不涉及派生类的特殊性,因此只定义在Quote中。
	std::string isbn() const;
	virtual double net_price(std::size_t n) const;
};

派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的,其中,每个基类前面可以有:publicprotected或者private中的一个,具体含义后面会讲解。

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

派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做(部分IDE会认为是重复的修饰符)。
C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即增加override关键字。

动态绑定

通过使用动态绑定,能用同一段代码分别处理基类和派生类的对象。

// 计算并打印销售给定数量的某种书籍所得的费用
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

在c++中,当使用基类的引用(或指针)调用一个虚函数时将发生动态绑定

15.2定义基类和派生类

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; // 对析构函数进行动态绑定
    
private:
	// 书籍的ISBN编号
    std::string bookNo;
    
protected:
	// 代表普通状态下不打折的价格
    double price = 0.0;
};

成员函数与继承

任何构造函数之外的非静态函数都可以是虚函数。virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时

访问控制与继承

基类可以使用受保护的访问运算符定义希望它的派生类有权访问而禁止其他用户访问的成员。

15.2.2定义派生类

派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明:

// 从Quote那里继承了isbn函数和bookNo、price等数据成员。
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 = 0;
    
    // 以小数表示的折扣额
    double discount = 0.0;
};
派生类对象及派生类向基类的类型转换

一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。

在这里插入图片描述

因为在派生类对象中含有与其基类对应的组成部分,所以能把派生类的对象当成基类对象来使用,而且也能将基类的指针或引用绑定到派生类对象中的基类部分上。

派生类构造函数

尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。派生类必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程
派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。

// 由Quote的构造函数负责初始化Bulk_quote的基类部分。当(空的)Quote构造函数体结束后,
// 构建的对象的基类部分也就完成初始化了。接下来初始化由派生类直接定义的数据成员。
// 最后运行Bulk_quote构造函数的(空的)函数体。
Bulk_quote(const std::string &book, double p, std::size_t qty, double disc)
            : Quote(book, p), 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;
    }
}

派生类的作用域嵌套在基类的作用域之内。

继承与静态成员

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

class Base {
public:
	static void statmem();
};

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

静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则既能通过基类使用它也能通过派生类使用它:

void Derived::f(const Derived &derived_obj) {
	Base::statmem();
	Derived::statmem();
	derived_obj.statmem();
	statmem();	// 通过this对象访问
}
派生类的声明

派生类的声明中包含类名但是不包含它的派生列表。派生列表以及与定义有关的其他细节必须与类的主体一起出现。

被用作基类的类

如果想将某个类用作基类,则该类必须已经定义而非仅仅声明。
原因在于,派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此,一个类不能派生它本身。

防止继承的发生

在类后跟一个关键字final

15.2.3类型转换与继承

智能指针类也支持派生类向基类的类型转换,意味着可以将一个派生类对象的指针存储在一个基类的智能指针内。

静态类型与动态类型

表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致

不存在从基类向派生类的隐式类型转换

因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换:

Quote base;
// 错误:不能将基类转换成派生类。
Bulk_quote *bulkP = &base;
// 错误:不能将基类转换成派生类。
Bulk_quote &bulkRef = base;
// 如果上述赋值是合法的,则有可能会使用
// bulkP或bulkRef访问base中本不存在的成员。

除此之外还有一种情况显得有点特别,即使一个基类指针或引用绑定在一个派生类对象上,也不能执行从基类向派生类的转换:

Bulk_quote bulk;
// 正确:动态类型是Bulk_quote。
Quote *itemP = &bulk;
// 错误:不能将基类转换成派生类。
Bulk_quote *bulkP = itemP;
// 编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器
// 只能通过检查指针或引用的静态类型来推断该转换是否合法。

如果在基类中包含一个或多个虚函数,可以使用dynamic_cast请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果已知某个基类向派生类的转换是安全的,则可以使用static_cast来强制覆盖掉编译器的检查工作。

在对象之间不存在类型转换

派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
当初始化或赋值一个类类型的对象时,实际上是在调用某个函数。这些成员通常都包含一个类类型的const版本的引用的参数。
因为这些成员接受引用作为参数,所以派生类向基类的转换允许给基类的拷贝/移动操作传递一个派生类的对象。这些操作不是虚函数。当给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。

Bulk_quote bulk;	// 派生类对象
// 只能处理bookNo和price两个成员,它负责拷贝bulk中Quote部分的成员,
// 同时忽略掉bulk中Bulk_quote部分的成员。
Quote item(bulk);	// 使用Quote::Quote(const Quote &)构造函数
item = bulk;	// 调用Quote::operator=(const Quote &)

当用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉

15.3虚函数

因为直到运行时才能知道到底调用了哪个版本的虚函数,所以所有的虚函数都必须有定义

对虚函数的调用可能在运行时才被解析

动态绑定只有当通过指针或引用调用虚函数时才会发生。
当通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。

派生类中的虚函数(覆盖时,virtual和override是可选的)

一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致
同样,派生类中虚函数的返回类型也必须与基类函数匹配。但是存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果DB派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*,只不过这样的返回类型要求从DB的类型转换时可访问的。

final和override说明符

派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类的版本。
就实际的编程习惯而言,这种声明往往意味着发生了错误,因为可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。因此,在新标准中,可以使用override关键字来说明派生类中的虚函数。

如果把某个函数定义成final,则之后任何尝试覆盖该函数的操作都将引发错误。

虚函数与默认实参

虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定
换句话说,如果通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与预期的不符。
因此,基类和派生类中定义的默认实参最好一致

回避虚函数的机制

使用作用域运算符可以实现:

// 强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制
当一个派生类的虚函数调用它覆盖的基类的虚函数版本时,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。

15.4抽象基类

纯虚函数

一个纯虚函数无须定义,通过在函数体的位置书写=0就可以进行说明。其中,=0只能出现在类内部的虚函数声明语句处

// 用于保存折扣值和购买量的类,其他的表示某种特定策略的类将分别继承自
// Disc_quote,派生类使用这些数据可以实现不同的价格策略。由于Disc_quote
// 类与任何特定的折扣策略都无关,因此,net_price函数是没有实际含义的。如果
// 不定义新的net_price,此时,Disc_quote将继承Quote中的net_price函数。
// 然而,这样的设计可能导致用户编写出一些无意义的代码。用户可能会创建一个
// Disc_quote对象并为其提供购买量和折扣值,如果将该对象传给一个像
// print_total这样的函数,则程序将调用Quote版本的net_price。显然,最终
// 计算出的销售价格并没有考虑在创建对象时提供的折扣值,因此上述操作毫无意义。
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), quantity(qty), discount(disc) {
	}

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

protected:
	// 折扣适用的购买量
	std::size_t quantity = 0;

	// 表示折扣的小数值
	double discount = 0.0;
};

也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。

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

抽象基类负责定义接口,而后续的其他类可以覆盖该接口。不能(直接)创建一个抽象基类的对象

派生类构造函数只初始化它的直接基类

// 当同一书籍的销售量超过某个值时启用折扣。折扣值是一个小于1的正的小数值,
// 以此来降低正常销售价格。每个Bulk_quote对象包含三个子对象:一个(空的)
// Bulk_quote部分、一个Disc_quote子对象和一个Quote子对象。
class Bulk_quote : public Disc_quote {
public:
    Bulk_quote() = default;

	// 每个类各自控制其对象的初始化过程。因此,即使Bulk_quote没有自己的数据成员,它也
	// 仍然需要像原来一样提供一个接受四个参数的构造函数。该构造函数将它的实参传递给
	// Disc_quote的构造函数,随后Disc_quote的构造函数继续调用Quote的构造函数,从而
	// 分别初始化。
    Bulk_quote(const std::string &book, double p, std::size_t qty, double disc)
            : Disc_quote(book, p, qty, disc) {}

    // 覆盖基类的函数版本以实现一种新的折扣策略
    double net_price(std::size_t) const override;
};

15.5访问控制与继承

每个类还分别控制着其成员对于派生类来说是否可访问

受保护的成员

一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员:

  • 受保护的成员对于类的用户来说是不可访问的。
  • 受保护的成员对于派生类的成员和友元来说是可访问的。
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
class Base {
protected:
	int prot_mem;	// protected成员
};

class Sneaky : public Base {
	friend void clobber(Sneaky &);	// 能访问Sneaky::prot_mem
	friend void clobber(Base &);	// 不能访问Base::prot_mem
	int j;	// j默认是private
};

// 正确:clobber能访问Sneaky对象的private和protected成员。
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
// 错误:clobber不能访问Base的protected成员。
void clobber(Base &b) { b.prot_mem = 0; }

公有、私有和受保护继承

某个类对其继承而来的成员的访问权限受到两个因素影响:一个是在基类中该成员的访问说明符,二是派生类的派生列表中的访问说明符。
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。

class Base {
public:
	void pub_mem();	// public成员
protected:
	int prot_mem;	// protected成员
private:
	char priv_mem;	// private成员
};

// 如果继承是公有的,则成员将遵循其原有的访问说明符。
struct Pub_Derv : public Base {
	// 正确:派生类能访问protected成员。
	int f() { return prot_mem; }
	// 错误:private成员对于派生类来说是不可访问的。
	char g() { return priv_mem; }
};

struct Priv_Derv : private Base {
	// private不影响派生类的访问权限
	int f1() const { return prot_mem; }
};

派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:

Pub_Derv d1;	// 继承自Base的成员是public的
Priv_Derv d2;	// 继承自Base的成员是private的
d1.pub_mem();	// 正确:pub_mem在派生类中是public的。
d2.pub_mem();	// 错误:pub_mem在派生类中是private的。

派生访问说明符还可以控制继承自派生类的新类的访问权限:

struct Derived_from_Public : public Pub_Derv {
	// 正确:Base::prot_mem在Pub_Derv中仍然是protected的。
	int use_base() { return prot_mem; }
};

// Priv_Derv继承自Base的所有成员都是私有的
struct Derived_from_Private : public Priv_Derv {
	// 错误:Base::prot_mem在Priv_Derv中是private的。
	int use_base() { return prot_mem; }
};

派生类向基类转换的可访问性

派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响(假设D继承自B):

  • 只有当公有地继承时,才能使用派生类向基类的转换;如果是受保护的或者私有的,则不能使用该转换
  • 不论D以什么方式继承BD的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用DB的类型转换;反之,如果D继承B的方式是私有的,则不能使用。

对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

友元与继承

友元关系也不能继承:

class Base {
	friend class Pal;	// Pal在访问Base的派生类时不具有特殊性
	// 其他成员保持一致
};

class Pal {
public:
	int f(Base b) { return b.prot_mem; }	// 正确:Pal是Base的友元。
	
	int f2(Sneaky s) { return s.j; }	// 错误:Pal不是Sneaky的友元。
	// 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此。
	int f3(Sneaky s) { return s.prot_mem; }	// 正确: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 {	// 注意:private继承。
public:
	// 保持对象尺寸相关的成员的访问级别
	using Base::size;

protected:
	using Base::n;
};

通过在类的内部使用using声明语句,可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。

  • 如果一条using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问。
  • 如果位于public部分,则类的所有用户都能访问它。
  • 如果位于protected部分,则该名字对于成员、友元和派生类时可访问的。

默认的继承保护级别

默认情况下,使用class关键字定义的派生类是私有继承的;而使用struct关键字定义的派生类是公有继承的。

15.6继承中的类作用域

每个类定义自己的作用域,当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。

名字冲突与继承

派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字:

struct Base {
	Base() : mem(0) {}

protected:
	int mem;
};

struct Derived : Base {
	// 用i初始化Derived::mem,Base::mem进行默认初始化。
	Derived(int i) : mem(i) {}

	int get_mem() { return mem; }	// 返回Derived::mem

protected:
	int mem;	// 隐藏基类中的mem
};

通过作用域运算符来使用隐藏的成员

struct Derived : Base {
	// 作用域运算符将覆盖掉原有的查找规则,并指示编译器从Base类的
	// 作用域开始查找mem。
	int get_base_mem() { return Base::mem; }
	// ...
};

除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。

一如往常,名字查找先于类型检查

定义派生类中的函数也不会重载其基类中的成员。如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉:

struct Base {
	int memfcn();
};

struct Derived : Base {
	int memfcn(int);	// 隐藏基类的memfcn
};

Derived d;
Base b;

b.memfcn();	// 调用Base::memfcn
d.memfcn(10);	// 调用Derived::memfcn
// 为了解析这条调用语句,编译器首先在Derived中查找名字memfcn;因为Derived
// 确实定义了一个名为memfcn的成员,所以查找过程终止。
d.memfcn();	// 错误:参数列表为空的memfcn被隐藏了。
d.Base::memfcn();	// 正确:调用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
bp2 -> fcn();	// 虚调用,将在运行时调用Base::fcn
bp3 -> fcn();	// 虚调用,将在运行时调用D2::fcn

D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2 -> f2();	// 错误:Base没有名为f2的成员。
d1p -> f2();	// 虚调用,将在运行时调用D1::f2()。
d2p -> f2();	// 虚调用,将在运行时调用D2::f2()。

再观察一些对于非虚函数的调用:

Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1 -> fcn(42);	// 错误:Base中没有接受一个int的fcn。
p2 -> fcn(42);	// 静态绑定,调用D1::fcn(int)。
p3 -> fcn(42);	// 静态绑定,调用D2::fcn(int)。

覆盖重载的函数

成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

struct Person {
    void walk() const {
        cout << "Person.walk()" << endl;
    }

    void walk(int step) const {
        cout << "Person.walk(int)" << endl;
    }
};

struct Student : public Person {
    void test() const {
    	// 此时只能调用Student::walk()
        walk();
        cout << "Student.test()" << endl;
    }

	// 如果不覆盖的话,可以调用Person::walk()以及Person::walk(int)。
    void walk() const {
        cout << "Student.walk()" << endl;
    }
};

有时,一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果不得不覆盖基类中的每一个版本的话,显然操作将极其繁琐。
一种好的解决方案是为重载的成员提供一条using声明语句,指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类的作用域中。
类内using声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问。

15.7构造函数与拷贝控制

位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为,这些操作包括创建、拷贝、移动、赋值和销毁。

15.7.1虚析构函数

基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象了。通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:

class Quote {
public:
	// 如果删除的是一个指向派生类对象的基类指针,则需要虚析构函数。
	// 和其他虚函数一样,析构函数的虚属性也会被继承。
	virtual ~Quote() = default;	// 动态绑定析构函数
};

Quote *itemP = new Quote;	// 静态类型与动态类型一致
delete itemP;	// 调用Quote的析构函数
itemP = new Bulk_quote;	// 静态类型与动态类型不一致
delete itemP;	// 调用Bulk_quote的析构函数
虚析构函数将阻止合成移动操作

基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

15.7.2合成拷贝控制与继承

基类或派生类的合成拷贝控制成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
值得注意的是,无论基类成员是合成的版本还是自定义的版本都没有太大影响。唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。

派生类中删除的拷贝控制与基类的关系

基类或派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数。此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:

  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
  • 编译器将不会合成一个删除掉的移动操作。当使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
class B {
public:
	B();

	// 因为定义了拷贝构造函数,所以编译器不会合成一个移动构造函数。
	B(const B &) = delete;
	// 其他成员,不含有移动构造函数。
};

class D : public B {
	// 没有声明任何构造函数
}

D d;	// 正确:D的合成默认构造函数使用B的默认构造函数。
D d2(d);	// 错误:D的合成拷贝构造函数是被删除的。
D d3(std::move(d));	// 错误:隐式地使用D的被删除的拷贝构造函数。

在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。

移动操作与继承

大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当确实需要执行移动操作时应该首先在基类中进行定义。

// Quote可以使用合成的版本,不过前提是Quote必须显式地定义这些成员。一旦
// Quote定义了自己的移动操作,那么它必须同时显式地定义拷贝操作。除非派生类
// 中含有排斥移动的成员,否则它将自动获得合成的移动操作。
class Quote {
public:
	Quote() = default;	// 对成员依次进行默认初始化
	Quote(const Quote &) = default;	// 对成员依次拷贝
	Quote(Quote &&) = default;	// 对成员依次拷贝
	Quote &operator=(const Quote &) = default;	// 拷贝赋值
	Quote &operator=(Quote &&) = default;	// 移动赋值
	virtual ~Quote() = default;
	// 其他成员保持一致
};

15.7.3派生类的拷贝控制成员

派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。

定义派生类的拷贝或移动构造函数
class Base { /* ... */ };
class D : public Base {
public:
	// 默认情况下,基类的默认构造函数初始化对象的基类部分,要想使用拷贝
	// 或移动构造函数,必须在构造函数的初始值列表中显式地调用该构造函数。
	D(const D &d) : Base(d) {	// 拷贝基类成员
		 /* ... */
	}

	D(D &&d) : Base(std::move(d)) {	// 移动基类成员
		/* ... */
	}
};

假设没有提供基类的初始值的话:

// D的这个拷贝构造函数很可能是不正确的定义,基类
// 部分被默认初始化,而非拷贝。
D(const D &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++11新标准中,派生类能够重用其直接基类定义的构造函数。一个类只初始化它的直接基类,出于同样的原因,一个类只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

class Bulk_quote : public Disc_quote {
public:
	using Disc_quote::Disc_quote;	// 继承Disc_quote的构造函数
};

编译器生成的构造函数形如:

derived(params) : base(args) {
}

通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。

继承的构造函数的特点

一个构造函数的using声明不会改变该构造函数的访问级别,而且,不能指定explicitconstexpr,继承的构造函数也拥有相同的属性。
当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。
如果基类含有几个构造函数,大多数时候派生类会继承所有这些构造函数,除了以下两种情况:

  • 派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。
  • 默认、拷贝和移动构造函数不会被继承。

15.8容器与继承

vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
// 正确:但是只能把对象的Quote部分拷贝给basket。
basket.push_back(Bulk_quote("0-201-54848-8", 50, 10, 0.25));
// 调用Quote定义的版本,打印750,即15 * 50。
cout << basket.back().net_price(15) << endl;

当派生类对象被赋值给基类对象时,其中的派生类部分将被切掉,因此容器和存在继承关系的类型无法兼容。

在容器中放置(智能)指针而非对象

当希望在容器中存放具有继承关系的对象时,实际上存放的通常是基类的指针(更好的选择是智能指针):

vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, 0.25));
// 调用Quote定义的版本;打印562.5,即在15 * 50中扣除掉折扣金额。
cout << basket.back()->net_price(15) << endl;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值