一,类和对象的创建和使用
在现实世界中,经常有属于同一类的对象。例如,你的自行车只是世界上很多自行车中的一辆。在面向对象软件中,也有很多共享相同特征的不同的对象,可以利用这些对象的相同特征为它们建立一个集合,而这个集合就称为类。
C++ 中类是把各种不同类型的数据(称为数据成员)和对数据的操作(成员函数)组织在一起而形成的用户自定义的数据类型。它提供了可重用性的好处。
类定义包括声明和实现两大部分。声明部分提供了对该类所有数据成员和成员函数的描述,而实现部分提供了所有成员函数的实现代码。
二 . 类的声明:
类的声明,指的是描述一个类所拥有的结构。类的声明主要包含两个部分:成员变量和成员函数。
声明一个类的格式如下:
class 类名
{
public: //类的公有成员
int A; // 公有的成员变量
void PublicFunc(); // 公有的成员函数
private: // 类的私有成员
long B; // 私有的成员变量
void PrivateFunc(); // 私有的成员函数
public: // public,private 块可以多次交替出现
double C;
}; // 注意不要忘了最后的分号
class 是 C++ 中新增的关键字,专门用来声明类的,后面紧跟着类名,类名的首字母一般大写,以和其他的标识符区分开。{}内部是类所包含的成员变量和成员函数,它们统称为类的成员。public 也是 C++ 的新增关键字,它只能用在类的定义中,表示访问权限,下面这里介绍两种访问性:
public:公开访问性,代表这个成员能在类的定义以外的地方被使用
private:私有访问性,代表这个成员只能在类的定义内被使用
例如:
class Test
{
public:
int a;
private:
int b;
};
int main()
{
Test t;
t.a = 10; // 访问公有成员变量,正确
t.b = 10; // 访问私有成员变量,错误
}
三 . 类的定义:
如果说声明是书的目录,那么定义就是目录所指的具体内容。
类的定义,指的是根据声明具体实现类的功能,与一般的函数定义很相似。比如:
class Test
{
public:
int a;
void PubFun();
private:
int b;
void PriFun();
}; // 声明一个带有两个成员函数的类
void Test::PubFun() //定义公有的那个成员函数
{
a = 10;
b = 10; // b 是私有成员变量,只能在成员函数的定义中访问
}
void Test::PriFun() //定义私有的那个成员函数
{
a = 20;
b = 20; // b是私有成员变量,只能在成员函数的定义中访问
}
定义普通函数与类成员函数只有一点不同:在函数名前面加了类名::的前缀。其中的::称之为作用域运算符,它指明了成员函数所属的类。
不管是 public 成员函数,还是 private 成员函数,定义它们的方式都是相同的。
四 . 对象:
类只是一种形式化的定义,要使用类提供的功能,必须使用类的实例,即对象,一个类可以定义多个对象,而对象要占据一定的内存空间。类和对象的关系就像整形和变量的关系。
每个对象都包含类中定义的各个数据成员的存储空间,共享类中定义的成员函数。对象的创建方法与声明一个普通变量相同,也采用类型名 变量名的格式。
class Test
{
//此处省略 Test 类成员
};
int main()
{
int a; // 声明一个普通变量
Test t1; // 创建一个类的对象
}
五 . 对象访问类的成员:
通过对象也可以访问一个类的成员,通过.成员运算符,格式是对象名.成员名。
如果是数据成员,就可以对它进行赋值,如果是函数成员,就可以调用它。我们可以将其看做为一般变量,只是在变量名前面多了代表它所属对象的前缀。
例如:
#include <iostream>
using namespace std;
class Test
{
public: // 两个公有成员
int a;
void Hello();
};
void Test::Hello() // 定义 Test 类的公有函数
{
cout<<"Hello "<<a<<endl;
}
int main()
{
Test t1;
t1.a = 10; // 给 t1 对象的数据成员 a 赋值
t1.Hello(); // 调用 t1 对象的成员函数 hello
}
输出结果为:Hello 10
六、构造函数与析构函数
构造函数、析构函数与赋值函数是每个类最基本的函数。他们太普通以致让人容易麻痹大意,其实这些貌似简单的函数在使用时要特别注意以免造成不必要资源浪费和产生意想不到的错误。
每个类只有一个析构函数和一个赋值函数,但是可以有多个构造函数(包含一个拷贝构造函数,其他的成为普通构造函数)。
一 . 构造函数
所谓构造函数,就是在对象构造的时候调用的函数。构造函数是一种特殊的成员函数,它主要用于为对象分配空间,进行初始化。
构造函数在定义类对象时自动调用,不需用户调用,也不能被用户调用。在对象使用前调用。如果类中没有定义构造函数,系统则会自动给出一个无参构造函数。
构造函数没有返回值,函数名必须与类名一致,一个类可以有多个构造函数,但是参数必须有差别(也就是所谓的重载)。
例如:
class Test
{
public:
Test(); // 无参数的构造函数
Test(int a); // 有一个 int 参数的构造函数
private:
Test(int a,int b); // 私有的两个参数的构造函数
};
Test::Test()
{ /* 此处省略一些初始化的工作 */}
Test::Test(int a)
{ /* …… */}
Test::Test(int a,int b)
{ /* …… */}
构造函数也会受访问性影响,在不同的作用范围,能调用的构造函数也会不同。
初始化成员
构造函数的一个重要任务就是给成员初始化,初始化成员有两种办法,一种是手动给成员赋值,另一种是使用初始化列表。这里介绍第二种,格式为:
类名::构造函数名(参数表): (成员初始化表){ 构造函数体 }
构造函数中的初始化列表只需要在参数列表的后面加一个冒号(:),然后将要初始化的成员按照成员名(参数)的格式排列在后面,个成员之间用逗号隔开。
例如:
class Test
{
public:
int A;
int B;
Test(int a);
};
Test::Test(int a)
:A(a),B(10) //给成员变量 A、B 初始化,不一定要和参数列表写在一行
{ /* …… */ }
其中成员的初始化顺序不是按照初始化列表中的顺序来的,而是按照成员声明的顺序来的,例如:
/* Test类的声明同上 */
Test::Test(int a)
:B(10),A(a) // 虽然 B 在前面,但还是 A 先初始化
{/* …… */}
Test::Test(int a)
:B(a),A(B)
//此处A的初始化依赖了B,然而是A先初始化,这就导致A得到了B中还没初始化的错误内容
{/* …… */}
二 . 析构函数:
析构函数是一种特殊的成员函数,它会在每次删除所创建的对象时执行。它执行与构造函数相反的操作,通常用于撤消对象时的一些清理任务,有助于在跳出程序(比如关闭文件、释放内存等)前释放资源。
析构函数的名称与类的名称是完全相同的,只是在前面加了个波浪号(~)作为前缀,它不会返回任何值,也不能带有任何参数。格式如下:
类名::~析构函数名(){}
class Test
{
public:
~Test(); // 析构函数
};
Test::~Test()
{/* 一些收尾的工作 */}
三 . 构造函数与析构函数的调用:
构造函数不能直接调用,只能通过声明一个对象或者使用new 运算符动态创建对象时由系统自动调用。
例如:
class Test
{
public:
int A;
Test();
Test(int a);
};
/* 此处省略定义构造函数部分 */
int main()
{
Test t; // 调用无参构造函数
Test t2(10); // 调用带参构造函数
Test t3 = Test(10); // 同上
Test *t = new Test; // 动态创建对象,调用无参构造函数
Test *t2 = new Test(10); // 动态创建对象,调用带参构造函数
}
而析构函数则不同,它能够通过对象主动调用,并在以下两种情况下它会自动调用:
-
若一个对象被定义在一个函数体内,当这个函数结束时(声明的变量的生命周期结束)会自动调用。
-
若一个对象是使用 new 运算符动态创建,在使用 delete 释放时会自动调用。
例如:
/* Test类的声明接上 */
Test::~Test() // 修改一下析构函数,让它打印一条消息
{
cout << "Test的析构函数被调用" << endl;
}
int main()
{
cout << "p1" << endl;
{
Test t1(1); // t1 的生命周期就只在这个大括号内
} //因此在这个位置 t1 的析构函数就会被调用
cout<<"p2"<<endl;
Test *t2 = new Test(10);
delete t2; // t2 所指对象的析构函数在此被调用
cout<<"p3"<<endl;
{
Test *t3 = new Test;
} // t3 所指对象的析构函数并不会被调用,因为没有使用 delete 运算符
}
//输出结果为:
p1
Test的析构函数被调用
p2
Test的析构函数被调用
p3
上述代码中 t1 对象的析构函数调用的位置有点微妙,它是在代码离开大括号 }
的那瞬间的位置被调用的,因为一个变量只在直接包含它的那层大括号的范围内存活
七、对象数组
对象数组
数组对象就是大批量实例化对象的一种方法,以往我们都是这样:Student stu实例化对象,如果有好几百个对象应该怎么办?
这时候就用到了对象数组,顾名思义,就是把所有要实例化的对象都放到一个组里面,然后直接实例化这个组,就像这样:Student stu[100],便可一次性实例化100个对象。
对象数组与一般的数组基本一致,只是多了两个过程:
在数组创建的时候对数组的每一个元素都调用了构造函数;
在数组生命结束的时候对数组的每一个元素都调用了析构函数。
如果使用 new 运算符来动态创建对象数组,也是同样的过程。
注意:在创建数组时如果不使用列表初始化语法对数组中的每一个元素调用构造函数,那么默认调用无参数的构造函数,因此也就要求这个类必须要有无参数的构造函数
class Test1
{
public:
Test1();
~Test1();
};
Test1::Test1()
{
cout << "Test1的构造函数" <<endl;
}
Test1::~Test1()
{
cout << "Test1的析构函数" <<endl;
}
class Test2
{
public:
Test2(int a); //没有无参数的构造函数
~Test2();
};
Test2::Test2(int a)
{
cout << "Test2的构造函数" <<endl;
}
Test2::~Test2()
{
cout << "Test2的析构函数" <<endl;
}
int main()
{
Test1 ts1[2]; // Test1 有无参构造函数,OK
Test2 ts2[2]; // 错误,Test2 没有无参构造函数
Test2 ts3[2]={Test2(10)};
//这个也错误,因为只对第一个元素调用了构造函数,第二个还是会主动调用无参构造函数
Test2 ts4[2]={Test2(10),Test2(20)}; // 正确
}
如果删除那两行错误的声明,那么输出结果为:
Test1的构造函数
Test1的构造函数
Test2的构造函数
Test2的构造函数
Test2的析构函数
Test2的析构函数
Test1的析构函数
Test1的析构函数
为了方便查看,在输出结果中间空了一行,上面是创建数组时对元素调用构造函数产生的输出,下面是数组死亡时对每一个元素调用析构函数产生的输出
八、静态成员
对象的内存中包含了成员变量,不同的对象占用不同的内存,这使得不同对象的成员变量相互独立,它们的值不受其他对象的影响。例如有两个相同类型的对象 a、b,它们都有一个成员变量 name,那么修改 a 对象的 name 值不会影响 b 中的 name 值。
可是有时候我们希望在多个对象之间共享数据,即对象 a 改变了某份数据后对象 b 可以检测到。共享数据的典型使用场景是计数。在 C++ 中,我们可以使用静态成员变量来实现多个对象共享数据的目标。
静态成员
静态成员变量是一种特殊的成员变量,它用关键字 static 来修饰。当我们声明类的成员为静态时,这意味着无论创建多少个类的对象,静态成员都只有一个副本,对它做出修改时所有对象都是可见的。
静态成员在类的所有对象中是共享的。声明一个静态成员与声明一个非静态成员(也叫实例成员)基本一致,只需要在声明的最前面加上一个 static 关键字即可。如果不存在其他的初始化语句,在创建第一个对象时,所有的静态数据都会被初始化为零。例如:
class Test
{
public:
int A; // 实例成员
static int B; // 静态成员变量
static void Fun1(); // 静态成员函数
};
注意:静态成员也是有访问性的。
定义分成两部分,一是静态变量的初始化,二是静态函数的定义。
静态变量的初始化不能在类的定义中,但是可以在类的外部通过使用范围解析运算符::
来重新声明静态变量从而对它进行初始化。而定义静态函数,那就与定义实例函数一样了。例如:
class Test
{
public:
static string HelloStr;
static void Hello();
void World();
};
string Test::HelloStr = "Hello"; // 静态变量初始化
void Test::World() // 定义实例成员函数
{
cout<<"World"<<endl;
}
void Test::Hello() // 定义静态函数,与定义 World 函数形式一样
{
cout<<"Hello"<<endl;
}
访问静态成员
静态成员的访问有以下两种方法:
-
使用
类型名::静态成员
格式访问; -
通过对象访问。
第一种访问方式可以将其看做是一个全局变量,只不过变量名要带上类型名::
的前缀;第二种可以将其看做是对象中的一个实例成员。
例如:
/* Test类的定义同上文 */
int main()
{
cout << Test::HelloStr << endl; // 通过作用域运算符访问
Test::HelloStr = "World"; // 修改静态变量 HelloStr
Test t1;
cout << t1.HelloStr <<endl; // 通过对象访问
Test::Hello(); // 通过作用域运算符访问
t1.World(); // 通过对象访问
}
//输出结果为:
Hello
World
Hello
World
九、对象与函数
一、类对象作为函数形参
类对象作为形参,本质上与基本类型作为形参并无区别。但是考虑到普通类型作为形参,使用的是值传递,也就是将实参值拷贝一份给形参。如果是类对象的话,此时将会调用一个拷贝构造函数。也就是以实参为参数拷贝构造形参。 如果类对象本身特别复杂,这个拷贝过程显然也会更加耗时,如此便会降低程序运行的效率。
class T{
public:
T(){}
T(const T&rhs){}
};
void f(T x){}
int main(){
T a;
f(a); //此处会发生一个拷贝构造,也就是用a去拷贝构造x
return 0;
}
出于效率的考虑,一般对于类类型的形参,一般会使用引用类型。如下:
void f(T& x){} //形参是T&类型,而不是普通的T类型
另一方面,出于程序健壮性、可读性的考虑,如果f函数并没有改变形参的内容,则还会给形参加上一个 const 修饰。如下:
void f(const T&x){}
二、对象作为函数返回值:
类对象作为函数返回类型,与基本类型并无不同。而且此处无需考虑效率问题,因为没办法考虑。 如果一定要使用类对象作为参数,出于效率考虑,可以将形参类型设置为类的引用。但是如果函数一定要返回类对象,此时是不能直接使用引用类型的。 考虑加法操作:
Int& add(const Int&a,const Int&b)
{
Int c = a + b;//假定这个加号操作是可以执行的
return c;
}
这个函数是错误的,因为其返回的是c的引用。但是c是一个局部变量,当add函数结束后,c就被销毁了。主函数只能得到一个“悬空的”引用,逻辑上是无意义的。 因此,是否返回引用,必须出于函数功能的考虑,而不能出于函数的效率考虑。一般而言,要使返回的引用有意义,需要返回全局变量的引用,或者返回形参的引用(形参本身也必须是引用类型)。如下
Int& min(const Int&a,const Int&b)
{
if ( a < b ) return a;//假定这个小于号是可以执行的
return b;
}
这个函数就是返回的引用,而且逻辑上也是正确的。当然,出于健壮性和可读性的考虑,这里所有的引用都应该使用 const 修饰。
三、类对象作为输出参数
上面提到过,类对象作为参数可以使用引用提高效率,但是类对象作为函数返回值,则无法保证一定可以使用引用。不使用引用直接返回,效率上就会有所影响,特别是类本身非常复杂的情况下。 有另外一种办法,既可以提高效率,又能够“返回”类对象。这就是使用输出参数。 以 add 函数为例,普通的 add 函数声明如下:
Int add(const Int&lhs,const Int&rhs);
完成 2 个 Int 的加法操作,因此参数数量为 2,返回类型也是 Int。 而使用输出参数的 add 函数则拥有 3 个参数,而且类型是 void。如下:
void add(const Int&lhs,const Int&rhs,Int&ans);
其中第 3 个参数实际上保存的是加法的结果,因此称之为输出参数。这在 C 语言和 C++ 中也是一种常用的编程方法。 同时这里也显示了 const 作为修饰符在代码可读性方面的作用。合理的使用 const 修饰,可以令用户非常清楚的了解个参数的用途。 例如,C 语言中字符串拷贝函数的声明如下:
char* strcpy(char *dest,const char *src);
有了 const,可以非常清楚的看到第二个参数代表源字符串,第一个参数代表目的。 特别要注意一点,不要以为使用参数名进行标识就万事大吉。因为在 C、C++ 以及 Java 中,函数(方法)声明都是不考虑形参名的。也就是形参名本质上不影响函数原型
十、动态内存分配
所谓动态内存分配就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。 动态内存分配不像数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。
首先外我们要知道,c++中用户存储区空间分为三部分:程序区(代码区)、静态存储区(数据区)和动态存储区(栈区和堆区)。代码区存放程序代码,程序运行前就可分配存储空间。数据区存放常量、静态变量、全局变量等。栈区存放局部变量、函数参数、函数返回值和临时变量等。堆区是程序空间中存在的一些空闲存储单元,这些空闲存储单元组成堆。在堆中创建的数据对象称为堆对象。当创建对象时,堆中的一些存储单元从未分配状态变为已分配状态;当删除所创建的堆对象时, 这些存储单元从已分配状态又变为未分配状态。当堆对象不再使用时,应予以删除,回收其所占用的动态内存。
在C中使用运算符new和delete来实现在堆内存区中进行数据的动态分配和释放。
运算符new:
在C++中,new的功能是实现内存的动态分配,在程序运行过程中申请和释放的存储单元称为堆对象。申请和释放的过程称为建立和删除堆对象。
使用方式:
指针变量=new 数据类型名;
指针变量=new 数据类型名(初值列表);
指针变量=new 数据类型名[元素个数];
例如:
int *p;
float *p1;
p=new int(100);//让p指向一个类型位整型的堆地址,该地址存放数值100
p1=new foat;//让p1指向一个类型为实型的堆地址
用new创建堆对象的格式:类名 *指针名=new 类名([构造函数参数]);
例如:Complex *p=new Complex(1,2);//创建对象*p,并调用构造函数初始化数据成员real,imag为1、2;
注意:类名后面是否带参数取决于类的构造函数,如果构造函数带参数,则new后面的类名需要带参数,反之则不带.
小细节:
1.new返回一个指定合法数据类型内存空间的首地址(指针),若分配不成功则返回一个空指针NULL
2.new可以为数组动态分配内存空间,这时应该在类型名后面指明数组大小,其中,元素个数是一个整型数值,可以是常数也可以是变量。指针类型应与数组类型一致。
int n,*p;
cin>>n;
p=new int[n];
//表示new为有n个元素的整型数组分配内存空间,并将首地址赋给指针p;
3.new不能对动态分配的数组存储区进行初始化。
int *p;
p=new int[10](0);//错误
4.用new分配的空间只能用delete释放,否则将造成内存泄漏!!!
运算符delete:
delete用来释放动态变量或动态数组所占的内存空间
(1)delete 指针变量名;
例如:int *p=new int;
delete p;
(2)delete [ ]指针变量名;
例如: int *p=new int[10];
delete [ ]p;
小细节:
1.new和delete要配套使用,如果搭配错将会导致严重后果.
2.delete释放时必须保证该指针所指的空间是用new申请的并且只能释放一次,否则将产生指针悬挂问题.
3.动态创建对象时会自动调用构造函数,同样在删除对象时会自动调用析构函数.
十一、继承
继承是使代码可以复用的重要手段,也是面向对象程序设计的核心思想之一。简单的说,继承是指一个对象直接使用另一对象的属性和方法。
C++ 中的继承关系就好比现实生活中的父子关系,继承一笔财产比白手起家要容易得多,原始类称为基类,继承类称为派生类,基类是对派生类的抽象,派生类是对基类的具体化。它们是类似于父亲和儿子的关系,所以也分别叫父类和子类。而子类又可以当成父类,被另外的类继承。
继承方式
不同的继承方式决定了基类成员在派生类中的访问属性,主要体现在:
-
派生类成员对基类成员的访问权限;
-
通过派生类对象对基类成员的访问权限。
对于派生类的成员或者派生类对象访问自己类的成员不讨论,跟一般类一样,下面只讨论对基类的成员的访问。
-
公有继承:基类的 public 和 protected 成员访问属性在派生类中保持不变;基类的 private 成员不可直接访问。
-
保护继承:基类的 public 和 protected 成员都以 protected 身份出现在派生类中;基类的 private 成员不可直接访问。
-
私有继承:基类的 public 和 protected 成员,都以 private 身份出现在派生类中;基类的 private 成员不可直接访问。
可以看出无论采用何种继承方式得到的派生类,派生类成员及其友元都不能访问基类的私有成员。且一般情况,保护继承与私有继承在实际编程中极少使用,它们只在技术理论上有意义。
公有继承
公有继承是访问性最高的一种继承,在子类中能完整延续父类成员的访问性,而且对外可见。如果要公有继承一个类,只需继承时在类名前面加上 public 关键字即可。
在公有继承中,派生类成员可以访问继承的基类的 public 部分与 protected 部分,但是不能访问 private 部分,只有基类成员以及基类的友元可以访问 private 部分。
例如:
class Base
{
public:
int A;
};
class D1 : public Base // 公有继承 Base 类
{
/* …… */
};
int main()
{
D1 d;
d.A = 10; // 访问 D1 的基类 Base 中的 A 成员,因为是公有继承,所以没问题
}
保护继承
保护继承相对于公有继承,访问性有所降低,父类的公有成员在子类中变成了保护成员,也就无法在外部通过一个对象访问父类成员了,但是对于这个子类的子类仍然是可见的(因为可见性只是降到了 protected )。
如果要保护继承一个类,只需继承时在类名前面加上 protected 关键字即可。
例如:
class Base
{
public:
int A;
};
class D1 : protected Base // 保护继承 Base 类
{
/* …… */
};
int main()
{
D1 d;
d.A = 10; // 尝试访问 D1 的基类 Base 中的 A 成员,但是由于是保护继承,所以这样做是错误的。
}
在保护继承中如果想通过子类访问父类的成员,只能在子类中增加一些 get 、set 函数来实现了。
/* Base类的定义同上 */
class D1 : protected Base
{
public:
void SetA(int a); // 设置 Base 类中 A 的值
int GetA(); // 获取 Base 类中 A 的值
};
void D1::SetA(int a)
{
A = a;
}
int D1::GetA()
{
return A;
}
int main()
{
Student st;
st.SetA(10); // 将 Base 类的 A 成员设置为 10
}
私有继承
私有继承在保护继承的基础上更进一步,访问性进一步降低,父类中的公有成员和保护成员的访问性均降到了私有 private,不仅对外不可见,对这个类的子类也不可见了。
要私有继承一个类,只需继承时在类名前面加上 private 关键字即可。
例如:
/* 继承关系:Base->D1->D2 */
class Base
{
public:
int A;
};
class D1 : private Base //私有继承Base类
{
public:
F1();
};
void D1::F1()
{
A = 10; //父类的成员A可以看做D1类的私有成员,在D1类中访问A是可行的
}
class D2 : public D1 //公有继承 D1
{
public:
F2();
};
void D2::F2()
{
A = 10; //这里就不行了,因为 D1类私有继承了Base类,所以Base类的A成员对D2类就是不可见的。
}
同样,如果想在某个类的外部或者它的子类中访问它私有继承的基类的成员,那也只能在这个类中增加 get、set 方法了。
例如:
/* Base类的定义同上 */
/* 继承关系:Base->D1->D2 */
class D1 : private Base
{
public:
void SetA(int a);//设置Base类中A的值
int GetA(); //获取Base类中A的值
};
void D1::SetA(int a)
{
A = a;
}
int D1::GetA()
{
return A;
}
class D2 : public D1 //公有继承D1类
{
public:
void F2();
}
void D2::F2()
{
SetA(10); //调用D1类的SetA公有方法设置Base类A的值
}
多继承
C++ 语言支持一个子类同时继承多个父类,就像单继承时一样,继承多个父类也就相当于同时有了多个父类的公有成员和保护成员,而且可以单独为每一个父类指定继承的方式。
因此多继承的优点说可以使一个类实现多个接口,而缺点使容易造成混淆。
如果要继承多个类,只需将父类的类名依次写在子类类名的冒号(:
)后面,基类名之间用逗号(,
)隔开,每一个基类名前面带上它的访问性关键字。即多继承声明语法如下:
class 派生类名 : 访问控制 基类名1, 访问控制 基类名2, ...
{
成员变量和成员函数的声明
};
/* 继承关系:BaseA->D,BaseB->D */
class BaseA
{
public:
int A;
};
class BaseB
{
public:
int B;
};
class D:public BaseA,public BaseB//公有继承BaseA BaseB
{
/* 其他成员 */
};
int main()
{
D d;
d.A = 10; // 给来自 BaseA 类的成员 A 赋值
d.B = 10; // 给来自 BaseB 类的成员 B 赋值
}
多继承访问基类成员
多继承访问基类成员大体与单继承一致,但当继承的多个父类中有同名的成员时,要访问其中一个成员就不能简单的只写成员名了,必须使用作用域运算符(::
)来指定是哪一个类的成员。
例如:
/* 继承关系:BaseA->D,BaseB->D */
class BaseA
{
public:
int A;
};
class BaseB
{
public:
int A; //与BaseA的A成员同名了
};
class D : public BaseA , public BaseB //公有继承 BaseA 和 BaseB
{
/* 其他成员 */
};
int main()
{
D d;
d.BaseA::A = 10; //使用作用域运算符,给来自BaseA类的成员A赋值
d.BaseB::A = 10; //使用作用域运算符,给来自BaseB类的成员A赋值
}
十二、类的多态性与虚函数
多态性
在面向对象的方法中,多态性是指向不同对象发送同一个消息,不同对象在接收时会产生不同的行为(方法)。
通俗点说就是可以不用像 C 语言中为了求多种图形的面积而针对不同的图形各设计一个独立名字的函数,在 C++ 中只要设计一个专门用于求面积的函数名即可。这个专门用于求面积的函数名可以作为各种求图形面积的函数名。
这么做的好处在于程序设计者可以省去设立多个函数名对应多个函数的麻烦,使用的时候统一用同一个函数名就可调用具有不同功能的函数。
多态在 C++ 中的实现可以是函数的重载、运算符的重载和虚函数,本实训我们介绍虚函数的使用。
虚函数
我们知道在同一个类中是不能定义两个名字相同、参数个数和类型完全相同的函数,否则就是重复定义。但是在类的继承层次结构中,在不同的层次中可以出现名字相同、参数个数和类型相同而功能不同的函数。这时系统会根据同名覆盖的原则决定调用的对象。
那么有没有一种方法,用同一种调用形式,既能调用派生类又能调用基类的同名函数?即不通过不同的对象名去调用不同派生层次中的同名函数,而是通过指针调用它们,虚函数就是用来解决这个问题的。
虚函数是一种动态的重载方式。虚函数的作用是允许在派生类中重新定义与基类同名的函数,并可以通过基类指针或引用来访问基类和派生类中同名函数。
C++ 中要声明一个成员函数为虚函数,只需要在函数的声明前加上一个关键字 virtual 即可,然后就像对待普通成员函数那样,给它加上定义。
例如:
class Base
{
public:
virtual void VFunc(); // 声明一个虚函数
};
void Base::VFunc()
{
cout << "虚函数" << endl;
}
重写父类虚函数
当一个类继承了一个含有虚函数的类,子类就可以选择是否要对父类的虚函数进行重写。所谓重写,就是覆盖父类中的定义,提供一个自己的定义。当然也可以选择不重写,那么就沿用父类的定义。
要重写一个虚函数,需要增加一条与要重写的函数相同(参数与返回值)的函数声明,然后在声明后面加上说明符 override。
例如:
/* Base类的声明同上 */
class D1 : public Base //继承Base类
{
public:
void VFunc() override; //重写VFunc函数
};
void D1::VFunc()
{
cout <"覆盖父类实现"<<endl;
}
int main()
{
D1 b;
b.VFunc();
}
输出结果为:
覆盖父类实现
在子类中重写虚函数时是可以重新定义访问性的,即使父类中虚函数的访问性为 private,在子类中仍然可以重写为 public。如果子类想要访问被重写的父类的定义,同样使用作用域运算符(::
)即可。
例如:
* Base类的声明同上 */
class D1 : public Base //继承Base类
{
public:
void VFunc() override; //重写VFunc函数
};
void D1::VFunc()
{
Base::VFunc(); //调用父类的定义
cout << "覆盖父类实现" <<endl;
}
int main()
{
D1 b;
b.VFunc();
}
输出结果为:
虚函数
覆盖父类实现
多态性的体现
C++ 允许将一个对象的指针赋值给它的父类指针变量。而当通过父类指针调用一个虚函数时,则会调用子类中最后被重写的那个版本,这样对于同一段通过指针调用某个虚函数的代码,就会因为实际指向的对象不同,而调用不同函数,这就是所谓的多态性。
同理,通过引用调用一个虚函数,也会有这样的效果。
例如:
class Base
{
public:
virtual void Cal(int a,int b);
};
void Base::Cal(int a, int b)
{
cout<<a*b<<endl; //默认是乘法
}
class Add:public Base
{
public:
void Cal(int a,int b) override;
};
void Add::Cal(int a,int b)
{
cout<<a + b<<endl; //实现一个加法
}
class Sub : public Base
{
public:
void Cal(int a,int b) override;
};
void Sub::Cal(int a,int b)
{
cout<<a-b<<endl; //实现一个减法
}
//普通函数
void call(Base *ptr)
{
ptr->Cal(10,10); //通过指针调用虚函数
}
int main()
{
Add ad;
call(&ad);
Sub sb;
call(&sb);
}
输出结果为:
20 0
可以看到,连续两次调用 call 函数,调用的效果有所不同。第一次调用的是对象是 Add,因此实现的是加法,即10+10=20
;而第二次的调用对象是 Sub,实现的则是减法,即10-10=0
。
虽然 C++ 也允许将子类对象直接赋值给父类变量,但是这样做会导致子类被切割成父类对象,丢失了子类的成分,这时调用虚函数,也就不会调用到被子类的重写的版本了。
例如:
/* 类的定义同上 */
void call(Base b) //这里不使用指针
{
b.Cal(10,10);
}
int main()
{
Add ad;
call(ad); //Add子类赋值给Base父类变量
Sub sb;
call(sb); //Sub子类赋值给Base父类变量
}
输出结果为:
100
100
如果子类对象赋值给父类变量,则使用该变量时只能访问子类的父类部分(因为子类含有父类的部分,所以不会有问题)。因此无论哪个对象在调用 Call 函数时都是调用的父类的成员函数,所以输出结果都为100,即10*10=100
。
虚析构函数
如果一个父类的析构函数没有声明成虚函数,那么使用 delete 运算符销毁一个父类指针所指的子类对象时,就只会调用父类的析构函数,子类的析构函数则不会被调用,这样就可能导致子类动态分配的资源无法及时回收,造成资源泄露。
例如:
class Base
{
public:
~Base(); //析构函数不是虚函数
};
Base::~Base()
{
cout << "父类析构函数" << endl;
}
class D:public Base
{
public:
int *Ptr;
D();
~D();
};
D::D():Ptr(new int){} //动态分配一块int类型大小的空间
D::~D()
{
delete Ptr; //回收Ptr所指空间
cout << "子类析构函数" << endl;
}
int main()
{
Base *ptr = new D();
delete ptr; //由于只会调用Base类的析构函数,导致D类中Ptr所指的那块空间没有被释放,造成内存泄露。
}
输出结果为:
父类析构函数
如果将析构函数声明为虚函数,调用它时除了调用子类重写的那个版本,还会沿着继承链向上(父类方向)依次调用父类的析构函数。
对于上面那个例子,如果将 Base 类的析构函数声明成虚函数,即virtual ~Base()
,那么最后得到的输出结果就是:
子类析构函数
父类析构函数
即也就是依次调用了 D 类、Base 类的析构函数。所以,在一般情况下析构函数建议声明成虚函数
纯虚函数
有时在类中将某一成员声明为虚函数,并不是因为基类本身的要求,而是因为派生类的需求,在基类中预留一个函数名,具体功能留给派生类区定义。这种情况下就可以将这个纯虚函数声明为纯虚函数。即纯虚函数的作用是在基类中为其派生类保留一个函数的名字,以便派生类对它进行定义。
纯虚函数就是在声明虚函数时被初始化为0的函数,但它只有名字,不具备函数功能,不能被调用,其一般形式是:virtual 函数类型 函数名(参数列表) = 0
纯虚函数没有函数体。最后的“=0”只是一种形式,告诉编译系统,它是一个纯虚函数,留在派生类中定义,并没有实际意义。
纯虚函数只有在派生类中定义了之后才能被调用。如果在一个类中声明了纯虚函数,而在派生类中没有对该函数定义,则该虚函数在派生类中仍然为纯虚函数。
例如:
class Base
{
public:
virtual void Func() = 0; //声明一个纯虚函数
};
抽象类
含有纯虚函数的类就成为抽象类。抽象类只是一种基本的数据类型,用户需要在这个基础上根据自己的需要定义处各种功能的派生类。
抽象类的作用就是为一个类族提供一个公共接口。抽象类不能定义对象,但是可以定义指向抽象类的指针变量,通过这个指针变量可以实现多态。
例如:
class Base
{
public:
virtual void Func() = 0; //声明一个纯虚函数
};
class D1:public Base {} //什么也不做
class D2:public Base
{
public:
void Func() override; //重写纯虚函数
};
void D2::Func() { /* …… */ }
int main()
{
Base b = Base(); //错误,Base 类是抽象类,不能定义对象。
Base *ptr1 = new D1(); //错误,D1没有重写Base类的Func函数,所以也是抽象类。
Base *ptr2 = new D2(); //正确
}
十三、文件操作
读取文本文件
在C++中,要打开一个文件非常简单,使用文件流即可。 如果该文件是用来输入的,则可以定义一个文件输入流。
ifstream in(文件名);
这里需要注意,文件名要么用全路径名,即目录名加文件名,要么该文件与程序在同一个目录下。 另外要注意的是:打开文件一定要做检查!做检查!做检查!
if(!in){
//这就表明打开文件失败,要转去失败处理。后面的代码完全没用
}
规范的说,编程时如果用到了系统资源,则打开时一定要做检查。打开文件要做检查,打开数据库要做检查,联网要做检查,申请内存要做检查…… 成功打开文件流以后,就如同cin一样,可以进行输入。当然与键盘输入一样,代码与文件内容需要配合,整个程序才能正常运行。
int x;
in>>x;//从文件中读取一个整型
//如果此时文件内容是实数,这个程序的运行与预期可能就不一致
我们可以直接用输入本身来作为判断
if(in>>x){
//这就表示正确的从文件读取了x
}else{
//这表示没有正确的从文件读取x
//不正确的原因可能是文件内容并非一个整型
//也有可能是文件读完了
}
因此判断是否读完了,非常简答,使用循环即可.
while(in>>x){
//文件没读完
}
//到这里,文件就读完了
最后不要忘了关闭文件流。
写文本文件
将数据输出到文件,跟输出到屏幕的操作是完全一样的。只不过一个是用文件输出流,而另一个是用标准输出流。 当然,首先还是要打开一个文件输出流,然后再输出。
ofstream out("文件名");
out<<x<<endl;//x是需要输出的数据
最后不要忘了关闭文件流。
二进制文件的读取
文本文件和二进制文件只是编码不同而已。本质上而言,计算机只能存储保存二进制的整数。所以保存在计算机中的文件,其内容当然也是整数。当你打开一个文件,到底能够看到什么,取决于那个软件以什么样的“眼光”来对待这些整数,也就是所谓的“解码”。 文本文件就是将文件内容按照ASCII码取看待,而二进制文件则是原码,也就是直接读出文件中的二进制整数。 当然,写文件的时候,则会有一个编码的过程。简单的说,当使用文本文件时,向文件里写入一个整数0,此时文件里保存的二进制整数并不是0,而是48。如果使用二进制文件,写入整数0,则文件里保存的二进制整数就是0。 如果考虑字节数量,这个问题会更复杂,但是文件格式的本质就是编码。 一般认为文本文件比较容易操作一些,这主要是因为当你编程操作文本文件时,可以用文本编辑软件如gedit、vim、记事本、写字板等打开文件进行查看,从而检查编程的正确性。
1、读取文件:
#include <iostream>
#include <fstream>
using namespace std;
int main(){
string ch;
int t;
int sum=0;
cin>>ch;
ifstream in(ch);
if(in){
while(in>>t)
sum=sum+t;
}
cout<<sum;
return 0;
}
//1.txt:1 2 3 4 5 6 7 8 9 10
2、写文本文件
#include <fstream>
#include <iostream>
using namespace std;
void f(){
string ch;
int t;
int sum=0;
cin>>ch;
ifstream in(ch);
if(in){
while(in>>t)
sum=sum+t;
}
ofstream ofile("1.txt", ios::out);
ofile <<sum<<endl;
ofile.close();
}
int main(){
f();
ifstream in("1.txt");
int x;
in>>x;
cout<<x<<endl;
return 0;
}
//1.txt:0
//1.in:1 2 3 4 5 6 7 8 9 10
十四、运算符重载与友元函数
所谓重载,就是赋予新的含义。函数重载可以让一个函数名有多种功能,在不同情况下进行不同的操作。运算符重载也是一个道理,同一个运算符可以有不同的功能。本关我们就一起来学习运算符重载的使用。
运算符重载
运算符重载的方法是定义一个重载运算符的函数,在需要执行被重载的运算符时,系统就自动调用该函数,以实现相应的运算。也就是说,运算符重载是通过定义函数实现的。运算符重载实质上是函数的重载。
重载运算符声明方式如普通成员函数一样,只不过他的名字包含关键字 operator,以及紧跟其后的一个 C++ 预定义的操作符。格式如下:
函数类型 operator 运算符名称 (形参表列)
{
// 对运算符的重载处理
}
例如:
class Test{ /* 类声明 */ };
Test operator+(Test t1,Test t2); // 重载 + 运算符,这个运算符要求两个操作数
并不是所有 C++ 中的运算符都可以支持重载,我们也不能创建一个新的运算符出来并且不能改变运算符操作对象的个数。有的运算符只能作为类成员函数被重载,而有的运算符则只能当作普通函数来使用:
-
不能被重载的运算符有:
.
、.*
、::
、?:
、sizeof
-
只能作为类成员函数重载的运算符有:
()
、[]
、->
、=
运算符重载有两种方式:
-
使用外部函数进行运算符重载;
-
使用成员函数进行运算符重载。
运算符重载之外部函数
要调用运算符重载函数,有两种方法,一种是通过函数名调用,即operator+(t1,t2)
,另一种是在使用运算符的时候自动调用,这里介绍第二种。
就如同函数重载的最佳匹配规则,使用一个运算符时,会寻找名为operator<运算符>
,且与当前操作数最佳匹配的那个重载版本进行调用。
例如:
class Test{/* 类声明 */};
class D:public Test {}; //创建一个 Test 类的子类
//外部函数
Test operator+(Test t1,Test t2){/* 一些操作 */}
int main()
{
Test t1,t2;
Test t3 = t1 + t2; //最佳匹配是operator+(Test,Test)
D d1,d2;
D d3 = d1 + d2; //最佳匹配也是operator+(Test,Test)
}
至于运算符重载函数内部怎么实现(定义),那就可以根据需求来了,例如:
class Test
{
public:
int a;
};
Test operator+(Test &t1,Test &t2)
{
//重载加法运算符,实际对Test类中的a成员变量进行加法运算
Test t;
t.a = t1.a + t2.a;
return t;
}
int main()
{
Test t1,t2;
t1.a = 10; t2.a = 20;
cout << (t1 + t2).a <<endl; //调用operator(Test&,Test&)的重载版本
}
输出结果为:
30
注意:在运算符重载函数中也是要考虑类成员访问性的问题的。
运算符重载之成员函数
运算符重载的函数也可以写到某个类中成为一个成员函数,格式与写在外面没有区别,但在参数列表上有一些差异。
成为成员函数的运算符重载函数的参数需要少写一个最左边的参数,而少的这个参数就由当前的对象代替。
例如
class Test
{
public:
int a;
Test operator+(Test& t2); //少了左边的一个参数
};
Test Test::operator+(Test &t2)
{
Test t;
t.a = a + t2.a; //当前对象代替了原本的第一个参数
return t;
}
int main()
{
Test t1,t2;
t1.a = 10; t2.a = 20;
cout << (t1 + t2).a << endl; //调用的是 Test 类成员函数的那个重载版本
}
输出结果为:
30
注意:作为成员函数的运算符重载函数也会受访问性影响。
友元函数
有时候我们希望某个函数能访问一个类的非公有成员,但又不想把它做成这个类的成员函数,这个时候就可以使用友元。
如果要将一个函数变成一个类的友元,只需要在类中函数前加一个 friend 关键字来声明函数即可,并且访问性不受限制。即表现形式为:
friend <返回类型> <函数名> (<参数列表>);
但这个友元函数他不属于该类的成员函数,他是定义在类外的普通函数,只是在类中声明该函数可以直接访问类中的 private 或者 protected 成员。
例如:
class Test{
private:
int a;
protected:
int b;
public:
friend void fun(Test t);//友元函数
};
void fun(Test t)
{
t.a=100;
t.b=2000;
cout<<"a = "<<t.a<< ", b = "<<t.b<<endl;
}
int main()
{
Test test;
fun(test);
return 0;
}
输出结果为:
a = 100, b = 2000
友元类
C++ 中也允许声明一个类为某个类的友元类,方法和声明友元函数旗鼓相当。但是需要注意的是,作为友元的类的声明必须在友元类的声明之前。
例如
class TestFriend
{
private:
int a;
}; // TestFriend 类的声明在 Test 类之前
class Test
{
private:
int a;
friend class TestFriend; // 声明友元类
};
有时候作为友元的类的声明不便放在友元声明之前,这个时候就可以使用前置声明。不过前置声明的类只是一个名字(或者说不完全类型),不能访问它内部的内容。
例如:
class TestFriend; // 前置声明 TestFriend 类,只是一个名字
class Test
{
private:
int a;
friend class TestFriend; // 声明友元类
friend void TestFriend::Func(); // 尝试将 TestFriend 类的成员函数 Func 作为友元函数,但由于 TestFriend 类目前只有前置声明,所以会出错。
};
class TestFriend // TestFriend 类的声明在 Test 类之后
{
private:
int a;
public:
void Func();
};
最后,友元声明还有如下限制:
-
友元关系不能被继承。
-
友元关系是单向的,不具有交换性。若类 B 是类 A 的友元,类 A 不一定是类 B 的友元。
-
友元关系不具有传递性。若类 B 是类 A 的友元,类 C 是 B 的友元,类 C 不一定是类 A 的友元。
转换构造函数
一个构造函数接收一个不同于其类类型的形参,可以视为将其形参转换成类的一个对象,像这样的构造函数称为转换构造函数。因此转换构造函数的作用就是将一个其他类型的数据转换成一个类的对象。
除了创建类对象之外,转换构造函数还为编译器提供了执行隐式类型转换的方法。只要在需要类的类型值的地方,给定构造函数的形参类型的值,就将由编译器执行这种类型的转换。
转换构造函数是构造函数的一个特例,当一个构造函数的参数只有一个,而且是一个其他类型的 const 引用时,它就是一个转换构造函数。
例如
class T1{};
class T2
{
public:
T2(const T1 &t); // 从 T1 转换到 T2 的转换构造函数
};
有了转换构造函数,就可以实现不同类型之间的类型转换了,比如
/* 类定义同上 */
int main()
{
T1 t1;
T2 t2= (T2)t1; // 用类型转换语法,从 T1 转换到 T2
T2 t3(t1); // 或者直接调用转换构造函数
}
注意:转换构造函数只能有一个参数。如果有多个参数,就不是转换构造函数。