美文网首页游戏开发
The Magic of Dynamic Language (I

The Magic of Dynamic Language (I

作者: 董夕 | 来源:发表于2017-11-01 15:51 被阅读104次

    10月份准备UWA杭州站的技术分享,博客的内容也就没有太多时间准备。但是每个月一篇技术博客的规则还是不要打破地好,因此“偷懒”整理了一下之前在公司内部进行的一场分享,作为10月的博客。

    1. 前言

    公司内会不定期地组织一些内部的技术分享,内容大都集中在大家正在开发的功能模块的实现或者技术方案上。这些分享起到了很好的效果,比如降低了平常沟通实现方案的成本,也可以让更多的人帮忙思考下目前方案可能潜在的问题或者更好的改进方法。

    在这一系列更偏实战,或者说更偏“工厂技术”的分享之外,我期望开辟一系列可以提升研发团队的基本技能和素养,提供长线发展动力的分享内容。这些内容尽量着眼在技术实现原理的探讨基本编程技能的思考这样相对没那么“实用”,但是同样很有价值的方面。毕竟“生活不止眼前的苟且,还有诗和远方的田野”。

    第一个系列的主题我选择了动态语言(Dynamic Language)。一方面,作为“猪厂”毕业的学生,对于动态语言的使用是从入学前就开始培养和训练的——入职作业中就有使用Python和Lua语言实现要求功能的部分,经过几年的学习和使用,对于动态语言也逐渐有了一些了解和自己的认识;另外一方面,动态语言在团队内使用还比较广泛,受众比较大,通用性也更强。现在公司的团队中客户端部分的逻辑开发主要使用Lua这门动态语言;服务端则是使用的C语言,但会使用Python构建更新、发布等工具链;Web后端也选择了基于Python的技术栈,前端也会用到JavaScript这样的动态语言。

    当然,已经有大量的文章和书籍在讲动态语言这样的主题,我并不期望自己可以比他们讲得更好,也不会去涉及那么多细节,而是以一种“管中窥豹”的方式,从一个小点往深处探究,来讨论学习语言的方法和应该达到的深度,并希望最终可以把这些学习到的知识应用到实际开发过程中。因此这一系列的主题叫做《The Magic of Dynamic Language》,动态语言的魔法,我们来看下动态语言的一些有意思的“魔法”,并一起来探求这些魔法背后的实现原理。

    这篇文章会是这一系列的第一部分——介绍,主要会包括动态语言的定义,讨论一个简单的语法糖背后的实现魔法,聊一聊动态特性在工程上的应用这三个部分。

    说明:这一系列中主要的例子和代码分析会以Python 2.7为主,原因只是因为这是笔者最为熟悉的动态语言。当然,原理是相通的。

    2. 什么是动态语言

    首先,我们来看下什么是动态语言。我列举了一下常用的几门语言,读者可以先思考下这几门语言是否是动态语言:

    • C/C++
    • Java/C#
    • Python/Lua
    • Golang

    2.1 基本定义

    也许在你心中已经有了一些答案,那接下来可以思考下你是基于什么来做出判断的?这些语言中的哪些特性让你认为它是或者不是一门动态语言?
    比如C#需要编译,所以它算静态语言?那它在后来的新版本中添加了很多动态特性之后呢?它是否可以被称为动态语言呢?

    如果心中有疑问,那我们来看一下相对权威的Wikipedia对于动态语言的定义:

    Dynamic programming language, in computer science, is a class of high-level programming languages which, at runtime, execute many common programming behaviors that static programming languages perform during compilation. These behaviors could include extension of the program, by adding new code, by extending objects and definitions, or by modifying the type system. Although similar behaviours can be emulated in nearly any language, with varying degrees of difficulty, complexity and performance costs, dynamic languages provide direct tools to make use of them. Many of these features were first implemented as native features in the Lisp programming language.

    简单来说,动态语言可以在运行时执行一些静态语言在编译时才可以执行的编程行为,比如添加新的代码,扩展对象和定义,修改类型系统等等。静态语言也可以具有这些行为,但是实现比较困难或者性能消耗更大。

    2.2 概念区分

    这里需要和几个概念做一些区分和讨论:

    • 动态类型(Dynamically typed )和静态类型(Statically typed)。所谓的静态类型和动态类型的差异是类型检查的时机,动态类型在运行时进行类型检查,而静态类型在编译期做这个操作。注意:大部分动态语言是动态类型,而非全部。
    • 脚本语言(Scripting Language)。虽然动态语言通常又被称为脚本语言,但它们也并不完全等价,通常把提供运行时环境(runtime language)的语言叫做脚本语言。
    • 强类型(Strongly typed)和弱类型(Weakly typed)。如果在一种语言里,值的类型不依赖于怎样使用它,那这种语言是强类型,否则是弱类型。比如同样去执行"1" + 2这样一段代码,如果结果是"12"这样的字符串,这种语言可以说是弱类型的,因为2这样一个值,在这里被当作一个字符串进行处理。

    思考:Python是强类型还是弱类型?

    动态语言和类型的强弱没有特别明确的关联关系,比如Python语言是动态语言,但它其实是强类型的,因为针对它来说——变量是无类型的,但是值(对象)是有类型的

    "1" + 2
    

    输出结果:

    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: cannot concatenate 'str' and 'int' objects
    

    这里提到的一些概念在知乎上也有一个讨论可以参考:《弱类型、强类型、动态类型、静态类型语言的区别是什么?》
    这里引用其中何幻回答中提到的《Type Systems》中的图,从语言设计的角度来看语言类型的种类。有兴趣了解更多的同学可以直接去看原文。

    语言类型定义语言类型定义

    红色区域外:well behaved (type soundness)
    红色区域内:ill behaved
    如果所有程序都是灰的,strongly typed
    否则如果存在红色的程序,weakly typed
    编译时排除红色程序,statically typed
    运行时排除红色程序,dynamically typed
    所有程序都在黄框以外,type safe

    2.3 结论

    说了这么多,对于动态语言其实没有非常准确的定义,现在新的语言设计都在越来越多的借鉴动态语言和静态语言的好处,比如Golang虽然是静态语言,但是在语言最初设计的时候就加入了很多动态语言才有的特性。动态语言和静态语言的边界也越来越模糊,所以说——

    There is no black or white.

    3. 语法糖

    3.1 定义

    语法糖(Syntactic Sugar)虽然不是动态语言的专利,比如C语言中a[i]就是一种语法糖,等价于*(a+i),但它让动态语言表现出了更加高效的开发方式。我们依然通过Wikipedia来看下语法糖的定义:

    In computer science, syntactic sugar is syntax within a programming language that is designed to make things easier to read or to express. It makes the language "sweeter" for human use: things can be expressed more clearly, more concisely, or in an alternative style that some may prefer.

    语法糖并不会影响语言的功能,只是方便程序员使用,更加清晰、简洁。以Python语言为例,常用的语法糖有不少:

    • Decorator
    • with关键字
    • for-else,try-else
    • 0 < x < 10
    • 列表推导式
    • ……

    3.2 a, b = b, a

    这里不去讨论每一个语法糖的细节,只聊一个虽然很简单但对于我来说印象深刻的语法糖。

    a, b = b, a

    大约是在六七年前,我接触到的第一门动态语言是Lua,当时在《Programming in Lua》中看到这样的使用方法,对于一直在用C++、JAVA这样静态语言的我来说,当时的感觉是这比之前写的变量交换的代码都要舒服多了啊!

    def swap1():
        a = 1
        b = 2
        c = a
        a = b
        b = c
    
    def swap2():
        a = 1
        b = 2
        a, b = b, a
    

    之前如果要交换两个变量的值,通常要写一个小函数专门来封装交换操作,在Python和Lua中,只需要这样一行非常直观的代码就可以实现,简介易读,兼职诠释了语法糖的魅力所在。但你有没有想过这一个简单的语法糖魔法是如何实现的?

    3.3 操作码

    Python有一个dis库可以用来查看一段代码的Python操作码,我们可以来dis一下这两段代码来看下他们在Python中的实现。

    import dis
    dis.dis(swap1)
    dis.dis(swap2)
    

    swap1函数的输出的结果是:

      2           0 LOAD_CONST               1 (1)
                  3 STORE_FAST               0 (a)
    
      3           6 LOAD_CONST               2 (2)
                  9 STORE_FAST               1 (b)
    
      4          12 LOAD_FAST                0 (a)
                 15 STORE_FAST               2 (c)
    
      5          18 LOAD_FAST                1 (b)
                 21 STORE_FAST               0 (a)
    
      6          24 LOAD_FAST                2 (c)
                 27 STORE_FAST               1 (b)
                 30 LOAD_CONST               0 (None)
                 33 RETURN_VALUE  
    

    swap1这个函数的操作码比较简单,就是通过寄存器进行赋值和交换的操作,Python的操作码还是比较易读的,这里没有什么隐晦的东西,大家可以通过字面意思来进行理解。首先把1赋值给变量a,然后把2赋值给变量b,然后把a赋值给c,把b赋值给a,把c赋值给b,就是代码字面的意思。

     9           0 LOAD_CONST               1 (1)
                  3 STORE_FAST               0 (a)
    
     10           6 LOAD_CONST               2 (2)
                  9 STORE_FAST               1 (b)
    
     11          12 LOAD_FAST                1 (b)
                 15 LOAD_FAST                0 (a)
                 18 ROT_TWO             
                 19 STORE_FAST               0 (a)
                 22 STORE_FAST               1 (b)
                 25 LOAD_CONST               0 (None)
                 28 RETURN_VALUE  
    

    swap2函数的操作码就比较短了,这里用了一个特殊的操作码ROT_TWO,通过字面意思它就是做两个数字的交换,这里有每个操作码的解释:dis

    ROT_TWO(): Swaps the two top-most stack items.

    前面针对a和b的赋值逻辑之后,将b的值压栈,然后将a的值压栈,使用ROT_TWO交换栈顶的两个元素,然后分别从栈中取出值赋给a,再取值赋给b,就完成了a和b值的交换。

    3.4 更多好奇心

    那好奇心很强的我又有一个疑问——如果是三个数字的互换呢?研究方法和上面一样,写一段代码,然后dis看下操作码是什么。

    def swap3():
        a = 1
        b = 2
        c = 3
        a, b, c = c, a, b
    
    dis.dis(swap3)
    

    输出的结果如下:

     1            0 LOAD_CONST               1 (1)
                  3 STORE_FAST               0 (a)
    
     2            6 LOAD_CONST               2 (2)
                  9 STORE_FAST               1 (b)
    
     3           12 LOAD_CONST               3 (3)
                 15 STORE_FAST               2 (c)
    
     4           18 LOAD_FAST                2 (c)
                 21 LOAD_FAST                1 (b)
                 24 LOAD_FAST                0 (a)
                 27 ROT_THREE           
                 28 ROT_TWO             
                 29 STORE_FAST               0 (a)
                 32 STORE_FAST               1 (b)
                 35 STORE_FAST               2 (c)
                 38 LOAD_CONST               0 (None)
                 41 RETURN_VALUE  
    

    这次又出现了一个操作码——ROT_THREE。那四个数字互换呢?

    def swap4():
        a = 1
        b = 2
        c = 3
        d = 4
        a, b, c, d = d, c, b, a
    
    dis.dis(swap4)
    

    这里的输出省略掉复制的部分,只截取交换的操作部分。

     68          24 LOAD_FAST                3 (d)
                 27 LOAD_FAST                2 (c)
                 30 LOAD_FAST                1 (b)
                 33 LOAD_FAST                0 (a)
                 36 BUILD_TUPLE              4
                 39 UNPACK_SEQUENCE          4
                 42 STORE_FAST               0 (a)
                 45 STORE_FAST               1 (b)
                 48 STORE_FAST               2 (c)
                 51 STORE_FAST               3 (d)
                 54 LOAD_CONST               0 (None)
                 57 RETURN_VALUE 
    

    可以看到这里用的操作码是BUILD_TUPLE和UNPACK_SEQUENCE,也就是说它像函数的返回值那样,把四个变量打包成一个列表,然后再解出来。可以自己测试数量更多的情况下也是用的这种方式。

    3.5 运行效率

    我们把目光回到两个变量的值交换上来,从操作码的数量来看swap函数更短,那是否意味着它的运行速度更加快呢?我们使用timeit模块来进行一下验证:

    import timeit
    n = 10000000
    print timeit.Timer('swap1()', 'from __main__ import swap1').timeit(n)
    print timeit.Timer('swap2()', 'from __main__ import swap2').timeit(n)
    

    在PC上运行一千万次的输出结果:

    1.31397167707
    1.23587510811
    

    和预期的一样,函数swap2更快一些。那Python底层是如何实现ROT_TWO这样的操作码达到更快的运行效率呢?我们可以来看下Python 2.7版本中的ceval.c文件中的一段代码:

            case ROT_TWO:
                v = TOP();
                w = SECOND();
                SET_TOP(w);
                SET_SECOND(v);
                goto fast_next_opcode;
    
            case ROT_THREE:
                v = TOP();
                w = SECOND();
                x = THIRD();
                SET_TOP(w);
                SET_SECOND(x);
                SET_THIRD(v);
                goto fast_next_opcode;
    

    在Python 2.7版本中,有一个大大的switch-case语句处理了大部分的操作码,如果你想了解BUILD_TUPLEUNPACK_SEQUENCE的实现,也可以在这个文件中找到。对于ROT_TWO这个操作码,他就是把栈顶的两个元素取出来,然后在反向压入栈里。用C的方式实现,效率上肯定比在Python层做要高一些,这就验证了我们的实验结果。

    3.6 总结

    针对a, b = b, a这样一个简单语法糖的思考和探索,我们了解了Python虚拟机基于操作码的设计理念,也深入到了Python源码的层面查看了几个操作码的实现原理。虽然这只是Python语言实现的一个小点,但这一番探索,也给了我们一些学习语言的思考和启示。

    1. 编写更加Python化(Pythonic)的代码。有时候语言提供的语法糖不仅开发效率高,易读性强,运行效率也可能做过特殊的优化,效率更高。
    2. 保持好奇心,思考更多,收获更多。学习语言的时候,多想一些背后的原理,可以学习到更多有趣的知识。
    3. 学习语言的过程。我们可以把学习语言的过程归纳为:

    知道(Know) --> 应用(Use) --> 理解(Understand) --> 扩展(Extend) --> 创造(Create) 这样五个阶段。

    像我们通过读书学习到Python和Lua可以使用a, b = b, a这样的语法糖是学习一门语言最初的步骤;然后我们记得在我们编写代码的时候应用他们;接着想这篇文章记录的一样,出于好奇心我们像今天一样去探究一下这个语法糖背后的实现原理,就到了理解它这一层次;而比如当我们有一个特殊的需求,需要“快速交换十个数字”的时候,我们可能自己修改Python的源码,提供一个ROT_TEN这样的操作码,就可以完成对于一门语言的扩展,在游戏开发中,对于脚本语言的扩展和改造用得尤其地多。最后,当你掌握了解了足够多的语言特性和实现原理之后,就可以考虑编程语言的最高境界——自己创造一门自己定义的语言,设计它的语法和语言特性,供别人使用。虽然在工程中能够做到扩展这一步就很不错了,但是程序员也应该有自己的梦想不是?~

    4. 动态特性的应用

    在比较深入地讨论了动态语言的一个语法糖之后,我们以Hotfix为例来看一下动态语言最为重要的动态特性在游戏开发中的应用。

    4.1 Hotfix定义

    这里先基于Python语言的实现,给Hotfix一个简单的定义:

    Change the code object of the function object at runtime.

    这也不是一个通用的定义,简单来说hotfix就是热更新,在不关闭应用(游戏客户端或者游戏服务器)进程的情况下,更新游戏逻辑的功能。之前发现有些开发者把Hotfix和Patch两种维护方式混淆了,不同人可能有不同定义,但是从严格的字面意义来说,运行时(Runtime)可以更改代码才真正应用了动态语言的动态特性。

    我们使用下面这种图来描述在应用的动态语言的情况下,进行Hotfix的一个简单流程:

    Hotfix的代码替换流程Hotfix的代码替换流程

    即服务端把要更新的代码序列化到一个buffer中,通过网络把这段数据传送给客户端,客户端反序列化之后,使用新的代码替换掉旧的逻辑,就实现了在玩家毫无感知的情况下更新代码修复bug的功能。在Python语言中,简单来说,只需要替换掉function的code属性部分,就可以实现代码逻辑的替换,如下图所示:

    Python函数对象替换Python函数对象替换

    在Python语言中,“万物皆对象”,而我们在游戏开发中所有进行替换的对象通常有两大类:FunctionMethod。我们先来看一下function的替换。

    4.2 热更新Function

    我们来构建一个demo演示下hotfix一个函数的流程。首先定义一个calculator模块,里面有一个做加法的函数add()。

    #calculator.py
    
    def add(a, b = 10):
        return a + b
    

    然后在另外一个function模块中使用calculator模块,首先验证add函数的功能,然后自己定义一个做乘法的mul模块,把calculator的add方法替换成mul,再次调用的时候就会看到输出的结果发生了改变,这就模拟的一个函数被替换的过程,代码和输出如下:

    #function.py
    
    import calculator
    
    print calculator.add(2)         #12
    
    def mul(a, b = 20):
        return a * b
    
    calculator.add = mul
    print calculator.add(2)         #40
    

    然后我们通过time.sleep函数来模拟进程不退出的情况下修改代码的流程:

    print calculator.add(2)         #12
    import time
    time.sleep(6)
    #在此过程中把add函数中的a + b 修改为 a * b,并且保存文件。
    reload(calculator)
    print calculator.add(2)         #20
    

    可以看到通过使用Python自带的reload功能,就能做到对于已经导入的模块代码进行重新加载,起到热更新的效果。通常在开发阶段,通过动态语言这样的特性,可以实现不需要重启游戏就可以进行代码逻辑的修改和调试,可以很大地提升开发效率。当然,要在正式的项目中实现hotfix的功能,通常并不能直接使用reload功能,因为reload会直接把所有的数据也重置掉,还需要额外的逻辑来保证运行时上下文的数据在hotfix之后依然正确。但整体的实现原理和reload相似,可以说reload函数已经实现了hotfix的核心精髓。

    在Python中,function也是对象,如果用dir来看的话,可以看到它有func_code、func_defaults等属性。其中func_code就是一个code object,可以利用对于它的替换来实现只替换代码部分。下面的代码给出了替换的一个简单示例:

    print calculator.add                #<function add at 0x0369D2B0>
    
    print dir(calculator.add)           
    #['__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__doc__', '__format__', '__get__', '__getattribute__', '__globals__', '__hash__', '__init__', '__module__', '__name__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'func_closure', 'func_code', 'func_defaults', 'func_dict', 'func_doc', 'func_globals', 'func_name']
    
    print calculator.add.func_name      #add
    
    print calculator.add.func_code      #<code object add at 030F6C80, file "xxx\The Magic of Dynamic Language\Introduction\calculator.py", line 1>
    
    calculator.add.func_code = mul.func_code
    
    print calculator.add(2)             #20
    
    print calculator.add.func_defaults  #(10,)
    

    Python的Code object也可以在运行时来编译生成,有兴趣的朋友可以扩展阅读这篇文章:《Exploring Python Code Objects》。而如果你想了解Python对于function对象的实现,可以去阅读Python源码中的funcobject.c文件。

    4.3 热更新Method

    在游戏开发中,面向对象的开发方式也是经常被使用的。而在Python中,方法(Method)和函数(Function)是有区别的,它们是两个不同的类型的对象。这里给一段简单的示例代码来描述类上方法的hotfix过程。

    class A(object):
        def foo(self):
            print "a"
    
    a = A()
    
    def foo_new(self):
      print "b"
    
    A.foo = foo_new
    
    a.foo()         #b
    

    可以看到,Class A的foo方法可以由输出a被替换成输出b,实现了逻辑的修改,然而对于method其实并没有这么简单,这背后有着Bound MethodUnbound Method隐含在其中,也是Python语言为了实现其动态特性所引入的特殊设计。具体的实验代码和原理分析在我的《Python进阶笔记》中有详细的描述,本文就不再详述了,可以阅读《Python进阶笔记(三)》这篇文章。

    如果你去读了上面提到的这篇文章,你应该可以理解Python在为了实现动态特性方面所做的努力,包括临时对象的设计、小对象缓冲池的使用,乃至对于其结合了引用计数和标记清除两种GC算法实现也会有更加深刻的理解,而这背后,也付出了性能上的代价。

    这里放一些讨论的结论供大家参考吧:

    • 在Python中,Bound/Unbound Method都是对象,本质上都是Function;
    • Python的绑定方法是绑定了一个实例对象,通常就是self,也就是这样才做到了调用方法的时候不需要手动传递self,在Lua语言中就需要自己来做self的传递,或者使用:这个语法糖;
    • Bound/Unbound Method几乎都是临时的实例对象,并使用缓冲池提升性能;
    • 当访问Bound/Unbound Method的时候,python会创建一个新的对象,然后在调用完毕之后销毁它。

    4.4 总结

    这里只讨论了hotfix的原理,如之前所说,要实现一套完整的hotfix流程,可以在玩家毫无感知的情况下进行bug的修复,甚至功能的增加,还需要很多额外的工作,针对不同类型的游戏也有不同需要注意的地方,比如我们如果使用帧同步来做游戏战斗逻辑的开发,对于hotfix应用时机的处理就有尤其的注意。这块东西不是本文的重点,也就不深入探讨了。

    从Hofix这个功能,我们看到了动态语言在游戏运营中应用动态特性的一个例子,它很强大,但是为了实现它,语言也付出了一些代价。

    5. 写在最后

    作为一个程序员,编程语言就像一个永不会过时的话题,旧的语言不断更新添加新的特性,新的语言也在各个领域不断出现得到更加广泛的应用。一个跟上时代的程序可能要不断的学习学习,才能够跟上这些语言更新换代的步伐。然而,很多语言的设计和原理又有很多共通点,值得我们深入去思考和探究。本文仅仅针对动态语言这一主题,从两个很小的“魔法”入手,从应用一直分析到源码,讨论和分析了魔法背后的实现原理。这一过程记录了我自己学习语言的过程,也希望可以引导读者对于动态语言产生学习和研究的兴趣。

    这是《The Magic of Dynamic Language》的第一部分《Introduction Part》,希望我后面有时间可以继续填这个坑,也欢迎读者来分享自己的见解和想法~

    2017年10月31日晚 于杭州家中

    相关文章

      网友评论

        本文标题:The Magic of Dynamic Language (I

        本文链接:https://www.haomeiwen.com/subject/ndrgpxtx.html