一、面向对象编程(Object Oriented Programming,OOP)
在序章中我们提到:
如果我们能把程序的每类功能都分门别类,解决某一类问题就用某一类的程序,同类的功能可以从同类中创建一个一样的实例对象来用,并且使用某一类程序的时候有相应的访问权限防止其他程序员错用,然后与该程序功能相似的程序可以通过在该程序的基础上增删查改一部分内容来实现的话,就能大大降低程序员的思维负担。
面向对象编程就是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描述某个事物在整个解决问题的步骤中的行为。
在游戏开发中,我们会发现有大量的事物是具有共性,并且可以划为一类的,比如玩家和敌人,都属于角色人物,都具有移动、攻击等等相同的功能和血量、攻击力等类型相同但值不同的参数。
如果使用面向过程的编程语言制作以上功能,我们就需要给角色写一遍控制移动的函数,给敌人也要写一遍控制移动的函数,同样的功能,就因为作用对象不同,我们可能就需要写几种不同的移动函数出来实现,非常麻烦。
而使用面向对象的编程语言,我们可以先写出玩家和敌人都拥有的功能和属性,也就是角色类的代码,那么玩家类和敌人类的代码就可以继承角色类的代码并且在其基础上增删查改一部分内容实现。这样就能实现代码的复用和减轻程序员的思维负担。
二、C++中的类
C++中的类是从C语言中的结构体进化而来的,在C语言的结构体中我们学到,结构体可以把一些数据封装打包整理到一起,在程序中我们就可以把一些数据打包在结构体中,就可以很方便地对大量的数据进行增删查改的操作。
例如,在写贪吃蛇程序的时候,我们可以把贪吃蛇身上每一个节点的坐标数据存储到结构体中,就可以很方便地对每个节点的坐标信息进行整体赋值,而不需要手动再对一个个参数进行:
position[0][1]=position[1][1]
就像这样:
#include<stdio.h>
struct SnakeNode
{
int position[2];//用以表示蛇节点的x,y轴坐标
};
int main()
{
struct SnakeNode snake[100];
snake[0].position[0] = 1;
snake[0].position[1] = 2;
snake[1] = snake[0];//让蛇的第二个节点坐标等于第一个节点的坐标
printf("snake.position[0]=[%d,%d]\n", snake[0].position[0], snake[0].position[1]);
printf("snake.position[1]=[%d,%d]\n", snake[1].position[0], snake[1].position[1]);
}
输出结果为:
snake.position[0]=[1,2]
snake.position[1]=[1,2]
但是我们可以发现,C语言的结构体作为一种数据结构,只能作为存储信息的单元,C语言的结构体内只能定义成员变量,不能定义“函数”或者说成员方法,自身并不具有操作数据、进行计算的能力,只能由外部的代码去修改它。
而C++中的类相对于C语言的结构体,增加了成员方法(或者说成员函数)和访问权限控制,并且拥有了继承、重写等功能。这意味着,它不仅可以像C语言的结构体一样可以存储数据,还拥有操纵数据、进行计算等能力。
假如我们要制作的游戏里面有玩家和敌人两类角色,我们可以先写出玩家和敌人都拥有的功能和属性,也就是角色类的代码,然后将玩家类和敌人类继承于角色类,这样玩家和敌人就具有了和其父类,即角色类拥有的成员变量和成员方法。
代码如下:
#include<iostream>
//class关键词用于声明类
class Character//玩家类和敌人类共有的父类——角色类
{
private://在此后面定义的方法和变量都是私有的
float blood_;//玩家和敌人都会有的血量值
public://在此后面定义的方法和变量都是公有的
//玩家和敌人类都需要的方法:
float getBlood()
{
return blood_;
}
float setBlood(float blood)
{
blood_ = blood;
return blood_;
}
};
//注意这里是public继承方式
//后面会讲到几种继承方式的区别
class Player :public Character
{
};
class Enemy :public Character
{
};
int main()
{
Character character = Character();
character.setBlood(1.0);
Player player = Player();
player.setBlood(2.0);
Enemy enemy = Enemy();
enemy.setBlood(3.0);
std::cout << character.getBlood() << std::endl;
std::cout << player.getBlood() << std::endl;
std::cout << enemy.getBlood() << std::endl;
}
输出结果为:
1
2
3
在C++中,成员方法和成员变量有三种访问权限,分别为:
(1)public:
在此关键字后声明的成员方法和成员变量是公有的,公有成员不仅可以在类内可以被访问,在类外和其派生类(子类)也是可以被访问的,是类对外提供的可访问接口。
在上述代码中,我们可以看到主函数的代码创建了Character类的一个实例即character之后,便可以通过实例character访问getBlood()和setBlood()成员方法。
(2)protected:
在此关键字后声明的成员方法和成员变量是受保护的,保护成员在类体外同样是隐藏状态,但是对于该类的派生类(子类)来说,相当于公有成员,在派生类中可以被访问。
(3)privated:
在此关键字后声明的成员方法和成员变量是私有的,私有成员仅在类内可以被访问,在类体外是隐藏状态,其派生类(子类)也无法访问。
C++的类中,当我们前面没有声明任何访问权限时,该成员的默认访问权限将会是私有的。
类的继承方式:
(1) public继承方式
class B:public A
- 基类中所有 public 成员在派生类中为 public 属性;
- 基类中所有 protected 成员在派生类中为 protected 属性;
- 基类中所有 private 成员在派生类中不能使用。
(2) protected继承方式
class B:protected A
- 基类中的所有 public 成员在派生类中为 protected 属性;
- 基类中的所有 protected 成员在派生类中为 protected 属性;
- 基类中的所有 private 成员在派生类中不能使用。
(3) private继承方式
class B:private A
- 基类中的所有 public 成员在派生类中均为 private 属性;
- 基类中的所有 protected 成员在派生类中均为 private 属性;
- 基类中的所有 private 成员在派生类中不能使用。
(4)默认继承方式
class B:A
- 和private继承方式相同,即C++默认继承方式为私有继承方式
C++中的多继承
在前面的例子中,派生类都只有一个基类,称为单继承(Single Inheritance)。除此之外,C++也支持多继承(Multiple Inheritance),即一个派生类可以有两个或多个基类。
多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用,后来的 Java、 C#、 PHP 等干脆取消了多继承。
多继承的语法也很简单,将多个基类用逗号隔开即可。例如已声明了类A、类B和类C,那么可以这样来声明派生类D:
class D: public A, private B, protected C
{
//类D新增加的成员
};
三、虚幻引擎中的C++类
我这里使用的是虚幻4.27.2进行示范,虚幻5同理,代码差别不大。
在虚幻引擎中,你需要创建类的话,需要在引擎中点左上角的文件选择创建C++类,才能创建
我们可以看到在虚幻引擎中创建C++类时会要求你选择一个父类,一般来说虚幻引擎中的gameplay层(也就是游戏逻辑代码)中的类,追根溯源一般都继承于UObject类,以UObject为父类创建的子类,都拥有垃圾回收(什么是垃圾回收请自行百度)、反射(什么是反射机制请自行百度)等功能。
关于虚幻引擎中的重要基类的继承关系可以看此图:
这里我以创建了一个Actor的派生类MyActor,在代码编译成功后它会自动打开Visual Studio,我们可以看到刚刚创建好的代码:
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"
UCLASS()
class CPPTESTPROJECT_API AMyActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AMyActor();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
};
我们可以发现虚幻引擎中的UObeject类的派生类在创建之后都会自带UCLASS()和 GENERATED_BODY()的宏标签,带有此标签的类会自带虚幻引擎的垃圾回收和反射功能。
我们还发现虚幻引擎中,AActor的子类都会带前缀A,表示它是Actor的子类,Actor类是一种可以放置在游戏场景中的内。在场景后,我们可以将它在内容浏览器内拖出,摆放到场景中。一般游戏空间中存在的物体,都是Actor类的子类。
类的构造函数和析构函数
在C++中,每一个类默认都会有构造函数和析构函数,他们必须都属于该类的公共成员方法(或者说成员函数),否则你无法创建这个类的实例。如果你没有声明构造函数或析构函数,编译器会自动帮你在编译时添加一个无参的构造函数或析构函数,编译器自己给你的无参构造函数中不会执行任何操作。
在游戏开发中,既然我们已经写好了玩家类和敌人类,那么在游戏开始运行的时候可能就会创建许多玩家或者敌人,就需要用到构造函数,设置好玩家或者敌人的实例的初始信息,才能运行它们。
而析构函数是我们在需要删除这一对象时,处理对象死亡善后工作的函数,在游戏开发中用于删除死亡后的角色实体等等操作。
类的构造函数需要和类同名,而析构函数的名字是在类名前加~
C++中类的构造函数的几种写法
#include <iostream>
using namespace std;
class Student {
public:
int m_age;
int m_score;
float m_money=0;
//类的构造函数可以写多种不同参数的形式,调用时可以选择你想要的调用
// 初始化列表方式,初始化列表
//C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。
//也就是说采用初始化列表的话,构造函数本体实际上不需要有任何操作,因此效率更高。
Student(int age, int score) :
m_age(age),
m_score(score)
{}
// 内部赋值方式
Student(int age, int score,float money)
{
m_age = age;
m_score = score;
m_money=money;
}
};
C++中类的析构函数:
#include<iostream>
class A
{
int a;
A *b;
public:
A()//A的无参构造函数
{
std::cout << "你创建了个A的实例" << std::endl;
}
A(int x)//构造函数可以有多种参数配置,可以选择你想要的方式构造类的实例
{
a = x;
std::cout << a << std::endl;//输出A
b = new A();//让指针b指向一个新创建的A类的一个实例
}
~A()//A的析构函数 注意析构函数都不能带参数
{
delete b;//删除b指针,即释放b指针指向的对象
}
};
int main()
{
A a = A(5);
//调用A的构造方法创建A的实例a,
//你也可以像数组一样创建一组A的实例:
//例如A *a = new A[6](5);
//或A a[2] = {A(5),A(6)};
}
运行结果为:
5
你创建了个A的实例
注意析构函数不能拥有任何形式参数。
在析构函数中我们往往需要完成的工作是手动delete类的成员变量中的指针类型的变量,例如上面这个例子中的char* b,因为指针类型的变量指向的对象可能是在程序运行的过程中使用new关键字动态创建的,就像C语言中的malloc()函数,是在程序运行的过程中动态分配的内存空间装下的这个对象。
C++并没有像java那样的语言拥有垃圾回收机制,能自动删除不用的对象。如果你在程序的运行过程中new了大量的A类的实例,在析构它们的时候没有delete完它们的成员变量中的指针指向的对象,那么那些没被删除的对象会一直占用你的内存,但因为你删除了A类的实例,导致你无法提供A类的实例去访问那些对象并删除他们,他们就会一直占用那块内存,直到程序运行结束或电脑内存用完,这就是C++中常见的内存泄漏问题。
回到我们的虚幻引擎,你会发现虚幻引擎中Actor的子类除了拥有构建函数以外,还新增了
virtual void BeginPlay() override;
BeginPlay()是在游戏关卡开始运行的第一帧(也称加载帧)时会被调用的函数,一般用于设置Actor在场景中的初始位置等等,因为尤其是Actor的位置、朝向等某些信息经常需要在场景世界创建完成后才能设置,否则生成时,场景还没有创建完,Actor就不知道该摆哪个场景去,程序就会出错。
我们发现它还具有virtual关键字,这表示它是一个虚函数,关于虚函数的详解,我们下一次教程中会讲。
我们打开MyActor对应的cpp文件可以看到BeginPlay()的实现。
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyActor.h"
// Sets default values
AMyActor::AMyActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
}
// Called when the game starts or when spawned
void AMyActor::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame
void AMyActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
如果我们在 void AMyActor::BeginPlay()函数增加一行虚幻引擎中打印Hello world的代码如下。
// Called when the game starts or when spawned
void AMyActor::BeginPlay()
{
Super::BeginPlay();
GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Yellow, TEXT("Hello world"));
}
这时,回到虚幻引擎的编辑界面点击编译按编译按钮
等编译完成后把MyActor从内容浏览器中拖入场景
点击运行按钮
我们就可以开始运行时,看到屏幕上打印出了Hello world信息
因为每个Actor的BeginPlay()函数会在关卡开始运行时被调用。于是开始运行后便打印了Hello world
恭喜你写出了你的第一个虚幻C++程序!
参考文献:
【1】C++ Primer Plus(第6版)中文版