15、面向对象程序设计
15、面向对象程序设计
面向对象程序设计(OOP)基于三个基本概念:数据抽象、继承和动态绑定。继承和动态绑定对程序的编写有两方面的影响:一是我们可以定义与其他类相似但不完全相同的新类;二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略它们的区别。
15.1 OOP:概述
继承
通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类通过直接或间接从基类继承而来,这些继承得到的类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身版本的,此时基类就将这些函数声明成虚函数。
class Quote{
public:std::string isbn() const;virtual double net_price(std::size_t n) const;
};
派生类必须使用类派生列表明确指出它是从哪个基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:
class Bulk_quote : public Quote {
public:double net_price(std::size_t n) const override;
};
派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做。C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字。
动态绑定
通过动态绑定,我们能用同一段代码分别处理Quote和Bulk_quote的对象。
注:当我们使用基类的引用或指针调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
15.2.1 定义基类
#include <iostream>
#include <vector>using namespace std;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:std::string bookNo;
protected:double price = 0.0;
};
基类通常应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
成员函数与继承
派生类可以继承其基类地成员,然而遇到如net_price这样与类型有关地操作时,派生类必须对其重新定义。
在C++语言中,基类必须将它地两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不需要改变的函数。对于前者,基类通常将其定义为虚函数。
关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
访问控制和继承
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权限访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们使用受保护访问运算符说明这样的成员。
15.2.2 定义派生类
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; //以小数表示折扣额
};
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。若派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类对象及派生类向基类的类型转换
在一个对象中,继承来自基类的部分和派生类自定义的部分不一定是连续存储的。
因为在派生类对象中含有与其及基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定在派生类对象中的基类部分上。这种转换通常被称为派生类到基类的类型转换。
派生类构造函数
尽管在派生类对象的中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。
派生类构造函数同样是通过构造函数初始化列表将实参传递给基类构造函数的。例如:接受四个参数的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(size_t cnt) const {if(cnt >= min_qty){return cnt * (1-discount) * price;}elsereturn cnt * price;
}
继承与静态成员
如果基类定义了一个静态成员,则在整个继承系统中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
派生类的声明
派生类的声明与其他类型差别不大,声明中包含类名但是不包含它的派生列表:
class Bulk_quote : public Quote;//错误
class Bulk_quote;//正确
被用作基类的类
如果我们想将某个类作为基类,则该类必须已经定义而非仅仅声明:
class Quote;//声明而未定义
//错误:Quote必须被定义
class Bulk_quote : public Quote {...};
防止继承的发生
C++11新标准提供了一种防止继承法发生的方法,即在类名后跟一个关键词final。
15.2.3 类型转换与继承
**注:**理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。
我们可以将基类的指针或引用绑定在派生类对象上。
静态类型和动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表达对象的动态类型区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
若表达式既不是引用也不是指针,则它的动态类型和静态类型一致。
不存在从基类向派生类的隐式类型转换
存在派生类向基类的隐式类型转换,因为派生类继承于基类,每个派生类对象都包含一个基类部分。
不存在基类向派生类的类型转换,因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在基类向派生类的自动类型转换:
Quote base;
Bulk_quote *bulkP = &base;//错误:不能将基类转换成派生类
Bulk_quote& bulkRef = base;//错误: 不能将基类转换成派生类
若上述合法,我们可能会使用bulkP或bulkRef访问base中不存在的成员。
在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针和引用类型有效,在派生类和基类类型之间不存在这样的转换。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类对象部分会被拷贝、赋值或移动,它的派生类部分将被忽略掉。
15.3 虚函数
在C++语言中,当我们使用基类的引用或指针调用一个虚函数成员时会执行动态绑定。因为我们直到运行时才知道到底调用了哪个版本的虚函数,所以所有虚函数必须有定义。
对虚函数的解析可能在运行时才被解析
当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型向匹配的那一个。
派生类中的虚函数
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数一致。同样,派生类中虚函数的返回类型也必须与基类函数匹配。
上述规则存在例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果D由B派生得到,则基类的虚函数可以返回B&而派生类的对应函数返回D&,只不过这样的返回类型要求从D到B的类型是可访问的。
final和override说明符
使用override关键字来说明派生类中的虚函数。
可以将某个函数指定为final,如果我们已经把函数定义成final,则之后的任何尝试覆盖该函数的操作都将引发错误。
final和override说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。
虚函数与默认实参
和其他函数一样,虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
**注:**如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下面的代码:
//强行调用基类定义的函数版本而不管baseP的动态类型是什么
double undiscounted = baseP->Quote::net_price(42);
注:通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
什么时候我们需要回避虚函数的默认机制?通常是当派生类的虚函数调用它覆盖的基类的虚函数版本时。在次情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
15.4 抽象基类
纯虚函数
将net_price定义成纯虚函数从而令程序实现我们设计的意图,这样可以清晰地告诉用户这个net_price函数是没有实际意义的。和普通的虚函数不一样,一个纯虚函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数声明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处:
//用于保存折扣值和购买量的类,派生类使用这些数据
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;
private:std::size_t quantity = 0.0;//折扣适用的购买量double discount = 0.0;//折扣的小数值
};
我们可以为虚函数提供定义,但是函数体必须定义在类的外部。
含有纯虚函数的类是抽象基类
含有(或者未经覆盖继承)纯虚函数的类是抽象基类。不能(直接)创建一个抽象基类的对象。
派生类构造函数只初始化它的直接基类
//重新实现Bulk_quote,继承Disc_quote而非直接继承Quote
//当同一书籍的销售量超过某个值时启用折扣
//折扣的值是一个小于1的正的小数值,以此来降低正常销售价格
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;
};
关键概念:重构
在Quote的继承体系中增加了Disc_quote类是重构的一个典型示例。重构负责重新设计类的体系以便将操和/或数据从一个类移到另一个类中。对于对象程序的应用程序来说,重构是一种很普遍的现象。
15.5 访问控制与继承
每个类分别控制其成员对于派生类是否可访问。
受保护的成员
使用关键字protected来声明那些它希望与派生类分享但是不想被其他公有访问使用的成员。是private和public中和后的产物。
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护对象没有任何访问权限。
公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受到两个因素的影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
#include <iostream>using namespace std;class Base {
public:void pub_mem();
protected:int prot_mem;
private:char priv_mem;
};struct Pub_Derv : public Base {int f() {return prot_mem; }//char g() {return priv_mem; } //错误
};struct Priv_Derv : private Base {//private不影响派生类的访问权限int f1() const {return prot_mem; }
};struct Derived_from_Public : public Pub_Derv {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; }
};
派生访问说明符对于派生类的成员(或友元)能否访问其直接基类的成员没什么影响。对于基类成员的访问权限只与基类中的访问说明符有关。Pub_Derv和Priv_Derv都能访问受保护的成员prot_mem,同时它们都不能访问私有成员priv_mem。
派生类访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:
Pub_Derv d1;Priv_Derv d2;Prot_Derv d3;d1.pub_mem();//d2.pub_mem(); 错误 pub在派生类中是private的//d3.pub_mem(); 错误 pub在派生类中是protected
派生类向基类转换的可访问性
友元与继承
友元关系不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。
改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用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;
};
因为Derived使用了私有继承,所以继承而来的成员size和n(在默认情况下)是Derived的私有成员。然而我们使用using声明语句改变了这些成员的可访问性。改变之后,Derived的成员将可以使用size成员,而Derived的派生类将能使用n。
**注:**派生类只能为那些它可以访问的名字提供using声明。
默认的继承保护级别
struct公有、class私有
15.6 继承中的类作用域
嵌套作用域
在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的指针或引用时会发生这种情况),但是我们能使用哪些成员仍是由静态类型决定的。
名字冲突与继承
派生类的成员隐藏同名的基类成员
通过作用域运算符来使用隐藏的成员
可以通过作用域运算符来使用一个被隐藏的基类成员:
struct Derived : Base {int get_base_mem() { return Base::mem; }
};
作用域运算符将覆盖原来的查找规则,并指示编译器从Base类的作用域来查找mem。
**注:**除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
构造函数与拷贝构造
15.7.1 虚析构函数
**注::**如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
虚析构函数将阻止合成移动操作
基类需要一个虚析构函数会对基类和派生类的定义产生另外一种间接的影响:如果一个类定义了析构函数,即使它通过==default的形式使用了合成的版本,编译器也不会位这个类合成移动操作。
15.7.2 合成拷贝控制与继承
对类本身的成员依次进行初始化、赋值或销毁的操作。先初始化、拷贝或销毁基类的成员,再初始化、拷贝或销毁派生类的成员。
定义了析构函数而不能拥有合成的移动操作,因此当我们移动基类对象时实际使用的是合成的拷贝操作。
派生类中删除的拷贝控制与基类的关系
移动操作与继承
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)将一个D对象传递给基类构造函数。Base的拷贝构造函数负责将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++11新标准下,派生类能够重用其直接基类定义的构造函数。一个类只初始化它的直接基类,出于同样的原因,一个类也只能继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一条标注了(直接)基类名的using声明语句。例如,重新定义Bulk_quote类,令其继承Disc_quote类的构造函数:
class Bulk_quote : public Disc_quote {
public:using Disc_quote::Disc_quote;//继承Disc_quote的构造函数double net_price(std::size_t) const;
};//等价于
Bulk_quote(const std::string& book, double price, std::size_t qty, double disc): Disc_quote(book, price, qty, disc) { }
继承的构造函数的特点
和普通成员的using声明不一样,一个构造函数的using声明不会改变构造函数的访问级别。例如,不管using声明在哪里,基类的私有构造函数在派生类中也是私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。
而且,一个using声明语句不能指定explicit或constexpr。如果基类的构造函数是explicit或constexpr,则继承的构造函数也拥有相同的属性。
当一个基类的构造函数含有默认实参,这些实参不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。
15.8 容器与继承
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指的对象的动态类型可能是基类类型,也可能是派生类类型。
15.8.1 编写Basket类
对于面向对象C++编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以我们通常定义一些辅助的类。首先,我们定义一个表示购物蓝的类:
class Basket {
public://Basket使用合成的默认构造函数和拷贝构造函数void add_item(const std::shared_ptr<Quote> &sale) {items.insert(sale);}
private://该函数用于比较shared_ptr,multiset成员会用到它static bool compare(const std::shared_ptr<Quote> &lhs, const std::shared_ptr<Quote> &rhs){return lhs->isbn() < rhs->isbn();}//multiset保存多个报价,按照compare成员排序std::multiset<std::shared_ptr<Quote>, decltype(compare)*> items{compare};
};
定义Basket的成员
定义total_receipt成员,它负责将购物篮的内容逐项打印成清单,然后返回购物篮中所有物品的总价格:
double Basket::total_receipt(ostream &os) const
{double sum = 0.0;//iter指向ISBN相同的一批元素中的第一个//upper_bound返回一个迭代器,该迭代器返回指向这批元素的尾后位置for(auto iter = items.cbegin(); iter != items.cend(); iter = items.upper_bound(*iter)) {sum += print_total(os, **iter , items.count(*iter));}os << "Total Sale: " << sum << endl;return sum;
}
隐藏指针
Basket的用户仍然处理动态内存,原因是add_item需要接受一个shared_ptr参数。
重新定义add_item,使得它接受一个Quote对象而非shared_ptr。新版本的add_item将负责处理内存分配,这样它的用户就不必再受困于此。我们将定义两个版本,一个拷贝它给定的对象,另一个则采取移动操作:
void add_item(const Quote& sale);
void add_item(Quote&& slae);//移动给定的对象
add_item不知道分配的类型。当add_item进行内存分配时,它将拷贝(或移动)它的sale参数。
new Quote(sale);
上面的表达式所做的工作可能是错误的:new为我们请求的类型分配内存,因此这条表达式将分配一个Quote类型的对象并且拷贝sale的Quote部分。然而sale实际指向的类型可能是Bulk_quote对象,此时,该对象被迫切掉一部分。
class Quote{
public://该虚函数返回当前对象的一份动态分配的拷贝virtual Quote* clone() const & {return new Quote(*this); }virtual Quote* clone() && {return new Quote(std::move(*this)); }
};class Bulk_quote : public Quote {
public:Bulk_quote* clone() const & {return new Bulk_quote(*this); }Bulk_quote* clone() && {return new Bulk_quote(std::move(*this)); }
};
可以使用clone很容易写出新版本的add_item:
class Basket {
public:void add_item(const Quote& sale){items.insert(std::shared_ptr<Quote>(sale.clone()));}void add_item(Quote&& sale){//调用move函数将右值引用绑定到sale上items.insert(std::shared_ptr<Quote>(std::move(sale).clone()));}
};