面向对象程序设计基于三个基本概念:数据抽象、继承、动态绑定。
数据抽象是一种依赖于接口和实现分离的编程(以及设计)技术;类的接口包括用户所能执行的操作,类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
OOP:概述:
面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
在C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于默写函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。
在C++中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
定义基类与派生类:
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此,可以利用编译器来提醒派生类析构函数的编写(个人认为)。
虚成员用virtual来声明,任意非static成员都可以为虚成员。
基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。对于部分成员,基类想让它的派生类访问,而禁止其他用户访问,用受保护的(protected)访问运算符来说明这样的成员。
派生类必须通过使用类派生列表来明确指出它是从哪个(哪些)类继承而来的。派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明,访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。大多数类都只继承自一个类,这种形式的继承被称作"单继承"。
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖它所继承其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。派生类可以在它覆盖的函数前使用virtual关键字,但不是非得这么做。
一个派生类对象有多个部分组成:一个含有派生类自己定义的(非静态)成员的子对象、以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那就有多个这样的子对象。因为派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或者引用绑定到派生类对象的基类部分上。这种转换通常称为派生类到基类的类型转换,和其他类型转换一样,编译器会隐式的执行派生类到基类的转换。这种隐式转换意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方;同样指针也可以。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
派生类构造函数:和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。 **每个类控制他自己的成员初始化过程。**首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
遵循基类的接口:必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给他的共有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并通过基类的构造函数来初始化那些从基类继承而来的成员。
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。无论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。
派生类的声明与其他类差别不大,声明中包含类名但是不包含它的派生类:一条声明语句的目的是令程序知晓某个名字的存在,以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与其他定义有关的其它细节必须与类的主体一起出现。如果我们想将某个类作基类,则该类必须已经定义而非仅仅声明。这一规定的原因显而易见:派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道他们是什么。因此,该规定还有一层隐含的意思,即一个类不能派生它自己。一个类是基类,同时也可以是一个派生类。
防止继承的发生:在类名后跟一个关键字"final",即表明不希望其他类继承他,或者不想考虑他是否适合做一个基类。
类型转换和继承:
可以将一个基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定的对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。
和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型:当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时从事已知的,它是变量声明时的类型或者表达式生成的类型;动态类型则是变量或者表达式表示的内存中对象的类型。动态类型直到运行时才可知。我认为是因为动态类型是直到运行时才能确定对象的类型,因为可能存在隐式类型转换等原因,从它的名字也能看出来。
基类的指针或者引用的静态类型可能与其动态类型不一致。
不存在基类向派生类的类型转换。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员,因此一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类想派生类的自动类型转换。、编译器在编译时不能确定某个特定的转换在运行时是否安全。这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。。如果基类中含有一个或多个虚函数,我们可以使用dynamic_cast请求一个类型转换,该转换的安全检查将在运行时执行,同样,如果我们家已知某个基类向派生类的转换是安全的,则我们可以使用static_cast来强制覆盖掉编译器的检查工作。派生类向基类的自动类型转换只对指针或者引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
当我们用一个派生类对象为一个基类对象初始化或者赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将会被忽略掉(切掉)。
要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要:
1、派生类向基类的类型转换只对指针或引用类型有效;
2、基类向派生类不存在隐式类型转换;
3、和任何其他成员一样,派生类向基类转换也可能会由于访问受限而变得不可行。
虚函数:
当我们使用基类的引用或者指针调用一个虚成员函数时会执行动态绑定。因为我们知道运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。通常情况下,如果我们不使用某个函数,则无需为该函数提供定义。但是我们必须为每一个虚函数都提供定义,而不管他是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
对虚函数的调用可能在运行时才会被解析:
当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。
OPP的核心思想是多态性(polymorphism):
我们把具有继承关系的多个类型称为多态类型,这是因为我们能使用这种类型的"多种形式"而无需在意他们的差异。引用或者指针的静态类型与动态类型不同这一事实正式C++语言支持多态性的根本所在。当且仅当我们通过指针或者引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
派生类中的虚函数:
当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字来指出该函数的性质。,然而并非必须这么做,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
一个派生类的函数如果覆盖了某个虚函数时,则它的形参类型必须与被它覆盖的基类函数完全一致。同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或者引用时,上述规则无效。
final和override说明符:
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类中的函数并没有覆盖掉基类中的版本,就实际的编程习惯来说,这种声明往往意味着发生了错误,在新标准中,我们可以使用override关键字来说明派生类中的虚函数,如果我们用override标记了某个函数,但该函数没有覆盖已存在的类型,此时编译器会报错。
我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作的都将引发错误。
如果虚函数使用默认实参,则基类和派生类中顶一顶默认实参最好一致。
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的。通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将会被解析为对派生类版本自身的调用,从而导致无限递归。
抽象基类:
纯虚函数:通过在函数体的位置书写"=0",其中"=0"只能出现在类内部的虚函数声明语句处。定义成纯虚函数可以清晰明了的告诉用户这么函数是没有实际意义的,和普通函数不一样,纯虚函数不需要定义。当然,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部,也就是说不能子在类的内部为一个"=0"的函数提供函数体。
含有纯虚函数的类是抽象基类:
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口,我们不能创建抽象基类的对象。
派生类构造函数只能初始化它的直接基类。
重构:重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。
访问控制与继承:
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问。其中特殊的就是受保护的成员"protect",概念很简单。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限。
派生类向基类转换的可访问性:
派生类向基类转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:
1.只有当D公有的继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或私有的,则用户代码不能使用该转换。
2.无论D以什么方式继承B,D是的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
3.如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B方式是私有的,则不能使用。
总结就是:对于代码中的某个定点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之不行。
就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的派生类的友元也不能随意访问基类的成员。不能继承友元关系;每个类负责控制各自成员的访问权限。
有时我们需要改变派生类继承的某个名字的访问级别,通过using声明可以达到这一目的,通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中的任何可访问成员标记出来。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。派生类只能为那些他们可访问的名字提供using声明。
继承中的类作用域:
每个类定义自己的作用域,在这个作用域内我们定义自己类的成员。**当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。**如果一个一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型和动态类型可能不一致,但是我们能使用的哪些成员仍然是由静态类型决定的。
和其他作用域一样,派生类也能重定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。即派生类的成员将隐藏同名的基类成员。**但是我们可以通过作用域运算符来使用一个被隐藏的基类成员。**作用域运算符将会覆盖掉原有的查找规则,并指示编译器从指示的作用域开始查找。因此,除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
名字查找与继承:理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem()(或者obj.mem()),则依次执行以下四个步骤:
1、首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然都是类类型。
2、在p(或obj)的静态类型对应的类中查找mem,如果找不到,测依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类任然找不到,则编译器将报错。
3、一旦找到了mem,就进行常规的类型检查以确定对于当前找到的mem,本次调用是否合法。
4、假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
------如果mem是虚函数且我们当前通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
------反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
名字查找先于类型查找:如前所述,声明在内层作用域的函数不会重载声明在外层作用域的函数。因此,定义在派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员和基类中的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也会仍然会被隐藏。
我们现在可以理解为什么基类和派生类中的虚函数必须有相同的作用域了,加入基类和派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。
对于重载的函数,如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。可以通过使用using来把所有的重载实例添加到派生类作用域中。
构数与拷贝函数:
虚析构函数:继承关系对基类拷贝控制最直接的影响是基类通常定义一个虚析构函数,这样我们就能动态分析继承体系中的对象了,可以确保执行正确得到析构函数版本。
虚析构函数将阻止合成移动操作。
作为基类使用的类应该具有虚析构函数,以保证在删除指向动态分配对象的基类指针时,根据指针实际所指的对象所属的类型运行适当的析构函数。虚析构函数可以为空,即不执行任何操作。一般而言,析构函数的主要作用是消除本类中定义的数据成员,如果该类没有定义指针类成员,则使用合成版本即可;如果该类定义了指针成员,则一般需要自定义析构函数以对指针成员进行适当的消除。因此,如果有虚析构函数必须执行的操作,则就是消除本类中定义的数据成员的操作。
就像其他任何类的情况一样,基类或派生类也能出于同样的原因(如果一个类有数据成员不能默认构造、拷贝、赋值、销毁,则对应的成员函数将被定义为删除的)将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数。此外某些基类的定义方式也可能导致有的派生类成员成为被删除的函数:
1、如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作;
2、如果在基类中有一个不可访问或者删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分;
3、和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用"=default"请求一个移动操作时,如果基类中的对应操作是删除的或者不可访问的,那么派生类中的该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类中的析构函数是删除的或者不可访问的,则派生类的移动构造函数也将是被删除的。
大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
当派生类定义了拷贝构造或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。派生类的赋值运算符也必须显示地为基类部分赋值。
值得注意的是,无论基类的构造函数或赋值运算符是自定义的还是合成的版本,派生类的对应操作都能使用它们。
和构造函数和赋值运算符不同,派生类析构函数只负责销毁由派生类自己分配的资源。
对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
如果析构函数或构造函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。因为其他版本的虚函数可能已经被销毁或者还未生成。
派生类能够重用其直接基类定义的构造函数,这些构造函数并非以常规的方式继承而来的。一个类只能初始化它的直接基类,出于同样的原因,一个类也只能继承其直接基类的构造函数。而不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将会为派生类合成他们。派生类继承基类构造函数的方式是提供一条注明了(直接)基类的using声明语句。
通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。和普通的using声明不一样,一个构造函数的using声明不会改变构造函数的访问级别。
当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数接受一个形参,它对应于基类中最左侧的没有默认值的那个形参。
容器与继承:
当派生类对象被赋值给基类对象时,其中派生类部分将被"切掉",因此容器和存在继承关系的类型无法兼容。
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型。
文本查询程序再探:
这一节的总结与心得在之前发的一篇专文中。