寫了這麼久的程序,很多人確定會疑問,計算的本質是什麼?對一臺圖靈機來講,那就是無限長的紙帶和可以自如移動的讀寫頭,這太抽象了。咱們今天嘗試換一種方式去理解計算:javascript
計算的本質是經過有限的步驟,讀入數據,將一串序列,轉換爲另一串序列,再將其輸出
這樣的概念甚爲樸素,你想一想看,計算機說白了,操做的不就是內存和外部設備嗎?咱們看看能從這個想法中探尋出什麼。java
從小學時候,咱們就知道怎麼去讓高級的計算器幫助計算數學題:
(2+3)*20/(12+7)
我一直幻想,若是編程也能像上面這個式子同樣簡單該多好。既然有了定義序列的轉換,那如何表示操做符呢?最簡單的想法,把操做符放在序列的開頭位置,而剩下的部分是該操做符的參數:
(Revert A,B,C) -> (C,B,A)
相似的,咱們也能夠有:
(+ 2 3) -> (5)
將運算符前置,避免了運算符優先級和可變參數的問題,反正一個括號裏後面的元素都是前面操做符的參數。
將運算符也當作數據,這很容易理解,好比switch-case,傳入不一樣參數,函數的行爲就有所不一樣。不一樣的運算符決定了不一樣的處理流程。將它們都當作數據,你會看到更強大的表達能力。
聽過那門古老人工智能語言Lisp的同窗說,這種括號表達式,不就是Lisp的s-表達式麼。確實如此,咱們就將這種表達方式叫作Lisp。node
扁平的記錄方式是計算機最喜歡的,由於能方便快速地尋址;但若是能將數據分門別類進行組織,那更符合人的思考模式。所以咱們引入更多的層級:
(+ (+ 2 3) (+ 5 6))
這樣的層次結構,模擬告終構體和類。回到計算的本質這個問題,能夠修改成:
計算是將一棵樹,變換爲另一棵樹。
固然,計算確定是有目標的,通常來講,計算是將樹不斷地規約,讓其變得愈來愈簡單,直到不能更簡單爲止。python
計算機初學者難於跨越的三個坎,分別是:程序員
咱們來看看,這三個坎如何用這個概念來理解。web
何爲賦值?賦值就是將一個值賦給另一個變量。最簡單的概念好比:a=5
在執行到這句代碼時,5這個值是肯定的。
但若是值依然沒有肯定呢?好比a=x+y
,但是此時x和y都不知道是多少。你點了點頭,恩,這不就是數學裏的方程嘛,到最後把x和y的值代入表達式就能夠了。
這種先推導公式,直到最後代入實際值的賦值過程,實際上是延遲求值的。用代碼能夠表述爲:算法
C#版本: a= ()=>return x+y; //傳遞lambda表達式 python版本: a= lambda ():x+y; #傳遞lambda表達式 Lisp版本: (let a (+ x y)) ;令a=x+y,倒是延遲求值的,let是Lisp的一個關鍵字
什麼意思?當任什麼時候候你要求a值的時候,程序都是現作,給你把x和y加起來返回。這就像包子鋪同樣,人們都買新鮮的包子,隔夜包子都很差賣的!
咱們能夠將變量賦值當作一棵樹的規約過程,看這個例子:編程
;Lisp代碼: (let timenow (+ (clock now) 1)) ;獲取當前時間,再加一個小時 (compare timenow timenow) ;比較兩個時間是否相等
好,那compare的結果是什麼?
若是兩個包子是同時作出來的,那就相等。其實這取決於解釋器是怎麼作包子的(也就是執行的策略):數組
四種策略,計算的結果都不一致!每種策略都有適用的範圍。經過控制解釋器,由於本質上規約是在樹上進行操做的,究竟是前中後序,甚至層序,仍是你想到的其餘規約方法,均可以本身定義。
這裏講了兩種變量賦值的方式,而函數傳參方式有四種,在備註中會提到。緩存
咱們學了不少種數據結構,隊列,棧,數組,鏈表,樹,圖等等。它們形態萬千。咱們可否用通用的方法來表示它呢?
答案應該是二元組(car,cdr)。
隊列和棧原本就是數組(也能夠是鏈表),哈希表稱爲關聯數組,天然也是能由數組構造的,數組和鏈表之間能夠互相轉換。
而鏈表就是二元組構成的鏈條,car表明該節點的值,cdr表明指向的下一個節點。
圖能夠轉換爲樹,全部的樹都能轉換爲二叉樹。而二叉樹的一個節點的car和cdr分別表示左右兩個子節點。
從上面的分析來看,咱們確實能經過二元組的組合,定義絕大多數結構,如何定義二元組呢?
;Lisp版本: (cons node1 node2) ;將node1和node2組合成一個二元組 (car pair) ;訪問二元組pair的左值 (cdr pair) ;訪問二元組pair的右值
爲何要去構造一個統一的結構來表示數據呢?由於這樣讓計算操做變得更加通用,只要三種運算符,cons,car和cdr,咱們就能構造和訪問絕大多數數據類型。
更重要的是,也許你創造出來的這個結構,它自己也許不存在!電腦裏只有扁平的內存,那些數據結構,都是咱們想象和建模的。經過對某種結構的一系列變換,這些變換將扁平的內存映射成了樹,讓它有了相似樹的特性而已。
若是再加上寄存器和堆棧,咱們就能實現一個上下文環境(env),進而環境能夠爲樹規約提供參數值,規約後的結果也能影響環境,一個基本的計算體系就構建了出來。
程序的行爲,分爲編譯期和運行期。絕大多數語言,都須要經過詞法分析和句法分析,將源代碼轉換爲抽象句法樹(ast)。 除此以外,編譯型語言還要生成中間代碼,這一步能夠經過對樹結構進行後序遍歷,生成三符號表達式,進而地翻譯爲機器碼。而解釋型語言則不須要中間代碼生成,直接在樹上操做便可。
那除了詞法和句法分析,編譯還作了什麼事情呢?優化。
編譯儘可能提高運行時的速度,減小運行時的空間消耗。固然更多狀況下是空間換時間。簡單地說,編譯就是對這棵樹進行優化操做。編譯優化應該包含幾種策略:
熟悉函數式編程的同窗會說,上一節的延遲計算,不就是高階函數嘛!那麼,函數做爲一個對象,它存儲在哪裏,佔用的是堆內存仍是代碼區?組合兩個函數得到的「那個東西」,是否創造了新的函數?
函數是過程,但過程不必定是函數
打個比方說,定義兩個子樹: 它們的子節點都是a,b,只是父節點不一樣,分別是加法+和減法-;那麼,若是把兩個子樹拼接起來,父節點是乘法*的話,那麼咱們就定義了新的計算:
result= (a+b)*(a-b)= > a^2-b^2;
咱們並無定義這個函數,卻經過組合加法和減法和乘法,造成了一個平方差的過程。
從上面的例子能夠看到,咱們要定義任務時,定義函數只是其中一種手段。緣由是這樣好寫,方便編譯器優化和人類理解。可是,計算機其實不須要函數,它只關心任務怎麼作,也就是計算的過程。
進一步地說,對於C#這樣的靜態語言,它是沒法動態生成函數的,它只能從新組合函數,就像剛纔組合二叉樹同樣(記不記得C#的ExpressionTree)? 對C來講,你能夠用二叉樹配合函數指針,實現和C#表達式樹同樣的功能,只是本身動手的工做量大一些。靜態語言的函數存放在代碼區,而代碼區是不會改變的。
對Python來講,下面的代碼確實動態創造了新的"函數":
code= ''' def add(a,b): return a+b; ''' exec(code);
不過,對於解釋型的動態語言來講,函數的定義大大弱化了。它已經遠沒有C裏的過程體那麼強硬而不可改變,Python編譯器在執行上面的exec動態編譯時,也是生成了一棵樹。
因此,靜態語言經過編譯生成句法樹(ast),動態語言在運行時生成ast;而Lisp,代碼就是數據,它們都ast。總之,一個程序就是樹,樹的每一個節點表明一個函數或過程名。
咱們再想一個問題,咱們能處理無窮的序列嗎?想象一個不停生產包子的包子鋪,剛出籠屜就被買走了,咱們稱之爲包子流,哈哈。
一個不停接受鍵盤命令的機器,處理的就是無窮流,它本身根本不知道該何時中止。若是數組是一個變量在空間維度的擴展,流就是在時間維度的擴展。因此,注意咱們第一節用到的序列,這裏,
序列=數組 或 流
;
在包子鋪窗口,每時你只能看到一個包子;但若是放在一個動態的時間角度,它其實沿着時間軸構成一個包子的序列(包子流)。看下面的代碼:
#python代碼 a='包子' while(1): print(a) #不停地打出包子,包子...
這是一個無窮的包子流,不斷地輸出包子,包子,包子...
再複雜一些,肉包子素包子交換銷售:
index=0; while(1): if index%2==0: print('肉包子') else: print('素包子') index+=1; #輸出:肉包子 素包子 肉包子 素包子...
若是再複雜一些,老闆要你先輸出三個豬肉白菜,再輸出兩個牛肉大蔥,再...
這就特別像剛開始學編程時,實現複雜邏輯用的狀態機。你必定會很頭疼switch-case裏再嵌套if-else,while的這樣的寫法,老師教導咱們應該將其解耦。但若是編譯器能幫作到這件事該多好!
也就是說,你只負責生產空包子,後面有小妹A幫你給包子裏填餡,後面還有小妹B幫你裝袋子,每五個裝一包。這樣就構成了一條包子流水線,今後你的工做就輕鬆不少了。
如何用編程來模擬這些幫你打下手的小妹呢?
#python代碼,定義兩個函數 def generator(): #生成器,不斷地輸出原始的包子流 while(1): yield '包子'; def map(items,func): #依次對包子加工,func從外部傳入加工方法 for item in items: yield(func(item)); def roubaozi(baozi): #將原始的包子加工成肉包子的函數 return '肉'+baozi; map(generator(),roubaozi);
這樣不就等價於剛纔的那個函數,不停地輸出肉包子這個序列嗎?
那若是編譯器更給力一點,將表達式倒過來寫(咱們習慣從左往右看),寫成:
generator().map(roubaozi)
甚至
generator().map(func1).map(func2)...
這就構成了一個漂亮的流水線!
若是外面沒有買包子的人了,這個流水線就自動停了,由於沒有消費了。但若是又有人過來,流水線又開始生產,也就是按需消費。
若是用代碼來表達,不論你用Python的生成器,仍是C#的Linq,它們能從中斷的地方恢復,看似神奇,本質在於它們被編譯器從新編排在同一個函數裏,使用着一樣的堆棧和內存。但這種寫法,給程序員提供了不少方便,是一個語法糖。
那流能作什麼呢?
那麼,Lisp解釋器是否也須要像Python編譯器那樣,去將一堆代碼組合到一個函數裏?答案是不須要。爲何?若是咱們用遞歸定義的數據結構,配合遞歸函數,那麼也能實現相似的功能,由於遞歸和循環是等價的,但遞歸能把函數描寫地更加簡便。具體的解釋請參考附錄
流的思路很清晰普遍,很是適合模塊解耦,一個圖遍歷算法也能生成一個節點流,以後對節點的處理交給外界去負責,一樣節點的處理模塊也不關心遍歷是深度優先仍是廣度優先的。
若是我說,流就是循環,流就是遞歸,你怎麼看這個問題?
咱們已經把程序員的前兩個坎邁過去了,如今看看第三個坎:異步和多線程。
包子鋪銷量好啊!可是包子怎麼也得15分鐘才能蒸熟啊。因而外面的顧客在排隊,你這個時候坐在蒸籠旁邊發呆,老闆娘確定過來就給你一腳,幹活去!
那怎麼辦,設個鬧鈴啊,到了十五分鐘,出籠不就行了,這個空閒時間,你還能作點雜活。這就是異步的意義。一旦某個任務完成,通知你去作剩下的任務就行了,而這個剩下的任務,就是回調(callback)。
因此,異步就是,讓主線程不阻塞能接着幹別的活,當包子蒸完了再接着賣包子的意思。
這裏有兩個關鍵點,
先看第一個問題,不阻塞幹別的活,這不就是多線程?異步就必定是多線程嗎?不必定!那怎麼能實現同時幹兩個活?下面是答案:
一般來講,計算密集型的是多線程的,IO密集型的是基於中斷的。
對第二個問題,如何告訴主任務如何處理剩下的內容,這就須要回調函數。回調函數要寫起來漂亮,實現起來優雅。可是像javascript用在web開發中,而網絡環境中都要求異步,因此會看到那麼多的回調函數,頭都暈了。
//js代碼: function myFunction(param) { var http= new XMLHttpRequest(); http.onreadystatechange=function() //第一層回調 { var http2=new XMLHttpRequest(); http.onreadystatechange=function() //第二層回調 { ... } } //這個訪問服務器的js代碼,是基於中斷仍是多線程?
這段代碼的意思是發出一個請求A,A得到回覆後再發出請求B,B得到回覆後再...因而回調函數一層套一層,看起來好不爽快。
若是能把回調函數使用地優雅一點就行了!
C# 5.0實現了這樣的語法, 就是async和await關鍵字,好比下面:
#C#的示例代碼: Task<string> GetValueAsync(string value){ return Task.Run(()=>{ Thread.Sleep(1000); //模擬長時間操做 return "完成"+value; }); } public async Process(string value) { var result= await GetValueAsync(value); //優雅的異步調用,調用線程不會阻塞 Console.WriteLine(result); }
看起來很high是吧,比js的回調好看多了!咱們先想一想,剛纔的那段C#代碼是基於中斷仍是多線程?顯然是多線程嘛。但是哪來的多線程?
注意那段GetValueAsync函數,return Task.Run...
這是個編譯器語法糖,在.Net庫裏實現並建立了線程池,並將這個任務交給了它。而絕大多數語言是不包含這樣的語法的,因此纔會有那麼多C/C++/Python異步庫,它內部都使用了線程池和隊列,但好像實現這樣的語法是不可行的。
OK!若是是Lisp實現異步呢?這個依然取決於Lisp解釋器的實現,若是你願意,解釋器能夠隨時調整樹結構,徹底能夠將完成後的操做的函數節點掛接上去就行了。
當程序運行時,代碼樹的結構通常是不發生變化的。程序計數器(pc)記錄了當前訪問的節點。當涉及函數調用時,向樹的子節點運動,並將以前的參數和環境壓入運行棧,從函數調用中返回後,再將結果從棧中彈出。
過程是編譯期的概念,活動是運行期的概念,活動是運行的過程。
對單線程來講,任什麼時候刻,一個樹節點只可能有一個線程在訪問。但對多線程來講,每個執行線程,對應一個獨立的棧,不一樣的線程在同一棵樹上進行遍歷訪問,造成了不一樣的活動
並行的本質就是同時有多個工做線程在同一棵樹上進行規約操做。天然而然地,若是同時修改和讀取共同的節點或環境,那麼確定就會出現數據爭用,若是不加鎖,就有可能產生錯誤。
函數式編程可以儘可能避免多線程的問題,是由於其操做以遞歸爲主,所以對環境的依賴變弱。而過程式語言特別強調環境,所以在多線程環境中就需特別注意。
計算的本質是什麼?圖靈的定義確定是最好的,但它難以被形式化地描述,將其表達爲序列的變換,再具體爲爲樹結構的變換,應該是個不錯的解釋。
進而,大神發明了Lisp語言,用以形式化地描述這種「樹結構的變換」。所以本文的不少講解都提到了Lisp。
針對流,異步和賦值模型的討論,僅僅是個皮毛,特別但願可以拋磚引玉,有任何問題,歡迎討論。
本文是筆者針對《計算機程序的構造與解釋》的讀書筆記。
函數參數傳遞,分爲四種類型:
一個產生無窮的包子流的Lisp代碼,我保證你必定能看懂:
(define generator (str) ;define是Lisp的關鍵字,定義 generator函數 (cons ;記不記得剛纔生成二元組的cons? (str generator(str)) ;遞歸調用 ) ) (define map (proc items) ;定義map函數,proc是加工方法,items是傳入值 (if (null? items) ;若是值爲空,就返回,nil表明空 nil (cons (proc (car items)) ;分解這個二元組,並進行加工,再拼接起來 (map proc (cdr items)) ) ) ) (map (generator "包子") print) ;最終的調用函數
若是用Python實現相似的功能:
def generator(s): return (s,generator(s)); def map(items,func): if isinstance(items,tuple): return (map(items[0]),map(items[1])); else: return func(items); map(generator('包子'),print);
咱們試圖構造一個遞歸的元組,也就是(包子,(包子,(包子,(包子..)))),而後送入map遞歸處理,可是卻悲劇地發現,第一個函數沒法工做,它會無窮遞歸!由於Python的解釋器是處於前面提到的第一種求值模式(先求參數,再傳入函數中)的。因爲沒法修改Python解釋器,所以這樣的寫法是不行的。
(有人可能提出,在generator中,改爲return (i,lambda ():generator(i))
,這其實也不可行)
從這個例子上咱們能看出,一個能自由控制求值策略的解釋器是多麼有用。
求笛卡爾集,在《計算機程序的構造與解釋》(SICP)中被稱爲「非肯定性計算」,參考4.4章,但筆者認爲這種描述並不穩當,這只是一種相似在矩陣中一行一行地搜索行爲,和非肯定性沒有關係。
最有趣的是,不一樣語言爲何要那麼設計,甚至有人說,其餘語言都是Lisp的真子集。對Lisp來講,編譯和運行的界限變得很是模糊。連GCC生成的中間代碼,都是Lisp風格的。還有爲何《黑客與畫家》爲何如此地推崇Lisp?
在我看來,像Lisp這樣的語言,既不須要詞法語法分析,也不須要代碼生成,因此你能夠用很是簡單的代碼(不到100行),實現一個Lisp解釋器,從而自定義地控制求值策略。Lisp語言還支持定義宏,在運行期對樹進行相似編譯時的操做。由於運行期能夠得到環境的更多參數,因此這種操做很是有效。絕大多數編譯原理的技術,都能經過Lisp,以極低的成本去設計和實現。相比其餘語言,Lisp擁有了直接和造物主對話的權限,一個能讓你直接編寫中間語言甚至機器語言的高級語言,不強纔怪。
那爲何Lisp沒有普遍地運用在商業環境中呢?很簡單,它雖然太強大了,但卻以難以閱讀著稱;設計解釋器太容易了,因此方言特別多;能寫出複雜的宏,從而讓語言徹底自定義。但這些,都與社會化的軟件開發套路相悖。 因此,學習Lisp不見得要用在開發中,但它的思路和設計,卻能極大地影響你對編程的認識。