1. 程式人生 > >PL真有意思(六):子程式和控制抽象

PL真有意思(六):子程式和控制抽象

前言

在之前我們把抽象定義為一種過程,程式設計師可以通過它將一個名字與一段可能很複雜的程式片段關聯起來。抽象最大的意義就在於,我們可以從功能和用途的角度來考慮它,而不是實現。

在大多數程式設計語言中,子程式是最主要的控制抽象的方法。大多數子程式都是引數化的,即通過傳遞一些引數來影響子程式的行為。

回顧棧的佈局

當一個子程式被呼叫的時候,在棧的頂部將給它一個新的棧幀或稱為活動記錄。這個棧幀可能包含實際引數和/或返回值、簿記資訊(包含返回地址和儲存的暫存器)、區域性變數和/或各種臨時量。當子程式返回時,棧幀從棧中彈出。

如果某個物件的大小在編譯時位置,那麼就將它放在棧幀的頂部大小可變的區域,並將它的地址和內情向量儲存在棧幀的某個部分,放在相對於棧指標的一個靜態可知的偏移處。

在那些允許巢狀子程式和靜態作用域的語言,物件有可能出現在外圍的子程式中,通過維護一個靜態鏈就可以找到這些既非區域性也非全域性的物件。每個棧幀都包含一個對詞法上位於其外圍的幀的引用

呼叫序列

維護子程式呼叫棧是呼叫序列的責任。所謂呼叫序列就是由呼叫方緊接著子程式子程式呼叫和之後執行的程式碼。

在進入自稱的過程中需要完成很多工作,包括出艾迪引數,儲存返回地址,修改程式計數器,修改棧指標以分配空間,儲存那些維護著重要的值但是可能被子程式改寫的暫存器等等等

暫存器的儲存和恢復

許多處理器呼叫序列都是將並非為特殊用途而保留的暫存器分為數目差不多的兩組,其中一組由呼叫方負責,另一組由被呼叫方負責。

靜態鏈的維護

在有巢狀子程式的語言中,至少有一部分靜態鏈維護工作必須由呼叫方完成,而不能由被呼叫方完成

  • 被呼叫直接巢狀在呼叫方內,在這種情況下,被呼叫方的靜態鏈應該直接引用呼叫方的棧幀

  • 被呼叫方在k>=0作用域之外,更接近詞法巢狀的外層,在這種情況下,所有圍繞著被呼叫方的作用也圍繞著呼叫方。這時候呼叫方就對靜態鏈做k次間接引用,將結果送給被呼叫方做靜態鏈

典型的呼叫序列

一般的呼叫序列呼叫方可以按如下的方式操作:

  • 保護起那些由呼叫方儲存、其值在呼叫之後還需要的暫存器
  • 計算出引數的值,並將它們移入棧或者暫存器中
  • 計算出靜態鏈,將它作為一個隱含的引數傳遞
  • 執行一條特殊的子程式呼叫指令跳進子程式,同時將返回地址放入棧或某個暫存器中

被呼叫方的前序操作則是:

  • 分配一個幀,也就是將sp指標減去某個適當的常數
  • 將原來的棧指標儲存在棧中,並給幀指標賦以適當的新值
  • 儲存那些由被呼叫方負責,而且在當前子程式中可能被複寫的暫存器

在子程式完成之後的後序操作:

  • 如果有返回值,則將返回值移入某個暫存器或棧中的某個保留位置
  • 根據需要恢復被呼叫方儲存的暫存器
  • 恢復fp和sp
  • 跳回到返回地址

最後呼叫方則可以:

  • 將返回值移入需要它的位置
  • 根據需要恢復呼叫方儲存的暫存器

內聯展開

作為基於棧的呼叫方式的一種替代,許多語言實現中還允許將特定子程式在呼叫的位置內聯展開。被呼叫子程式的副本成為呼叫方的一部分;沒有任何實際子程式呼叫發生。

在C中可以由程式設計師來指示是否建議將某些子程式內聯化

inline int max(int a, int b) { return a > b ? a : b;}

但是與真正的子程式呼叫相比,內聯展開的一個明顯缺點就是增加了程式碼量

引數傳遞

大多數子程式都是引數化的,它們將得到一些引數,這些引數或控制著子程式行為的某些特定方面,或指定子程式需要來操作的資料。

引數模式

之前提了一下實參傳遞,以及明確實參與形參關係的語義規則。有些語言定義了唯一一組規則,適用於所有引數,這樣的語言包括了C 、Fortran和Lisp,其它一些語言則提供了兩組或更多組不同的規則

對於f(x)我們有兩種實現方式,

  • 可以為f提供一個x的副本
  • 直接將x的地址傳遞給f

這兩種最基本的引數傳遞模式分別稱為值呼叫和引用呼叫,它們的設計反映了它們的實現方式

值呼叫和引用呼叫在使用值模型的語言的語言中最有意義。在使用引用模型的語言中,變數本身已經是物件的引用,這兩種模型實際上都沒有意義。

在Java中,內部型別使用值呼叫,而使用者定義型別使用引用模型。相對的是C#中使用的是值呼叫,但是可以通過顯式的關鍵字來使用引用傳遞。

閉包作為引數

閉包(對一個子程式的引用,再加上該子程式的引用環境)也會因為某些原因需要作為引數傳遞。最明顯的原因就是當引數被宣告為子程式時。

在子函式式語言中,子程式往往是作為引數傳遞的,並作為結果返回

在面嚮物件語言中,雖然沒有巢狀子程式,但是也可以模仿子程式閉包的行為,方法是將與一個方法和它的環境打包在一個顯示的物件裡,

C#的代理擴充套件了物件閉包的概念,代理不僅可以用特殊的物件方法來例項化,也可以用靜態函式或者匿名巢狀代理或lambda表示式來例項化。

特殊目的的引數

相似陣列

在不同語言中,陣列維數和邊界的約束時間也不大相同,可推遲到執行時再確定形狀的形式陣列引數稱為相似陣列引數或開放陣列引數,例如C中的多維陣列。

預設引數

預設引數就是呼叫方可以不提供的引數,如果沒有給出就使用預先設定的預設值

實現方式也是直截了當的,呼叫時如果缺少了某個實際引數,編譯器就認為提供的是相應的預設值

命名引數(關鍵字引數)

在至今為止的討論中,我們一直假定引數按位置相互對應:第一個實參對應於第一個形參,以此類推。實際上,在一些語言中,如Lisp和Python,這些語言都允許對引數進行命名,命名引數與預設引數結合時特別有用。

命名引數不僅可以使引數以任意順序描述,還可以起到說明引數用途的作用

可變個數的引數表

在Lisp、Python和C及其後羿的一個不尋常之處,是它允許使用者定義一類子程式,這種子程式的引數個數可以變化

在C中,printf可以按如下方式宣告:

int printf(char *format, ...)

C中通過內建的函式來獲取省略引數

在Java中則是將省略引數包裝成一個數組

static void print_lines(String foo, String...lines)

函式返回

對於函式指定返回值的語法,各語言之間區別很大,在Lisp和ML這種不區分表示式和語句的語言中,函式的值就是函式體的值,而函式體本身就是一個表示式

而現在的許多命令式語言都引入了顯示的return語句

return expr

泛型子程式和模組

子程式為在許多不同的物件值(引數)上執行某個操作提供了一種很自然的方式。在大型程式中,也常常需要在許多不同的物件型別上做某個操作。

在之前有一篇講到隱式引數多型性繞過了這個問題,它使我們可以宣告一種子程式,其引數型別式沒有完全描述的,但仍然是型別安全。但是這種方式,需要將所有的型別檢查推遲到型別檢查時才來做。

還有一種顯式多型性的泛型機制,使一組類似的子程式或模組可以通過唯一一段原始碼創建出來。

不同實現方法

泛型特徵可以通過多種方式實現。在C++的大多數實現中,它們是一種純粹的靜態機制,建立和使用泛型程式碼多個例項的所有工作都在編譯時完成。在通常情況下,編譯器為每個例項建立一個獨立程式碼副本。但是在C++中,為這樣每個例項安排獨立的型別檢查

而在Java中使用一種型別擦除的機制,從效果上看,如果T是Java中的一個泛型型別引數,那麼類T的物件將被當作標準基類Object的例項對待,但程式設計師不需要在將它們用作T類的物件之前插入顯式的型別強制,而且編譯器可以保證這樣的省略的強制不會發生失敗。

泛型引數的約束條件

因為泛型也是一種抽象,其接宣告的頭部應該為抽象的使用者提供使用它需要知道的全部資訊

在Java和C#中,利用了面向物件和繼承的能力來實現。它可以要求某個泛型引數必須支援一組特定的方法

例如在Java中:

public static <T extends Comparable<T>> void sort(T A[]) {

}

異常處理

異常可以定義為程式執行過程中出現了沒有預料的情況,或者至少是不尋常的情況,而這種情況很難在區域性上下文中處理。異常情況可能是由語言實現自動檢查的,或者是由程式本身顯式引發的。

異常的定義

在許多語言中,動態語義錯誤會自動產生程式可捕獲的異常。程式設計師還可以定義其它特定於具體應用的異常

在大多數面嚮物件語言中,異常是某個與風衣或使用者定義的類型別的一個例項。

通常使用嵌入在If語句中的throw語句或raise語句來在執行時引發異常。如果一個子程式引發了異常,但是其內部沒有捕獲,那麼它就可能以某種非預期的方式返回。在Java和C++中,在子程式頭部包含了一個表,在其中列出可能傳播到子程式之外的異常。

異常的傳播

在大多數語言中,一個程式碼塊可以由一組異常處理程式,在C++中:

try {

} catch(end_if_file) {

} catch(io_error_r) {

}

在出現異常時,處理程式將出現的順序檢查,控制傳入第一個與異常匹配的處理程式。

表示式上的處理程式

在像Lisp一類的面向表示式語言中,異常處理程式被附著於表示式上,而不是語句上。在發生異常時,由於處理程式的執行將代替被保護程式碼中尚未結束的那一部分,因此附在表示式上的處理程式還必須為表示式提供一個值

val foo = (f(a) * b) handle Overflow => max_int

異常的實現

異常的最明顯實現方式是維護一個處理程式的連結表棧。當控制進入一個受保護塊時,將作用於這個塊的處理程式被加到表的頭部。當某個異常被引發時,語言執行時系統就彈出表中最內層的處理程式並且呼叫它。

在一種內部並沒有提供異常的語言中,有時也可以模擬異常機制。

Scheme提供了一個名為call-with-current-continuation的通用函式。這個函式帶有一個引數f,該引數本身也是函式。它呼叫f並將一個繼續c(閉包)傳給它作為引數。這個閉包包含當前的程式計數器和引用環境。在未來的任何時刻,f可以通過呼叫c來重新建立起所儲存的環境。如果以前做過巢狀呼叫,控制機制就會彈出它們,就像異常所做的那樣。

C的大多數版本提供了一對庫例程setjmp和longjmp。setjmp以一個緩衝區作為引數,它將程式當前狀態以某種形式存入其中。隨後我們可以將這個緩衝區傳給longjmp,要求恢復所儲存的狀態。

協程

有了對執行時棧的佈局的理解後,我們可以考慮更一般的控制抽象的實現問題,協程。與繼續一樣,協程也需要用閉包表示,可以通過非區域性的goto跳進來,關於協程的這種特定操作被稱為transfer。這兩種抽象之間的主要不同點在於:繼續是一個常量,一旦建立之後就不會改變了,而協程在每次執行中都會變化。

從效果上看,一組協程在一些同時存在的上下文中執行,但在每個時刻只有一個正在執行,控制將通過命名方式在它們之間轉移。協程可以用於實現迭代器和執行緒

棧分配

由於不同協程是併發的,因此它們不能共享同一個棧,因為作為一個整體看,它們的子程式呼叫和返回並不是按後進先出的順序進行的。如果每個協程都放在詞法巢狀的最外層宣告處,那麼它們的棧就是互不相交的

最簡單的解決方案是給每個協程一塊固定大小的靜態分配的棧空間

轉移

在從一個協程轉移到另一個協程時,執行系統必須修改程式計數器、棧和處理器暫存器的內容。這些修改都被封裝在transfer操作中。

對於棧的修改,最常見的方式就是簡單的修改棧指標暫存器,避免在transfer中使用幀指標。在transfer開始,我們將返回地址和所有其它被呼叫所儲存的暫存器壓入當前棧,然後修改sp,由新棧中彈出新的指令地址和其它暫存器內容,然後返回

事件

事件就是在程式外部發生,出現的時間不可預測,但是需要執行中的程式相應某種情況。最常見的事件就是圖形使用者介面系統的輸入:按鍵、滑鼠活動。

順序處理程式

傳統上,順序程式設計語言中事件處理程式是作為自發的子程式呼叫實現的,一般會使用語言之外由作業系統定義和實現的機制。為了準備好通過這種機制接受事件,一個程式將呼叫一個setup_handler庫例程,在事件發生時將希望呼叫的子程式作為引數傳遞

在硬體層上,在P的執行期間非同步裝置的活動將觸發一箇中斷機制,保持在P的暫存器,切換到一個不同的棧,並跳轉到OS核心中的一個預先定義的地址上。類似的,如果另一個過程Q在中斷髮生時正在執行,則核心將在自己最後的時間段結束時,儲存P的狀態。

當一箇中斷髮生時,主程式可能處於程式碼的任何位置,核心將儲存狀態,並通過正常的呼叫序列呼叫事件處理程式,最後恢復狀態。

總結

這一篇集中關注控制抽象的問題,特別是子程式有關的問題。首先我們先了解了子程式呼叫棧的管理問題和維護棧的呼叫序列。在之後討論了有關引數的問題,各種引數傳遞模型等。最後考察了異常處理機制、協程和事件