介绍Mixin——理解Mixin的结构
本文翻译自:Introduction to Mixins Understanding Mixin Architecture
在开始开发Mixin之前,要使它们产生效果,最重要的是对其基本概念的理解。本文简要介绍了这些概念。尽管您可能熟悉这里所述的所有内容,但我建议至少略读前三个部分,因为它们介绍了我将用来演示如何使用Mixin的示例案例,以及Java和JVM中大量使用Mixin的一些特殊部分。
这不是一篇教程 本介绍并非教程,有关Mixin实现的更多详细信息,请参阅Sponge仓库中的Mixin示例代码。
注意
如果你已经全面了解了字节码、名称绑定,或直白的说你已经知道INVOKESPECIAL到INVOKEVIRTUAL,那么可以跳到第四节,这将介绍Mixin本身。
1. 理解PortalMixin - 以实例为例
为了能够想象Mixin是如何工作的,我将给出一个示例。 注意,这个示例纯粹是为了演示而编写的,与真正的代码库中的名称完全不同!
在示例中,我们可以看到一个EntityPlayer
类,它的直接(并且唯一)父类是Entity
。我们可以用这样的UML风格来表示它:
图1 - 一个简单的(虚构的)类层次结构
在Mixin术语中,EntityPlayer
是目标类(Target Class),Mixin将被应用于该类。
为了充实示例,让我们添加一些假想的字段和方法到想象的示例类中:
图二 - 一个有假想字段和方法的简单的类层次结构
选择这种表示方法是为了凸出哪些成员在类的公开区域,其中公共方法和字段将凸出在类主体之外,因为它们对其他对象是可见的。在使用Mixin时,外界是我们必须记住的一个重要概念。
注意,继承自Entity
的公共方法也是类公共可见区域的一部分,而从父类继承的“幽灵”方法getHealth
和setHealth
也存在与类的整体外观中。
在使用Mixin之前,深入了解this
和super
这两个Java关键字是非常重要的。这似乎很奇怪,因为任何使用Java超过五分钟的人都会认识这些关键词及其用法,但如果你不想在编写Mixin时抓狂,那么充分理解这两个词的微小差异是至关重要的。
首先来看一下我们假想类中的一些可能的调用和访问:
图3 - 一些字段和方法的访问
对于该情况,this.level
、this.update()
和this.food
似乎没什么有争议的,它们看起来都很标准。此外,调用super.health
和this.health
,以及从update
调用super.onUpdate()
的目的是为了凸出JVM的一个方面,它在编写Java代码时并不明显。
问一下自己下述问题:
用
super
来限定onUpdate
的调用有什么实际意义,为什么不用this
?用
super
来限定health
的调用有什么实际意义,为什么不用this
?
毕竟这两种限制在实际中都一样,对吧?
上述两个问题的答案如下:
super.onUpdate()
将始终调用Entity
中的方法,即使子类重写它,而this.onUpdate()
将调用子类中重写的方法。没有区别,
Entity
中的字段始终从方法takeDamage
访问,即使子类通过再次声明来“隐藏”字段。
这种行为的根本原因是,用super
限定的调用和所有字段访问在编译时被静态绑定(Statically Bound),这意味着它们总是引用成员。相反,用this
限定的访问都是动态绑定(Dynamically Bound),这意味着它们直到实际调用时才解析它们的目标,从而允许子类重写方法并在适当的时候调用它们。
注意
除了
super
限定的调用,访问private
和static
方法也总是静态绑定。在字节码中,静态绑定调用是以INVOKESPECIAL和INVOKESTATIC操作符来表示,而动态调用是以INVOKEVIRTUAL操作符来表示。
在开发Mixin时,准确意识到这些关键字的性质是有用的,这也是对Mixin类施加的一些限制的原因,稍后对此进行更多的说明。
2. 透镜窥秘
我在上述描述中避免使用接口来描述公开可见的成员,以避免与实际的接口混淆,因为接口本身在使用Mixin时起着关键的作用。
为了理解接口如何影响我们与类的交互,让我们看看在例子中创建一个包含一些方法的接口,让后通过接口访问这些方法会发生什么。
旁注:是的,这完全偏离了UML的轨道,但UML对于表示此处的概念并不真的有用,这个框图的底部从其他任何对象来看都是“可视区域”,该接口实际上位于公共类“之前”,并给出它的一个子集。
图4 - 一个让UML爱好者讨厌的图
这里有一些有用的东西值得注意:
首先,必须注意到
Entity
类中的getHealth
和setHealth
方法实际上正在实现接口方法,即使Entity
类不了解LivingThing
接口,这意味着接口方法中没有任何特殊:只要方法签名1与接口中的签名匹配,就认为类方法实现了接口方法。从这里可以清楚地看出接口方法调用是动态绑定。我们也没有修改两个类,除了声明它
implements LivingThing
。事实上,如果Java不要求我们包含implements
子句,那么这个程序结构是合法的,对程序没有任何改变。这告诉我们,如果能够以某种方式偷偷地将implements
子句插入到目标类中,那么只要接口的方法存在,我们就能在目标类上调用它们。
1 一个方法的签名是它的一组参数及其返回类型。例如下述方法:
1 public ThingType getThingAtLocation(double scale, int x, int y, int z, boolean squash) {
它的签名将会是:
1 (double,int,int,int,boolean)com.mypackage.ThingType
注意,我们将参数放在括号中,最后是返回类型。在实际中,为了节省空间,将会使用更紧凑的语法,在字节码中,上述签名是这样的:
1 (DIIIZ)Lcom/mypackage/ThingType;
如果你打算使用Injector,你需要熟悉字节码描述符。
3. 嘎嘎
我们正在慢慢组装的拼图的最后一块是一个与接口有关的实用的Java语言特性,即你可以将任何对象引用转换为任何接口,编译器很乐意编译它。
例如,假设我们为可以升级的对象创建一个新的接口,叫做Leveller
,就像这样:
图5 - 多么美好的一天啊,嘿嘿
下述代码将愉快地编译:1
2
3
4
5
6
7
8
9
10
11
12public void method() {
// 创建一个新的EntityPlayer
EntityPlayer player = new EntityPlayer();
// 这将被编译,即使EntityPlayer没有
// 实际上实现接口,但它在运行时
// 会抛出一个ClassCastException
Leveller lev = (Leveller)player;
// 我们永远不会到达这个代码,但它也将编译好。
int level = lev.getLevel();
}
上节中我们知道,EntityPlayer
的方法getLevel()
能愉快地在不改变类的情况下实现接口,但implements
子句没有显式声明接口这一事实导致在运行时转换失败。如果我们可以在运行时以某种方式添加implements
字句,那么最终有一种可行的方法以使用接口在Java中实现鸭子类型(Duck typing)。
“实现什么?”
- 可能是你说的
鸭子类型(Duck typing)是一种在动态类型化语言中使用的隐式类型化方法,它允许基于对象的成员是否存在来访问或调用对象的成员。它的名字来自“鸭子测试(Duck test)”,表达如下:
当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。
换句话说,如果我们只关心一个对象有方法quack()
和walk()
,那么对我们而言,它是一个Duck
,而不关心它是否只是一个非常聪明的Pigeon
,只要它有这些方法,那它对我们来说就是Duck
。
如果还不清楚这里说了什么,那么我建议阅读Wikipedia的条目,因为它详细地涵盖了超出本介绍范围的概念。
那么到目前为止我们知道了什么?
我们知道类和接口之间的关系非常脆弱,只需稍加修改就可以以多种方式做对我们有利的事情。
我们知道可以利用Java中的动态绑定编写能通过编译的代码(即使它不能运行),并以某种方式将
implements
子句添加到目标对象上是这项工作的关键。我们知道在编译时,使用
super
关键字的父类调用是静态绑定,这意味着在指定super
时,我们需要额外考虑,我们指定的是什么。
最后要考虑的是,当类不实现接口时会发生什么。让我们把另一个名为setLevel()
的方法添加到我们的示例接口Leveller
中:
图6 - 添加setLevel()
将第二个方法添加到接口中会增加另一个 - 不同的 - 运行时错误,在本例中为AbstractMethodError
。
1 | public void method() { |
理解了Java和JVM的这些方面,让我们来看看Mixin它自己。
4. 只有你Mixin能拯救人类
那么现在我们知道Mixin必须完成的基本任务,以便使我们可以使其他的对象嘎嘎:
- 让我们在运行时将我们所选的接口应用到目标类
- 让我们为接口中声明但目标类中不存在的任何方法插入一个方法实现
首先让我们看看如何声明一个Mixin类,以EntityPlayer
作为它的目标类:
1 |
|
是的,就是这么简单。使用@Mixin
注解将这个类定义为一个Mixin类,并指定我们想应用它的目标类。但要注意:
Mixin类使用
abstract
修饰符标记。虽然这不是必须的,但当在IDE中使用Mixin时它会很有用,因为它意味着终端用户不能编写试图实例化Mixin类的代码,这会在运行时导致错误。它还避免了必须实现任何声明的接口中的每一个方法的要求(Java编译器强加的),这是Mixin的主要目的之一。Mixin类继承了
Entity
,这是与我们的目标类相同的超类。这是很重要的,用以保持任何静态编译的语义编译到我们的Mixin类中。稍后再详细说明。
如果我们现在在运行时包含这个Mixin并运行游戏,那么将应用Mixin但绝对不会更改任何内容,这是因为我们实际上没有在Mixin中声明任何内容。让我们来看看如何实现上述目标1,并使用Mixin在目标类上添加新接口:
1 |
|
就是这样!当Mixin被处理时,在Mixin上声明的任何接口都被应用到目标类。让我们看看当前的类层次结构:
图7 - Mixin层次结构(应用前)
虽然这个图代表了我们将创建的类的实际层次结构,但它实际上更用于(并且在一些更复杂的情况下,至关重要)认识到Mixin不是真正的类。在运行时,Mixin将被应用到目标类,因此,认为Mixin存在于目标类内反而更有利于良好的思考过程。
在Mixin应用之后,新的类层次看起来像这样:
图8 - 类层次结构(应用后)
如你所见,目标类现在实现了LivingThing
接口,现在允许我们的鸭子类型按所想那样使用了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public void method() {
EntityPlayer player = new EntityPlayer();
// 应用了Mixin之后,转换成功
LivingThing living = (LivingThing)player;
// 因为转换成功了,所以我们可以传递对象
// 到其他需要应用LivingThing的地方
// 如下所示
if (this.isAlive(living)) {
// 万岁
}
}
public boolean isAlive(LivingThing living) {
// 我们现在可以很好地调用getHealth()方法,因为该方法
// 存在并可通过LivingThing接口访问
int health = living.getHealth();
return health > 0;
}
由于我们已经实现了第一个目标,现在可以成功地向目标类应用新的接口,因此让我们来看看第二个目标:
- 让我们为接口中声明但目标类中不存在的任何方法插入一个方法实现
我们首先让我们的Mixin类实现Leveller
接口,该接口声明当前未在我们的目标类及其任何父类中实现方法:1
2
3
4
5
public abstract class MixinEntityPlayer
extends Entity
implements LivingThing, Leveller {
}
生成以下类层次:
图9 - Mixin类层次(应用前)
因为我们的Mixin类是abstract
,该代码将很好地通过编译,但是在运行时任何对setLevel()
方法的调用都会抛出如上所述的AbstractMethodError
。我们可以在Mixin自身中定义setLevel()
方法来解决这个问题:1
2
3
4
5
6
7
8
9
10
public abstract class MixinEntityPlayer
extends Entity
implements LivingThing, Leveller {
public void setLevel(int newLevel) {
// TODO 实现该方法
}
}
图10 - 添加一个方法到Mixin
现在,当应用Mixin时,新方法也将被添加到目标类:
图11 - 类层次结构(应用后)
我们修改的目标类现在完全实现了所有声明的接口,我们可以看到想目标类添加新方法是多么容易。但目前我们的新方法实际上没有做任何事情,我们将在下一节中看到如何修复。
5. 点燃蜡烛将投下Shadow
因此,现在我们有办法将新方法注入目标类,但是在实现新注入的方法体时,我们就很快遇到一个问题:在理想情况下,我们希望新的setLevel()
实现能够访问EntityPlayer
中的level
变量。但有一个问题是……它不能。
图12 - 不可能的访问
我们不能访问目标类的成员,因为在实际应用Mixin之前,字段不存在!因为Mixin类的父类是Entity
,如果字段是protected
,它甚至没用:对Java编译器而言,字段是不可视的。
但我们知道当Mixin被应用时,字段将在那里,我们需要的是一种方法告诉Java“嘿,这个字段将会存在,让我访问它”。幸运的是,Mixin提供了一个机制,通过@Shadow
注释做到这一点:
1 |
|
@Shadow
注释在Mixin中创建一个“虚拟字段”,它反映了目标类的对应部分:
图13 - 我和我的影子
还可以将@Shadow
使用在方法上,一遍调用只在目标类中定义的方法,例如,在设置等级后立刻调用update()
方法,我们可以轻松的影射方法,然后从新的setLevel()
方法体中调用它:
1 |
|
我们通常将影子方法声明为abstract
,只是为了避免编写方法体,但很显然,我们不可能将private
与abstract
同时声明,所以我们只是用空方法体声明影子方法。
图14 - 影射万物
6. 它是鸟吗?是飞机吗?不,它是父类!
旅途的最后一站是关于Mixin的基本特性,简要介绍如何在Mixin中处理父类的访问。首先,我们需要理解为什么一个Mixin类被声明为与目标类相同的超类。
首先让我们快看看当前的类层次结构:
图15 - 游戏状态
请记住,从第一节开始使用super
关键字的调用都是静态绑定的。在我们的Mixin类上下文中,如果我们如图15所示调用super.onUpdate()
,那么生成的字节码将具体地引用Entity
类中的onUpdate
方法。
当Mixin与目标类具有相同的父类时,这正是我们想要的。然而,实际上Mixin可以继承目标类层次结构上的任何类,直到并包括Object
。
让我们假设一下,EntityPlayer
不是直接从Entity
继承的,而是从中间的一个类EntityMoving
,而Mixin类仍然可以直接继承Entity
:
图16 - 继承层次结构 - 注意:此图是故意错误的!
看看这个新的层次结构,现在很明显为什么super.onUpdate()
将出现在Mixin类中调用Entity
的方法,但这里很重要的一点是,忽略IDE(可能还有常识)告诉你的,并记住Mixin的关注点永远在 目标类!
这里的问题是,中间类EntityMoving
已经重写了onUpdate
,并且类的功能范围使得在超类中调用onUpdate
实际上会导致不一样的行为。当我们在Mixin中调用super.onUpdate()
时,它必须具有相同的语义,就像从目标类调用同一个Java语句一样,并且确实如此。
为了保持你键入到Mixin中的Java代码的语义一致性,Mixin转换器在应用时更新Mixin类中所有的静态绑定。这意味着在上述例子中,调用
super.onUpdate()
将正确地调用EntityMoving
中的方法。这并不影响
this
关键词的语义。对于protected
和public
方法,它们总是使用动态绑定,因此总是调用适当的子类方法。
为了实现该技术,转换器将处理Mixin中所有的INVOKESPECIAL操作符,并分析目标类的父类层次结构,以找到该方法的最特化的版本。该过程开销很高,并且只在“分离的”Mixin(那些父类与目标类的父类不同的Mixin)上执行。为了避免这种处理步骤,建议尽可能地将Mixin类与它们的目标类具有相同的父类。
图17 - 最终层次结构(Mixin应用后)
如你所见,将Mixin应用到目标类之后,将super.onUpdate()
调用的语言更新为与目标类一致,并且一切都再次工作良好。
7. 圆满完成
虽然本介绍涵盖了Mixin的基本知识,但是还有很多方面需要探讨,尤其是在目标类在使用之前会被混淆的生产环境中工作时。