第十一章 封装/继承/多态

面向对象程序设计(OOP)是一种计算机编程架构,主要目标是为了实现代码的重用性、灵活性和扩展性。面向对象程序设计以对象为核心,程序由一系列对象组成。对象间通过消息传递(一个对象调用了另一个对象的函数)相互通信,来模拟现实世界中不同事物间的关系。

面向对象程序设计有三大特性:封装,继承,多态。还有一种特性就是抽象。

封装的基本体现就是对象,封装使得程序的实现“高内聚、低耦合”的目标。封装就是把数据和数据处理包围起来,对数据的处理只能通过已定义的函数来实现。封装可以隐藏实现细节,使得代码模块化。继承允许我们依据一个类来定义另一个类。当创建一个类时,不需要重新编写新的数据成员和函数成员,只需继承一个已有类即可。这个已有类称为基类(父类),新建的类称为派生类(子类)。派生类就自然拥有了基类的数据成员和函数成员。这样做可以重用代码功能和提高执行效率。多态性是指不同的对象接收到同一个的消息传递(函数调用)之后,所表现出来的动作是各不相同的。多态是构建在封装和继承的基础之上的。多态就是允许不同类的对象对同一函数名的调用后的结果是不一样的(函数体不同)。

我们创建一个怪物的父类,创建Monster.h和Monster.cpp文件,用于该类的声明和实现。以下是Monster.h内容:

#pragma once
#include <iostream>
#include <string>
using namespace std;


// 定义一个怪物类
class Monster {

protected:
	int id;		// ID
	string name;	// 名称
	int attack;		// 攻击值

public:
	
	// 声明默认构造方法
	Monster();

	// 声明有参构造方法
	Monster(int _id, string _name, int _attack);

	// 声明战斗方法
	void battle();
};

以下是Monster.cpp内容:

#include "Monster.h"

// 默认构造方法
Monster::Monster() {
	this->id = 1;
	this->name = "monster";
	this->attack = 0;
}

// 有参构造方法
Monster::Monster(int _id, string _name, int _attack) {
	this->id = _id;
	this->name = _name;
	this->attack = _attack;
}

// 定义战斗方法
void Monster::battle() {
	cout << name << " attack " << attack << " !" << endl;
}

该类主要定义了怪物的攻击属性,因为游戏中的怪物都会具备这样的共性。然后我们再声明和定义蜘蛛类和蛇类,分别继承这个怪物的父类,然后在其各自的战斗函数中实现自己的攻击方式。为了简单方便,我们直接在Monster.h中定义这两个类,代码如下:

// 定义个蜘蛛(继承怪物)类
class Spider : public Monster {};

// 定义个蛇(继承怪物)类
class Snake : public Monster {};

注意,C++默认继承是private,也就是说子类不能访问父类的数据和函数,因此我们这里改用public,这样我们就能访问父类的数据和函数了。另外一点,构造函数是不能被继承的。在我们的主文件ConsoleApplication.cpp中,我们可以这样使用这两个新类:

// 实例化一个Spider对象
Spider spider;
spider.battle();

// 实例化一个Snake对象
Snake snake;
snake.battle();

这样既简化了我们的代码,又能实现对应功能。

构造方法用来初始化类的对象,与父类的其它成员不同,它不能被子类继承(子类可以继承父类所有的成员变量和成员方法,但不继承父类的构造方法)。因此,在创建子类对象时,为了初始化从父类继承来的数据成员,系统需要调用其父类的构造方法。在Monster类的构造函数后,加一个冒号(:),然后加上父类的带参数的构造函数。这样,在子类的构造函数被调用时,系统就会去调用父类的带参数的构造函数去构造对象。

因此我们需要对两个新类再次改动:
 

// 定义个蜘蛛(继承怪物)类
class Spider : public Monster {

public:
	// 调用父类的构造函数
	Spider() : Monster() {}
	Spider(int _id, string _name, int _attack) : Monster(_id, _name,_attack){}
};

// 定义个蛇(继承怪物)类
class Snake : public Monster {

public:
	// 调用父类的构造函数
	Snake() : Monster() {}
	Snake(int _id, string _name, int _attack) : Monster(_id, _name, _attack) {}
};

在我们的主文件ConsoleApplication.cpp中,我们可以使用新类的构造函数了:

// 实例化一个Spider对象
Spider spider2(1, "Spider", 100);
spider2.battle();

// 实例化一个Snake对象
Snake snake2(2, "Snake", 200);
snake2.battle();

函数重写(override):在基类中定义了一个非虚拟函数,然后在派生类中又定义了一个同名同参数同返回类型的函数,这就是重写了。在派生类对象上直接调用这个函数名,只会调用派生类中的那个。例如我们重写父类的战斗方法,先在Monster.h做声明,然后在Monster.cpp文件中完成即可:

// 重写战斗方法
void battle();

// 重写Spider类的战斗方法
void Spider::battle() {

	attack += 100;
	cout << "Spider attack " << attack << "!" << endl;
}

在我们的主文件ConsoleApplication.cpp中,我们可以使用重写函数了:

// 实例化一个Spider对象
Spider spider;
spider.battle();

函数重载(overload):在基类中定义了一个非虚拟函数,然后在派生类中定义一个同名,但是具有不同的参数表的函数,这就是重载。在派生类对象上调用这几个函数时,用不同的参数会调用到不同的函数,有可能会直接调用到基类中的那个。例如我们重写父类的战斗方法,先在Monster.h做声明,然后在Monster.cpp文件中完成即可:

// 重载Spider类的战斗方法
void battle(int _attack);

// 重载Spider类的战斗方法
void Spider::battle(int _attack) {

	cout << "Spider attack " << _attack << "!" << endl;
}

在我们的主文件ConsoleApplication.cpp中,我们可以使用重载函数了:

// 实例化一个Spider对象
Spider spider;
spider.battle(500);

派生类可以访问基类中所有的非私有成员。因此基类成员如果不想被派生类的成员函数访问,则应在基类中声明为 private。多继承即一个子类可以有多个父类,它继承了多个父类的特性。C++中一个派生类中允许有两个及以上的基类,我们称这种情况为多继承。使用多继承可以描述事物之间的组合关系,但是如此一来也可能会增加命名冲突的可能性,冲突可能很有可能发生在基类与基类之间,基类与派生类之间。C#和Java是不允许多继承的!

C++ 多态意味着调用成员函数时,会根据调用函数的对象的类型来执行不同的函数。简单的说,重写是父类与子类之间多态性的体现,而重载是一个类的行为的多态性的体现。C++支持两种多态性:编译时多态性和运行时多态性(也称为静态多态和动态多态)。一般情况下,我们所说的多态都是指的运行时多态,也就是动态多态。

C++编译器在编译的时候,要确定每个对象调用的函数的地址。这种绑定关系是根据对象的数据类型来决定的。如果想要系统在运行时再去确定对象的类型以及正确的调用函数,就要在基类中声明函数时使用virtual关键字,这样的函数我们称为虚函数。我们说多态是在程序进行动态绑定得以实现的,而不是编译时就确定对象的调用方法的静态绑定。程序运行到动态绑定时,通过基类的指针所指向的对象类型,然后调用其相应的方法,即可实现多态。

构成多态还有两个条件:

1. 调用函数的对象必须是指针或者引用。

2. 被调用的函数必须是虚函数,且完成了虚函数的重写。

多态一般的用法,就是用父类的指针指向子类,然后用父类的指针去调用子类中被重写的方法。多态性可以简单的概括为“1个接口,多种方法”,在程序运行的过程中才决定调用的机制程序实现上是这样,通过父类指针调用子类的函数,可以让父类指针有多种形态。

在我们的实例中,我们首先需要在父类Monster中使用virtual来修饰战斗函数,如下所示:

// 声明战斗方法
virtual void battle();

如果父类Monster中的battle方法是一个普通的方法,即没有使用virtual修饰的话,那么虽然spiderPointer和snakePointer里面存储的的确是Spider类和Snake类的指针,但是他们依然只会执行父类Monster的battle方法。只有我们使用virtual修饰父类battle方法后,才能得到我们想要的正确执行结果。

在我们的主文件ConsoleApplication.cpp中,我们可以使用多态了:

// 实例化一个Spider对象
Spider spider3(1, "Spider", 100);
// 实例化一个Snake对象
Snake snake3(2, "Snake", 200);

// 定义怪物对象指针
Monster* spiderPointer = &spider3;
spiderPointer->battle();	// 执行子类函数
Monster* snakePointer = &snake3;
snakePointer->battle();		// 执行父类函数

因为Snake类并没有重写battle函数,因此它只能调用父类的battle函数了。但是Spider类重新了battle函数,在重写的函数中,我们在原来的攻击值上增加了100。

封装性是基础 ,继承性是关键 ,多态性是补充 ,多态性又存在于继承的环境之中 ,所以这三大特征是相互关联的 ,相互补充的。

最后要理解的抽象的特性。面向对象程序设计中一切都是对象,对象都是通过类来描述的,但并不是所有的类都可以来描述对象的。如果一个类没有足够的信息来描述一个具体的对象,而需要其他具体的类来实现它,那么这样的类我们称它为抽象类。比如游戏怪物类Monster,它没有一个具体肖像,只是一个概念,需要一个具体的实体,如一只蜘蛛,一条蛇来对它进行特定的描述,我们才知道它的具体呈现。抽象类就是实现多态的一种机制。它定义了一组抽象的方法,至于这组抽象方法的具体内容由派生类来实现。同时抽象类提供了继承的概念,它的出发点就是为了继承,否则它没有存在的任何意义。

在 Java 中,可以通过两种形式来体现 OOP 的抽象:抽象类(abstract)和接口(interface)。抽象类里的抽象方法是一种特殊的方法:它只有声明,而没有具体的实现。因为抽象类中含有无具体实现的方法,所以不能用抽象类创建对象。但是抽象类不一定必须含有抽象方法。对于一个父类,如果它的某个方法在父类中实现出来没有任何意义,必须根据子类的实际需求来进行不同的实现,那么就可以将这个方法声明为抽象方法。如果一个类继承于一个抽象类,则子类必须实现父类的抽象方法。如果子类没有实现父类的抽象方法,则必须将子类也定义为为抽象类。

接口是用来建立类与类之间的协议,它所提供的只是一种形式,而没有具体的实现。同时实现该接口的实现类必须要实现该接口的所有方法。接口中可以含有变量和方法。但是要注意,接口中的变量必须是静态常量,而方法必须是抽象方法。从这里可以隐约看出接口和抽象类的区别,接口是一种极度抽象的类型,它比抽象类更加"抽象"。可以看出,允许一个类遵循多个特定的接口。如果一个非抽象类遵循了某个接口,就必须实现该接口中的所有方法。对于遵循某个接口的抽象类,可以不实现该接口中的抽象方法。

总结:抽象类是对类抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类的行为进行抽象。抽象类是自下向上抽象而来的,接口是自上向下设计出来的。当我们总结了大部分怪物(狼,蜘蛛,蛇)的属性和行为后,我们就可以把他们共性的内容提取出来作为一个怪物的抽象类。这些具体的怪物中,他们的攻击方式基本上是不同的,但是类似蜘蛛和蛇的怪物可能会附带一些毒性,因此我们可以提供一个接口来提供这个行为的抽象,让具有该特性的怪物来继承实现。

C++语言并没有像Java那样对抽象类和接口有显示的支持。C++中只能使用virtual关键字来声明虚函数。C++语言中也没有抽象类的概念,但是可以通过纯虚函数实现抽象类。如果类中至少有一个函数被声明为纯虚函数,则这个类就是抽象类。抽象类只能用作父类被继承,子类必须实现纯虚函数的具体功能。C++ 接口则是使用抽象类来实现的。该类中没有定义任何的成员变量;所有的成员函数都是公有的纯虚函数;接口就是一种特殊的抽象类。

最后还有讲一个组合的概念。类是一种构造数据类型,在类中可以其他类定义数据成员,这种数据成员称为对象成员。类中包含对象成员的形式,称为组合(Composition)。类之间的组合关系称为has-a关系,是指一个类拥有两一个类的对象。组合不仅仅是一种软件复用技术,更是面向对象设计技术进行客观事物描述的方法。面对复杂的事物,最常见的思维方式是从中划分出相对简单的部分,找出它们之间的联系,把复杂的事物视为简单事物的组合。

含有对象成员的类在调用构造函数对其数据成员进行初始化,其中的对象成员也需要调用其构造函数赋初值,语法如下:

<类名> :: <构造函数名> ([<形参表>]) : [对象成员1](<实参表1>) , [对象成员2](<实参表2>){

       …

}

单冒号之后用逗号分隔的是类中对象成员和传递的实参,称为成员初始化列表。普通的数据成员既可以在构造函数中对其赋值,也可以在成员初始化列表中完成。对象成员只能在初始化列表中初始化,并且对象成员的构造函数的调用先于主类的构造函数。

首先我们构造两个类Sword.h(武器)和Player.h(玩家),代码如下:

#pragma once
#include <iostream>
#include <string>
using namespace std;

// 定义一把剑
class Sword {

protected:
	int id;		// 唯一标示
	string name;	// 名称
	int attack;		// 攻击值

public:
	// 定义默认构造函数
	Sword() {
		
		id = 1;
		name = "Sword";
		attack = 10;
	}

	// 定义有参构造方法
	Sword(int _id, string _name, int _attack) {
		this->id = _id;
		this->name = _name;
		this->attack = _attack;
	}

	// 定义攻击方法
	void battle() {
		cout << name << " Sword attack " << attack << " !" << endl;
	}
};

#pragma once
#include <iostream>
#include <string>
#include "Sword.h"
using namespace std;

// 定义一个玩家
class Player {

protected:
	int id;			// 唯一标示
	string name;		// 名称
	Sword weapon;		// 武器类

public:

	// 默认构造函数,调用武器类的构造函数
	Player() : id(1), name("Player"), weapon() {}

	// 定义有参构造函数,调用武器类的构造函数
	Player(int pid, string pname, int sid, string sname, int sattact) : weapon(sid, sname, sattact) {
		this->id = pid;
		this->name = pname;
	}

	// 战斗函数,调用Sword的战斗函数
	void battle() {
		weapon.battle();
	}
};

然后在我们的主文件ConsoleApplication.cpp中,我们可以使用类的组合了:

// 类的组合使用
Player player(1, "小菜鸟", 1, "木剑", 10);
player.battle();

备注:这是我们游戏开发系列教程的第一个课程,主要是编程语言的基础学习,优先学习C++编程语言,然后进行C#语言,最后才是Java语言,紧接着就是使用C++和DirectX来介绍游戏开发中的一些基础理论知识。我们游戏开发系列教程的第二个课程是Unity游戏引擎的学习。课程中如果有一些错误的地方,请大家留言指正,感激不尽!

上一篇:spider-01


下一篇:Text-to-SQL学习笔记(二)数据集