掌握 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


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