1973年的C編譯器

到現在為止,我們以逐步漸進的方式開發C編譯器。這樣的開發流程在某種意義上,可以說是走過了C語言的歷史。

看看現在的C語言,充滿了意義不清和過於複雜之處,這些部份如果跳脫歷史來看根本無法理解。現今C語言的難解之處,如果看看早期C的程式碼、了解早期C語言的樣子和後來語言與編譯器的發展,會覺得很多地方茅塞頓開。

C在1972年,為了Unix而開始開發。1972、1973年當時的,也就是C語言歷史中極早期的程式原始碼有留在磁帶裡,該檔案有被讀出並公開放在網路上。我們來看看當時的C編譯器程式碼吧。底下的程式碼,是printf格式中,接收訊息並編譯為錯誤訊息顯示的函式:

error(s, p1, p2) {
  extern printf, line, fout, flush, putchar, nerror;
  int f;

  nerror++;
  flush();
  f = fout;
  fout = 1;
  printf("%d: ", line);
  printf(s, p1, p2);
  putchar('\n');
  fout = f;
}

有種難以言喻的詭異感,看起來像C又不像C。當時的C就是這樣的語言。讀了這段程式碼後,可以注意到:和我們做的早期編譯器一樣,函式的回傳值或引數是沒有型態的。這裡s是指向字串的指標、p1p2為整數,但是當時的機器上它們全都是同樣的大小,所以變數像這樣是沒有型態的。

第2行中,error中寫了所指涉的全域變數和函式宣告。當時的C編譯器沒有標頭檔也沒有前處理器指令,所以程式設計師得像這樣告訴編譯器變數和函式的存在。

和現在我們的編譯器一樣,只確定函式名稱是否存在,並不會檢查引數的個數或型態是否正確。只要把預定數量的引數推進堆疊後,接著跳到函式本體後函式呼叫就算是成功了。

fout是持有輸出目標檔案描述子(file descriptor)的全域變數。在當時,還沒有fprintf,如果不是想要輸出到標準輸出(standard out,C語言中的stdout)而是想輸出到標準錯誤輸出(standard error,C語言中的stderr)的話,就得透過全域變數來切換輸出的目標。

error裡呼叫了2次printf。第2次的printf除了格式化的次串之外還傳了2個值。如此一來,在錯誤訊息只取第1個值時該怎麼辦呢?

其實這個error函式就算引數給少了,還是可以正常運作。回想一下,在當時並不存在函式的引數檢查。sp1p2這些引數只是單純從堆疊指標起算的第1、2、3個字組(word),實際上有沒有傳p2對應的值編譯器並不在意。而printf只會根據第1引數字串中的%d%s的個數去存取引數,所以如果訊息只包含1個%d時,完全不會去存取p2。於是引數個數不一致也完全不會有問題。

早期的這種C編譯器,和現階段的 9cc 有許多類似之處。

我們再來看1個例子。底下的程式是會複製傳進來的字串到分配好的靜態記憶體空間,再回傳指向該位址的指標的函式。也就是靜態版的類似strdup的函式。

copy(s)
char s[]; {
  extern tsp;
  char tsp[], otsp[];

  otsp = tsp;
  while(*tsp++ = *s++);
  return(otsp);
}

當時還沒有發明像int *p這樣的宣告。取而代之的是用像int p[]來宣告指標。而在函式的引數表和函式名之間插進了像是變數的定義的東西,這是要宣告s為指標型態。

這版早期的C編譯器還有其他值得一提的是:

  • 這時候還沒有結構(struct)。

  • 還沒有&&||運算子。這時的&|if等條件式中會變成邏輯運算,會依照前後文脈絡不同而執行不同操作。

  • +=運算子寫成=+。在這個文法裡,如果打算把i-1代入,在沒有加上空格時寫成i=-1時,會有被判斷成i =- 1這樣產生意料之外操作的問題。

  • 整數型態只有charint,並沒有shortlong。也缺乏「函式指標的陣列」這種型態的宣告,無法描述複雜的型態。

除此之外,70年代早期的C編譯器還缺乏了很多功能。但是這個C編譯器,從上述的程式碼也可以看出,是用C寫成的。在那個連結構型態都沒有的年代,C語言已經可以自我編譯(self-hosting)了。

從這些古老的程式碼來看,可以猜想C語言中有些難懂的文法為什麼會變成現在的樣子。externautointchar後一定要接變數名稱,這樣的文法在分析時會比較簡單。而表示指標的[]也是如果只出現在變數名稱後面的話,分析起來會比較簡單。從這個早期的編譯器所顯示的發展方向來看,好像可以理解為什麼會演變出現在這些非必要、形式複雜的文法。

1973年當時,Unix 和C語言共同的開發者 Dennis Ritchie 所使用的,正是漸進式的開發方法。他在發展C語言的同時,就使用C語言來開發其編譯器。現在的C語言,並不是在持續追加語言的功能的過程中,因為達到了某個里程碑而完成;而是 Dennis Ritchie 在某個時間點,認為其作為語言的機能十分充足了,才被認為該語言完成了。

我們的編譯器也不是在一開始就要追求完成版。完整的C語言本身就不具有什麼特別的意義,特別去追求恐怕也沒什麼意義。持續開發在每一個時間點都具有合理功能的語言,最終成為C語言,這才是原始C編譯器所採用的正統開發方式。我們有信心地繼續前進吧!

小知識:Rob Pike的程式設計5原則

9cc 有受到 Rob Pike 對程式設計看法的影響。Rob Pike 是C語言作者 Dennis Ritchie 的前同事,也是Go語言的作者,他和 Unix 的作者 Ken Thompson 一起開發了 Unicode 的 UTF-8。

底下引用 Rob Pike 的「程式設計5原則」(Rob Pike's 5 Rules of Programming):

  1. 你無法預期程式的在哪個部份耗時。瓶頸可能出現在出乎意料的之處,在確定瓶頸出在哪之前,不要盲目猜測或動手腳來增強效能。

  2. 一定要量測。不要在進行量測之前做最佳化,就算測了也不要對沒有明顯拖累效能的部份進行調整。

  3. 精彩的演算法在 n 數量小的時候很慢,而 n 通常很小。精彩的演算法其常數項大。除非你確定 n 通常夠大,不然別走太精彩的路。(就算 n 夠大,也請先試過規則2。)

  4. 精彩的演算法比簡單的演算法容易有 bug,而且較難實作。請用簡單的演算法和資料結構。

  5. 資料決定一切。如果選對了資料結構且有好好組織資料,演算法通常是自明(self-evident)的。演匴法不是程式設計的中心,資料結構才是。

Last updated