写在前面:在19年UML那篇文章里提到过要系统的梳理一下OOP的相关内容,两年之后终于来填坑了!本文内容严重参考《C++ Primer》5th ch15。
面向对象程序设计概述
核心思想:数据抽象、继承和动态绑定。
- 数据抽象:类的接口和实现分离;
- 继承:定义相似类型并对其关系建模;
- 动态绑定:一定程度上忽略类型的区别,使用相对统一的方式使用他们的对象。
简单提一下数据抽象,直观上理解就是:一个包含接口(一些方法)的类,就是一个抽象数据类型,即程序员无法访问该类中未暴露出来的数据对象;反之,那些允许用户访问数据成员,并由用户编写操作的类就不是抽象数据类型。(这里的用户指的是使用这个类进行开发的程序员)
继承
基类:祖宗
派生类:这个祖宗的子孙,因此派生类直接或者间接地从基类继承而来。
因为基类与派生类中存在着一些相互关联但是有细微差别的概念,比如同样的一个方法,在不同类别的对象中表现就不一样。所以,那些不涉及类的特殊性的方法,就定义在基类中;反之那些类型相关的操作,应该在基类和派生类中都有实现。可以看一下下面的例子:
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
关键字,也可以如上段代码所示,使用override
关键字(C++11)
动态绑定
让我们跳过晦涩难懂的概念,直接看这段代码:
double print_total(ostream &os, const Quote &item, size_t n) {
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() <<
" # sold: " << n <<
" total due: " << ret << endl;
return ret;
}
上面这段代码给出了使用动态绑定的一个直观概念。根据print_total
中形参item
的类型不同,会调用不同的net_price
函数。所以print_total
的运行版本由实参类型决定,即在运行时确定函数的版本,所以动态绑定有时也叫运行时绑定。
具体为什么会出现动态绑定,我们会在下面的章节中进行解释。
注意:当我们使用基类的引用或者指针调用一个虚函数时会出现动态绑定。
定义基类和派生类
定义基类
我们完成Quote
的定义:
class Quote {
public:
Quote() = default;
Quote(const std::string &book, double sales_prices):
bookNo(book), price(sales_prices) { }
std::string isbn() const { return bookNo; }
virtual double net_price (std::siez_t n) const { return n * price; }
virtual ~Quote() = default;
private:
std::string bookNo;
protected:
double price = 0.0;
};
需要解释的有以下几点:
- 基类中通常会定义一个虚析构函数。具体的原因会在后文中解释;
- 对于那些基类希望派生类直接继承的函数,不要定义为虚函数,这类函数的解析过程发生在编译阶段,并不涉及动态绑定的相关知识;
- 对于那些基类希望派生类进行覆盖的函数,定义为虚函数即可;
protected
关键字的作用是:允许基类的派生类访问,但是不允许其他用户访问。
定义派生类
派生类必须使用类派生列表,就是指出它是从哪个基类继承来的。形式是一个冒号,后面紧跟逗号分隔的基类列表,基类前可以加上public, protected, private
三个访问说明符其中之一。如果一个派生是public的,则基类的公有成员也是派生类接口的组成部分。
让我们完成Bulk_quote
的定义:
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;
};
派生类中的虚函数
派生类大多数情况下会覆盖它继承的虚函数。如果派生类没有覆盖基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承基类中的版本。
覆盖时可以在函数前添加virtual
关键字或者使用override
关键字(C++11)。
派生类对象以及派生类向基类的类型转换
一个派生类的对象包含多个组成部分,总的来说包括派生类自己定义的对象和继承的基类对应的子对象,所以,一个Bulk_quote对象包含四种数据元素:
std::string bookNo //Quote
double price; // Quote
double min_qty; // Bulk_quote
double discount; // Bulk_quote
/*
* a Bulk_quote may be like:
* +---------+
* | bookNo |
* | price |
* +---------+
* | min_qty |
* | discount|
* +---------+
*/
那么,在派生类向基类进行类型转换时,其实是将基类的指针或引用绑定到派生类对象中的基类部分上。
以下代码是派生类到基类的类型转换。
Quote item;
Bulk_quote bulk;
Quote *p = &item;
p = &bulk; // p指向bulk的Quote部分
Quote &r = bulk; // r绑定到bulk的Quote部分
/*
* bulk :
* +----------+ -
* | bookNo | } Quote *p
* | price |
* +----------+ -
* | min_qty |
* | discount |
* +----------+
*/
派生类构造函数
派生类并不能直接初始化那些从基类继承过来的成员,派生类必须使用基类的构造函数初始化自己的基类部分。
除非我们特别指出,否则派生类对象的基类部分执行默认初始化,如果想使用其他的基类构造函数,需要显式地指出,如下所示:
Bulk_quote(const std::string& book, double p,
std::size_t qty, double disc) :
Quote(book, p), min_qty(qty), discount(disc) { }
// 注意 Quote(book, p) 这部分
派生类使用基类的成员
派生类可以使用基类的公有成员和受保护成员。目前我们只需知道,派生类的作用域嵌套在基类的作用域中。
继承与静态成员
如果基类定义了静态成员,那么整个继承体系中只存在该成员的唯一定义。假设该静态成员是可访问的(public or protected),那么我们可以通过基类或者派生类访问这个成员。
class Base {
public:
static void statmem();
};
class Derived : public Base {
void f(const Derived&);
};
void Derived::f(const Derived& derived_obj) {
Base::statmem(); //
Derived::statmem(); //
derived_obj.statmem(); // 通过Derived对象访问
statmem(); // 通过this对象访问
}
派生类的声明
派生类的声明与声明一个变量类似:class Bulk_quote;
被用作基类的类
被用作基类的类必须被定义而非仅仅声明。因为派生类要初始化并使用从基类继承来的数据成员,所以一个类不能派生他自己。
一个类可以即是派生类,又是基类。
class Base {...};
class D1 : public Base {...};
class D2 : public D1 {...};
如此就形成了一个继承链,依次分析即可。
防止继承发生
C++11中提供了一个关键字final
,这个关键字表示这个类不能作为基类。
class NoDeirved final {...};
class Base {...};
class Last final : Base {...};
class Bad : NoDerived {...};
class Bad2 : Last {...};
20210527:待续
类型转换与继承
当使用基类指针(或引用)时,实际上我们并不完全清楚该指针(或引用)所绑定对象的真实类型,该对象可能是基类的对象也可能是派生类的对象。
注意:智能指针也支持派生类向基类的类型转换,也就是说可以将一个派生类的对象指针存在基类的智能指针内。
静态类型与动态类型
double print_total(ostream &os, const Quote &item, size_t n) {
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() <<
" # sold: " << n <<
" total due: " << ret << endl;
return ret;
}
简单理解的话,静态类型是编译时已知的,即显式地声明在代码中的;而动态类型是实际内存中对象的类型,知道运行时才可知。
例如在调用print_total
时,我们知道item
的静态类型是Quote&
,而动态类型则依赖于item
绑定的实参,例如它的动态类型可以是Bulk_quote
。
如果表达式既不是指针,也不是引用,那么它的动态类型和静态类型永远一致。
不存在基类向派生类的隐式类型转换
非常重要!
如前文所述,之所以存在从派生类向基类的类型转换,是因为派生类中包含基类的部分,因此基类的指针或者引用可以绑定到派生类的基类部分上,而基类的对象不包含派生类中自行实现的部分,因此不存在从基类向派生类的类型转换,因为如果这样合理,那么很可能会出现用基类指针访问派生类中自行实现的数据对象的情况。
Quote base;
Bulk_quote *bulkp = &base; // error
Bulk_quote &bulkr = base; // error
而且还有一种看似合理的情况:
Bulk_quote bulk;
Quote *itemp = &bulk; // OK
Bulk_quote *bulkp = itemp; // error
注意上述代码的第三行,在逻辑上是合理的,因为此时itemp
绑定的是一个Bulk_quote
的基类部分,但是编译器并不能做到这么智能,因为编译器只能通过静态类型转换来确定转换是否合法。当然,如果你能确定上述类型转换是合法的,那么可以使用static_cast
关键字执行静态的类型转换,该关键字多用于执行编译器无法自行执行的类型转换。(注:这里省去了dynamic_cast
的介绍,有兴趣的同学可以自行查阅)
在对象之间也不存在类型转换
派生类向基类的自动类型转换只对指针或者引用有效,在派生类类型以及基类类型之间不存在这样的转换。
当我们初始化或者赋值一个类类型的对象时,实际上是在调用某个函数,比如构造函数、赋值运算符等等(可能不会显式地体现出来),这些成员函数通常包含一个参数,参数类型通常是类类型的const版本的引用:void Foo(const Base&)
,因此允许给基类传递一个派生类的对象。
Bulk_quote bulk;
Quote item(bulk);
item = bulk;
上述代码是正确的,在构造item
时,只能处理Quote
中的成员,而且会忽略bulk
中Bulk_quote
部分的成员。简言之,拷贝时只拷贝自己的部分,并切掉(cut off)不属于自己的部分。在本例中指切掉Bulk_quote
的部分。
本节中重点的概念是存在继承关系的类型之间的转换原则:
- 从派生类到基类的转换只对指针或者引用有效;
- 基类向派生类不存在隐式类型转换;
- 派生类和基类的类型转换也可能因为访问受限而变得不可行,这里涉及到后文中提到的可访问性问题。
虚函数
与一般的函数不同,所有的虚函数都必须有定义,因为编译器也无法确定具体调用哪个函数,所以必须全部实现。
动态绑定只有在使用引用或者指针调用虚函数时才会发生。
派生类中的虚函数
派生类中覆盖的虚函数的返回类型以及形参必须要与基类中定义的虚函数完全一致,但是如果虚函数返回类本身的指针或者引用,上述规则无效。举例来说,D是一个由B派生来的类,那么对于同样的一个虚函数,D中的虚函数可以返回D,B的虚函数可以返回B,该规则要求从D到B的转换是可访问的,可访问的概念后文会具体讲解。
override
与final
关键字
-
override
关键字:使用这个关键字的目的是明确的指出派生类中的虚函数要覆盖基类中同名的虚函数。因为如果在派生类中实现了一个与基类中虚函数同名但是形参列表不同的函数,此时编译器不会报错,认为这个函数并未覆盖基类中存在的虚函数,但是有时程序员并不是这么想的,所以我们使用一个关键字来明确我们的目的,并对编译器进行一定的指导作用。 -
final
关键字:在之前,我们提到了用final
阻止类的继承,类似的也可以用在函数中,一个函数被final
修饰后就不能被其他派生类覆盖。举例来说:
struct B {
virtual void f1(int) const;
virtual void f2();
void f3();
};
struct D2 : B {
void f1(int) const final;
void f2();
};
struct D3 : D2 {
void f1(int) const; // 错误,因为D2中f1已被final修饰
void f2(); // 正确
};
虚函数与默认实参
可以使用默认实参,但是最好保证基类与派生类的默认实参一致。因为本次函数调用的实参值由本次调用的静态类型决定,即当基类中的虚函数与派生类中的虚函数的默认实参不一致时,如果我们通过基类的指针或者引用进行调用,那么默认实参就是基类中定义的值,即使实际运行的是派生类中覆盖的版本也是如此。
#pragma once
#include <iostream>
class Quote {
public:
Quote() = default;
Quote(const std::string& book, double sales_prices) :
bookNo(book), price(sales_prices) { }
std::string isbn() const { return bookNo; }
virtual double net_price(std::size_t n) const { return n * price; }
virtual std::string test(std::string s = "Quote") const {
std::cout << "in Quote: " << s << std::endl;
return s;
}
virtual ~Quote() = default;
private:
std::string bookNo;
protected:
double price = 0.0;
};
class Bulk_quote :
public Quote
{
public:
Bulk_quote() = default;
Bulk_quote(const std::string& book, double p,
std::size_t qty, double disc) :
Quote(book, p), min_qty(qty), discount(disc) { }
double net_price(std::size_t) const override;
std::string test(std::string s = "Bulk_quote") const override;
private:
std::size_t min_qty = 0; // 适用折扣的最低购买量
double discount = 0.0;
};
double Bulk_quote::net_price(size_t cnt) const {
if (cnt >= min_qty) return cnt * (1 - discount) * price;
else return cnt * price;
}
std::string Bulk_quote::test(std::string s) const {
std::cout << "in Bulk_quote: " << s << std::endl;
return s;
}
int main()
{
Quote* itemp = new Quote("aaaaaa", 10);
Bulk_quote* bulkp = new Bulk_quote("aaaaaa", 10, 100, 0.7);
itemp = bulkp;
itemp->test();
bulkp->test();
return 0;
}
输出结果是:
in Bulk_quote: Quote
in Bulk_quote: Bulk_quote
从结果中发现,itemp
在调用test
时,实际上调用的是Bulk_quote
中的版本,在Bulk_quote
中test
的默认实参是'Bulk_quote',但是输出的却是'Quote',验证了上面的说法,即默认实参的值由本次调用的静态类型决定。
回避虚函数的机制
我们有时候希望对虚函数的调用不要进行动态绑定,而是强迫其执行某一个特定的版本,可以使用作用域运算符完成这个目的:
double undiscounted = baseP->Quote::net_price(10);
此时,不管baseP
绑定的是什么类型的对象,net_price
会一直执行Quote
类型中定义并实现的版本。此调用在编译时确定。
20210531待续
抽象基类
假设我们希望对上面的书店模拟再新增几种折扣策略,每种策略都需要不同的购买量以及折扣,因此我们可以定义一个类Disc_quote
表示折扣策略,表示特定策略的类将分别继承自Disc_quote
,每个派生类通过定义自己的net_price
函数来实现各自的折扣策略。
对于Disc_quote
而言,它的net_price
函数实际上是没有任何实际含义的,这个类仅表示折扣策略的抽象,而不具体指代某个特定的折扣策略,即net_price
的具体实现要放在继承Disc_quote
的派生类中。
在具体实现上,我们可以让Disc_quote
继承Quote
,进而不新定义net_price
函数,使用Quote
的net_price
函数,但是这样,用户就可以实例化一个Disc_quote
对象,当这个对象作为实参传递给例如print_total
函数时,会返回一个不打折购书的价格。在代码的行为上来说这样没什么问题,但是这会给用户带来困扰,因为如果使用Disc_quote
很大程度上意味着想要打折购书,但是代码具体的行为却与不打折购书一致。
纯虚函数
上述问题的症结也已经提到过,即:Disc_quote
仅表示折扣策略的抽象,而不具体指代某个特定的折扣策略。换句话说,我们并不希望创建一个Disc_quote
对象,因为这本身没有任何意义,我们更关注那些Disc_quote
的派生类,因为这些是具体的折扣策略。
那么,我们可以通过纯虚函数来实现我们的设计目的,纯虚函数与虚函数之间有一些区别,一个纯虚函数无需定义,我们可以看一个Disc_quote
的具体定义:
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) const = 0;
protected:
std::size_t quantity = 0;
double discount = 0.0;
};
可以看到double net_price(std::size_t) const = 0;
是一个纯虚函数的声明。而且纯虚函数也是可以定义的,不过必须在类的外部实现。
含有纯虚函数的类是抽象基类
抽象基类负责定义接口,而后续的类可以覆盖该接口。我们无法创建一个抽象基类的对象,可以创建一个派生类的对象,但是派生类必须给出纯虚函数的定义,否则仍是抽象基类。
派生类的构造函数只初始化它的直接基类
由以上的理论基础,我们现在来重新定义Bulk_quote
:
class Bulk_quote :
public Disc_quote
{
public:
Bulk_quote() = default;
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;
};
这个版本的Bulk_quote
的直接基类是Disc_quote
,间接基类是Quote
,虽然Bulk_quote
并不存在自己的数据成员,但是仍需要提供接受四个参数的构造函数。该构造函数首先将实参传递给Disc_quote
的构造函数,随后Disc_quote
的构造函数再调用Quote
的构造函数。即:派生类的构造是一个类似递归的过程。
访问控制与继承
每个类控制着自己成员的初始化过程,同时也控制着其成员对于派生类来说是否可访问。
受保护的成员
- 对于类的用户来说,protected的成员是不可访问的;
- 对于基类的派生类来说,是可以访问的;
- 派生类的成员或者友元只能通过派生类对象访问基类的受保护成员,且派生类的成员或者友元只能访问派生类对象中的基类部分,而无法访问普通基类对象中的成员。
class Base {
protected:
int prot_mem;
};
class Sneaky : public Base {
friend void clobber(Sneaky&);
friend void clobber(Base&); // error
int j;
};
void clobber(Sneaky& s) { s.j = s.prot_mem = 0; }
void clobber(Base& b) { b.prot_mem = 0; } // error
如果派生类可以访问基类的受保护成员,那么第二个clobber
是合法的,而且这个函数并不是Base
的友元,但是也可以修改Base
对象的内容。因此如果这样合理的话,我们只需要定义一个形如Sneaky
的类就可以绕过protected
机制。