c++程序设计(第3版)系列笔记
预备知识
在c当中,避免字符串被截断的输入为gets(S),但是由于c语言新标准的推行和部分删除,在使用gets(S)时只能通过宏定义#define gets(S) fgets(S,sizeof(S),stdin)处理之后使用。
在c++当中,面对难以处理的字符串,我们不必再使用字符串数组进行存储,可以使用string类型进行操作,使用cin对于string进行输入时遇到空格会自动截断,可以使用getline(cin,字符串名);对于字符串进行含有空格的输入。
另外对于输入输出流来说,很显然c语言的格式化输入与格式化输出对于格式有着具体限制,这也有了scanf和printf在速度方面优于cin与cout,因为cin与cout在进行输入输出时自动对数据类型进行识别,所以速度较慢。c语言在输出方面使用printf对于格式有着非常省事的操作方法,但是对于c++来说,若相对于输出的数据格式进行修饰,要调用库函数#include 。比如:
- 设置列宽在所输出数据之前加上setw(列宽);
- 保留小数位数在所输出数据之前添加setiosflags(ios::fixed)和setprecision(保留位数)
当局部变量和全局变量名称一样时,对于全局变量的操作要在变量名之前添加::避免对局部变量的影响。
内联函数
顾名思义实际上就是在所定义的函数类型名之前添加inline,使程序在执行到该函数时不是转移至该函数的接口去调用该函数,而是将该函数于本该的调用位置就此展开,利用额外的内存空间换取运行速度。当然,在函数体内有循环语句、分支语句、if嵌套语句之内不能定义内联函数,否则将会代码膨胀导致内存爆炸。
引用
即"起外号"":给某一个变量起了另一个名字。例如int a;int &b=a;那么我们对b进行操作时,就等价于对a进行操作,在这里我们特别强调,此处的a、b共用同一段存储单元。
缺省函数
缺省函数即为缺少部分函数参数的函数,在我们未给出相对应的参数时,参数的值即为定义函数时的初值。譬如:
c++
void dispaly(int a=1;int b=2;int c=3){cout<<a<<' '<<b<<' '<<c;}
- 当 display(666,66,6)输出为666 66 6
- 当display(666,66)输出为666 66 3
- 当display(666)输出为666 2 3
- 当display()输出为1 2 3
注意,若存在以下定义:
c++
void display(int a,int b,int c=520,int d=1314){cout<<a<<b<<c<<d;}
void display(int a,int b){cout<<a<<b;}
当调用display(1,2);时,系统将无法确认到底是第一个函数还是第二个函数,从而导致编译错误。
重载函数
重载函数即为具有相同的功能,相同名字的函数,但是类型不同。
c++
int getmax(int a,int b,int c);
double getmax(double a,double b,double c);
long long getmax(long long a,long long b,long long c);
在我们设置一个变量去接收函数的值的时候,系统会自动根据我们所设置的接受变量的类型去匹配对应的函数返回类型:
c++
int ans=getmax(int a,int b,int c);//ans为int型
double ans=getmax(double a,double b,double c);//ans为double型
long long ans=getmax(long long a,long long b,long long c);//ans为long long 型
第八章笔记
面向对象程序设计方法概述
凡是以类对象为基本构成单位的程序称为基于对象的程序,面对象对象的程序设计的特点:封装,抽象,继承还有多态性。那么我们来说说,什么是对象?对象有两个基本要素:一是静态特征称为属性,二是动态特征称为行为。任何一个对象都有这两个基本要素所组成,在c++中,对象是由数据和函数这两个部分组成的,数据体现了之前提到的静态特征属性,函数体现了之前提到的动态特征行为,在一个系统的多个对象之间通过一定的渠道相互联系,要使某一个对象实现某一种行为,应向它传送相应的信息。
封装性
对一个对象进行封装处理,把它的一部分属性和功能对外界屏蔽,也就是说从外界是看不到的甚至是不可知的。好比c++当中的vetor整形数组,我们在对其进行输出,删除的时候只需要调用相关的函数即可,并不用去了解其是如何实现的,我们只负责运用,不负责架构。
封装性:一是将有关的数据和操作代码封装在一个对象之中,形成一个基本单位,各个对象之间相互独立,互不干扰、二是将对象之中的某些部分对外隐蔽,即隐蔽其内部细节,只留下少量接口,以便与外界联系,接收外界的消息。这种方法被称为信息屏蔽,信息屏蔽有利于数据安全,防止无关的人了解和篡改数据。
抽象性
抽象的作用是表示同一类事物的本质。
类与结构体相似但不同于结构体,它能够对自己的私有成员的数据通过自身各种函数访问并修改,从而实现具体情况具体化操作。举个例子,现在需要对学生的成绩系统进行设计,每个学生呢需要有其成绩,学号,指导老师,各课成绩等等,那么我们此时只需要构建一个类,并实现相应的功能就可以,而我们所设计的这个类,类似于int和double等,可以直接命名变量,我们命名的变量可以通过调用自身函数实现其本身的各式操作,我们定义的学生类就是对于学生这个整体的抽象,而我们定义的学生类的对象就是这个学生类的具体体现。即类是对象的抽象,对象是类的特例,也就是类的具体表现形式。
继承性
继承性体现在可以对于一个已经设置好的类进行额外的扩充与扩展,从而能够减少重新设计类的时间,以及实现更多的新功能。具体实现方式将在以后的学习当中提到。被继承的类称为基类或者称为父类,派生出来的类被称为派生类或者子类。
多态性
由于继承而产生的不同的派生类,其对象对同一消息会作不同的响应,多态性是面向对象程序设计的一个重要特征,能增加程序的灵活性。
面向对象程序设计的特点
将数据和有关操作封装成一个对象。程序设计者的任务包含两个方面:一是设计各种所需要的类和对象,决定把哪儿些数据和对象封装在一起;二是考虑怎样向有关对象发送消息以完成所需的任务。对于大型的程序设计来说,面向对象的程序设计能够大大的降低工作的难度,降低错误风险。
类和对象的作用
类是所有面向对象语言的共同特征,所有面向对象的语言都提供了这种类型。基于对象就是基于类,与基于过程的程序不同,基于对象的程序是以类和对象为基础的,程序的操作是围绕着对象进行的。在此基础上利用了继承机制和多态性,就是面向对象的程序设计。
一组数据是与一组操作相对应的,因此人们把相关的数据和操作放在一起,形成一个整体,与外界相对分隔。这就是面向对象的程序设计中的对象。
在基于过程的结构化的程序设计当中:程序=算法+数据结构,基于对象和面向对象程序设计当中:对象=算法+数据结构 程序=对象s+消息。这里的对象s表示多个对象,消息的作用就是对对象的控制。程序设计的关键是设计好每一个对象以及确定这些对象发出的命令,使各对象完成相应的操作。
类和对象的关系
类是抽象的不占用内存,对象是具体的,占用内存空间,类的声明的具体格式:
#include <iostream>
using namespace std;
class name//类名
{
private:
//私有的数据和成员函数
protected:
//不可以被类外函数访问但可以被派生类的成员函数访问
public:
//公有的数据和成员函数
};
private意味私有的,public意味公有的,他们被称为成员访问限定符。在类的定义当中,若是没有对数据和成员函数进行限定,那么默认为private。在struct结构体当中,则会默认为public。类名+对象名即可实现对对象的定义:
name OJ;
类的成员函数
类的成员函数和之前我们c中定义的函数基本相同,它可以在private、public、public当中,不同点在于它是一个属于类的成员只能出现在类体当中,成员函数所遵循的规则与数据一样。
一般来说,成员函数只是一个工具函数,我们把public中的函数定为类的对外接口。外界可以调用这些接口从而实现对类的具体操作,而有的不在public中的函数是支持其他函数的。这种函数用于支持其他函数的操作,被称为工具函数,类的成员函数十分重要,若是一个类当中没有了成员函数,那么它将与结构体没有差异,它的定义也就体现不出在面向对象程序设计的作用。
在类外定义成员函数
前提是该成员函数已经在类之中进行声明,在类外可以通过函数返回值类型 类名::函数名()对其进行定义。"::"是作用域限定符或称作用域运算符,若在作用域限定符前没有类名,或在函数名之前既无类名又无作用域限定符,那么此函数为全局函数。
内置成员函数
内置函数这个概念在c++第一次课中已经讲到,就不再赘述,对于类来说,在其之中定义的函数默认为inline内置函数,而在类之外定义类的函数时,需要加inline才为内置函数,否则系统不认为其是内置函数。
成员函数的储存方式
同一类的不同对象中的数据成员的值一般是不同的,而不同对象的函数代码是相同的,不论调用哪儿一个对象的函数的代码,其实调用的都是同样内容的代码,在c++之中,**每个对象所占用的存储空间只是该对象的数据成员所占用的存储空间,而不包括函数代码所占用的存储空间。**即一个对象所占的空间大小只取决于该对象中数据成员所占用的空间,与成员函数无关。
无论成员函数在类内定义还是在类外定义,成员函数的代码段的存储方式都是相同的,都不占对象的存储空间,不要将成员函数的这种存储方式和inline函数的概念混淆,**不论是否用inline声明,成员函数的代码段都不占用对象的存储空间,inline函数只影响程序的执行效率,而与成员函数是否占用对象的存储空间无关,**虽然成员函数没有放在对象的存储空间当中,但从逻辑上的角度,成员函数是和数据一起封装在一个对象中的,只允许本对象中成员的函数访问同一对象中的私有数据。
对象成员的引用
对象名.成员函数 对象名.成员名 其中.为成员运算符。在一个类当中应当至少有一个公用函数,作为对外的接口,否则就无法对对象执行任何操作。也可以使用对象的引用,利用引用访问对外接口从而访问对象。
类的封装性与信息屏蔽
共用成员函数可以说是用户使用类的公用接口,或者说是类的对外接口,仅仅调用类而无法了解其内核体现了接口与实现分离,通过成员函数对数据成员进行操作称为类的功能的实现,类中被操作的数据是私有的,类的功能的实现细节是对用户隐蔽的,这种实现方式称为私有实现,"类的公用接口与私有实现的分离"形成了信息屏蔽。
类声明和成员函数定义的分离
类声明头文件是用户使用类库的共用接口,包含成员函数定义的文件就是类的实现,类声明和函数定义是放在两个文件中的,一个完成的c++程序由三部分组成(建一个项目):
- 类声明头文件 .h
- 类实现文件 .cpp
- 类的使用文件 .cpp(主文件)
对于类实现文件仅仅需要编译一次即可,在使用时与对应的程序目标文件相连接。在系统提供的头文件中只包括对成员函数的声明,而不包括成员函数的定义,只有把对成员函数的定义单独放在另一文件当中,单独编译,才能做到不重复编译。
类库包括两个部分:
- 包含类声明的头文件
- 已经过编译的成员函数的定义,它是目标文件
面向对象程序设计当中的几个名词
类的成员函数在面向对象程序理论中被称为"方法",很显然,只有被声明为公用的方法才能被外界所激活。外界是通过发"消息"来激活有关方法的。
补充笔记
- 类中的成员不允许初始化,若初始化要使用构造函数,将在下一章讲到
- 类中的成员函数默认为private类
- 派生类的友元函数可以访问类private中的数据
- 私有private对于变量和函数的限制要求是一样的
- 可以在类的外部定义类的成员函数,在类中声明函数之后
- 在类外使用函数返回值类型+类名+::+函数名(参数)即可完成对其定义
- 类的存储空间仅为内容变量开辟
- 如果定义了一个对象的引用,那么引用与原本对象共用一段存储单元
第九章笔记
对象的初始化
因在类中,类并不是一个具体的实体,而是一种抽象的类型,所以我们在定义一个类时,对于类中的变量赋初值是非法的,因为类在定义之后并不占用存储空间,所以也无处容纳数据,若如果一个类当中的所有的成员都是公用的,那它与结构体也就基本等价,此时可以在定义一个对象时对于其中的数据初始化,例如:
#include <iostream>
using namespace std;
class student
{
public:
string num;
string name;
string subject;
};
int main()
{
student stu={"202110900000","朱奕锦","计算机科学与技术"};
cout<<stu.num<<' '<<stu.name<<' '<<stu.subject;
return 0;
}
对于学生类中的学号,名字,专业进行了初始化,即直接赋值,但仅仅对于class类中数据全为public时才可以。否则编译报错。那么对于对象的初始化,我们发现,当类中数据全为public时我们可以对类中数据赋初值,那么如果类中的数据不为public时呢?我们还是和之前那样为了对private或者protected中的数据赋值设置一个一个函数去得到输入值并且return赋予吗?对于数据较小时可以,但对于数据较大时,一个一个调用函数未免也太麻烦了。这时我们引入一个新的概念------------构造函数。
构造函数实现数据成员的初始化
构造函数与我们定义的一般函数不同,它是一种特殊的函数,用于处理对象的初始化,它不需要用户来调用,而是在用户建立对象的时候自动执行。构造函数的要求:
-
它必须与类同名,便于系统识别他为构造函数
-
它不具有任何类型 ,不具有任何返回值
#include <iostream>
using namespace std;
class student
{
private:
string num;
string name;
string subject;
public:
student()
{
num="空";
name="空";
subject="空";
}
void setdata();
void display();
};
int main()
{
student stu;
stu.display();
stu.setdata();
stu.display();
return 0;
}
void student::setdata()
{
num="202110900000";
name="朱奕锦";
subject="计算机科学与技术";
}
void student::display()
{
cout<<num<<' '<<name<<' '<<subject<<endl;
}
我们在此学生类未输入任何数据时定义的构造函数对所有数据初始化为"空",在数据输出后第二次输出对象中的数据发现数据进行了改变,这就是构造函数的作用。注意:
-
在建立类的对象的时刻会自动调用构造函数,对象建立几个,构造函数运行几次,构建一个学生库,即开辟一个类对象为数组,我们发现,在数组当中,为输入数据时,相应的学号姓名专业都为"空"。即拥有了初值。
-
构造函数的作用仅仅是对对象进行初始化,因此他并不需要声明类型
-
构造函数无需调用,也不能够被用户调用
-
可以用一个类对象初始化另一个类对象
-
在构造函数的函数体中不仅仅可以包括对于数据成员赋初值,还可以包括其他语句例如cout
-
如果用户在定义类的时候没有定义构造函数,那么c++系统将会自动生成一个空的构造函数,此构造函数不进行任何操作
带有参数的构造函数
带有参数的构造函数声明时:
构造函数名(参数类型);
带有参数的构造函数定义时(在类外定义):
类名::构造函数名(参数类型 参数)
{
某一数据成员=参数;
}
带有参数的构造函数调用时:
类名 对象名(实参);
例如:
#include <iostream>
using namespace std;
class student
{
private:
string num;
string name;
string subject;
public:
student(string,string,string);
void display();
};
int main()
{
student stu1("202110900000","朱奕锦","计算机科学与技术");
stu1.display();
student stu2("202110900000","min","金融学");
stu2.display();
return 0;
}
void student::display()
{
cout<<num<<' '<<name<<' '<<subject<<endl;
}
student::student(string a,string b,string c)
{
num=a;
name=b;
subject=c;
}
在带有参数的构造函数声明是仅仅在括号内添加相应参数的类型即可,在类外定义时,直接对数据成员用形参进行赋值,在带有参数的构造函数调用时要注意,在构建对象时在后面直接加括号与相关的形参以及其类型输出实参并与之一一对应,即可实现带有参数的构造函数赋初值,带有参数的构造函数赋初值在实际应用上比无参的构造函数要好用且实用得多。这种初始化对象的方法使用起来很简便,很直观,可以从定义语句之中直接看到数据成员的初值。
用参数初始化表实现对数据成员的初始化
参数初始化表可以在类体内定义构造函数而不是在类体外,并且能够减少函数体的长度,使代码变得简短精炼。尤其当需要初始化的数据成员较多时更能凸显出其优越性,此方法被许多c++程序人员喜爱并且使用。
类名 (参数类型 参数):参数(数据成员){}
例如:
#include <iostream>
using namespace std;
class student
{
private:
string num;
string name;
string subject;
public:
student(string a,string b,string c):num(a),name(b),subject(c){}
void display();
};
int main()
{
student stu1("202110900000","朱奕锦","计算机科学与技术");
stu1.display();
student stu2("202110900000","min","金融学");
stu2.display();
return 0;
}
void student::display()
{
cout<<num<<' '<<name<<' '<<subject<<endl;
}
num(a)的意思是:将a的值赋予num,以此类推,在使用参数初始化表时在语句末尾为大括号。
构造函数的重载
我们在c++的第一次课堂上已经提到了重载的概念,很显然,构造函数可实现重载,例如:
#include <iostream>
using namespace std;
class student
{
private:
string num;
string name;
string subject;
public:
student();
student(string a,string b,string c):num(a),name(b),subject(c){}
void display();
};
int main()
{
student stu1;
stu1.display();
student stu2("202110900000","朱奕锦","计算机科学与技术");
stu2.display();
return 0;
}
void student::display()
{
cout<<num<<' '<<name<<' '<<subject<<endl;
}
student::student()
{
num="空";
name="空";
subject="空";
}
不指定实参时,则调用的不含参数的构造函数,指定了实参,调用的为参数初始化表对于数据成员赋值。参数不同,调用的构造函数不同,这就是构造函数的重载。系统可以根据参数个数来决定调用哪一个构造函数。需要注意的是:
-
在建立对象时不必给出实参的构造函数称为默认构造函数,在一个类当中有且只能有一个默认构造函数,若用户在定义类的时候没有定义默认构造函数,那么c++系统将会自己定义,空的默认构造函数不具有任何作用
-
在定义无参构造函数时一定要注意其形式,且不能被用户主动调用
-
尽管在一个类当中可以定义多个构造函数,但对于每一个对象来说,在建立的过程中仅仅执行其中一个构造函数,并非每个构造函数都被执行
使用默认参数的构造函数
构造函数参数的值既可以通过实参传递,也可以指定为某些默认值,即如果用户不指定实参值,编译系统就是形参取默认值,例如:
#include <iostream>
using namespace std;
class student
{
private:
string num;
string name;
string subject;
public:
student(string a="202110900000",string b="朱奕锦",string c="计算机科学与技术"):num(a),name(b),subject(c){}
void display();
};
int main()
{
student stu0;
stu0.display();
student stu1={"202110916113"};
stu1.display();
student stu2={"202110916113","张毅"};
stu2.display();
student stu3={"202110916113","张毅","物联网工程"};
stu3.display();
return 0;
}
void student::display()
{
cout<<num<<' '<<name<<' '<<subject<<endl;
}
注意:
-
应在构造函数声明时指定其默认值,而不能够只在定义函数时指定默认值
-
在声明构造函数时,形参名可以省略
-
一个类只能有一个默认构造函数,即可以不用参数而调用的构造函数,一个类有且只能有一个
-
在一个类当中定义了全部是默认参数的构造函数之后,不能够再定义重载函数,否则系统将无法判断调用哪儿个函数,容易产生歧义,因此,一般不应同时使用构造函数的重构和有默认参数的构造函数
析构函数
析构函数在定义的过程之中在函数名头前加上~,析构函数的函数名要和类名相同,析构函数的作用并不是删除一个对象。析构函数的真正作用是在撤销对象占用的内存之前完成一些清理工作,或者是在最后一次使用对象之后所执行一些操作。析构函数不返回任何值,没有函数类型,也没有函数参数,由于没有函数参数,所以也不能够被重载。
在一个类当中可以有多个构造函数,但是有且只能有一个析构函数,与构造函数相同的一点是,若在定义类的过程中未定义析构函数,那么类就会在其本身自己生成一个析构函数,徒有名称与形式,实际什么都不做,当对象的生命周期结束是,会执行析构函数,简单来说,有以下四种情况:
-
如果在一个函数之中定义了一个对象,当这个函数被调用结束时,对象应该释放,在对象释放前自动执行析构函数
-
static静态局部对象在函数调用结束时对象不释放,因此也不调用析构函数,只有在main函数或者调用exit函数结束程序时,才调用static局部对象的析构函数
-
如果定义了一个全局的对象,则在程序的流程离开其作用域时(如main函数结束或调用exit函数)时,调用该全局的对象的析构函数
-
如果用new运算符动态地建立了一个对象,当用delete运算符释放该对象时,先调用该对象的析构函数
调用构造函数和析构函数的顺序
在一般情况下,调用构造函数的顺序正好与调用析构函数的顺序相反:先构造的后析构,后构造的先析构,相当于一个栈,先进后出。但是并非所有情况下都按照这一原则来处理的:
-
如果在全局 范围定义对象,那么它的构造函数在本文件模块中的所有函数(包括main函数)执行前调用,但如果一个程序包含多个文件,而在不同的文件之中都定义了全局对象,则这些对象的构造函数的执行顺序是不确定的。当main函数执行完毕或调用exit函数时了,调用析构函数
-
如果定义的是局部自动对象,则在建立对象时调用其构造函数,如果对象所在的函数被多次调用,则在每次建立对象时都要调用构造函数,在函数调用结束,对象释放时先调用析构函数
-
如果在函数中定义静态局部对象,则只在程序第一次调用该函数定义对象是调用构造函数一次 ,在调用函数结束时对象并不释放,因此也不调用析构函数,只在main函数结束或者调用exit函数结束程序时才调用析构函数
-
使用new开辟的对象在开辟时自动调用其析构函数,只有在其使用delete删除之后才使用析构函数,否则即使程序结束,所开辟的对象仍旧存在,并且占用一定的存储空间(内存泄漏)
对象数组
数组不仅可以由简单的变量组成,也可以由类对象组成。在建立数组时,建立多少个数组对象元素,则需要调用多少次构造函数 。如果构造函数仅有一个参数,在定义数组时可以直接在等号后面的花括号内提供实参,若构造函数有多个参数时,每个元素要对应多个实参,如果再考虑到构造函数有默认参数的情况,很容易造成实参与形参的对应关系不清晰,出现歧义性。例如当定义student stu[3]={10,8,3};这三个实参的值并不是第一个对象的三个实参的值 ,而是前三个对象的首个实参的值,因此一般不这样来写程序。
编译系统只为每个对象元素的构造函数传递一个实参,所以在定义数组的时候提供的实参的个数不能够超过数组元素的个数。当然,在花括号中分别写出构造函数名并在括号内指定实参,可以对具有多个参数的构造函数的对象数组实现初始化。在建立对象数组时,分别调用构造函数对于每个元素初始化。每一个元素的实参分别用括号括起来,对应构造函数的一组形参,不会混淆。
补充笔记
- 对于局部对象,每次定义对象时,都要调用构造函数
- 对于静态对象,在首次定义对象是,调用构造函数,且由于对象一直存在,只调用一次构造函数
- 对于全局对象,是在main函数执行之前调用构造函数的
this指针
在类建立多个对象时,如何实现的具体对象的具体数据分开存储的呢?对象中的数据成员的名字都相同,系统通过一个this指针来实现,在每一个成员函数中都包含一个特殊的指针,这个指针的名字是固定的,称为this。它是指向本来对象的指针,它的值是当前被调用的函数所在的对象的其起始地址。例如对象a中有数据成员volume,我们在调用的时候通常操作为a.volume,就是给了系统一个提示我们调用的是a对象中的数据成员volume而不是其他对象当中的,其实质为this->volume,其中this的值为对象a的起始地址,this指针是作为隐式使用的,它是作为参数被传递给成员函数的。我们在平常的类和对象的使用当中,this指针的实现都是编译系统自动实现的,编程序者不必人为地在形参中增加this指针,也不必将对象a的地址传给this指针。
举个例子,例如一个计算体积的函数,隐式使用this指针为:
int Box::volume()
{
return (height*lenth*width);
}
显式使用this指针为:
int Box::volume(Box *this)
{
return ((*this)->height*(*this)->lenth*(*this)->width);
}
this指针的使用过程,只是为了更好的使读者理解this指针实现的作用和机理。
共用数据的保护
常对象
定义常对象的一般形式为:类名 const 对象名[(实参表)];
-
常成员函数是常对象的唯一对外接口
-
常成员函数允许访问 常对象中的数据成员,但是不允许修改常对象中数据成员的值
常对象成员
常数据成员
只能通过构造函数的参数初始化表对常数据成员进行初始化,其他任何函数都不能对长数据成员赋初值,因此在定义常对象时,构造函数只能用参数初始化表对长数据函数进行初始化。
常成员函数
在函数的参数表之后加上const即可,一般格式为:类型名 函数名(参数表) const。
数据成员 | 非const的普通成员函数 | const成员函数 |
---|---|---|
非const的普通数据成员 | 可以引用并修改其值 | 只读 |
const数据成员 | 只读 | 只读 |
const对象 | 不允许 | 只读 |
-
如果在一个类当中,有些数据成员的值允许改变,有些不允许改变,那么可以将不可以改变的值用const修饰,确保其值不被改变,可以使用非const的成员引用这些数据成员的值并对非const的数据成员进行修改
-
如果要求所有的数据成员都不可以改变,那么都对其进行const修饰,然后用const成员函数引用,起到双保险的作用
-
常对象只能调用常成员函数,但是常对象里面并不都是常成员函数,常对象只是保证其数据成员不能被修改而已
指向对象的常指针:一般形式为:类名 *const 指针名=&所指对象名;将指针变量声明为const型,会使指针所指向的对象不可以在再改变。
指向常对象的指针变量
-
如果一个变量被声明为常变量,那么只能用一个指向常变量的指针来指向它,而不能用非const型的指针指向它
-
指向常变量的指针不仅可以用来指向常变量,也可以用来指向非const变量,但仅仅为只读。指向常对象的常指针指向的对象可以改变但是其指向的对象的值不可以改变,一般用作函数的形参,用于保护函数调用对象的相关数据不被修改
-
如果函数的形参为非const型指针,那么实参需为非const型指针,因为要对其所指向的变量进行改变数据的操作
常对象的注意事项:
-
在指向常对象,只能够用const型指针指向它
-
如果定义了一个指向常对象的const型指针,若它指向了非const的对象,那么该对象的值不可改变
-
指向常对象的指针一般用于函数的形参,可以保护指针所指的对象的数据成员不被修改,当希望在调用函数时对象的值不被修改,就应当把形参定义为指向常对象的指针变量,同时用对象的地址作为实参。
-
如果定义了一个指向常对象的指针变量,是不能通过它改变所指向的对象的值的,但是指针变量本身的值是可以改变的,即该指针指向的常对象可以改变
对象的常引用
对象可以在函数体内引用,例如void display(BOX &A);此时引用的对象A的值其中的数据成员是可以改变的,但是若是在类名后加const,即void display(BOX const &A);此时引用的对象A的值其中的数据成员是不可以改变的。
补充笔记
-
常对象只能调用常成员函数,即在成员函数后面加上const即可
-
常数据成员,在说明数据成员时用const修饰。
-
常数据成员只能通过构造函数参数表对其进行初始化,其他函数都不能对该成员赋值
-
若构造函数参数表中对一个const成员进行初始化,因为构造函数参数表的赋值晚于构造函数定义内容时对成员的初始化,将导致对初始化const成员赋值,造成编译错误
-
为什么要对const成员一定用构造函数参数表赋值?因为在是用构造函数参数表赋值的话,const成员的值已经给出了,但若是使用一般的构造函数的话,const成员先创建未赋值,里面将是一个随机值。
使用普通构造函数对const成员赋值的话,等价于对于带有随机值的const成员重新赋值,导致编译错误
-
常成员函数,在一般的函数之后加const即可
-
常对象只能调用常成员函数
-
常成员函数不能更新对象的数据成员
-
指向对象的常指针:类名 *const 指针名=&对象名;
-
指向对象的常指针变量的值不能被改变,但其所指对象的数据成员的值可以被改变
-
常用来作为函数的形参,避免调用函数时误操作使指针指向的指的那块数据改变
-
指向常变量的指针变量:const 类型名 *指针变量名=&常变量;(只读)
-
如果一个变量已被声明为常量,则只能用指向常变量的指针变量指向它
-
指向常变量的指针变量也可以指向一个非const变量,即无法更改只读
-
如果函数的形参是指向非const型变量的指针,则实参只能用指向非const型变量的指针
- const->const只读
- const->非const只读
- 非const->非const修改
-
函数的形参为const常引用情况下,表示传递的实参不允许被修改只允许被调用
-
类的成员函数当中若有函数含有常量引用作为函数的实参,若该类定义的对象不为常对象则直接毙掉
对象的动态建立与释放
new 类名可以创建一个该类的动态对象,但是此对象没有名字,只能通过指针来访问并使用,在new一个类名的时候,仅仅是创建了一个动态的对象。并不能去访问和使用,因此我们就需要用一个指针来指向它,以便于使用它。
c++
类名 *指针名;
指针名=new 类名;
c++
类名 *指针名=new 类名;
在new动态创建一个对象的时候,这个对象是无名的,无法知道它的对象名,因此无法访问和使用,但是这个对象是的的确确的存在着的,并在动态创建其时调用了自身的构造函数。想要使用它的话,就只能通过指向它的指针来访问。当不再需要使用它的时候,用
delete 指向其的指针名;
即可删除,在删除时我们要注意指针的指向,指针指向谁,删哪儿个指针,哪儿个动态建立的对象就会被删除,不要删错了。在执行delete运算符时,该动态对象首先完成析构函数进行数据的相关善后工作,而后才被删除释放内存空间。
对象的赋值与复制
对象的赋值:对于两个同类的对象来说,假设a,b对象同类a中的所有数据成员都有值而b没有,我们想让b对象中数据成员的值与a相同,那么执行b=a即可实现b对象中所有成员函数的赋值。
对象的赋值仅仅包括对象的数据成员的赋值,而不包括对成员函数的赋值:因为对象的数据成员是占据着存储空间的,对象的赋值仅仅为将一个对象的数据成员的空间存储状态赋值给另一个对象,使另一个对象的数据成员的空间存储状态与赋值对象完全相同。成员函数不赋值的原因是因为成员函数本身就是一个代码段,而同一个类当中的成员函数的代码段都是相同的,所以不需要赋值,且无法赋值。
类的数据成员之中绝对不要包括动态分配的数据,否则将产生及其严重的后果(记住结论即可)。
对象的复制:对象的复制就是快速创建多个与源对象一模一样的新对象,一般形式为:
类名 对象2(对象1);
创建一个对象2,与对象1中的数据完全相同,在类当中,我们需要定义一个复制函数即:
c++
类名::类名(const 类名 &对象名)
{
数据成员1=对象名.数据成员1;
数据成员2=对象名.数据成员2;
数据成员3=对象名.数据成员3;
}
我们可以发现,这个复制函数与构造函数形式非常相似。仅仅使形式参数不同,复制函数的形式参数为对象名,且一般要加const ,目的是为了放置在对象的复制过程中因操作失误修改了源对象的值,本函数称为复制构造函数,如果用户未创建一个复制构造函数,那么系统将会自动创建,不过自动创建的复制构造函数仅仅能够实现简单的数据成员的复制。还有另外一种复制对象的方式:
类名 对象2=对象1;
其作用为调用复制构造函数创立对象2与对象1中的数据成员的值相同,那么重中之重来了,对象的赋值与复制都是将一个对象的数据成员的值复刻给另一个对象,如何区分呢?复制与赋值又有什么区别呢?
对象的赋值与复制的区别
首先我们从对象创立的角度来看:对象的赋值是对一个已经存在的对象赋值,因此必须要先定义一个对象使其存在之后,才能够进行赋值操作。但对象的复制则是一个对象从无到有的复刻的过程。
简单来说:
c++
对象的赋值是有钱吃饭,有一定的基础存在再实现
对象的复制是赚钱吃饭,创造一定的基础并去实现
普通构造函数与复制构造函数的区别
我们可以从三方面来具体看:
- 从构造函数的形式参数方面
普通构造函数的声明为:
c++
类名(数据类型 数据成员);
复制构造函数的声明为:
c++
类名(类名 &对象名)
明显可以看出,复制构造函数的形参为对象。
- 在建立对象时的实际参数不同
利用普通的构造函数创建对象:
c++
类名 对象名(数据类型 数据成员);
利用复制构造函数创建对象:
c++
类名 对象名1(对象名2);
- 调用的情况
普通的构造函数在对象创立的时候就已经被调用,复制构造函数在用以有对象复制一个新对象的时候被调用。其具体情况为:
- 程序当中在建立一个新对象的同时用另外一个对象对新对象进行初始化
- 当函数的参数为类的对象的时候,系统需要将实参对象完整的传递给形参,因此需要建立一个实参的拷贝,即根据实参复制一个形参供复制构造函数使用,系统是通过调用复制构造函数来实现的,以此保证形参具有与实参完全相同的值
- 函数的返回值是类的对象时,在函数调用完毕将返回值带回函数的调用处时,此时需要将函数中的一个对象复制一个临时对象并传回函数调用处,返回的对象并不是函数中所使用的对象,而是利用函数中具有一定周期生命的对象所创建的临时对象
在以上三种情况下,系统会自动调用复制构造函数,谨记即可。
静态成员
在过去c的学习过程当中,我们了解到了static并用其创建静态变量,在c++的学习当中,我们了解到类的使用与数据类型的使用有类似的地方,是不是代表着我们也可以创建一个静态的类对象中的数据成员或者函数呢?答案是肯定的。
静态数据成员
静态数据成员即该类的对象所能共同拥有的一个数据成员,它在各个对象中的值都是相同的,并且只占一份存储空间,而不是在每个对象当中都占一份。所有对象都可以引用它,若改变它的值,则它在所有对象中的值都将改变,这样可以节省空间,提高效率。
在第八章之中我们知道,在定义一个类的同时,其不占据存储空间的,仅仅在创建对象的时候才会为其数据承运创建存储空间。但若是在一个类当中含有static静态数据成员,无论该对象是否创建,系统都为该静态数据成员开辟存储空间,即静态数据成员不随对象的建立而分配空间,也不随对象的撤销而释放,静态数据成员是在程序开始运行时创建,程序结束时释放空间。静态数据成员可以初始化,但要在类外进行初始化。在一个类当中,所有的对象共用这些静态数据成员,并且可以引用它们。
在类外进行初始化时:
数据类型 类名::静态数据成员名=值;
仅仅在声明静态数据成员时加static即可,不必在初始化的时候再加,注意:不能通过参数初始化表来对静态数据成员进行初始化。静态数据成员可以通过类名引用,也可以通过对象名引用。
c++
类名::静态数据成员名;
对象名.静态数据成员名;
只有定义为公用的静态数据成员才可以在类外直接引用,若定义为private,则需要通过接口来实现间接的引用。没有定义对象,也可以访问静态数据成员,证明静态数据成员并不是属于对象的,而是属于类的,可以被类和类的对象引用。静态数据成员再某种意义上来说与全局变量作用类似,但是不完全相同。全局变量的使用破坏了c++的面向对象程序设计的封装性,而静态数据成员恰恰弥补了这一点。
静态成员函数
静态成员函数的性质与静态数据成员相同,即他不属于对象而是属于类,能够通过类和类的对象调用,与一般函数的定义的区别在于,在函数的返回值类型前加static即可。
通过类名调用:
c++
类名::函数名(参数);
通过类的对象名调用
c++
对象名.函数名();
在之前我们了解到,当调用一个对象的非静态成员函数时,系统会默认产生一个this指针指向该对象的 起始地址,因为静态成员函数并不属于某一对象,因此其没有this指针 ,这也决定了静态成员函数不能方位本类当中的非静态数据成员,但也不是绝对的,可以引用非静态数据成员,只是不能进行默认访问,因为无法知道访问哪儿个对象,若是有对象名的指引,则可以引用。
**静态成员函数可以直接引用本类当中的静态成员,因为静态成员也是属于本类的,可以直接引用。**在c++当中,静态成员函数主要用来访问静态数据成员,而不访问非静态数据成员。公用的成员函数可以引用本对对象当中的静态数据成员和非静态数据成员。在c++的操作当中,为了避免出错,我们要养成静态成员函数仅仅引用静态数据成员的习惯。静态数据成员与静态成员函数的作用域仅仅限制在定义该类的作用域范围之内。
友元
友元即friend,通过友元函数可以访问某一个类当中private型函数,友元函数和友元类我们可以通过比喻形象的理解,我们可以把public中的数据成员和函数看作自己家的客厅,把private中的数据成员和函数看作自己家的卧室。
众所周知,在我们的传统观念当中,private相当于卧室,public相当于客厅。而友元函数则是相当于好朋友的身份,他可以进入卧室。友元类当中含有多个数据成员以及函数,所以它相当于好朋友的一家子,他们都能进卧室。
普通函数声明为友元函数,友元成员函数与友元类
- 将普通函数声明为友元函数
注意:在声明时仅仅需要在普通函数的函数类型前加friend,在定义时将这个函数当成普通函数定义即可,即其不是类的内部函数,不需要加类名::来限制。要求:在本类以外定义的一个函数,声明在类中,且函数类型前加friend。
c++
#include <iostream>
using namespace std;
class NUM
{
private:
string baby;
public:
friend void display(NUM &);
NUM(string);
};
int main()
{
NUM a("王嘉隆");
display(a);
return 0;
}
void display(NUM &a)
{
cout<<"运用友元函数访问类中的私有数据:"<<endl;
cout<<"我的大宝儿是"<<a.baby<<endl;
}
NUM::NUM(string a)
{
baby=a;
}
譬如以上程序,我们可以运用友元函数访问类当中的baby成员函数,注意这一行代码:
c++
friend void display(NUM &);
意思是友元函数的形式参数是一个NUM类定义的对象,这样使用友元函数可以使友元函数的访问对象得以指定,因为当同一个友元函数在多个类的定义当中被声明时,我们不知道要用其访问哪儿一个,那么它可以访问多种类的对象的私有数据成员,我们需要为其指定,因为友元函数不是类的成员函数,没有this指针来供其使用。
- 友元成员函数
友元函数不仅仅可以作为类外的函数,也可以作为类内的成员函数,用于访问另一个类中的数据。
c++
#include <iostream>
using namespace std;
class Date;
class Time
{
private:
int hour;
int minute;
int second;
public:
void display(Date &);//Time类定义的对象可以访问Date类定义的对象
Time(int,int,int);//构造函数
};
class Date
{
private:
int year;
int month;
int day;
public:
Date(int,int,int);//构造函数
friend void Time::display(Date &);//声明Time类中的友元成员函数display可以对于Date类定义的对象进行访问
};
int main()
{
Time a(19,45,33);
Date b(2022,4,26);
a.display(b);
return 0;
}
Date::Date(int a,int b,int c)
{
year=a;
month=b;
day=c;
}
Time::Time(int a,int b,int c)
{
hour=a;
minute=b;
second=c;
}
void Time::display(Date &A)
{
cout<<A.year<<'-'<<A.month<<"-"<<A.day<<endl;
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
Time类当中定义的display成员函数在Date类当中定义为友元成员函数,即display成员函数在Time类当中为普通函数,形参为Date类。display成员函数在Date类里作为其的友元成员函数。
- 友元类
在一个类A的定义时,我们可以把另一个类Bfriend一下,而后这个类B当中的所有函数就都是类A的成员函数了,即类B中的所有函数可以访问类A中的所有数据成员。
c++
#include <iostream>
using namespace std;
class Date;//提前声明类Date
class Time
{
private:
int hour;
int minute;
int second;
public:
void display(Date &);//Time类定义的对象可以访问Date类定义的对象
Time(int,int,int);//构造函数
};
class Date
{
private:
int year;
int month;
int day;
public:
friend Time;
Date(int,int,int);//构造函数
};
int main()
{
Time a(19,45,33);
Date b(2022,4,26);
a.display(b);
return 0;
}
Date::Date(int a,int b,int c)
{
year=a;
month=b;
day=c;
}
Time::Time(int a,int b,int c)
{
hour=a;
minute=b;
second=c;
}
void Time::display(Date &A)
{
cout<<A.year<<'-'<<A.month<<"-"<<A.day<<endl;
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
** 注意类在未定义时我们在其定义前使用它的时候要声明**:即class 类名;
三种友元的特征总结
普通函数作为友元 | 友元成员函数 | 友元类 | |
---|---|---|---|
声明 | 在此普通函数前加friend | A成员函数形参中为(B类对象) | 注意A类要定义在B类之前 |
定义 | 类外定义,与普通函数定义格式相同 | B类成员函数当中加friend+函数返回值类型+A类名+::函数名+(形参) | 在定义B类的时候friendA类 |
以上情况为A类可以访问B类数据的情况。
类模板
能够实现功能相同,且适用于不同数据的模板通用类。由于类模板包含类型参数,因此又称为参数化的类,类是对象的抽象,对象是类的实例,类模板是类的抽象,类是类模板的实例。例如我们定义一个比较大小的类,适用于整数和浮点数以及字符:
c
#include <iostream>
using namespace std;
template <class nametype>
class Compare
{
private:
nametype x;
nametype y;
public:
Compare(nametype a,nametype b){x=a,y=b;}
nametype maxitem(){return (x>y)?x:y;}
nametype minitem(){return (x<y)?x:y;}
};
int main()
{
Compare <int> a(3,5);
cout<<"二者最大值为:"<<a.maxitem()<<endl;
cout<<"二者最小值为:"<<a.minitem()<<endl;
Compare <float> b(7.8,9.6);
cout<<"二者最大值为:"<<b.maxitem()<<endl;
cout<<"二者最小值为:"<<b.minitem()<<endl;
Compare <char> c('a','z');
cout<<"二者最大值为:"<<c.maxitem()<<endl;
cout<<"二者最小值为:"<<c.minitem()<<endl;
return 0;
}
第十章
重载运算符函数引言
前面我们介绍过函数的重载,即同样的函数能够实现不同的功能,其实运算符也可可以重载。在系统中,我们使用预处理时,#include 中就包含了输入与输出流,即>>可以当作流插入运算符使用也可以当作位移运算符使用,且<<可以当作流提取运算符使用也可以当作位移运算符使用。
假定我们设置了一个专门的复数类,那么我们是可以通过它的成员函数来实现数据的输入和输出以及加减乘除等的基本运算,例如下例我们用类的成员函数实现复数类的加法:
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0,imag=0;}
Complex(double a,double b){real=a,imag=b;}
Complex Complex_add(Complex &B);
void display();
};
int main()
{
Complex a(1.2,2.4),b(2.4,3.6),c;
c=a.Complex_add(b);
c.display();
return 0;
}
void Complex::display()
{
cout<<'('<<real<<'+'<<imag<<'i'<<')'<<endl;
}
Complex Complex::Complex_add(Complex &B)
{
Complex C;
C.real=real+B.real;
C.imag=imag+B.imag;
return C;
}
我们利用类的成员函数实现了类对象的加法,在这个成员函数当中,我们发现,复数加法的成员函数的形参仅为一个,在运算加法的过程当中,我们创建了一个临时类对象,其实我们定义的复数加法成员函数是隐藏了一个形参,第一个隐藏的形参为对象本身,所以创建一个临时对象之后,可以将临时对象的实数数据赋值为本对象与传入形参对象的实数的和,虚数的运算也是同样的道理。函数返回临时变量c,即我们在代码中创建了一个新的对象c,这个名字为c的对象接受了临时对象的数据,从而实现了两个复数的相加。
那么运用成员函数实现类的对象的加法运算是可以实现的,但是当代码长度过于长时,代码量会比较大且检查的时候不容易直接找到出错点,有没有一种简便的方法可以避免这种情况呢?那就是运算符的重载。
运算符的重载
运算符重载实质上来说就是函数的重载,对于原有的运算符进行重载,也就是对于定义函数实现,使原有的运算符不仅可以实现原有的功能,还能够实现我们定义的功能。在使用被重载的运算符时,系统自动调用其所在的函数模块,从而实现相应的功能。
运算符的重载在函数的定义上仅仅有很小的改变,就是在函数名上,函数名变成了关键字operator和所重载的运算符组成,即:函数返回类型 operator 所重载的运算符 (形参表){函数内容}。那么根据运算符重载的概念,我们可以对于上次使用成员函数实现复数和的代码进行修改:
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0,imag=0;}
Complex(double a,double b){real=a,imag=b;}
Complex operator + (Complex &B);//将原有的成员函数声明改为运算符的重载函数的声明
void display();
};
int main()
{
Complex a(1.2,2.4),b(2.4,3.6),c;
c = a + b;//在使用时仅仅用我们重载过的运算符即可
c.display();
return 0;
}
void Complex::display()
{
cout<<'('<<real<<'+'<<imag<<'i'<<')'<<endl;
}
Complex Complex::operator + (Complex &B)//定义运算符重载函数
{
Complex C;
C.real=real+B.real;
C.imag=imag+B.imag;
return C;
}
可以发现, 我们仅仅对与代码进行了很小的修改,就实现了我们所想要达到的效果,并且代码理解方面变得更加容易,在这里,运算符重载函数的形参情况与上文中的一样,并不是我们故意少设置了一个形参,而是系统默认为我们设置了隐藏形参,使函数中的real与imag数据自带this指针。
当然对于函数的当中对于临时对象的定义,我们也可以换种方法,反正最后运算结束都是要返回一个复数型给予一个新创建的对象,那么我们也可以不进行临时对象的创建,可以返回一个无名对象,即将函数修改为:
c++
Complex Complex::operator + (Complex &B)//定义运算符重载函数
{
return Complex(real+B.real,imag+B.imag);
}
以上的形式也可以达到同样的效果。
在我们实现这个复数类对象的加法之后,运算符的重载使系统不但能够对int,float,double,long,char进行运算,也可以对于复数类型进行运算,**对于单目运算符,其执行在于一侧的数据类型,对于双目运算符,其执行在于两侧的数据类型。**比如将一个复数与一个整数相加是非法的,编译会出现错误。运算符重载的使用,贴近c++的基本思想,即我们仅仅提供了一个类,也提供了所有运算符能够自适应与之对应,用户不必在乎这些如何实现,仅仅关心如何使用,类的设计与其中数据成员与成员函数的设计(包括运算符的重载)是类的设计人员的事情,与我们无关,实现了数据的封装。
运算符重载的规则
-
运算符的重载不允许自定义新的运算符,只能够对于系统中的运算符进行定义。
-
允许重载的运算符如下:
.和*运算符不能够重载的原因是为了保证访问成员的功能不能被改变,与运算符和sizeof运算符的运算对象是类型而不是变量或者是一般的表达式,不具备重载的特征。
-
重载不能够改变运算符运算对象的个数。
-
重载不能够改变运算符的优先级别。
-
重载不可以改变运算符的结合性。
-
重载运算符的函数不能够有默认的参数,否则与第三点矛盾。
-
重载的运算符必须和用户定义的自定义类型对象一起使用,其参数至少应有一个是类对象(或者类对象的引用)。比如重载一个+运算符,参数为int型,将其重载为两数相减,则于系统标准库当中的运算符发生冲突产生了歧义无法正确编译,是绝对禁止的。即参数不可以全部是c++当中的标准类型。
-
用于类对象的运算符一般要重载,但是对于=与&则不用。因为=运算符已经能够实现数据成员之间的复制和对于对象的复制,也可以实现类的对象的赋值。但是当类中含有动态分配内存的指针成员时,就会发生危险,仅在在此种情况之下对于=重载。对于&运算符,它能够返回类对象在内存中的起始地址,不必重载。
-
从理论上来说,可以将一个运算符重载为执行任意的操作,但是为了避免重载之后实现的功能让人难以理解,我们应当使运算符重载之后能够实现类似于的原有功能。
运算符重载函数作为类的成员函数和友元函数
在运算符重载函数当中,我们可以将其分为两种类型:第一种是作为类的成员函数,第二种是为普通函数,在类当中声明其为友元函数。在作为类的成员函数的时候,仅有一个参数,正如上两节之中提到的,在此就不再赘述。那么作为友元函数的时候我们可以这样写:
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0,imag=0;}
Complex(double a,double b){real=a,imag=b;}
friend Complex operator + (Complex &A,Complex &B);//将原有的成员函数声明改为运算符的重载函数的声明
void display();
};
int main()
{
Complex a(1.2,2.4),b(2.4,3.6),c;
c = a + b;//在使用时仅仅用我们重载过的运算符即可
c.display();
return 0;
}
void Complex::display()
{
cout<<'('<<real<<'+'<<imag<<'i'<<')'<<endl;
}
Complex operator + (Complex &A,Complex &B)//定义运算符重载函数
{
return Complex(A.real+B.real,A.imag+B.imag);
}
我们可以发现,在将运算符重载函数声明为友元函数时,我们必须要写两个形参,定义时也是,不可以省略。且往往将第一个实参传入的数据放入运算符左侧,第二个实参传入的数据放入运算符右侧。运算符重载后,对于实参数据的使用有着严格的位置要求,即不遵循加法的交换律。
如果将运算符重载函数作为类的成员函数,可以少写一个参数,但是必须第一个参数是一个类的对象,而且于运算符函数的类型相同。因为必须通过类的对象去调用该类的数据成员,而且只有运算符重载函数返回值与该对象类型相同,运算结果才有意义。 如果运算符左侧是一个其他的类的对象或者是c++的标准类型,那么运算符重载函数不能够作为成员函数,而只能作为非成员函数。
由于友元的使用从某些方面来说破坏了类的封装,因此从原则上来说,要尽量将运算符的重载函数设置为类的成员函数,但是当运算符的重载函数需要访问到类的私有数据成员时,则必须为友元函数。
-
c++规定,=、[]、->、()的重载函数必须为类的成员函数。
-
流的插入与流的提取>>、<<的重载函数必须重载为类的友元函数
-
一般将单目运算符和复合运算符的重载函数为类的成员函数
-
一般将双目运算符的重载函数为类的友元函数
重载双目运算符
上节之中我们已经提到,对于双目运算符的重载函数要为类的友元函数,对于关系运算符的重载我们通常会使用bool,比如定义一个String类,重载>、<、==运算符来判断类的对象之间的关系:
c++
#include <iostream>
#include <string.h>
using namespace std;
class String
{
private:
char *p;//用于指向字符串的指针
public:
String(){p=NULL;}
String(char *str);
friend bool operator == (String &A,String &B);
friend bool operator > (String &A,String &B);
friend bool operator < (String &A,String &B);
void display();
};
void Compare(String &A,String &B);
int main()
{
String A("hello"),B("book"),C("computer"),D("hello");
Compare(A,B);
Compare(B,C);
Compare(A,D);
}
String::String(char *str){p=str;}
bool operator == (String &A,String &B)
{
if(strcmp(A.p,B.p)==0)return true;
else return false;
}
bool operator > (String &A,String &B)
{
if(strcmp(A.p,B.p)>0)return true;
else return false;
}
bool operator < (String &A,String &B)
{
if(strcmp(A.p,B.p)<0)return true;
else return false;
}
void Compare(String &A,String &B)
{
if(operator == (A,B)==1)cout<<"长度相等"<<endl;
else if(operator > (A,B)==1){A.display(),cout<<"长于",B.display(),cout<<endl;}
else if(operator < (A,B)==1){A.display(),cout<<"短于",B.display(),cout<<endl;}
}
void String::display()
{
cout<<p;
}
在这里我们定义运算符的重载函数返回类型为bool型,以便Compare函数调用
重载单目运算符(自增和自减)
c++
类名 operator 需要重载的运算符 ();//前置
类名 operator 需要重载的运算符 ();//后置
** 在定义时注意要有返回值,对于数据进行操作之后返回*this指针即可**
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0,imag=0;}
Complex(double a,double b){real=a,imag=b;}
Complex operator ++ ();//前置
Complex operator ++ (int);//后置
Complex operator -- ();//前置
Complex operator -- (int);//后置
void display();
};
int main()
{
Complex a(1.2,2.4),b(2.4,3.6),c(3.6,4.8),d(4.8,6.0);
a.display();
b.display();
c.display();
d.display();
++a;
a.display();
b++;
b.display();
--c;
c.display();
d--;
d.display();
return 0;
}
void Complex::display()
{
cout<<'('<<real<<'+'<<imag<<'i'<<')'<<endl;
}
Complex Complex::operator ++ ()
{
++real;
++imag;
return *this;
}
Complex Complex::operator ++ (int)
{
real++;
imag++;
return *this;
}
Complex Complex::operator -- ()
{
--real;
--imag;
return *this;
}
Complex Complex::operator -- (int)
{
real--;
imag--;
return *this;
}
重载插入流运算符和提取流运算符
c++
istream& operator >> (istream&,类名&);//插入流运算符
ostream& operator << (ostream&,类名&);//提取流运算符
c++
istream& operator >> (istream& 插入流名称,类名& 形参对象名);
ostream& operator >> (ostream& 提取流名称,类名& 形参对象名);
注意return插入流名称或者return提取流名称保证连续插入与连续提取的实现
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0,imag=0;}
Complex(double a,double b){real=a,imag=b;}
friend istream& operator >> (istream&,Complex&);
friend ostream& operator << (ostream&,Complex&);
};
int main()
{
Complex a;
cin>>a;
cout<<a;
return 0;
}
istream& operator >> (istream& input,Complex& A)
{
input>>A.real>>A.imag;
return input;
}
ostream& operator << (ostream& output,Complex &A)
{
output<<"("<<A.real<<"+"<<A.imag<<"i"<<")"<<endl;
return output;
}
有关运算符重载的归纳
-
运算符的重载体现了面向对象的程序设计的灵活性与可适用性,能够结合实际问题设计并方便问题的解决。例如设计的复数类,对于其定义的类的对象的操作可以通过运算符快速且便捷的实现,同时也可以体现类的封装性,也方便使用者能够更好的理解代码。 运算符的重载使类的设计丰富多样,扩大了类的功能和适用范围,使程序易于理解,易于对对象进行操作,它体现了为用户着想、方便用户使用的思想。有了好的类,使用者就不必再利用复杂的函数调用来实现,能够使主程序更加简单易读。好的运算符重载能够体现面向对象程序设计的思想。
-
使用运算符重载的具体做法:先确定要重载的是哪儿一个运算符,想把它用于哪儿一个类。重载运算符只能够将一个运算符用于一个指定的类而不是所有的类。设计运算符重载函数和有关的类。在实际的工作之中,很多情况下并不是我们自己去编写一个类以及其中的成员函数(包括运算符重载函数等),而是这些工作由其他人完成,我们仅仅需要调用和使用即可,这叫做项目的合作。在使用其他人定义的类时,我们需要包含类的设计者所提供的头文件,这样就能够在编码过程当中使用其他人的类。使用者需要了解在该头文件包含哪儿些运算符的重载,适用于哪儿些类,有哪儿些参数。也就是需要了解运算符的重载函数的原型,就可以方便的使用该运算符了。如果有特殊的需要,并且没有现成的重载运算符可以使用,就需要自己设计重载运算符函数。应当注意把每次设计的重载运算符函数保存下来,以免下次用到时要重新设计。
-
在本章所举的各个例子当中,我们发现函数不采用以往的虚实结合的方式,而是采取引用的方式,使用这种方式我们可以减少时间和空间的开销。如果重载函数的返回值是对象的引用时,返回的不是常量,而是引用所代表的对象,它可以出现在赋值号的左侧而成为左值,可以被赋值或参与其他操作。但使用引用时要小心,因为修改了引用就代表修改了它所代表的对象。
-
c++大多数运算符都可以重载,在本章所举的例子当中仅仅牵涉到了部分运算符的重载,希望大家可以通过这些例子举一反三。
不同类型数据间的转换
** 标准类型数据间的转换**:隐式转换如下例,属于系统的自我转换
i = 6;
i = 0.5 + i;
第二行代码运行时i的值为0.5+6=6.5,但是由于i时整形数据,所以自动转换之后i值为6。c++也提供了显示转换,其形式为类型名(数据) ,在c语言当中的形式为(类型名)数据。系统可以了解标砖类型数据之间的转换,那么我们是否可以类比一下,将一个类的对象转换为另一个类的对象呢?答案是可以的,但是我们要用某种方式让系统知道,我们怎么引导它实现类型的转换。
用转换构造函数进行不同类型数据的转换:转换构造函数的作用是将一个其他类型的数据转换成一个类的对象。那么为了避免混淆其他构造函数的概念,我们先来复习一下构造函数的几种类型:
-
默认构造函数
Complex();
-
用于初始化的构造函数
Complex(double a,double b);
-
用于复制对象的复制构造函数
Complex(Complex &c);
-
转换构造函数
Complex(double r){real=r;imag=0;}
那么上例当中的这个转换构造函数的功能是将一个浮点型转换为复数类的对象,并将r作为复数的实部,虚部为0.用户可以根据需要定义转换构造函数,在函数体中告诉编译系统怎么样去类型转换。在类体当中可以有转换构造函数,也可以没有转换构造函数,视需要而定。
以上的四种构造函数可以同时出现一个类当中,他们是构造函数的重载。编译系统会根据建立对象是给出的实参的个数与类型选择形参与之匹配的构造函数。
Complex c(3.5);
调用转换构造函数使浮点型常数3.5转换为一个名为c的实部为3.5虚部为0的复数类的对象,当然也可以建立一个无名对象,但无法使用:
Complex(3.5);
不过可以创建一个类的有名对象,将无名对象赋值给有名对象:
c=Complex(3.5);
由于运算符重载的定义使重载过后的运算符的运算两侧必须要是同类型,所以当用一个复数的对象与一个浮点数相加时编译会出错,这时候就可以对于浮点数进行类型转换:
c=c1+Complex(3.5);
类的类型转换于标准类型数据间的转换相同,也存在强制类型转换,如上例。转换构造函数的定义要有意义,转换构造函数只能有一个参数,如果有多个参数,则不为转换构造函数,因为系统无法确定将哪儿个参数转换为类的对象。
使用转换构造函数将一个指定的数据转换为类对象的方法如下:
-
先声明一个类。
-
在这个类中定义一个只有一个参数的构造函数,参数的类型是需要转换的类型,在函数体中指定转换的方法。
-
在该类的作用域内可以用以下形式进行转换:类名(指定类型的数据) ,就可以将指定类型的数据转换为此类的对象。
当然对于转换构造函数的使用不仅仅可以将一个标准类型数据转换为类对象,也可以将另一个类的对象转换成转换构造函数所在的类对象,例如:
Teacher(Student& s){num=s.num;strcpy(name,s.name);sex=s.sex;}
学生毕业之后当了教师,数据的转入,但注意对象中的num,name,sex必须是公用成员,否则不能被类外引用。
类型转换函数 :前面介绍了构造转换函数能够将一个标准类型的数据转换为一个类的对象、一个类的对象转换为另一个类的对象,那么能不能用一种函数让一个类的对象转换成一个标准类型的数据呢?c++提供了类型转换函数来解决了这个问题,类型转换函数是将一个类的对象转换成另一类型的数据,比如在一个复数类当中:
operator double()
{return real;}
函数返回的是double型real的值,请注意函数名是operator+类型名,即类型转换函数的模板是
operator 类型名()
{实现转换的语句}
**在函数名之前不能够指定函数的类型,且函数没有参数,其返回值的类型是由函数名中指定的类型名所确定的。类型转换函数只能够作为成员函数,因为转换的主体是本类的对象,不能作为友元函数或者普通函数。**从函数形式上可以看得出来类型转换函数于运算符重载函数较为相似,其类型名的使用不仅可以使系统能够识别原有的double型,还可以将Complex类对象作为double型的数据处理。Complex类对象只有在需要时才转换,并不是所有的类对象都一律转换为double类型数据。
转换构造函数和类型转换函数都有一个共同的功能:在需要时,编译系统会自动调用这些函数,建立一个无名的临时对象或者临时变量。
结合实际来说就是:
- 转换构造函数是将标准类型数据转换为类对象
- 类型转换函数是将类对象转换为标准类型数据
所以当在使用加法的时候,一共可以看作有两种情况供我们了解:
第一种情况:在一个复数类当中double d1,d2;Complex c1;运行d1=d2+c1;那么此处的加号若未经过重载且不能对于复数类进行加法运算且该类当中含有对于复数类的类型转换函数使复数类型转换为double类型的函数时,系统就会自动寻找我们定义的类型转换函数将加号左右的类型调整为一致,即将c1调整为double类型。
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0;imag=0;}
Complex(double a,double b){real=a;imag=b;}
operator double(){return real;}//类型转换函数,将复数类对象转换为double类型数据
};
int main()
{
double d1,d2=2.4;
Complex c1(1.2,2.4);
d1=d2+c1;//2.4+1.2=3.6
cout<<d1;
return 0;
}
第二种情况:在一个复数类当中double d1;Complex c1,c2;运行c2=c1+d1;那么此处的加号若经过复数类的重载,能运行复数类对象的加法时,且复数类当中含有构造转换函数时,系统会自动调用构造转换函数使加法两边的数据类型均为复数型,即d1转换为Complex型。
c++
#include <iostream>
using namespace std;
class Complex
{
private:
double real;
double imag;
public:
Complex(){real=0;imag=0;}
Complex(double a,double b){real=a;imag=b;}
Complex(double a){real=a;imag=0;}//转换构造函数
friend Complex operator + (Complex A,Complex B);
void display();
};
int main()
{
Complex c1,c2(3.6,4.8);
double d1=1.2;
c1=c2+d1;//(1.2,0)+(3.6,4.8)=(4.8,4.8)
c1.display();
}
Complex operator + (Complex A,Complex B)
{
return Complex{A.real+B.real,A.imag+B.imag};
}
void Complex::display()
{
cout<<'('<<real;
if(imag>0)cout<<'+'<<imag<<'i'<<')'<<endl;
else cout<<imag<<'i'<<')'<<endl;
}
从其中的隐式调用我们可以发现一个规律,在已定义了相应的转换构造函数的情况下,将运算符'+'重载为友元函数时可以使用交换律。如果运算符重载函数不为友元函数而为类的成员函数时,则结论不成立,因为作为运算符重载函数成员函数时,省略了一个形参,这个形参往往以this指针的形式指向其对象,例如:
cpp
c1+2.5
系统把它识别成为c1.operator + (2.5);可以正确运行等价于c1.operator + (Complex(2.5));
2.5+c1
系统则会将它识别为2.5.operator + (c1);很显然是错误的。通常把类型转换函数称为类型转换运算符函数,由于它也是重载函数,因此也称为类型转换运算符重载函数。
结论:如果运算符函数重载为成员函数,它的第一个参数必须是本类的对象。当第一个操作数不是类对象时,不能将运算符函数重载为成员函数,如果将运算符'+'+函数重载为类的成员函数,则交换律不适用。
所以在一般情况下将双目运算符重载为友元函数,将单目运算符则多重载为成员函数
如果第一个参数不是本类的对象时一定要将运算符函数重载为成员函数也不是不行,要定义另外一个重载函数,只是形参位置不同,从而实现加法交换律。
同时还有一个小条件要记住:类的转换构造函数、类的运算符重载函数不能够与类的类型转换函数同时存在,否则将会出现二义性,使系统无法识别,造成编译错误。
第十一章
引言
通过对于c++第八章、第九章、第十章的初步学习我们可以里了解到面向对象程序设计的两个重要特征:数据抽象和封装,学会了在程序中使用类和对象,写出了基于对象的程序,这是面向对象程序设计的基础。现在让我们复习一下面向对象程序设计的四个特点:抽象性,封装性,继承性,多态性。
面向对象技术强调软件的可重用性,极大的方便了程序的开发和人力物力资源的节省。那么继承性是如何做到这一点的呢?我们在具体的程序设计的过程当中,会发现有许多的程序模块拥有相同或者类似的功能,但我们在编写过程当中如果没有继承性的话还得重头再来写一个实现类似功能的程序模块, 很显然这样的工作量是极其巨大的。我们利用继承性可以不必重写功能相似的代码模块,只需要在其添加一些额外的功能与数据即可,这就是继承性的应用与作用。
继承与派生的概念
继承在c++当中是一个很重要的概念。例如在一个student类当中,有学号,名字,性别:
c++
class Student
{
private:
string name;
int num;
char sex;
public:
Student(string a,int b,char c){name=a,num=b,sex=c;}
void display()
{
cout<<"name:"<<name<<endl;
cout<<"num:"<<num<<endl;
cout<<"sex:"<<sex<<endl;
}
};
若在其中加上地址和年龄信息,则我们不利用继承的话还得重新创建一个类:
c++
class Student1
{
private:
string name;
int num;
char sex;
int age;
string address;
public:
Student(string a,int b,char c,int d,string e){name=a,num=b,sex=c,age=d,address=e;}
void display()
{
cout<<"name:"<<name<<endl;
cout<<"num:"<<num<<endl;
cout<<"sex:"<<sex<<endl;
cout<<"age:"<<age<<endl;
cout<<"address"<<age<<endl;
}
};
很明显我们仅仅对于原Student类进行了些微的修改,明明有着许多的数据皆一致,却需要重新创建类,很显然是费时费力的。那么我们可以利用继承性,使Student1类继承Student类的数据,从而减少工作量。c++的继承机制就是为了解决此类问题的出现。在c++当中所谓的继承就是在一个已存在的类的基础上建立一个新的类。已存在的类称为基类或者父类,新建立的类称为派生类或者子类。
一个新的类从已有的类哪里获得其已有属性,这种现象称为类的继承。通过继承,一个新建子类从已有的父类那里获得父类的特性。从另一个角度来说,从已有的父类产生一个新的子类叫做类的派生。类的继承是用已有的类来建立专用类的编程技术。
其实父类和子类的关系我们可以用数据结构当中的树来比较,把根节点比作祖宗也就是基类,把下面的叶节点比作儿子也就是派生类。不过派生类所拥有的基类不一定拥有,但是基类拥有的派生类一定会有。即拿人的一生来比喻,你小时候拥有的技能你长大后也会拥有,但是你长大后拥有的技能你小时候却不一定拥有,就是这个道理。当一个派生类只从一个基类派生时,这被称作为单继承 。一个派生类拥有两个及以上的基类称为多重继承。派生类是基类的具体化,
派生类是基类的具体化,基类是派生类的抽象。
派生类的声明方式
class 派生类名:[继承方式] 基类名
{
派生类新增加的成员
}
在这里继承方式有多样:private,public,protected,若不声名则为private。例如我们把上例当中的Student1类声明为Student的派生类:
class Student
{
private:
string name;
int num;
char sex;
public:
void display()
{
cout<<"name:"<<name<<endl;
cout<<"num:"<<num<<endl;
cout<<"sex:"<<sex<<endl;
}
};
class Student1:public Student
{
private:
int age;
string address;
public:
void display()
{
cout<<"age:"<<age<<endl;
cout<<"address"<<age<<endl;
}
};
派生类的构成
派生类当中的成员包括从基类继承过来的成员和自己增加的成员两大部分。从基类继承的成员体现了派生类从基类继承而获得的共性,新增加的成员体现了派生类的个性。正是这些新增加的成员体现了派生类与基类的不同,体现了不同派生类之间的区别。事实上并不是把基类的成员和派生类自己增加的成员简单地加载一起就成为派生类。构造一个派生类包括以下3部分工作:
- 从基类接收成员。
派生类对于基类的所有成员的要求的接收均为强制性 的接收,派生类对于基类中的成员不可以选择性的接收 ,这也就导致了某些类在派生的过程之中有的数据不能够使用而占用内存空间,造成数据的传递过程当中也花费了不少的时间,降低了效率,这是目前c++无法解决的。所以在基类的设计当中我们也要用心,使派生过程中的冗余量最小。
- 调整从基类接受的成员。
虽然对于从基类接受的成员是无法选择的,但是程序员可以对于这些成员作出调整,例如改变成员的属性为private,或者说是直接替代。**我们可以通过设置与基类当中同名的变量,则派生类当中的新成员会覆盖掉原有的变量。**在进行函数的替代时,不仅仅要求函数名要相同,函数的形参表(参数的个数和类型)也要相同。如果不相同,则为函数的重载,而不是覆盖。
- 在声明派生类时增加的成员。
增加的成员也要经过精心设计,方便使用与理解。在声明派生类时,析构函数和构造函数是不能够从基类继承过来的,所以我们还应该手动定义。
从以上三点我们可以看出来:派生类是基类定义的延续,可以先声明一个基类,在此基类中只提供某些最基本的功能,而另外有些功能并未实现,然后在声明派生类时加入某些具体的功能,形成适用于某一特定应用的派生类。通过对基类声明的延续,将一个抽象的基类转化成具体的派生类。因此,派生类是抽象基类的具体实现。
派生类成员的访问属性
对于基类成员和派生类自己增加的成员是按不同原则处理的,比如以下六种情况:
-
基类的成员函数访问基类成员
-
派生类的成员函数访问派生类自己增加的成员
-
基类的成员函数访问派生类的成员
-
派生类的成员函数访问基类的成员
-
在派生类外访问派生类的成员
-
在派生类外访问基类的成员
对于1和2中的情况,经过我个人的实践发现,派生类仅仅可以访问基类中的public和protected成员,对于基类当中的private成员仍需要借助基类中public的对外接口访问。当然对于派生类自己增加的成员来说,可以通过本身的成员函数来访问。对于3中的情况,基类成员函数只能访问基类成员,不可以访问派生类成员。对于5中的情况,在派生类外可以访问派生类的公有成员,而不能够访问私有成员。对于4和6当中的情况要具体讨论,比如继承关系。
(1)公用继承
基类的公有成员和保护成员在派生类当中保持原有的访问属性,其私有成员仍为基类私有。
(2)私有继承
基类的公有成员和保护成员在派生类中成了私有成员。其私有成员仍为基类私有。
(3)受保护的继承
基类的公有成员和保护成员在派生类中成了私有成员,其私有成员仍为基类私有。保护成员的意思是,不能被外界引用,但可以被派生类的成员引用。
公有继承
即继承方式为public的继承称为公有继承,用公有继承方式建立的派生类称为公有派生类,其基类称为公有基类。在公有继承当中,基类的private数据对于派生类来说仍为private数据,是不可访问的成员,只有基类自己的成员函数能够引用。 但派生类能够访问基类当中的public和protected数据,且访问private数据的方式仅仅只有通过派生类函数调用基类函数,通过基类函数间接访问这一种方式可以实现。
c++系统的数据的封装性体现在基类的私有成员不可以在派生类当中被派生类的成员函数给直接引用,这样有利于测试、调试和修改系统。保护私有成员是一条重要的原则。
私有继承
在声明一个派生类时将基类的继承方式指定为private称为私有继承,用私有方法建立的派生类称为私有派生类,其基类称为私有基类。
私有基类的共用成员和保护乘员在派生类中的访问属性相当于派生类中的私有成员,及派生类的成员函数可以访问他们,而在派生类外不可以访问,私有基类的私有成员在派生类中称为不可访问成员,只有基类的成员函数可以引用它们。一个基类成员在基类当中的访问属性何在派生类当中的访问属性可能是不同的。私有基类的某些成员可以被基类的成员函数访问,但不能被派生类的成员函数访问。
即对于派生类来说,私有基类的公用成员和保护成员成为了派生类的私有成员,私有基类的私有成员被隐藏:
-
不能通过派生类对象引用从私有基类继承过来的任何成员
-
派生类的成员函数不能访问私有基类的私有成员,但是可以访问私有基类的公用成员 和受保护成员。
虽然私有基类的私有成员不可以被派生类访问,不过派生类可以通过派生类的成员函数调用私有基类的公有成员函数,再利用私有基类的公有成员函数对于私有基类的私有数据进行访问。
保护成员和保护继承
protected声明的成员为受保护的成员,简称保护成员。受保护的成员不可以被类外访问,但保护成员可以被派生类的成员函数引用。如果基类声明了私有成员,那么任何的派生类都是不能访问他们的,若希望在派生类中能够访问他们,应当把他们声明为保护成员,如果在一个类当中声明了保护成员,那么也就意味着此类可能会用作基类。在定义一个派生类时将基类的继承方式指定为protected的称为保护继承,用保护继承的方式建立的派生类称为保护派生类,其基类称为受保护的基类,简称保护基类。
保护基类的特点是:保护基类的公有成员和保护成员在派生类中都成了保护成员,其私有成员仍为基类私有,也就是把基类原有的公有成员也保护起来,不让类外随意访问。
-
保护基类的所有成员在派生类当中都被保护起来,类外不能访问,其公有成员和保护成员可以被派生类的成员函数访问私有成员则不可访问。
-
对于私有继承和保护继承比较来说,当仅仅有一个派生类时,作用相同。若有多个派生类时,私有基类的成员在新的派生类当都成为了不可访问的成员,无论在派生类内外都不可以访问。而保护继承中的成员在新的派生类当中为保护成员,可以被新生派生类的成员函数访问。
-
基类当中的私有成员被派生类继承后变为不可访问的成员,派生类当中的一切成员均无法访问他们,如果在派生类当中需要引用基类的某些成员,那么应当将基类的这些成员声明为protected而不是private。
-
在类的派生中,成员有四种不同的访问属性
(1)公有的,派生类内类外均可访问
(2)受保护的,派生类内可以访问,派生类外不可访问,下一层派生类可以访问
(3)私有的,派生类内可以放为,派生类外不可访问
(4) 不可访问的,派生类内类外均不可访问
(5)类的成员在不同作用域中有不同的访问属性
多级派生时的访问属性
类B是类A的直接派生类,类C是类A的间接派生类。在类的多级派生中,可以发现,把无论哪儿一种继承方式,在派生类是不能访问基类的私有成员的,私有成员只能被本类的成员函数所访问,毕竟派生类和基类不是同一个类。
如果在多级继承当中都采用公有继承,那么直到最后一级的派生类也能够访问基类的公用成员和保护成员。如果采用的是私有继承,经过若干次继承之后,基类的所有成员已经变成不可访问的了。如果采用保护继承,在派生类外是无法访问派生类中的任何成员的,而且经过多次派生之后,人们很难记清楚哪儿些成员可以访问哪儿些不可以访问,很容易出错。
因此在实际操作中,最常用的是公有继承。
派生类的构造函数和析构函数
简单的派生类的构造函数
在对于派生类进行构造函数定义时,若基类无构造函数,我们则需要将派生类中的所有数据包括从基类当中继承来的数据进行初始化,这就造成了非常大的工作量。那么如果基类当中拥有构造函数并且在派生类当中继承过来的话我们就可以省许多事:
c++
#include <iostream>
using namespace std;
class point
{
protected://便于派生类直接访问避免嵌套调用函数
double x,y;
public:
point(double a=0,double b=0):x(a),y(b){}//参数表构造赋值一体化
};
class circle:protected point//注意保护继承中派生类可访问基类的public与protected
{
protected://便于派生类直接访问避免嵌套调用函数
double r;
public:
circle(double a=0,double b=0,double c=0):point(a,b),r(c){}//参数表构造赋值一体化
};
class cylinder:private circle//注意私有继承派生类可以访问基类的public和protected
{
protected://便于派生类直接访问避免嵌套调用函数
double h;
public:
cylinder(double a=0,double b=0,double c=0,double d=0):circle(a,b,c),h(d){}//参数表构造赋值一体化
friend ostream& operator << (ostream& output,const cylinder& A);//友元运算符重载函数的声明
};
int main()
{
cylinder A(1.2,2.4,3.6,4.8);
cout<<A;
return 0;
}
ostream& operator << (ostream& output,const cylinder& A)
{
//因为protected的使用,在这里对于数据的调用输出就方便了许多
output<<"该圆柱体的高为:"<<A.h<<endl<<"该圆柱体的底面半径为:"<<A.r<<endl<<"该圆柱体底面的一个点的坐标为"<<"("<<A.x<<','<<A.y<<")"<<endl;
return output;
}
派生类构造函数一般形式为:
- 派生类构造函数名(总参数表) :基类构造函数名(参数表)
- {在派生类当中新增的数据成员的初始化语句}
在总参数表中包含所有的所需要用到的参数,在总参数表当中包括参数的类型和参数名,而在基类构造函数名后面的参数表中仅仅包括参数名而没有参数类型,因为在这里**不是定义基类构造函数而是调用基类构造函数,因此这些参数是实参而不是形参。**它们可以是常量、全局变量和派生类构造函数总参数表中的参数。
在建立一个对象的时候,若派生类的构造函数中继承了基类的构造函数,那么派生类构造函数则会优先调用基类的构造函数,然后再执行派生类构造函数本身。同样,先执行派生类的析构函数,再执行基类的析构函数。
有子对象的派生类的构造函数
内嵌对象即为子对象,简单来说就是对象中的对象。即在一个类的定义当中用已经定义过的类作为其数据成员来使用。
c++
#include <iostream>
using namespace std;
class Student
{
protected:
string name;
int num;
public:
Student(string a,int b):name(a),num(b){}
void display(){cout<<name<<' '<<num<<endl;}
};
class Studento:public Student
{
private:
string add;
Student monitor;
public:
Studento(string a,int b,string c,int d,string e):Student(a,b),monitor(c,d){add=e;}
void show_monitor()
{
cout<<"该班长是:";
monitor.display();
}
void show()
{
cout<<"该学生是:";
display();
cout<<"他家住在:"<<add<<endl;
}
};
int main()
{
Studento A("赵铁柱",100,"王大锤",101,"图图幼儿园");
A.show();
A.show_monitor();
return 0;
}
定义派生类构造函数的一般形式为:
- 派生类构造函数名(总参数表):基类构造函数名(参数表),子对象名(参数表)
- {派生类当中新增数据成员初始化语句}
执行派生类构造函数的顺序是
-
调用基类构造函数对于基类数据成员初始化
-
调用子对象构造函数,对子对象数据成员初始化
-
再执行派生类构造函数本身,对派生类数据成员进行初始化
多层派生时的构造函数
一个类不仅可以派生出一个派生类,派生类还可以继续派生,就像人类繁衍一样,生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生生。
c++
#include <iostream>
using namespace std;
class point
{
protected://便于派生类直接访问避免嵌套调用函数
double x,y;
public:
point(double a=0,double b=0):x(a),y(b){}//参数表构造赋值一体化
};
class circle:protected point//注意保护继承中派生类可访问基类的public与protected
{
protected://便于派生类直接访问避免嵌套调用函数
double r;
public:
circle(double a=0,double b=0,double c=0):point(a,b),r(c){}//参数表构造赋值一体化
};
class cylinder:private circle//注意私有继承派生类可以访问基类的public和protected
{
protected://便于派生类直接访问避免嵌套调用函数
double h;
public:
cylinder(double a=0,double b=0,double c=0,double d=0):circle(a,b,c),h(d){}//参数表构造赋值一体化
friend ostream& operator << (ostream& output,const cylinder& A);//友元运算符重载函数的声明
};
int main()
{
cylinder A(1.2,2.4,3.6,4.8);
cout<<A;
return 0;
}
ostream& operator << (ostream& output,const cylinder& A)
{
//因为protected的使用,在这里对于数据的调用输出就方便了许多
output<<"该圆柱体的高为:"<<A.h<<endl<<"该圆柱体的底面半径为:"<<A.r<<endl<<"该圆柱体底面的一个点的坐标为"<<"("<<A.x<<','<<A.y<<")"<<endl;
return output;
}
我们可以发现:当进行多层派生时我们仅仅需要列出上一层派生类的构造函数即可,在多层构造函数的调用当中,构造函数的调用是从基类逐层往上开始调用。
派生类构造函数的特殊形式
-
不需要对新增的派生类中的成员进行任何操作时,{}中为空即可
-
如果在基类当中没有定义构造函数,或定义了没有参数的构造函数,那么在定义派生类构造函数时可以不写基类构造函数,系统会优先调用基类默认的构造函数。
如果在基类或者子对象类型的声明中都没有定义带参数的构造函数,而且也不许对派生类自己的数据成员初始化,那么可以不必显式的定义派生类构造函数。
如果在基类或者子对象类型的声明中定义了带参数的构造函数,那么就必须显式地定义派生类构造函数,并在派生类构造函数中写出基类或者子对象类型的构造函数以及其参数表
如果在基类中既定义了无参的构造函数又定义了有参的构造函数,则在定义派生类构造函数时,既可以包含基类构造函数及其参数又可以不包含基类构造函数。
派生类的析构函数
在派生时,派生类是不能继承基类的析构函数的,也需要通过派生类的析构函数去调用基类的析构函数。在派生类中可以根据需要定义自己的析构函数,用来对派生类中所增加的成员进行清理工作,基类的清理工作仍由基类的析构函数负责。在执行派生类的析构函数时,系统会自动调用基类的析构函数和子对象的析构函数,对基类的子对象进行清理。
调用的顺序正好与构造函数相反,先调用派生类的析构函数,对于派生类新增的成员进行清理,在调用子对象的析构函数对于子对象进行清理,最后调用基类的析构函数对于基类进行清理。
多重继承
声明多重继承的方法
为了符合一个类从多个类继承的这种情况,也就有了多重继承的出现,例如class D:public A,protected B,private C{D类增加的数据成员}。
多重继承派生类的构造函数
派生类构造函数名(总参数表):基类1构造函数(参数表), 基类2构造函数(参数表),基类3构造函数(参数表){派生类中新增数据成员初始化语句}。
各个基类的排列顺序任意,派生类构造函数的执行顺序先调用基类的构造函数,再执行派生类构造函数的函数体。调用基类的构造函数的顺序是按照声明派生类时基类出现的顺序,那让我们实现一下,当一个学生毕业后打算留校当老师。提示将Graduate继承于Student和Teacher :
c++
#include <iostream>
using namespace std;
class Student
{
protected:
string name;
int age;
double score;
public:
Student(string s,int a,double b):name(s),age(a),score(b){}
void display()
{
cout<<"name:"<<name<<endl;
cout<<"age:"<<age<<endl;
cout<<"score:"<<score<<endl;
}
};
class Teacher
{
protected:
string name0;
char sex;
string title;//职称
public:
Teacher(string a,char b,string c):name0(a),sex(b),title(c){}
void display1()
{
cout<<"name:"<<name0<<endl;
cout<<"sex:"<<sex<<endl;
cout<<"title:"<<title<<endl;
}
};
class Graduate:public Student,public Teacher
{
private:
double wage;//津贴
public:
Graduate(string a,char b,int c,double d,string e,double f):Student(a,c,d),Teacher(a,b,e){wage=f;}
void show()
{
cout<<"name:"<<name<<endl;
cout<<"sex:"<<sex<<endl;
cout<<"age:"<<age<<endl;
cout<<"score:"<<score<<endl;
cout<<"title:"<<title<<endl;
cout<<"wage:"<<wage<<endl;
}
};
int main()
{
Graduate A("大怨种",'m',24,98,"讲师",888);
A.show();
return 0;
}
在代码的实现过程中我们可以发现,为了避免二义性的出现,我们把该毕业生的名字在Student类中设置为了name,在Teacher类中设置为了name0,此外display函数我们也做了微微的修改,这是为了避免同名时出现二义性导致编译错误,当然这种方法是可行的,不过有的时候会引起思维混论和错误难以检查出来,是最低级的避免二义性的做法。那么什么是二义性呢?
多重继承引起的二义性问题
多重继承能够很好的对于现实中的情况进行处理,但是同时也引入了新的问题,如在派生类与多个基类的数据成员或者成员函数重名时将会引起二义性的产生,编译及报错。它也增加了程序的复杂度,是程序的编写和维护变得相对困难,容易出错。
我们从三种情况讨论:
第一种情况和第二种情况均是建立在C类继承于A类和B类的情况上的
第一种情况:
当C类的新建的成员函数和数据成员的名称无与A、B类的成员函数或者数据成员的名称一致时,且A、B两个类当中有成员重名,系统则会在编译的时候出现错误,即产生了二义性。解决办法是若是在C类中调用A、B类中的重名成员时要加上"类名::"作为限制来避免二义性的产生。
第二种情况:
当C类中新建的成员函数和数据成员的名称与基类的数据成员和成员函数名相同时,在系统调用时则不会产生二义性,因为在系统之中有条规则这样写道:**基类的同名成员在派生类中被屏蔽,成为不可见的,或者说派生类中新增加的同名成员覆盖了基类当中的同名成员。**因此如果在定义派生类对象的模块中通过对象名访问同名的成员,则访问的时派生类的成员。
注意:不同的成员函数只有在函数名和函数参数个数相同时,类型相匹配的情况下才发生同名覆盖,如果只有函数名相同而参数不同,不会发生同名覆盖,而属于函数重载。
第三种情况:
假设两个B、C类继承于A类,D类又继承于B、C类,我们在用C类建立对象时,在派生类当中新建的成员会将基类当中的同名旧成员进行覆盖,通过D类对象对于A类的成员进行访问或者对于A类的成员函数进行调用时会出现二义性,因为B、C类继承于A类,系统不知道我们想调用的到底是B类继承的A类成员还是C类继承的A类成员,因此我们在调用的时候也要加上作用域限定符,例如:
D a;
a.A::n=3;
a.B::display();
由此可见我们对于作用于限定符的使用可以避免二义性的产生,但是还是不够精简以及比较麻烦。
虚基类
虚基类的作用
我们知道在定义D的对象时,假设A类当中有一个数据成员n,那么我们可以通过D类定义的对象t来对其进行访问。例如对于由B类继承而来的A类当中的n访问:t.B::n=3;对于由C类继承而来的A类当中的n访问:t.A::n=3;
由此我们可以得出,在不使用虚基类的情况下,我们对于D类的间接基类A中的数据是存了直接基类份的,也就是两份,在实际操作工程当中,这样的操作会使存储空间得以浪费且增加了访问这些成员的困难,在实际上我们不需要存这么多份,仅仅一份够。c++提供虚基类是的在继承间接共同基类时只保留一份成员,那么我们如何具体实现呢?
c++
class A
{};
class B:virtual public A
{};
class C:virtual public B
{};
class D:public B,public C
{};
虚基类的定义并不是在基类的创建时,而是在声明派生类时,指定继承方式时声明的。声明虚基类的一般格式为:
class 派生类名:virtual 继承方式 基类名
经过这样的声明之后,当基类通过多条派生路径被某一个派生类继承时,该派生类只继承该基类一次,也就是说,基类成员只保留一次。
基类的初始化
c++
#include <iostream>
using namespace std;
class Person
{
protected:
string name;
int age;
char sex;
public:
Person(string a,int b,char c):name(a),age(b),sex(c){}
};
class Student:virtual public Person
{
protected:
double score;
public:
Student(string a,int b,char c,double d):Person(a,b,c){score=d;}
};
class Teacher:virtual public Person
{
protected:
string title;
public:
Teacher(string a,int b,char c,string d):Person(a,b,c){title=d;}
};
class Graduate:public Student,public Teacher
{
private:
double wage;
public:
Graduate(string a,int b,char c,double d,string e,double f):Person(a,b,c),Student(a,b,c,d),Teacher(a,b,c,e){wage=f;}
void show()
{
cout<<"name:"<<name<<endl;
cout<<"age:"<<age<<endl;
cout<<"sex:"<<sex<<endl;
cout<<"score:"<<score<<endl;
cout<<"title:"<<title<<endl;
cout<<"wage:"<<wage<<endl;
}
};
int main()
{
Graduate A("朱小花",18,'m',99.9,"鸟不拉屎国教授",888);
A.show();
return 0;
}
我们在多重继承当中对于基类构造函数的继承与单一继承时不同,多重继承当中,在最后的派生类中不仅要负责对直接基类进行初始化,还要负责对虚基类进行初始化 ,例如上例,大家可能会疑惑在Graduate类的构造函数当中我们调用了虚基类Person的构造函数,也调用了Student和Teacher直接基类中的构造函数,是不是就意味着虚基类被构造了3次呢?nonono,c++编译系统只执行最后的派生类对虚基类的构造函数的调用,而忽略基类的其他派生类如Student和Teacher类对虚基类的构造函数的调用,这就保证了虚基类的数据成员不会被多次初始化
有了虚基类的使用,对于Graduate的公共基类Person中的数据name,sex,age的值就不用再加::,直接的调用也不会再产生二义性
在程序员的不成文的规定当中:不提倡在程序中使用多重继承,只有在比较简单和不易出现二义性的情况下或实在必要时才使用多重继承,如果能用单一继承解决的问题不要采用多重继承,也是这个原因,有些面向对象的程序设计语言如Java并不支持多重继承
基类与派生类的转换
从之前介绍过的三种继承方式当中我们可以发现,只有公有继承能够较好地保留基类的特征,它保留了除了构造函数和析构函数以外的基类所有成员,基类的公有或保护成员的访问权限在派生类中全部都按原样保留下来了,在派生类外可以调用基类的公有成员函数以访问基类的私有成员。
因此,公用派生类具有基类的全部功能,所有基类能够实现的功能,公用派生类都能够实现。而非共用派生类(protected、private)不能实现基类的全部功能(例如在派生类之外不能调用基类的公有成员函数以访问基类的私有成员)。
只有公有派生类才是基类真正的子类型,他完整的继承了基类的功能
在c语言的学习当中我们了解到,整型数据可以转换为双精度型数据,但是不能把一个整型数据赋值给一个指针变量。这种不同类型数据之间的自动转换和复制,称为赋值兼容
类似的,基类对象可以类似于整型数据,派生类对象可以类似于双精度型数据,也可以实现赋值兼容,具体可以表现在以下四个方面:
1. 派生类对象对于基类对象赋值
我们知道将双精度型数据转换为整型数据时会丢失部分数据,同样,在利用派生类对象对基类对象赋值的时候也有类似的效果,与double转int丢失小数点后面的数据不同,派生类对象对基类对象赋值之后会舍弃自己派生类新增的成员
我们假设A为基类B为派生类
A a;
B b;
a=b;
以上语句合乎语法,编译正确,为派生类对象对基类对象赋值,即"大材小用"
注意,在这里的赋值指的是仅仅对于基类中的数据成员赋值,而不包括成员函数,我们假设A基类中有n,B派生类中有m,那么在a=b之后我们可以调用a.n,但是不可以调用b.m,因为a对象中被赋值的成员仅仅为B类当中从A类继承过来的成员,也就是此时对象a当中并无m
应当注意,子类型的关系是单向的、不可逆的。B是A的子类型,而不能说A是B的子类型。**只能用子类对象对其基类对象赋值,而不能用基类对象对其子类对象赋值。**理由就是基类对象中不包含派生类的成员,无法对派生类的成员赋值。同理,同一基类的不同派生类对象之间也不能赋值。
2. 派生类对象可以代替基类对象对基类对象的引用进行赋值或初始化
A a;
B b;
A &r=a;
此时r即为a的别名,r与a共用一段存储单元:
A &r=b;
可以用子类对象初始化r:
r=b;
3. 如果函数的参数是基类对象或基类对象的引用,相应的实参可以用子类对象
若num为基类A当中的值:
A a;
B b;
void display(A &a)
{cout<<num<<endl;}
使用display(b);也可以输出num的值。
4. 派生类对象的地址可以赋给指向基类对象的指针变量,也就是说,指向基类对象的指针变量也可以指向派生类对象
注意对于赋值来说是大材小用,对于指针的指向来说是小指大:
c++
#include <iostream>
using namespace std;
class Student
{
private:
string name;
int age;
char sex;
public:
Student(string a,int b,char c):name(a),age(b),sex(c){}
void display()
{
cout<<"name:"<<name<<endl;
cout<<"age:"<<age<<endl;
cout<<"sex:"<<sex<<endl;
}
};
class Graduate:public Student
{
private:
double wage;
public:
Graduate(string a,int b,char c,double d):Student(a,b,c),wage(d){}
void display()
{
Student::display();
cout<<"wage:"<<wage<<endl;
}
};
int main()
{
Student A("小芳",18,'f');
Graduate B("土狗",18,'m',888);
Student *p;
p=&A;
p->display();
p=&B;
p->display();
return 0;
}
那么根据输出格式我们可能会产生一些误解,误以为两次调用的都是Student中的display函数,实则不然,第一次我们的基类指针指向的是基类对象,调用的是Student中的display函数,但是第二次基类指针指向的是派生类当中的对象,其能访问的仅仅为基类中的成员而不包括wage,因此第二次未输出wage且访问的是Graduate的display函数。
继承与组合
即在一个类的定义当中数据成员为其他类的对象,类的组合和继承一样,是软件重用的重要方式,组合和继承都是有效地利用已有类的资源。但二者的概念和用法不同。通过继承建立了派生类与基类的关系,他是一种"是"的关系,派生类是类的具体化实现,是基类的一种。通过组合建立了成员类与组合类的关系,它们之间不是"是"的关系而是"有"的关系
继承是纵向的,组合是横向的
继承在软件开发中的意义
缩短软件开发过程的关键在于鼓励软件重用,c++的继承就是为了实现这一点,这也是和c语言的关键区别所在。在类库的使用过程中,我们要知道类库并不是c++编译系统的一部分,不同的c++编译系统提供的,由不同厂商开发的类库一般是不同的。因此在一个c++编译系统之中利用类库开发的程序在其他编译系统中不一定能够完全正确运行,除非类库移植。在使用类库时,我们仅仅需要在头文件中声明即可。由于基类时单独编译的,程序在编译时只需对派生类新增的功能进行编译,这就大大的提高了调试程序的效率。如果在必要时修改基类,只要基类的公共接口不变,就不必对派生类进行修改,但基类要重新编译,派生类也要重新编译,否则不起作用。
那么我们为何这么看重c++的继承呢?
-
有许多基类是被程序的其他部分或其他程序使用的,这些程序要求保留原有的基类不受破坏。使用继承是建立新的数据类型,他继承了基类的所有特征,但不改变基类本身,基类的名称、构成和访问属性丝毫没有改变,不会影响其他程序的使用。
-
用户无法知道基类的源代码。保护了基类的安全,防止基类被篡改。
-
在类库当中,一个基类可能已被指定与用户所需的多种组件建立了某种关系,因此在类库中的积累是绝对不允许修改的。
-
实际上许多基类仅仅为一个框架,并无实际作用,目的只是为了建立通用的数据结构,以便用户在此基础上添加各种功能建立各种功能的派生类。
-
在面向对象的程序设计过程中,需要设计类的层次结构,也就是需要一层一层的设计类,从最初的抽象类出发,是不断的从抽象到具体的过程。每一层的派生和继承都需要站在整个系统的角度统一规划。
第十二章
多态性的概念
多态性是面向对象程序设计的一个重要特征,如果一种语言只支持类而不支持多态,是不能够称为面向对象的语言的,只能说是基于对象的,如VB。c++支持多态并且能够实现多态性,利用多态性可以设计和实现一个易于扩展的系统。
在面向对象方法中一般是这样来描述多态的:向不同的对象发送同一个消息,不同的对象在接收时会产生不同的行为(即方法)。也就是说,每个对象可以用自己的方式去相应共同的消息。所谓消息就是调用函数,不同的行为就是不同的实现,即执行不同的函数。
其实我们之前已经接触过了多态性,例如运算符的重载、函数的重载等。
在c++中,多态性的表现形式之一是:具有不同功能的函数可以用同一个函数名,这样就可以实现用一个函数名调用不同内容的函数。
多态分为两类:静态多态性和动态多态性:
静态多态性
静态多态性是根据函数重载实现的。有函数重载和运算符重载形成的多态性就是静态多态性,要求在程序编译时就知道调用函数的全部信息 ,因此,在程序编译时系统就能决定要调用的时哪儿个函数。**静态多态性又称为编译时的多态性。**静态多态性的函数调用速度快、效率高,但缺乏灵活性,在程序运行前就已决定了执行的函数和方法。
动态多态性
动态多态性的特点是不在编译时确定调用的是哪儿个函数,而是在程序运行过程中才动态地确定操作所针对的对象。它又称运行时的多态性, 动态多态性是通过虚函数实现的。
重载函数与同名覆盖的区别
- 重载函数在函数类型和参数个数两方面至少有一个不同
- 同名覆盖则是在参数个数和函数类型两方面全部相同且一个处于基类一个处于派生类
利用虚函数实现动态多态性
虚函数的作用
在同一个类当中是不能够定义两个名字相同、 参数个数和类型都相同的函数的,否则就是重复定义。但是在类的继承层次结构中,在不同的层次中可以出现名字相同、参数个数和类型都相同而功能不同的函数,但是要通过虚函数来实现。
c++当中的虚函数就是用来解决动态多态问题的。所谓虚函数,就是在基类声明函数是虚拟的,并不是实际存在的函数,然后在派生类中才正式定义函数。在程序运行期间,用指针指向某一派生类中的对象,这样就能调用指针指向的派生类对象中的函数,而不会调用其他派生类中的函数。
注意:虚函数的作用是在派生类中重新定义与基类同名的函数,并且可以通过基类指针来访问基类和派生类中的同名函数。
c++
#include <iostream>
using namespace std;
class Student
{
protected:
string name;
int age;
char sex;
public:
Student(string a,int b,char c):name(a),age(b),sex(c){}
virtual void display()//声明为虚函数
{
cout<<"name:"<<name<<endl;
cout<<"age:"<<age<<endl;
cout<<"sex:"<<sex<<endl;
}
};
class Graduate:public Student
{
private:
double wage;
public:
Graduate(string a,int b,char c,double d):Student(a,b,c),wage(d){}
void display()
{
cout<<"name:"<<name<<endl;
cout<<"age:"<<age<<endl;
cout<<"sex:"<<sex<<endl;
cout<<"wage:"<<wage<<endl;
}
};
int main()
{
Student A("小芳",18,'f');
Graduate B("土狗",18,'m',888);
Student *p;
p=&A;
p->display();
p=&B;
p->display();
return 0;
}
例如对于上一章中继承的一个例子进行小小的修改(修改部分为注释那行),我们可以发现指针p指向的display函数能够成功的输出Graudate类对象的数据而不是与之前那样仅仅输出姓名、年龄以及性别。原因在于多态性的实现:在原有的未加virtual的程序当中,本来我们定义的基类指针p是指向基类对象的,如果用它来指向派生类对象,则会自动进行指针类型转换,将基类指针转换为派生类指针,这样基类指针指向的就是派生类对象中的基类部分,也仅仅能对于派生类中从基类继承来的数据进行输出。在程序修改之前是无法通过基类指针去调用派生类对象中的成员函数的。但是虚函数打破了这一限制,在基类中的display被声明为虚函数后,对于派生类中的display函数进行重定义,这时派生类的同名函数就取代了基类中的虚函数。因此在基类指针指向派生类对象后,调用display函数就是调用的派生类的display函数。要注意的是,只有用virtual声明了函数为虚函数后才具有以上作用。
虚函数的以上功能具有十分强大的实际意义,在面向对象的程序设计当中,经常会用到基类的继承,目的是保留基类的特性,以减少基类的开发时间。但是,从基类继承来的某些成员函数不完全适应派生类的需要,且如果派生的层次过多还要起不同的名字,若名字相同又会出现同名覆盖的现象
虚函数很好的解决了这个问题。可以看到:当把基类当中某个成员函数声明为虚函数后,允许起派生类中对该函数重新定义赋予它新的功能,并且可以通过指向基类的指针指向同一类族中不同的类的对象,从而调用其中的同名函数。
注意:由虚函数实现的动态多态性就是:同一类族中不同类的对象对同一函数调用作出不同的相应
虚函数的使用方法
-
在基类当中用virtual来声明成员函数为虚函数。在类外定义虚函数时,不必再加virtual
-
在派生类中重新定义此函数,函数名、函数类型、函数参数个数和类型必须与基类的虚函数相同,根据派生类的需要重新定义函数体。**当一个函数被声明为虚函数时,其派生类当中的同名函数都自动改为虚函数。**因此在派生类重新声明该函数时,可以加virtual,也可以不加,但习惯上一般在每一层声明该函数时都加virtual,使程序更加清晰
-
定义一个指向基类对象的指针变量,并使它指向同一类族中需要调用该函数的对象
-
通过该指针变量调用此虚函数,此时调用的就是指针变量指向的对象的同名函数
通过虚函数与指向基类对象的指针变量的配合使用,就能实现动态的多态性。如果想调用同一类族中不同类的同名函数,只要先用基类指针指向该类对象即可。如果指针先后指向同一类族当中不同类的对象,就能不断地调用这些对象中的同名函数
需要说明,有时在基类中定义的非虚函数会在派生类中被重新定义,如果用基类指针调用该成员函数,则系统会调用对象中基类部分的成员函数,如果用派生类指针调用该成员函数,则系统会调用派生类对象中的成员函数,这并不是多态性行为,没有用到虚函数的功能
我们可以发现,函数重载处理的是同一层次上同名函数的问题,虚函数处理的是不同派生层次上的同名函数问题,前者是横向重载 ,后者是纵向重载 ,但虚函数与函数重载不同的是:同一类族的虚函数的首部是相同的,而函数重载时函数的首部是不同的(参数个数或参数类型不同)
静态关联与动态关联
在编译系统当中,编译系统要根据已有的信息对于同名函数的调用作出判断。比如函数重载就是根据函数的参数或参数的类型不同来判断究竟调用哪儿个函数。对于调用同一类族当中的虚函数,我们应带在调用的时候用一定的方式告诉编译系统要调用的是哪儿一个类当中的函数。
确定调用的具体对象的过程称为关联 ,一般来说关联指把一个标识符和一个存储地址联系起来。前面提到的函数重载和通过对象名调用的虚函数,在编译时即可确定其调用的虚函数属于哪儿一个类,其过程称为静态关联 ,由于是在运行前进行关联的,因此又称为早期关联,函数的重载属于静态关联。
从上面那块代码程序我们可以看出,通过指针p指向的display函数属于对虚函数的调用,且我们在调用的时候并没有为其指定对象名,且在编译过程中通过指针p对于display函数的调用是合乎语法符合编译的,但是我们发现在编译阶段无法确定调用的是哪儿个display函数。在这种情况下,编译系统把它放在运行阶段处理,在运行阶段,基类指针变量先指向某一个类对象,然后通过此指针变量调用该对象中的函数。此时调用哪儿一个对象无疑是确定的。由于是在运行阶段把虚函数和类对象"绑定"在一起的,因此,此过程称为动态关联 。这种多态性是动态的多态性,即运行阶段的多态性
由于在运行阶段,指针可以先后指向不同的类对象,从而在调用同一类族中的不同类的虚函数。由于动态关联是在编译以后的运行阶段进行的,因此也称为滞后关联。
在什么情况下应该声明虚函数
使用虚函数时要注意两点:
-
只能用virtual声明类的成员函数,把它作为虚函数,而不能将类外的普通函数声明为虚函数,虚函数仅仅能够用于类的继承层次结构当中,因为虚函数的作用是允许在派生类中对基类虚函数重新定义的
-
一个成员函数被声明为虚函数后,在同一类族当中的类就不能在定义一个非虚且与该虚函数具有相同参数(个数和类型)和函数返回值相同的同名函数
使用虚函数的情况:
-
首先看成员函数所在的类是否会作为基类。然后看成员函数在类的继承后有无可能被更改功能,如果希望更改功能,一般将它声明为虚函数
-
如果成员函数在类被继承后功能无需修改,或派生类用不到该函数,则不要把它声明为虚函数。不要仅仅考虑到其作为基类而把基类所有成员函数都声明为虚函数
-
应考虑对成员函数的调用是通过基类指针还是引用去访问,若是通过基类指针或引用去访问则应当声明为虚函数
-
有时在定义虚函数时,其为空,后面的功能仅仅留给派生类中去重新定义实现添加
注意:
使用虚函数系统要有一定的空间开销 。当一个类带有虚函数时,编译一同会为该类构造一个虚函数表,他是一个指针数组,存放每个虚函数的入口地址。系统在进行动态关联的时间开销是很少的,因此多态性是十分高效的
虚析构函数
虚析构函数的使用也是多态性的一种体现,前面我们了解到派生类可以对于基类的构造函数进行继承,因此在派生类对象的建立过程中首先调用基类的构造函数,而后才调用派生类的构造函数,在此对象被清理的时候先调用派生类的析构函数再调用基类的析构函数
然而若是我们new一个派生类的对象,在对其进行delete时我们会发现其仅仅执行了基类的析构函数,对于动态建立的派生类的析构函数并无丝毫的调用,这就容易造成内存泄漏
解决这个情况的方式就是对于基类的析构函数进行虚函数处理 ,与普通的虚函数不同,在基类进行析构函数的虚函数处理之后,其派生类中的虚构函数无论名字相不相同则全部转化为虚函数
专业人员一般都习惯声明虚析构函数,即使基类并不需要定义虚析构函数,也显式的定义一个函数体为空的虚析构函数,以保证再撤销动态分配空间时能够得到正确的处理。
构造函数不能声明为虚函数。这是因为在执行构造函数当中类对象还未完成建立的过程,当然谈不上把函数与类对象绑定:
c++
#include <iostream>
using namespace std;
class Student
{
protected:
string name;
int age;
char sex;
public:
Student(){}
virtual ~Student(){cout<<"完成基类的清理"<<endl;}
};
class Graduate:public Student
{
private:
double wage;
public:
Graduate(){}
~Graduate(){cout<<"完成派生类的清理"<<endl;}
};
int main()
{
Student *p=new Graduate;
delete p;
return 0;
}
纯虚函数与抽象类
纯虚函数
有的情况下,虚函数为空且仅仅为了派生类当中能够对其功能重新定义,并无实际意义,我们把这样的函数叫做纯虚函数,有个简便的写法:virtual 函数类型 函数名(参数列表)=0;
注意:
-
纯虚函数没有函数体
-
最后的=0并不代表函数返回值为0他只是起一个形式上的作用告诉编译系统这时纯虚函数
-
这是一个声明函数,末尾加分号
纯虚函数仅仅是一个函数名字不具备函数的功能,不能被调用,只是个空壳而已。它只是通知编译系统:"在这定义了一个虚函数留给后面派生类中定义"。纯虚函数的作用是在基类中为其派生类保留一个函数的名字,以便派生类根据需要对它进行定义。如果在基类中没有保留函数名字,则无法实现多态性。如果在一个类中声明了纯虚函数而在派生类当中没有对该函数进行定义,则该虚函数在派生类中仍然为纯虚函数。
抽象类
有了空壳函数作为基础来供派生类中的函数进行重定义,自然也有类似于空壳类的存在。也就是抽象类。如果声明了一个类,一般可以用它定义对象。但是在面向对象的程序设计过程中往往有一些类它们不用来生成对象。定义这些类的唯一目的是用它作为基类去建立派生类。它们作为一种基本类型提供给用户,用户在这个基础上根据自己的需要定义出功能各异的派生类,用这些派生类去建立对象。
这种不用来定义对象而只作为一种基本类型用作继承的类,称为抽象类,由于它常用作基类,通常称为抽象基类。饭时包含纯虚函数的类都是抽象类。因为纯虚函数是无法调用的,包含纯虚函数的类是无法建立对象的。抽象类的作用是作为一个类族的共同基类,或者说,为一个类族提供一个公共接口。
如果在抽象类所派生出来的新类当中对于基类的所有纯虚函数进行了定义,那么这些函数就被赋予了新的功能,可以被调用。这个派生类就不是抽象类,而是可以用来定义对象的具体类,如果在派生类当中没有对于所有纯虚函数进行定义,此派生类仍然是抽象类,不能用来建立对象。
虽然抽象类不可以用来建立对象,但是可以定义指向抽象类数据的指针变量,当派生类成为具体类之后。就可以用这种指针指向派生类对象,然后通过该指针调用虚函数实现多态性的操作。
小结
-
一个基类如果包含一个或一个以上的纯虚函数时就是抽象基类,抽象基类是不能也不必要定义对象
-
抽象基类于普通基类不同,它一般不是现实存在的对象的抽象,它可以是没有任何物理上的或者其他实际意义方面的含义
-
在类的层次结构当中,顶层或者最上面的几层可以是抽象基类。抽象基类体现了本类族中各类的共性,把各类中公有成员函数集中在抽象基类中声明。
-
抽象基类是本类族的公共接口,或者说从同一基类派生出的多个类由同一接口,因此能响应同一形式的消息,但是相应的方式因对象不同而异。在通过虚函数实现动态多态性时,可以不必考虑对象是哪儿一个类的,都用同一种方式调用
-
如果能够通过对象名在编译阶段确定调用的是哪儿个类的虚函数这是静态关联,如果是通过基类的指针p调用虚函数,在编译阶段无法确定调用哪儿一个类的虚函数,只有在运行时p指针指向某一类对象时才确定,那么此为动态关联
-
如果在基类中声明了虚函数,那么在派生类中凡是与该函数有相同的函数名、函数类型、参数个数和参数类型的函数均为虚函数。同一虚函数在不同的类当中可以有着不同的定义。纯虚函数是在抽象基类中声明的,只是在抽象基类中才称为纯虚函数,在其派生类中虽然继承了该函数,但除非再次用=0把它声明为纯虚函数,否则不能称为纯虚函数
-
使用虚函数提高了程序的可扩充性