掌握 PHP 中的正則表達式

所有機器都會消耗輸入,執行某種工作,然後生成輸出。例如,電話把聲能轉換爲電信號並重新轉換回聲頻來啓動對話。發動機吸收燃料(蒸汽、裂變、汽油或者做大量的功)並將其轉換爲功。又或者將朗姆酒、冰塊、酸橙和柑桂酒倒入調酒壺中,並且用力攪拌製作麥泰(或者,如果您希望調製出更具有大都會特色的飲品,請嘗試使用一點香檳酒和帶果肉的梨汁飲料來享用貝利尼。調酒壺真是一個靈活而又非凡的工具)。

由於軟件將轉換數據,因此每個應用程序也是一臺機器 —— 但是是一臺 “虛擬” 機器,因爲沒有使用物理部件。例如,編譯器期望獲得源代碼作爲輸入並將其轉化爲適於執行的二進制代碼。氣象建模工具將根據歷史測量數據來生成預報。而圖像編輯器將消耗並生成像素,對每個像素或每組像素應用規則從而銳化圖像或形成某種風格。

就像任何其他機器一樣,軟件應用程序期望獲得某些 “原料”,例如數字列表、XML 模式中封裝的數據或者協議。如果給程序提供錯誤的 “原料” —— 類型或形式不匹配 —— 則很可能得到無法預測的結果,甚至導致災難。有句格言說得好,“錯誤的輸入必然導致錯誤的輸出”。

事實上,所有比較重要的問題都要求從錯誤數據中過濾出正確數據和/或拒絕錯誤數據以防止得到錯誤輸出。對於 PHP Web 應用程序來說也是如此。無論輸入是來自手動形式還是來自編程式的 Asynchronous JavaScript + XML (Ajax) 請求,程序都必須在執行任何計算之前檢查傳入信息。可能要求數值屬於某個範圍或者被限定爲整數。值可能需要匹配一種特定格式,如郵政編碼。例如,美國的郵政編碼是五位數字加上可選的 “4 個” 限定號碼,後者由連字符和四位附加數字組成。其他字符串可能是特定數目的字符,例如兩個字母表示的美國各州的縮寫。字符串最爲棘手:PHP 應用程序必須對嵌入 SQL 查詢、JavaScript 代碼或者其他能夠改變應用程序行爲或有礙安全性的惡意操作程序保持警惕。

但是程序如何告知輸入是數字還是遵循某個約定(例如郵政編碼)?基本上,執行匹配需要使用一個小型解析器 —— 創建一個狀態機、讀取輸入、處理標記、監視狀態並生成結果。但是,即使是一個簡單的解析器,也難於進行創建和維護。

幸運的是,由於模式匹配分析是最常見的計算需求,因此,隨着時間的推移,一種特殊的簡寫方式(引擎)應運而生(大約從 UNIX® 出現之後),它可以減輕事務的工作量。正則表達式 (regex) 使用簡明、易讀的符號描述模式。給定一個正則表達式和數據,正則表達式引擎將得到數據是否匹配模式及匹配內容(如果找到匹配)等結果。

下面是應用從 UNIX 命令行實用程序 grep 中提取的正則表達式的簡單示例,該實用程序將在一個或多個 UNIX 文本文件的內容中搜索指定模式。命令 grep -i -E '^Bat' 將搜索序列 beginning-of-line(用脫字符號 [^] 來表示),後面緊接着大寫或小寫字母 b、a 和 t(使用 -i 選項將在模式匹配時忽略大小寫,舉例來說,也就是 B 和 b 是等效的)。因此,給出文件 heroes.txt:

清單 1. heroes.txt
Catwoman
Batman
The Tick
Black Cat
Batgirl
Danger Girl
Wonder Woman
Luke Cage
The Punisher
Ant Man
Dead Girl
Aquaman
SCUD
Blackbolt
Martian Manhunter

上述 grep 命令將生成兩個匹配:

Batman
Batgirl

正則表達式

PHP 將提供兩個 regex 編程接口,一個用於可移植操作系統接口(Portable Operating System Interface,POSIX),另一個接口用於 Perl Compatible Regular Expressions (PCRE)。基本上,推薦使用第二個接口,因爲 PCRE 比 POSIX 實現更加強大,可以提供能在 Perl 中找到的所有操作符。要了解關於 POSIX regex 函數調用的更多信息,請閱讀 PHP 文檔(請參閱 參考資料)。在這裏,我主要介紹 PCRE 功能。

PHP PCRE regex 包含針對特定字符和其他操作符、針對特定位置(例如字符串的開頭或結尾)或者針對單詞的開頭或結尾的匹配操作符。regex 還可以描述替代詞,即在其他技術中可能描述爲 “this” 或 “that” 的單詞;定長、變長或不確定長度的副本;字符集(例如,“a 到 m 之間的任意字母”);以及 或各類字符(可打印字符或標點符號)。regex 中的特殊操作符也允許分組 —— 一種將某個操作符應用到所有其他操作符的方法。

表 1 顯示了一些常用的 regex 操作符。您可以連接和結合表 1 中的基本操作符(以及其他操作符)並進行組合來構建(非常)複雜的 regex。

表 1. 常見 regex 操作符
操作符 用途
.(句點) 匹配所有單個字符
^(脫字符號) 匹配出現在行或字符串開頭的空字符串
$(美元符號) 匹配出現在行尾的空字符串
A 匹配大寫字母 A
a 匹配小寫字母 a
\d 匹配所有一位數字
\D 匹配所有單個非數字字符
\w 匹配所有單個字母或數字字符;同義詞是 [:alnum:]
[A-E] 匹配所有大寫的 A、B、C、D 或 E
[^A-E] 匹配大寫 A、B、C、D 或 E 之外的任何字符
X? 匹配出現零次或一次的大寫字母 X
X* 匹配零個或多個大寫字母 X
X+ 匹配一個或多個大寫字母 X
X{n} 精確匹配 n 個大寫字母 X
X{n,m} 至少匹配 n 個且不多於 m 個大寫字母 X;如果忽略 m,則表達式將嘗試匹配至少 n 個 X
(abc|def)+ 匹配一連串的(最少一個)abc 和 defabc 和 def 將匹配

下面是 regex 的常見用法示例。假定 Web 站點要求每個用戶創建一個登錄名。每個用戶名至少要包含 3 個但不多於 10 個字母數字字符,並且必須以字母爲開頭。要強制遵守這些規範,可以使用以下 regex 在提交給應用程序時驗證用戶名:^[A-Za-z][A-Za-z0-9_]{2,9}$

脫字符號將匹配字符串的開頭。第一個集合 [A-Za-z] 表示所有字母。第二個集合 [A-Za-z0-9_]{2,9} 表示由至少 2 個至多 9 個任意字母、數字和劃線組成的序列。並且使用美元符號 ($) 匹配字符串末尾。

乍看之下,美元符號可能看似不必要,但是它是至關重要的。如果忽略掉它,regex 將匹配開頭爲字母、包含 2 至 9 個字母數字字符以及任意數目的任何其他字符的所有字符串。換言之,沒有美元符號錨定字符串的結尾,帶有匹配前綴(例如 “martin1234-cruft”)的非常長的字符串將生成誤判 (false positive)。

用 PHP 和 regex 編程

PHP 提供了用於在文本中查找匹配、將每個匹配替換爲其他文本(la 搜索和替換)以及在列表的元素之中查找匹配的函數。函數包括:

  • preg_match()
  • preg_match_all()
  • preg_replace()
  • preg_replace_callback()
  • preg_grep()
  • preg_split()
  • preg_last_error()
  • preg_quote()

爲了演示函數,讓我們編寫一個小型 PHP 應用程序,該應用程序將搜索單詞列表以查找特定模式,遵循這種模式的單詞和 regex 都是由傳統的 Web 表單來提供的,並且結果都使用 simple print_r() 函數返回給瀏覽器。如果需要測試或改進 regex,則這種小型程序非常有用。

清單 2 顯示了 PHP 代碼。所有輸入都是通過簡單的 HTML 表單來提供的(爲了簡潔起見,不顯示相應的表單,並且已經省略了用於捕捉 PHP 代碼錯誤的代碼)。

清單 2. 比較文本與模式
<?php
	//
	// divide the comma-separated list into individual words
	//   the third parameter, -1, permits a limitless number of matches
	//   the fourth parameter, PREG_SPLIT_NO_EMPTY, ignores empty matches
	//
	$words = preg_split( '/,/',  $_REQUEST[ 'words' ], -1, PREG_SPLIT_NO_EMPTY );

	//
	// remove the leading and trailing spaces from each element
	//
	foreach ( $words as $key => $value ) { 
		$words[ $key ] = trim( $value ); 
	}

	//
	// find the words that match the regular expression
	//
	$matches = preg_grep( "/${_REQUEST[ 'regex' ]}/", $words );

	print_r( $_REQUEST['regex' ] ); 
	echo( '<br /><br />' );
	
	print_r( $words ); 
	echo( '<br /><br />' );
	
	print_r( $matches );
	
	exit;
?>

首先,使用 preg_split() 函數把用逗號分隔的單詞字符串分隔爲單個元素。此函數將在匹配給定 regex 的每個點上劃分字符串。在這裏,regex 只是 ,(一個逗號,以逗號分隔的列表中的分隔符)。代碼中的首尾斜槓只表示 regex 的開頭和結尾。

preg_split() 的第三個參數和第四個參數都是可選的,但是每個參數都十分有用。給第三個參數提供整數 n 將只返回前 n 個匹配;或者提供 -1 返回所有匹配。如果指定第四個參數,標誌 PREG_SPLIT_NO_EMPTYpreg_split() 將處理所有空結果。

接下來,用逗號分隔的單詞列表中的每個元素都是通過 trim() 函數整理的(省略了開始和結束部分的空白),然後與提供的 regex 進行比較。使用函數 preg_grep() 可以非常輕鬆地處理列表:只需提供模式作爲第一個參數,並提供要匹配的單詞數組作爲第二個參數。函數將返回匹配數組。

例如,如果鍵入 regex ^[A-Za-z][A-Za-z0-9_]{2,9}$ 作爲模式和變長單詞列表,則可能獲得類似清單 3 的內容。

清單 3. 簡單 regex 的結果
^[A-Za-z][A-Za-z0-9_]{2,9}$

Array ( [0] => martin [1] => 1happy [2] => hermanmunster ) 

Array ( [0] => martin )

順便說一句,您可以轉化 preg_grep() 操作並查找與具有 PREG_GREP_INVERT 可選標誌的模式(與命令行中的 grep -v 相同) 匹配的元素。用 $matches = preg_grep( "/${_REQUEST[ 'regex' ]}/", $words, PREG_GREP_INVERT ) 替換第 22 行並重用清單 3 的輸入,生成 Array ( [1] => 1happy [2] => hermanmunster )

分解字符串

函數 preg_split() 和 preg_grep() 是優秀的小函數。如果使用可預測的模式分隔子字符串,則前者可以將字符串分解成幾個子字符串。函數 preg_grep() 還可以快速地過濾列表。

但是如果必須使用一個或多個複雜規則分解字符串會發生什麼情況?例如,美國的電話號碼通常顯示爲 “(305) 555-1212”、“305-555-1212” 或 “305.555.1212”。如果刪除標點符號,所有電話號碼都減少到 10 位,這樣在使用 regex \d{10} 時十分易於識別。但是,美國的三位區號和三位電話號碼前綴不能以零或一爲開頭(因爲零和一是非本地呼叫的前綴)。regex 不會把數字序列分隔爲單個數字並編寫複雜的代碼,而是會測試其有效性。

清單 4 顯示了執行此任務的代碼片段。

清單 4. 確定電話號碼是否是有效的美國電話號碼
<?php   
	$punctuation = preg_quote( "().-" );
	$number = preg_replace( "/[$punctuation]/", '', $_REQUEST[ 'number' ] );

	$valid = "/[2-9][0-9]{2}[2-9][0-9]{2}[0-9]{4}/";	
	if ( preg_match( $valid, $number ) == 1 ) {
		echo(  "${_REQUEST[ 'number' ]} is valid<br />" );
	}
		
	exit;
?>

讓我們詳細查看這段代碼:

  • 如 表 1 所示,regex 使用一小組操作符,例如方括號 ([ ]),對一個集合命名。如果需要匹配對象文本中的這樣一個操作符,則必須用前導反斜槓 (\) 來對 regex 中的操作符進行 “轉義”。在轉義了操作符後,它將像所有其他文字一樣進行匹配。例如,如果需要匹配一個句點字符,比方說,在完全限定主機名中查找句點,則編寫 \.。您可以隨意將字符串傳遞給 preg_quote() 來自動轉義它找到的所有 regex 操作符,如第 1 行所示。如果在第 1 行後使用 echo() $punctuation,則應當會看到 \(\)\.-
  • 第 2 行將刪除電話號碼中的所有標點符號。preg_replace() 函數將把 $punctuation 中出現的所有某個字符替換爲空字符串(因此,使用集合操作符 [ ]),從而有效地省略字符。新字符串將被返回並分配給 $number
  • 第 4 行定義有效的美國電話號碼模式。
  • 第 5 行執行匹配,把現在僅含數字的電話號碼與模式進行比較。如果有匹配,則函數 preg_match() 返回 1。如果未找到匹配,則 preg_match() 返回零。如果在處理過程中出錯,則函數將返回 False。因此,要檢查是否成功,請查看返回值是否爲 1。否則,查看 preg_last_error() 的結果(如果使用 PHP V5.2.0 或更高版本)。如果不爲零,則可能已經超出計算範圍,例如超出 regex 可以遞歸的深度。您可以在 PCRE Regular Expression Functions 頁面中查找關於 PHP regex 使用慣例和限制的討論(請參閱 參考資料)。

捕捉

如果在數據驗證中只需要進行 “是否匹配?” 的測試,則有許多實例可用。但是,regex 更常用於檢驗匹配和提取關於匹配的信息。

返回到電話號碼示例,如果找到匹配,您可能希望將區號、前綴和行號保存到數據庫的獨立字段中。Regex 可以記住與 capture 匹配的內容。capture 操作符是一些括號,並且操作符可以出現在 regex 中的任意位置。您還可以對捕捉進行嵌套,以查找較大捕捉的子分段。例如,要捕捉 10 位電話號碼中的區號、前綴和行號,您可以使用:

/([2-9][0-9]{2})([2-9][0-9]{2})([0-9]{4})/

如果找到匹配,則把前三個數字捕捉到第一組括號中,把接下來的三個數字捕捉到第二組括號中;並且把最後四個數字捕捉到剩餘操作符中。preg_match() 調用的變體將檢索捕捉。

清單 5. preg_match() 如何檢索捕捉
$valid = "/([2-9][0-9]{2})([2-9][0-9]{2})([0-9]{4})/";	
if ( preg_match( $valid, $number, $matches ) == 1 ) {
	echo(  "${_REQUEST[ 'number' ]} is valid<br />" );
	echo(  "Entire match: ${matches[0]}<br />" );
	echo(  "Area code: ${matches[1]}<br />" );
	echo(  "Prefix: ${matches[2]}<br />" );
	echo(  "Number: ${matches[3]}<br />" );
}

如果提供一個變量作爲 preg_match() 的第三個參數,例如這裏的 $matches,則它被設爲捕捉結果列表。第 0 個元素(索引編號爲 0)是整個匹配;第 1 個元素是分別與第一組括號相關聯的匹配,以此類推。

嵌套捕捉幾乎可以捕捉任意深度的分段和子分段。嵌套捕捉的訣竅在於預測每個匹配出現在匹配數組(例如 $matches)中的位置。下面是需要遵循的規則:從 regex 的開頭開始計算左括號的數目 —— 該計數是匹配數組的索引。

清單 6 提供了用於提取街道地址片段(人爲設計)的示例。

清單 6. 用於提取街道地址的代碼
$address = "123 Main, Warsaw, NC, 29876";

$valid = "/((\d+)\s+(\w+)),\s+(\w+),\s+([A-Z]{2}),\s+(\d{5})/";

if ( preg_match( $valid, $address, $matches ) == 1 ) {
	echo(  "Street: ${matches[1]}<br />" );
	echo(  "Street number: ${matches[2]}<br />" );
	echo(  "Street name: ${matches[3]}<br />" );
	echo(  "City: ${matches[4]}<br />" );
	echo(  "State: ${matches[5]}<br />" );
	echo(  "Zip: ${matches[6]}<br />" );
}

同樣,整個匹配是在索引 0 處找到的。在哪裏找到街道編號?從左側開始計算,街道編號是由 \d+ 匹配的。左括號是從左側算起第二個;因此,$matches[2] 是 123$matches[4] 保存城市名稱,而 $matches[6] 捕捉 ZIP 編碼。

強大的技術

處理文本非常普通,並且 PHP 提供了一些功能使大量操作可以更輕鬆地完成。下面是需要牢記的一些簡寫:

  • preg_replace() 函數可以對單個字符串或字符串數組進行操作。如果對一個字符串數組而不是一個字符串調用 preg_replace(),則數組中的所有元素都將進行替換。在本例中,preg_replace() 將返回修改後的字符串數組。
  • 正如其他 PCRE 實現一樣,您可以從替換中引用子模式匹配,允許操作進行自引用。爲進行演示,考慮統一電話號碼的格式。所有標點符號都被刪除,而使用點來代替。清單 7 中顯示了一種解決方案。
清單 7. 用點替代標點符號
$punctuation = preg_quote( "().-" );
$number = preg_replace( "/[$punctuation]/", '', $_REQUEST[ 'number' ] );
$valid = "/([2-9][0-9]{2})([2-9][0-9]{2})([0-9]{4})/";	

$standard = preg_replace( $valid, "\\1.\\2.\\3", $number ); 
if ( strcmp ($standard, $number) ) {
	echo(  "The standard number is $standard<br />" );
}

如果模式匹配,則模式測試和標準電話號碼的轉換將在一個步驟中完成。

雖然術語數據 和信息 可以互換使用,但是兩者之間有很大的差別。數據是有據可依的。溫度列表、近期銷售狀況說明或者庫存零部件清單,這些都是數據。信息含有一定見解。天氣預報、損益表和銷售趨勢屬於信息。數據是由若干個 1 和 0 表示,而信息則經由人腦分析得出。

數據和信息之間是軟件應用程序:引擎將把數據和信息來回轉換。例如,如果在線購買圖書,購書應用程序將把信息 —— 書名、身份信息、銀行帳號信息 —— 轉換爲數據,例如訂單號、售價、信用卡交易詳細信息和對存貨清單的調整。類似地,購書應用程序將把數據再轉換爲倉庫提貨請求、運輸標籤和跟蹤編號等完成銷售所需的信息。

當然,創建應用程序的複雜度與其影響的轉換直接成正比。Web 站點留言本十分簡單,它把姓名和地址轉換爲數據庫中的字段。同時,在線商店十分複雜,它將把各類信息轉換爲業務數據模型並把數據轉換爲信息來推動決策。編程的藝術在於對數據和信息的熟練處理 —— 類似於在明暗處理中捕捉亮色的技能。

如 第 1 部分 中所述,regex 是處理數據的最強大工具之一。使用簡明的簡寫方式,regex 說明了數據的格式並分解數據。例如,您可以使用下面的 regex 處理所有攝氏或華氏溫度:/^([+-]?[0-9]+)([CF])$/

regex 將匹配行的開頭(由脫字符號 ^ 表示),後接一個正號,一個負號,或者兩者都不是 ([+-]?),後接一個整數 ([0-9]+),數值範圍限定符 —— 攝氏或華氏 ([CF]) —— 並在行尾(用美元符號 $ 表示)終止。

在溫度 regex 中,行開頭和行結尾操作符是兩個零寬度斷言 示例,或者匹配位置而非文字。括號也不是文字。相反,嵌入到括號內的模式將捕捉匹配模式的文本。因此,如果文本匹配了整個模式,第一組括號將生成表示一個正整數或負整數的的字符串,例如 +49。第二組括號將生成字母 C 或 F

第 1 部分介紹了 regex 的概念和可用於比較文本與模式和提取匹配的 PHP 函數。現在我將更深入地研究 regex 並查看一些高級操作符和處理方法。

(再次)使用括號

在大多數情況下,使用一組括號可以定義子模式和捕捉匹配子模式的文本。但是,括號不需要捕捉子模式。正如在複雜的數學公式中,您可以簡單地使用括號來給術語分組。

下面是一個示例。您能否說出它匹配哪類數據?

/[-a-z0-9]+(?:\.[-a-z0-9]+)*\.(?:com|edu|info)/i

您可能已經預料到此 regex 將匹配主機名(雖然只在 .com、.edu 和 .info 這幾個域中)。差別是添加了 ?:。子模式限定符 ?: 將禁用捕捉,留下括號來闡明操作的優先次序。例如,在這裏,短句 (?:\.[-a-z0-9]+)* 將匹配零個或多個字符串實例(例如 “.ibm”)。類似地,短句 \.(?:com|edu|info) 表示句點,後接字符串 comedu 或 info 中的任意一個。

禁用捕捉可能看似毫無意義,直至您意識到捕捉需要額外的處理。如果代碼將處理大量數據,則忽略捕捉可能是有意義的。此外,如果 regex 特別複雜,禁用某些子模式中的捕捉可以更輕鬆地提取真正感興趣的子模式。

注:使用 regex 末尾的 i 修飾語可以使模式內的所有匹配都不區分大小寫。因此,子集 a-z 將匹配所有字母,而不區分大小寫。

PHP 將提供其他子模式修飾詞。使用第 1 部分中提供的 regex 測試 jig(如 清單 1 所示),將針對候選字符串 “EDU”、“edu” 和 “Edu” 匹配 regex ((?i)edu)。如果子模式以修飾詞 (?i) 爲開頭,則在子模式中進行匹配不區分大小寫。只要子模式結束,區分大小寫將被重新啓用(將此修飾詞與上面的 /.../i 修飾詞相比較,後者應用於整個模式)。

清單 1. 簡單的 regex 測試實用程序
<?php
    //
    // divide the comma-separated list into individual words
    //   the third parameter, -1, permits a limitless number of matches
    //   the fourth parameter, PREG_SPLIT_NO_EMPTY, ignores empty matches
    //
    $words = preg_split( '/,/',  $_REQUEST[ 'words' ], -1, PREG_SPLIT_NO_EMPTY );

    //
    // remove the leading and trailing spaces from each element
    //
    foreach ( $words as $key => $value ) { 
        $words[ $key ] = trim( $value ); 
    }

    //
    // find the words that match the regular expression
    //
    $matches = preg_grep( "/${_REQUEST[ 'regex' ]}/", $words );

    print_r( $_REQUEST['regex' ] ); 
    echo( '<br /><br />' );
    
    print_r( $words ); 
    echo( '<br /><br />' );
    
    print_r( $matches );
    
    exit;
?>

另一個有用的子模式修飾詞是 (?x)。它允許您在子模式中嵌入空白,使 regex 更易讀。因而,子模式 ((?x) edu | com | info)(請注意備用操作符之間的空格,這些空格是爲了易讀性而添加的)與 (edu|com|info) 相同。您可以使用全局修飾詞 /.../x 在整個 regex 中嵌入空白和註釋,如下所示:

清單 2. 嵌入空白和註釋
$matches = preg_grep( 
            "/
              [- a-z 0-9]+            # machine name
              (?: \. [- a-z 0-9]+)*   # subdomains
              \. (?: com | edu | info)# domain
             /xi", $words );

正如您所見,還可以根據需要組合修飾詞。另外,如果需要在使用 (?x) 時匹配空格,那麼,使用元字符 \s 來匹配所有空格字符或使用 \(反斜槓後接空格)來匹配單個空格,如 ((?x) hello \ there)

其他應用

regex 的大量應用都是驗證或分解存儲爲存儲庫中的數據或由應用程序立即執行的各個小塊的輸入。處理表單中的字段、解析 XML 代碼以及解釋協議都是典型應用。

regex 的另一個應用是格式化、規範化或提高數據的可讀性。格式化不是使用 regex 查找和提取文本,而是使用 regex 查找並在正確位置插入文本。

下面是一個有用的格式化應用程序。假定 Web 表單把按照美元計算的薪金提交給應用程序。由於把薪金存儲爲整數,因此應用程序必須先去掉所粘貼數據中的標點符號,然後再保存。但是,在從存儲庫中檢索出數據時,則需要使用逗號重新設定數據的格式使其具有可讀性。下面顯示了一個用於把美元金額轉換爲數字的簡單 PHP 調用。

清單 3. 把美元金額轉換爲數字
$salary = preg_replace( "/[\$\s,]/", '', $_REQUEST[ 'salary' ] );

if ( is_numeric( $salary ) ) {
    // persist the data
}
else {
    // error
}

調用 preg_replace() 函數將用空字符串替換美元符號、所有空格和每個逗號,生成認爲是整數的內容。如果調用 is_numeric() 對輸入進行了驗證,則可以存儲數據。

接下來,讓我們反向操作輸出帶有貨幣符號和用於分隔百、千、百萬的逗號的數字。您可以編寫代碼來查找這些數字單元,也可以使用向前查找向後查找 在正確位置上插入逗號。子模式修飾詞 ?<= 指示從當前位置開始向後查找(即向左查找)。修飾詞 ?= 表示從當前位置開始向前查找(向右查找)。

那麼,正確位置在哪裏?字符串中左側至少有一位數並且右側有一組或多組三位數的任意位置,不包括小數點和美分數。給定該規則和兩個查找修飾詞(兩者都是零寬度斷言),這條語句將可成功執行:

$pretty_print = preg_replace( "/(?<=\d)(?=\d\d\d)+$)/", ',', $salary );

後面的 regex 如何工作?從字符串的開頭開始並繼續通過每個位置,regex 將斷言 “左側是否至少有一位數並且右側是否有一組或多組三位數”?如果是這樣,逗號將 “替換” 零寬度斷言。

使用類似於上面的策略可以輕鬆地免除許多複雜匹配。例如,下面是另一種可以輕鬆解決一般困難的向前查找。

清單 4. 向前查找示例
$tab_data = preg_replace( '/
    ,                               # look for a comma
    (?=                             # then look ahead for
        (?:[^"]*$)                  # a string with no quotes and eol
        |                           #  -or-
        (?:[^"]*"[^"]*"[^"]*)*$     # a string with balanced quotes
    )                               # 
    /x', "\t", $csv_data );

這條 preg_replace() 指令將把一行用逗號分隔的數據轉換爲一行用製表符分隔的數據。它很聰明,不會替換在引號括起的字符串中找到的逗號。

regex 將在所有出現逗號(這是位於 regex 開頭的逗號)的位置做出斷言:“前面是不是沒有引號或者前面的引號個數是否爲偶數”?如果斷言爲真,則可以用製表符 (\t) 替換逗號。

如果不希望使用查找操作符,或者使用的是不提供查找操作符的語言,則可以使用傳統 regex 把逗號嵌入到數字中,儘管這樣做要求完成多次迭代。下面是一種可能的解決方案。

清單 5. 嵌入逗號
$pretty_print = preg_replace( "/[\$\s,]/", '', $_REQUEST[ 'salary' ] );

do {
    $old = $pretty_print;
    $pretty_print = preg_replace( "/(\d)(\d\d\d\b)/", "$1,$2", $pretty_print );
} while ( $old != $pretty_print );

讓我們仔細研究一下代碼。首先,移除 salary 參數的標點來模擬從數據庫中讀取整數。接下來,循環將重複執行,查找這樣一個位置:一位數 ((\d) 後接三位數 ((\d\d\d\) 並在 \b 所指定的詞界(word boundary)立即終止的位置。詞界 是另一個零寬度斷言並被定義爲:

  • 如果第一個字符爲單詞字符,則在字符串中的第一個字符之前。
  • 如果最後一個字符爲單詞字符,則在字符串中的最後一個字符之後。
  • 在單詞字符和非單詞字符之間,緊跟在單詞字符之後。
  • 在非單詞字符和單詞字符之間,緊跟在非單詞字符之後。

因而,空格、句點和逗號都是有效的詞界。

由於是外部循環,因此 regex 實質上將從右向左前進查找後接三位數和詞界的一位數。如果找到匹配,則在兩個子模式之間插入一個逗號。只要 preg_replace() 找到匹配,循環就必須繼續,這解釋了 $old != $pretty_print 條件。

貪婪和懶惰

Regex 十分強大。甚至有時候過於強大。例如,考慮當 regex ".*" 被應用到字符串 “The author of 'Wicked' also wrote 'Mirror, Mirror.'” 上時發生的情況。雖然預期 preg_match() 可能返回兩個匹配,但是您可能會驚訝地發現只有一個結果:'Wicked' also wrote 'Mirror, Mirror.'

原因是什麼?除非進行指定,否則諸如 *(無或多個)和 +(一個或多個)之類的操作符都很貪婪。如果模式可以繼續匹配,那麼它可能將生成最多的結果。要使匹配最少,則必須強制使某些操作符變得懶惰。懶惰操作將查找最短的匹配,然後就停止。要使操作符變得懶惰,請添加問號後綴。清單 6 顯示了一個示例。

清單 6. 添加問號後綴
    $text = 'The author of "Wicked" also wrote "Mirror, Mirror."';
    if ( preg_match_all( '/".*?"/', $text, $matches ) ) {
        print_r( $matches[0] );
    }

上面的代碼片段將生成:

Array ( [0] => "Wicked" [1] => "Mirror, Mirror." )

regex ".*?" 變爲匹配一個引號,後接剛好足夠的 字符,後接一個引號。

但是,使用 * 操作符有時可能太懶惰。例如,採用以下代碼片段。它將生成什麼輸出?

清單 7. 簡單的 regex 測試實用程序
if (preg_match( "/([0-9]*)/", "-123", $matches  ) ) {
    print_r( $matches );
}

猜測輸出是什麼?“123”?“1”?沒有輸出?實際上,輸出是 Array ( [0] => [1] => ),表示找到一個匹配,但是未捕捉到任何內容。爲什麼?回想一下操作符 * 可以匹配零次或多次。在這裏,表達式 [0-9]* 針對字符串開頭匹配零次,隨後停止處理。

要解決此問題,請添加零寬度斷言來錨定匹配,這將強制 regex 引擎繼續進行匹配;/([0-9]*\b/ 就可解決問題。

更多提示和技巧

regex 可以解決簡單或複雜的文本處理問題。首先掌握一些操作符,隨着經驗逐漸豐富,您可以進一步擴展詞彙表。要立即開始使用,請參考下面這些提示和技巧。

用字符類實現可移植的 regex

您已經看到過匹配所有空格字符的元字符,例如 \s。此外,許多 regex 實現都支持更易於跨多種編寫語言使用和移植的預定義字符類。例如,字符類 [:punct:] 表示當前語言環境中的所有標點字符。您可以使用 [:digit:] 代替 [0-9],並且 [:alpha:] 是比 [-a-zA-Z0-9_] 更具有可移植性的替代者。例如,您可以使用以下語句移除字符串中的所有標點符號:

$clean = preg_replace( "/[[:punct:]]/", '', $string );

使用字符類比清楚說明所有標點符號更簡潔。要獲得字符類的完整列表,請參閱適用於您的 PHP 版本的文檔。

排除不需要查找的內容

與將逗號分隔的值 (CSV) 轉換爲用製表符分隔的數據一樣,列出 需要匹配的內容有時更容易也更精確。以脫字符號 (^) 爲開頭的集合將匹配集合中不包括的所有字符。例如,您可以使用正則表達式 /[2-9][0-9]{2}[2-9][0-9]{2}[0-9]{4}/ 來驗證美國電話號碼。使用排除集合,可以把 regex 編寫爲更顯式的 /[^01][0-9]{2}[^01][0-9]{2}[0-9]{4}/。兩個 regex 都可以正常運行,但是顯然後者意圖更加明顯。

跳過換行符

如果輸入跨度多行,則使用典型的 regex 是不夠的,因爲掃描將在 $ 所指示的換行符處終止。但是,如果使用 s 或 m 修飾詞,regex 引擎將按照不同的方式處理輸入。前者將把字符串處理爲單行,強制用點匹配換行符(它通常不這樣做)。後者將把字符串處理爲多行,其中 ^ 和 $ 將分別匹配每行的開頭和結尾。下面是一個示例:如果設置 $string = "Hello,\nthere";,則語句 preg_match( "/.*/s", $string, $matches) 將把 $matches[0] 設爲 Hello,\nthere(刪除 s 將生成 Hello)。

正則表達式幾乎無所不能,也許惟一的限制因素就是您的想象力和創造力了。


引用文章:

http://www.ibm.com/developerworks/cn/opensource/os-php-regex1/

http://www.ibm.com/developerworks/cn/opensource/os-php-regex2/index.html


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章