正規表示式的入門與應用(三)

張耀仁
changyj@rtfiber.com.tw
http://www.rtfiber.com.tw/~changyj
目前任職於瑞泰纖維
作者同時譯有 Linux 核心研究篇
著作權所有﹐欲轉載請與筆者聯絡

本期介紹給讀者的內容有:

延伸型 RE

延伸型(extended)RE 提供了比基本型 RE 更多的特殊字元﹐如 +, ?, 以及 |:
  1. X+:
  2. X?:

[問題12]
請利用 egrep 把含有 long 或 loong 的資料行列出來。
[說明]

  1. egrep 與 grep 的差別在於解譯 RE 的方式:前者採用延伸型﹐ 而後者採用基本型 RE 的語法。
  2. 比較 long 與 loong 時發現﹐這兩個字串只相差一個 'o'﹐ 可以把 RE 寫成 loo?ng﹐第二個 o 之後的 ? 代表這個 o 可有可無。
  3. 執行範例如下:
    [changyj:~] cat data7
    Edward Furloong
    has
    long hair.
    
    [changyj:~] egrep -n 'loo?ng' data7
    1:Edward Furloong
    3:long hair.
    

特殊字元『|』

當利用『|』把幾個 RE 連接起來﹐例如 RE1 | RE2 | ... | REn 時﹐ 代表字串只要符合 RE1, RE2, ..., REn 中的任何一個 RE 時即可﹐讀者可以把上式解讀成 RE1 『或』RE2『或』... REn。 『|』常被稱為 alternation operator。

以下利用檔案 data8 來示範『|』的用法。首先看看檔案 data8 的內容:

[changyj:~] cat data8
I keep a cat and
a dog. The cat
likes to eat fish
and the dog prefers
roasted beef.
想列出含有 dog『或』fish 字串的資料行時﹐可以把 RE 設計成『dog|fish』:
[changyj:~] egrep -n 'dog|fish' data8
2:a dog. The cat
3:likes to eat fish
4:and the dog prefers
如果要列出含有 cat『或』beef 字串的資料行﹐使用『cat|beef』這個 RE 即可:
[changyj:~] egrep -n 'cat|beef' data8
1:I keep a cat and
2:a dog. The cat
5:roasted beef.
而使用『cat|dog』這個 RE 則可列出含有 cat『或』dog 的資料行:
[changyj:~] egrep -n 'cat|dog' data8
1:I keep a cat and
2:a dog. The cat
4:and the dog prefers
我們發現檔案 data8 的第二行同時含有 cat 與 dog。那麼 egrep 究竟是因為發現該行有 cat﹐還是該行有 dog 才列出該行? 以下列出 egrep 處理該行的過程:
  1. 一開始的 A 點(請參考第一期的說明)在第一個字元﹐也就是字元 'a' 的位置。 egrep 先檢查在該位置是否可能出現符合 RE﹐也就是『cat|dog』的字串。 而處理『|』時的順序是由左而右﹐因此對於同一個 A 點﹐ egrep 會先檢查該位置是否出現 cat;否則則檢查該處是否有 dog 字串。 在發現該位置並未出現符合任何子 RE 的字串後﹐egrep 把 A 點移到下一個字元。
  2. 此時 A 點在第二個字元﹐由於該位置並未出現 cat 或 dog﹐ egrep 把 A 點移到下一個字元。
  3. 當 A 點在第三個字元時﹐雖然該處並沒有 cat 字串﹐但卻出現了 dog 字串符合子 RE『dog』﹐於是 egrep 把該行列出。
  4. 因此 egrep 是先發現該行有 dog 而把該行列出。

『( )』的使用

在延伸型 RE 中﹐『( )』有兩種功能﹐統稱為 Grouping:
  1. 維持『子 RE』的完整性:
    當要設計只讓 company 及 companies 能符合的 RE 時﹐ 我們發現這兩個字串前半部都是 compan。請參閱表 1:
    [表1]
    第一部分 第二部分
    合乎要求的字串 compan y 或 ies
    相對應的 RE compan y | ies
    如果把兩個部分的 RE 直接連接起來﹐得到『company|ies』﹐ 這個 RE 代表要搜尋的字串是 company 或是 ies﹐顯然與原先的構想不同。 解決的方法是藉由『( )』來維持『y|ies』的完整性﹐ 把 RE 寫為『compan(y|ies)』﹐執行範例如下:
    [changyj:~] cat data9
    Although there are many companies
    selling computers, our
    company is the most famous one.
    
    [changyj:~] egrep -n 'compan(y|ies)' data9
    1:Although there are many companies
    3:company is the most famous one.
    
  2. 使多字元的 RE 與 *, +, ? 等特殊字元結合:
    在介紹基本型 RE 時﹐曾強調 * 必須與佔有一個字元位置的 RE(one-character RE) 結合;在延伸型 RE 中﹐只要把多字元的 RE 利用一組『( )』含括起來 即可與 *, +, ? 等特殊字元結合。例如要尋找 cabc, cababc, cabababc, ... 等 由字元 c 再接著若干組 ab 再接上 c 的字串時﹐可以把 RE 設計為『c(ab)+c』﹐ 此時與特殊字元 + 結合的 RE 是 ab:
    [changyj:~] echo 'ccababcc' | egrep -n 'c(ab)+c'
    1:ccababcc
    
一般而言﹐grep, sed 等支援的是基本型 RE;而 egrep, perl 支援的是 延伸型 RE。表 2 列出了延伸型 RE 的特殊字元﹐以及在基本型 RE 中相對應的寫法; 若標示『無』則代表該類 RE 並未提供該項功能:
[表2]
延伸型 RE 的特殊字元 在基本型 RE 內的寫法
'.', ^, $,
[...], [^...]
同左
*, +, ?, |, {min,max} *,無,無,無,\{min,max\}
(...) \(...\)
(...)*, (...)+, (...)?
[註1] 讀者在 GNU 的 grep 與 sed 中可以使用延伸型 RE 內的 + 與 ?﹐ 寫法分別是 \+ 與 \?。
[註2] GNU 的 grep 與 sed 允許 *, \+, \? 與多字元的 RE 結合﹐ 只要把該 RE 放在一組 \( 及 \) 內即可。

如何在 Perl 中使用 RE

本節的目的在於介紹如何在 Perl 上使用 RE﹐ 筆者假設讀者對 Perl 已有基本的認識﹐文中將不詳述與 RE 無關的部分。

引號與引號型的運算子

在 Perl 中﹐我們必須在字串型資料的前後各加上 一個單(雙)引號﹐例如 'It is fine...', "Are you OK?"﹐以便把不屬於 該字串的其他字元分隔開來。

Perl 對於單、雙引號有不同的處理方式:

請參考以下的範例:
$var = '\n';   # $var 的內容為兩個字元:'\' 以及 'n'
$var = "\n";   # $var 的內容為一個字元:跳行字元
$name = 'John';
$var = 'My name is $name..';  # $var 的內容為 My name is $name..
$var = "My name is $name..";  # $var 的內容為 My name is John..

相較於其他的程式語言﹐Perl 提供了『引號型運算子』﹐ 讓使用者可以選擇除了 ' 與 " 之外﹐任何非文數字(non-alphanumeric)及 非空白字元(non-whitespace)的字元來做為分隔符號(delimiter):

  1. 單引號型運算子:
  2. 雙引號型運算子:寫法為 qq/字串/﹐與 "字串" 同義。
有關引號型運算子的詳細說明﹐ 請參考 perlop manpage 內 'Quote and Quote-like Operators' 一節。

尋找符合 RE 的字串-- =~ 與 m/RE/ 的結合

要檢查一個變數是否含有符合 RE 的字串時﹐ 我們可以利用運算子 m/RE/ 或 /RE/﹐並與 =~ 合用﹐格式為 變數 =~ m/RE/ 或是 變數 =~ /RE/ 當變數含有符合 RE 的字串時﹐運算的結果為真﹐否則為偽。

[問題13]
請寫一 Perl 程式﹐當使用者輸入的資料含有數字時﹐印出『有數字』的訊息。
[說明]
程式如下:

#!/usr/bin/perl
while(1)
 { print "請輸入: "; 
   chop($input=<STDIN>);
   if($input =~ m/[0-9]+/) { print "有數字...\n\n"; }
 }
在這個程式中:
  1. 有一個無窮迴圈(while(1) {...})。
  2. 使用者每輸入一行﹐ 該行的內容會被存入 $input 這個變數中($input=)﹐ 包含按 ENTER 時產生的跳行字元(newline﹐\n)。
  3. 我們利用 chop($input) 來刪除 $input 內的最後一個字元﹐也就是跳行字元。
  4. 當 $input 內含有數字時會使得 if(m/[0-9]+/) 成立而印出『有數字』的訊息。 例如輸入『number 235811 text 44759』時:
採用 m/RE/ 而不是 /RE/ 的好處在於前者可以使用 其他的字元來做為分隔符號﹐方式與先前介紹的『引號型運算子』相同。

[問題14]
請寫一 Perl 程式讓使用者輸入一路徑名稱﹐並判斷該路徑是否為絕對路徑。
[說明]
程式如下:

#!/usr/bin/perl
while(1)
 { print "請輸入路徑: ";
   chop($path=<STDIN>);
   if($path =~ m|^/|) { print " $path 是絕對路徑\n\n"; }
   else { print " $path 是相對路徑\n\n"; }
 }
在這個程式中﹐
  1. 我們以『 $path 的內容是否以 / 開頭』來做為是否為絕對路徑的依據。
  2. 其中 ^ 為定位字元﹐代表變數內容的最前端﹐ 如果為 $ 則為變數內容的末端。
  3. 如果不改變分隔符號的寫法是 $path =~ m/^\//。
  4. 我們可以把 m|^/| 寫成 m(^/)、m[^/]、m{^/} 或 m<^/>。
  5. print 後的字串是以雙引號來做為分隔符號﹐而 '$path' 之前並沒有 \﹐ 因此 Perl 會以 $path 的內容來取代 '$path' 這五個字元。

特殊序列

為簡化 RE 的寫法﹐Perl 定義了一些特殊序列(meta sequence)﹐請參考表 3:

[表三]
特殊序列 說明
\w 等於 [A-Za-z0-9_]﹐由文數字元(alphanumeric)及 底線 _ 所構成的字元集合
\W 等於 [^A-Za-z0-9_], 為 \W 的反相字元集合
\s 由空白字元所構成的字元集合﹐通常為 [ \t\n\r\f]
\S \s 的反相字元集合
\d 等於 [0-9]﹐由數字字元(digit)所構成的字元集合
\D 等於 [^0-9]﹐為 \d 的反相字元集合

因此﹐問題 13 中的 $input =~ m/[0-9]+/ 可改寫成 $input =~ m/\d+/﹐ 也可寫成 $input =~ m{\d+} 等。

引用合乎 RE 的字串之某些部分

當變數內含有符合 RE 的字串時﹐Perl 允許我們引用該字串的某些部分﹐說明如下:
  1. 設計 RE 時在欲引用的部分前後各加上『(』與『)』做為標記﹐ 在此『( )』的功能是『擷取文字』(capturing text)。
  2. RE 中的『(』與『)』必須成對(不考慮與『\』結合者)。
  3. 把 RE 中的『(』加以編號﹐編號從 1 開始﹐由左向右遞增。
  4. 對於經由『( )』標記的部分﹐如果標記該組的『(』之編號為 N﹐ 則該組的編號亦為 N﹐我們利用『$N』來引用該組的內容。
  5. Perl 採用延伸型 RE﹐因此『( )』也能做為 grouping 之用 (請參考『( ) 的使用』一節)。 即使我們要某些『( )』擔任的是 grouping 的角色﹐Perl 仍賦予它擷取文字的 功能;因此在編號時仍須計算在內。
  6. 在 sed 中是利用『\(』與『\)』來標記﹐以『\編號』來取用﹐ 與 Perl 不同﹐請讀者留意。

[問題15]
如果 $var 內含有符合像 '12:23:52' 這種格式的字串時﹐ 請把該字串所代表的時、分、秒印出來﹐並分別存到 $hour, $min, $sec 內。
[說明]

  1. 首先設計出讓 '12:23:52' 這種格式的字串能符合的 RE:『\d+:\d+:\d+』。 在此不使用 \d\d 而採用 \d+ 是為讓時(分、秒) 只有一位數的情況﹐像 8:15:4﹐也能符合所設計的 RE。
  2. 接著分別把代表時、分、秒的部分標記起來﹐得到『(\d+):(\d+):(\d+)』。 時、分、秒分別是第一、二、三組用『(』及『)』標記起來的部分。
  3. 當有字串符合『(\d+):(\d+):(\d+)』這個 RE 時﹐ 我們分別可用 $1, $2 及 $3 來引用字串中屬於時、分、秒的部分。 程式範例如下:
    #!/usr/bin/perl
    while(1)
     { print "請輸入: "; 
       chop($var=<STDIN>);
       if($var =~ m/(\d+):(\d+):(\d+)/)
        { $hour = $1; $min  = $2; $sec  = $3;
          print " 時=$1, 分=$2, 秒=$3\n";
          print " \$hour=$hour, \$min=$min, \$sec=$sec\n\n";
        }
     }
    
  4. 如果想同時印出整個符合的字串時﹐可以把整個 RE 用一組『( )』 標記起來﹐成為『((\d)+:(\d+):(\d+))』。此時 $1 的值即為整個符合 的字串﹐而字串中屬於時、分、秒的部分則分別是 $2, $3 與 $4。程式改寫如下:
    #!/usr/bin/perl
    while(1)
     { print "請輸入: "; 
       chop($var=);
       if($var =~ m/((\d+):(\d+):(\d+))/)
        { $hour = $2; $min  = $3; $sec  = $4;
          print " Time=$1, 時=$2, 分=$3, 秒=$4\n";
          print " \$hour=$hour, \$min=$min, \$sec=$sec\n\n";
        }
     }
    
  5. 由於 Time=13:246:7890 這種不合理的字串也符合剛才設計的 RE﹐ 為使程式更加完善﹐我們可以增加做為判斷之用的程式碼而改寫成
    if($var =~ m/(\d+):(\d+):(\d+)/)
     { if($1 >=24 || $2 >=60 || $3 >= 60)
        { print "時間格式不正確!!\n"; 
        }
       else 
        { $hour = $1; $min  = $2; $sec  = $3; 
          print "\$hour=$hour, \$min=$min, \$sec=$sec\n"; 
        }
     }
    
    即可﹐不必堅持要改寫 RE;實際上在設計 RE 時﹐ 如果要把所有條件都考慮進去﹐往往是耗時且困難的。

m// 的傳回值因執行環境而異

在剛才的例子中﹐'if' 所能接受的值屬於純量型(scalar)﹐ 因此 'if' 要求 $var =~ m// 傳回純量值(例如真、偽); 我們稱此時 $var =~ m// 是在純量環境(scalar context)下執行。

如果要求 $var =~ m// 傳回串列(例如利用它的傳回值來設定陣列等)﹐ 則稱它是在串列環境(list context)下執行﹐分為兩種情形:

  1. RE 中有利用『(』及『)』標記起來的部分:
    當變數含有符合 RE 的字串時﹐$var =~ m// 會傳回由 $1, $2, ... 等變數值 所構成的串列;否則傳回空的串列。

    [問題16]
    如果 $var 內含有符合像 '12:23:52' 這種格式的字串時﹐ 請印出該字串所記錄的時、分、秒。
    [說明]
    程式範例如下:

    #!/usr/bin/perl
    @label=('時','分','秒');
    while(1)
     { print "請輸入: ";
       chop($var=<STDIN>);
       $i=0;
       foreach $x ($var =~ m/(\d+):(\d+):(\d+)/)
        { print " $label[$i]=$x\n";
          $i++;
        }
     } 
    
    (1) foreach $x 之後需要一個串列﹐因此 $var =~ m// 是在串列環境下執行。
    (2) 如果使用者輸入『I'll meet him at 11:34:5 in the park.』時﹐ 由於 11:34:5 符合 RE﹐m// 將傳回由 ('11', '34', '5') 這樣的串列。 我們再透過 foreach 的迴圈把每一個元素列出。
    (3) 如果使用者輸入『I hate regular expression!』時﹐由於其中並無符合 RE 的字串﹐m// 將傳回空串列;foreach 則不會執行迴圈。
  2. RE 中沒有利用『(』及『)』標記起來的部分: 當變數內有字串符合 RE 時﹐傳回串列 (1);否則傳回空的串列。

m//g 的運用

當 m// 找到第一個符合 RE 的字串後就不會再繼續搜尋;如果要求 m// 找出所有符合 RE 的字串時則必須使用 g 選項。在不同的執行環境下﹐m//g 也有不同的回應:
  1. 串列環境:
  2. 純量環境:
    m//g 會從上一個符合 RE 字串之後的第一個字元開始搜尋; 若是第一次搜尋或上次搜尋失敗時﹐則從變數內容的第一個字元開始。 當找到符合 RE 的字串時傳回值為真﹐否則為偽﹐ 通常配合迴圈來使用。以下的程式將列出 $input 內所有的數字:
    #!/usr/bin/perl
    while(1)
     { print "請輸入: "; 
       chop($input=<STDIN>);
       print "找到的數字有 ";
       $i=0;
       while($input =~ m/(\d+)/g)
        { $i++; print " $1 "; 
        }
       print "\n一共有 $i 個數字..\n\n";
     }
    

s/// 運算子

還記得在 sed 中我們利用編輯命令 s 來進行字串取代的動作嗎? Perl 也提供了格式相同的 s 運算子﹐可用來修改變數的內容。範例如下:
#!/usr/bin/perl
$var = "The good boy has a good cat and a GooD dog.";
print " \$var 先前的內容是 $var\n";
$var =~ s/good/bad/;
print " \$var 現在的內容是 $var\n";
執行上述程式時發現在 $var 中只有『第一個』good 被換成 bad﹐ 如果要把所有的 good 換成 bad﹐則必須加上 g(代表 global 之意)選項﹐ 把敘述改寫成『$var =~ s/good/bad/g;』即可。 如果不區分大小寫﹐要把 good, Good, gOOD 等都換為 bad 時﹐則必須再加上 i(代表 ignore case 之意)選項﹐把原敘述改為『$var =~ s/good/bad/gi;』即可。

在 Perl 的 s/// 運算子中﹐不論是要引用符合 RE 的字串﹐或是字串的某些部分﹐ 或是改變分隔符號﹐均採用與 m// 相同的方式。做個實驗:

#!/usr/bin/perl
$var = "abc123def";
print " \$var 先前的內容是 $var\n";
$var =~ s/(\d)/$1$1/g;
print " \$var 現在的內容是 $var\n";
$path = "/usr/bin/ghostview";
print " 原先 \$path 的內容是 $path\n";
$path =~ s|/usr/bin|/usr/local/bin|;
print " 現在 \$path 的內容是 $path\n";
這個程式的前半部會把變數 $var 內每一個(因為用了 g 選項)數字字元重覆一次﹐ 因此 $var 的內容會變成 abc112233def。後半部則把 $path 內的 /usr/bin 換成 /usr/local/bin。

[問題17]
假設 $var 內容是由三個以『,』分隔開的欄位所構成﹐ 現在我們想把第 1 與第 2 個欄位對調。

[說明]
程式如下:

#!/usr/bin/perl
$var = "欄位 1, 欄位 2,  欄位 3";
print " \$var 先前的內容是 $var\n";
$var =~ s/^([^,]*),([^,]*)/$2,$1/; 
print " \$var 現在的內容是 $var\n";
由於是以逗號來區隔欄位﹐我們假設每個欄位內不會有逗點出現﹐ 因此可以把每個欄位的內容想成是由 『零或多個非逗號的字元』 所構成﹐可以用『[^,]*』這個 RE 來描述。

Perl 的 s/// 提供了一個很實用的選項 e﹐以例子來說明:

#!/usr/bin/perl
$var = "num 54321 num 1357 num 246";
print " \$var 先前的內容是 $var\n";
$var =~ s/(\d+)/sprintf("%06d", $1)/ge;
print " \$var 現在的內容是 $var\n";
  1. s/// 的 RE 部分為 \d+﹐Perl 會嘗試以『貪心』(即越多越好) 的方式來尋找連續多個的數字字元。
  2. 在 $var 中﹐Perl 找到第一個符合 \d+ 的字串是 54321。 由於使用了 e 選項﹐Perl 會先把 sprint("%06d", $1) 當成一般的式子來執行﹐ 並以該式子所產生的資料來做為『用來取代的字串』。 而 sprintf("%06d", $1) 會把 $1 的值以六位數的方式印出﹐ 不足六位時會在前方以 0 補滿。 由於此時 $1 的值為 54321﹐sprintf() 的結果為 054321﹐因此 Perl 會把 $var 內的 54321 換成 054321。
  3. 第二、三個符合 \d+ 的字串分別是 1357 與 246﹐ 而執行 sprintf() 後產生的字串分別是 001357 與 000246﹐ 因此 Perl 分別用 001357 以及 000246 把兩者取代掉。

結語

筆者借用了三期的篇幅﹐向各位介紹 RE 的概念﹐並示範如何在 grep, sed, egrep 與 Perl 上使用 RE。 三期的內容﹐只能讓各位初窺 RE 的奧妙﹐無法讓各位馬上成為 RE 的行家﹐ 畢竟要熟悉 RE﹐除了多加思考與練習﹐並沒有其他的捷徑。 冀望將來能有機會﹐與各位一起更深入地研究正規表示式。

參考書目