在Linux这样的系统中,文本数据无处不在,扮演着至关重要的角色。然而,在深入了解这些工具之前,我们必须先掌握一项关键技术——正则表达式(Regular Expressions)。 初次见面,正则表达式可能会让你觉得有些神秘,甚至有点“天书”的感觉。它的语法看起来确实有些古怪,充满了各种符号。请不要被它的外表吓到,学习它所花费的每一分努力都是值得的。 一旦你掌握了它,你就能在海量的文本中施展出惊人的“魔法”。

简单来说,正则表达式就是一种用来描述文本模式的“模板”或“规则”。它就像一种高度浓缩的语言,可以帮助我们在文本中精确地查找、匹配甚至替换符合特定规则的字符串。 你可以把它想象成一种超级强大的搜索工具。普通的搜索(比如Ctrl+F)只能查找固定的文字,但正则表达式可以让你进行模糊和抽象的搜索。 例如,你可以轻松地用一个表达式找出所有以大写字母开头、以句号结尾的句子,或者所有格式正确的电子邮件地址。它在命令行工具和几乎所有编程语言中都得到了广泛支持,是解决文本处理问题的利器。
需要注意的是,正则表达式在不同工具和编程语言(如 Perl、Python、Java等)中存在诸多“方言”及实现细节差异。我们将重点讲解符合 POSIX 标准的正则表达式,该标准被 Linux 及大多数主流命令行工具广泛支持,具有极高的通用性和规范性。 深入理解 POSIX 语法,有助于提升日常文本处理与命令行数据分析的专业效率与适应性。
我们学习正则表达式的向导,正是在命令行中无处不在的grep命令。grep这个名字本身就源自于“global regular expression print”(全局正则表达式打印),由此可见它与正则表达式的深厚渊源。
简单来说,grep的核心工作就是在文件中搜索包含特定正则表达式的行,并把这些匹配的行输出到屏幕上。
到目前为止,我们可能已经用grep来搜索过固定的字符串,像这样:
|$ ls /usr/bin | grep zip
这个命令会列出 /usr/bin 目录下所有文件名中含有 "zip" 这个子串的文件。这其实就是最简单的正则表达式——一个只包含普通字符的模式。
grep命令的基本用法是 grep [选项] 正则表达式 [文件名...]。
为了更好地探索grep的功能,我们需要一些文本文件作为练习材料。我们可以用下面的命令创建几个文件列表:
|$ ls /bin > dirlist-bin.txt $ ls /usr/bin > dirlist-usr-bin.txt $ ls /sbin > dirlist-sbin.txt $ ls /usr/sbin > dirlist-usr-sbin.txt
现在,我们可以在这些新创建的文件中进行搜索了。
grep提供了许多有用的选项来调整其行为。例如,-i可以让我们忽略大小写,这样grep -i 'zip'就能同时匹配"zip"和"ZIP"。
如果你想反向查找,只显示那些不包含匹配项的行,可以使用-v选项。有时候我们不关心匹配行的内容,只想知道匹配了多少次,这时-c选项就能派上用场,它会直接输出匹配(或在-v模式下,不匹配)的总行数。
在搜索多个文件时,如果你只想知道哪些文件包含了匹配项,而不是具体的匹配行,可以使用-l选项;反之,-L则会列出那些完全不包含匹配项的文件名。
为了方便定位,-n选项可以在每行输出前加上它在文件中的行号。最后,当搜索多个文件时,grep默认会显示文件名,如果你不想要这个前缀,可以用-h来抑制它。
让我们来看一个实际例子。如果我们想知道哪些文件列表包含了 "bzip" 这个字符串,可以这样做:
|$ grep bzip dirlist*.txt dirlist-bin.txt:bzip2 dirlist-bin.txt:bzip2recover
如果我们只关心文件名,不关心具体哪一行匹配了,就可以加上 -l 选项:
|$ grep -l bzip dirlist*.txt dirlist-bin.txt
这个结果告诉我们,只有 dirlist-bin.txt 文件中包含了 "bzip" 这个字符串。
你可能会觉得,我们刚刚用的grep bzip dirlist*.txt命令看起来和普通的字符串搜索没什么两样。没错,这正是一个非常简单的正则表达式。
在这个例子里,“bzip”这四个字符都是字面量(literal characters),意思就是它们就代表它们自己,b就匹配b,z就匹配z。
但正则表达式的真正威力,来自于那些不代表自己、而是拥有特殊含义的符号,我们称之为元字符(metacharacters)。这些元字符是正则表达式的“语法关键字”,它们包括 ^ $ . [ ] { } - ? * + ( ) | \ 等等。
请特别注意:很多元字符(比如 * 和 ?)在 shell 中也有特殊含义(用于文件名展开)。因此,当你在命令行中使用包含元字符的正则表达式时,一定要用单引号 ' ' 把整个表达式包围起来,防止 shell 错误地解释它们。
.我们来认识第一个元字符:点 .。它的作用非常简单,就是可以匹配任意一个字符。
如果我们想在文件列表中查找所有以某个字符开头,后面跟着 "zip" 的文件,就可以这样写:
|$ grep -h '.zip' dirlist*.txt bunzip2 bzip2 bzip2recover gunzip gzip funzip gpg-zip preunzip prezip prezip-bin unzip unzipsfx
在这个例子里,.zip 这个表达式的意思是“任意一个字符,后面紧跟着 z、i、p”。所以,像 unzip、gzip 这样的名字都被匹配到了。
但你可能会注意到,"zip" 这个程序本身却没有出现在结果里。这是因为我们的表达式 .zip 要求匹配四个字符的长度,而 "zip" 只有三个字符,所以不符合模式。
^ 与行尾 $接下来是两个非常有用的元字符:^(脱字符号)和 $(美元符号)。它们被称为锚点(anchors),因为它们能把我们的匹配模式“锚定”在行的特定位置。
^ 用来匹配一行的开始位置。$ 用来匹配一行的结束位置。让我们看看它们如何工作。如果我们想找所有以 "zip" 开头的文件名:
|$ grep -h '^zip' dirlist*.txt zip zipcloak zipgrep zipinfo zipnote zipsplit
只有那些行首就是 "zip" 的结果被显示了出来。 现在,我们想找所有以 "zip" 结尾的文件名:
|$ grep -h 'zip$' dirlist*.txt gunzip gzip funzip gpg-zip preunzip prezip unzip zip
这次,所有以 "zip" 结尾的行都被匹配了。 那么,如果我们想精确查找只有 "zip" 这三个字母、不多不少的行呢?我们可以把两个锚点结合起来:
|$ grep -h '^zip$' dirlist*.txt zip
^zip$ 这个表达式的意思是:从行首开始,紧接着是 "zip",然后就到了行尾。这确保了整行除了 "zip" 之外没有任何其他字符。
一个小技巧:^$ 这个表达式(一个行首紧跟着一个行尾)可以用来匹配所有空行。
我们已经知道 . 可以匹配任意字符,但这有时太过宽泛。如果我们想匹配一个来自特定集合的字符,比如“a、b、c中的任意一个”,这时就需要方括号表达式 [ ] 了。
[ ]方括号允许我们创建一个字符“菜单”,匹配时会从这个菜单里任选一个。例如,如果我们想查找所有包含 "bzip" 或 "gzip" 的行,可以这样写:
|$ grep -h '[bg]zip' dirlist*.txt bzip2 bzip2recover gzip
[bg]zip 这个表达式的意思是:匹配一个字符,这个字符要么是 b,要么是 g,然后紧跟着 "zip"。
方括号里可以放任意多个字符。元字符在方括号内通常会失去它们的特殊含义,变成普通的字面量字符。
[^...]如果在方括号表达式的开头加上一个 ^ 符号,它的功能就会完全反转,从“白名单”变成“黑名单”。它会匹配任何不在这个集合中的字符。
让我们修改一下上一个例子,查找所有以 "zip" 结尾,但前面那个字符既不是 b 也不是 g 的文件名:
|$ grep -h '[^bg]zip' dirlist*.txt bunzip2 gunzip funzip gpg-zip preunzip prezip prezip-bin unzip unzipsfx
这里,[^bg]zip 匹配了 "unzip", "funzip" 等,因为 u 和 f 都不在 b 和 g 这个集合里。
注意,即使是反向选择,也必须存在一个字符来匹配。所以 "zip" 这个文件名没有出现在结果中,因为它前面没有字符。^ 只有在方括号内的第一个位置时才表示“反向”,否则它就是一个普通的 ^ 字符。
手动指定一个字符范围,比如 [a-z] 或者 [0-9],是一种很常见的做法。然而,这种传统方法有时会因为系统语言环境(locale)的设置不同而产生意想不到的结果。
很久以前,计算机世界只使用简单的ASCII编码,字符顺序是固定的。但在今天,为了支持多语言,系统通常使用更复杂的排序规则,比如字典序(a, A, b, B...)。在这种规则下,[A-Z] 可能不仅包含大写字母,还会包含许多小写字母,导致匹配结果混乱。
为了解决这个问题并提供一种更标准、更可靠的方式来表示字符集合,POSIX标准引入了字符类(Character Classes)。它们是一些预定义的、有名字的集合,例如:
[:alnum:] - 字母和数字[:alpha:] - 字母[:digit:] - 数字 (0-9)[:lower:] - 小写字母[:upper:] - 大写字母[:space:] - 空白字符 (空格, Tab等)[:punct:] - 标点符号使用POSIX字符类时,需要将它再用一层方括号包起来,例如 [[:upper:]] 才表示所有大写字母。
所以,如果我们想查找所有以大写字母开头的文件名,最稳妥的写法是:
|$ grep -h '^[[:upper:]]' dirlist*.txt MAKEDEV ControlPanel GET HEAD POST X X11 Xorg MAKEFLOPPIES NetworkManager NetworkManagerDispatcher
这种写法比 ^[A-Z] 更具可移植性和准确性,无论系统环境如何设置,它都能正确工作。
就在我们以为正则表达式的规则已经够多的时候,会发现POSIX标准其实还把正则表达式分成了两种“流派”:基础正则表达式(BRE)和扩展正则表达式(ERE)。
我们到目前为止学习的所有元字符(^ $ . [ ] *)都属于BRE的范畴,任何兼容POSIX的工具(比如grep)都支持它们。
那么ERE又是什么呢?它在BRE的基础上,增加了更多功能强大、表达更方便的元字符,例如 ( ) { } ? + |。这让复杂的表达式写起来更简洁。
grep 默认使用的是BRE。如果我们想让它切换到功能更强的ERE模式,需要加上 -E 选项。或者,你也可以直接使用 egrep 命令,它的效果和 grep -E 完全一样。
|扩展表达式带来的第一个利器是交替(alternation),由元字符 |(竖线)表示。它就像一个“或”操作符,允许我们在多个表达式之间进行选择。
比如,我们想匹配字符串 "AAA" 或 "BBB",可以这样写:
|$ echo "AAA" | grep -E 'AAA|BBB' AAA $ echo "BBB" | grep -E 'AAA|BBB' BBB
'AAA|BBB' 这个表达式的意思就是“匹配AAA或者匹配BBB”。
如果想把交替和其他表达式元素组合起来,我们需要用圆括号 () 将交替的部分括起来,形成一个分组。例如,查找所有以 "bz"、"gz" 或 "zip" 开头的文件名:
|$ grep -Eh '^(bz|gz|zip)' dirlist*.txt
这里的 (bz|gz|zip) 就创建了一个临时的整体,^ 锚点作用于这个整体,表示必须以这三者之一开头。
ERE还引入了量词(quantifiers),它们可以精确地控制一个元素应该出现多少次。
? :匹配零次或一次这个量词表示它前面的那个元素是可选的。例如,我们想匹配一个单词 "color",但要同时兼容美式英语 "color" 和英式英语 "colour"。u 这个字母可有可无,我们就可以用 ? 来表示:
|$ echo "color" | grep -E 'colou?r' color $ echo "colour" | grep -E 'colou?r' colour
u? 表示 u 这个字符可以出现0次或1次。
* :匹配零次或多次星号 * 我们在BRE中已经见过,它表示前面的元素可以出现任意次数,包括零次。例如,[a-z]* 会匹配任意长度的小写字母序列,甚至是空字符串。
+ :匹配一次或多次加号 + 和 * 很像,但它要求前面的元素至少出现一次。这在需要确保某个部分不能为空时非常有用。例如,[0-9]+ 会匹配一个或多个数字,但不会匹配空字符串。
{ } :随心所欲的匹配次数花括号 { } 是最灵活的量词,它允许我们指定匹配次数的精确范围。它有几种用法:
{n}:精确匹配 n 次。{n,m}:至少匹配 n 次,但不能超过 m 次。{n,}:至少匹配 n 次。{,m}:至多匹配 m 次。让我们用它来简化一个匹配美国电话号码的例子。假设一个号码的格式是 (123) 456-7890。我们可以用 [0-9]{3} 来代替冗长的 [0-9][0-9][0-9]。一个完整的表达式可以是:
^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$
我们来测试一下:
|$ echo "(555) 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' (555) 123-4567 $ echo "555 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' 555 123-4567 $ echo "5555 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' # (无输出,因为区号是4位数,不匹配{3})
这个表达式通过 \(? 和 \)? 允许区号两边的括号可选,然后用 {3} 和 {4} 精确规定了数字的个数,非常简洁高效。
现在让我们看看如何在日常的命令行操作中,利用正则表达式解决实际问题。
grep 校验数据列表正则表达式是数据清洗和校验的强大工具。假设我们有一个文件 emails.txt,里面记录了一些用户的邮箱地址,但格式可能不统一,有些甚至是无效的。
我们可以先创建一个示例文件:
|$ cat << EOF > emails.txt user.one@example.com usertwo@example.org user three@example.com user4@ user.five@example.co.uk user-six@sub.domain.net EOF
一个相对简单但有效的邮箱正则表达式可以是这样的:^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$。让我们分解一下:
^...$:确保整行匹配,不多不少。[a-zA-Z0-9._-]+:用户名部分,允许字母、数字、点、下划线和连字符,至少出现一次。@:一个字面量的 @ 符号。[a-zA-Z0-9.-]+:域名部分。\.:一个字面量的点(需要转义)。[a-zA-Z]{2,4}:顶级域名,由2到4个字母组成。现在,我们可以用 grep 找出所有格式不正确的邮箱,以便进行修正。这里我们使用 -E 开启扩展模式,并用 -v 进行反向匹配:
|$ grep -Ev '^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$' emails.txt user three@example.com user4@
grep 轻松地帮我们揪出了那两个格式有问题的行。
find 查找特殊文件find 命令也可以结合正则表达式来查找文件,这在需要根据复杂命名规则搜索时特别有用。与 grep 不同,find 的正则表达式需要匹配整个路径,而不仅仅是路径的一部分。
假设我们想在当前目录下查找所有文件名中含有非ASCII字符(比如中文、日文或其他特殊符号)的文件。这样的文件在跨平台共享时有时会引发问题。
我们可以使用这样一个正则表达式:.*[^-_./0-9a-zA-Z].*。
.*:匹配任意数量的任意字符。我们把它放在开头和结尾,因为find要求匹配整个路径。[^-_./0-9a-zA-Z]:这是一个反向字符集,它会匹配任何不属于“标准”文件名字符(字母、数字、连字符、下划线、点、斜线)的字符。find 使用 -regex 选项来应用正则表达式:
|$ touch "myfile.txt" "另一个文件.txt" "file with spaces.log" $ find . -regex '.*[^-_./0-9a-zA-Z].*' ./另一个文件.txt ./file with spaces.log
这个命令成功地找到了包含中文字符和空格的文件,因为这些字符都不在我们的“安全字符集”中。
less 和 vim 中搜索在阅读长文件时,less 和 vim 内置的搜索功能也支持正则表达式。按下 / 键,然后输入你的正则表达式,就可以高亮显示所有匹配项。这对于快速定位和分析日志文件或代码中的特定模式非常方便。
例如,在 vim 中打开一个文件后,输入 /^import 就可以快速跳转到所有以 "import" 开头的行。