1. 程式人生 > >PL真有意思(三):名字、作用域和約束

PL真有意思(三):名字、作用域和約束

前言

這兩篇寫了詞法分析和語法分析,比較偏向實踐。這一篇來看一下語言設計裡一個比較重要的部分:名字。在大部分語言裡,名字就是識別符號,如果從抽象層面來看名字就是對更低一級的記憶體之類的概念的一層抽象。但是名字還有其它相關的比如它的約束時間和生存週期等等

約束時間

約束就是兩個東西之間的一種關聯,例如一個名字和它所命名的事物,約束時間就是指建立約束的時間。有關的約束可以在許多不同的時間作出

  • 語言設計時
  • 語言實現時
  • 編寫程式時
  • 編譯時
  • 連結時
  • 裝入時
  • 執行時

這就是為什麼基於編譯的語言實現通常會比基於直譯器的語言的實現更高效的原因,因為基於編譯的語言在更早的時候就做了約束,比如對於全域性變數在編譯時就已經確定了它在記憶體中的佈局了

物件生存期和儲存管理

在名字和它們所引用的物件的約束之間有幾個關鍵事件:

  • 物件的建立
  • 約束的建立
  • 對變數、子程式、型別等的引用,所有這些都使用了約束
  • 對可能暫時無法使用的約束進行失活或者重新約束
  • 約束的撤銷
  • 物件的撤銷

物件的生存期和儲存分配機制有關

  • 靜態物件被賦予一個絕對地址,這個地址在程式的整個執行過程中都保持不變
  • 棧物件按照後進先出的方式分配和釋放,通常與子程式的呼叫和退出同時進行
  • 堆物件可以在任意時刻分配或者釋放,它們要求更通用的儲存管理演算法

靜態分配

全域性變數是靜態物件最顯而易見的例子,還有構成程式的機器語言翻譯結果的那些指令,也可以看作是靜態分配物件。

還有像每次呼叫函式都會保持相同的值的區域性變數也是靜態分配的。對於數值和字串這些常量也是靜態分配。

還有用來支援執行時的各種程式,比如廢料收集和異常處理等等也可以看作是靜態分配

基於棧的分配

如果一種語言允許遞迴,那麼區域性變數就不能使用靜態分配的方式了,因為在同一時刻,一個區域性變數存在的例項個數是不確定的

所以一般對於子程式,都用棧來儲存它相關的變數資訊。在執行時,一個子程式的每個例項都在棧中有一個相應的棧幀,儲存著它的引數、返回值、區域性變數和一些簿記資訊

基於堆的分配

堆是一塊儲存區域,其中的子儲存塊可以在任意時間分配與釋放。因為堆具有它的動態性,所以就需要對堆空間進行嚴格的管理。許多儲存管理演算法都維護著堆中當前尚未使用的儲存塊的一個連結表,稱為自由表。

初始時這個表只有一個塊,就是整個堆,每當遇到分配請求時,演算法就在表中查詢一個大小適當的塊。所以當請求次數增多,就會出現碎片問題,也需要相應的解決

所以有廢料收集的語言其實就是對堆的管理

作用域作用

一個約束起作用的那一段程式正文區域,稱為這個約束的作用域。

現在大多數語言使用的都是靜態作用域,也就是在編譯時就確定了。也有少數語言使用動態作用域,它們的約束需要等到執行時的執行流才能確定

靜態作用域

在使用靜態作用域的語言,也叫作詞法作用域。一般當前的約束就是程式中包圍著一個給定點的最近的,其中有與該名字匹配的宣告的那個快中建立的那個約束。比如C語言在進入子程式時,如果區域性變數和全域性變數,那麼當前的約束就是與區域性變數關聯,直到退出子程式才撤銷這個約束

但是有的語言提供了一種可以提供約束的生存期的機制,比如Fortran的save和C的static

巢狀子程式

有許多語言允許一個子程式巢狀在另一個子程式的。這樣有關約束的定義通常來說都是首先用這個名字在當前、最內層的作用域中查詢相應的宣告,如果找不到就直接到更外圍的作用域查詢當前的約束,直到到達全域性作用域,否則就發生一個錯誤

訪問非區域性變數

上面提到的訪問外圍作用域的變數,但是當前子程式只能訪問到當前的棧幀,所以就需要一個呼叫幀鏈來讓當前的作用域訪問到外圍作用,通過呼叫順序形成一個靜態鏈

宣告的順序

關於約束還有一個問題,就是在同一作用域裡,先宣告的名字是否能使用在此之後的宣告

在Pascal裡有這樣兩條規則:

  1. 修改變數要求名字在使用之前就進行宣告
  2. 但是當前宣告的作用域是整個程式塊

所以在這兩個的相互作用下,會造成一個讓人吃驚的問題

const N = 10;

procedure foo;
const
  M = N; (*靜態語義錯誤*)
  N = 20;

但是在C、C++和Java等語言就不會出現這個問題,它們都規定識別符號的作用域不是整個塊,而是從其宣告到塊結束的那一部分

並且C++和Java還進一步放寬了規則,免除了使用之前必須宣告的要求

模組

恰當模組化的程式碼可以減少程式設計師的思維負擔,因為它最大限度的減少了理解系統的任意給定部分時所需的資訊量。在設計良好的程式中,模組之間的介面應儘可能的小,所有可能改變的設計決策都隱藏在某個模組裡。

模組作為抽象

模組可以將一組物件(如子程式、變數、型別)封裝起來。使得:

  1. 這些內部的物件相互可見
  2. 但是外部物件和內部物件,除非顯示的匯入,否則都是不可見的

模組作為管理器

模組使我們很容易的建立各種抽象,但是如果需要多個棧的例項,那麼就需要一個讓模組成為一個型別的管理器。這種管理器組織方式一般都是要求在模組中增加建立/初始化函式,並給每一個函式增加一個用於描述被操作的例項

模組型別

對於像這種多例項的問題,除了管理器,在許多語言裡的解決方法都是可以將模組看作是型別。當模組是型別的時候,就可以將當前的方法認為是屬於這個型別的,簡單來說就是呼叫方法變化了

push(A, x) -> A.push(x)

本質上的實現區別不大

面向物件

在更面向物件裡的方法裡,可以把類看作是一種擴充了一種繼承機制的模組型別。繼承機制鼓勵其中所有操作都被看作是從屬於物件的,並且新的物件可以從現有物件繼承大部分的操作,而不需要為這些操作重寫程式碼。

類的概念最早應該是起源於Simula-67,像後來的C++,Java和C#中的類的思想也都起源於它。類也是像Python和Ruby這些指令碼語言的核心概念


從模組到模組型別再到類都是有其思想基礎,但是最初都是為了更好的資料抽象。但是即使有了類也不能完全取代模組,所以許多語言都提供了面向物件和模組的機制

動態作用域

在使用動態作用域的語言中,名字與物件間的約束依賴於執行時的控制流,特別是依賴子程式的呼叫順序

n : integer

procedure first
  n := 1

procedure second
  n : integer
  first()

n := 2
if read_integer() > 0
  second()
else
  first()
write_integer()

這裡最後的輸出結果完全取決於read_integer讀入的數字的正負,如果為正,輸出就為2,否則就列印一個1

作用域的實現

為了跟蹤靜態作用域程式中的哥哥名字,編譯器需要依靠一個叫做符號表的資料結構。從本質上看,符號表就是一個記錄名字和它已知資訊的對映關係的字典,但是由於作用域規則,所以還需要更強大的資料結構。像之前那個寫編譯器系列的符號表就是使用雜湊表加上同一層作用域連結串列來實現的

而對於動態作用域來說就需要在執行時執行一些操作

作用域中名字的含義

別名

在基於指標的資料結構使用別名是很自然的情況,但是使用別名可能會導致編譯器難以優化或者造成像懸空引用的問題,所以需要謹慎使用

過載

在大多數語言中都或多或少的提供了過載機制,比如C語言中(+)可以被用在整數型別也可以用在浮點數型別,還有Java中的String型別也支援(+)運算髮

要在編譯器的符號表中處理過載問題,就需要安排查詢程式根據當前的上下文環境返回一個有意義的符號

比如C++、Java和C#中的類方法過載都可以根據當前的引數型別和數量來判斷使用哪個符號

內部運算子的過載

C++、C#和Haskell都支援使用者定義的型別過載內部的算術運算子,在C++和C#的內部實現中通常是將A+B看作是operator+(A, B)的語法糖

多型性

對於名字,除了過載還有兩個重要的概念:強制和多型。這三個概念都用於在某些環境中將不同型別的引數傳給一個特定名字的子程式

強制是編譯器為了滿足外圍環境要求,自動將某型別轉換為另一型別的值的操作

所以在C中,定義一個計算整數或者浮點數兩個值中的最小值的函式

double min(double x, double y);

只要浮點數至少有整數那麼多有效二進位制位,那麼結果就一定會是正確的。因為編譯器會對int型別強制轉換為double型別

這是強制提供的方法,但是多型性提供的是,它使同一個子程式可以不加轉換的接受多種型別的引數。要使這個概念有意義,那麼這多種型別肯定要具有共同的特性

顯式的引數多型性就叫做泛型,像Ada、C++、Clu、Java和C#都支援泛型機制,像剛才的例子就可以在Ada中用泛型來實現

generic
  type T is private;
  with function "<" (x, y : T) return Boolean;
function min(x, y : T) return T;

function min(x, y : T) return T is
begin
  if x < y then return x;
  else return y;
  end if;
end min

function string_min is new min(string, "<")
function date_min is new min(date, date_precedes);

像List和ML中就可以直接寫

(define min (lambda (a b) (if (< a b) a b)))

其中有關型別的任何細節都由直譯器處理

引用環境的約束

提到引用環境的約束就有兩種方式:淺約束和深約束

推遲到呼叫時建立約束的方式淺約束。一般動態作用域的語言預設是淺約束,當然動態作用域和深約束也是可以組合到一起的。
執行時依然使用傳遞時的引用環境,而非執行時的引用環境。那麼這種規則稱為深約束,一般靜態作用域的語言預設是深約束

閉包

為了實現神約束,需要建立引用環境的一種顯示錶示形式,並將它與對有關子程式的引用捆綁在一起,這樣的捆綁叫做閉包

總而言之,如果子程式可以被當作引數傳遞,那麼它的引用環境一樣也會被傳遞過去

一級值和非受限生存期

一般而言,在語言中,如果一個值可以賦值給變數、可以當作引數傳遞、可以從子程式返回,那麼它被稱為具有一級狀態(和我們在js中說函式是一等公民一個含義)。大多數的語言中資料物件都是一級狀態。二級狀態是隻能當作引數傳遞;三級值則是連引數也不能做,比如C#中一些+-*/等符號。

在一級子程式會出現一個複雜性,就是它的生存期可能持續到這個子程式的作用域的執行期外。為了避免這一問題,大部分函式式語言都表示區域性變數具有非受限的生命週期,它們的生命週期無限延長,直到GC能證明這些物件再也不使用了才會撤銷。那麼不撤銷帶來的問題就是這些子程式的儲存分配基於棧幀是不行了,只能是基於堆來分配管理。為了維持能基於棧的分配,有些語言會限制一級子程式的能力,比如C++,C#,都是不允許子程式巢狀,也就從根本上不會存在閉包帶來的懸空引用問題。

小結

這一篇從名字入手,介紹了名字與其背後的物件的約束關係、以及約束時間的概念;然後介紹了物件的分配策咯(靜態、棧、堆);緊接著討論了名字與物件之間建立的約束的生命週期,並由此引出了作用域的概念;進一步延伸出多個約束組成的引用環境的相關概念以及問題