《C++ Primer》读书笔记—第十五章 面向对象程序设计
声明:
- 文中内容收集整理自《C++ Primer 中文版 (第5版)》,版权归原书所有。
- 学习一门程序设计语言最好的方法就是练习编程
1、面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
2、继承和动态绑定对程序编写有两方面的影响:一是我们可以更容易地定义与其他类相似但不完全相同的新类;二是在使用这些彼此相似的类编写程序时,可以在一定程度上忽略他们的区别。
一、OOP:概述
1、面向对象程序设计核心思想是数据抽象、继承、动态绑定。数据抽象将类的接口与实现分离,使用继承可以定义相似的类型并对其相似关系建模;使用动态绑定可以在一定程度上忽略相似类型的区别,已统一的形式使用对象
2、通过继承联系在一起的类构成一种层次关系。根部有一个基类,其他类间接或直接从基类继承来。继承得到的类叫派生类。基类负责定义在层次关系中所有类共同的成员,而每个派生类定义各自特有的成员。
3、在C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区别对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。
4、派生类必须通过类派生列表明确指出它从哪些基类继承来。类派生列表的形式:首先一个冒号,后面紧跟以逗号隔开的基类列表,其中每个基类前面都可以有访问说明符。
基类:
1 class Quote 2 { 3 public: 4 std::string isbn() const; 5 virtual double net_price(std::size_t) const; 6 }
派生类:
1 class Bulk_quote : public Quote //Bulk_Quote公有继承了Quote 2 { 3 public: 4 double net_price(std::size_t) const override; //显式注明将使用哪个成员函数改写基类的虚函数 5 }
5、派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数之前加上virtual关键字。C++新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字。
1 double print_total(ostream &os,const Quote &item,size_t n){ 2 //根据传入的item形参决定调用Quote::net_price或者Bulk_quote::net_price 3 double ret = item.net_price(n); 4 os<<"ISBN"<<item.isbn()<<"# sold: "<<n<<" total due: "<<ret<<endl; 5 return ret; 6 }
6、动态绑定又称运行时绑定,指的是函数运行版本由实参决定,即在运行时选择函数的版本。
7、在C++中,当我们使用基类的引用(或指针)调用一个虚函数时会发生动态绑定。
二、定义基类和派生类
1、Quote类的定义:
1 class Quote 2 { 3 public: 4 Quote() = default; 5 Quote(const std::string &book, double sales_price) : 6 bookNo(book), price(sales_price) { } 7 std::string isbn() const {return bookNo;} 8 //返回定数量的书籍的销售总额 9 //派生类负责改写并使用不同的折扣计算算法 10 virtual double net_price(std::size_t n) const 11 { return n*price; } 12 virtual ~Quote() = default; //对析构函数进行动态绑定 13 private: 14 std::string bookNo; 15 protected: 16 double price = 0.0; 17 };
2、基类通常都应该定义一个虚析构函数,即使函数不执行任何操作也是如此(用于给派生类销毁派生类独有的那部分内存,否则只能销毁派生类中从基类继承而来的那部分内存)。
3、派生类继承可以继承基类的成员,然而必须对其重新定义,派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承来的旧定义。
4、构造函数和static成员函数不能是virtual的。因为:
(1) static 函数和实例无关,只和类有关,可以把static成员看成某个namespace里的函数。
(2) virtual函数要用到虚函数表(vtable),而vtable是在构造函数中建立的。所以调用构造函数的时候还没有vtable,不能把构造函数设为virtual。
5、定义派生类:
1 class Bulk_quote : public QUote 2 { 3 public: 4 Bulk_quote() = default; 5 Bulk_quote(const std::string&, double, std::size_t, double); 6 //覆盖基类的函数版本以实现基类大量购买的折扣政策。 7 double net_price(std::size_t) const override; 8 private: 9 std::size_t min_qty = 0; //适用折扣政策的最低购买量 10 double discount = 0.0; //以小数表示的折扣额 11 };
类派生列表的形式:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问控制符的一种:public,protected,private。
6、派生类不一定会覆盖他继承的虚函数,如果派生类没有覆盖其基类的某个虚函数,那么这个函数在派生类的表现和其他普通成员函数没有区别(功能和在基类中一样)。
C++11允许显式表示函数使用某个成员参数覆盖了它继承的虚函数,方法是在函数体前加override(其实不加也可以覆盖,override只是显式表示)。
7、派生类到基类的转换:
1 Quote item; //基类对象 2 Bulk_quote bulk; //派生类对象 3 Quote *p = &item; //p指向Quote对象 4 p = &bulk; // p 绑定到bulk的Quote部分 5 Quote &r = bulk; // r 绑定到bulk的Quote部分
8、这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方,同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。(但是只有引用和指针才能多态)。
9、派生类并不能直接构造初始化基类的成员,而是要调用基类的构造函数来初始化它的基类部分。(不算是委托构造函数,委托构造函数是同类之间的)
10、要想与类的对象交互必须使用类的接口,即这个对象是派生类的基类部分也是如此。所以派生类不能直接初始化基类的成员。而是用调用基类的接口(构造函数)初始化基类。
11、如果基类中定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义(但是基类能不能访问就是另一回事了,private)。不论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。
静态成员也有public,private,protected之分。
12、一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与类的主题一起出现。
13、一个类是派生类,同时他也可以是其他类的基类(派生链)。(基类和派生类的关系是相对的)。
每个类都会继承直接积累的所有成员,所以继承链的顶端的那个派生类,将包含了它的直接基类的子对象以及每个间接基类的子对象。
14、在一个类定义中加入final可防止类被继承:在类名后接final 。
1 class NoDerived final { /* */ }; //派生链一开始就断了 2 class Base {/* */}; 3 class Last final : Base {/* */}; //派生链在在中间断 4 class Bad : NoDervied {/* */}; 5 class Bad2 : Last {/* */};
三、虚函数
1、因为调用虚函数的时候会进行动态绑定,直到运行时我们才知道到底调用了哪个版本的虚函数。所以必须为虚函数进行定义,不管他是否被用到了。因为编译器也无法确定到底会使用哪些学函数。
2、被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。
3、引用或指针的动态类型与静态类型不同是c++多态性的根本所在。
4、动态绑定只有我们通过【指针或引用】调用【虚函数】时才会发生!(关键字:指针 引用 虚函数)
5、一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被他覆盖的基类函数完全一致,返回类型也必须一致。但当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。
6、如果我们使用override标记了一个函数,但该函数没有覆盖已存在的虚函数,则编译器会报错。
1 struct B 2 { 3 virtual void f1(int) const; 4 virtual void f2(){}; 5 void f3(); 6 }; 7 8 struct D1 : B 9 { 10 void f1(int) const override; //正确 11 //void f2(int) override; //错误,没有const,找不到匹配,但又声明了override 12 //void f3() override; // 错误,B中的f3 不是虚函数 13 //void f4() override; //错误,B中无f4。 14 };
7、在派生类中,要么不写虚函数的声明(直接隐式使用基类的定义),一旦写了声明,就必须把定义也写了,否则虚函数表会出错。
final和override出现在形参列表(包括const和引用修饰符)以及尾置返回类之后
8、虚函数允许有默认实参,实参值由本次调用的静态类型决定。
9、回避虚函数的机制:我们可以通过作用域运算符来回避虚函数:
通常,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
1 Base *p = Derived(); 2 p -> Base::f(); //执行的是基类的f().
四、抽象基类
1、将基类的某个成员函数定义为纯虚的(pure virtual),这个基类就是抽象类。书写=0即可将一个虚函数说明为纯虚函数。=0只能在类内部的虚函数声明语句处:
2、虽然一个抽象基类不能定义对象,但是这个基类的派生类的构造函数会调用基类的构造函数来初始化派生类中的基类部分。所以该定义的函数还是要正常定义(实现)。
3、我们也可以为纯虚函数提供定义,不过函数体必须定义在类外部。
4、含有(或者未经覆盖直接继承)抽象函数的类是抽象基类。
抽象基类负责定义接口。派生类可以覆盖该接口。不能创建基类对象,因为纯虚函数没有实现。
派生类的构造函数只初始化它的直接基类。
5、重构(refactoring)负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。
五、访问控制与继承
1、protected声明一个类希望与派生类共享但不被其他类公共访问的成员。
派生类成员或友元只能通过派生类对象来访问基类的受保护成员,派生类对一个基类对象中受保护的成员没有任何访问特权。
2、某个类对继承来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
3、protected :只有 【自身】、【派生类】和【友元】能访问pritected,private: 只有【自身】和【友元】可访问。public:谁都能访问。派生类能访问protected指的是能访问自身从基类继承下来的protected部分。而不是说给一个派生类的函数传进去一个基类的对象就能直接访问基类的pritected成员。
1 class Base 2 { 3 protected: 4 int x; 5 }; 6 7 class Derived : public Base 8 { 9 friend void f(Derived &d); //能访问Derived::x; 10 friend void f(Base &b); //不能访问Base::x; 11 }; 12 void f(Derived &d){cout << d.y << endl;} //正确 13 void f(Base &b){cout << b.x << endl;} //错误
4、派生类的成员和友元只能访问派生类对象中基类部分的受保护成员;对于普通的基类中的成员不具有特殊的访问性。
1 class Base 2 { 3 public: 4 int x; 5 protected: 6 int y; 7 private: 8 int z; 9 }; 10 11 struct Pub_Derv : protected Base 12 { 13 int f1() {return x;}//正确 14 int f2() {return y;}//正确 15 int f3() {return z;}//错误 16 }; 17 struct Priv_Derv : private Base 18 { 19 // private不影响派生类的访问权限。但在之后,xyz都变成private的了。 20 int f1() {return y;} 21 };
5、派生类访问说明符对于派生类的成员(及友元)能否访问其【直接基类】没有什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
6、派生类向基类转换的可访问性:
(1)只有当D公有继承B时,用户代码才能使用派生类向基类的转化;如果D继承B的方式是受保护或者私有的,则用户代码不能使用该转换。
(2)不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
(3)如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的转换;反之,如果D继承B的方式是私有的,则不能使用。
7、友元与继承:友元关系不能继承。当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。
不能继承友元关系,每个类负责控制自己成员的访问权限。
1 class Base 2 { 3 friend class Pal; 4 private: 5 int xb; 6 }; 7 8 class Sneaky : public Base //如果不是Base就不行了 9 { 10 private: 11 int xs; 12 }; 13 class Pal 14 { 15 public: 16 int f1(Base b) { return b.xb; } //正确,友元 17 int f2(Sneaky s){ return s.xs; } //错误,xs是Sneaky新的成员。 18 int f3(Sneaky s) { return s.xb; } //正确,xb部分是属于Base类,Pal是Base的友元 *******重点******* 19 };
8、可以使用using改变成员的可访问性。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。
private:该名字只能被类的成员和友元访问
public:类的所有用户都能访问
protected:对于成员、友元和派生类都可以访问。
派生类只能为那些它可以访问的名字提供using声明。
1 class Base 2 { 3 public: 4 std::size_t size() const {return n;} 5 protected: 6 std::size n; 7 }; 8 9 class Derived : private Base //注意,是private继承,如果不处理的话,下面就全部默认private了 10 { 11 public: 12 //保持对象尺寸相关的成员的访问级别 13 using Base::size; 14 15 protected: 16 using Base::n; 17 };
9、默认派生运算符也由定义派生类所用的关键字来决定。class默认私有继承,struct定义的派生类是公有继承的。
10、一个私有派生的类最好显式的声明private,而不是仅仅依靠默认的设置。显式声明的好处是可以令私有的继承关系清晰明了,不至于产生误会。
六、继承中的类作用域
1、每个类都有自己的作用域,当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器会继续在其外层的基类作用域中寻找该名字的定义。
2、当使用一个成员时,先在派生类中寻找,找不到再找它的直接基类(寻找方向和继承方向相反)。
3、一个对象、引用或指针的静态类型。决定了该对象的哪些成员是可见的。即使静态成员和动态成员可能不一致(基类指针或引用绑定到派生类对象中时)。但是我们能使用哪些成员是由静态类型(也就是说只能用静态类型拥有的成员,虚函数也算基类拥有,因为名字相同,而只不过是实现不同)决定的。但是如果是虚函数,而派生类又没有重载的话,就由动态类型决定。
如果静态类型和动态类型不同,而动态类型覆盖了静态类型的成员函数,那么还是会调用静态类型的哪个被覆盖了的函数。
4、名字冲突与继承:派生类能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。
1 class Base 2 { 3 public: 4 void bf(){} 5 }; 6 class Derived : public Base 7 { 8 public: 9 void df(){} 10 }; 11 int main() 12 { 13 Base *p = new Derived; 14 p->df(); 15 return 0; 16 }
有没有由静态函数决定,都有的情况下虚函数用哪个由动态规划决定。
1 struct A 2 { 3 int x = 1; 4 }; 5 struct B : public A 6 { 7 }; 8 9 struct C : public B 10 { 11 int x = 3; 12 void show_x(){cout << B::x << endl;} 13 }; 14 C c; 15 c.show_x(); 会打印1,因为现在B中找x,找不到,再往B的基类找,永远不会找到C::x。
5、
- 名字查找的步骤:以p->mem()/obj.mem()为例:
- 首先确定p(或obj)的静态类型。因为我们调用一个的是一个成员,所以该类型必须是一个类类型。
- 在p(或obj)的静态类型对应的类中查找mem,如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果还是找不到,将报错。
- 一旦找到了mem,将进行常规的类型检查,以确定对于当前找到的mem,本次调用是否合法。
- 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
- (1)如果mem是虚函数且我们是通过指针或引用进行的调用。则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据的是对象的动态类型。
- (2)反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器产生一个常规函数调用。
6、名字查找先于类型检查:如果派生类的成员和基类的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致。
7、除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
8、
1 class Base 2 { 3 public: 4 void f(){ } 5 }; 6 class Derived : public Base 7 { 8 public: 9 void f(int x){ } 10 }; 11 Derived d; 12 Base b; 13 b.f(); 14 d.f(10); 15 d.f(); //错误,被void f(int x) 隐藏了。</pre>所以基类和派生类的虚函数必须要有相同的形参列表,否则我们将无法通过引用或指针调用派生类的虚函数了。
- 一个派生类中的新函数(形参类型不同)可以把基类中的虚函数隐藏,使它不可见,但还是可以用。p550
- 判断有没有一个函数时,以静态类型为准;如果找到函数,但要确定执行哪个版本时,以动态类型为准。
9、如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。否则如果只覆盖一部分的话,另一部分会被隐藏(报错)。但是如果加上”Base::”,就不会报错了。
也可以使用一个using语句指定一个名字而不指定形参列表。。此时派生类只需要定义其特有的函数就可以了。而无须为继承而来的其他函数重新定义。
10、基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问。
七、构造函数与拷贝控制
1、继承关系对于基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数。这样我们就能动态分配继承体系中的对象了。
2、析构函数的虚属性也会被继承。
3、一个基类总是需要析构函数,而且它能将虚构函数设定为虚函数。这对派生类的定义产生间接影响:如果一个类定义了析构函数,即使通过=default的形式使用了合成版本,编译器也不会为这个类合成移动操作。
4、基类或派生类的合成拷贝控制成员的行为与其他的合成的构造函数、赋值运算或析构函数类似:对类的本身的成员依次初始化、赋值或销毁操作。
5、对于派生类的析构函数,除了销毁自己的成员外,还负责销毁派生类的直接基类,该直接基类又销毁自己的直接基类,直至继承链的顶端。
6、大多数基类会定义一个虚析构函数。在默认情况下,基类通常不含有合成的移动操作,而在他的派生类中也没有合成的移动操作。当确实需要执行移动操作时应该首先在基类中定义。
7、派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数再拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。派生类赋值运算符也必须为其基类部分的成员赋值。
8、析构函数只负责销毁派生类自己分配的资源,对象的成员是隐式销毁的,派生类对象的基类部分也是自动销毁的。
9、当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
10、基类默认构造函数初始化派生类对象的基类部分。如果我们向拷贝或移动基类部分,则必须在派生类的构造函数初始化列表中显式地使用基类的拷贝或移动构造函数。
11、派生类的析构函数只负责销毁由派生类自己分配的资源。对象销毁顺序与创建顺序相反,派生类的析构函数首先执行,然后是基类的析构函数,沿着继承体系的反方向直至最后。
12、如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
13、一个类只初始化它的直接基类,也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器会为派生类合成它们。
14、通常,using只是令某个名字在当前的作用域可见,而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的构造函数,编译器都生成一个与之对应的派生类构造函数。
15、与普通using声明不同,一个构造函数的using声明不会改变该构造函数的访问级别。不管using出现在哪里,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样规则。
16、一个using语句不能指定explicit或constexpr。
17、基类构造函数的默认实参不能被继承。相反,派生类会获得多个继承的构造函数,其中每个构造函数分别省略一个含有默认实参的形参。如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类会获得两个构造函数,一个接受两个实参(没有默认实参),另一个构造函数只接受一个实参,对应于基类最左侧没有默认值的那个形参。
18、如果基类含有几个构造函数,除了以下两例外,大多数派生类会继承所有这些构造函数。一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。第二是默认、拷贝、移动构造函数不会被继承。
八、容器与继承
1、当使用容器存放在继承体系之外的对象时,必须采用间接存储的方式。因为不允许在容器中保存不同的类型的元素,所以不能把具有继承关系的多种类型的对象直接存储在容器中。
2、容器和存在继承关系的类型无法兼容。
3、当我们希望存储在容器中具有继承关系的对象时,实际上存储的是基类的指针(更好的是智能指针)。这些指针所指的对象的动态类型可能是基类类型,也可能是派生类类型。
4、
5、
九、文本查询程序再探
4.24:
看到一个有趣的排序方式,sleep排序。大家一起睡,谁小谁先醒。笑死。
int mumber[10] = {8,42,38,111,2,39,1}; number.forEach(num=>{setTimeout(()=>{consle.log(num},num);