面向对象程序设计
在很多程序中都存在着一些,相互关联但是有细微差别的概念。例如:书店中不同书籍的定价策略可能不同:有的书籍按照原价卖,有的书籍打折销售,有时我们为购买书籍超过一定数量的顾客打折,有时只对前多少本销售的书籍打折等等。面向对象的程序设计(OOP)适用于这类应用。
一、OOP概述
面向对象程序设计(OOP)的核心思想是:数据抽象、继承、动态绑定。
- 数据抽象 : 将类的实现和接口分离
- 继承 : 定义相似的类型并对其相似关系建模
- 动态绑定 : 在一定程度上忽略相似类型的区别,以同一的方式使用它们的对象。
继承
通过继承联系在一起的类构成一种层次关系。在层次关系根部有一个基类,其他类直接或间接从基类继承而来,成为派生类。
基类将 与类型相关的函数 和 派生类直接继承的函数 区分对待。与类型相关的函数,基类会将它们声明为虚函数。
为了实现之前所说的定价策略,我们首先定义一个名为Quote的类,表示按原价销售的书籍,它派生出一个Bulk_quote类,表示可以打折销售的书籍。
class Quote {
public:
string isbn() const; //与类型无关
virtual double net_price(size_t n) const; //与类型相关
};
class Bulk_Quote :public Quote {
public:
double net_price(size_t n) const override;
};
动态绑定
通过使用动态绑定,我们可以使用同一段代码分别处理Quote和Bulk_quote的对象。
double print_total(ostream& os,const Quote &item,size_t n)
{
double ret = item.net_price(n);
os << item.isbn() << n << ret << endl;
return ret;
}
print_total(cout,basic,20); //执行Quote版本的
print_total(cout,bulk,20); //执行Bulk_Quote版本的
从上述代码中我们可以看出,由于形参的item是基类类型的引用,因此我们可以通过基类和派生类调用这个函数。而基类和派生类的net_price()函数是不同的,当我们调用net_price()时,实际的对象类型将会决定到底执行哪个版本。
当我们使用基类的引用或指针调用虚函数时,将发生动态绑定。
二、定义基类和派生类
1. 定义基类
定义Quote类。
class Quote {
public:
Quote() = default;
Quote(const string &book,double sales_price):bookNo(book),price(sales_price){}
string isbn() const { return bookNo; } //与类型无关
virtual double net_price(size_t n) const { return n * price; } //与类型相关
virtual ~Quote() = default; //基类都会定义一个虚析构函数
private:
string bookNo;
protected:
double price = 0.0;
};
成员函数与继承
- 我们将基类中与类型相关的函数定义为 虚函数(virtual)
- 派生类继承了基类的虚函数,并且需要对这些操作提供自己的新定义来覆盖从基类继承而来的旧定义
- 构造函数 和 static函数不可为虚函数
- 当使用指针和引用调用虚函数时,会发生运行时绑定。
访问控制与继承
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。
派生类 可以访问基类中的公有成员,不可以访问基类中的私有成员。
而对于基类中的保护成员,派生类可以访问,但是其他类不可以访问。
2. 定义派生类
派生类必须通过使用派生类列表明确指出它是从哪些基类继承而来的。派生类列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面有以下三种访问说明符中的一个:public、protected、private。
class Bulk_Quote : public Quote {
public:
Bulk_Quote() = default;
Bulk_Quote(const string&, double, size_t, double);
double net_price(size_t n) const override;
private:
size_t min_qty = 0; //打折的最低购买量
double discount = 0.0; //折扣额
};
对于访问说明符,现在我们只需要知道,访问说明符是为了控制派生类从基类继承而来的成员是否对派生类的用户可见。
如果一个派生类是公有的,那么基类中的公有成员也是派生类接口的组成部分。
并且我们能将公有派生类型的对象绑定到基类的引用或指针上。
派生类对象及派生类向基类的类型转换
一个派生类的对象包含多个组成部分。
- 一个含有派生类自己定义成员的子对象(如min_qty、 discount)
- 一个含有基类定义成员的子对象(如bookNo、price)
因为派生类对象中含有与基类对象相对应的组成部分,所以可以将派生类对象转化为基类对象,也可以将基类的引用和指针绑定到派生类对象的基类部分上。
Quote item;
Bulk_Quote bulk;
Quote *p = &item;
p = &bulk;
Quote *t = &bulk;
派生类构造函数
尽管派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员,必须使用基类的构造函数来初始化它的基类部分。
因为每个类负责定义各自的接口,想要与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
Bulk_Quote::Bulk_Quote(const string & book, double pri, size_t qty, double disc):
Quote(book,pri),min_qty(qty),discount(disc){}
在初始化的过程中,先对基类部分进行初始化,再对派生类进行初始化。
派生类使用基类的成员
派生类可以访问基类的public和protected成员。
double Bulk_Quote::net_price(size_t n) const
{
if (n >= min_qty)
return n * (1 - discount) * price;
return n * price;
return 0.0;
}
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。
如果某静态成员是非private的,那么我们可以通过基类或者派生类来访问它。
class Base{
public:
static void statmem();
};
class Derived : public Base{
void f(const Derived& d)
{
Base::statmem(); //通过基类访问
Derived::statmem(); //通过派生类访问
d.statmem(); //通过派生类的对象访问
statmen(); //通过this访问
}
};
派生类的声明
派生类的声明中包含类名但是不包含它的派生列表。
因为声明只是让程序知道一个名字的存在、以及该名字表示了一个怎样的实体(类、函数、变量)。派生列表以及与定义有关的其他细节必须与类的主体一起出现。
class c; //正确
class d : public Quote; //错误
如果我们将一个类作为基类,那么这个类一定是被定义而非仅仅声明。因此,一个类不可以派生它本身。
防止继承
有时候我们希望一个类不被继承,C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final。
class NoDerived final{
……
}; //NoDerived不能作为基类
class Last final : public Base{
……
}; //Last不能作为基类
3. 类型转换与继承
通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应该与对象一致。
存在继承关系的类是一个重要的例外,我们可以将基类的指针和引用绑定到派生类对象上。
那么,当我们在使用基类的指针或引用时,实际上我们并不清楚该引用或指针绑定对象的真实类型。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该变量或表达式表示对象的的动态类型区分开来。
- 静态类型 : 在编译时已知,是变量声明的类型或表达式的生成类型
- 动态类型 : 在运行时才知道,是变量或表达式 表示的内存中的对象的类型
以我们前面所说的print_total为例,item的静态类型为Quote,动态类型可能是Quote或Bulk_quote。
如果表达式既不是指针也不是引用,那么它的静态类型和动态类型一定一致。
如果表达式是指针或引用,那么它的静态类型和动态类型不一定一致。
关于类型转换,派生类向基类的自动类型转换只对指针或引用有效,在派生类类型和基类类型之间不存在这样的转换。
Bulk_quote bulk;
Quote item(bulk); //调用基类的拷贝构造函数,参数类型为const Quote&
item = bulk; //调用基类的拷贝赋值运算符,参数类型为const Quote&
当我们用派生类对象来初始化或赋值基类对象时,调用的是基类的拷贝控制操作,派生类对象中的基类部分会被拷贝、赋值、移动,而派生部分则会被忽略。
存在继承关系的类型之间的转换规则:
- 派生类向基类的类型转换只对指针或引用有效
- 派生类对象和基类对象不存在隐式类型转换
- 派生类向基类的类型转换会受到访问权限的影响
三、虚函数
当我们使用基类的指针或引用调用一个虚函数时,会发生动态绑定,即无法确定会调用哪个版本的虚函数。因此,我们必须对所有的虚函数进行定义,而不管它是否被用到,因为编译器根本不知道会调用哪个虚函数。
C++的多态性
OOP的核心思想是多态。多态即具有继承关系的类,当我们使用这些类时,可以忽略它们之间的差异,统一使用它们。
由此我们可以看出 多态的根本 是 指针或引用的静态类型与动态类型不一定相同。
当我们使用基类的指针或引用时,无法确定它的静态类型和动态类型是否一致;当我们使用基类的指针或引用调用虚函数,无法确定它的动态类型,因此产生多态。
派生类中的虚函数
一个派生类的函数如果覆盖了某个继承而来的虚函数,那么
- 形参类型必须与基类完全一致
- 返回类型必须与基类完全一致(除非基类的返回类型为 基类的指针或引用,那么在有访问权限的情况下,派生类的返回类型可以是 派生类的指针或引用)
final和override说明符
我们可以定义与基类的虚函数同名但是形参列表不同的函数,但是此时,这个函数不会覆盖从基类继承而来的虚函数,而是与它相互独立。
就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能想要覆盖虚函数但是把形参列表弄错了。
在C++11新标准中,我们可以使用override关键字来说明派生类中的虚函数,这么做的好处是在使得程序员的意图更加清晰的同时,让编译器可以为我们发现一些错误。如果我们使用overrride标记了某个函数,但是没有覆盖已存在的虚函数,这时编译器将会报错。
我们还可以将一个函数指定为final的,这样任何尝试覆盖该函数的操作发生错误。
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。通常情况下,当派生类对象想要使用基类对象的虚函数时,不希望进行动态绑定。使用作用域运算符可以实现这一目的。
double undiscounted = baseP->Quote::net_price(42);
作用域运算符强行调用Quote的net_price函数。
四、抽象基类
纯虚函数
若一个函数为纯虚函数,则告诉用户这个函数是没有实际意义的。
我们通过=0将一个虚函数说明为纯虚函数,=0只能出现在函数体内部。
一个纯虚函数,不要求有函数体,但也可以有函数体,不过函数体必须定义在类的外部。
class Disc_quote : public Quote {
public:
Disc_quote() = default;
Disc_quote(const string& book,double pri,size_t n,double dis):
Quote(book,pri),quantity(n),discount(dis){}
double net_price(size_t) const = 0; //纯虚函数
private:
size_t quantity = 0;
double discount = 0.0;
};
含有纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能直接创建一个抽象基类的对象。