UnityScript 到底是啥

字数 4212阅读 2736

讲讲 Unity3D 里面的 "JavaScript" 到底是什么

引言

如果你做过 Unity3D 开发你一定会知道它支持的开发语言是 .Net 上的 C# 和所谓的 "JavaScript",而它的学名其实是 UnityScript。本文会详细的讲讲我所了解的 UnityScript 相关的东西。如果你不做 Unity3D 开发,那本文讲述了一个小型编程语言的由来以及其具有的问题。很意外的,本文中有很多我自己臆想的东西,请你根据自己的知识和理解来阅读。如果有错的离谱的地方请跟我联系我会改正...

请一定不要用 UnityScript 开发

首先把最重要的一点放在最开始讲,你再任何情况下都不应该用 UnityScript 做开发。其最大的问题就是编译速度慢,在代码行数超过一定后,任何代码修改都会有将近三四分钟的编译时间,这意味着你在代码里把 1 改成 2 就要等 Unity3D 转三分钟的圈才能看到结果。如果你不相信的话,这里有一个开源且代码量较大的 Unity 游戏,你可以自己拖下来试一试。Unity 5.0 开始消减对跟 UnityScript 有紧密联系的 Boo 语言的支持,在今年的 Unite 教程中项目的语言也是 C#,而且如果你仔细看你会听到他们说现在他们正在把主要推荐的语言转成 C#。

重要的事情就是要再说一遍,你在任何情况下都不应该选择 UnityScript 做开发。

为什么会有 UnityScript

从 Unity3D 的更新记录来看,其从 1.0 开始就有 UnityScript 支持。Unity 脚本编写是基于 .Net 上的,已经有了 C# 这个不错的语言,为什么要额外新增一个语言呢?就我看来原因是两方面的。一方面可能是开发者认为需要一个语言来进一步的降低上手门槛,并且特殊处理一些 Unity 里常用的东西。而另一方面,也是我认为占主要的一个原因是市场导向上的,希望通过支持当下最火热的 JavaScript 来吸引更多的用户。不得不说从现在反过,Unity 现在铺的这么开,这个决定肯定也有相当大的效果。

那么 UnityScript 到底是什么

既然决定了,那么要看怎么在 .Net 平台上弄一个 JavaScript 出来。一个办法是在 .Net 的基础上实现一个真的跟浏览器里面的 js 一样的 JavaScript Runtime。这样比较明显的问题是运行效率,以及动态类型的 js 和 C# 差异太大难以用一套 API 统一起来。最终他们选择的办法是实现了一个跟 C# 在同一层面且极其类似的 .Net 平台语言。拿 C# 来讲,C# 的代码经过编译出来生成 DLL,这些 DLL 文件实际上是 .Net DLL,也被称作 .Net Assembly 。比起原生的 DLL 里存储的是机器码,Assembly 里面的内容是被称作 CIL 的字节码,它需要在一个 .Net 的 Runtime (Unity3D 用的 runtime 就是 mono) 上才能执行。就这个层面来讲,UnityScript 跟 C# 其实是完全相同的,UnityScript 的 .js 代码经过其自己的编译器,直接生成 Assembly,其中没有转成 C# 代码或者其他的步骤。

就我们外行人看来都会觉得要实现一个跟 C# 相同层次的语言显然不是一个简单的事情。Unity 很睿智的通过复用开源项目的方法达到了他们的目的,而这也是为什么 Unity 支持的语言中会有 Boo 这个都没有听说过的语言的原因:UnityScript 是基于 Boo 语言来实现的,可以说 UnityScript 就是在 Boo 语言的实现上改出来的。

Boo 是一个比较有历史的 .Net 平台上的第三方语言实现。它是一个看起来很像 Python 的静态类型语言,代码看起开像是这样的

def fib():
    a, b = 0L, 1L
    while true:
        yield b
        a, b = b, a + b
 
# Print the first 5 numbers in the series:
for index as int, element in zip(range(5), fib()):
    print("${index+1}: ${element}")

这段看起来跟 Python 几乎一模一样而且能做到静态类型的关键,就是自动类型推导(Type Inference)。C# 到 3.0 才引入了 var 关键字,Boo 在这方面应该算是领先与 C# 的。Boo 的理念可能跟现在开始流行的 Go 类似,就是 "看起来写起来像脚本语言实际上是静态类型语言"的感觉。Boo 可以调用 .Net 的库,可以直接编译到 Assembly,就像是比较轻快的 C# 的样子。

在实现上,Boo 语言有一个非常灵活的实现,从最开始解析代码,到类型推导和 CIL 代码生成每个步骤你都可以在不修改其本身实现的情况下对其做修改和扩展。你可以原封不动的拿 Boo 的几个 DLL,在自己的项目里通过调用和继承来实现一个看起来完全不一样的语言。事实上 UnityScript 的实现也就是这么做的。Unity 在 github 上维护他们自己用的 BooUnityScript,后者项目编译出来的结果包括 us.exe,对应的其实就是在 Unity3D 安装目录下 Editor/Data/Mono/lib/mono/unity/us.exe,这个就是 UnityScript 的编译器。如果你想知道 us.exe 正确的调用方法可以试着在 Unity 中引入一个语法错误,然后在 editor.log 里面可以看到 Unity Editor 是怎么调用它的(这里 有一个完整的例子)。经过简单的修改你可以看到编译器工作过程中的每一个 Pass:

UnityScript.Steps.PreProcess
UnityScript.Steps.Parse
Boo.Lang.Compiler.Steps.PreErrorChecking
UnityScript.Steps.ApplySemantics
UnityScript.Steps.ApplyDefaultVisibility
Boo.Lang.Compiler.Steps.MergePartialTypes
...
Boo.Lang.Compiler.Steps.CheckNeverUsedMembers
UnityScript.Steps.Lint
UnityScript.Steps.EnableRawArrayIndexing
Boo.Lang.Compiler.Steps.CacheRegularExpressionsInStaticFields
Boo.Lang.Compiler.Steps.EmitAssembly

光是看这些步骤的名字基本就能大概知道 UnityScript 编译器所做的工作。它最开始取代了 Boo 语言的解析器,用它自己的 Parser 来解析它的 .js 代码,再生成 Boo 能够识别的中间层表示,中间再再合适的位置做一些 UnityScript 自己独有的特性处理,最后再用 Boo 的代码生成来输出 DLL。

这样的做法有一个很明显的结果就是 UnityScript 和 Boo 虽然语法差异很大,但是语义上是非常近似的。比如说 Boo 像 Python 一样支持 Array Slicing:

a = [1,2,3,4,5]
slice = a[2:4]

虽然文档上几乎没有提及,UnityScript 也是支持 Slicing 的:

var ls = [1,2,3,4,5];
var slice = ls[2:4];

另一方面这个实现也可以解释为什么 UnityScript 编译特别慢,一方面它是没有增量编译的 (不过其实 C# 也没有),而另一方面是编译器中 Pass 过多,基本上可以讲每个 Pass 对整个代码数据都会遍历一遍,70 多个 Pass 在代码量大起来以后显然是怎么都快不起来的。

那么回到本节开始的问题,我觉得 UnityScript 是一个针对 Unity3D 特化的 Boo 衍生语言。它本质上仍然是跟 C# 类似,class-y 的静态类型语言。UnityScript 和真正 JavaScript 的关系只有后缀名都是 .js 和语法看起来有些接近而已,其于方面没有任何关系。

UnityScript 的"效率",和 mono

一个比较常见的说法是 “Unity 的 JavaScript 比 C# 效率上要慢”,根据我们上面的分析,我们可以对这个命题来分析一下。

首先 UnityScript 和 C# 在 Unity 游戏运行时的最终形态都是 Assembly,里面的 CIL 字节码在 mono 的 Runtime 上执行。假设两者最终产生的 Assembly 是一模一样的,那么在这个时候两者运行的快慢应该是一模一样的。

但事实上由于语义上的差别,UnityScript 生成的 CIL 比起 C# 的编译结果某些地方会差一点。比方说 UnityScript 允许任何类型的东西进行比较:

if (123 == 'String') {
    Debug.Log("not happening");
}

这个对应的东西在 C# 中是编译不过的,因为 == 两边类型不符合。检视最后生成的 IL 代码,会发现这一段等价的 C# 代码是这样的:

if (Boo.Lang.Runtime.RuntimeServices.EqualityOperator(123, "String"))
{
    Debug.Log("not happening");
}

// Boo.Lang.Runtime.RuntimeServices
public static bool EqualityOperator(object lhs, object rhs)
{
    .....
}

这里的 Boo.Lang.Runtime.RuntimeServices.EqualityOperator 是用来支持这种行为的 Boo 标准库中的函数,其会把参数转成 object 然后再一步步检查。在写 UnityScript 代码的时候有时候会不注意就触发了这种类型的比较,这种显然比一个简单的 == 消耗要多。本文下一部分会介绍更多 UnityScript 和 C# 的区别,有不少地方 UnityScript 的做法会有些额外的消耗,导致生成的 IL 比起 C# 的会有些不理想。另一方面,由于 UnityScript / Boo 的编译器完全是第三方的,其对于生成 IL 的优化部分可以想象比起成熟的 C# 实现要差一点,不过这部分我没有验证过。

到这里的意思是 UnityScript 可能是比 C# 要慢一些,但原因不是因为它是"动态语言"或者别的,而是其语义和实现导致其生成的 Assembly 比 C# 要差一些。

另一个值得一提的是关于 Unity 使用的 Mono。Mono 是一个开源的 .Net 平台实现,包括完整的 C# 编译器以及在各个平台上的 Runtime。Unity3D 使用的是自己维护的一份比较老版本的 Mono,在 github 上也可以找的到。如果你在 Unity 编辑器里面看 About Unity 页面,你可以看到 Mono 下面的 License 的后面跟的是 2011, Novell,这个是最早的时候商业支持 Mono 的公司。现在去看 Mono Licensing 页面,你可以看到现在要嵌入 Mono Runtime 的话(Unity 在 iOS 和 Android 上明显就属于这个范围)需要向 Xamarin 购买License。弄成这样子的原因,我猜是 Unity3D 在最开始的时候在 Novell 那里获得了 License,在后来 Mono 转到 Xamarin 手中后可能 Unity 和 Xamarin 由于什么原因没谈拢,Unity 决定还是自己维护自己老的那份 Mono。这也是为什么你去看 Mono 已经支持到 .Net 4.5 了而 Unity 还停在 .Net 2.x 时代。

UnityScript 和 C# 的区别

乍一看起来 UnityScript 和 C# 似乎每一行都可以一一对应的转换。但事实上在各种边边角角地方还是有些差异。这部分会列举一下我注意到的部分。(后文中 UnityScript 简称为 US)。

  1. US 里所有的 method 都是可以被 override 的,而 C# 里面可以要 override 的函数需要被显示的标记为 virtual:

    // us
    public function Foo() : void
    // c#
    public virtual void Foo()
    
  2. US 里面特殊处理了 value type assignment,一个常见的例子就是 US 里面可以这样写:

    // us
    transform.position.x += 2;
    

    这个在 C# 里面也是编译不过的,这段等价的是 C# 代码大概是这样的:

    // C#
    transform.GetPosition().x += 2f;
    

    事实上 transform.position 是一个 getter,每次取值的时候都是一个函数调用拿到的值是一个返回的临时 Vector3。如果 Vector3 是一个普通的 class 这样的 Reference Type 的话这样是没问题的,但是 Vector3structValue Type,这里的值就是一个临时的值,对其赋值是没有意义的。US 里这样能行完全是 US 对其作了特殊处理,在 C# 里你需要新建一个 Vector3:

    // C#
    transform.position = new Vector3(transform.position.x + 2f,
        transform.position.y,
        transform.position.z);
    

    这样显然很难看,你可以用 Trasform.Translate,或者写 Extension Methods来处理下。

  3. US 里面你可以通过一个 instance 来调用上面的 static method 或者访问 static property,而 C# 里面不行,你必须通过 class name 来用 static 的东西。

    // US
    gameObject.Find("Foo"); // 用的是 GameObject.Find,这个在 C# 里编译不过
    // C#
    GameObject.Find("Foo");
    
  4. US 里你可以在构造函数里显示的调用 super(),而且可以把它放在其它语句后面。C# 里面要调用父类构造函数要写在特殊的地方,而且它会先于其它东西执行:

    // US
    public function Foo() {
        Debug.Log("First");
        super(123);
    }
    // C#
    public Foo() : base(123) {
        Debug.Log("NOT First"); // 这里执行顺序明显变了
    }
    

    这应该是一个典型的 CIL 支持的功能而 C# 里面故意没有对应的用法,感觉是应为这个它带来的问题比起这一点灵活性更多,因为在 base() 执行前父类是没有初始化好的,其里直接写一些常数值也是没有初始化的。

  5. US 像 JavaScript 一样做 hoisting,在一个作用域里声明的局部变量都会被提到作用域最开始的地方,而且 US 里面的 if/for/while 是没有局部作用域的。C# 里面有正常的 block scoping:

    // US
    for (var ix = 0; ix < 10; ++ix) {}
    Debug.Log(ix); // ix 其实被提到了循环外面
    // C#
    for (int ix = 0; ix < 10; ++ix) {}
    // ix 在这里就访问不到了
    
  6. US 的 switch 像 C 里面一样可以 fall through,C# 里面为了避免这个行为带来的各种问题特地默认没有这个功能,需要用 goto 来显示的做:

    // US
    switch (ix) {
        case 1:
            Debug.Log("one");
        case 2:
            Debug.Log("two");
            break;
    }
    // C#
    switch (ix) {
        case 1:
            Debug.Log("one");
            goto case 2;
        case 2:
            Debug.Log("two");
            break;
    }
    
  7. US 里面 if/while 这些地方有隐式的 boolean 转换,C# 这些位置上需要自己保证这些地方值的类型是 boolean:

    // US
    if (GameObject.Find("Foo")) {...}
    // C#
    if (GameObject.Find("Foo") != null) {...}
    
  8. US 里面所有东西都可以用 == 来比较,而 C# 里需要两边都是同样的类型或者之间有继承关系:

    // US
    if (123 == "foo") {...} // 上面讲到过这个会被编译成函数调用
    
  9. C# 里面的常数数值要求有对应其类型的后缀,US 里面这方面比较松散:

    // US
    private var f : float = 2.0;
    // C#
    private float f = 2.0f; // 没有 f 后缀编译不过
    
  10. US 里面有 list literal,可以写成 [1,2,3] 这样。C# 里面需要写数组造函数:

    // US
    private var arr : float[] = [1,2,3];
    // C#
    private float[] arr = new float[]{1f,2f,3f};
    
  11. 可能你都没有注意到,US 里面是可以省略 new 的,C# 里面则不行:

    // US
    var v = Vector3(1,2,3); // 加上 new 也是可以的
    // C#
    var v = new Vector3(1f,2f,3f);
    
  12. US 里面有一个 Function 类型,其对应的其实是 Boo.Lang.ICallable,实际是一个无类型的 functor 这种感觉。而 C# 里面有 delegate 和比较新的 System.ActionSystem.Func 这些强类型的东西,也要好用不少。

    // US
    private var callback : Function = Foo;
    private function Foo(s : String) : void {...}
    // C#
    private Action<string> callback = Foo;
    private void Foo(String s) {...};
    
  13. US 为了表现的跟 JavaScript 比较像,加入了很多内置函数并且对默认的类型做了修改。比如有小写的 array.lengthparseInt() 这些方法。

    // US
    Debug.Log(arr.length);
    parseInt("123");
    // C#
    Debug.Log(arr.Length);
    int.Parse("123");
    
  14. C# 有很多功能 UnityScript 里面是没法用的,比如泛型,比如 event/delegate,比如 struct,比如 ref/out,比如运算符重载。很多都是 UnityScript 里面有办法用但是没办法写新的,有时候就很让人头疼。

最后如果你对 UnityScript 的语义感兴趣的话,一个好办法是拿 .Net 反编译器(比如开源的 ILSpy) 打开其 DLL 编译结果 (在项目目录下的 Library/ScriptAssemblies/Assembly-UnityScript.dll) 看看其对应的 CIL。

最后

如果你现在要开始一个新的 Unity3D 项目,那么千万别用 UnityScript。它编译速度慢,很难找到一份完整的文档,而且实际项目中我遇到过直接其编译结果直接出错,需要再代码上做毫无意义的修改来避开问题的情况。相对的 C# 是一门挺成熟的语言,你有更多的文档和工具来辅助你的开发。如果你已经在用 UnityScript 写,那么希望本文能够帮助你对这个语言有更多了解。

推荐阅读更多精彩内容

  • 前段时间编译了一下Unity的Mono,看了很多相关的文章,也遇到很多新坑。所以来总结一下,加深自己对Mono的理...
  • 这个是我刚刚整理出的Unity面试题,为了帮助大家面试,同时帮助大家更好地复习Unity知识点,如果大家发现有什么...
  • 这个是我刚刚整理出的Unity面试题,为了帮助大家面试,同时帮助大家更好地复习Unity知识点,如果大家发现有什么...
  • Unity技术面试题 一:什么是协同程序? 答:在主线程运行时同时开启另一段逻辑处理,来协助当前程序的执行。换句话...
  • 一:什么是协同程序? 在主线程运行的同时开启另一段逻辑处理,来协助当前程序的执行,协程很像多线程,但是不是多线程,...