博客 / 詳情

返回

C語言的”代碼化石“出土:1979年的英文文本判別器

各位參觀者請看,

圖中是一段出土自 Unix V7 系統“遺蹟”的 C 語言代碼,這段代碼可追溯至距今 40 餘年的 1979 年左右,作者是 Ian Darwin。它是早期 file 命令源代碼的一部分。

file 命令用於在不依賴文件擴展名的情況下判斷文件類型——當“打開方式不對”時,file 命令能告訴你文件中實際包含的內容。直到今天,大多數類 Unix 操作系統裏還能見到 file 命令的身影。

現在看到的這個名為 english(bp, n) 的函數能夠對文本的特徵進行分析,並以此來判斷一段文本是否由英文寫成。換句話説,這是一個早期的“文本語言識別器”。

運行在 Unix V7 模擬器上的 file 命令。這裏輸出了根目錄下所有文件的類型。可見,文件“COPYRIGHT”是一個英文文檔

接下來,我們就來一同看看這件“數字文物”是如何構思和實現的,它的寫法與我們今天的編碼風格有何不同?在算法設計上是否還有可借鑑之處?


K&R 風格的函數聲明

先來看前兩行函數聲明的部分,這無疑是整段程序中與現代 C 語言差異最明顯的地方了吧。

english (bp, n)
char *bp;

若按照今天的 C 語言語法,這個函數的簽名大致會寫作:

int english(char *bp, int n)

回到 1979 年,C 語言剛剛走過了它的第 7 個年頭。那時還沒有標準化的 ANSI C,主流仍是所謂的 K&R 風格語法——函數參數的類型不寫在參數列表裏,而是緊接函數名後;而參數或返回值如果是 int 類型,通常也可以省略。這種寫法在今天看來或許有些古怪,但卻在當年普遍使用。

這個函數體可以大致分為三部分:初始化統計規則檢查

初始化

先來看看初始化部分。和現代 C 語言相比,局部變量的定義倒是沒有太大變化。為了方便大家理解,先來簡單介紹下幾個關鍵標識符的含義:

english (bp, n)
char *bp;
{
# define NASC 128
    int ct[NASC], j, vow, freq, rare;
    int badpun = 0, punct = 0;
    if (n<50) return(0); /* no point in statistics on squibs */
    for(j=0; j<NASC; j++)
        ct[j]=0;
... ...
  • bp:指向待判斷的文本的指針,即指向待分析的字符串的首個字符
  • n:文本長度
  • NASC:定義了統計數組 ct 的大小,128 是 ASCII 字符的數量
  • ct:用來統計文本中每種字符出現的次數
  • ji 已用作全局變量,所以這裏用 j 作為循環變量
  • vow:記錄文本中出現的元音字母(vowel)的數量
  • freq:記錄高頻英文字母(e、t、a、i、o、n)的出現次數
  • rare:記錄罕見英文字母(v、j、k、q、x、z)的數量
  • badpun:記錄不符合英文書寫規範(即其後沒有空格或換行)的標點符號的出現次數
  • punct:記錄所有標點符號(punctuation)的總數

統計

接下來,我們進入統計部分。簡單來説,這裏會統計文本中每種字符出現的次數。

    for(j=0; j<n; j++)
    {
        if (bp[j]<NASC)
            ct[bp[j]|040]++;
        switch (bp[j])
        {
        case '.': 
        case ',': 
        case ')': 
        case '%':
        case ';': 
        case ':': 
        case '?':
            punct++;
            if ( j < n-1 &&
                bp[j+1] != ' ' &&
                bp[j+1] != '\n')
                badpun++;
        }
    }

這裏就有個小技巧——使用了 | 040 這個位運算來實現英文字母大小寫的轉換。在 ASCII 碼錶中,英文字母的大寫和小寫只差一個固定的位,這個位就是第 6 位,即 0010 0000,十進制的 32,也就是八進制的 040)。

舉個例子吧,把英文字母 A 的 ASCII 碼(65)與 040 做按位“或”操作,就能把它轉換成小寫字母 a。這樣,不管是大寫還是小寫字母,都會被統一為小寫,方便統計和比較。

不過,從更嚴謹的角度看,這相當於把 ASCII 碼錶中第 1、2 列的字符分別映射到了第 3、4 列上,把第 5、6 列的字符分別映射到了第 7、8 列上(所以 “)” 和“*”的數量可能虛高)。

規則檢查

好了,現在我們來到了整段代碼的“壓軸環節”——規則檢查部分。

    if (badpun*5 > punct)
        return(0);
    vow = ct['a'] + ct['e'] + ct['i'] + ct['o'] + ct['u'];
    freq = ct['e'] + ct['t'] + ct['a'] + ct['i'] + ct['o'] + ct['n'];
    rare = ct['v'] + ct['j'] + ct['k'] + ct['q'] + ct['x'] + ct['z'];
    if (2*ct[';'] > ct['e']) return(0);
    if ( (ct['>']+ct['<']+ct['/'])>ct['e']) return(0); /* shell file test */
    return (vow*5 >= n-ct[' '] && freq >= 10*rare);

這裏使用了一些簡單的規則,基於以下幾個語言統計特徵來判斷文本是否為英語:

  • 不符合書寫規範的標點符號是否過多(> 20%)
  • 元音字母 vow 的比例
  • 高頻英文字母 freq(e、t、a、i、o、n)出現的頻率
  • 罕見英文字母 rare(v、j、k、q、x、z)的比例
  • 特殊(非自然語言)符號的比例,例如 <、> 等 Shell 腳本中常用的符號
  • ……

總之,雖然這些規則未必來自正式的語言統計方面的論文,但它們的確描述了英文的基本結構特徵:一段“像”英語的文本應該足夠長、標點用得得當、元音字母夠多、常用的英文字母佔優勢,而且不能像代碼那樣滿是奇怪的符號。


尾聲:古老代碼中的現代迴響

今天,判斷一段文本屬於哪種語言,早已有了更復雜、更強大的工具。例如基於樸素貝葉斯分類的算法,或利用大量語料庫對各類語言進行概率建模,特別是眼下熱門的大語言模型(LLMs),甚至已經能夠對語言風格、上下文、甚至作者習慣做出精準判斷。

相比之下,1979 年的這段 english() 函數雖然原始,卻展現了早期計算的美學:在資源有限的條件下,用最簡單的方法儘可能準確地解決實際的問題

回望這段塵封多年的 C 語言代碼,我們依然能感受到它的精巧。就像在展櫃中看到一件鏽跡斑斑的工具,讓人不禁感嘆:“原來當年,已經有人想得這麼遠了啊。”

🔚

🏛️ 遺蹟地址

  • https://www.tuhs.org/cgi-bin/utree.pl?file=V7/usr/src/cmd/file.c Unix V7 上的 file 命令的源代碼
  • https://copy.sh/v86/?profile=unix-v7 Unix V7 模擬器

user avatar f702 頭像 u_16213653 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.