Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
kernigan_paik.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
2.91 Mб
Скачать

9.2. Регулярные выражения

Спецификаторы формата для pack и unpack — достаточно простой способ записи, описывающий компоновку пакетов. Следующая тема нашего обсуждения — несколько более сложный, но гораздо более выразительный способ записи — регулярные выражения (regular expressions), определяющие шаблоны текста (patterns of text). В этой книге мы время от времени использовали регулярные выражения, не давая им точного описания; они достаточно знакомы, чтобы понять их без особых пояснений. Хотя регулярные выражения широко распрост­ранены в средах программирования под Unix, в других системах они применяются гораздо реже, поэтому в данном разделе мы решили по­казать некоторые их преимущества. На случай, если у вас нет иод ру­кой библиотеки с регулярными выражениями, мы приведем ее про­стейшую реализацию.

Существует несколько разновидностей регулярных выражений, но суть у них у всех одинакова — это способ описания шаблона буквенных символов, включающего в себя повторения, альтернативы и сокраще­ния для отдельных классов символов вроде цифр или букв. Примером такого шаблона могут служить всем знакомые символы замещения (wildcards), используемые в процессорах командной строки или обо­лочках для задания образцов поиска имен файлов. Как правило, сим­вол * используется для обозначения "любой строки символов", так что команда

С:\> del *.exe

использует шаблон, которому соответствуют все имена файлов, оканчи­вающиеся на ". ехе". Как это часто бывает, детали меняются от системы к системе и даже от программы к программе.

Причуды отдельных программ могут навести на мысль, что регулярные выражения являются механизмом, создаваемым ad hoc, для каждого слу­чая отдельно, но на самом деле регулярные выражения — это язык с фор­мальной грамматикой и точным значением каждого выражения. Более того, при должной реализации конструкция работает очень быстро: благо­даря соединению теории и практического опыта; вот пример в пользу специализированных алгоритмов, упоминавшихся нами в главе 2.

Регулярное выражение есть последовательность символов, которая определяет множество образцов поиска. Большинство символов просто-напросто соответствуют сами себе, так что регулярное выражение abc будет соответствовать именно этой строке символов, где бы она ни появ­лялась. Но, кроме того, существует несколько метасимволов, которые обозначают повторение, группировку или местоположение. В принятых в Unix регулярных выражениях символ ^ обозначает начало строки, а $ — конец строки, так что шаблон ^х соответствует только символу х в начале строки, х$ — только х в конце строки. Шаблону ^х$ соответ­ствует только строка, содержащая единственный символ х, и, наконец, ^$ соответствует пустая строка.

Символ . (точка) обозначает любой символ, так что выражению х.у будут соответствовать хау, х2у и т. п., но не ху или xaby; выражению ^.$ соответствует строка из одного произвольного символа.

Набору символов внутри квадратных скобок соответствует любой из этих символов. Таким образом, [0123456789] соответствует одной цифре, это выражение можно записать и в сокращенном виде: [0-9]16.

Эти строительные блоки комбинируются с помощью круглых скобок для группировки, символа | — для обозначения альтернатив, * — для обозначения нуля или более совпадений, + — для обозначения одного или более совпадений и ? — для обозначения нуля или одного совпаде­ния. Наконец, символ \ применяется в качестве префикса перед мета­символом для исключения его специального использования, то есть \* обозначает просто символ *, а \\ — собственно символ обратной косой черты \.

Наиболее известным инструментом работы с регулярными выраже­ниями является программа grep, о которой мы уже несколько раз упоми­нали. Эта программа — чудесный пример, показывающий важность но­тации. В ней регулярное выражение применяется к каждой строке вводимого файла и выводятся строки, которые содержат образцы поис­ка, описываемые этим выражением. Эта простая спецификация с помо­щью регулярных выражений позволяет справиться с большим количе­ством ежедневной рутинной работы. В приведенных ниже примерах обратите внимание на то, что синтаксис регулярных выражений, ис­пользуемых в качестве аргументов g rep, отличается от шаблонов поиска, применяемых для задания набора имен файлов; различие обусловлено разным назначением выражений.

Какой исходный файл использует класс Regexp?

% grep Regexp *. Java

В каком файле этот класс реализован?

% grep 'class.*Regexp' *.java

Куда я подевал это письмо от Боба?

% grep '^From:.* bob@' mail/*

Сколько непустых строк кода в этой программе?

% grep '.' *. c++ | wc

Благодаря наличию флагов, позволяющих выводить номера соответ­ствующих выражению строк, подсчитывать количество соответствий, осу­ществлять сравнение без учета регистра, инвертировать смысл выражения (то есть отбирать строки, не соответствующие шаблону) и т. п., программа grep используется сейчас настолько широко, что стала уже классическим примером программирования с помощью специальных инструментов.

К сожалению, не во всех системах имеется grep или ее аналог. Некото­рые системы включают в себя библиотеку регулярных выражений, кото­рая называется, как правило, regex или regexp, и ее можно использовать для создания собственной версии grep. Если же нет ни того, ни другого, то на самом деле не так трудно реализовать какое-то скромное подмноже­ство языка регулярных выражений. Здесь мы представляем реализацию регулярных выражений и grep, чтобы работать с ними; для простоты мы ограничимся метасимволами ^$ . и *, причем * обозначает повторение предыдущей точки или обычного символа. Выбор этого подмножества обеспечивает почти все возможности регулярных выражений, тогда как его программировать значительно проще, чем в исходном общем случае.

Начнем с самой функции, осуществляющей проверку на соответ­ствие. Ее задача — определить, соответствует ли строка текста регуляр­ному выражению:

/* match: ищет regexp в text */

int match(char *regexp, char *text) {

if (regexp[0] == ‘ ^ ‘)

return matchhere(regexp+1, text);

do { /* должны попробовать, даже если строка пуста */

if (matchhere(regexp, text))

return 1;

} while (*text++ != '\0');

return 0;

}

Если регулярное выражение начинается с ^, то текст должен начинаться с символов, соответствующих остальной части выражения. При другом начале мы проходим по тексту, используя matchhere для выяснения, со­ответствует ли текст каждой позиции выражения. Как только мы нахо­дим соответствие, миссия наша завершена. Обратите внимание на ис­пользование do-while: выражениям может соответствовать пустая строка (например, шаблону $ соответствует пустая строка, а шабло­ну . * — любое количество символов, включая и ноль), поэтому вызвать matchhere мы должны даже в том случае, если строка текста пуста.

Большая часть работы выполняется в рекурсивной функции matchhe re:

/* matchhere: ищет regexp в начале text */

int matchhere(char *regexp, char «text)

{

if (regexp[0] == '\0')

return 1;

if (regexp[1] == '*')

return matchstar(regexp[0], regexp+2, text);

if (regexp[0] == '$' && regexp[1] == ‘\0')

return *text == ‘\0';

if (*text! = '\0' && (regexp[0]=='. ' | | regexp[0]==*text))

return matchhere(regexp+1, text+1);

return 0;

}

Если регулярное выражение пусто, это означает, что мы дошли до его конца и, следовательно, нашли соответствие. Если выражение оканчива­ется символом $, оно имеет соответствие только в том случае, если текст также расположен в конце. Если выражение начинается с точки, то пер­вый символ соответствующего текста может быть любым. В противном случае выражение начинается с какого-то обычного символа, который в тексте соответствует только самому себе. Символы ^ и $, встречающи­еся в середине регулярного выражения, рассматриваются как простые литеральные, а не метасимволы.

Отметьте, что matchhere, убедившись в совпадении одного символа из шаблона и подстроки, вызывает себя самое, таким образом, глубина ре­курсии может быть такой же, как и длина шаблона (при полном соответ­ствии).

Единственный сложный случай представляет собой выражение, на­чинающееся с символа и звездочки, например х*. В этом случае мы осу­ществляем вызов matchstar, где первым аргументом является операнд звездочки (то есть х), а следующими аргументами — шаблон после звез­дочки и сам текст.

/* matchstar: ищет с*regexp в начале text */

int matchstar(int с, char *regexp, char *text)

{

do { /* знак * означает ноль или более вхождений */

if (matchhere(regexp, text))

return 1;

} while (*text != '\0' && (*text++ == с | | с == ' . '));

return 0;

}

Здесь мы опять используем do-while — из-за того, что регулярному выражению х* может соответствовать и ноль символов. Цикл проверяет, совпадает ли текст с остатком регулярного выражения, пытаясь сопоста­вить их, пока первый символ текста совпадает с операндом звездочки.

Наша реализация получилась довольно простенькой, однако она ра­ботает и занимает всего лишь три десятка строк кода — можно утверж­дать, что для того, чтобы ввести в обращение регулярные выражения, не нужно применять никаких сложных средств.

Скоро мы представим некоторые соображения по расширению воз­можностей нашего кода, а пока напишем свою версию grep, использую­щую match. Вот как выглядит основная часть:

/* grep main: ищет regexp в файлах */

int main(int argc, char *argv[])

{

int i, nmatch;

FILE *f;

setprogname("grep");

if (argc < 2)

eprintf("usage: grep regexp [file ...]");

nmatch = 0;

if (argc == 2) {

if (grep(argv[1], stdin, NULL))

nmatch++;

} else {

for (i = 2; i < argc; i++) {

f = fopen(argv[i], "r");

if (f == NULL) {

weprintf("can't open %s:", argv[i]);

continue;

}

if (grep(argv[1], f, argc>3 ? argv[i] : NULL) > 0)

nmatch++;

fclose(f);

}

return nmatch == 0;

}

По соглашению программы на С возвращают 0 при успешном заверше­нии и ненулевое значение — при различных сбоях. Наша программа дгер, так же как и Unix-версия, считает выполнение успешным, если была найдена строка, соответствующая шаблону. Поэтому она возвра­щает 0, если было найдено хотя бы одно соответствие; 1 — если соответ­ствий найдено не было, и 2 (посредством eprintf) — если произошла ошибка. Эти значения можно протестировать, использовав в качестве оболочки какую-то другую программу.

Функция grер сканирует один файл, вызывая match для каждой его строки:

/* grер: ищет rеgехр в файле */

int grep(char *regexp, FILE *f, char *name)

{

int n, nmatch;

char buf[BUFSIZ];

nmatch = 0;

while (fgets(buf, sizeof but, f) != NULL) {

n = strlen(buf);

if (n > 0 && buf[n-1] == '\n')

buf[n-1] = ‘\0';

if (match(rеgехр, buf)) {

nmatch++;

if (name != NULL)

printf ("%s:", name);

printf("%s\n", buf);

}

}

return nmatch;

}

Если открыть файл не удается, то программа не прекращает работу. Такое поведение было выбрано для того, чтобы при вызове

% grep herpolhode *.*

даже если какой-то файл в каталоге не может быть открыт, g rep уведом­ляла об этом и продолжала работу; если бы на этом она останавливалась, пользователю пришлось бы вручную набивать список всех файлов в ка­талоге, кроме недоступного. Обратите внимание и на то, что grер выво­дит имя файла и строку, соответствующую шаблону, однако имя файла не выводится, если чтение происходит из стандартного потока ввода или из одного файла. Может быть, такое поведение покажется кому-то ста­ромодным, однако оно отражает идиоматический стиль, в основе кото­рого лежит опыт. Когда задан только один источник ввода, миссия grep состоит, как правило, в выборе соответствующих строк, и имя файла ни­кого не интересует. А вот если поиск происходит в нескольких файлах, то, как правило, задачей является найти все совпадения с какой-то стро­кой, и здесь имя файла будет весьма информативно. Сравните

% strings markov.exe | grep 'DOS mode'

и

% grер grammar chapter*.txt

Продуманность нюансов — одна из тех черт, что сделали g rep столь по­пулярным инструментом; здесь мы видим, что для создания хороших программ нотация обязательно должна учитывать человеческую психо­логию.

В нашей реализации match завершает работу сразу же, как только най­дено соответствие. Для g rep это вполне подходит в качестве поведения по умолчанию, но для реализации оператора подстановки (найти и заме­нить) в текстовом редакторе больше подошел поиск крайне левого самого длинного совпадения (leftmost longest). Например, если нам задан текст "ааааа", то шаблону а* соответствует, в частности, и пустая строка в на­чале текста, однако более естественным было бы включить в совпадение все пять а. Для того чтобы match искала крайне левую и самую длинную строку, matchstar надо переписать так, чтобы добиться жадного (greedy) поведения17 алгоритма: вместо того чтобы просматривать каждый сим­вол текста слева направо, он должен каждый раз пытаться пройти до конца самой длинной строки, соответствующей оператору-звездочке, и откатываться назад, если оставшаяся часть обрабатываемого текста не соответствует оставшейся части шаблона. Другими словами, она должна выполняться справа налево. Вот как выглядит версия matchstar, осуще­ствляющая поиск крайне левого максимального совпадения:

/* matchstar: поиск в начале теста с*rеgехр */

int matchstar(int с, char *rедgхр, char *text)

{

char *t;

for (t = text; *t != ‘\0' && (*t == с || с == '. '); t++);

do { /* знак * соответствует нулю или более вхождений */ if (matchhere(regexp, t))

return 1;

} while ( t -- > text);

return 0;

}

Для grep абсолютно неважно, какое именно совпадение будет обнаруже­но, поскольку эта программа проверяет лишь наличие соответствий во­обще и выводит всю строку, содержащую соответствие. Таким образом, поиск крайне левого максимального совпадения, жизненно необходи­мый для оператора замещения, хоть и делает некоторую ненужную для g rep работу, все же может быть применен без изменений и для этой про­граммы.

Наша версия grep вполне сравнима с версиями, поставляемыми с раз­личными системами (неважно, что синтаксис наших регулярных вы­ражений несколько скуднее). Существуют некоторые патологические выражения, которые приводят к экспоненциальному возрастанию тру­доемкости поиска, — например, выражение а*а*а*а*а*Ь при введенном тексте ааааааааас, однако экспоненциальный поиск присутствует и в не­которых коммерческих реализациях. Вариант grep, применяемый в Unix (он называется egrep), использует более сложный алгоритм поиска соот­ветствий; этот алгоритм гарантирует линейный поиск, избегая отката, когда не подходит частичное соответствие.

А как насчет того, чтобы match умела обрабатывать полноценные регу­лярные выражения? Для этого надо добавить возможность сопоставле­ния классов символов типа [a-zA-Z] конкретному символу алфавита, возможность исключать значение метасимволов (например, для поиска точки нужно обозначить этот символ как литеральный), предусмотреть скобки для группировки, а также ввести альтернативы (abc или def). Первый шаг здесь — облегчить match работу, скомпилировав шаблон в некое новое представление, которое было бы проще сканировать: слишком накладно производить разбор класса символов для каждого нового сравнения его с одним символом; предварительно вычисленное представление, основанное на битовых массивах, сделает классы симво­лов гораздо более эффективными. Полная,реализация регулярных вы­ражений, со скобками и альтернативами, получится гораздо более сложной; облегчить написание помогут некоторые средства, о которых мы поговорим далее в этой главе.

Упражнение 9-6

Как соотносится быстродействие match и strstr при поиске простого текста (без метасимволов)?

Упражнение 9-7

Напишите версию matchhere без рекурсии и сравните ее быстродей­ствие с рекурсивной версией.

Упражнение 9-8

Добавьте в grep несколько ключей командной строки. К наиболее по­пулярным ключам относятся -v для инвертирования смысла шаблона, -i для поиска без учета регистра и -n для вывода номеров строк. Как дол­жны выводиться номера строк? Должны ли они печататься на той же строке, что и совпавший текст?

Упражнение 9-9

Добавьте в match операторы + (один или более) и ? (ноль или один). Шаблону a+bb? соответствует строка из одного или более а, за которыми следует одно или два b.

Упражнение 9-10

В нашей реализации match специальное значение символов ^ и $ от­ключается, если они не стоят, соответственно, в начале или конце выра­жения, а звездочка * рассматривается как обычный символ, если она не стоит непосредственно после литерального символа или точки. Однако более стандартным поведением является отключение значения мета­символа постановкой перед ним символа обратной косой черты (\). Из­мените match так, чтобы она обрабатывала этот символ именно таким образом.

Упражнение 9-11

Добавьте в match классы символов. Класс символов определяет соот­ветствие любому из символов, заключенных в квадратные скобки. Для удобства лучше ввести диапазоны, например [a-z] соответствует любой строчной букве (английского алфавита!), а также определить способ ин­вертирования смысла, - например, [^0-9] соответствует любому симво­лу, кроме цифры.

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]