面向对象程序设计
- 面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定:
- 通过使用数据抽象,我们可以将类的接口与实现分离;
- 使用继承,可以定义相似的类型并对其相似关系建模;
- 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
OOP(object-oriented programming):概述
-
继承
-
基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员;
//基类 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; };
-
虚函数:对于某些函数,基类希望它的派生类各自定义适合自身的版本:
- 在基类中加上virtual关键字声明为虚函数;
- 派生类必须在其内部对所有重新定义的虚函数进行声明。
-
-
动态绑定
-
当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定,根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
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; }
- 使用同一段代码可以分别处理基类和派生类的对象;
- 函数的运行版本由实参决定,即在运行时选择函数的版本。
-
定义基类和派生类
-
定义基类
- 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此;
- 成员函数与继承:
- 基类希望其派生类进行覆盖的函数,通常将其定义为虚函数;
- 在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定;
- 任何构造函数之外的非静态函数都可以是虚函数;
- 关键字 virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义;
- 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数;
- 成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
- 访问控制与继承
- protected : 基类和其派生类还有友元可以访问;
- private : 只有基类本身和友元可以访问。
-
定义派生类:
-
派生类必须通过类派生列表明确指出它是从哪个基类继承而来;
- 形式:冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有以下三种访问说明符的一个:public、protected、private;
class Base { /* ... */ } ; class D1: public Base { /* ... */ };
-
访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见:如果一个派生类是公有的,则基类的公有成员也是派生类接口的组成部分;
-
派生类中的虚函数:派生类常覆盖继承的虚函数,若无覆盖则会直接继承其在基类中的版本;
-
派生类对象及派生类向基类的类型转换
-
一个派生类对象包含有派生类自己定义成员的子对象,以及其所继承的基类对应的子对象;
-
派生类到基类的类型转换(编译器会隐式地执行):可以将派生类的对象当成基类对象使用,也可以将基类指针或引用绑定到派生类对象中的基类部分;
Quote item; //基类对象 Bulk_quote bulk; //派生类对象 Quote *p = &item; //p指向Quote对象 p = &bulk; //p指向bulk的Quote部分 Quote &r = bulk; //r绑定到bulk的Quote部分
-
-
派生类构造函数:
- 每个类控制它自己的成员的初始化过程,因此派生类必须使用基类的构造函数来初始化它的基类部分;
- 首先初始化基类的部分,然后按照声明的顺序依次初始化派生列的成员。
-
继承与静态成员:
- 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义,不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例;
- 假设某静态成员是可访问的,则既能通过基类使用它也能通过派生类使用它。
-
派生类的声明中包含类名但是不包含它的派生列表。
-
被用作基类的类:
-
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明;
-
一个类不能派生它本身;
-
最终的派生类将包含它的直接基类的子对象以及每个间接基类的子对象。
class Base { /* ... */ } ; class D1: public Base { /* ... */ }; class D2: public D1 { /* ... */ }; //Base是D1的直接基类,同时是D2的间接基类
-
-
防止继承发生的方法,即在类名后跟一个关键字final:
class NoDerived final{}; NoDerived不能作为基类 class Last final:public Base{}; Last不能作为基类
-
-
类型转换与继承
- 可以将基类的指针或引用绑定到派生类对象上,这也意味着当使用基类的引用或指针时,实际上我们并不清楚该引用或指针所绑定对象的真实类型;
- 静态类型与动态类型
- 静态类型:对象被定义的类型或表达式产生的类型,静态类型在编译时是已知的;
- 动态类型:对象在运行时的类型,引用所引对象或者指针所指对象的动态类型可能与该引用或指针的静态类型不同;
- 如果一个变量非指针也非引用,则它的静态类型和动态类型永远一致。
- **不存在从基类向派生类的隐式类型转换:**一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类向派生类的自动类型转换。
- 在对象之间不存在类型转换:
- 派生类向基类的自动类型转换只对指针或引用类型有效,而在派生类类型和基类类型之间不存在这样的转换;
- 当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动、赋值,它的派生类部分会被忽略掉。
虚函数
-
当使用基类的引用或指针调用一个虚成员函数时,会执行动态绑定,对虚函数的调用可能在运行时才被解析;
- 通过一个具有普通类型的表达式调用虚函数时,在编译时就会将调用的版本确定下来;
- C++的多态性:具有继承关系的多个类型称为多态类型,引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
-
派生类中的虚函数:
- 基类中的虚函数在派生类中隐含地也是一个虚函数,当派生类覆盖了某个虚函数时,该函数的形参必须与派生类中的形参严格匹配;
- 派生类中的返回类型也必须与基类函数匹配,但存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,该规则无效。
-
final 和 override 说明符
-
若派生类定义了一个函数与基类中虚函数的名字相同但形参列表不同仍然是合法行为,此时编译器认为新定义的函数与基类中原有的函数相互独立,即派生类中的函数没有覆盖掉基类中的版本;
-
然而上述的声明往往意味着错误,因为可能希望派生类能够覆盖掉基类中的虚函数,此时可以使用 override 关键字来说明派生类中的虚函数,如果我们使用 override 标记了某个函数,但该函数没有覆盖已存在的虚函数,此时编译器将会报错;
class B { virtual void fl(int) const; virtual void f2(); void f3 (); }; class Dl : B { void fl(int) const override; //正确:fl与基类中的fl匹配 void f2(int) override; //错误:B没有形如f2(int)的函数 void f3() override; //错误:f3不是虚函数 void f4() override; //错误:B没有名为f4的函数 };
-
还可以把某个函数指定为 final,则之后任何尝试覆盖该函数的操作都将引发错误。
struct D2 : B{ //从B继承f2()和f3(),覆盖f1(int) void f1(int) const final; //不允许后序的其他类覆盖f1(int) }; struct D3 : D2{ void f2(); //正确,覆盖从间接B类继承而来的f2 void f1(int) const; //错误,D2已经将f2声明为final };
- 注:final 和 override 说明符出现在形参列表(包括任何 const 或引用修饰符)以及尾置返回类型之后。
-
回避虚函数机制:在某些情况下,我们希望虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可以实现这一目的:
double undiscounted = baseP->Quote::net_price(42);
- 通常只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制。
-
抽象基类
- 纯虚函数
- 通过在函数体的位置(即在声明语句的分号之前)书写 =0 就可以将一个虚函数说明为纯虚函数,其中 =0 只能出现在类内部的虚函数声明语句处;
- 不能在类的内部为一个 =0 的函数提供函数体。
- 含有纯虚函数的类是抽象基类
- 抽象基类负责定义接口,而后续其他类可以覆盖该接口;
- 不能创建一个抽象基类的对象。
- 派生类构造函数只初始化它的直接基类
- 每个类各自控制其对象的初始化过程。
- 重构:负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。
访问控制与继承
每个类分别控制自己的成员初始化过程,每个类还分别控制着其成员对于派生类来说是否可访问。
-
受保护的成员 protected
-
和私有成员类似,受保护的成员对于类的用户来说是不可访问的;
-
和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的;
-
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员,派生类对于一个基类对象中的受保护成员没有任何访问特权。
class Base{ protected: int prot_mem; }; class Sneaky : public Base{ friend void clobber(Sneaky&); //能访问Sneaky::mem friend void clobber(Base&); //不能访问 Base::mem int j; //j默认是private }; //正确:clobber 能访问 Sneaky 对象的 private 和 protected 成员 void clobber(Sneaky &s) { s.j = s.prot_mem = 0; } //错误:clobber 不能访问 Base 的 protected 成员 void clobber(Base &b) { b.prot_mem = 0; }
-
-
公有、私有和受保护继承
-
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
- 派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响,对基类成员的访问权限只与基类中的访问说明符有关;
- 派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限;
基类中public成员 基类中protected成员 基类中private成员 公有继承 public protected 不可见 私有继承 private private 不可见 保护继承 protected protected 不可见
-
-
派生类向基类转换的可访问性
- 友元关系不能传递,且不能继承;
- 每个类负责控制各自成员的访问权限。
-
改变个别成员的可访问性
-
有时我们需要改变派生类继承的某个名字的访问级别,可以使用using声明:
class Base3 { public: size_t size()const { return n; } protected: size_t n; }; class Derived : private Base3 { public: using Base3::size; protected: using Base3::n; };
-
派生类只能为那些它可以访问的名字提供using声明。
-
-
默认的继承保护级别
- 使用 class 关键字定义的派生类是私有继承,使用 struct 关键字定义的派生类是公有继承;
- struct 和 class 的唯一差别就是默认成员访问说明符以及默认派生访问说明符。
继承中的类作用域
-
当存在继承关系时
- 派生类的作用域嵌套在其基类的作用域之内;
- 如果一个名字在派生类的作用域无法正常解析,则编译器将继续在外层的基类作用域中寻找该名字的定义;
- 派生类可以像使用自己的成员一样使用基类的成员,但基类无法通过对象、引用或指针调用派生类的成员。
-
名字冲突与继承
- 派生类的成员将隐藏同名的基类成员;
- 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
-
名字查找先于类型检查
-
声明在内层作用域的函数并不会重载声明在外层作用域的函数;
-
若派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内**隐藏(而非重载)**该基类成员,即使派生类和基类成员的形参列表不一致。
class Base{ public: void fun(); }; class Derived : public Base{ public: void fun(int); //即使形参列表不一致,基类成员fun()也会被隐藏掉 }; Derived d; Base b; d.fun(10); d.fun();//错误,fun()被隐藏 d.Base::fun(); //正确,调用Base::fun()
-
-
虚函数必须有相同的形参列表,否则就不是覆盖(override)而是隐藏了。
-
通过基类调用隐藏的虚函数
-
通过基类指针或引用调用执行动态绑定;通过对象调用执行常规调用;
class BaseHide { public: virtual void fcn();//虚函数 }; class DerivedHide1 : public BaseHide { public: void fcn(int); //隐藏BaseHide的fcn() virtual void f2(); }; class DerivedHide2 : public DerivedHide1 { public: void fcn(int); void fcn(); void f2(); };
BaseHide bh; DerivedHide1 dh1; DerivedHide2 dh2; BaseHide *bp1 = &bh, *bp2 = &dh1, *bp3 = &dh2; bp1->fcn(); //虚调用,BaseHide::fcn() bp2->fcn(); //虚调用,BaseHide::fcn() bp3->fcn(); //虚调用,DerivedHide2::fcn() cout << endl; DerivedHide1 *dp1 = &dh1; DerivedHide2 *dp2 = &dh2; bp2->f2(); //错误,BaseHide没有名为f2的成员 dp1->f2(); //虚调用,DerivedHide1::f2() dp2->f2(); //虚调用,DerivedHide2::f2() cout << endl; BaseHide *p1= &dh2; DerivedHide1 *p2 = &dh2; DerivedHide2 *p3 = &dh2; p1->fcn(42); //错误,BaseHide没有fcn(int) p2->fcn(42); //静态绑定,DerivedHide1::fcn(int) p3->fcn(42); //静态绑定,DerivedHide2::fcn(int)
-
-
覆盖重载的函数
- 如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个都不覆盖;
- 有时我们仅需覆盖重载集合中的一些而非全部函数;
- 可以为重载的成员提供一条 using 声明语句,using 声明语句指定一个名字而不指定形参列表,而一条基类成员函数的 using 声明语句可以把该函数的所有重载实例添加到派生类作用域中,这样就无须覆盖基类中的每一个重载版本,只需定义特有的函数就可以了,对派生类没有重新定义的重载版本的访问实际上是对 using 声明点的访问。
构造函数与拷贝控制
-
虚析构函数
- 基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象。
- 使用虚析构函数可以在通过基类指针或引用时动态绑定析构函数;
- 若基类的析构函数不是虚函数,则delete指向派生类对象的基类指针将产生未定义的行为。
- 虚析构函数将阻止合成移动操作。
- 基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象。
-
合成拷贝控制与继承
- 派生类中删除的拷贝控制与基类的关系
- 若基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类对应的成员将是被删除的,因为此时编译器不能使用基类成员来执行派生类对象的基类部分的构造、赋值或销毁操作;
- 若基类中有一个不可访问或删除掉的析构函数,则派生类合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类的基类部分;
- 和过去一样,编译器不会合成一个删除掉的移动操作,当我们使用 =default 请求一个移动操作时,若基类中的对应操作时删除的或不可访问,则派生类中的该函数是被删除的,因为派生类的基类部分不可移动;同样,若基类的析构函数是删除的,则派生类的移动构造函数也是被删除的;
- 注:在实际编程中,若基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。
- 派生类中删除的拷贝控制与基类的关系
-
派生类的拷贝控制成员
-
派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员;类似的,派生类赋值运算符也必须为基类部分的成员赋值;
-
定义派生类的拷贝或移动构造函数
-
默认情况下,基类默认构造函数初始化派生类对象的基类部分;若想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数:
class Base {...}; class D:public Base { public: D(const D& d):Base(d) //拷贝基类成员 D(const D& d):Base(std::move(d))//移动基类成员 };
-
-
派生类赋值运算符
-
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:
// Base::operator=(const Base&); 不会被自动调用 D &D::operator=(const D &rhs){ Base::operator=(rhs); //为其基类部分赋值 //按照过去的方式为派生类的成员赋值 //酌情处理自赋值及释放已有资源等情况 return *this; }
-
-
派生类析构函数
- 和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源;
- 派生类析构函数首先执行,然后是基类的析构函数,以此类推。
-
在构造函数和析构函数中调用虚函数(不太懂)
- 如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型的虚函数版本。
-
-
继承的构造函数
-
一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数;
-
类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们;
-
派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的 using 声明语句:
class Bulk_quote : public Disc_quote{ public: using Disc_quote::Disc_quote; //继承 Disc_quote 的构造函数 double net_price(std::size_t) const; }; //在上述程序中,继承的构造函数等价于 //若派生类有自己的数据成员,将被默认初始化 Bulk_quote(const std::string& book, double price, std:size_t qty, double disc): Disc_quote(book, price, qty, disc){ }
-
-
继承的构造函数的特点
- 一个构造函数的using声明不会改变该构造函数的访问级别;
- 当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参;
- 如果基类有多个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数。第一个例外情况是,如果派生类定义的构造函数于基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。第二个例外是默认、拷贝和移动构造函数不会被继承。
-
-
容器与继承
- 因此当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式:
- 因为不允许在容器中保存类型不同的元素;
- 当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”。
- 在容器中放置(智能)指针而非对象
- 当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型。
- 因此当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式: