在mcfunction中实现面向对象

||

面向对象是编程中的一个非常重要的概念,也是非常基础的编程的方式。作为Java和C#的长期使用者,面向对象编程的思维早就已经深埋在我的脑中了。因此,在编写面向mcfunction的mcfpp语言时,除了支持函数以外,自然而然的也想要支持面向对象这一点。等到我真正开始尝试实现的时候,才发现它是多么的艰难。事实上,直到我写下这篇博客的时候,我仍然没有完全理清楚应该如何实现我的面向对象语言。因此呢,我是边写边找思路,顺便记录一下自己的点子。

那么首先咱们看一个简单的类的例子:

class Student extends People{
    public static int id;

    public int score;

    public Student(int score,String name){
        super(name);
        id++;
        this.score = score;
    }

    public static int getID(){
        return id;
    }

    public int getScore(){
        return score;
    }
}

这是一个相当简单的Java语言编写的类,但是我们可以看到它包含了面向对象中的绝大多数语法要素。为了方便起见,我们把这样的一个类用mcfpp语言重写一次:

class Student extends People{
    public static int id;

    public int score;

    public Student(int score,entity target){
        super(target);
        id++;
        this.score = score;
    }

    public static getid(static int id){
        return id;
    }

    public getscore(static int score){
        return score;
    }
}

class People{
     entity target;

     public People(entity target){
         this.target = target;
     }
}

可以看到两端代码几乎一模一样。但是值得注意的是mcfpp的函数的返回是通过static关键字完成的。

总结一下我们要实现哪些东西吧。字段,静态字段,构造函数,方法,静态方法。嗯……不着急,我们先把这些实现了。

类的对象

首先要实现的当然是字段是类的对象啦。我们都知道,在Java中,一个类变量相当于一个指针指向了堆中的一个地址,这个地址上储存的就是我们要找的对象。那么这个指针一样的东西我们怎么在MC中实现呢?我在早期的mcfpp语法细则中提到过类实现的思路:

类是如何实现的?


在minecraft的原生命令中并不存在类这个概念。类是mcfpp强行引入的一个概念,勉强地达到了类的效果。
在声明一个类的变量的时候,这个变量实际上起到的是一个指针的作用。我们在访问类的成员时,就通过这个指针来访问成员。指针与成员之间的对应是通过记分板的数值来对应的,这个记分板的名字由类决定。接下来,还是以student类为例,详细说明类的建立和访问过程。
一个学生类有两个变量,一个是实体,一个是分数。在编译到类的声明部分时,编译器知道了这个类,就创建一个名为mcfpp_cs_student的记分板,用于对应指针以及对象。接下来,我们用student stu = new stu(100,@p);来创建一个student对象。首先,编译器根据变量的名字,生成了一个名为$obj_student_p_stu的实体,并将这个实体的分数设置为0。这个0可以看作一个类似与地址的东西。接着就是生成这个对象。实体的保存相对简单。将这个实体(这个例子中是最近的玩家)的mcfpp_cs_student记分板分数设置为0,然后加上obj_student_m_p,表示这个实体是stu对象的p变量。像这种数值的声明则略麻烦。对于每一个数值的变量,都会单独生成一个记分板。对我们现在的例子,即score变量来说,就会生成一个名为obj_student_m_score的变量。同时,刚刚的指针实体,即名为$obj_student_p_stu的实体的obj_student_m_score也会被赋值为100。接着,在访问的时候,如果访问数值,则直接根据指针实体取出记分板中的数值,如果访问实体,则找到和指针实体mcfpp_cs_student分数相同的带有obj_student_m_p标签的实体。

不过这个说的其实不够清楚和细节,我们还是在后面详细说一说吧!但是在此之前,我们先了解一下MC中的几个基本数据类型。

基本数据类型

这里的基本数据类型指的是,MC中的命令能直接操作和修改的数据类型。放眼硕大无比的命令大全表格,我们最终会发现,实际上命令能操作的也就是三种数据类型:记分板,NBT,实体

记分板大家都不陌生,作为最常用的数据类型之一,几乎每一个数据包或者地图都会用到记分板,而/scoreboard命令更是每一个命令初学者必须掌握的命令。无论是其作为MC中唯一可以进行计算操作的值,还是作为资源占用最低的几个数据操作之一,记分板都发挥了相当大的作用。从记分板中,我们可以衍生出大量的衍生类型,比如布尔型,浮点型等等。

NBT作为命令中另一个大型系统,储存了大量游戏的数据。通过NBT,我们可以修改实体的各种属性,也能储存一些特殊的数据例如字符串等等。虽然NBT性能开销较大,并且不能直接计算导致其操作需要通过记分板计算,但是它的功能的丰富性仍然让它占据了命令的半壁江山。

最后一种基本类型就是实体。我们在数据包中,大量的操作都是通过实体完成的,比如我们用实体进行坐标标记,用实体进行模型或动画展示。实体本身也是数据的天然载体,无论是记分板还是NBT都不在话下。同时,它还能作为函数执行的上下文,使用起来相当的灵活。

欸,看到这里,是不是突然意识到了什么?实体的储存特性,让实体成为了我们类指针的完美候选人。

实体指针

在mcfpp中,我们用一个marker实体来作为一个类的指针。marker实体占用资源极少,不渲染,而且杂NBT很少,使得NBT的访问速度也有所优化。那么,首先就是,怎么让这个marker和我们的类相互联系呢?

答案就是记分板。

对于一个类,我们可以创建一个名为xxx_class_classname_index(xxx起到命名空间的作用)的记分板。每一个类指针,都在这个记分板上拥有一个不同的值,而这个记分板的值,就相当于我们的地址。

是不是豁然开朗了呢?

现在,让我们看看怎么把这三个基础类型塞到这个实体上。

首先是记分板类型,这个类型是最简单的。只需要新建一个记分板,名字就叫xxx_class_classname_yyy(yyy为变量名),然后,让指针在这个记分板上拥有一个值。这样,只要我们能获取到这个指针实体,我们就能访问到这个类上的yyy字段了。

然后就是nbt类型。nbt类型也同理,因为marker实体有一个data标签,简直就是天然的nbt容器。

最后稍微麻烦一些的就是实体类型的访问了。当一个(一些)实体应当是某一个类的字段时,我们需要给它(们)加上一个标签,标签名不妨设置为xxx_class_classname_zzz(zzz是变量名)。而这些实体都应当在名为记分板xxx_class_classname_entity上有一个分数。如果实体分数和指针分数一样,就相当于实体的地址就是指针指向的地址,那么就可以借此访问到我们类中的实体了。

嗯,似乎搞定了,但是我们是不是忘记了什么?真的就只有这三种类型了吗?

当然不是。还有第四种基本类型,引用类型。这个类型就是我们自定义的类,本质上也是一种实体类型,但是因为比较特殊,所以我们就单独拿出来。如果我们自定义了一个类,我们当然可以在这个类里面把我们定义的类作为字段的类型。这个时候,我们要怎么访问这个类类型的字段呢?

我们说过,引用类型本质上还是一个实体类型,因此我们完全可以就像实体类型一样对它操作。给这个指针实体加上tag,给上分数,然后就可以用一样的方式访问了。只要获取到了这个指针实体,我们就能进一步访问到这个指针实体对应的其他对象啦!

好,到此为止,我们已经实现了类的对象了,现在是时候看看它的成员们了!

(静态)字段

静态字段

字段的实现其实我们刚刚已经说过了,但是静态字段似乎还没有说。嗯,什么是静态字段呢?静态字段是整个类共有的,直接用类名访问的东西,因此它和我们的指针实体其实关系不大。但是捏,我们可以投机取巧一下,我们同样用一个marker实体,但是我们不指定它的记分板地址分数,相反,我们给它一个xxx_class_classname_static的标签,这样它就可以相当于我们的静态字段的访问指针啦。当然值得注意的是,静态访问指针是只能有一个的。

初始化

在类中,我们可能会直接在类体里面,在声明字段的时候就把字段初始化了。这样的初始化分为两种,一种是普通的字段,初始化发生在创建新的类的对象的时候,另一种是静态的字段,创建在整个类进行初始化的时候。所以我们分两步来。

首先,对于每一个类,都会隐式创建一个static_init函数,这个函数就是对static字段进行初始化。它执行在整个数据包加载的时候,并且同时要求此时时刻没有这个类的静态指针实体存在。

然后就是在创建一个类的对象的时候。值得注意的是这里的调用顺序。至少在Java中,是先对字段进行初始化,然后再执行构造函数的内容的。因此,我们可以为每个类创建一个new_init函数,它会在构造函数的开头先调用一次,然后再执行构造函数的内容。

构造函数

函数的构造函数通常在一个对象被创建的时候被调用。那么我们首先要解决的问题是,如何创建一个对象。

我们先创建一个指针,并给这个对象分配记分板值,也就是分配地址。

summon marker ~ ~ ~ {Tags:["xxx_class_className_pointer"]}
scoreboard players operation @e[limit=1,distance=0] xxx_class_className_index = $static_id xxx_class_className_index
scoreboard players add $static_id xxx_class_className_index 1

我们用一个名为$static_id的记分项来作为标记,这样每一个对象都能拥有不同的地址。记分板的范围和int相当,所以我们不用担心地址会重复。

在创建指针以后,我们就开始调用构造函数。但是我们先不急着执行构造函数中的内容,我们先对这个对象的字段进行初始化。

#对记分板字段(expr_result是我们声明字段时右侧表达式计算的结果)
scoreboard players operation @e[limit=1,distance=0] xxx_class_className_yyy = expr_result xxx_class_className_yyy

#对nbt字段(exprCal.result同理)
data modify entity @e[limit=1,distance=0] data.xxx_class_className_yyy set from storage mcfpp:temp exprCal.result

#对实体(expr_result同理)
scoreboard players operation @e[tag=expr_result] xxx_class_className_entity = @e[limit=1,distance=0] xxx_class_className_index

在1.19.4以后,我们利用execute summon来帮助我们设置上下文,因此上面的所有@e[limit=1,distance=0]实际可以简写为@s。在此后的内容中,我们也会沿用这样的简写。

在这之后,便是构造函数内容的调用了,而构造函数中的语句实际上和普通函数并没有什么差异,因此这个地方便不再赘述了。

(静态)方法

对于一个类来说,它的方法是它功能的核心部分。那么,怎么调用类的方法呢。我们可以看到,一个类的方法执行的时候,实际上是以这个类为对象作为上下文,从而能在方法中访问到类中的字段等。因此,我们只需要用execute as将我们的指针实体作为上下文即可。

同理,对于静态方法,我们也只需要以静态的指针实体作为上下文即可。

字段访问

现在我们的类已经可以储存字段和方法,也能用构造函数进行调用,那么下一步我们就要试着访问类里面的数据了。方法的调用刚刚说过了,我们这里只说怎么访问类中的三种基本类型。假定我们已经将指针实体作为上下文,即@s

记分板类型直接访问实体的记分板即可。

NBT类型直接用/data访问实体的data标签中的内容即可。

实体类型,遍历所有带有tag的实体,选择记分板分数和指针实体相等的即可。

继承

继承的概念这里就不必多说了,毕竟能看到这里的都应该是对面向对象相当熟悉的大佬们了吧。首先,假设我们的A类继承了B类。

那么在编译的过程中,对于字段,我们将直接把字段复制到这个类中,这个过程将发生在类编译刚刚开始的时候,发生在编译器了解到A类继承了B类的时候。而方法部分,则会建立一个表,将B类的方法作为”虚方法”暂时储存,以供后续A类重写。

接下来,则是编译类的时候了。如果A类声明了和B类相同的字段,那么原字段将会被覆盖。如果A类重写了B类中的方法,那么同理,虚方法将会被覆盖。当进入构造函数时,编译器会直接将B对应的构造函数内联到A中,构造函数要么选择隐式调用super(),要么根据显式的super方法调用选择。

将类本身编译完成之后,如果类中仍然有虚方法,那么将会将父类的方法直接内联进去。

值得注意的是,我们前面说到,类的指针实体拥有一个tag用来表示它是哪一个类的,而这样有继承的实体将会拥有至少两个标签,即一个自己的,一个父类的。同理,它也应该在父类的记分板地址上有一席之位。这是为了后续多态的部分考虑的。

多态

整个面向对象拥有三个基本特征,即封装、继承和多态。而现在,我们要实现的最后一个功能便是多态。多态要求即使是同一个接口(父类),因为实际引用的是使用不同的子类实例而执行不同的方法。前文中我们提到,我们要求子类的指针实体同样也含有它的父类的tag,并且也要在父类的记分板上有分数,这都是为了保证一个父类的变量也能指向它。而在执行方法的时候,我们需要有一个额外的函数。在这个函数中,我们将根据这个指针实体上实际的子类标签来分流。对于多层继承的,应当保证最子的子类(好怪的说法喵)在上面,即假设我们有A、B继承了C,C继承了D,现在我们以一个D类型的指针实体为上下文,那么我们的伪代码应该是:

if(@s.tag.contains(A)) A.func();
else if(@s.tag.contains(B)) B.func();
else if(@s.tag.contains(C)) C.func();
else if(@s.tag.contains(D)) D.func();

而字段由于我们直接覆写,所以直接访问即可。


到此,封装、继承、多态三种基本要素我们已经完全搞定。剩下的特性,譬如接口,抽象类等,都更多的是从语法中解决而不是原理上解决的了。我并不期望用此文来让读者自己手写一个面向对象的框架,更多的是记录下自己开发mcfpp过程中的思路,当然,如果能对读者有帮助,我就更开心啦喵。

类似文章

发表回复