跳至正文

C#基础知识(2)

如果说上一章的C#语法很多编程语言都具备,那么C#并不比C语言好出多少。真正让我们开发如此方便的是这门语言的面向对象部分,所谓面向对象,就是尽可能模拟人类的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界、解决现实问题的方法和过程。本教程中,方法和函数这两个名词会混用,因为在C#语境下,他们之间没有任何区别。

这一章我们会把C#的面向对象知识讲完,希望这能解开你们在第一部分对于类和重写函数的疑惑。除此之外,这章的概念也会贯穿整个Mod制作流程,所以掌握对你理解TML和原版代码有着巨大的帮助。

书籍推荐:《C#入门经典》,MSDN


面向对象

比如说,我们把一大团数据抽象成Player(玩家)这个对象,如果你想杀掉玩家就可以直接player.KillMe,从外面看来,你好像就是给了玩家一个指令让它去死,实际上这个函数里面是在操作很多数据。那么这就是面向对象的第一个特点

封装性(Encapsulation):你不知道里面有什么,你只知道调用这个函数就能得到你想要的结果。

再比如说,模板剑是继承于ModItem(Mod物品)的,代表模板剑是一个Mod物品。同理,我们做出来的Buff,NPC,弹幕也都各自继承于ModBuff,ModNPC,ModProjecile。这样我们的物品就具有和ModItem,ModBuff等一样的功能,拥有一样的信息,以及被TML识别的能力。这就是面向对象的第二个特点

继承性(Inheritance):逻辑上讲,这个对象是属于另一个对象的,所以理应继承它的所有信息和功能。

虽然模板剑(SkirtSword)这个类是继承于ModItem的,但是它的功能却跟ModItem不完全一样,如果你不写任何重写函数,那么模板剑的功能就和ModItem一模一样,那么何必有模板剑?重写函数就是这样一个为我们提供自定义的方式。那么这样的继承有什么好处呢?因为TML在读取的时候只会知道你这个物品是ModItem,并不知道具体是哪个物品(他也没法知道啊),于是就只能使用ModItem的功能,这时候就轮到重写函数发威了。

如果这个ModItem其实是具体的某个Mod物品,它的SetDefaults被重写了,那么TML调用的就是被修改过的SetDefaults(即SkirtSword的SetDefaults),而不是ModItemSetDefaults。那么这个其实是面向对象的第三个特点

多态性(Polymorphism):虽然都是对一类对象操作,但是会根据实际执行的对象都不同而有不同(或相同)的操作。

有了这些特性我们就可以很轻松的去把游戏中的物体表示在代码中了,这就是面向对象的威力,接下来我讲介绍C#的面向对象语法是什么样的。


类与对象

由于ModItem属性太多太杂,所以我还是使用一个更简单的模型来做教学吧。

假设我们在写另一个游戏,这个游戏也有物品,怪物,弹幕等等,我们的目标是完成一个物品系统。这个游戏中物品的功能比较单调,只有使用,售出两种功能。每个物品有体积(Volume),密度(Density),每公斤价格(Price)和物品ID这几个基本属性,以及两个方法/函数UseSell。于是接下来我们开始设计物品这个类

当你定义一个类时,你定义了一个数据类型的蓝图。这实际上并没有定义任何的数据,但它定义了类的名称和意味着什么,也就是说,类的对象由什么组成及在这个对象上可执行什么操作。对象是类的实例。构成类的方法字段/属性成为类的成员。

什么是实例呢,就是Item2 item = new Item2()里面的那个item,而Item2是类。也就是说Item2是个类,而Item2生成的东西是对象,这个概念一定要搞清楚。

首先我们要声明这个类,然后把属性加进去,在此之前有个问题,体积密度和价格应该用什么数据类型来存储呢??

public class Item2 {
    // 体积
    public double volume;
    // 密度
    public double density;
    // 单位价格
    public double price;
    // 物品ID
    public int id;
}

我把这个类叫Item2的原因就是防止和泰拉瑞亚原版的Item冲突,你可以把这个类放在任何一个地方,只要确保正确的using了命名空间就可以访问到。

封装性——访问标识符

访问标识符 (Access Specifier)指定了对类及其成员的访问规则。如果没有指定,则使用默认的访问标识符。类的默认访问标识符是 internal,成员的默认访问标识符是 private。 访问标识符正是封装性的体现。

一般地,访问控制修饰符常用的有3个:publicinternalprotectedprivate

public修饰的内容可以在任何地方被访问,包括程序集外部,是最自由的访问修饰符。ModItem就是一个public的class的,它的重写函数,部分属性也是public的,这样你才能在TML之外访问它。

internal修饰的内容与public差不多,但是不能在程序集外部访问,比如ModItem的mod属性在外部是不能写的,但是在TML内部却是可以随便写的。因为如果你在外面乱改mod属性就会出现很奇怪的行为甚至报错,所以TML就把这个属性不对外开放(事实上几乎不会有需要改这个属性的时候)。

protected修饰的内容只能在类或者继承它的子类内部访问。

private修饰的内容是不能在类外面访问的,连继承自己的子类也不行。

而我们的Item类的属性都是在外部可以访问的,体积价格都是外面可以随便访问和改写,这些可能都无所谓,但是ID如果被乱改,那就出事了啊。所以我们最好还是把ID这个属性用private修饰来保护起来。

如果这个属性不可访问,那么VS的自动补全中不会出现这个属性。

public class Item2 {
    // 体积
    public double volume;
    // 密度
    public double density;
    // 单位价格
    public double price;
    // 物品ID
    private int id;
}

方法/函数和字段

既然我们的ID被隐藏了,如果我们想要在外面知道这个物品的ID该怎么办呢?别急,虽然这个属性本身不可在外面访问,者不代表我们真的不能在外面读取它。

以下是一个方法/函数的语法,相信你们应该已经从重写函数/方法中看出来了吧

<修饰符> <返回值> <函数名字> ( <参数列表...> ) {
    // 函数主体代码
}

还记得这个函数吗

void Player.AddBuff(int type, int time1, [bool quiet = true])

首先这个void代表的是这个函数没有返回值,也就是不需要写return XXX;这样的代码。之后的Player.AddBuff说明这个函数是Player类里面的,也就是说,这个方法能修改玩家的属性。

接下来我们会看到int type, int time1这样的格式,也就是说,它告诉了你每个参数是什么样的类型,type和time1必须得是int类型,你不能传入一个float类型。根据两个名字type和time1,我们可以猜出type就是Buff的ID,而time1就是持续时间。

有人可能要问了,那么后面那个[bool quiet = true]是啥意思?这个被中括号围起来参数叫做可选参数,也就是说,你可以不去设置它,从bool我们可以推断出它只能是true或者false,如果你不去设置这个参数,它默认就是true,也就是等于号右边的值。注意,可选参数必须放在所有非可选参数的后面。

构造函数/方法

要想创建一个类,我们还需要一个构造函数。构造函数和普通函数的区别就是构造函数没有(也不能写)返回值,同时方法名称必须和类名一样。比如这样

public Item2() {

}

构造函数的作用是初始化一个类的对象的属性,比如我们刚才定义的那些属性,如果不写就是用C#默认的方法去初始化。

否则我们可以通过构造函数的参数去初始化一些属性:

public Item2(double volume, double density, double price, int id) {
    this.volume = volume;
    this.density = density;
    this.price = price;
    this.id = id;
}

这里的this就是当前的这个对象,this.volume就是当前这个对象的volume属性/字段,this本来是可以省略的,但是这里函数的参数和属性刚好同名了,如果不写this就无法区分了。

于是我们就可以在类里面加方法了

public class Item2 {
    // 体积
    public double volume;
    // 密度
    public double density;
    // 单位价格
    public double price;
    // 物品ID
    private int id;
    // 构造函数
    public Item2(double volume, double density, double price, int id) {
        this.volume = volume;
        this.density = density;
        this.price = price;
        this.id = id;
    }
    // 物品的使用函数
    public void Use() {
    }
    // 物品的出售函数,返回出售了多少钱
    public void Sell() {
    }
}

那么同理,我们要访问id属性可以声明一个这样的方法

public int GetID() {
    // 方法被调用的时候返回这个Item对象的ID
    return id;
}

但是注意哦,虽然看似你好像能访问id了,但是你只能读取不能修改,即使你修改了GetID()返回的值,也不会对这个Item对象的id产生任何影响。

接下来我们开始编写UseSell的主体。

public void Use() {
    Main.NewText($"物品{id}被使用了!");
}
public void Sell() {
    Main.NewText($"物品被卖出了{volume * density * price}元钱");
}

接下来我们就可以测试一下这个类的功能了。首先找一个你喜欢的重写函数(比如Shoot,先确定它能执行),然后写下这样一段代码

// 实例化一个Item2对象,体积10,密度10,单价20,id是1
Item2 item = new Item2(10, 10, 20, 1);
item.Use();
item.Sell();

注意,这里的new Item2(10, 10, 20, 1);就是使用new关键字执行Item2的构造函数,然后实例化了一个Item2的对象,名字是item。你不能直接写Item2.Use(),因为这个函数不是作用于类上的,而是对象。


继承

我们可以把物品细分分为武器(Weapon)和装备(Equipment),而且这两种物品的都有不同的使用方式和价值计算方法,同时可能还有更多的属性。这时候就是继承发挥作用的时候。

相比于物品,武器多了个伤害属性并且价格计算要加上伤害的数值。 所以我们先实现一下这个类的基本结构

public class Weapon : Item2 {
    private int damage;
    public Weapon(double volume, double density, double price, int id, int damage)
        : base(volume, density, price, id) {
        this.damage = damage;
    }
    public int GetDamage() {
        return damage;
    }
}

是不是出乎意料的简单?因为很多属性都已经在Item2里实现了,所以我们没必要再实现一次。注意那个: base(volume, density, price, id)。这句话的作用是调用基类(父类,也就是Item2)的构造函数,也就是说,连构造函数我们都可以使用Item2的,然后我们再加入我们自己的属性的初始化即可。以后看见base就知道它是指的基类(父类)的对象的意思,this是当前这个对象的意思。

虚函数

因为接下来我们需要对物品的价值计算做一些改动,所以我们新建一个方法计算价值,然后在Sell里面调用这个方法来输出卖出价格。为什么这样做呢?因为我们的价格输出格式是不会变的,只会改变计算方式,同时我们还要允许子类更改价格计算方式。为了能让子类进行重写,我们的函数要被标记为虚函数。我们可以用关键字virtual标记这个函数:

protected virtual double GetPrice() {
    return volume * density * price;
}
public void Sell() {
    Main.NewText($"物品被卖出了{GetPrice()}元钱");
}

为什么用protected来修饰呢?因为我们想让这个函数只有在类内才能调用,同时不能是private不然子类就没法继承了。然后子类就可以用相同的方法签名(除了virtual变成override)来重写这个类了。具体是这样

protected override double GetPrice() {
    return base.GetPrice();
}

注意:如果父类的虚函数是public修饰的,那么重写时也应该用public。

同时Use这个函数也要被标记为虚函数,因为使用时子类也会有不同的行为。此时Weapon类变成了这样

public class Weapon : Item2 {
    private int damage;
    public Weapon(double volume, double density, double price, int id, int damage)
        : base(volume, density, price, id) {
        this.damage = damage;
    }
    public int GetDamage() {
        return damage;
    }
    // 重写Item2的Use
    public override void Use() {
        Main.NewText($"武器被使用了!造成了{damage}点伤害");
    }
    // 重写Item2的GetPrice
    protected override double GetPrice() {
        return damage + base.GetPrice();
    }
}

这里的base.GetPrice就是调用了基类的价格计算函数。于是我们尝试运行一下这样的代码:

// 实例化一个Weapon对象,体积10,密度10,单价20,id是1,伤害是100
Weapon weapon = new Weapon(10, 10, 20, 1, 100);
weapon.Use();
weapon.Sell();

可以看到确实是我们想要的效果,但是如果我这么改

// 实例化一个Weapon对象,体积10,密度10,单价20,id是1,伤害是100
Item2 weapon = new Weapon(10, 10, 20, 1, 100);
weapon.Use();
weapon.Sell();

也是一样的结果哦,这就是所谓的多态,因为WeaponItem2的子类,所以WeaponItem2,声明成Item2类型是完全没有问题的。但是由于重写了函数,所以行为是Weapon的行为了。 当然,把子类都当做Item2以后,子类自己独有的一些功能(比如Weapon的GetDamage)就无法使用了。

Equipment类也是用相似的方法去继承Item2,可以留作课后作业。但是我要说的是,无论这个对象是Item2类型,Weapon类型还是Equipment类型,我们都可以把它当做Item2,他们会拥有Item2的所有功能,最终进行的行为会根据具体是哪个类而变化。这就是ModItem等类以及TML的主要工作原理。你可以想象成你看不见的TML后台就是在用ModItem装你的Mod物品类,然后调用相应的重写函数。


属性(Property)

这个东西虽然也叫属性(Property),但是跟物品的属性(Identity)这个属性不是一个东西,为了表述方便以下都称这个东西为 Property

Property是C#提供的一个语法糖,它的作用就是表面上看上去是个字段,但是其实你在调用函数。Property有两个访问器,分别是get(读访问器)和set(写访问器)。顾名思义,get访问器的作用就是读取这个变量,这里跟之前的GetID函数是一样的。而写访问器就是发生在读取这个字段的时候。我们可以把之前的GetID写成这样:

public int ID {
    get { return id; }
}

然后我们可以用item.ID来访问里面的id字段,注意哦,虽然访问语法都是一样的,但是ID却并不存在。如果我们想让ID同时也是可以写的,我们可以用set访问器

public int ID {
    get { return id; }
    set { id = value; }
}

这里的value就是你要给这个变量赋的值,比如item.ID = 233,那么value就是233。它是不需要你去定义的。

属性也是可以重写和虚化的,比如GetPrice

protected virtual double Price {
    get {
        return volume * density * price;
    }
}

重写的方法也是类似的,可以自己去探索。


静态成员

TR中有一些类的属性/方法即使不用实例化这个对象也能使用,也就是说,这个成员不依赖于任何对象,这样的类的成员叫做静态成员

比如说Main.NewText就是一个Main的成员函数

同理字段也是

只要我们把修饰标识符加上static就可以创建一个静态成员,但是要注意静态方法是无法获得类的对象成员的(但是可以获得静态),因为那个样子就不是静态函数了。


结构体(struct)

在C#里面,类(class关键字)所定义的类型都是引用类型,也就是说,所有传值方式都是引用传值,除非显式复制。但是结构体(struct)定义的类型却都是值类型(和基本数据类型一样)。那么什么是引用类型什么是值类型呢?我们可以做一个小测试:

// Item2 此时是class声明的引用传值
Item2 item = new Item2(10, 10, 10, 1);
// 这里是引用传值
Item2 item2 = item;
item2.price = 0;
Main.NewText($"1号物品的价格{item.price}");
Main.NewText($"2号物品的价格{item2.price}");

你觉得应该输出什么?

在我的预料之中,不知道在不在你们的预料之中

因为item2 = item那个语句其实是让item2和item都指向了同一个对象,无论你修改的是哪一个,另一个都会变。

这时你把Item2class变成struct,你就会得到一些错误,因为值类型并没有办法重写,把这些错误排查以后重新运行代码,你就会发现:

这下他们是不同的物品了!

此外,函数传参的时候,值类型struct也会被复制一份到函数里面,而class则是把引用传到函数里面,这样引用类型对象在函数里面被修改了的话,外面的对象也会被修改,但是值类型就不会。如果想要让值类型也被修改,就要用到ref、out关键字了。

我们在Shoot函数里面应该见过ref关键字。在函数里面修改这个值类型,外面的值也会被修改

值类型ref传参:“我对象拿去给你用,怎么整都行”

值类型不带ref传参:“这是我对象的克隆人,你随便搞,反正我对象还是在我这”

out关键字也是差不多的,只不过out关键字函数一定给这个参数赋值

值类型out传参:“我没有对象,但是你必须给我弄一个”

值得注意的是,ref和out关键字修饰的参数在传参的时候必须也加上ref或者out,比如func(ref a, out b);

还有一个很有意思的in关键字传参:“我把我对象给你,你随便看,但要是敢动一下你就死定了”


练习

  1. 为什么Item不是值类型而是引用类型?可不可以是?PlayerNPC呢?
  2. 完成Equipment类,装备比物品多出一个防御属性,价格计算方式变成 \((\text{防御} + \text{体积})\times 密度\) ,同时使用的文本也有改变。
  3. 完成Sword类,剑是一把武器,并且比武器要多出一个锋利度属性,武器的伤害需要用这个属性进行计算,你会怎么去实现这个类?如果不想伤害被乱改,需不需要修改父类?
  4. 为什么大部分面向对象代码的字段成员都是private修饰的,而泰拉瑞亚却有很多public,这样做的优点是什么,缺点又是什么?

《C#基础知识(2)》有6个想法

  1. 裙子好强!
    个人对面向对象的理解
    写模组->造车
    类(class)->图纸(规定”车”的各项数值等)
    对象(类的实例)->用图纸造出来的车
    构造器->造车的机器,通过你给予的参数造车
    继承->车分很多种,越野车,出租车公交车,但是都是车,能跑
    封装->你不需要深入理解车的运行方式就可以使用车
    多态->车的拓展功能,比如你给你的车换上了花纹更深的轮胎
    但是它依然是车,只是可能会具有别的优势

  2. Pingback: TeddyTerri:使用绘制来实现影子拖尾 - 裙中世界

  3. Pingback: TeddyTerri:使用绘制实现影子拖尾 – TeddyTerri's Blog

发表回复