Unity的C#编程教程_53_类 Class 详解及应用练习(二)

Class Inheritence

1. Class Inheritence

  • 类的继承,用于创建新的类的时候,使用“现有类”的一些现成的属性。

比如我们设计了一个类,用于存储游戏中所有物品的信息:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class Item
{
    
    public string name;
    public int id;
    public string description;
    public Sprite icon; // 设定道具图标
    
    public Item()
    {

    }

    public Item(string name, int id, string description)
    {
        
        this.name = name;
        this.id = id;
        this.description = description;
    }
    
}

那我们现在想要设计一个类,用于存储所有武器的信息。

武器是物品中的一个分支,所以物品有的属性,武器都有,比如名字,id,价格,描述。

但是武器还有些别的物品没有的属性,比如攻击力,或者还可以有攻击频率,熟练度等。

另外游戏中一般还有消费品,比如补血瓶也是一种道具,但是除了基础属性,还有一些特有属性,比如可以增加生命值等

那对于武器和消耗品,是不是都要单独建立一个新的类呢?

答案是不用,我们可以复用一些 “道具” 的基础属性!

比如我们新建一个武器的类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class Weapons : Item
{

    public int attack;

    public Weapons(string name,int id, string description,int attack) : base(name, id, description)
    {
        this.attack = attack;
    }

}

比如还可以新建一个消耗品类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Consumables : Item
{

    public int addHealth;

    public Consumables(int addHealth)
    {
        this.addHealth = addHealth;
    }
}

然后我们新建一个空的游戏对象 ItemDatabase,挂载上同名脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ItemDatabase : MonoBehaviour
{

    public Item[] items; // 可以建立一系列道具
    public Weapons[] weapons; // 可以建立一系列武器
    public Consumables[] consumables; // 可以建立一个消耗品列表

    public Weapons bigSword; // 可以单独建立一个武器

    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

编译后,进入 Unity 可以看到可以自己修改和定义的道具列表和武器列表,还有一个名字叫做 Big Sword 的武器(具体属性内容为空)。

另外由于我们的消耗品类前面没有添加 [System.Serializable],所以在 unity 中不可见。

可以看到,武器类的道具除了有 attack 的特有属性,也有一般道具的基础属性。

这个继承的方法,可以很好地来拓展我们的道具分类,比如道具下面有武器分支,武器下面还有远程武器分支,远程武器下面还有魔法武器分支,等等。

如果不使用继承,那可以想象,在我们的 Item 类下面需要放超多属性,要涵盖所有门类的道具的所有属性才行!

2. Bank System Inheritance Example

  • 任务说明:
    • 设计一个银行账户的类,包含属性:银行名称,账号,余额
    • 包含方法:查询余额,存钱,取钱
    • 设计一个账户管理程序,管理所有的银行账户
    • 另外还有一个专门的银行账户,用于申请贷款(使用一个可以调用的方法,确定你能否贷款,能贷款多少钱)

首先定义一个银行账户的类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


[System.Serializable]
public class BankAccount
{
    public string bankName; // 所属银行
    public int id; // 账号
    public float money; // 余额

    public void CheckBalance() // 显示余额
    {
        Debug.Log(bankName + id + " has money: " + money);
    }

    public void Withdraw(float moneyOut) // 取钱
    {
        Debug.Log("Withdraw money: " + moneyOut);
        money -= moneyOut;
        Debug.Log(bankName + id + " has money: " + money);
    }

    public void Deposit(float moneyIn) // 存钱
    {
        Debug.Log("Deposit money: " + moneyIn);
        money += moneyIn;
        Debug.Log(bankName + id + " has money: " + money);
    }
}

创建一个脚本,用于控制所有的银行账户:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AccountManager : MonoBehaviour
{
    public BankAccount[] bankAccounts;

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

创建一个空的游戏对象,用作挂载银行账户的控制脚本,这就好比一个可以控制你所有账户的实体终端,类似网银这种。然后把控制脚本挂载到这个空的游戏对象下。

这个时候我们假设有 3 个银行账户,则可以在 unity 的 inspector 中输入 size 为 3,然后分别输入 3 个银行账户的信息。

这里的账户控制脚本是可以复用的,比如现在我假设有一台特定银行的 ATM 机器,那么我创建一个空的游戏对象,并命名为 A Bank ATM,然后同样可以挂载上面的控制脚本,按照实际情况,这里可以设定 size 为 1,然后对应设定银行名字,账号和余额即可。

有了以上的这些账户后,我们就需要对这些账户进行特定操作了,比如我们面对一台 ATM 机器,那应该可以查询余额,存钱和取钱才对。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AccountManager : MonoBehaviour
{
    public BankAccount[] bankAccounts;

    // Start is called before the first frame update
    void Start()
    {
        // 查询余额
        bankAccounts[0].CheckBalance();

        // 存钱 10 元
        bankAccounts[0].Deposit(10);

        // 取钱 5 元
        bankAccounts[0].Withdraw(5);
    }

    // Update is called once per frame
    void Update()
    {

    }
}

这里只做了最基本的演示,如果要更真实,那我们要有特定的按键或者选择菜单,然后还需要有对应金额的输入。

然后我们可以定义一个用于申请贷款的账户,这个账户不但有银行账户的基本功能,还能贷款:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class LoanAccount : BankAccount // 继承基础的银行账户功能
{

    public bool status; // 是否可以贷款
    public float loanApprove; // 可以贷款多少钱
}

在账户管理下面,可以多生成一个贷款账户:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AccountManager : MonoBehaviour
{
    public BankAccount[] bankAccounts; // 普通银行账户
    public LoanAccount loanAccount; // 贷款账户

    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {

    }
}

然后在 Unity 中就可以编辑这个贷款账户的具体信息。

自然,我们可以在这个贷款账户的定义脚本下添加贷款账户的专用方法,比如获取贷款(账户余额增加),偿还贷款(账户余额减少)。

需要注意的是,我们所定义的这些类下面的方法,只有在 MonoBehaviour 下面调用的时候才会运行,不会自动运行!

Protected Data Members

  • 前面我们接触到的大多数是 private 和 public 的对象,其实还有一种叫做 protected 的对象
  • public 对象谁都可以访问并修改
  • private 对象只有类本身的方法可以进行访问和修改
  • 而 protected,介于两者之间,除了本身的类可以访问,继承的子类也可以访问

比如一个银行账户的类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


[System.Serializable]
public class BankAccount
{
    public string bankName; // 所属银行
    public int id; // 账号
    protected float money; // 余额
    // private float money; 如果使用的是 private 则只能在类本身进行访问,子类也不行

    public void CheckBalance() // 显示余额
    {
        Debug.Log(bankName + id + " has money: " + money);
    }

    public void Withdraw(float moneyOut) // 取钱
    {
        Debug.Log("Withdraw money: " + moneyOut);
        money -= moneyOut;
        Debug.Log(bankName + id + " has money: " + money);
    }

    public void Deposit(float moneyIn) // 存钱
    {
        Debug.Log("Deposit money: " + moneyIn);
        money += moneyIn;
        Debug.Log(bankName + id + " has money: " + money);
    }
}

其子类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class LoanAccount : BankAccount // 继承基础的银行账户功能
{

    public bool status; // 是否可以贷款
    public float loanApprove; // 可以贷款多少钱

    
    public void loanMoney()
    {
        money = 100; // 只有在 money 是 public 和 protected 时子类才能访问
        loanApprove = money * 100;
    }
}

不仅仅是变量,类下面的方法也可以设定为 protected,一样的效果。

在实际的游戏中,用于“交互”的信息,通常需要设定为public,比如一个角色的血量,别的角色攻击可以造成扣血,所以需要外部访问。而有的信息不需要交互,比如玩家的动作是奔跑还是行走,这个通常是玩家自己控制而不是外部决定的,所以应该设计为 private 或者 protected。

Virtual Methods and Overriding

  • 除了继承我们自己定义的类,也可以继承 unity 的 MonoBehaviour 这个类
  • 假设我们要设计一个宠物系统
    • 新建一个脚本 Pat 继承于 MonoBehaviour
    • 宠物有名字,会奔跑

首先新建一个脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Pet : MonoBehaviour
{
    public string patName;

    public virtual void Run() // 设定为虚拟方法,子类可以重写
    {
        Debug.Log("I'm running!");
    }

    // Start is called before the first frame update
    void Start()
    {
        Run(); // 调用移动方法
    }

    // Update is called once per frame
    void Update()
    {

    }
}


这个是基础的宠物,我们可以衍生出一只狗:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Dog : Pet
{

    public override void Run() // 重写方法
    {
        Debug.Log("Dog is running!");
    }

}

然后在 unity 中创建一个 cube,假设是个宠物狗,然后挂载上这个脚本

还可以有别的宠物,移动方式不同:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bird : Pet
{

    public override void Run() // 重写方法
    {
        Debug.Log("Bird is flying!");
    }

}

由于最初的 Pet 继承于 MonoBehaviour,所以有 Start 方法可以使用。

这个时候调用了一个 virtual 方法,程序自动会去检查子类有没有重写该方法,没有被重写的则使用父类默认的方法。

这里需要注意,我们也可以把 start 中调用 Run() 的语句放到子类中。另外虽然子类中没有调用方法,但是由于继承于父类,父类继承于 MonoBehaviour,所以子类运行的时候,父类中的 start() 是自动运行的。

另外注意,虚拟方法和重写方法的作用域必须统一,比如虚拟方法是 protected,那么重写方法也必须是 protected

Q and A on Using MonoBehaviour Custom Classes

  • 自定义类的一个常见问题是:什么时候该继承 MonoBehaviour
    • 当我们逻辑功能和行为的时候,需要继承 MonoBehaviour
    • 比如我们设计的宠物系统,宠物需要有一些特定伴随游戏运行需要调用的方法,那宠物就需要继承 MonoBehaviour,这样我们把宠物的脚本挂载到游戏对象上的时候,才能调用对应的方法,产生对应的动作
    • 另外比如游戏中的道具,一般道具不会随着游戏运行而不断变化,而是被玩家使用,所以不需要继承 MonoBehaviour。
    • 还有比如游戏里面的各种敌人,那就应该是继承于 MonoBehaviour 的,因为我们要为其添加各种行为和动作。

Structs, Memory Management, and Value vs. Reference Types

  • 现在 structs 常用于性能增强,或者代替 classes 的作用
    • 一般情况下,如果一个东西的衍生不超过 4 个领域,那可以考虑用 structs 代替类的继承
    • 其实 structs 和 classes 基本差不多,区别在于 structs 不能继承,不能继承即表示不可通过继承来重写方法,或者添加其他东西,也即实现了“不可变性/统一性”
    • 常见的应用场景,比如不同的弹药,属性统一为 3 个:伤害,冷却时间,范围,我们不需要通过继承来为其添加额外的东西

比如道具的案例:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Item // 引用类型 reference type
{
    public string itemName;
    public int itemId;

    public Item(string name, int id)
    {
        this.itemName = name;
        this.itemId = id;
    }

}

public struct Item2 // 值类型 value type
{
    public string itemName;
    public int itemId;

    public Item2(string name, int id)
    {
        this.itemName = name;
        this.itemId = id;
    }
}

public class StructTest : MonoBehaviour
{
    Item sword;
    Item2 spear;

    // Start is called before the first frame update
    void Start()
    {
        sword = new Item("Big Sword", 1);
        spear = new Item2("Small Spear", 2);
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

值类型 value type 和 引用类型 reference type 的概念非常重要:

  • 值类型:占用内存空间(栈 stack),所以访问速度较快
  • 引用类型:可以理解为不包含具体的值(其在堆 heap 中分配内存空间),仅为一个内存地址,所以不占用额外内存,相比值类型有更大的存储规模,较低的访问速度

在 C# 中有垃圾回收机制,不需要过多考虑内存管理,但是上面的概念依然值得了解!

常见的 int,float,long,bool,bytes,char的都是值类型:

    // Start is called before the first frame update
    void Start()
    {
        // value type
        int num = 12; // 这里的 num 就是个值类型,有单独的内存空间
    }

同样,struct 也是值类型。

引用类型常见的是 string 字符串:

    // Start is called before the first frame update
    void Start()
    {
        // value type
        int num = 12; // 这里的 num 就是个值类型,有单独的内存空间

        // reference type
        string myName = "Hello World";
    }

这里并不是在内存中直接存储了 “Hello World”,而是存储了一个地址,指向存储位置。

引用类型还有:arrays,class,delegates。

当我们传递数据的时候,值类型的数据会被复制(增加内存占用),原始存储的值不会被改变。比如我们把一个 int 变量传递到一个函数中。但是引用类型传递到函数中的时候,传递的是内存地址,所以可以修改到原数据。

演示传递的区别:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class Item1 // 引用类型 reference type
{
    public string itemName;
    public int itemId;

    public Item1(string name, int id)
    {
        this.itemName = name;
        this.itemId = id;
    }

}

[System.Serializable]
public struct Item2 // 值类型 value type
{
    public string itemName;
    public int itemId;

    public Item2(string name, int id)
    {
        this.itemName = name;
        this.itemId = id;
    }
}


public class StructTest : MonoBehaviour
{
    public Item1 sword;
    public Item2 spear;

    // Start is called before the first frame update
    void Start()
    {
        //sword = new Item1("s", 1);
        sword.itemName = "Big sword";
        sword.itemId = 1;

        spear = new Item2("Small Spear", 2); // 也可以用上面那种初始化方法

        // 用改名程序验证值类型和引用类型,对于数据传递的区别
        Debug.Log("Before: " + sword.itemName);
        ChangeName(sword); // 调用改名方法
        Debug.Log("After: " + sword.itemName);
        // 可以看到名字改了
        // 因为传递的是地址,所以直接对该地址存储的数据进行了改动

        Debug.Log("Before: " + spear.itemName);
        ChangeName(spear); // 调用改名方法
        Debug.Log("After: " + spear.itemName);
        // 可以看到结果名字不变
        // 因为原来的值被复制了,所以改的是复制的值,原始值不变
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    void ChangeName(Item1 classItem) // 改名方法
    {
        classItem.itemName = "changed name sword";
    }

    void ChangeName(Item2 structItem) // 改名方法
    {
        structItem.itemName = "changed name spear";
    }
}

在 unity 的 console 窗口中可以看到,sword 的名字改了,spear 的名字没改,这就是变量传递的区别。如果我们在改名方法中添加一个语句显示 itemName,可以看到名字变了,相当于在该方法中是一个封闭空间。

这一点非常重要!而且经常会作为面试题!

Tags: