借助 C++ 进行 Windows 开发

将正则表达式用于最新 C++

Kenny Kerr

“C++ 是一种用于开发和使用简练有效抽象的语言。”— Bjarne Stroustrup

引自 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 并没有什么错误,但是我发现使用正则表达式进行的大部分工作倾向于针对内存映射文件。 将这些文件的内容复制到字符串对象中只会使应用程序的运行速度变慢。 标准库的正则表达式支持是中性的,非常适用于处理字符序列,而无需考虑如何管理它们。

我需要的下一个内容是匹配对象:

auto m = cmatch {};

这实际上是匹配集合。 cmatch 是专用于 char 字符串的 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();

下标运算符返回指定的 sub_match 对象。 值为零的索引将匹配作为一个整体来表示,后续索引可准确定位在正则表达式中标识的任何组。 match_results 和 sub_match 对象都不会创建或分配子字符串, 而是使用指针或迭代器将字符范围表述为匹配或子匹配的开头和结尾,从而生成适合标准库的常规半开放范围。 在此例中,我对每个 sub_match 显式调用 str 方法,以字符串对象的形式创建每个子匹配的副本。

这可处理第一种可能的格式。 对于第二种可能的格式,我需要使用其他可选模式再次调用 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++ 语言功能,您以前可能使用也可能没有使用过这些功能,但应该没什么过于令人吃惊的内容。 现在我要介绍如何使用可变参数模板真正使正则表达式的使用增色。 我不会深入探讨更多语言功能,我首先会介绍一个用于简化文本处理的简单抽象,这样可以保持实用简练的风格。

首先,我定义一个简单类型,用于表示不一定以 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 类完成的工作不多,但我会使用一组非成员函数来扩展它。 我从通常定义范围的一对函数开始:

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 的成员,编译器会在封闭范围内查找合适的对。 问题在于,如果编译器找到名为 begin 和 end 的成员,但是它们不合适,则编译器不会尝试进一步查找。 这可能有点狭隘,但是 C++ 有复杂的名称查找规则,任何其他内容都会使其更令人困惑且不一致。

strip 类是一个简单的小构造,但是它本身完成的工作不多。 我现在将它与正则表达式库组合起来,生成简练的抽象。 我需要隐藏匹配对象的机制,这是表达式处理中乏味的部分。 这是引入可变参数模板的位置。 要理解可变参数模板,关键在于认识到您可以将第一个参数与其余参数分隔开来。 这通常会形成编译时递归。 我可以定义一个可变参数模板,用于将匹配对象解包为后续参数:

template <typename... Args>
auto unpack(cmatch const & m,
            Args & ... args) -> void
{
  unpack<sizeof...(Args)>(m, args...);
}

“typename...”指示 Args 为模板参数包。 args 类型中的对应“...”指示 args 为函数参数包。 “sizeof...”表达式确定参数包中的元素数。 args 后的最后一个“...”告知编译器将参数包展开为其元素序列。

每个参数的类型可以不同,但是在此例中,每个参数都是非常量 strip 引用。 我使用的是可变参数模板,因此可以支持未知数量的参数。 迄今为止,unpack 函数没有表现出递归性。 它通过一个附加模板参数,将其参数转发到另一个 unpack 函数:

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 函数捕获参数包中的原始元素数量。 这是必需的,因为每个递归调用都会实际生成大小逐渐减小的新参数包。 请注意我如何从原始总数中减去参数包的大小。 根据此总数或稳定大小,我可以索引到匹配集合中以检索各个子匹配并将其相应边界复制到可变参数中。

这可以完成匹配对象的解包。 虽然不是必需的,不过我仍然发现在不直接需要匹配对象时(例如,如果仅在访问匹配前缀和后缀时需要)隐藏该对象本身会十分有用。 我打包所有内容以提供更简单的匹配抽象:

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 函数以进行处理。 它还提供本地匹配对象,并在 strip 的 begin 和 end 帮助程序函数中定义搜索序列。 可以编写一个几乎相同的函数来容纳 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);
  }
}

我随后可以使用 lambda 表达式调用此函数来解包每个匹配:

for_each(s, r, [] (cmatch const & m)
{
  strip hello, world;
  unpack(m, hello, world);
});

这肯定是可以的,但是我总是发现无法方便地中断这类循环构造,十分令人沮丧。 range-for 语句可提供更方便的替代方法。 我首先定义一个简单的迭代器范围,编译器会识别该范围以实现 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++ 部分将令人赏心悦目!

Kenny Kerr 是加拿大的一名计算机程序员,他是 Pluralsight 的作者,也是 Microsoft MVP。他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。