文章目录
OOP 概述
很多程序中都存在着一些相互关联但有细微差别的概念。例如,书店中不同书籍的定价策略可能不同:有的书按照原价销售,而有的打折;又或是,当顾客购买的书超过一定数量时打折;又或者只对前多少本书打折。面向对象程序设计适用于这类应用。
面向对象程序设计(object-oriented programming) 的核心思想是数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似的关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
继承
通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
对上面提到的不同定价策略建模,我们首先定义一个名为 Quote 的类,并将它作为层次关系中的基类。Quote 对象表示按原价销售的书籍。Quote 派生出另一个名为 Bulk_quote 的类,它表示可以打折销售的书籍。
这些类将包含下面两个成员函数:
- isbn(),返回书的 isbn 编号。该操作不涉及派生类的特殊性,因此只定义在 Quote 中
- net_price(size_t),返回书籍的实际销售价格,前提是用于购买该书的数量达到一定标准。这个操作显然是类型相关的,Quote 和 Bulk_quote 都应该包含该函数
在 C++ 中,基类将某些类型相关的函数与派生类不做改变直接继承的函数区别对待。对于与类型相关的函数,基类希望它们的派生类各自定义适合自己的版本,此时基类就将这些函数声明成虚函数。因此,我们可以将 Quote 类这样编写:
class Quote {
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const;
};
派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符。
class Bulk_quote : public Quote { // Bulk_quote 继承 Quote
public:
double net_price(std::size_t n) const override;
}
我们可以发现,Bulk_quote 派生列表中使用了 public,所以我们完全可以把 Bulk_quote 的对象当成 Quote 的对象来使用。
派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上 virtual,但非必须。C++11 允许显式地注明它将使用哪个成员函数改写基类的虚函数,具体做法是在参数列表后加上 override 关键字。
动态绑定
使用动态绑定,我们能用同一段代码分别处理 Quote 和 Bulk_quote 对象。例如,当要购买的书籍和购买的数量已知时,下面的函数负责打印总费用:
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;
}
关于上面的函数有两个有意思的结论:因为函数 print_total 的 item 形参是 Quote 的一个引用,我们既能使用基类 Quote 对象调用该函数,也能用派生类 Bulk_quote 调用该函数;又因为 print_total 是使用引用类型调用 net_price,所以实际传入 print_total 的对象类型将决定到底指向哪个 net_price:
// basic 的类型是 Quote,bulk 是 Bulk_quote
print_total(cout, basic, 20); // 调用 Quote 的
print_total(cout, bulk, 20); // 调用 Bulk_quote 的
在 C++ 中,当我们使用基类的引用 (或指针) 调用一个虚函数时将发生动态绑定。
定义基类和派生类
定义基类
首先有 Quote 类的定义:
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; // 书的 isbn
protected:
double price = 0.0;
};
记住,作为继承关系中的根节点的类通常都会定义一个虚析构函数。即使该函数不进行任何实际操作。
成员函数与继承
派生类可以继承基类的成员,但对于 net_price 这样与类型相关的操作时,派生类需要对这些操作提供自己的新定义以覆盖 (override) 从基类继承而来的旧定义。
C++ 中,基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不改变的函数。对于前者,基类通常将其定义为虚函数,以便该调用被动态绑定。
任何构造函数之外的非静态函数都可以是虚函数。关键字 virtual 只能出现在类内部的声明语句中,不能用于类外部的函数定义。派生类继承而来的虚函数隐式地也是虚函数。
成员函数如果没有被声明成虚函数,其解析过程发生在编译时而非运算时。如果 isbn() 函数一样,无论是基类还是派生类,都不会出现执行哪个版本的问题。
访问控制与继承
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定能够访问从基类继承而来的成员。派生类能访问公有成员,不能访问私有成员。同时,protected 访问说明符指明这些成员能够被派生类访问,但不能被其他用户访问。
定义派生类
派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符。 访问说明符有三种:public、private 和 protected。
派生类必须将其继承而来需要覆盖的成员函数重新声明,因此我们的 Bulk_quote 类必须包含一个 net_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;
~Bulk_quote() { }
private:
std::size_t min_qy = 0; // 使用折扣政策的最低购买量
double discount = 0; // 以小数表示的折扣额
};
如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上。
所以我们的 Bulk_quote 的接口隐式包含 isbn 函数,同时,在任何需要 Quote 的引用或指针的地方我们都能使用 Bulk_quote 的对象。
派生类中的虚函数
派生类经常 (但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似其他普通成员,派生类会直接继承其在类中的版本。
派生类可以在它覆盖的函数前使用 virtual 关键字。C++11 允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数,具体做法如上。
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分:一个含有派生类自己定义的 (非静态) 成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。
因此,一个 Bulk_quote 对象将包含四个数据元素:它从 Quote 继承而来的 bookNo 和 price 数据成员,以及 Bulk_quote 自己定义的 min_qty 和 discount 成员。
C++ 并没有明确规定派生类的对象在内存中如何分布,以及在一个对象中,继承自基类的部分和派生类自定义的部分不一定是连续存储的。
派生类对象中含有与其基类对应的组成部分,所以我们能把派生类对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。
Quote item;
Bulk_quote bulk;
Quote *p = &item;
p = &bulk; // p 指向 bulk 的 Quote 部分
Quote &r = bulk; // r 绑定到 bulk 的 Quote 部分
这种转换通常称为派生类到基类类型转换。编译器会隐式执行派生类到基类的转换。
派生类构造函数
尽管派生类对象中含有从基类继承而来的成员,但是派生类不能直接初始化这些成员。派生类必须通过基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程
派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化的。派生类构造函数通过构造函数初始化列表来将实参传递给基类构造函数。
例如,接受四个参数的 Bulk_quote 构造函数如下:
Bulk_quote::Bulk_quote(const std::string &book, double p, std::size_t qty, double disc):
Quote(book,p), min_qy(qty), discount(disc) { }
除非我们特别指出,否则派生类对象的基类部分就会像数据成员一样执行默认初始化。
派生类的成员初始化顺序:先初始化基类部分,然后按照声明的顺序依次初始化派生类的成员。
派生类使用基类的成员
派生类可以访问基类的公有成员和受保护成员:
double Bulk_quote::net_price(std::size_t cnt) const {
if(cnt >= min_qy)
return cnt * (1 - discount) * price;
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(); // ok,Base 定义了 statmem
Derived::statmem(); // ok,Derived 继承了 statmem
// ok,派生类的对象能访问基类的静态成员
derived_obj.statmem(); // 通过 dervied_obj 对象访问
statmem(); // 通过 this 访问
}
派生类的声明
派生类的声明与普通类一样,不用包含它的派生列表:
class Bulk_quote : public Quote; // 错误,派生列表不能出现在这里
class Bulk_quote; // ok,声明派生类的正确方式
被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明:
class Quote;
class Bulk_quote : Quote { ... }; // 错误
原因显而易见,派生类需要包含且使用基类的成员,所以基类必须被定义而非声明。通过这个规定我们可以知道,一个类不能派生它本身。
一个类是基类的同时,也可以是派生类:
class Base { /* */ };
class D1 : public Base { /* */ };
class D2 : public D1 { /* */ };
在这个继承关系中,Base 是 D1 的直接基类,是 D2 的间接基类。所以我们可以知道,对于一个派生类来说,它将包含它的直接基类的子对象以及每个间接基类的子对象。
防止继承的发生
C++11 中提供了一种防止继承的方法:在类名后跟一个关键字 final:
class NoDerived filnal { /* */ }; // NoDerived 不能作为基类
class Bad : NoDerived { /* */ }; // 错误
class Base { /* */ };
class Last final : Base { /* */ }; // Last 不能作为基类
类型转换与继承
正常情况下,我们的引用或指针的类型应该与想要绑定对象的类型相同。但存在继承关系的类是一个重要的例外:我们可以将基类的指针或引用绑定到派生类对象上(当然,并不是所有情况都可以,还与继承方式有关系)。所以,当使用基类的引用 (或指针) 时,实际上我们并不清楚该引用 (或指针) 所绑定对象的真实类型。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。
表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象类型,动态类型直到运行时才可知。
例如,当 print_total 调用 net_price 时:
double ret = item.net_price(n);
我们知道 item 的静态类型是 Quote&,它的动态类型直到运行调用此函数时才会知道。如果我们传递给 print_total 的是 Bulk_quote,则 item 的动态类型是 Bulk_quote。
如果表达式不是引用或指针,则它的动态类型与静态类型一致。例如,Quote 类型的变量永远是一个 Quote 对象。
基类的指针或引用的静态类型可能与其动态类型不一致。这是显然的,因为基类的指针或引用绑定到派生类对象上。
不存在基类向派生类的隐式类型转换……
之所以存在派生类向基类的类型转换是因为每个派生类对象都包含基类的一部分,而基类的引用或指针可以绑定到该基类部分上。一个基类对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。所以不存在从基类向派生类的自动转换。
Quote base;
Bulk_quote* bulkP = &base; // 错误:不能从基类转换成派生类
Bulk_quote& bulkRef = base; // 错误:不能从基类转换成派生类
假设转换成立,则我们有可能会使用 bulkP 或者 bulkRef 访问本 base 中本不存在的成员。
需要特别注意一点:即使一个基类指针或引用绑定在一个派生类对象上,我们也不能指向从基类向派生类的转换:
Bulk_quote bulk;
Quote *itemP = &bulk; // 正确
Bulk_quote *bulkP = item; // 错误,不能从基类转换成派生类
……在对象之间不存在类型转换
派生类向基类的自动转换只对指针类型或引用类型有效,在派生类类类型和基类类型之间不存在这样的转换。
我们知道,对于类类型的初始化是调用构造函数,当进行赋值操作时,调用赋值运算符。而这些成员都包含参数类型是该类类型的 const 版本的引用。这些操作都不是虚函数。所以我们允许在基类的拷贝/移动操作中传递一个派生类对象,但使用的操作都是基类的操作。
Bulk_quote bulk;
Quote item(bulk); // 使用 Quote::Quote(const Quote&) 构造函数
item = bulk; // 使用 Quote::operator=(const Quote&)
上述代码会忽略 Bulk_quote 自己定义的成员部分。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。