Марейн Хавербеке - Выразительный JavaScript
Шаблоны с выбором
Допустим, надо выяснить, содержит ли текст не просто номер, а номер, за которым следует pig, cow, или chicken в единственном или множественном числе.
Можно было бы написать три регулярки и проверить их по очереди, но есть способ лучше. Символ | обозначает выбор между шаблонами слева и справа от него. И можно сказать следующее:
var animalCount = /bd+ (pig|cow|chicken)s?b/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pigchickens"));
// → false
Скобки ограничивают часть шаблона, к которой применяется |, и можно поставить много таких операторов друг за другом, чтобы обозначить выбор из более чем двух вариантов.
Механизм поиска
Регулярные выражения можно рассматривать как блок-схемы. Следующая диаграмма описывает последний животноводческий пример.
Выражение совпадает со строкой, если можно найти путь с левой части диаграммы в правую. Мы запоминаем текущее положение в строке, и каждый раз, проходя прямоугольник, проверяем, что часть строки сразу за нашим положением в ней совпадает с содержимым прямоугольника.
Значит, проверка совпадения нашей регулярки в строке "the 3 pigs" при прохождении по блок-схеме выглядит так:
• на позиции 4 есть граница слова, и проходим первый прямоугольник
• начиная с 4 позиции находим цифру, и проходим второй прямоугольник
• на позиции 5 один путь замыкается назад перед вторым прямоугольником, а второй проходит далее к прямоугольнику с пробелом. У нас пробел, а не цифра, и мы выбираем второй путь.
• теперь мы на позиции 6, начало “pigs”, и на тройном разветвлении путей. В строке нет “cow” или “chicken”, зато есть “pig”, поэтому мы выбираем этот путь.
• на позиции 9 после тройного разветвления, один путь обходит “s” и направляется к последнему прямоугольнику с границей слова, а второй проходит через “s”. У нас есть “s”, поэтому мы идём туда.
• на позиции 10 мы в конце строки, и совпасть может только граница слова. Конец строки считается границей, и мы проходим через последний прямоугольник. И вот мы успешно нашли наш шаблон.
В принципе, работают регулярные выражения следующим образом: алгоритм начинает в начале строки и пытается найти совпадение там. В нашем случае там есть граница слова, поэтому он проходит первый прямоугольник – но там нет цифры, поэтому на втором прямоугольнике он спотыкается. Потом он двигается ко второму символу в строке, и пытается найти совпадение там… И так далее, пока он не находит совпадение или не доходит до конца строки, в таком случае совпадение не найдено.
Откаты
Регулярка /b([01]+b|d+|[da-f]h)b/ совпадает либо с двоичным числом, за которым следует b, либо с десятичным числом без суффикса, либо шестнадцатеричным (цифры от 0 до 9 или символы от a до f), за которым идёт h. Соответствующая диаграмма:
В поисках совпадения может случиться, что алгоритм пошёл по верхнему пути (двоичное число), даже если в строке нет такого числа. Если там есть строка “103”, например, понятно, что только достигнув цифры 3 алгоритм поймёт, что он на неправильном пути. Вообще строка совпадает с регуляркой, просто не в этой ветке.
Тогда алгоритм совершает откат. На развилке он запоминает текущее положение (в нашем случае, это начало строки, сразу после границы слова), чтобы можно было вернуться назад и попробовать другой путь, если выбранный не срабатывает. Для строки “103” после встречи с тройкой он вернётся и попытается пройти путь для десятичных чисел. Это сработает, поэтому совпадение будет найдено.
Алгоритм останавливается, как только найдёт полное совпадение. Это значит, что даже если несколько вариантов могут подойти, используется только один из них (в том порядке, в каком они появляются в регулярке).
Откаты случаются при использовании операторов повторения, таких, как + и *. Если вы ищете /^.*x/ в строке "abcxe", часть регулярки .* попробует поглотить всю строчку. Алгоритм затем сообразит, что ему нужен ещё и “x”. Так как никакого “x” после конца строки нет, алгоритм попробует поискать совпадение, откатившись на один символ. После abcx тоже нет x, тогда он снова откатывается, уже к подстроке abc. И после строчки он находит x и докладывает об успешном совпадении, на позициях с 0 по 4.
Можно написать регулярку, которая приведёт ко множественным откатам. Такая проблема возникает, когда шаблон может совпасть с входными данными множеством разных способов. Например, если мы ошибёмся при написании регулярки для двоичных чисел, мы можем случайно написать что-то вроде /([01]+)+b/.
Если алгоритм будет искать такой шаблон в длинной строке из нолей и единиц, не содержащей в конце “b”, он сначала пройдёт по внутренней петле, пока у него не кончатся цифры. Тогда он заметит, что в конце нет “b”, сделает откат на одну позицию, пройдёт по внешней петле, опять сдастся, попытается откатиться на ещё одну позицию по внутренней петле… И будет дальше искать таким образом, задействуя обе петли. То есть, количество работы с каждым символом строки будет удваиваться. Даже для нескольких десятков символов поиск совпадения займёт очень долгое время.
Метод replace
У строк есть метод replace, который может заменять часть строки другой строкой.
console.log("папа".replace("п", "м"));
// → мапа
Первый аргумент может быть и регуляркой, в каком случае заменяется первое вхождение регулярки в строке. Когда к регулярке добавляется опция g (global, всеобщий), заменяются все вхождения, а не только первое.
console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar
Имело бы смысл передавать опцию «заменить все» через отдельный аргумент, или через отдельный метод типа replaceAll. Но к сожалению, опция передаётся через саму регулярку.
Вся сила регулярок раскрывается, когда мы используем ссылки на найденные в строке группы, заданные в регулярке. Например, у нас есть строка, содержащая имена людей, одно имя на строчку, в формате «Фамилия, Имя». Если нам надо поменять их местами и убрать запятую, чтобы получилось «Имя Фамилия», мы пишем следующее:
console.log(
"Hopper, GracenMcCarthy, JohnnRitchie, Dennis"
.replace(/([w ]+), ([w ]+)/g, "$2 $1"));
// → Grace Hopper
// John McCarthy
// Dennis Ritchie
$1 и $2 в строчке на замену ссылаются на группы символов, заключённые в скобки. $1 заменяется текстом, который совпал с первой группой, $2 – со второй группой, и так далее, до $9. Всё совпадение целиком содержится в переменной $&.
Также можно в качестве второго аргумента передавать и функцию. Для каждой замены будет вызвана функция, аргументами которой будут найденные группы (и вся совпадающая часть строки целиком), а её результат будет вставлен в новую строку.
Простой пример:
var s = "the cia and fbi";
console.log(s.replace(/b(fbi|cia)b/g, function(str) {
return str.toUpperCase();
}));
// → the CIA and FBI
А вот более интересный:
var stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
amount = Number(amount) - 1;
if (amount == 1) // остался только один, удаляем 's' в конце
unit = unit.slice(0, unit.length - 1);
else if (amount == 0)
amount = "no";
return amount + " " + unit;
}
console.log(stock.replace(/(d+) (w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs
Код принимает строку, находит все вхождения чисел, за которыми идёт слово, и возвращает строчку, где каждое число уменьшено на единицу.
Группа (d+) попадает в аргумент amount, а (w+) – в unit. Функция преобразовывает amount в число – и это всегда срабатывает, потому что наш шаблон как раз d+. И затем вносит изменения в слово, на случай, если остался всего один предмет или ни одного.
Жадность
Несложно при помощи replace написать функцию, убирающую все комментарии из кода JavaScript. Вот первая попытка:
function stripComments(code) {
return code.replace(///.*|/*[^]**//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 1
Часть перед оператором «или» совпадает с двумя слэшами, за которыми идёт любое количество символов, кроме символов перевода строки. Часть, убирающая многострочные комментарии, более сложна. Мы используем , т. е. любой символ, не являющийся пустым, в качестве способа найти любой символ. Мы не можем использовать точку, потому что блочные комментарии продолжаются и на новой строке, а символ перевода строки не совпадает с точкой.