C++学习笔记
二、对C的改进及扩展
1、名字空间的定义及使用
标准名字空间std:涵盖标准C++的所有定义和声明,包含C++所有的标准库。在iostream文件中定义的所有变量函数等都位于名字空间std中。使用"using namespace std;",程序员可以直接使用iostream中所定义的所有变量和函数。
2、C++允许自己定义并使用名字空间:
(1)、语法:namespace 名字空间名称{…;}。大括号后不加分号。
(2)、定义名字空间以关键字namespace开头。名称是用户自定义标识符。
(3)、使用名字空间中的内容
①、名字空间名称::局部内容名,::为域解析符或作用域运算符。
②、using namespace 名字空间名称。 可使用该名字空间所有内容。
③、using 名字空间名称::局部内容名。 可使用名字空间这一局部内容。
举例:
#include<iostream>
using namespace std;
namespace one
{
int x;
int inf=-100;
}
namespace two
{
int y;
}
using namespace one;//方法2
int main()
{
x=10;//方法2
using two::y;//方法3
y=10;//方法3
one::inf*=2;//方式1
return 0;
}
3、函数重载
- 函数名相同的函数,重载时,在形参的个数、类型、顺序的至少一个方面要有区别。返回值类型不是区分重载函数的要素。
- 在函数调用时,若实参与形参类型不同,编译器会自动转换,若转换成功,则程序继续运行。可能也会产生错误。
4、引用:给一个已有的变量起别名
-
声明格式:数据类型 & 引用名 = 一个已定义的变量名。
如: int x = 10, y = 20 ; int & r= x;于是r就是x的别名,r=y;等效于x=y; 但不理解为r重新成为变量y的别名。 -
说明:
①、&不是取地址运算符。
②、声明一个引用时,若不是作为函数参数或返回值则必须初始化。
③、引用被声明后就像普通变量一样,直接用引用名进行访问。
④、引用仅是别名,不分配内存空间,与其所代表的变量占用同一段内存空间。
⑤、并不是任何类型的数据都有引用,不能建立void类型引用、引用的引用、指向引用的指针、引用数组。
如:int x=10,a[10];
int &&r = x;int & *p = x; int &ra[10] = a; void & r = x ;就都是错误的。 -
最主要的用途是作为函数的形式参数,对引用进行访问和修改,达到对实参进行操作的效果。
-
与引用形参对应的实参只能是变量。引用参数在函数被调用时就是对那个实参变量的别名,对引用形参的任何操作实际上就是对实参变量的操作。
int f(const int &x,int &y,int z)
{
//x++; 错误,不能修改常量的值
y++;//通过y修改第二个实参的值
z++;//实参的值不变
return y;
}
- 引用作为返回值的函数可以作为左值调用(即将函数的调用放在赋值号左边,当变量使用)。如:int & f(int a,int b,int c);可将常数赋值给f,如:f(a,b,c)=20;
同时,引用返回的函数:
①、return后面只能时变量,也不能是常引用,保证能作为左值调用。
②、return后面变量的内存空间在本次函数调用结束后应当仍然存在,因此自动局部变量不能作为引用返回。
5、动态内存空间管理
指针: 专门用于存放内存地址的特殊变量。
指针值:只能通过赋值而不能读入:①、获取变量地址。②、申请动态内存空间,获得这一段空间的首地址。
方式:C语言中用malloc、calloc函数申请,用free释放。
C++中新增运算符new申请,运算符delete释放。
int *p1=new int;//申请一个int空间
int *p1=new int(10);//申请一个int空间,初始值为10
int *p3=new int[10];//申请十个int空间
int *p4=new int[10]();//申请十个int空间,初始置为0,括号中不能有值!
int *p5=new int *[10];
for(int i=0;i<10;i++)//申请10行5列的动态二维数组p5
p5[i]=new int [5];
注意: 动态空间使用结束后,一定要及时释放部分动态空间,避免产生内存垃圾。
delete p1;//释放一个int
delete []p3;//释放数组p3
for(int i=0;i<10;i++)//释放数组p5
delete []p5;
delete []p5;//不能忘
注意:不能用delete 指针变量名; 释放一维数组空间,会导致后面N-1个元素空间未释放。
6、异常处理:throw、try-catch语句
- 程序中的错误可以分为语法错误和运行错误。语法错误在编译时即可纠正。
运行错误:是编译通过后,程序运行时出现的错误。包括不可预料的逻辑错误和可以预料的运行异常。
逻辑错误:常常时由于设计不当引起的。
运行异常:时由系统运行环境造成的,事先可以增加一些预防代码来避免这类异常。 - 在执行某个函数时检查出异常,通常不在本函数中进行处理,而是通过throw抛出异常机制,将异常传给调用它的函数(上级函数)。
上级函数通过catch捕捉异常信息进行处理。若上级函数不处理异常,就继续传给更上一级的函数。若没有任何一级函数处理,程序可能会被终止。 - (1)、检查异常(try)
(2)、抛出异常(throw)
(3)、捕捉异常(catch)
(1)、(3)在上级函数中处理,(2)可能在当前函数中处理。
举例:
int divide(int x,int y)
{
if(y==0) throw y;//分母为0
return x/y;
}
try//try函数检查
{
cout<<divide(a,b)<<endl;
}
catch(int)//catch捕获异常,输出一条提醒
{
cout<<"failed"<<endl;
}
说明:
①、try-catch语句必须一起出现在可能出现异常的函数的上级函数中,且try块先,catch块后。
②、try和catch之间不能由任何其他语句。
③、只能有一个try块,对应的,可以有多个catch块,表示不同异常信息相匹配。
④、需要检测异常的函数调用必须放在try块中,检测到异常后如何处理的语句必须放在catch块中。
完整过程:
①、程序顺序执行try中的语句。
②、若try中无异常,跳过catch。若发生异常,则有throw抛出异常信息,余下语句不再执行。
③、寻找与之匹配的catch子句,捕获异常并处理。
④、异常处理完毕后,继续执行 catch子句后面的语句。
三、类与对象
1、类的定义
-
类的静态属性——数据成员。动态属性——成员函数。
类是将表征静态属性的数据和与这些数据相关的操作封装在一起构成一个相对封闭的集合体。 -
定义格式:
class Cdate
{
private: //可省略
int year,month,day;
protected:
...
public:
int GetYear() //公有成员函数
{
return year;
}
void Set(int x,int y,int z); //内部申明
}; //分号不能忘
Cdata date1; //定义一个cDate类的对象
void Cdate:: Set(int x,int y,int z) //外部实现,要加类名::
{
year=x,month=y,day=z;
}
- 内联函数
函数首部的最前面增加关键字inline,该函数就被声明为内联函数。
当一个类的成员函数在类说明中实现时,则默认其为内联函数。
通常我们采用内部声明,外部实现的方式来定义函数。内联函数适合用于短的简单函数。
2、 this指针:
用于存放当前对象的地址。
3、 构造函数与析构函数
①、构造函数是类的一种特殊的成员函数,在定义类的对象时,系统会自动调用构造函数来创建并初始化对象。
构造函数与普通成员函数的定义方式相同,除了具有一般函数的特征外,还有如下特殊的性质:
(1)、构造函数的函数名必须与类名相同,以类名为函数名的函数一定是类的构造函数。
(2)、构造函数没有返回值类型。
(3)、构造函数为public属性,否则定义对象时无法自动调用构造函数。
(4)、构造函数只在创建对象时由系统自动调用,所定义的对象在对象名后要提供初始化对象所需要的实际参数。
————
class CDate
{
private:
int year,month,day;
public:
CDate(int , int , int ); //构造函数原型声明
void Display( );
};
CDate::CDate(int y, int m, int d ) //在类体外实现构造函数
{
year = y;
month = m;
day = d;
}
int main()
{
CDate today(2019,3,11); //定义对象同时完成对象的初始化
return 0;
}
注意:
如果类定义中已经为类提供了任意一种形式的构造函数,编译器就不会再提供默认的无参构造函数。
系统自动提供的无参构造函数只负责为对象分配空间。
一个类可以拥有多个构造函数。对构造函数可以进行重载,重载的多个构造函数必须在形式参数的类型、个数和顺序等至少一方面不一样,要注意避免出现二义性。
建议在定义构造函数时,为避免出现因参数数量不同而找不到合适的构造函数,构造函数采用带默认参数的形式比较安全。
②、初始化列表
在构造函数体中使用赋值语句初始化对象的数据成员
CDate::CDate(int y , int m , int d) : year (y), month (m), day (d) {}
初始化列表是初始化对象某些特殊数据成员的唯一方法。
③、拷贝构造函数
拷贝构造函数也是一种重载版本的构造函数,它是用一个已存在的对象初始化另一个新创建的同类对象。
该函数的参数是一个引用。通常使用常引用(也可以是普通的引用)。
使用常引用的好处是,既能以常对象作为源对象,也能以普通对象作为源对象去初始化目标对象。
拷贝构造函数在以下3种情况下由系统自动调用:
(1)明确表示由一个已定义的对象初始化一个新对象。
Cdate newday(2010);
Cdate day=newday; //用newday初始化day
(2)函数的形式参数为一个对象,当发生函数调用,对象作为实参传递给函数形参时。注意,如果形式参数是引用或指针,就不会调用拷贝构造函数,因为此时不会产生新对象。
Cdate:: Cdate(const Cdate &date)
{
year=date.year,month=date.month,day=date.day;
}
Cdate day1(2020,3,11);
Cdate day2(day1); //把day1复制给day2
(3)对象作为函数返回值。
Cdate f(Cdate newdate1)
{
Cdate newdate2(newdate1); //把1复制给2
return newdate2; //返回2
}
Cdate day3=f(day2);
④、析构函数
创建类的对象时,系统会自动调用构造函数。同样,当对象生命期结束时,
需要释放所占的内存资源,程序将自动调用类的析构函数来完成。
注意:
(1)析构函数也是类的成员函数,其函数名与类名相同,但在类名前要加“~”号。
(2)析构函数没有返回值类型,前面不能加“void”,且必须定义为公有成员函数。
(3)析构函数没有形式参数,也不能被重载,每个类有且仅有一个析构函数。
(4)析构函数由系统自动调用执行,在两种情况下会发生析构函数调用:第一种是对象生存期结束时由系统自动调用;第二种是用new动态创建的对象,用delete释放申请的内存时,也会自动调用析构函数。
(5)与构造函数类似,若程序中没有用户自定义析构函数,系统此时也会提供一个默认的析构函数,其函数体为空。
4、深拷贝与浅拷贝
- 浅拷贝: 拷贝对象是指针,调用默认拷贝函数生成的新对象仅仅复制了指针中的地址,而未申请新的内存空间。因此,当对象生命周期结束要撤销对象时,指针所指向的动态空间会被多次释放,产生错误。
- 深拷贝: 重新申请一块内存,并复制源对象在地址中存储的内容。这样,两个对象的指针成员就拥有不同的地址值,分别指向不同的动态存储空间,但两个动态空间中的内容是完全一样的。
- 举例:若一个类中有一个成员变量是char型数组,则使用浅拷贝会存在问题。
原因:浅拷贝即默认的拷贝做’=‘运算,两个字符数组不可以用’=’,要用strcpy进行深拷贝。
但如果一个类中只有int型和double型的成员变量,则浅拷贝不会出现任何问题。
四、类与对象的知识进阶
1、对象成员
- 定义:定义一个新的类型时,可以用已有的类的实例化对象作为新类的数据成员使用。
- 与普通对象一样,对象成员在创建时需要调用构造函数,在生命周期结束时需要调用析构函数。当对象被创建时,对象成员也会被创建,对象析构时,对象成员也一同被析构。
- 创建一个对象时,构造函数的调用次序是:首先调用对象成员的构造函数,再调用对象自身的构造函数。析构时顺序完全相反。
2、静态成员
- 静态成员可以在同一个类的不同对象之间提供数据的共享,不管这个类创建了多少个对象,但静态成员只有一份拷贝(副本),为所有属于该类的对象所共享。
- 静态成员包括静态数据成员和静态成员函数。
- 静态数据成员:
①、类中的静态数据成员初始化必须在类外进行。
②、若未创建任何对象,此时只能以“类名::公有静态数据成员”的形式访问,呈现的是即使没有创建任何类对象,类的静态成员也是存在的。也就是说,若定义的静态数据成员不是公有的,那么当未创建对象时,则无法访问静态数据成员。
方式:
class A
{
public:
static int num;
};
int A::num = 0;
类外初始化静态数据成员时不需要再使用static关键字,但是需要使用“类名::”来限定成员名,以使得编译器理解此处使用的是某个类的静态成员,否则,编译器会理解为只是创建一个与类无关的全局变量。
- 静态成员函数:
①、静态数据成员是公有属性,为同类对象共享,可以在类外直接访问,如果静态数据成员不是公有属性,则无法直接用类名或对象名来访问,这时同样需要借助于函数。
②、通过对某个成员函数声明为static,该函数将独立于本类的任何实例。静态成员函数的优点是:即使本类没有创建任何对象,静态成员函数也已存在并可以被调用,在这种情况下,静态成员函数只能访问静态数据成员,不允许访问非静态数据成员。
③、说明:
若需要了解在没有创建任何对象前私有静态数据成员的情况,可以用 “类名::静态成员函数” 进行操作;
静态成员函数没有this指针,所以无法对非静态数据成员进行访问操作。
3、常对象
◆ ⭐常对象可以调用常成员函数,不能调用非const成员函数;非const对象,可以调用普通成员函数和常成员函数。
◆ 常对象的成员函数不一定都是常成员函数;同样的常对象的数据成员不一定都是常数据成员。
◆ 常对象一旦初始化,常对象的数据成员便不允许修改,而不是说常对象的数据成员都是常数据成员。
◆ PS:定义常对象有两种方法,
1. Point const a;
2. const Point a;
(同样的,定义变量或指针时,const放前放后意义一样,如:1. const double a; double const a; 2. const int *p; int const *p; 但是定义指针常量时,只能用 int * const p;)
4、常成员
①、常数据成员
定义形式:
class A
{
public:
const int num;
A(int n=0) : num(n) {}
};
◆ 常数据成员必须进行初始化,并且不能被更新。
◆ 常数据成员不能在声明时赋初始值(普通数据成员也是),常数据成员必须在构造函数初始化列表进行初始化;普通数据成员在初始化列表和函数体中初始化均可。
◆ PS:类的成员对象若要传入参数初始化,则必须在构造函数初始化列表进行;(成员对象:当一个类的成员是另一个类的对象时,这个对象就叫成员对象。)
②、常成员函数
定义形式:
class A
{
int n=0;
public:
void show () const ;
};
void A::show() const
{
cout<<n<<endl;
}
◆ 只能对成员函数声明为const,对普通的函数不能这样声明,这样的声明使该函数中的this指针指向常对象,则类中的数据成员不可以出现在赋值符号的左边。
◆ 常成员函数不能调用该类中未经关键字const修饰的普通成员函数。由于普通成员函数可以改变数据成员的值,如果允许被常成员函数调用,则说明常成员函数可以间接修改数据成员的值,显然,这与常成员函数保护本类内部数据成员的初衷相左,因此不被允许。但是反过来,普通成员函数可以调用常成员函数。
◆ 常成员函数不更新对象的数据成员。
◆ 常成员函数的const关键字可以被用于参与对重载函数的区分。
◆ ⭐通常非const成员函数需要定义一个const版本的重载函数,以方便定义常对象后调用常成员函数。
◈ 如果常对象调用的常成员函数返回的是指向当前对象的指针(或返回的是当前对象),那么此常成员函数的声明的返回类型要加const,例如:
◊ 成员函数返回指向当前对象的指针
const *Point fun1(); //非const成员函数的类内声明;
const *Point fun1() const; //习惯上要再声明一个常成员函数作重载函数,注意到此函数声明有两个const;
◊ 成员函数返回指向当前对象
const Point fun1(); //非const成员函数的类内声明;
const Point fun1() const; //习惯上要再声明一个常成员函数作重载函数,注意到此函数声明有两个const;
◊ ⭐注意,如果一个类中声明以下4个重载函数:
① Point fun1();
② const Point fun1();
③ Point fun1() const;
④ const Point fun1() const;
【解析】①和②是冲突的,因为无法区分仅按返回类型区分的重载函数;③和④是冲突的,也是因为无法区分仅按返回类型区分的重载函数。
所以正确的重载函数搭配有3种:
◊ ①和③搭配:
Point fun1(); //函数返回非const对象
Point fun1() const; //函数返回非const对象
[解析]适用于定义常对象后调用常成员函数,常成员函数返回类型是非const的Point类对象。
◊ ①和④搭配(这里把返回类型改为指针,因为常用):
Point *fun1() {return this; }; //函数返回指向本对象的指针
const Point *fun1() const { return this; };//函数返回指向本常对象的指针,第一个const说明返回的是指向常对象的指针,第二个const说明此函数是常函数
[解析]适用于定义常对象后调用常成员函数,常成员函数返回类型是常Point类对象(如return *this;)。
◊ ②和④搭配:
const Point fun1(); //函数返回常对象
const Point fun1() const; //函数返回常对象
[解析]适用于定义常对象后调用常成员函数,常成员函数返回类型是常Point类对象。
◊ ②和③搭配:
const Point fun1();
Point fun1() const;
[解析]虽然搭配合法,但是似乎不存在这种搭配。
5、友元
友元的3种形式如下:
(1)一个不属于任何类的普通函数声明为当前类的友元,称为当前类的友元函数。
定义形式:
class A
{
int x;
public:
A(int n=0) : x(n) {}
friend void f(A );
};
void f(A tmp)
{
cout<<tmp.x<<endl;
}
(i)通常友元函数是在类的定义中给出原型声明,声明的位置任意,不受访问属性的限制。声明以后的友元函数在类外面给出完整定义,此时前面不再加关键字friend。
(ii)友元函数也可以在类内部直接给出定义,定义的首部相当于原型声明。这样的定义默认是内联函数。
(2)一个其他类的成员函数声明为当前类的友元,称为当前类的友元成员。
A类的成员函数作为B类的友元成员时,必须先定义A类。
定义形式:
class B; //先声明类B
class A
{
int x;
public:
A(int n=0) : x(n) {}
void showB(B b); //先声明函数
};
class B //定义类B
{
int x;
public:
B(int n=0) : x(n) {}
friend void A::showB(B b); //声明友元成员函数
};
void A::showB(B b) //定义友元函数
{
cout<<b.x<<endl;
}
(3)另一个类声明为当前类的友元,称为当前类的友元类。
定义形式:
class B;
class A
{
int x;
public:
A(int n=0) : x(n) {}
void visb(B &b);
};
class B
{
int x;
public:
B(int n=111) : x(n) {}
friend A;
};
void A::visb(B &b)
{
cout<<b.x<<endl;
}
注意:
友元关系是单向的,不具有交换性
友元关系也不具备传递性。
五、继承性
1、继承与派生的概念
-
(1). 继承:一个新定义的类具有某个或某些旧类的功能与数据成员,与旧类不完全相同,额外添加了一些功能或数据成员
旧类称为基类,也称为父类,新类称为派生类,也称为子类(2). 继承与派生:
实质:描述的是同一个过程,都是指在已有类的基础上,增加新特性而产生新类的过程
区别: 继承是从新类的角度来称呼这一过程,派生是从基类的角度来称呼这一过程(3).基类与派生类的关系:
在C++中,一个基类可以派生出多个派生类,一个派生类也可以有多个基类,派生类也可以作为新的基类,继续派生出新的派生类
根据基类数目的不同,继承通常分为单一继承和多重继承两大类。
2、派生类的定义与访问控制
- 定义方式:
class A
{
public:
void f();
};
class B : public A
{
public:
void f(); //可调用A中f,同时可以新增功能
};
说明:
- 派生类的定义与普通类的定义类似,只是在类名称与类体之间必须给出继承方式与基类名。对于单一继承,只有一个基类名,对于多重继承,有多个基类名,彼此之间以逗号分隔。
- 继承方式指明派生类是以什么方式继承基类。继承方式共有三种:public(公有)、private(私有)和protected(保护),如果缺省,则默认为私有继承方式。
- 基类的构造函数和析构函数不能被继承。
3种继承方式下,基类成员在派生类中的访问属性如下表
-
基类的private成员在派生类中无法直接访问。
-
在派生类中,继承来的成员跟新定义的成员一样,访问没有任何限制。
3、派生类的构造及析构
- 对于一个派生类对象而言,新增加成员的初始化可以在派生类的构造函数中完成,其基类成员的初始化则必须在基类的构造函数中完成。
- 派生类的析构函数只能完成对新增加数据成员的清理工作,而基类成员的扫尾工作则应由基类的析构函数完成。由于析构函数没有参数,因此派生类的析构函数默认直接调用了基类的析构函数。
- 在定义一个派生类对象时,构造函数的调用顺序如下:
① 基类的构造函数
② 派生类对象成员的构造函数(按定义顺序)
③ 派生类构造函数
析构函数的调用次序正好与构造函数的调用次序相反。
派生类构造函数的初始化列表语法格式
class A
{
int x;
public:
A(int t=0) : x(t) {}
int Get() {return x;}
};
class B : public A
{
int y;
public:
B(int a,int b) : A(a),y(b) {} //这里的基类构造函数就是申请 B从A中继承而来的一部分空间
//若未写A(a),则会自动调用默认参数构造函数或者无参构造函数
void f()
{
cout<<Get()<<' '<<y<<endl; //由于私有成语无法直接访问,定义了Get函数
}
};
说明:
(1)派生类只需负责直接基类构造函数的调用。若直接基类构造函数不需要提供参数,则无需在初始化列表中列出,但实质上也是会自动调用基类构造函数的。
(2)基类构造函数的调用通过初始化列表来完成。当创建一个对象时,实际调用次序为声明派生类时各基类出现的次序,而不是各基类构造函数在初始化列表中的次序。
(3)其他初始化项包括对象成员、常成员和引用成员等。另外,普通数据成员的初始化,也可以放在初始化列表中进行。
4、同名冲突及其解决方案
-
基类与派生类的同名冲突
无论是派生类内部成员函数还是派生类对象访问同名成员,如果未加任何特殊标识,则访问的都是派生类中新定义的同名成员。如果派生类内部成员函数或派生类对象需要访问基类的同名成员,则必须在同名成员前面加上“基类名::”进行限定。
说明:
(1)通过派生类的指针或引用,访问的是派生类的同名成员,此时同名覆盖原则仍然发挥作用。
(2)基类的指针指向派生类对象时,访问的依然是基类中的同名成员。
(3)基类的引用成为派生类对象别名时,访问的也依然基类中的同名成员。
-
多重继承中直接基类的同名冲突
解决方案与上一种冲突类似,在成员前指名基类名即可。 -
多层继承中共同祖先基类引发的同名冲突
Derived类中共有两个成员a,一个是经Base—Base1—Derived继承下来的a,另一个是经Base—Base2—Derived继承下来的a。因此在Derived中访问a时会出现同名冲突。Base的构造函数与析构函数各被执行了两次,说明确实存在两个a。
如下图所示,家具类furniture是一个公共基类,它有一个protecetd型成员weight,表示家具的重量。沙发类sofa与床类bed分别继承自furniture,而沙发床类sofabed又继承自sofa与bed。在sofabed类中访问weight时,可以使用sofa::weight和bed::weight来加以区分。
从语法上看这样处理没有问题,但是对于一个实际的沙发床来说,它不应该有两个重量,只应该有一个重量。因此,这样处理与实际不符合。
产生的原因在于sofabed中产生了weight的多个拷贝,而多个拷贝又是因为多个基类多次调用了共同基类furniture的构造函数所致。
解决方案:定义虚基类
或者
virtual确保虚基类的构造函数至多被调用一次。程序运行时,系统会进行检查。如果虚基类的构造函数还没有被调用过,那就调用一次,如果已经被调用过了,那就忽略此次调用。
Base类为虚基类时的继承关系示意图
只有最后一层派生类对虚基类构造函数的调用会发挥作用。
当创建一个对象时,其完整的构造函数调用次序是:
① 所有虚基类的构造函数(按定义顺序)
② 所有直接基类的构造函数(按定义顺序)
③ 所有对象成员的构造函数(按定义顺序)
④ 派生类自己的构造函数
析构函数的调用次序与之完全相反。
5、赋值兼容规则
- 定义:赋值兼容就是指需要使用基类的地方可以使用其公有派生类来代替,换言之,公有派生类可以当成基类来使用。
赋值兼容的理论依据是:公有派生类继承了基类中除构造函数、析构函数以外的所有非私有成员,且访问权限也完全相同,因此,当外界需要基类时,完全可以用它来代替。 - 赋值兼容主要有以下四种常见情形:
(1)基类对象 = 公有派生类对象。
赋值后的基类对象只能获得基类成员部分,派生类中新增加的成员不能被基类对象访问。
(2)指向基类对象的指针 = 公有派生类对象的地址。
利用赋值后的指针可以间接访问派生类中的基类成员。
(3)指向基类对象的指针 = 指向公有派生类对象的指针。
利用赋值后的指针可以间接访问原指针所指向对象的基类成员。
(4)基类的引用 = 公有派生类对象,即派生类对象可以初始化基类的引用。
赋值后的引用只可以访问基类成员部分,不可以访问派生类新增成员。
总结: 基类类型的对象、指针、引用,只能访问派生类的基类成员部分。
说明:
(1)使用赋值兼容时,必须是公有派生类,因为只有公有继承才会保持访问属性不变。
(2)在赋值兼容中,公有派生类可以当成基类使用,反之不行。
(3)在赋值兼容中,也不可以通过基类的对象、指针或引用来访问派生类的新增成员。
六、多态性
1、多态的两种类型
- 定义: 多态性指一种行为对应着多种不同的实现。
- C++程序的多态的实现分为静态联编(static binding)和动态联编(dynamic binding)
静态联编:是在程序编译阶段就能实现的多态性,这种多态性称为静态多态性,也称编译时的多态性,可以通过函数重载和运算符重载实现
动态联编: 是在程序执行阶段实现的多态性,这种多态性称为动态多态性,也称运行时的多态性,可以通过继承、虚函数、基类的指针或引用等技术实现
(1)静态多态性: 同一个类中的同名成员函数,定义时在形式参数的个数、顺序、类型方面有所不同,在程序编译时就能根据实际参数与形式参数的匹配情况,确定该类对象究竟调用了哪一个成员函数。
(2)动态多态性: 在基类与派生类中存在的同名函数,要求该同名函数的原型在基类和派生类中完全一致,而且是虚函数。在编译时无法确定究竟调用的是哪一个同名函数,只有在程序运行时,通过基类的指针指向基类或派生类对象,或基类的引用代表的是基类或派生类的对象,确定调用的是基类还是派生类中的同名函数。
总结:
静态: 同名函数重载的参数顺序、个数、类型不同,可以确定调用哪个函数。
动态: 基类和派生类中的同名函数,基类的指针或引用指向派生类对象时,默认调用基类的函数。通过
2、静态多态性的实现——重载
- 静态多态性的优点是函数调用速度快、效率高;缺点是编程不够灵活。
- 静态多态性可以通过函数重载和运算符重载实现。运算符重载本质上就是一种特殊的函数重载。
函数重载的3种表现形式:
①、非类的一般函数重载
②、同一个类的多个同名成员函数之间
③、在基类和派生类中的同名成员函数之间
C++语言中可以重载和不可以重载的运算符
(3). 运算符重载的规则:
(1)除了表 6_2中的5个运算符以外,全部可以重载。只能重载已有运算符,不能创造新运算符。
(2)重载后,运算符的优先级与结合性都不改变。
(3)运算符重载是针对新类型数据的实际需要,对原有运算符进行适当地改造。一般来讲,重载的功能应当与原有功能相类似,运算符重载不应当改变运算符的功能和意义,避免引起歧义。
(4)运算符重载不能改变原运算符的操作对象个数,同时至少要有一个操作对象是自定义类型。
(5)在可以被重载的运算符中,除了赋值运算符“=”以及复合赋值运算符(如“+=”、 “>>=”等) 之外,其余在基类重载的运算符都能被派生类所继承。
- 运算符重载的实现方式有两种:
通过类中的成员函数或类的友元函数重载
只能重载为成员函数的运算符:“=”、 “( ) ”、 “[ ] ”、 “->”,单目运算符及复合赋值运算符也建议重载为成员函数, 第一运算对象必须本类对象
只能重载为友元函数的运算符: 提取符“>>”、插入符“<<”
其余大部分运算符两种方式都可以重载
示例:
#include <iostream>
#include <cmath>
using namespace std;
class Point
{
int x,y;
public:
Point(int r=0,int i=0) : x(r),y(i){};
void print(){cout<<'('<<x<<','<<y<<')'<<endl;}
int getx() {return x;}
int gety() {return y;}
friend double operator - (Point obj1,Point obj2);
Point operator ++ (); //前置自增运算符
Point operator -- (int ); //后置自减运算符
Point operator - ();
};
double operator - (Point obj1,Point obj2)
{
return sqrt((obj1.getx()-obj2.getx())*(obj1.getx()-obj2.getx())+(obj1.gety()-obj2.gety())*(obj1.gety()-obj2.gety()));
}
Point Point :: operator ++ ()
{
x++;
y++;
return *this;
}
Point Point :: operator -- (int)
{
Point tmp;
tmp.x=x--;
tmp.y=y--;
return tmp;
}
Point Point :: operator-()
{
Point tmp;
tmp.x=-x;
tmp.y=-y;
return tmp;
}
int main()
{
Point p1,p2(10,10);
cout<<p1-p2<<endl;
++p1; //p1.operator ++ ();
p1.print();
p2--; //p2.operator -- (0);
p2.print();
p1=-p2;
p1.print();
return 0;
}
几点说明:
①、成员函数必定通过类对象进行调用,调用运算符函数的当前类对象就是调用该运算符函数的第一运算对象
②、重载单目运算符时,形式参数表为空
③、重载双目运算符时,形式参数表中只有一个形式参数,对应于运算符的第二运算对象(右操作数)
④、友元函数是独立于类之外的一个普通函数,没有this指针,在调用时必须提供所有的运算对象
⑤、重载单目运算符时,形式参数表必须有一个形参
⑥、重载双目运算符时,形式参数表中必须有两个形参,对应于运算符的第一、第二运算对象
对于同一运算符,用成员函数重载和友元形式重载的区别:
C++中的运算符,重载的情况有三种:
(1)绝大部分运算符既可以用成员函数又可以用友元函数重载运算符函数,例如:+、-、*、/、++、-- 等
(2)只能用成员函数重载运算符函数,例如: =、[ ]、( )、-> 等
(3)只能用友元函数重载运算符函数,例如:>>、<<
(4)单目运算符及复合赋值运算符建议用成员函数重载
5.赋值运算符“=”的重载
①、只能被重载为成员函数
②、不能被继承
③、当运算对象不是基本类型时,应当重载“=”
④、其类内声明的一般形式为:
类名 & operator = ( const 类名 & ) ;
一定以某类型的引用返回,目的是为了使函数返回值可以作为左值使用
一般情况下,系统为每个类都生成一个默认的赋值运算符,实现将赋值号右边对象的各个数据成员的值依次赋值给赋值号左边对象的对应成员
要求左右两边的对象属于同一种类类型
在没有特殊处理的情况下(如对内存的动态分配等),只使用这个默认的赋值运算符就足够了
如果一个类包含指针数据成员,并且在构造函数中用该指针成员申请了动态内存空间,则该类中不仅要定义析构函数释放内存空间,还要定义拷贝构造函数以实现深拷贝。
深拷贝与浅拷贝的问题同样存在于赋值运算
结论:不是每个类都需要重载赋值运算符”=“,只有当类中有指针数据成员并通过其管理了动态内存空间,才必须重载”=“
如: 字符指针默认赋值仅仅是将指针值进行了复制,析构时会将同一空间释放两次。解决方案需要重载’='运算符,在赋值的过程中申请一块动态空间将指针指向的字符串值复制到新的内存空间,再将新的指针指向该空间。
系统默认的”= ”实现浅拷贝和重载的”= ”实现深拷贝的区别:
(6).提取运算符“>>” 和插入运算符“<<”的重载
C++ 中对于标准类型变量的输入可以用“cin>>变量名”的方式,输出则可以用“cout<<表达式”方便地进行。
C++语言允许以友元函数的形式重载提取运算符“>>”和插入运算符“<<”以方便地实现用户自定义类型数据的输入和输出。
(1)重载提取运算符“>>”的声明与定义格式:
① 类内声明语句:
friend istream & operator >> (istream & in , 用户类类型 & obj);
② 类外定义的程序段形如:
istream & operator >> (istream & in , 用户类类型 & obj)
{
in >> obj.item1; //输入类对象的数据成员item1
in >> obj.item2; //输入类对象的数据成员item2
…… //其他语句
return in;
}
说明:
① 因为第一操作数必须是流类对象而非本类对象,所以只能以友元函数形式重载提取运算符“>>”。
② **函数返回输入流istream的引用,函数体返回第一个形式参数,**便于用形如“cin>>对象1>>对象2;”的形式连续输入多个对象的值,也可以在同一条输入语句中输入本类对象及其他变量
③ 有两个形式参数,第一个必须为输入流istream的引用,第2个必须为本类的对象引用,不可以用常引用或值形式参数。
(2)重载插入运算符“<<” 的声明与定义格式:
① 类内声明语句:
friend ostream & operator << ( ostream & out , const 用户类类型 & obj);
② 类外定义的程序段形如:
ostream & operator << ( ostream & out , const 用户类类型 & obj)
{
out << obj.item1; //输出类对象的数据成员item1
out << obj.item2; //输出类对象的数据成员item2
…… //其他语句
return out;
}
说明:
① 因为第一操作数必须是流类对象而非本类对象,所以只能以友元函数形式重载插入运算符“<<”。
② 函数返回输出流ostream的引用,函数体返回第一个形式参数,便于用形如“cout<<对象1<<对象2;”的形式连续输出多个同类对象的值,也可以在同一条输出语句中输出本类对象及其他输出项
③有两个形式参数,第一个必须为输出流ostream的引用,第2个是本类的对象引用,为保护对应实参和提高效率,一般用常引用参数。
示例:
#include <iostream>
using namespace std;
class Complex //定义类 Complex
{
float real;
float imag;
public:
Complex ( float r = 0 , float i = 0 ) : real(r),imag(i) {} //形参带有默认值
friend istream & operator >> ( istream &in , Complex &com);
friend ostream & operator << ( ostream &out , const Complex &com);
};
ostream & operator << ( ostream &out , const Complex &com)
{
out << com.real ;
if( com.imag != 0 ) //虚部为0则不需要输出
{
if( com.imag > 0 ) //虚部为正数则要输出“+”号
out << "+" ;
out << com.imag << "i" ; //输出数学上虚部的标识i
}
out << endl;
return out;
}
istream & operator >> ( istream &in , Complex &com)
{
in >> com.real >> com.imag ; //输入实部和虚部
return in ;
}
int main( )
{
Complex c1 (1.5 , 2.5) , c2 ;
cout << "c1=" ; //显式调用形式为:operator << (cout,"c1=");
cout << c1 ; //显式调用形式为:operator << (cout , c1);
cout << "c2=" << c2 ;
//显式调用形式为:operator << (operator << (cout , "c2=") ,c2);
cout << "input c1,c2:\n" ;
//显式调用形式为:operator << ( cout , "input c1,c2:\n" );
cin >> c1 >> c2 ;
//显式调用形式为:operator >> (operator >> (cin , c1) , c2);
cout << "c1=" << c1 << "c2=" << c2 ; //调用四次operator <<函数
return 0 ;
}
3、动态多态性的实现
- 动态多态性: 在程序的执行阶段实现,通过继承、虚函数、基类的指针或引用等来实现。
①、在派生类Derive中重新定义了基类中已有的同名函数,产生了同名覆盖现象。主函数中,无论基类的指针指向基类对象还是派生类对象,始终调用基类中的同名函数;同样,无论基类的引用是基类对象还是派生类对象的别名,始终调用基类中的同名函数。派生类中的同名函数只能通过派生类的对象或引用才能调用
②、如果希望达到这样的效果:当基类的指针指向基类对象时调用基类中的同名函数,而指向派生类对象时就调用派生类的同名函数,这就是动态联编所能达到的效果。 - 实现动态多态性,首先要将该同名函数(函数原型必须完全一样)声明为虚函数。
(1).虚函数的声明方法
①、虚函数必须是类的非静态成员函数,在类体内声明,函数原型声明方式如下:
virtual <返回类型> <成员函数名>(形式参数表);
实现部分可以在类体内,也可以在类体外,在类体外定义时前面不能再加“virtual”。
虚函数在基类中一定要加“virtual”声明,在公有派生类中,该原型相同的函数前可以省略“virtual”关键字,自动默认该成员函数就是虚函数。
(2).虚函数的作用— 实现动态多态性的关键
虚函数可以通过基类指针或引用访问基类或派生类中被声明为虚函数的同名函数。
公有继承关系是首要条件
该同名虚函数在基类和派生类的函数原型完全一致,否则将无法通过虚函数实现动态多态性。
必须定义基类的指针或引用,通过它们才能调用不同类中的同名函数。
关于虚函数和动态多态性,作几点说明:
① 一旦在基类中用“virtual”声明某成员函数为虚函数,那么公有派生类中重新定义的原型一样的成员函数均自动为虚函数。但是建议在派生类中仍然加上关键字virtual。
②类的非静态成员函数才可以是虚函数。
③静态成员函数、内联函数、构造函数、友元函数都不能作为虚函数;析构函数可以是虚函数
派生类只有从基类公有继承,才允许基类的指针指向派生类对象,基类的引用才允许是派生类对象的别名。
(3). 虚析构函数:
析构函数可以定义为虚函数,在某些情况下还必须定义为虚析构函数
例如:用基类的指针可以申请公有派生类对象的空间,则基类的指针指向了动态派生类的对象。此时需要将析构函数声明为虚析构函数。因为,当用语句“delete 该基类指针;”释放动态派生类对象时,就会调用该指针指向的派生类的析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象被完全释放。
如果析构函数不是虚函数,则编译器实施静态绑定,在删除基类指针所指向的动态派生类对象时,只调用指针所属的基类的析构函数而不调用派生类的析构函数,这会导致析构不完全。
示例:
#include <iostream>
#include <cmath>
using namespace std;
class A
{
public:
virtual ~A( ) ; //将析构函数声明为虚函数
};
class B: public A //定义公有派生类
{
public:
B ( int i ) ;
~B ( ) ; //派生类的析构函数自动成为虚函数
private:
char *buffer; //指针数据成员用于管理动态空间
};
A::~A( )
{
cout << "A::~A() is called\n" ;
}
B::B( int i )
{
buffer = new char[i] ;
}
B::~B()
{
delete [] buffer ;
cout << "B::~B() is called\n" ;
}
int main( )
{
A *a = new B ( 5000 ) ; //利用基类指针申请派生类动态对象
delete a ; //调用派生类的析构函数
return 0 ;
}
在基类的成员函数被声明为虚函数后,其公有派生类中要有该虚函数的重新定义版本,也就是说,派生类中的函数原型(包括函数返回值类型、函数名、形式参数表)与基类中的虚函数原型必须完全一致,这样才可以利用基类的指针或引用实现动态多态性。
一个函数,如果在基类和其公有派生类中拥有相同的函数名,但是函数返回值类型不同,或者是形式参数表不同,即使在基类中被声明为虚函数,也不具备动态多态性。此时,基类中的函数无虚函数特性,当作普通成员函数使用,而在派生类中存在的同名函数,就是之前所讲到的同名覆盖现象,无法通过基类的指针或引用实现动态多态性。
4、纯虚函数与抽象类
纯虚函数与抽象类是两个密切相关的问题
拥有至少一个纯虚函数的类称之为抽象类
虚函数可以实现动态多态性
- 有一种虚函数,仅仅为多态机制提供一个界面而没有任何实体定义,这就是纯虚函数
纯虚函数只给出了函数的原型声明而没有具体的实现内容,其声明方式为:在虚函数原型的最后赋值0,声明的一般形式如下:
virtual <返回类型> <成员函数名> (<形式参数表>)=0;
示例:
#include <iostream>
#include <cmath>
using namespace std;
class Point
{
public:
virtual void Draw () = 0 ; //定义纯虚函数
};
class Line:public Point
{
public:
void Draw ( ) ; //在派生类Line中定义Draw函数,给出实现代码
};
class Circle:public Point
{
public:
void Draw ( ) ; //在派生类Circle中定义Draw函数,给出实现代码
};
void Line::Draw()
{
cout << "Line::Draw is called.\n" ;
}
void Circle::Draw()
{
cout << "Circle::Draw is called.\n" ;
}
void DrawObject ( Point *p ) //定义顶层函数,以基类指针为形式参数
{
p -> Draw ( ) ; //通过基类指针调用虚函数Draw
}
int main( )
{
Line L;
Circle C;
DrawObject ( &L ) ; //相当于Point *p=&L;语句,基类指针指向L对象
DrawObject ( &C ) ; //相当于Point *p=&C;语句基类指针指向C对象
return 0;
}
- 纯虚函数的特点及用法总结:
(1)纯虚函数是一种没有函数体的特殊虚函数,在声明时将“=0”写在虚函数原型最后,表示这是一个纯虚函数。
(2)纯虚函数不能被调用,因为它只有函数名,而无具体实现代码, 需要在派生类中完整定义
(3)虚函数的作用在于基类给派生类提供一个标准的函数原型,统一的接口,为实现动态多态性打下基础 - 抽象类指该类中至少包含一个纯虚函数。为所有派生类提供了统一的接口,注意几点:
(1)抽象类不能生成对象,因为该类中的纯虚函数,无实现代码。 若在示例代码的main函数中加入Point P;将会编译出错。
(2)可以定义抽象类的指针或引用,用来实现动态多态性。但是,不能用抽象类作为参数类型、函数返回值类型或显式转换的类型。
(3)抽象类的基类不能是普通类(即不是抽象类的类),抽象类是下面诸多的派生类的集中归宿。通常抽象类要有它的派生类,如果派生类中还有纯虚函数,则该派生类仍为抽象类。但是最终总会有具体类来给纯虚函数一个具体实现,这样才有意义。
(4)抽象类除了必须至少有一个纯虚函数以外,还可以定义普通成员函数或虚函数。
示例:
#include <iostream>
#include <cmath>
using namespace std;
const double PI = 3.1415 ;
class Shape //定义抽象基类 Shape
{
public:
virtual double area ( ) const = 0 ; //纯虚函数原型声明
};
class Triangle: public Shape //定义派生三角形类Triangle
{
public:
Triangle ( double b , double h ):base(b),hight(h) {}
double area() const ; //在派生类中定义纯虚函数的实现代码
private:
double base , hight ; //数据成员,代表底和高
};
class Rectangle:public Shape //定义派生矩形类Rectangle
{
public:
Rectangle (double h , double w ): hight(h) , width(w) {}
double area() const ; //在派生类中定义纯虚函数
private:
double hight , width; //数据成员,代表长和宽
};
class Circle:public Shape //定义派生圆类Circle
{
public:
Circle(double r) : radius (r) {}
double area() const ; //在派生类中定义纯虚函数
private:
double radius ; //数据成员,代表半径
};
double Triangle::area( ) const
{
return 0.5 * base * hight ; //三角形的面积
}
double Rectangle::area() const
{
return hight * width ; //矩形的面积
}
double Circle::area() const
{
return PI * radius * radius; //圆的面积
}
int main( )
{
Shape *ptr[3] ; //定义抽象类的指针数组
ptr[0] = new Triangle (2.5 , 10.0 ); //创建Triangle类的对象
ptr[1] = new Rectangle(15 , 22); //创建Rectangle类的对象
ptr[2] = new Circle( 3.0 ); //创建Circle类的对象
cout << "The area of Triangle(2.5,10.0) is: " ;
cout << ptr[0]->area( ) << endl ; //调用Triangle的area()函数
cout << "The area of Rectangle(15, 22) is: " ;
cout << ptr[1]->area( ) << endl ; //调用Rectangle的area()函数
cout << "The area of Circle(3.0); is: " ;
cout << ptr[2]->area( ) << endl; //调用Circle类的area()函数
return 0;
}