第3章 基础知识

3.1 类型、字段、方法和注解

3.1.1 类与类型

与JavaScript、Python等语言不同,Java是一门静态类型(Statically Typed)的编程语言,与之对应的是动态类型(Dynamically Typed)的语言。静态类型编程语言的一大特征,就是在代码中,或者在程序运行时出现的所有(Value),都有其特定的类型。有时,我们还会说一个值是一个类型的实例(Instance)。Java中出现的所有类型共分为两种,值类型(Value Type)和引用类型(Reference Type)。值类型和引用类型的名称,在其背后有很深刻的原因,这里略过不提。

值类型又称基本数据类型(Primitive Type),这种类型在计算机中处于底层,它们的实例通常能够和计算机的内存一一对应。基本数据类型有八种:

● int,代表一个介于-214748648~2147483647的整数。

● short,代表一个介于-32768~32767的整数。

● byte,代表一个介于-256~255的整数,它有一个别称,即字节(Byte)。

● long,代表一个介于-18446744073709551616~18446744073709551615的整数。

● float,代表一个较低精度的浮点数(也就是小数点后可以不为零的数)。

● double,代表一个精度更高的浮点数。

● char,代表一个字符,通常能够代表绝大多数Unicode字符。

● boolean,代表一个布尔值,也就是只有“真(true)”和“假(false)”两个值的类型。

在通常情况下,还会把int,short,byte,long统称为整型(Integer Type),把float和double统称为浮点型(Floating Point Type)。float通常被称为单精度浮点数(Single-precision Floating-point Number),double通常被称为双精度浮点数(Double-precision Floating-point Number)。

引用类型通常是基本数据类型的组装,这种类型的实例通常被称为对象(Object),所有对象都是引用类型的实例。

在Java代码中,一种常见的引用类型被称为(Class)。所有对象都是以类的形式组织和管理的。一个类代表的是一个分类。类这个概念不容易理解,在此列举几个来自生活和计算机的例子:

● 大千世界,有一类生物能够制作并使用工具,这一类生物被统称为人类。如果说“人类”是一个类,那么正在阅读这本书的你,撰写出这本书的作者等,都属于“人类”的实例。

● 当然,如果你是一个Minecraft玩家,那么也属于“Minecraft玩家”这个类的实例。一个对象可以同时属于多个类的实例,在后面的章节中将会对这种情况进行进一步探讨。

● 在计算机中,一串文本被称为字符串(String)。在Java中,所有字符串都是一个类的实例。这个类就是java.lang.String。java.lang.String代表它从属于java.lang包,并且其名称为String。

另一类引用类型被称为接口(Interface)。一个对象可以同时是多个类的实例,也可以是多个接口的实例。接口代表的是一种约定,或者代表一种能力,在此列举几个来自生活和计算机的例子:

● 自然界中的绝大多数植物,都可以通过叶绿体直接吸收太阳发出的特定波长的光,并获取其中的能量,这一现象被称为光合作用。如果把“能够光合作用”称为一个接口,那么毫无疑问,几乎所有植物都应该是这个接口的实例。

● 一台电脑上有各种各样的插口,比如USB插口,PS/2鼠标键盘的插口等。如果把“拥有USB插口”称为一个接口,那么读者家里的电脑很可能就是这个接口的实例,这个接口代表了一种可以和USB设备交互的能力,很明显,所有这个接口的实例都拥有或看起来拥有这一能力。Java中“接口”的概念和现实生活中“接口(插口)”的概念是重合的。

对于接口的声明及其与类的关系,后面的章节将会有更具体的介绍和讲解。

在通常情况下,一个描述Java源代码的文件内部,包含一个或多个类或者接口的声明和描述。这些源代码文件都以.java为后缀,并分门别类地放置在一些特定的目录下,正如前面章节提到的那样,这些特定的目录代表的就是一个Java项目的包。换言之,包是Java代码的最大一级组成单元,而类和接口是包的下一级组成单元。

还有一类十分特殊的类型被称为数组(Array),数组相当于若干相同类型的对象的有序排列,能够存放的最多的元素个数被称为数组的长度,换言之,一个数组相当于一个长度无法变化的列表。特定类型的数组的长度可以有所不同,但是特定的数组对象的长度是不可变的,当讨论数组时,我们不知道它们的长度是多少,不过针对一个特定的数组,就可以说它的长度是固定的某个数字。数组遇到的场合相对较少,在后面如有遇到,将会有更详细的说明。

值类型和引用类型,换言之,基本数据类型、类、接口和数组类型,共同组成了Java的类型系统。由于在Java中,除八种基本数据类型之外,其他都基于类和对象,类同时还是Java代码的基本组成单位之一,因此,通常称Java是一门面向对象(Object Oriented,简称OO)的语言。

打开目前源代码中唯一的类,ExampleMod.java。先看第一行:

这一行代码声明了这个类所属的包,这个包名要和目录树一致。然后是接下来几行:

这几行代码声明了需要从别的包里引用的类。不过细心的读者可能会注意到,我们并没有把java.lang.String等类引用进来,这是因为Java规定所有java.lang包下的类都是已经被默认引用的,不需要再引用。

现在应该知道在修改一个包的包名时,IDE还额外做了什么了。因为package和import开头的行里的包名,也是需要进行相应的更改的。实际上,这几行代码的内容,开发者几乎不需要去管,因为在大多数情况下IDE会自动填充。然后我们来看下面几行:

第10行以@开头的被称为注解(Annotation),注解在Java代码中有特殊的用途,这里被用于修饰一个类。

接下来的第11行就是类的声明了。这里以class为分界线分析:

● class前面的public被称为修饰符(Modifier)。一个类的修饰符可以有多个,它们的作用各不相同,后面的章节会详细讲解。

● class后面就是类的名称了。和包名一样,类的名称也有一些规定。

- 虽然理论上,类名允许出现其他字符,但是不建议出现数字、大写字母和小写字母之外的字符。

- 类名应该采用大写驼峰式(Upper Camel Case),大写驼峰式指的是每个单词只有首字母大写,然后把它们直接拼合起来,比如ImmersiveEngineering等。

- 对于缩写单词,所有字母全部大写或者只有首字母大写往往都可以,比如FMLTutor和FmlTutor都是可以的。

● 然后是一个左大括号,对于一部分类,在类名和左大括号之间还会有其他对象,不过这里先不讨论这种情况。

● 里面的内容就是类的主体了,最后以一个右大括号结束。

● 一个.java文件中,以这种方式声明的类只能有一个,而且它的类名必须要与文件名相同。

在Java中,除了字符串内部,所有空白字符(空格、Enter和Tab符等)都是等价的。因此与之对应的大括号通常有两种写法:

实际上两种写法都在被广泛使用着。不过由于MinecraftForge大多采用第一种写法,因此本书也只采用第一种写法。

现在把ExampleMod改成我们想要的名称:FMLTutor。这对IDE来说其实很简单。单击出现ExampleMod字符串的任何地方,然后右击,把鼠标光标移动到Refactor,然后移动到Rename:

把它重命名为我们想要的名字就可以了,也可以在IntelliJ IDEA中使用Shift+F6组合键完成重命名行为。

3.1.2 字段和方法

现在进入类的主体部分:

这里涉及两个部分:前三行和之后的一行代码声明了四个字段(Field),有时又称属性(Property),而接下来几行代码声明了一个方法(Method)。

字段是什么?字段其实就是一个容器——容纳某种特定类型的容器。这种类型可以是八种基本数据类型,也可以是类。这里的两个字段,存储的对象都属于java.lang.String类。换言之,它们都是这个类的实例,是一个字符串。方法是什么?我们之前说到,Java是一门面向对象的语言,因此作为一个对象,总要有一些行为。

计算机中的字符串,往往会有一些额外的操作,比如把所有的字母都变成小写的,取第一个字符,把前几个字符删掉等,这些都是java.lang.String类的方法。

很多方法都会接收一个或多个参数(Parameter),比如想获取一个前几个字符删掉后的字符串,就可以向删去前几个字符的方法中传入一个类型为int的参数,这个参数代表需要删去多少个字符。然而有的方法是没有参数的。有一些方法会有返回值,比如字符串操作,我们就需要相应的方法返回一个新的被操作过的字符串。不过有一些方法也没有必要有返回值。实际上,这里看到的方法,就没有返回值,在Java代码中,没有返回值的方法会在相应位置使用void作为占位符。一个方法可以接收多个参数,但是只能返回一个值,或者使用void占位符代表不返回值。

定义了三个类型都是String的字段,分别为MODID、NAME和VERSION。第一个字段代表一个"examplemod"的字符串,第二个字段代表"Example Mod",第三个字段代表的是"1.0"。还定义了一个preInit方法,这个方法传入一个FMLPreInitializationEvent的实例,把它命名为event。定义这个方法的返回值处填的是void,也就是没有返回值。接着定义了一个init的方法,并让该方法接受一个FMLInitializationEvent。

读者可以把这些结论和上面的代码进行一一对比。也许读者对其中的一些细节可能有一些茫然,不过没关系,在后面的模组编写中,我们将不断强化相关的认识。

3.1.3 不可变字段

我们注意到String和void之前有一些修饰符。这些实际上与类的修饰符类似,只不过是针对字段和方法的修饰符:

● 方法init拥有名为public的修饰符。

● 字段logger拥有名为private和名为static的修饰符。

● 字段MODID、NAME和VERSION各自均同时拥有三个修饰符public、static和final。

这里首先讲述的是针对不可变字段添加的final修饰符。其他修饰符在后续章节如有出现,都会逐一介绍。

被final修饰符修饰的字段,在通常情况下,其值一经设置就会被冻结,从而被认为在未来永远不会发生变化。比如对于MODID字段,其值永远都是"fmltutor",而对于NAME和VERSION字段,其值永远都是"Example Mod"和"1.0"。然而,logger字段是可变的,因为它没有名为final的修饰符来修饰。

在未来我们看到的绝大多数字段都属于不可变字段,因此它们都将带有final修饰符。

3.1.4 表达式

作为一门计算机编程语言,其主要任务就是把一些基础的东西,通过一些特定的方式,变换组合起来,然后输送出去。

现在先定义一些“基础的东西”,也就是不需要运算直接就应该给出的东西。

一些数字比如1,2,450,-1,-999999999等。这些数字都是int类型的,在部分情况下也可以当作byte和short类型。

long的使用范围要比int要大,表示的数字范围要更大一些,因此为了表示这个数字是一个很大的数字,需要在其后加上字母L:-999999999没有超过int的范围,因此可以不加字母L。-9999999999就超过了int的范围,需要加上字母L,变成-9999999999L的形式。虽然在后面加上小写l亦可,但是这个字母很容易和数字1混淆,因此不建议这样做。

当然,还有浮点数,比如3.14159265,987654721.33,1E-12等(最后一个是科学记数法),它们都可以当作float或者double类型,不过有时使用float类型会损失一定的精度,而且要加上F的后缀(3.141592650F,987654721.33F,1E-12F),小写f同样也可以。

对于char类型,Java的表示方式是使用单引号(')把一个字符引进来,比如'c'、'h'、'a'、'r'等。

对于boolean类型,只有两个值,在Java中分别表示为true(真)和false(假)。

这些不需要通过运算得到,并可以直接在代码中给出的被称为字面量(Literal)。对于基本数据类型,这些就是字面量。不过对于引用类型,String类有一种特殊的字符串字面量(String Literal),这种字面量的写法是把一个字符串用双引号(")引起来,前面已经见过了"examplemod"和"1.0"两个字符串字面量。当然,还有其他字面量对象,这里暂且不讨论。

把这些字面量通过变换组合,常见的方式就是数学运算,比如:

还有比较运算:

注意比较两个基本数据类型是否相等用的是==而不是=,=是用来赋值的,比如下面的三行代码:

其中第一行代码把MODID赋值为”examplemod”,而第三行代码把VERSION赋值为”1.0”。

字符串有一种特殊的加法运算,两个字符串相加代表把它们拼合:

不过这里有两种运算,与面向对象有关,它们分别是获取字段和调用方法。这两种运算都要用到一个标点符号:小数点(.)。例如,ExampleMod.MODID代表获取ExampleMod类的MODID字段。这两个运算在什么地方用到了呢?

这个代码与:

是完全等价的。

如果想要调用方法,比如Foo类的bar方法,那么它的写法如下:

如果还想传入一个参数baz,再传入一个参数42的话,那么它的写法如下:

运算后的结果也可以接着应用到其他运算中,必要的时候可以加上括号,它们和数学运算是类似的:

字面量本身或者它们的各种运算统称为表达式(Expression)。

3.1.5 深入方法内部

这里以init方法为例。我们先看方法内部的第一行:

这是一行注释(Comment)。注释本身和程序的执行结果没有关系,只是为了方便开发者理解,起到解释说明的作用。注释分为两种,第一种是行注释,以//开头,就是上面的代码中所表现出的形式。行注释前的//标记代表直到该行结束的部分都是注释。如果注释非常长,需要跨多行,那么每一行注释的开头都要加上//标记:

Java支持的另一种注释叫做块注释,块注释以/*开头,以*/结尾,其中的所有字符,包括Enter都将被认作注释,比如:

然后看这一行:

在这一行中看到了表达式的身影,来逐点分析:

● logger代表获取了这个类本身的logger字段。

● “DIRT BLOCK >> {}”本身是一个字符串字面量。

● Blocks.DIRT代表获取net.minecraft.Blocks类的DIRT字段。

● Blocks.DIRT.getRegistryName()代表调用上面那个字段的getRegistryName方法,并获取其返回值。

● logger.info(“DIRT BLOCK >> {}”,Blocks.DIRT.getRegistryName())代表调用最开始那个logger字段的info方法,然后传入两个参数,第一个参数是字符串字面量,而第二个参数是一个方法的返回值。

最后还多出来了一个分号(;)。这个分号十分重要,它把一个表达式变成了一个语句(Statement)。在一个表达式后添加一个分号形成的是简单的语句,还有一些语句十分复杂,后面的章节也会有提及。一个方法内部可能有很多个语句,它们将会按照出现的先后顺序来执行。语句和注释共同构成了一个方法的主体。

最后证明一下这段代码的确执行了。运行一下客户端或服务端,在控制台上找到下面这一行:

从字面含义理解,Blocks.DIRT.getRegistryName()的返回值理应是一个名称,且该名称应该代表泥土方块,也就是Blocks.DIRT,也即泥土方块的名称,因此这里输出minecraft:dirt也是合理的。

3.1.6 日志系统

再来看preInit方法。以下是该方法中唯一出现的语句:

调用了event对象的getModLog方法,并将调用得到的返回值给logger字段赋值。logger字段存储的对象,代表的是Mod的日志系统。本书接下来所有需要向控制台输出内容的代码,都会用到这一字段。

3.1.7 注解和FML启动Mod的方式

注解是一种特殊的类,这种类的实例通常会挂靠在某些地方,比如已经挂靠在类和方法上,而不单独存在。

之前说过,Mod的JAR,本质上就是一些包含Java代码的包组合在一起,不过一个Mod可能有很多包,一个包也可能有很多类,那么多的包和类,从哪儿开始执行代码呢?

FML的做法是:在FML的API中提供一个名为@Mod的注解,然后它会检索所有含有@Mod注解的类,并构造出它们的实例。因此,这个带有@Mod注解的代表Mod的类被称为Mod的主类(Main Class),而且一个Mod只会有一个主类。

至于如何在JAR里找到注解,构造出一个对象,并调用相应的方法,这件事十分复杂,涉及的概念很多,也超出了这本书的范围。感兴趣的读者可以自己查找相关的资料阅读。

3.1.8 生命周期

在之前的章节中提到过生命周期事件。实际上,当生命周期事件触发的时候FML就会去通知Mod,并调用Mod主类下带有@EventHandler的特定方法。不过,如果一个Mod的多个方法都添加上了@EventHandler注解,那么FML到底应该调用哪个呢?

这时候,起决定性作用的就是Mod主类方法的不同参数了。FML规定,只有Mod主类中含有@EventHandler注解的方法才会被调用,同时该方法只能有一个参数。而决定FML什么时候调用该方法的,正是这个参数的参数类型。换言之,每个生命周期事件在FML中都有一个唯一的类型。通过阅读代码我们能够看到,在Forge的示例Mod中,使用了FMLPreInitializationEvent和FMLInitializationEvent。

Mod最常用的生命周期事件有三个:FMLPreInitializationEvent、FMLInitializationEvent和FMLPostInitializationEvent。这三个事件会在Minecraft游戏启动时依次触发。不过,对Minecraft游戏本身来说,它的很多初始化工作都出现在FMLPreInitializationEvent和FMLInitializationEvent之间,因此,这为FMLPreInitializationEvent赋予了十分特殊的地位。当然,除此之外,FMLInitializationEvent和FMLPostInitializationEvent也在一些场合有重要的用途。

对Mod来说,生命周期事件通常用于在Minecraft启动前对Minecraft进行一些调整,以及Mod之间的交互等。对于旧版本Minecraft,尤其是1.10版本之前的Minecraft,生命周期事件往往还用于注册方块、物品等。不过对于1.12.2版本的Minecraft,包括但不限于方块和物品等很多游戏元素,都有专用的注册方式,不再需要生命周期事件了,因此在本书的最初几章,都是用不到生命周期事件的。不过随着本书讲解内容逐渐深入,我们能够注意到一些游戏元素的注册,仍然要放到生命周期事件中完成。