C++ — это язык для разработки и использования элегантных и эффективных абстракций — Бьёрн Страуструп
Эта цитата из высказываний создателя C++ фактически суммирует то, что мне нравится в этом языке. Я получил возможность разрабатывать элегантные решения для своих задач, комбинируя языковые средства и стили программирования, которые я считаю наиболее подходящими для конкретной задачи.
В C++11 введен длинный список средств, которые сами по себе весьма интересны, но, если бы вы увидели в этом списке только перечень средств, вы пропустили бы очень многое. Именно сочетание этих средств придает C++ ту мощь, которую высоко ценят многие разработчики. Я намерен проиллюстрировать эту мысль на примере того, как в современном C++ используются регулярные выражения. В стандарте C++11 появилась эффективная библиотека регулярных выражений, но если вы будете использовать ее изолированно, применяя традиционный стиль программирования на C++, то сочтете работу с ней довольно утомительной. К сожалению, именно так вводится большинство библиотек C++11. Однако в таком подходе есть свое преимущество. Если бы вы искали четкий пример использования какой-то новой библиотеки, вам было бы крайне трудно одновременно вникнуть в уйму новых языковых средств. Однако возможность комбинирования языковых средств C++ и библиотек превращает C++ в очень продуктивный язык программирования.
Чтобы сфокусировать примеры на C++, а не на регулярных выражениях, как таковых, я буду использовать очень упрощенные шаблоны. Возможно, вас заинтересует, почему я применяю регулярные выражения для столь тривиальных задач, но это помогает не запутаться в механике обработки выражений. Вот простой пример: мне нужно сравнивать строки с именами, где имена могут быть отформатированы в виде «Kenny Kerr» или «Kerr, Kenny». Требуется идентифицировать имя и фамилию, а затем выводить их в каком-то едином виде. Сначала целевая строка:
char const s[] = "Kerr, Kenny";
Чтобы ничего не усложнять, я придерживаюсь символьных строк (char) и избегаю использования класса basic_string стандартной библиотеки во всех случаях, кроме ситуации, где надо проиллюстрировать результаты определенных совпадений. В basic_string нет ничего дурного, но большая часть работы, выполняемая мной с регулярными выражениями, ориентирована на проецируемые в память файлы (memory-mapped files). Копирование содержимого этих файлов в строковые объекты только замедлило бы мои программы. Для поддержки регулярных выражений в стандартной библиотеке эти варианты безразличны, и данная библиотека прекрасно обрабатывает последовательности символов, не заботясь о том, как именно ими управляют.
Следующее, что мне нужно, — это объект совпадения (match object):
auto m = cmatch {};
На самом деле это набор совпадений. Объект cmatch — шаблон класса match_results, специализированный для символьных строк. В этот момент «набор» совпадений пуст:
ASSERT(m.empty());
Мне также потребуется пара строк для приема результатов:
string name, family;
Теперь можно вызвать функцию regex_match:
if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
}
Эта функция пытается проверить совпадение шаблона с целой последовательностью символов. Она является противоположностью функции regex_search, которая вполне счастлива, обнаружив совпадение в любой позиции внутри строки. Я создаю объект regex прямо в строке кода для краткости, но это не обходится без некоторых издержек. Если бы вы повторно искали совпадения с этим регулярным выражением, вам следовало бы один раз создать объект regex, а затем хранить его на протяжении всего времени работы своего приложения. Предыдущий шаблон соответствует именам в формате «Kenny Kerr». Предполагая, что это совпадение, я могут просто скопировать подстроки:
name = m[1].str();
family = m[2].str();
Операция индексации (subscript operator) возвращает указанный объект sub_match. Нулевой индекс представляет совпадение в целом, тогда как последующие индексы относятся к любым группам, идентифицированным в регулярном выражении. Ни match_results, ни sub_match не создают подстроки и не выделяют память для них. Вместо этого они разграничивают диапазон символов с помощью указателя или итератора, указывающего на начало и конец совпадения или подсовпадения (submatch), что приводит к созданию обычного полуоткрытого диапазона (half-open range), которому отдается предпочтение в стандартной библиотеке. В данном случае я вызываю явным образом метод str в каждом sub_match, чтобы создать копию каждого подсовпадения как строковые объекты.
Это обеспечивает обработку первого из возможных форматов. Для второго мне нужен другой вызов regex_match с альтернативным шаблоном (с технической точки зрения, вы могли бы проверять оба формата одним выражением, но это выходит за рамки моих целей):
else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
name = m[2].str();
family = m[1].str();
}
Этот шаблон предназначен для сравнения с именами в формате «Kerr, Kenny». Заметьте, что я вынужден обратить индексы, так как первая группа, представленная в этом регулярном выражении, идентифицирует фамилию, а вторая — имя. Вот и все, что касается функции regex_match. На рис. 1 дан полный листинг.
Рис. 1. Пример с regex_match для справки
char const s[] = "Kerr, Kenny";
auto m = cmatch {};
string name, family;
if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
name = m[1].str();
family = m[2].str();
}
else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
name = m[2].str();
family = m[1].str();
}
else
{
printf("No match\n");
}
Не знаю, как вам, а мне код на рис. 1 кажется довольно утомительным. Хотя библиотека регулярных выражений, безусловно, эффективна и гибка, она не особенно элегантна. Мне нужно знать об объектах match_results и sub_match. Я должен помнить, как индексируется этот «набор» и как извлекаются результаты. Я мог бы избегать создания копий, но очень быстро это станет затруднительным.
Я уже использовал ряд новых языковых средств C++, с которыми вы, возможно, еще не имели дела, но в них нет ничего такого сногсшибательного. Теперь я хочу показать, как можно применять шаблоны с переменным количеством аргументов (variadic templates), чтобы добиться большей элегантности в использовании регулярных выражений. Вместо того чтобы прибегнуть к дополнительным языковым средствам, я начну с того, что продемонстрирую вам простую абстракцию, которая облегчает обработку текста; она позволит сделать пример практичным и элегантным.
Первым делом я определю простой тип, представляющий последовательность символов, которые не обязательно заканчиваются null. Это класс strip:
struct strip
{
char const * first;
char const * last;
strip(char const * const begin,
char const * const end) :
first { begin },
last { end }
{}
strip() : strip { nullptr, nullptr } {}
};
Несомненно, таких классов, которые я мог бы использовать повторно, существует очень много, но на мой взгляд он помогает избегать чрезмерного количества зависимостей при создании простых абстракций.
Класс strip ничего особенного не делает, однако я дополню его набором функций, которые не являются его членами (nonmember functions). Начнем с пары функций, позволяющих определять диапазон:
auto begin(strip const & s) -> char const *
{
return s.first;
}
auto end(strip const & s) -> char const *
{
return s.last;
}
Хотя это не обязательно для данного примера, я считаю такой прием полезным для согласованности с контейнерами и алгоритмами стандартной библиотеки. Я вернусь к функциям begin и end чуть позже. Следующей у нас является вспомогательная функция make_strip:
template <unsigned Count>
auto make_strip(char const (&text)[Count]) -> strip
{
return strip { text, text + Count - 1 };
}
Эта функция удобна при создании strip на основе строкового литерала. Например, strip можно инициализировать так:
auto s = make_strip("Kerr, Kenny");
Кроме того, зачастую полезно определять длину или размер strip:
auto size(strip const & s) -> unsigned
{
return end(s) - begin(s);
}
Как видите, здесь я просто повторно использую функции begin и end, чтобы избежать зависимости от членов strip. Я мог бы защитить члены класса strip. С другой стороны, зачастую полезно иметь возможность напрямую манипулировать ими из какого-либо алгоритма. Однако, раз мне не нужна жесткая зависимость, я не стану этого делать.
Очевидно, что создать стандартную строку из strip достаточно легко:
auto to_string(strip const & s) -> string
{
return string { begin(s), end(s) };
}
Это может пригодиться, если какие-то из результатов переживут исходные последовательности символов. Таким образом, я дополняю базовую обработку strip. Можно инициализировать strip и определить его размер, а благодаря функциям begin и end можно использовать выражение «range-for» для перебора его символов:
auto s = make_strip("Kenny Kerr");
for (auto c : s)
{
printf("%c\n", c);
}
Когда я первый раз писал класс strip, я надеялся, что смогу назвать его члены «begin» и «end» вместо «first» и «last». Беда в том, что компилятор, встречая выражение «range-for», сначала пытается найти подходящие члены, которые можно вызывать как функции. Если целевой диапазон или последовательность не включают никаких членов с именами begin и end, то компилятор ищет подходящую пару в окружающей области видимости (enclosing scope). Однако, если компилятор обнаруживает члены с именами begin и end и они не подходят, он не станет предпринимать дальнейших попыток. Это может показаться упущением, но в C++ действуют сложные правила поиска имен, и что-либо другое сделало бы их еще более запутанными и несогласованными.
Класс strip — простая конструкция, но сама по себе ничего особенного не делает. Теперь я скомбинирую его с библиотекой регулярных выражений, чтобы создать элегантную абстракцию. Я хочу скрыть механику объекта совпадения, т. е. всю утомительную часть обработки выражений. И здесь на сцену выходят шаблоны с переменным числом аргументов (variadic templates). Ключ к пониманию таких шаблонов — осознание того, что вы можете отделять первый аргумент от остальных. Это, как правило, приводит к рекурсии на этапе компиляции. Я могу определить шаблон с переменным числом аргументов, чтобы распаковывать объект match в последовательные аргументы:
template <typename... Args>
auto unpack(cmatch const & m,
Args & ... args) -> void
{
unpack<sizeof...(Args)>(m, args...);
}
Часть «typename...» указывает, что Args является пакетом параметров шаблона. Соответствующий «...» в типе args задает, что args — это пакет функциональных параметров (function parameter pack). Выражение «sizeof...» определяет количество элементов в пакете параметров. Заключительный «...», следующий за args, сообщает компилятору раскрыть пакет параметров в последовательность элементов.
Тип каждого аргумента может различаться, но в данном случае каждый из них является неконстантной ссылкой на strip (non-const reference). Я использую этот шаблон, чтобы поддерживать неизвестное заранее количество аргументов. Пока что функция unpack не была рекурсивной. Она пересылала свои аргументы другой функции unpack с дополнительным аргументом template:
template <unsigned Total, typename... Args>
auto unpack(cmatch const & m,
strip & s,
Args & ... args) -> void
{
auto const & v = m[Total - sizeof...(Args)];
s = { v.first, v.second };
unpack<Total>(m, args...);
}
Однако эта функция unpack отделяет первый аргумент, следующий за объектом совпадения, от остальных аргументов. И это рекурсия при компиляции в действии. Предполагая, что пакет параметров args не пуст, она вызывает сама себя с остальными аргументами. В конечном счете последовательность аргументов опустошается, и для завершения требуется третья функция unpack:
template <unsigned>
auto unpack(cmatch const &) -> void {}
Она ничего не делает — просто подтверждает тот факт, что пакет параметров пуст. Предыдущие функции unpack хранят ключ для распаковки объекта совпадения. Первая функция unpack получила исходное количество элементов в пакете параметров. Это необходимо, потому что каждый рекурсивный вызов в конечном счете будет создавать новый пакет параметров с последовательно уменьшающимся числом элементов. Обратите внимание на то, что я вычитаю размер пакета параметров из исходного значения. Располагая исходным (стабильным) размером, я могу обращаться к набору совпадений по индексу, получать индивидуальные подсовпадения и копировать их границы в переменное количество аргументов.
Тем самым происходит распаковка объекта совпадения. Хотя это не обязательно, я все равно считаю полезным скрывать сам объект совпадения, если он не нужен для прямых операций, например вам требуется лишь доступ к префиксу и суффиксу совпадения. Я обертываю все это для получения более простой абстракции match:
template <typename... Args>
auto match(strip const & s,
regex const & r,
Args & ... args) -> bool
{
auto m = cmatch {};
if (regex_match(begin(s), end(s), m, r))
{
unpack<sizeof...(Args)>(m, args...);
}
return !m.empty();
}
Эта функция — тоже шаблон с переменным числом аргументов, но сама по себе она не является рекурсивной. Она просто пересылает свои аргументы исходной функции unpack для обработки. Кроме того, она берет на себя предоставление локального объекта match и определение последовательности поиска в терминах вспомогательных функций begin и end класса strip. Для работы с regex_search вместо regex_match можно написать почти идентичную функцию. Теперь я могу переписать пример с рис. 1 и свести его к гораздо более простому виду:
auto const s = make_strip("Kerr, Kenny");
strip name, family;
if (match(s, regex { R"((\w+) (\w+))" }, name, family) ||
match(s, regex { R"((\w+), (\w+))" }, family, name))
{
printf("Match!\n");
}
А как насчет итераций? Функция unpack также удобна для обработки результатов совпадения при итеративном поиске. Представьте строку с каноническим «Hello world» на множестве языков:
auto const s =
make_strip("Hello world/Hola mundo/Hallo wereld/Ciao mondo");
Я могу проверять совпадения следующим регулярным выражением:
auto const r = regex { R"((\w+) (\w+))" };
Библиотека регулярных выражений предоставляет regex_iterator для перебора совпадений в цикле, но прямое использование итераторов может стать делом весьма нудным. Один из вариантов — написать функцию for_each, вызывающую предикат для каждого совпадения:
template <typename F>
auto for_each(strip const & s,
regex const & r,
F callback) -> void
{
for (auto i = cregex_iterator { begin(s), end(s), r };
i != cregex_iterator {};
++i)
{
callback(*i);
}
}
Затем эту функцию можно было бы вызывать с лямбда-выражением для распаковки каждого совпадения:
for_each(s, r, [] (cmatch const & m)
{
strip hello, world;
unpack(m, hello, world);
});
Это работает, но меня всегда раздражает, когда я не могу легко избавиться от этого типа цикла. Выражение «range-for» позволяет использовать более удобную альтернативу. Я начну с определения простого диапазона итератора (iterator range), который компилятор распознает и реализует цикл «range-for»:
template <typename T>
struct iterator_range
{
T first, last;
auto begin() const -> T { return first; }
auto end() const -> T { return last; }
};
Теперь я могу написать более простую функцию for_each, которая возвращает iterator_range:
auto for_each(strip const & s,
regex const & r) -> iterator_range<cregex_iterator>
{
return
{
cregex_iterator { begin(s), end(s), r },
cregex_iterator {}
};
}
Компилятор позаботится о подготовке итерации, и я могу просто написать выражение «range-for» с минимальными синтаксическими издержками и возможностью досрочного выхода, если мне это понадобится:
for (auto const & m : for_each(s, r))
{
strip hello, world;
unpack(m, hello, world);
printf("'%.*s' '%.*s'\n",
size(hello), begin(hello),
size(world), begin(world));
}
В консоль будут выведены ожидаемые результаты:
'Hello' 'world'
'Hola' 'mundo'
'Hallo' 'wereld'
'Ciao' 'mondo'
C++11 дает возможность вдохнуть новую жизнь в разработку на C++, используя современный стиль программирования, который позволяет создавать элегантные и эффективные абстракции. Грамматика регулярных выражений может ввести в ступор даже самых опытных разработчиков. Так почему бы не потратить несколько минут на создание более элегантной абстракции? По крайней мере, та часть вашей задачи, которая относится к C++, превратится в сплошное удовольствие!