OOP:概述
面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。
继承(inheritance):通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类(base class)。其他类直接或者间接从基类继承而来,这些继承得到的类成为派生类(derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
对于某些函数,基类希望它的派生类个自定义适合自己的版本,此时基类就将这些函数声明成虚函数(virtual function)。
派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个基类继承而来。形式:一个冒号,后面紧跟以逗号分隔的基类列表,每个基类前都可以有访问说明符。
1 | class Quote{ |
2 | public: |
3 | std::string isbn() const; |
4 | virtual double net_price(std::size_t n) const; |
5 | } |
6 | |
7 | class Bulk_quote : public Quote{ /* 派生列表 */ |
8 | public: |
9 | doublie net_price(std::size_t) const override; |
10 | }; |
派生类必须在其内部对所有重新定义的虚函数进行声明。可以在函数之前加上virtual
关键字,也可以不加。C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后加一个override
关键字。
动态绑定(dynamic binding,又称运行时绑定):使用同一段代码可以分别处理基类和派生类的对象。函数的运行版本由实参决定,即在运行时选择函数的版本。使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
定义基类和派生类
定义基类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
基类通过在其成员函数的声明语句前加上关键字virtual
使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual
只能出现在类内部的声明之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
如果成员函数没有被声明为虚函数,则解析过程发生在编译时而非运行时。
访问控制:和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。基类使用protected
说明符使派生类有访问该成员的权限,同时禁止其他用户访问。
定义派生类
派生类必须通过类派生列表(class derivation list)明确指出它是从哪个基类继承而来。形式:冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有一下三种访问说明符的一个:public
、protected
、private
。
派生类经常但不总是覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员函数,派生类会直接继承其在基类中的版本。
C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后,或者const成员函数const关键字后面,或者引用成员函数的引用限定符后面加一个override
关键字。
派生类构造函数:派生类必须使用基类的构造函数去初始化它的基类部分。首先初始化基类的部分, 然后按照声明的顺序依次初始化派生类的成员。
静态成员:如果基类定义了一个基类成员,则在整个继承体系中只存在该成员的唯一定义。静态成员遵循通用的访问控制规则,如果基类中的成员是private
的,则派生类无权访问它,如果静态成员是可访问的,则既能通过基类也能通过派生类来使用它。
派生类的声明:声明中不包含它的派生列表。
最终的派生类将包含它的直接基类子对象以及每个间接基类的子对象。
C++11新标准提供了一种防止继承的方法,在类名后面跟一个关键字final
。
类型转换与继承
理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。可以将基类的指针或引用绑定到派生类对象上。
智能指针也支持派生类向基类的类型转换,可以将一个派生类对象的指针存储在一个基类的智能指针内。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在从基类向派生类的隐式类型转换。
派生类向基类的自动类型转换只对指针或引用类型有效,对象之间不存在类型转换。
当用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝,移动或赋值,它的派生类部分将被忽略。
虚函数
使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。必须为每一个虚函数都提供定义,而不管它是否被用到,因为编译器也无法确定到底会使用哪个虚函数。
OOP的核心思想是多态性(polymorphism)。
对非虚函数的调用在编译器时进行绑定,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
派生类必须在其内部对所有重新定义的虚函数进行声明。可以在函数之前加上virtual
关键字,也可以不加。
派生类的函数覆盖某个继承而来的虚函数时它们的形参类型与被覆盖的基类函数类型需完全一致。派生类中的返回类型也必须与基类函数的返回类型匹配。一个例外:当虚函数返回类型是类本身的指针或引用时,上述规则无效。
C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后加一个override
关键字。如果我们想覆盖某个虚函数,但不小心把形参列表弄错了,这个时候就不会覆盖基类中的虚函数。加上override
可以明确程序员的意图,让编译器帮忙确认参数列表是否出错。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
通常,只有成员函数(或友元)中的代码才需要使用作用域运算符(::
)来回避虚函数的机制。
抽象基类
纯虚函数(pure virtual):清晰地告诉用户当前的函数是没有实际意义的。纯虚函数无需定义,只用在函数体的位置前(声明语句的分号前)书写=0
就可以将一个虚函数说明为纯虚函数。纯虚函数的函数体必须定义在类的外部。
含有纯虚函数的类是抽象基类(abstract base class),抽象基类负责定义接口,不能创建抽象基类的对象。
派生类构造函数只初始化它的直接基类。
访问控制与继承
protected
关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。
- 类似于私有成员,受保护的成员对类的用户来说是不可访问的。
- 类似于公有成员,受保护的成员对于派生类的成员和友元来说是可访问的。
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
派生访问说明符对于派生类的成员(及友元)能否访问其直接积累的成员没什么影响。
派生访问说明符的目的是:控制派生类用户对于基类成员的访问权限。
1 | class Base{ |
2 | public: |
3 | void pub_mem(); |
4 | protected: |
5 | int prot_mem; |
6 | private: |
7 | char priv_mem; |
8 | }; |
9 | struct Pub_Derv : public Base{ |
10 | //正确:派生类能访问protected成员 |
11 | int f() { return prot_mem; } |
12 | //错误:private成员对于派生类不可访问 |
13 | char g() { return priv_mem; } |
14 | }; |
15 | struct Priv_Derv : private Base{ |
16 | //private不影响派生类的访问权限 |
17 | int f1() const { return prot_mem; } |
18 | } |
19 | |
20 | Pub_Derv d1; //继承自Base的成员是public的 |
21 | priv_Derv d2; //继承自Base的成员是private的 |
22 | d1.pub_mem(); //正确,pub_mem()在派生类中是public的 |
23 | d2.pub_mem(); //错误,pub_mem()在派生类中是private的 |
友元关系不能继承。每个类负责控制各自成员的访问权限。
改变个别成员的可访问性:使用using
声明语句。using
声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。
默认情况下,使用class
关键字定义的派生类是私有继承的;使用struct
关键字定义的派生类是公有继承的。
继承中的类作用域
每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。
派生类的成员将隐藏同名的基类成员。可以通过作用域运算符来使用隐藏同名的基类成员。
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
构造函数与拷贝控制
虚析构函数
如果基类的析构函数不是虚函数,则delete
一个指向派生类对象的基类指针将产生未定义的行为。
基类的析构函数不遵守:一个类需要析构函数,那么它也需要拷贝和赋值操作。
虚析构函数将阻止合成移动操作。
合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为和其他合成的构造函数、赋值运算符或析构函数类似:他们对类本身的成员依次进行初始化、赋值或销毁的操作。
基类缺少移动操作会阻止派生类拥有自己合成的移动操作,所有当需要移动操作时应该在基类中进行定义。
派生类的拷贝控制成员
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
1 | class Base { /* ... */ }; |
2 | class D: public Base { |
3 | public: |
4 | //默认情况下,基类的默认构造函数初始化对象的基类部分 |
5 | //要想使用拷贝或移动构造函数,必须在构造函数初始值列表中 |
6 | //显示地调用该构造函数 |
7 | D(const D& d): Base(d) //拷贝基类成员 |
8 | /* D的成员的初始值 */ { /* ... */ } |
9 | D(D&& d): Base(std::move(d)) |
10 | /* D的成员的初始值 */ { /* ... */ } |
11 | }; |
12 | |
13 | D &D::operator=(const D&rhs){ |
14 | //派生类的赋值运算符也必须显示的为其基类部分赋值 |
15 | Base::operator=(rhs); |
16 | |
17 | return *this; |
18 | } |
默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果想要拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。
派生类析构函数只负责销毁由派生类自己分配的资源, 派生类析构函数先执行,然后执行基类的析构函数。
继承的构造函数
C++11新标准中,派生类可以重用其直接基类定义的构造函数。一个类只初始化它的直接基类,也只继承其直接基类的构造函数,类不能继承默认,拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一条注明了基类名的using
声明语句。using
声明语句作用于构造函数时,对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。基类构造函数有默认实参时,这些默认实参不会被继承,同时派生类将获得多个继承的构造函数,每个构造函数分别省略掉一个含有默认实参的形参。
1 | class Bulk : public Disc{ |
2 | public: |
3 | using Disc::Disc; //继承Disc的构造函数 |
4 | double net_price(std::size_t) const; |
5 | } |
容器与继承
使用容器存放继承体系中的对象时,通常必须采用间接存储的方式。
派生类对象直接赋值给积累对象,其中的派生类部分会被切掉。
在容器中存放具有继承关系的对象时,通常放置(智能)指针而非对象。
1 | vector<shared_ptr<Quote>> basket; |
2 | basket.push_back(make_shared<Quote>("0-201-82470-1", 50)); |
3 | basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25)); |