本篇介绍的是 C++ 面向对象编程的基本要点。内容包括类,继承,方法的相关操作。
一. 类
(A) 定义
(a) 类定义
类定义是以关键字 class 开头,后跟类的名称。类的主体是包含在一对花括号中,类定义后必须跟着一个分号或一个声明列表。
例如,我们使用关键字 class 定义 Box 数据类型,如下所示:
1 | class Box |
关键字 public 确定了类成员的访问属性,这里 public 声明了在类对象的作用域内,公共成员在类的外部是可以访问的。您也可以指定类的成员为 private 或 protected,这个在类访问修饰符中讲解。
(b) 定义对象
类提供了对象的蓝图,对象是根据类来创建的。声明类的对象,就像声明基本类型的变量一样。下面的语句声明了类 Box 的两个对象:
1 | Box box1; //声明box1,类型为Box |
对象 box1 和 box2 都有它们各自的数据成员
(c) 类成员函数
类的成员函数是指那些把定义和原型写在类定义内部的函数,就像类定义中的其他变量一样。类成员函数是类的一个成员,它可以操作类的任意对象,可以访问对象中的所有成员。
(1) 定义
成员函数可以定义在类定义内部,或者单独使用范围解析运算符 ::
来定义。在类定义中定义的成员函数把函数声明为内联的,即便没有使用 inline 标识符。所以您可以按照如下方式定义 Volume( ) 函数:
1 | class Box |
您也可以在类的外部使用范围解析运算符 ::
定义该函数,如下所示:
1 | double Box::getVolume(void) |
在这里,需要强调一点,在 ::
运算符之前必须使用类名。调用成员函数是在对象上使用点运算符 (.),这样它就能操作与该对象相关的数据,如下所示:
1 | Box mybox; // 创建一个对象 |
(2) 实例
1 |
|
结果为
1 | Box1 的体积:210 |
(B) 类访问修饰符
数据封装是面向对象编程的一个重要特点,它防止函数直接访问类的内部成员。类成员的访问限制是通过在类主体内部对各个区域标记 public,private,protected 来指定的。关键字 public,private,protected 称为访问修饰符。
一个类可以有多个 public,private,protected 标记区域。每个标记区域在下一个标记区域开始之前或者在遇到类主体结束右括号之前都是有效的。成员和类的默认访问修饰符是 private。
(a) 访问修饰符
公有成员 public
公有成员在程序中类的外部是可访问的。您可以不使用任何成员函数来设置和获取公有变量的值。私有成员 private
私有成员变量或函数在类的外部是不可访问的,甚至是不可查看的。只有类和友元函数可以访问私有成员。
默认情况下,类的所有成员都是私有的。例如在下面的类中,width 是一个私有成员。1
2
3
4
5
6
7
8class Box
{
double width;
public:
double length;
void setWidth(double wid);
double getWidth(void);
};实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using namespace std;
class Box
{
public:
double length;
void setWidth( double wid );
double getWidth( void );
private:
double width;
};
// 成员函数定义
double Box::getWidth(void)
{
return width ;
}
void Box::setWidth( double wid )
{
width = wid;
}
// 程序的主函数
int main( )
{
Box box;
// 不使用成员函数设置长度
box.length = 10.0; // OK: 因为 length 是公有的
cout << "Length of box : " << box.length <<endl;
/* 不使用成员函数设置宽度
box.width = 10.0; Error: 因为 width 是私有的 */
box.setWidth(10.0); // 使用成员函数设置宽度
cout << "Width of box : " << box.getWidth() <<endl;
return 0;
}受保护成员 protected
protected 成员变量或函数与私有成员十分相似,但有一点不同,protected 成员在派生类(即子类)中是可访问的。
例如下面的实例,我们从父类 Box 派生了一个子类 smallBox,在这里 protected 修饰的 width 成员可被派生类 smallBox 的任何成员函数访问。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using namespace std;
class Box
{
protected:
double width;
};
class SmallBox:Box // SmallBox 是派生类
{
public:
void setSmallWidth( double wid );
double getSmallWidth( void );
};
// 子类的成员函数
double SmallBox::getSmallWidth(void)
{
return width ;
}
void SmallBox::setSmallWidth( double wid )
{
width = wid;
}
// 程序的主函数
int main( )
{
SmallBox box;
// 使用成员函数设置宽度
box.setSmallWidth(5.0);
cout << "Width of box : "<< box.getSmallWidth() << endl;
return 0;
}
(b) 访问属性继承类型
有 public,protected,private 三种继承方式,它们相应地改变了基类成员的访问属性。
- public 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:public,protected,private
- protected 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:protected,protected,private
- private 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:private,private,private
下面两条恒成立:
- private 成员只能被本类成员(类内)和友元访问,不能被派生类访问
- protected 成员可以被派生类访问
(c) 友元函数
(1) 概述
类的友元函数是定义在类外部,但有权访问类的所有 private 成员和 protected 成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。
友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元。
(2) 实例
如果要声明函数为一个类的友元,需要在类定义中该函数原型前使用关键字 friend,如下所示:
1 | class Box |
声明 ClassTwo 的所有成员函数作为类 ClassOne 的友元,需要在类 ClassOne 的定义中放置如下声明:
1 | friend class ClassTwo; |
(C) 构造与析构
(a) 构造
(1) 类的构造函数
类的构造函数是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。
构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回 void。构造函数可用于为某些成员变量设置初始值。
下面的实例有助于更好地理解构造函数的概念:
1 |
|
结果为
1 | Object is being created |
(2) 带参数的构造函数
默认的构造函数没有任何参数,但如果需要,构造函数也可以带有参数,这样在创建对象时就会给对象赋初始值。如下面的例子所示:
1 |
|
结果为
1 | Object is being created, length = 10 |
(3) 使用初始化列表来初始化
假设有一个类 C,具有多个字段 X,Y,Z 等需要进行初始化,可使用如下语法:
1 | C::C(double a, double b, double c): X(a), Y(b), Z(c) |
(b) 析构
(1) 类的析构函数
类的析构函数是类的一种特殊的成员函数,它会在每次删除所创建的对象时执行。
析构函数的名字与类的名称是完全相同的,只是在前面加了个波浪号 (~) 作为前缀,它不会返回任何值,也不能带有任何参数。析构函数有助于在跳出程序(如关闭文件,释放内存等)前释放资源。
下面的实例有助于更好地理解析构函数的概念:
1 |
|
结果为
1 | Object is being created |
(D) this 指针
(a) 如何理解
- 关于 this 指针的一个经典回答:
当你进入一个房子后,你可以看见桌子、椅子、地板等,但是房子你是看不到全貌了。
对于一个类的实例来说,你可以看到它的成员函数、成员变量,但是实例本身呢?this 是这样的一个指针,它时时刻刻指向实例本身。 - class 类就好比这座房子,this 就好比一把钥匙,通过钥匙来打开了这座房子的门,那么里面的东西就随意你取用了。
- this 是指向实例对象本身的地址,通过该地址可以访问内部的成员函数和成员变量。
(b) 为什么需要 this
因为 this 的作用域是在类的内部,自己声明一个类的时候,还不知道实例化对象的名字,所以用 this 来使用对象变量的自身。在非静态成员函数中,编译器在编译的时候加上 this 作为隐含形参,通过 this 来访问各个成员(即使你没有写上 this 指针)。
例如 a.fun(1) 等价于 fun(&a,1)
(c) this 的使用
- 在类的非静态成员函数中返回对象的本身的时候,直接用 return *this。这常用于操作符重载和赋值,拷贝等函数。
- 传入函数的形参与成员变量名相同时,例如:this->n=n (不能写成 n=n)。
(d) 实例说明
1 | class Point |
注,
(1) 对象 point1
调用 MovePoint(2 , 2)
的时候,即将 point1
对象的地址传递给了 this
指针。
(2) 编译器编译后的原型应该是 void MovePoint(Point *this, int a, int b)
(3) { x+=a; y+=b;}
在函数体中可以写成 {this->x += a;this->y += b;}
(4) { x+=a; y+=b;}
也等价为 point1.x += a;point1.y += b;
二. 继承
继承指的是子类继承父类的特征和行为,使得子类对象(实例)具有父类的属性和方法。
(A) 定义子类
- 语法为
1 | class derived-class: access-specifier base-class |
其中 derived-class
为子类,access-specifier
是 public,protected,privated
中的一个,base-class
是基类。如果未使用访问修饰符 access-specifier
,则默认为 private
。
- 实例
1 | // 父类(基类) |
(B) 访问控制和继承
子类可以访问父类中所有的非私有成员。因此父类成员如果不想被子类的成员函数访问,则应在父类中声明为 private。
我们可以根据访问权限总结出不同的访问类型,如下所示:
访问 | public | protected | private |
---|---|---|---|
同一个类 | yes | yes | yes |
子类 | yes | yes | no |
外部的类 | yes | no | no |
一个子类继承了所有的父类方法,但下列情况除外:
(1) 父类的构造函数,析构函数和拷贝构造函数;
(2) 父类的重载运算符;
(3) 父类的友元函数。
(C) 访问属性继承类型
- 有 public,protected,private 三种继承方式,它们相应地改变了基类成员的访问属性。
- public 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:public,protected,private
- protected 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:protected,protected,private
- private 继承:基类 public,protected,private 成员的访问属性在派生类中分别变成:private,private,private
- 下面两条恒成立:
- private 成员只能被本类成员(类内)和友元访问,不能被派生类访问
- protected 成员可以被派生类访问
(D) 多继承
多继承即一个子类可以有多个父类,它继承了多个父类的属性和方法。
(a) 语法
C++ 中多继承的语法为:
1 | class 子类名:继承方式1 父类名1, 继承方式2 父类名2,···· |
(b) 实例
1 |
|
(c) 虚继承
(1) 概述
多继承中,从不同路径继承来的同一父类,会在子类中存在多份拷贝。这将存在两个问题:
(1) 浪费存储空间;
(2) 存在二义性。具体来说是将子类对象的地址赋值给父类对象,实现的方式是将父类指针指向继承类(继承类有父类的拷贝)中的父类对象的地址,但是多继承可能存在一个父类的多份拷贝,这就出现了二义性。虚继承可以解决多继承的这两个问题。
虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4 字节)和虚基类表(不占用类对象的存储空间)。这里需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了。
当虚继承的子类被当作父类继承时,虚基类指针也会被继承。虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。在虚继承机制下,不论虚基类在继承体系中出现了多少次,在子类中都只包含一份虚基类的成员。
(2) 语法
1 | class 子类:virtual 基类 |
三. 方法相关操作
(A) 覆盖与重载方法
(a) 覆盖方法
(1) 概述
- 子类可继承父类中的方法,而不需要重新编写相同的方法。但有时子类并不想原封不动地继承父类的方法,而是想做一定的修改,这种情况即是覆盖方法。
- C++ 可以让我们实现这种既有共同特征又需要在不同的类里有不同实现的方法。简单地举个例子说就是,动物都知道用嘴吃,那么吃我们就可以说是动物的一个共同特征(相当于基类里面实现吃的方法),但是我们知道不同的动物会有不同的吃法,这个就是不同的实现方法。
(2) 发生覆盖的条件
- “三同一不低”:子类和父类的方法名称,参数列表,返回类型必须完全相同,而且子类方法的访问修饰符的权限不能比父类低;
- 子类方法不能抛出比父类方法更多的异常。即子类方法所抛出的异常必须和父类方法所抛出的异常一致,或者是其子类,或者什么也不抛出;
- 被覆盖的方法不能是 final 类型的。因为 final 修饰的方法是无法覆盖的;
- 被覆盖的方法不能为 private。否则在其子类中只是新定义了一个方法,并没有对其进行覆盖;
- 被覆盖的方法不能为 static。所以如果父类中的方法为静态的,而子类中的方法不是静态的,但是两个方法除了这一点外其他都满足覆盖条件,那么会发生编译错误,反之亦然。即使父类和子类中的方法都是静态的,并且满足覆盖条件,但是仍然不会发生覆盖,因为静态方法是在编译的时候把静态方法和类的引用类型进行匹配。
(3) 实例
1 |
|
结果为
1 | Animal::eat I'm eatting! |
(b) 重载方法
(1) 概述
- 重载方法指的是在一个类中定义多个同名的方法,但要求每个方法具有不同的参数类型或参数个数。调用重载方法时,编译器能通过检查调用的方法的参数类型和个数选择一个恰当的方法。
- 注,对从基类继承来的方法进行重载,程序永远编译不过的。
(2) 实例
1 |
|
结果为
1 | Animal::eat I'm eatting! |
(c) 小结
方法的覆盖和重载具有以下相同点:
- 都要求方法同名
- 都可以用于抽象方法和非抽象方法之间
方法的覆盖和重载具有以下不同点:
- 方法覆盖要求参数列表必须一致,而方法重载要求参数列表必须不一致
- 方法覆盖要求返回类型必须一致,方法重载对此没有要求
- 方法覆盖只能用于子类覆盖父类的方法,方法重载用于同一个类中的所有方法
- 方法覆盖对方法的访问权限和抛出异常有特殊的要求,而方法重载在这方面没有任何限制
- 父类的一个方法只能被子类覆盖一次,而一个方法可以在所有的类中被重载多次
(B) 静态属性和静态方法
(a) 静态
静态成员是所有对象共享的,所以不能在静态方法里面访问非静态元素,但非静态方法可以访问类的静态成员及非静态成员。
(b) 为什么需要静态属性和静态方法
当我们需要在每次创建对象时进行对象的计数,使用普通变量的话,由于普通变量为局部变量,也就是说,每建立一次对象,对象中的局部变量都会被初始化并重新写入。而有了静态变量,由于静态变量是类中的全局变量而且不可被类外的对象访问,全局变量值在每次对象构建时不会被初始化,而是在原值的基础上进行累计。使用静态属性和静态方法,保证了重要参数的安全性。
面向对象编程技术的一个重要特性是用一个对象把数据和对数据处理的方法封装在一起。实际编程时有时会遇到这样的问题:如果我们所需要的功能或者数据不属于某个特定的对象,而是属于整个类的,该怎么办?
比如我们不妨假设现在需要统计一下有多少只活的动物,那么我们需要一个计数器数量:每诞生一只宠物,就给宠物计数器加 1,每挂掉一只,就减去 1。
我们首先想到的是创建一个全局变量来充当这个计数器,但这么做的后果是程序中的任何代码都可以修改这个计数器,稍不小心就会在程序里留下一个难以查堵的漏洞。
所以坚决不建议在非必要的时候声明全局变量,我们真正需要的是一个只有在创建或删除对象的时候才允许访问的计数器。这个问题必须使用 C++ 的静态属性和静态方法才能完善地得到解决,C++ 允许我们把一个或多个成员声明为属于某个类,而不是仅属于该类的对象。
这么做的好处在于程序员可以在没有创建任何对象的情况下调用有关的方法,而且有关数据仍能在该类的所有对象(静态和非静态皆可)之间共享。
(c) 静态数据成员
静态数据成员的声明
静态数据成员实际上是类域中的全局变量。类中的静态成员数据和函数都只是相当于声明的作用,而不是定义。声明不分配空间,在类里面需要使用 const static 进行数据定义(初始化)。静态数据成员的作用域
静态数据成员被类的所有对象共享,包括该类的子类对象。静态数据成员可以视为类内的全局变量。静态数据成员可以成为成员函数的可选参数,而普通数据成员不可以,举例如下:
1
2
3
4
5
6
7
8
9class cla
{
public :
static int i1; //静态数据成员
int i2; //普通数据成员
void f1(int i = i1);
//OK 允许作为此类内的成员函数的参数进行值传递(因为静态数据成员可看作全局变量)
void f2(int i = i2); //错误 不允许
};静态数据成员的类型可以为本类的类型,而普通数据成员不可以。
(d) 静态成员函数
可以在没有定义任何对象前使用,即无须创建任何对象实例就可以使用此成员函数,举例如下:
1
2
3
4
5
6class cla
{
public:
static void func(); //建立一个静态成员函数
};
cla::func();静态成员函数不可调用类的非静态成员,静态成员函数不包含 this 指针,非静态成员必须与特定对象相对。
(e) 类内外声明静态成员格式
(1) 类内声明静态成员
1 | class Pet |
(2) 类外声明静态变量
静态成员的值对所有的对象是一样的。静态成员可以被初始化,但只能在类体外进行初始化。
静态成员不可在类体内进行赋值,因为它是被所有该类的对象所共享的。你在一个对象里给它赋值,其他对象里的该成员也会发生变化。为了避免混乱,所以不可在类体内进行赋值,真正要为它们分配内存并进行初始化的时候,需要在类外进行声明,格式如下:
1 | 数据类型 类名::静态数据成员名=初值; |
示例:
1 | int Pet::count = 0; |
(3) 类外实现静态成员
静态成员函数在类外实现时候无须加 static 关键字,否则是错误的。若在类外实现上述的那个静态成员函数,是不能加上 static 关键字的,需要这样写:
1 | int Pet::getcount() |
(f) 实例
1 |
|
结果为
1 | 一只小宠物出生啦!名字叫做:wangcai |
(C) 虚函数和虚方法
(a) 虚函数:实现类的多态性
C++ 中的虚函数的作用主要是实现了多态的机制。基类定义虚函数,子类可以重写该函数。在子类中对基类定义的虚函数进行重写时,需要在派生类中声明该方法为虚方法。
当子类重新定义了父类的虚函数后,当父类的指针指向子类对象的地址时,父类指针根据赋给它的不同子类指针,动态地调用子类的该函数,而不是父类的函数。且这样的函数调用发生在运行阶段,而不是发生在编译阶段,称为动态联编。而函数的重载可以认为是多态,只不过是静态的。
注意,非虚函数静态联编,效率要比虚函数高,但是不具备动态联编能力。定义虚函数使用 virtual 关键字,程序将根据引用或指针指向的对象类型来选择方法,否则使用引用类型或指针类型来选择方法。
下面的例子解释动态联编性:
1 | class A{ |
(b) 虚函数的底层实现机制
- 编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针 (vptr),这种数组称为虚函数表 (virtual function table, vtbl)。即,每个类使用一个虚函数表,每个类对象用一个虚表指针。
- 举个例子:基类对象包含一个虚表指针,指向基类中所有虚函数的地址表。子类对象也将包含一个虚表指针,指向子类虚函数表。看下面两种情况:
- 如果子类重写了基类的虚方法,该子类虚函数表将保存重写的虚函数的地址,而不是基类的虚函数地址。
- 如果基类中的虚方法没有在子类中重写,那么子类将继承基类中的虚方法,而且子类中虚函数表将保存基类中未被重写的虚函数地址。注意,如果子类中定义了新的虚方法,则该虚函数的地址也将被添加到子类虚函数表中。
(D) 接口和抽象类
(a) 概述
接口描述了类的行为和功能,而不需要完成类的特定实现。C++ 接口是通过抽象类来实现的。
如果类中至少有一个函数被声明为纯虚函数,则这个类就是抽象类。纯虚函数是通过在声明中使用 “=0” 来指定的,如下所示:1
2
3
4
5
6
7
8
9
10class Box
{
public:
// 纯虚函数
virtual double getVolume() = 0;
private:
double length; // 长度
double breadth; // 宽度
double height; // 高度
};设计抽象类(通常称为 ABC) 的目的,是为了给其他类提供一个可以继承的适当的基类。抽象类不能被用于实例化对象,它只能作为接口使用。如果试图实例化一个抽象类的对象,会导致编译错误。
因此,如果一个 ABC 的子类需要被实例化,则必须实现每个虚函数,这也意味着 C++ 支持使用 ABC 声明接口。如果没有在子类中重写纯虚函数,就尝试实例化该类的对象,会导致编译错误。
可用于实例化对象的类被称为具体类。
(b) 实例
下面的实例中,基类 Shape 提供了一个接口 getArea( ),在两个派生类 Rectangle 和 Triangle 中分别实现了 getArea( )
1 |
|
结果为
1 | Total Rectangle area: 35 |
(E) 多态
(a) 多态定义及构成条件
多态是在不同继承关系的类对象,去调同一函数,产生了不同的行为。
就是说,有一对继承关系的两个类,这两个类里面都有一个函数且名字,参数,返回值均相同,然后我们通过调用函数来实现不同类对象完成不同的事件。
但是构成多态还有两个条件:
- 调用函数的对象必须是指针或者引用
- 被调用的函数必须是虚函数,且完成了虚函数的重写
(b) 实例
以下代码
1 |
|
结果为
1 | Adult need Full Fare! |
注,
- 调用函数就是这里的
fun
,参数int
没有实际意义,就是为了体现函数重写必须要返回值一样,函数名一样及参数一样。 - 被调用的函数必须是虚函数,也就是说必须要在两个产生多态的函数前面加
virtual
关键字。 - 调用函数的形参对象必须是基类对象,这里是因为子类只能给基类赋值,会发生切片操作。基类不能给子类赋值。
- 调用函数的参数必须是指针或引用,因为子类改变了虚表,那么这个虚表就属于子类对象,赋值的时候只会把基类的成员给过去,虚表指针不会给。所以在调用函数的时候会发生语法检查,如果满足多态的条件,就会触发寻找虚表中虚函数地址。如果不满足条件,则会直接用基类对象调用基类函数。
(c) 析构函数重写问题
- 基类中的析构函数如果是虚函数,那么子类的析构函数就重写了基类的析构函数。这里他们的函数名不相同,看起来似乎违背了重写的规则,其实不然。这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,这也说明了基类的析构函数最好写成虚函数。
- 因为基类指针可能指向子类,当 delete 的时候,如果不定为虚函数,系统会直接调用基类的析构函数,这个时候子类就有一部分没有被释放,就会造成可怕的内存泄漏问题。
若定义为虚函数构成多态,那么就会先调用子类的析构函数然后子类的析构函数就会自动调用基类的析构函数,这个结果满足我们的本意。 - 所以,在继承的时候,尽量把基类的析构函数定义为虚函数,这样继承下去的子类的析构函数也会被变成虚函数构成多态。