2014年11月20日 星期四

Jonathan Blow's Ideas About Programming Language雜記(2)



在上一個Talk中,獨立遊戲Braid製作人Jonathan Blow道出了針對高效能遊戲開發之程式語言的許多想法。Talk #2接續上個演講,有許多想法可能跟之前有重疊,但這次重點放在各種宣告方式(Declaration)與其對重構性(Factorability)的影響,在影片後段也提到了C++中lambda的Capture可以如何改進。部分觀念極大地挑戰了過去大家對編程方法最佳實踐(Best practice)的認知,整體來說依然精彩,筆記整理如下。

Talk #2 : Declarations and Factorability

Preface

在這個Talk的標題中,使用的是"Factorability"而非"Refactrobility",因為若是用Refactorbility聽起來好像在說:"Oh,我的Code寫的好爛,應該要來重構(Refactorbility)一下。"這不是我們所要討論的情形,我(Jonathan)想強調的是在開發的過程中,Code Base自然而然會經歷大大小小的增修,Code區塊到處移動的此種流動的現象,在我們設計程式語言時有哪些方式能支援,因此使用Factorbility是比較恰當的。

在上一個Talk中,花了那麼多時間,我想表達一個概念:傳統上被認為是best practice的程式設計法則遊戲程式設計師的實際經驗有巨大的鴻溝。這不是說我們這些寫遊戲程式的全部一致同意這想法,我們過去也曾經遵守這些best practice,但依我們長久以來的經驗,加上與許多程式設計同儕討論之後,我們"基本上同意",這些所謂的best practice裡面至少有一部分對程式設計而言反而是有破壞性的。

Insomniac公司的Engine Director-Mike Acton在CppCon 2014上的這個Talk就傳達了類似上述想法,大家可以參考看看。

而有一部分的程式語言,在設計上可能會強迫使用者去遵守某些best practice,例如Safety之類的,以我們的經驗來說,這樣子的設計太過理想化。可能有些程式語言的狂熱支持者,如C++、D或Rust粉絲會認為我又在大放厥詞,或者我根本就不懂這些程式語言,但實際情況並非如此。

What I Learned In Programming School

inline與函數

請看下面這段Code:

上半段的MajorFunction裡註解的意思是把MinorFunction1~3的內容inline寫進Code裡。

在學校裡,教授會教我們說:每當你要寫一個很長的函數(MajorFunction)的時候,要把可重覆使用的部分另外獨立成一個函數(MinorFunction1~3)。這麼做有許多好處,第一,獨立的函數比較好理解,以函數層級去理解整個程式也會比較好理解,因為有許多瑣碎的操作被封裝了,你可以用高階一點的概念去思考整個程式。第二,你增加了程式的重用性,因為這些獨立的函數可以被重複使用。第三,獨立的函數裡的操作,不會汙染到其他函數,減少了互相影響導致出錯的機會。基於以上優點,教授會強烈建議你一開始就把函數寫成下半部那樣,或是當你發現可以寫成小函數就馬上寫成小函數。

聽起來似乎很有道理,但是基於過去經驗,我開始了解這樣做是錯的。

為什麼?
當你把操作寫成 小函數,或許這個小函數本身真的比較好理解,但是對整個程式來說,你為這個程式引入了新的概念(i.e.你剛獨立出來的小函數),如果類似這種小函數越多,整體程式而言概念也就越多,而一個程式裡的函數越多,程式越難理解,因為本來直接了當的概念(inline)變的間接隱晦(MinorFunction)了。

為了理解整個程式你必須理解這些小函數內部的操作,還有他們之間的互動關係。而當概念變的間接隱晦,你會更難理解概念之間的互動,你必須把這些小概念在做什麼都記在你有限的短期記憶裡,這樣會讓你更難寫程式。

把操作寫成小函數為整個程式帶來哪些負擔呢?
舉例來說,當你把函數獨立出來,那表示整個程式都有可能可以呼叫這個函數,"哪些東西呼叫了這個函數?" 這就是你所必須多花費心力考慮的地方,你可以用IDE或editor幫助你解決這個問題,但顯然這樣讓事情變得更麻煩。

再來你的函數何時會被呼叫?被呼叫時必須滿足哪些前提?當你只是把操作inline寫在某個區塊裡,你所需要考慮的state就在這個區塊裡而已,但是當你把這些操作fator成小函數,你所需要考慮的就是整個程式的state變化了。

當然你可以用註解來減緩這個問題,但對程式設計師而言,寫正確的註解並非易事。且對程式而言,註解就是沒被跑過的Code,而我們知道沒被跑過的Code通常很有可能有Bug,而且你沒有有效的方法去驗證註解本身的正確性,因此註解幫助不大。

John Carmack的內部信

過去數年的程式設計經驗讓我開始有了上述想法,但我還不是非常肯定。直到有一天我讀了John Carmack在2007年寫給Id software內部員工的一封email:
The flight control code for the Armadillo rockets is only a few thousand lines of code, so I took the main tic function and started inlining all the subroutines. While I can't say that I found a hidden bug that could have caused a crash (literally...), I did find several variables that were set multiple times, a couple control flow things that looked a bit dodgy, and the final code got smaller and cleaner.
John Carmack說他曾經嘗試把原本是小函數的Code inline寫進main tick function,結果雖然沒有找到隱藏的足以導致火箭爆炸的bug,但是他發現有些變數被多次賦值,還有一些隱晦的流程控制,並且最後Code變得更精簡明晰。

其中跟遊戲相關的(當時John正進行Rage的開發):
If something is going to be done once per frame, there is some value to having it happen in the outermost part of the frame loop, rather than buried deep inside some chain of functions that may wind up getting skipped for some reason.
John Carmack:如果有些事情你每個frame都要做,與其讓這些事情深埋在一連串基於某些原因可能跳過這些事情的函數中,倒不如就直接把這些事情inline在main loop中。
Besides awareness of the actual code being executed, inlining functions also has the benefit of not making it possible to call the function from other places. That sounds ridiculous, but there is a point to it. As a codebase grows over years of use, there will be lots of opportunities to take a shortcut and just call a function that does only the work you think needs to be done. There might be a FullUpdate() function that calls PartialUpdateA(), and PartialUpdateB(), but in some particular case you may realize (or think) that you only need to do PartialUpdateB(), and you are being efficient by avoiding the other work. Lots and lots of bugs stem from this. Most bugs are a result of the execution state not being exactly what you think it is.
John Carmack:除了能知曉目前實際上哪些Code會被執行,inline函數還有個優點:他們不能在其他地方被呼叫。這聽起來似乎很荒謬,但這是有理由的。開發過程中Codebase會經歷數年的增刪修改,有很高的可能性有些人會圖方便呼叫某些小函數去完成他覺得那個函數會做的事。例如在main loop中可能有個FullUpdate()函數,其中呼叫PartialUpdateA(),PartialUpdateB()...有時候你會覺得我只需要做PartialUpdateB()就好,那我就直接Call PartialUpdateB()來做我想做的東西,這樣會比較有效率。但其實像這種事根本是Bug的根源。大部分的Bug都是因為目前的執行狀態並非如你所想。

大部分的Bug都是因為目前的執行狀態並非如你所想。
大部分的Bug都是因為目前的執行狀態並非如你所想。
大部分的Bug都是因為目前的執行狀態並非如你所想。

很重要所以要說三次。
當你把過多小函數獨立出來,你變相就在鼓勵這種Bug的發生。
而這些概念跟學校教我們的best practice是相反的,但很多程式語言的設計基本上是基於這些"best practice",如果我要做一個新的程式語言的話,這些並不是我想走的方向。

當然,像pure function的情形,這種情況factor out基本上是可以做的,雖然這樣做也會為程式引進新的概念,但至少他們不會造成許多bug。關於pure function我想以後再討論。

John Carmack在email後段寫到,我拿來總結以上想法:
I know there are some rules of thumb about not making functions larger than a page or two, but I specifically disagree with that now -- if a lot of operations are supposed to happen in a sequential fashion, their code should follow sequentially.
John Carmack: 我知道有些經驗法則說不要讓函數超過一兩個頁面,但我現在並不同意。如果有些操作本來就是循序發生,那這些操作所對應的Code就應該是循序的。

Locally-scoped Functions, Blocks and Codebase Evolution

如果程式語言中提供locally-scoped function的話倒是不錯的功能,因為local function不會造成某些問題:
1.我們不用擔心local function在其他地方被呼叫。
2.local function只跟local state有關。
因此local function是個不錯的想法。除此之外Local Block也有類似的效用,在C++中Local Block可以讓Code變得比較"衛生",他們可以防止該block中的變數去汙染到後續的code。另外Local block還有個好處就是他們提供一種方式讓你還不想factor out的相似操作可以暫時存在。
請看以下Code:
想像這段Code是一個大函數中的一部分,而且實際上我們不會只進行設定名子這種操作,請想像在同一個block中有很多其他操作在進行。一般來說,我們是以所謂"探索式"的方式在寫程式的,一開始其實大家還不確定最終成品會是什麼樣子,起初我們可能只是想實驗一些東西,在這種情況下其實是想到什麼就寫什麼,所以會有像上面一樣看似很糟糕的Code。像上述情形如果有local block的支援會讓我們方便許多,類似的操作可以獨立成一塊一塊的Block,變數不會互相干擾,而且這些區塊可以成為未來重構時函數的雛形,如下面這段Code:

當開發過了ㄧ段時間,我們已經大致確定要的是什麼東西之後,就可以把block中重複的部分重構成區域函數。上面這段Code中的lambda函數基本上就跟之前local block中的內容是一樣的。在C++中,目前必須用lambda來達成local function的功能。但據說目前C++的lambda有效能上的問題。據說實作上為了紀錄[]中capture的變數,會在heap上面allocate一個table來達成。
再過一段時間之後,有天你可能會想讓其他的函數也能呼叫這個maker,這時候我們終於可以將local function移到外面讓整個程式都看的到了,雖然我不確定這樣做是好是壞:
好,現在一定有人會問說:"為什麼不一開始就用最後這種寫法?"

最主要的理由是,現實世界是如此複雜,我們不是一開始就知道我們所面對的問題最佳解是什麼,尤其在遊戲這種程式環境中,我們甚至無法想像最種成品的樣貌,如果一開始就預設最後的解法會是怎樣而用上段Code的寫法來寫,以我過去的經驗是通常這些函數都必須重寫好幾次,與其用top-down的思考方式去寫程式,不如採用bottom-up的思維,我們一次實驗一些想法,慢慢趨近最佳解,讓問題本身來形塑他該有的解法。等開發了ㄧ段時間,你非常確定了你想要什麼東西之後,再來進行重構。

關於這種程式設計方式,Casey Muratori有篇不錯的文章,以The Witness中的UI系統為例說明Code重構的過程,大家可以參考看看。
http://mollyrocket.com/casey/stream_0019.html

註: Casey是Jonathan的朋友,除了The Witness外現在正進行Handmade Hero專案,台灣時間禮拜一到裡拜五每天正午12點在Twitch.tv上實況從無到有不用任何library/engine寫出一個跨平台2D遊戲。

註2: Casey的實況會有Q&A時間,Jonathan Blow有時候會上去搞笑,上次Jon問了一個問題:"What is Variable? Can Variable run javaScript?"    Casey: " No, javaScript is too high performance that Variable can't run it."  不論是問題還是回答都令人XD

Choosing a Syntax For Our New Language


為了支持前段所述的程式設計方式,我們的新語言必須要有新的與C/C++不同的語法,如你所見的,C/C++在這方面的支援並不好,像上一段我們把lambda函數外移成獨立函數時,函數的宣告語法改變了,這對常常需要移動Code的情況來說對生產力是有害的。

Sean's declaration syntax

在變數宣告部分,友人Sean的想法不錯:
我們用" :" 來宣告type,"="來表示assign。
如果想用type inference,就用第5行的寫法。
如果只是要assign值給已宣告的變數,就用最後一種寫法。
而第5行的寫法跟下面這個f: auto =1是一樣的。

不是說像這樣的宣告方式就是最好的方式,有些人可能會覺得Code中標點符號太多不太好,但我的重點是無論如何,宣告方式必須有一致且易於理解,只要符合這兩項特質,就是不錯的宣告語法。

那麼函數的宣告方式呢?
對於函數,我們希望宣告方式能:
1.方便在local/global之間剪貼移動。
2.方便在匿名/非匿名/lambda之間切換。
3.方便在class method與非method之間切換。(由於不確定class method是不是好東西,這次暫不討論這點)

(註:這邊筆者省略一段Jon舉的將C++中lambda改成一般函數的過程,這邊大家只要知道1.C++在lambda/函數的宣告方式上不一致 2.Code有像上一大段所述的演化過程 就行了。)

在C++之中,一般函數與lambda在宣告與type上都不一致:
lambda前面必須要有[] capture,用來表示這個lambda可以存取哪些非local變數,C++用這個可以達成類似closure的功能。

在Rust之中,lambda跟一般函數的宣告也有差異,lambda的傳入參數要用奇怪的pipe符號(|)包起來,而且函數本體不用加{}。:

不知道基於何種原因Rust會這樣設計語法,或許是因為實作或Parsing上的方便之類的。不過至少Rust在lambda的跟函數的type上是一樣的。

(註: 筆者猜因為lambda的起源是lambda calculus,數學上等式只能以一行expression來表達,不能用multiple statement,很多語言lambda body只能有expression,例如python。 只能寫一行的東西若還要{}包起來語法上反而冗長,所以才會有這樣的設計。)

What I Learned In Programming School: The Good Part

Anonymous Functions (Lambdas) Are Not Big Deal

學校教的內容也有好的部分,其中之一就是:"匿名函數(or lambda)不是什麼大不了的東西。他們只是沒有名子的函數。"這些匿名函數是設計來方便讓你用在"可以用完即丟"的情形。

匿名函數(or Lambda)本質上跟一般函數沒有差異,但現在很多程式語言的設計思維卻把兩者看成不一樣的東西,或是把支援Lambda當作了不起的feature,當你秉持這樣的想法去設計成式語言時,反而跟匿名函數或lambda原本的設計目的背道而馳。

這邊來看一個例子,在學校時我們教的是Scheme語言,這是一個Lisp的方言,有許多不同的實作。我不會建議大家在產業界使用Scheme,因為對我來說他有許多缺點,例如它不是static typing,語法有些怪異難讀,效能有疑問,最重要的理由是它有Garbage collection。然而Scheme作為教學用語言能夠讓你學習許多程式語言的原理,從這觀點看Scheme是相當不錯的。

在Scheme中要定義一個函數,你會用define去定義一個symbol,在此例中是square。如果你需要參數則在square後面加上一個符號,如此例中的x。後面的(* x x)則是function body。Scheme的運算是prefix,*號寫在前面(實際上"*"也是一個函數)。

當你要定義local symbol你必須用let,基本上let跟define沒什麼不同。要用lambda時,你必須加上lambda關鍵字,後面加上參數跟函數本體。行3的語意是:"定義一個symbol square,square代表lambda (x) (* x x)"。

初看你會覺得scheme在lambda跟一般函數的宣告語法上也有差異,但實際上呢,在scheme中,define只是一個語法糖,最終它會被展開成此例中行5的寫法,也就是函數其實就是有對應symbol(有名子)的lambda。當你比較行3跟行5的語法,除了let跟define不同外其餘是完全一致的。

會讓人覺得lambda跟一般函數不同的地方在於,lambda可以有closure。當你在lambda中用到外部scope的變數時,那個變數的lifetime會跟lambda黏在一起,那個變數在他原本的scope結束時不會從stack上pop掉,而會存在heap上,跟你的lambda包在一起同生共死,我們把lambda還有它的環境(也就是你用到的外部scope變數)叫做closure。

而很多程式語言基於以上原因把函數跟lambda當作不一樣的東西來處理,例如C++中為了讓lambda可以支援類似closure的功能,而且又為了效能問題(不想把外部scope變數存在heap上,因為fragmentation跟較高的cache miss rate)讓你必須用[] capture外部scope的變數到lambda函數的stack上...這種作法只是為了效能的最佳化,為此而造成函數與lambda語法與實作都不一致,為程式語言引進兩種不同概念造成Programmer的負擔,這樣是不好的。

所以在我們的新程式語言中,函數的宣告語法目前是如此設想的:

如行1,我們用跟之前變數宣告一樣的語法來宣告函數,":="表示同時給它type還有值。而在行3中,第三個參數的部分則是一個lambda,請注意這個lambda跟行1的函數本體完全一模一樣。有些人可能會覺得return type部分(-> float)有些冗長,但如果你想要的話我們可以用type inference推導出return type,可以少寫一點code,但我還不確定這樣是好是壞,因為return type可以當作函數的良好註解之一。

再來行7的部分,跟變數一樣,這邊我們只給它type。
行8,跟行1是一樣的東西。
行10,如果f已經被宣告type,這時你可以直接assign另一個現存的函數給它。
行11,同上,只是我們直接把函數本體寫出來。
行12,這是一個類似map用來處理array的函數,我們給它一個自訂函數讓它處理array中的元素。

總之,不管在哪個地方,使用函數的語法都是一致的。

有些人可能會認為這些只不過是語法上的小伎倆,但我必須告訴你過去幾年跟C++搏鬥下來,我發現語法是非常重要的東西,這關乎程式設計的舒適度,不良的語法設計會讓你士氣低落,會讓你覺得不管做什麼事好像都是在沼澤中前進一樣痛苦。就像我希望我的生命是有意義的,我希望我key進的每個字元都是有意義的!

Capture !

在C++中,capture是放在函數的type前面,而在我們提出的新語言中,capture放在block前面。

為何要放在block前?第一,capture並不是type的一部分,type的宣告最好就放在左邊,這樣不會破壞之前一直強調的語法一致性。如果把capture放在type前,萬一當你搞錯type時,你要修改type就沒那麼容易,因為你必須把capture也改掉,反之如果type是獨立在左邊的,你要修改的話就直接copy左邊的部分貼上就好了。

再來如果capture變成block的一種屬性的話,這似乎是個有趣的想法,我們可以讓每個block也都跟函數一樣有capture:
之前提到在Codebase的演化過程中,local block可以成為之後重構函數的雛形,但是local block本來就可以存取外部scope的變數,有必要用Capture嗎?如果有Capture,這個Capture可以限制這個local block可存取的外部scope變數,這樣可以防止block內的東西汙染到其他code,而且如此一來block更像函數了,這對未來的重構也是有幫助的,正如上段Code中,我們可以發現,block可以在local block->local block with capture->local lambda ->local or global named function四個階段之間輕鬆的漸變,且格式一致。

另外,Capture可能也對thread safety有幫助。通常一開始我們會用single-thread, synchronous的方式來寫Code並測試,之後再擴展成Multi-thread,而基本上測試時都會先確保single-thread情形下的正確性。當你擴展成multi-thread的時候,很多Thread可能會存取一堆共享或非共享的變數,改變整個程式的狀態。如果有Capture的話,我們至少可以在compile time時先確定,某段非同步的block裡可以存取哪些外部scope變數,如果某個block違反了capture,compiler可以先報錯。如此一來大大縮小了可能出錯的Code範圍。不過這邊只是我的猜想,還沒實作之前並不能驗證實用性。(註:筆者這邊省略一些code sample)

Capture也能用在Global function上,限制Global function所能存取的global state,這可以讓不純的函數更趨近於純函數。如下面這個pathfind函數:

像這個函數,我們在debug時可能需要存取一些Debug用的視覺化資訊,這時候如果把那些資訊放在capture內,可以確保這個global function不會碰到其他global變數,如果函數內用到其他global則compile time時報錯。

這邊用雙重[]是用來表示
"1.此函數中其他函數可以存取其他global跟
  2.只要在此函數中,任何函數呼叫都只能存取capture中的global "
中2.的行為。

像上述的capture設計,希望能開啟大家的討論空間。

Final Thoughts

Most of you have probably read various popular articles about the development process that produces the space shuttle software, and while some people might think that the world would be better if all software developers were that "careful", the truth is that we would be decades behind where we are now, with no PC's and no public internet if everything was developed at that snail's pace.
(筆者:最後這邊Jon講了一點Global的必要性跟引用John Carmack的信件做結尾,內容上跟之前講的差不多,我就連QA也一起省略了。)

(實作Demo請參考下一篇,To be continued...)



沒有留言: