The Magic of Dynamic Language (Introduction Part)
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" + 2n
輸出結果:
Traceback (most recent call last):n File "<stdin>", line 1, in <module>nTypeError: cannot concatenate str and int objectsn
這裡提到的一些概念在知乎上也有一個討論可以參考:《弱類型、強類型、動態類型、靜態類型語言的區別是什麼?》。
這裡引用其中何幻回答中提到的《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():n a = 1n b = 2n c = an a = bn b = cnndef swap2():n a = 1n b = 2n a, b = b, an
之前如果要交換兩個變數的值,通常要寫一個小函數專門來封裝交換操作,在Python和Lua中,只需要這樣一行非常直觀的代碼就可以實現,簡介易讀,兼職詮釋了語法糖的魅力所在。但你有沒有想過這一個簡單的語法糖魔法是如何實現的?
3.3 操作碼
Python有一個dis庫可以用來查看一段代碼的Python操作碼,我們可以來dis一下這兩段代碼來看下他們在Python中的實現。
import disndis.dis(swap1)ndis.dis(swap2)n
swap1函數的輸出的結果是:
2 0 LOAD_CONST 1 (1)n 3 STORE_FAST 0 (a)nn 3 6 LOAD_CONST 2 (2)n 9 STORE_FAST 1 (b)nn 4 12 LOAD_FAST 0 (a)n 15 STORE_FAST 2 (c)nn 5 18 LOAD_FAST 1 (b)n 21 STORE_FAST 0 (a)nn 6 24 LOAD_FAST 2 (c)n 27 STORE_FAST 1 (b)n 30 LOAD_CONST 0 (None)n 33 RETURN_VALUE n
swap1這個函數的操作碼比較簡單,就是通過寄存器進行賦值和交換的操作,Python的操作碼還是比較易讀的,這裡沒有什麼隱晦的東西,大家可以通過字面意思來進行理解。首先把1賦值給變數a,然後把2賦值給變數b,然後把a賦值給c,把b賦值給a,把c賦值給b,就是代碼字面的意思。
9 0 LOAD_CONST 1 (1)n 3 STORE_FAST 0 (a)nn 10 6 LOAD_CONST 2 (2)n 9 STORE_FAST 1 (b)nn 11 12 LOAD_FAST 1 (b)n 15 LOAD_FAST 0 (a)n 18 ROT_TWO n 19 STORE_FAST 0 (a)n 22 STORE_FAST 1 (b)n 25 LOAD_CONST 0 (None)n 28 RETURN_VALUE n
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():n a = 1n b = 2n c = 3n a, b, c = c, a, bnndis.dis(swap3)n
輸出的結果如下:
1 0 LOAD_CONST 1 (1)n 3 STORE_FAST 0 (a)nn 2 6 LOAD_CONST 2 (2)n 9 STORE_FAST 1 (b)nn 3 12 LOAD_CONST 3 (3)n 15 STORE_FAST 2 (c)nn 4 18 LOAD_FAST 2 (c)n 21 LOAD_FAST 1 (b)n 24 LOAD_FAST 0 (a)n 27 ROT_THREE n 28 ROT_TWO n 29 STORE_FAST 0 (a)n 32 STORE_FAST 1 (b)n 35 STORE_FAST 2 (c)n 38 LOAD_CONST 0 (None)n 41 RETURN_VALUE n
這次又出現了一個操作碼——ROT_THREE
。那四個數字互換呢?
def swap4():n a = 1n b = 2n c = 3n d = 4n a, b, c, d = d, c, b, anndis.dis(swap4)n
這裡的輸出省略掉複製的部分,只截取交換的操作部分。
68 24 LOAD_FAST 3 (d)n 27 LOAD_FAST 2 (c)n 30 LOAD_FAST 1 (b)n 33 LOAD_FAST 0 (a)n 36 BUILD_TUPLE 4n 39 UNPACK_SEQUENCE 4n 42 STORE_FAST 0 (a)n 45 STORE_FAST 1 (b)n 48 STORE_FAST 2 (c)n 51 STORE_FAST 3 (d)n 54 LOAD_CONST 0 (None)n 57 RETURN_VALUE n
可以看到這裡用的操作碼是BUILD_TUPLE和UNPACK_SEQUENCE,也就是說它像函數的返回值那樣,把四個變數打包成一個列表,然後再解出來。可以自己測試數量更多的情況下也是用的這種方式。
3.5 運行效率
我們把目光回到兩個變數的值交換上來,從操作碼的數量來看swap函數更短,那是否意味著它的運行速度更加快呢?我們使用timeit
模塊來進行一下驗證:
import timeitnn = 10000000nprint timeit.Timer(swap1(), from __main__ import swap1).timeit(n)nprint timeit.Timer(swap2(), from __main__ import swap2).timeit(n)n
在PC上運行一千萬次的輸出結果:
1.31397167707n1.23587510811n
和預期的一樣,函數swap2更快一些。那Python底層是如何實現ROT_TWO這樣的操作碼達到更快的運行效率呢?我們可以來看下Python 2.7版本中的ceval.c文件中的一段代碼:
case ROT_TWO:n v = TOP();n w = SECOND();n SET_TOP(w);n SET_SECOND(v);n goto fast_next_opcode;nn case ROT_THREE:n v = TOP();n w = SECOND();n x = THIRD();n SET_TOP(w);n SET_SECOND(x);n SET_THIRD(v);n goto fast_next_opcode;n
在Python 2.7版本中,有一個大大的switch-case語句處理了大部分的操作碼,如果你想了解BUILD_TUPLE
和UNPACK_SEQUENCE
的實現,也可以在這個文件中找到。對於ROT_TWO這個操作碼,他就是把棧頂的兩個元素取出來,然後在反向壓入棧里。用C的方式實現,效率上肯定比在Python層做要高一些,這就驗證了我們的實驗結果。
3.6 總結
針對a, b = b, a
這樣一個簡單語法糖的思考和探索,我們了解了Python虛擬機基於操作碼的設計理念,也深入到了Python源碼的層面查看了幾個操作碼的實現原理。雖然這只是Python語言實現的一個小點,但這一番探索,也給了我們一些學習語言的思考和啟示。
- 編寫更加Python化(Pythonic)的代碼。有時候語言提供的語法糖不僅開發效率高,易讀性強,運行效率也可能做過特殊的優化,效率更高。
- 保持好奇心,思考更多,收穫更多。學習語言的時候,多想一些背後的原理,可以學習到更多有趣的知識。
- 學習語言的過程。我們可以把學習語言的過程歸納為:
知道(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的代碼替換流程
即服務端把要更新的代碼序列化到一個buffer中,通過網路把這段數據傳送給客戶端,客戶端反序列化之後,使用新的代碼替換掉舊的邏輯,就實現了在玩家毫無感知的情況下更新代碼修復bug的功能。在Python語言中,簡單來說,只需要替換掉function的code屬性部分,就可以實現代碼邏輯的替換,如下圖所示:
Python函數對象替換
在Python語言中,「萬物皆對象」,而我們在遊戲開發中所有進行替換的對象通常有兩大類:Function和Method。我們先來看一下function的替換。
4.2 熱更新Function
我們來構建一個demo演示下hotfix一個函數的流程。首先定義一個calculator模塊,裡面有一個做加法的函數add()。
#calculator.pynndef add(a, b = 10):n return a + bn
然後在另外一個function模塊中使用calculator模塊,首先驗證add函數的功能,然後自己定義一個做乘法的mul模塊,把calculator的add方法替換成mul,再次調用的時候就會看到輸出的結果發生了改變,這就模擬的一個函數被替換的過程,代碼和輸出如下:
#function.pynnimport calculatornnprint calculator.add(2) #12nndef mul(a, b = 20):n return a * bnncalculator.add = mulnprint calculator.add(2) #40n
然後我們通過time.sleep函數來模擬進程不退出的情況下修改代碼的流程:
print calculator.add(2) #12nimport timen time.sleep(6)n#在此過程中把add函數中的a + b 修改為 a * b,並且保存文件。n reload(calculator)nprint calculator.add(2) #20n
可以看到通過使用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>nnprint dir(calculator.add) n#[__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]nnprint calculator.add.func_name #addnnprint calculator.add.func_code #<code object add at 030F6C80, file "xxxThe Magic of Dynamic LanguageIntroductioncalculator.py", line 1>nncalculator.add.func_code = mul.func_codennprint calculator.add(2) #20nnprint calculator.add.func_defaults #(10,)n
Python的Code object也可以在運行時來編譯生成,有興趣的朋友可以擴展閱讀這篇文章:《Exploring Python Code Objects》。而如果你想了解Python對於function對象的實現,可以去閱讀Python源碼中的funcobject.c文件。
4.3 熱更新Method
在遊戲開發中,面向對象的開發方式也是經常被使用的。而在Python中,方法(Method)和函數(Function)是有區別的,它們是兩個不同的類型的對象。這裡給一段簡單的示例代碼來描述類上方法的hotfix過程。
class A(object):n def foo(self):n print "a"nna = A()nndef foo_new(self):n print "b"nnA.foo = foo_newnna.foo() #bn
可以看到,Class A的foo方法可以由輸出a被替換成輸出b,實現了邏輯的修改,然而對於method其實並沒有這麼簡單,這背後有著Bound Method和Unbound 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日晚 於杭州家中
推薦閱讀: