位元詩人 GNU AWK:現代化 AWK 實作品

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

gawk(1) 與 grep(1) 的本質相同,皆為處理文字串流(text stream)的過濾器(filter)。然而,AWK 具備完整的程式語言特性,且內建強大的欄位(column)處理能力,這點是 grep 所缺乏的。

由於歷史發展脈絡,AWK 存在多種不同的實作品。但在當前絕大多數的類 Unix 系統中,刻意去相容舊版的 POSIX AWK 已無太大必要,因此本文將直接以功能最全面的 GNU AWK(gawk)作為主要探討對象。

核心定位

在現代開發環境中,gawk 的核心定位包含:

  • 高效文字過濾:快速篩選與轉換複雜的文字資料。
  • 原生欄位處理:無需額外工具即可精準解析表格化數據。
  • 大檔案串流處理:透過串流機制,以極低的記憶體消耗處理巨型文字檔。
  • 原地編輯支援:新版本引進了原地修改檔案(in-place editing)的參數功能。

雖然 AWK 的應用場景與 Perl 高度重疊,但 AWK 的程式碼通常比等效的 Perl 更加簡潔精煉。不過,若你想一勞永逸地減少學習各種命令列工具的成本,Perl 亦是一個功能全面的替代選擇。

安裝 GNU AWK

Ubuntu / Linux Mint / Debian

$ sudo apt install gawk

RHEL / Fedora / Rocky / Alma

$ sudo dnf install gawk

SLES / openSUSE

$ sudo zypper install gawk

撰寫第一個 awk 程式

簡短的 awk 程式不需要寫在命令稿中,直接寫在命令列即可。以下是 awk 版本的 Hello World 程式:

> gawk "BEGIN { print \"Hello World\"; }"
Hello World

為什麼 awk 程式會長這樣呢?這牽涉到 awk 程式的構造。詳見後文。

撰寫 awk 命令稿

對於較長的 awk 程式,每次都要直接在命令列上重新寫一行程式 (one liner) 比較麻煩。可以將 awk 程式預寫在命令稿中,之後可重覆執行該命令稿。以下是使用 awk 命令稿的虛擬指令:

> gawk -f path\to\script.awk path\to\file.txt

-f 參數後面接的是 awk 命令稿的位置。第二個之後的檔案路徑則是要掃描的目標檔案。原本 awk 就是用來寫一行程式 (one liner) 的,只有碰到比較長的 awk 程式才會寫成命令稿 (註) ,所以要使用 awk 命令稿反而要加參數。

(註) 有時候短的 awk 程式也會寫成命令稿,像是要繞過雙引號跳脫的議題時。

awk 程式的構造

雖然 awk 具有完整的程式語言,但 awk 在設計上不是拿來當成通用型語言,而是一種特化的文字過濾工具。awk 程式的構造如下:

condition {
    command;
    ...
}

在 awk 程式中,當 condition 符合條件時,逐一執行該區塊內的指令。

實際上,awk 會逐一掃描文字檔案,對檔案中每一行文字重覆執行 awk 程式。像是以下虛擬指令:

> gawk "condition { command; ... }" path\to\file.txt

所以,從這裡就可以理解為什麼 awk 程式要設計成這個樣子。

BEGIN 和 END 區塊

承上,當我們想要寫一些不掃描文字串流的 awk 程式碼時,就可以將其寫在 BEGIN 或 END 區塊。如同其名,BEGIN 區塊的程式碼會發生在掃描文字串流前,而 END 區塊的程式碼會發生在掃描文字串流後。

加上這些區塊的 awk 程式的虛擬碼如下:

# The block runs before awk
#  scanning text streams.
BEGIN {
    command;
    ...
}

# The block runs multiple times
#  during awk scanning text streams.
condition {
    command;
    ...
}

# The block runs after awk
#  scanning text streams.
END {
    command;
    ...
}

再回頭看先前的 Hello World 程式:

> gawk "BEGIN { print \"Hello World\"; }"

由此可知,這個程式就是只有 BEGIN 區塊的 awk 一行程式。

awk 入門

本節簡要地列出 awk 的基本語法。這裡的內容無法讓讀者變成 awk 的專家,但可以看得懂一些 awk 程式碼、開始寫 awk 程式。

資料型態 (Data Type)

awk 有字串 (string) 和數字 (numeric) 兩種資料型態。實例如下:

  • 3.1415927 (數字)
  • "Hello World" (字串)

外界輪入的文字資料 (text data) 理論上全部是字串型態資料,但資料的樣式像數字 (為 numeric string) 時,awk 會自動將其轉為數字。

另外,awk 承襲 C 的概念,沒有布林型態,但有布林語境。數字 0 和空字串 "" 視為偽 (falsy),其他值視為真 (truly)。

變數 (Variable)

awk 的變數不需預先宣告,可以直接使用。未賦值的變數視為空字串。會採這樣的設計是因為 awk 是用來寫一行程式的,程式碼要越短越好。

awk 變數沒有綁定資料型態。實際上,awk 的資料型態相當鬆散,字串和數字間可隨情境自動轉換。

除了使用者建立的變數外,awk 有許多內建變數 (built-in variables)。實際上,awk 的內建變數相當有用,提供許多有用的資訊。這裡有內建變數的清單。

運算子 (Operator)

以下是 awk 的算數運算子:

  • a + b:相加
  • a - b:相減
  • a * b:相乘
  • a / b:相除,得浮點數
  • a % b:取餘數
  • a ** b 或 a ^ b:取指數
  • +a:取正號。會轉換型態為數字
  • -a:取負號

以下是 awk 的遞增/減運算子:

  • ++a:前綴遞增。先遞增再取值
  • a++:後綴遞增。先取值再遞增
  • --a:前綴遞減。先遞減再取值
  • a--:後綴遞減。先取值再遞減

字串相接沒有運算子,直接將相鄰字串寫在一起即可。如下例:

> gawk "BEGIN { print \"Hello \" \"World\"; }"
Hello World

以下是 awk 的指派運算子:

  • =:一般的指派運算
  • +=:相加後指派
  • -=:相減後指派
  • *=:相乘後指派
  • /=:相除後指派
  • %=:取餘數後指派
  • **= 或 ^=:取指數後指派

以下是 awk 的比較運算子:

  • ==:相等
  • !=:相異、不相等
  • >:大於
  • >=:大於或等於
  • <:小於
  • <=:小於或等於
  • x ~ /pattern/:符合 pattern
  • x !~ /pattern/:不符合 pattern
  • subscript in array:subscript 位於 array 中

若 x 為 $0 (輸入行本身),可略去。虛擬指令如下:

> gawk "/pattern/ { command; ... }" path\to\file.txt

以下是 awk 的邏輯運算子:

  • &&:且 (logical and)
  • ||:或 (logical or)
  • !:非 (logical not)

控制結構 (Control Structure)

if 敘述是基本的選擇控制結構。在 if 敘述中可加上選擇性的 else if 敘述 (零到多個) 和 else 敘述 (零到一個)。以下範例用到 if 敘述:

function rand_int(min, max)
{
    return int(rand() * (max - min + 1)) + min;
}

BEGIN {
    srand();
    n = rand_int(-1, 1);

    if (n > 0) {
        print n " is larger than zero";
    }
    else if (n == 0) {
        print n " is equal to zero";
    }
    else {
        print n " is smaller than zero";
    }
}

switch 敘述是特化的選擇控制結構,主要的目的是簡化 if 敘述。一般情形下,每個 switch 敘述的 case 子區塊要用 break 敘述區隔。刻意不區隔時會繼續執行下一個 case 子區塊,這項特性稱為 fallthrough 。以下是範例程式:

BEGIN {
    now = systime();
    dow = strftime("%w", now) + 0;

    switch (dow) {
    case 6:
        /* Fallthrough. */
    case 7:
        print "Weekend";
        break;
    case 5:
        print "Thanks God. It's Friday";
        break;
    case 3:
        print "Hump day";
        break;
    default:
        print "Week";
        break;
    }
}

while 敘述是基本的迭代控制結構,主要用於不特定次數的迴圈。以下是範例程式:

BEGIN {
    n = 1;
    while (n <= 10) {
        print n;

        ++n;
    }
}

for 敘述是特化的迭代控制結構,主要用於特定次數的迴圈。以下是範例程式:

BEGIN {
    for (i = 1; i <= 10; ++i) {
        print i;
    }
}

break 敘述需搭配迴圈使用,用於提早離開迴圈。以下是範例程式:

BEGIN {
    for (i = 1; i <= 10; ++i) {
        if (i > 5) {
            break;
        }

        print i;
    }
}

continue 敘述同樣需搭配迴圈使用,用於略過一輪迭代。以下是範例程式:

BEGIN {
    for (i = 1; i <= 10; ++i) {
        if (0 == i % 2) {
            continue;
        }

        print i;
    }
}

資料結構 (Data Structure)

awk 唯一的資料結構是關連性陣列 (associative array)。至於陣列 (array) 則是用關連式陣列模擬出來的。因為 awk 不是用在大量數字運算的程式語言,這樣的設計是可接受的。

在 awk 中使用常規表示式 (Regular Expression)

常規表示式是用於字串比對的小型語言 (mini language)。很多程式語言和軟體工具都會附加這項特性,但每個軟體內附的常規表示式可能略有差異。

對於使用 awk 來說,常規表示式是相當重要的一部分。本節列出 awk 常用的常規表示式:

  • 字元對應
    • 一般字元:直接一比一對應
    • .:對應任意單一字元
    • [...]:對應任意數個字元
    • [^...]:不對應任意數個字元
  • 重覆
    • ?:重覆零到一次
    • *:重覆零到多次
    • +:重覆一到多次
    • {n}:重覆 n 次
    • {n,}:重覆至少 n 次
    • {n,m}:重覆 n 到 m 次
  • |:或 (or)
  • (...):群組 (grouping)
  • 文字邊界
    • ^:字串開頭
    • $:字串結尾
  • 常見 POSIX 特定字元 (bracket expression):用在 [...] 內
    • [:alnum:]:字母或數字
    • [:alpha:]:字母
    • [:lower:]:小寫字母
    • [:upper:]:大寫字母
    • [:digit:]:數字
    • [:space:]:空白字元
    • [:blank:]:空白 (space) 和 TAB
  • 常見 gawk 特定字元
    • \w:等同於 [[:alnum:]_]
    • \W:\w 的反向。等同於 [^[:alnum:]_]
    • \s:空白字元。等同於 [[:space:]]
    • \S:\s 的反向。等同於 [^[:space:]]
    • \y:文字邊界
    • \B:\y 的反向。文字連續處

使用 awk 處理具有欄位 (Column) 的資料

awk 內建將資料按欄位切割的能力。本節展示在 awk 內使用橺位的方式。

以下是某檔股票的一年交易資料。這裡以 awk 節錄出前十行資料:

> gawk "FNR <= 10 { print $0 }" asset.csv
date,open,high,low,close,volume,transaction
2020-01-02,24.00,24.10,23.90,24.00,6940,2776
2020-01-03,24.05,24.10,23.95,24.10,12353,4588
2020-01-06,24.00,24.05,23.80,23.80,7043,2860
2020-01-07,23.80,23.90,23.70,23.70,7694,2813
2020-01-08,23.70,23.70,23.50,23.55,9123,3264
2020-01-09,23.55,23.80,23.55,23.75,8137,2678
2020-01-10,23.80,23.90,23.70,23.85,7271,2641
2020-01-13,23.90,24.00,23.85,24.00,9763,3499
2020-01-14,24.00,24.05,23.90,24.05,7882,2815

我們想要用 awk 找出該檔股票的收盤價大於或等於 24.5 的日期。awk 預設的分欄位字元是空白 (space),但這裡的欄位是以 , (逗號) 區隔,故用 -F 參數重新指定分欄字元:

> gawk -F "," "$5 >= 24.5 { print $0 }" asset.csv
date,open,high,low,close,volume,transaction
2020-12-14,24.05,25.00,24.00,24.65,99981,28777
2020-12-21,24.50,25.00,24.50,24.95,72165,19050
2020-12-22,25.00,25.60,24.25,24.50,123105,36477
2020-12-29,24.50,24.75,24.45,24.65,36190,11966
2020-12-30,24.80,25.15,24.70,25.00,62189,17828
2020-12-31,25.00,25.00,24.65,24.75,26966,6919

awk 的欄位是從 $1、$2、$3 ... 依序計數。本例的資料位於第五欄,故為 $5。

遞迴掃描多個檔案

awk 本身缺乏遞迴掃描檔案的能力,所以要搭配其他的命令列工具。Windows 上接近 find(1) 的指令為 forfiles。以下指令掃描某專案的 src 子目錄內的所有 PHP 命令稿:

> forfiles /p src /s /m *.php /c "cmd /c gawk -f c:\Users\user\libexec\source.awk @path"

使用 forfile 指令時,awk 指令要嵌在字串中,難以使用內嵌字串,所以要將 awk 指令寫在命令稿中。

awk 範例命令稿

awk 對讀者來說是較為陌生的語言。為了讓讀者熟悉 awk,本節展示數個範例程式。

偵測寬度超過 80 字元的程式碼

傳統上,終端機的寬度是 80 個字元。程式碼最好不要超過這個寬度,以免造成捲動程式碼的困難。以下範例程式檢查超過 80 個字元的檔案和其行數:

length($0) > 80 { print FILENAME ":" FNR ": " $0; }

現在的終端機模擬器沒有這個議題,所以可以稍微放寬這項限制。可以考慮改為 120 個字元的寬度。

偵測使用 Snake Case 的 PHP 變數

PHP 程式碼混合 C 和 Java 兩種風格,許多內建函式採 C 風格,但變數、自訂函式、類別採用 Java 風格。本範例程式找出使用 C 風格的變數:

/\$\w+(_\w+)+/ { print FILENAME ":" FNR ": " $0; }

去掉 $ 前綴的話,會掃出一堆內建函式。過多偽陽性結果不是我們所樂見的。

偵測使用字串實字 (String Literal) 的字典

在程式碼中,應減少使用字串實字做為關連式陣列的索引。因為修改索引時要逐一修改,在軟體工程上是不利的。以下範例程式會掃出程式碼中使用字串實字做為關連式陣列的索引處:

/\['\w+'\]/ { print FILENAME ":" FNR ": " $0; }
/\["\w+"\]/ { print FILENAME ":" FNR ": " $0; }
/\{'\w+'\}/ { print FILENAME ":" FNR ": " $0; }
/\{"\w+"\}/ { print FILENAME ":" FNR ": " $0; }

在程式語言中,關連式陣列可能用 [...] (大部份語言) 或 {...} (Perl),而字串可能用單引號或雙引號,故範例程式碼要寫成四行。實際上的效果是每行文字掃描四次。

偵測程式碼中的魔術數字 (Magic Number)

在程式碼中,應減少直接使用數字實字。因為日後要修改時,需要逐一修改,這在軟體工程上是不利的。以下範例程式會掃出程式碼中使用數字實字的地方:

/[+-]?[[:digit:]]+(\.[[:digit:]]+)?/ { print FILENAME ":" FNR ": " $0; }

模擬 Unix 的 head(1) 指令

Unix 的 head(1) 指令可以節錄文字檔案前幾行的內容,對於觀看文字檔案很方便。但 Windows 沒有這個指令,本小節使用 awk 來模擬這個指令。

以下是 awk 命令稿:

BEGIN {
    if ("" == n) {
        n = 10;
    }
}

FNR <= n {
    print $0;
}

當使用者未輸入 n 的數量時,自動將 n 設為 10。這是仿造 head(1) 的慣例。

實際要輸出文字時,只輪出前 n 行的文字即可。使用 FNR <= n 可控制是否要輸出文字。

使用此命令稿的方式如下:

> gawk -f C:\Users\user\libexec\head.awk n=10 path\to\file.txt

模擬 Unix 的 tail(1) 指令

承上,Unix 的 tail(1) 指令可以顯示文字檔案最後幾行的內容。在 Windows 上沒有這個指令,我們使用 awk 來模擬這個指令。

以下是 awk 命令稿:

BEGIN {
    if ("" == n) {
        n = 10;
    }
}

{
    line[NR]=$0;

    if (NR > n) {
        delete line[NR-n];
    }
}

END {
    for (i = NR-(n-1); i <= NR; ++i) {
        print line[i];
    }
}

當使用者未輸入 n 的數量時,自動將 n 設為 10。這是仿造 tail(1) 的慣例。

awk 掃描文字檔案的方式是逐行掃描,而非一口氣吃入整個文字檔案,這樣的設計是為了節約記憶體。所以,這個命令稿會邊掃描邊儲存 n 行的內容,並移除舊內容以節約記憶體。

待掃完整個文字後,再將最後 n 行的內容印出即可。

這個命令稿完美展示 awk 程式的構造,讀者可以和先前的虛擬碼相互比對一下。

使用此命令稿的方式如下:

> gawk -f C:\Users\user\libexec\tail.awk n=10 path\to\file.txt

檢查 CSV 表格是否對齊

只要使用簡短的 AWK 命令稿,就可以檢查 CSV 表格是否對齊:

BEGIN { FS=","; }

!n { n=NF }
n!=NF { failed=1; exit }

END {
    if (failed)
        print "Malformed CSV sheet"

    exit failed
}

使用此命令稿的方式如下:

> gawk -f C:\Users\user\libexec\csvlint.awk path\to\sheet.csv
關於作者

位元詩人 (ByteBard) 是資訊領域碩士,專注於從探索到產品的開發過程,並以工具驅動的方式改善專案。喜歡以開源專案作為成果,回饋社群。

主要方向包括:自用工具的打磨 (dogfooding)、編譯器技術在工具開發中的應用,以及將研究轉化為可維護的開源成果。

除了技術之外,也喜歡日本料理和黑咖啡,偶爾自助旅行,將生活中的靈感融入技術隨筆。