65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的正则表达式解析器实现

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (42投票s)

2008年8月1日

CPOL

8分钟阅读

viewsIcon

193231

downloadIcon

7300

关于如何实现正则表达式解析器的文章

RegEx.GIF

引言

我想了解正则表达式解析器是如何工作的。所以我进行了一些搜索,并找到了一些描述正则表达式如何查找匹配过程的精彩文章。我在本文的参考文献部分列出了这些文章。我根据我的研究实现了这个解析器。我将不会详细描述正则表达式背后的过程和理论,因为参考文献中的文章已经很好地涵盖了这一点(正则表达式的主题非常广泛,需要一本书来全面解释)。

在本文中,我将简单地展示一个简单的正则表达式解析器(或迷你正则表达式解析器)的实现。我将继续使用术语自动机、NFADFA最小DFA、状态转换epsilon转换。如果您不理解这些术语,我强烈建议您在继续之前阅读参考文献中的一些文章。

所以您会问,“为什么是这个?”这个实现是分步进行的,所以对于想了解正则表达式工作原理的人来说很容易。其他特性

  • 拥有一个有助于您理解状态和转换的 GUI
  • 使用 ^ 和 $ 标记分别指定在模式开头和结尾进行匹配
  • C# 实现,非常面向对象
  • 易于理解的代码,附带注释
  • 具有允许您控制解析器贪婪度的功能——让您体验贪婪度的不同行为。
  • 不限于 ASCII 字符 (0-255)
  • 与我遇到的许多解析器相比,此实现更完整。

所以我想与 CodeProject 用户分享这个实现。顺便说一句:这是我第一次提交到 CodeProject,希望您喜欢。

特点

下表显示了解析器支持的符号。

符号 描述 示例
? (量词) 匹配前一个字符零次或一次 "A?" 匹配零次或一次 A
+ (量词) 匹配前一个字符一次或多次 "A+" 匹配一次或多次 A
* (量词) 匹配前一个字符零次或多次 "A*" 匹配零次或多次 A
_ (下划线) 任意单个字符。 "a_b" 将匹配 "abb", "aab", "acb",但不会匹配 "ab"
_* 匹配任意字符零次或多次(通配符)。 "a_*p" 在字符串 "appleandpato" 中将匹配 "appleandp"。
[ ] 指定范围 ([a-f]) 或集合 ([abcdef]) 内的任意单个字符。 "[C-P]arsen" 匹配 "Carsen", "Larsen", "Karsen" 等。
[^] 指定范围 ([^a-f]) 或集合 ([^abcdef]) 外的任意单个字符。 "de[^l]-*" 匹配以 "de" 开头且后续字母不是 l 的字符串。
$ 指定匹配字符串开头的标记 "AA$" 匹配 "AAA" 中的最后两个 A
^ 指定匹配字符串结尾的标记 "^AA" 匹配 "AAA" 中的前两个 A
\ (转义) 您可以转义以下字符

\(, \), \n (换行符), \r (回车符), \[, \], \*, \?, \+, \t (制表符)

"\n\n" 匹配任意两个连续出现的换行符。

"a]b[cd\]]" 是一个有效模式。

一些解析器使用点 (.) 来表示任意单个字符,而不是下划线 (_)。如果您愿意,可以通过简单地更改为此定义的常量值来修改源代码。

您可以通过设置 RegEx.UseGreedy 属性来控制解析器的贪婪度。如果您将此属性设置为 false 并在字符串 "appleandpato" 中使用模式 "a_*p",它将匹配 "ap" 而不是 "appleandp"。

解析器会验证输入模式字符串的正确性。如果遇到语法错误,它会准确地报告错误及其详细信息(即错误位置、长度和错误类型)。

Using the Code

此解析器中的算法遵循 Mike Clark 先生的讲义。不幸的是,在他将讲义下线后我才下载。所以我在此提供它们。

我认为这些讲义相当简单且易于理解。我的代码结构就是按照这些讲义中提到的步骤进行的。如果您有时觉得难以理解我的代码在做什么(我希望不会),请阅读其中相关的讲义。

此解析器使用 C# 和 Visual Studio 2005 编写。下面是该组件关键类的部分类图。

Set 类是数学中集合的简单表示。由于 .NET 没有提供 Set 类,我不得不自己编写一个。Map 类是一个介于键和一个或多个对象之间的映射,.NET 中也没有。State 类保存了自动机的 (automata) 数据结构。RegEx 是实际使用其他类的主要类。RegExValidator 类用于验证模式字符串。验证使用递归下降解析 (Recursive Descent Parsing) 进行。除了验证模式,它还执行另外两项任务:插入隐式标记使其显式化以及扩展字符类。例如:

  • "AB" -> "A.B" (插入连接运算符(.))
  • "A.B" -> "A\.B" (插入转义)
  • "[A-C]" -> "(A|B|C)" (扩展范围)
  • "(AB" -> 报告括号不匹配的错误

您会在源代码中找到类方法的描述作为注释。

下面的序列图显示了如何使用该解析器。

下面的代码片段展示了如何使用 RegEx

    private void TestMatch()
    {
      RegEx re = new RegEx();
      string sPattern = "a_*p";
      ErrorCode errCode = re.Compile(sPattern);

      if (errCode != ErrorCode.ERR_SUCCESS)
      {
        string sErrSubstring = sPattern.Substring(re.GetLastErrorPosition(),
            re.GetLastErrorLength());
        string sFormat =
            "Error occured during compilation.\nCode: {0}\nAt: {1}\nSubstring: {2}";
        sFormat = String.Format(sFormat, errCode.ToString(),
            m_regEx.GetLastErrorPosition(), sErrSubstring);
        MessageBox.Show(sFormat);
        return;
      }

      int nFoundStart = -1;
      int nFoundEnd = -1;
      string sToSearch = "appleandpotato";

      bool bFound = m_regEx.FindMatch(sToSearch, 0, sToSearch.Length - 1,
          ref nFoundStart, ref nFoundEnd);
      if (bFound)
      {
        int nMatchLength = nFoundEnd - nFoundStart + 1;
        if (nMatchLength == 0)
        {
          MessageBox.Show("Matched an empty string at position " +
              nFoundStart.ToString() + ".");
        }
        else
        {
          string sMatchString = sToSearch.Substring(nFoundStart, nMatchLength);

          MessageBox.Show("Match found at: " + nFoundStart.ToString() + "\n" +
              sMatchString);          
        }
      }
      else
      {
        MessageBox.Show("No match found.");
      }
    }

关注点

量词 **\***, **\+**, **\?** 的 NFA 模型可以在我提到的文章中找到。

在实现解析器时,我在几个转换上遇到了很多麻烦

**_** (下划线) - 任意单个字符

**[^A]** - 字符集的补集

我在搜索时没有找到关于这些转换的信息。

经过大量的试错,我找到了可以正常工作的 NFA 模型。使用这些模型,您无需修改原始算法。

这个 "AnyChar" 转换在 RegEx.FindMatch 方法中作为特殊情况处理。如果当前状态没有当前输入符号的转换,它会检查当前状态是否具有 "AnyChar" 符号的转换。如果有,则使用该转换。

字符集补集使用 "AnyChar" 转换和 "Dummy" 转换。如果当前状态使用了被禁止的转换(例如,在 [^A] 中的 A),它会转到一个只有一个出度的状态——即 "Dummy" 转换,并且该状态不是接受状态。"Dummy" 转换在实际过程中永远不会被使用,因此解析器会到达死胡同状态,从而导致不匹配。如果当前状态没有当前输入符号的任何转换,它会使用 "AnyChar" 转换并到达接受状态,从而有效地匹配正确的部分/字符串。

此外,RegEx.FindMatch 方法也需要一些讨论。如果没有实现 ^(匹配开始)、$(匹配结束)、_(任意单个字符)、_*(通配符 - 任意数量的任意字符)和贪婪度(控制它)的功能,此方法将非常简单,如下所示:

    public bool FindMatch(string sSearchIn,
                          int nSearchStartAt,
                          int nSearchEndAt,
                          ref int nFoundBeginAt,
                          ref int nFoundEndAt)
    {

      if (m_stateStartDfaM == null)
      {
        return false;
      }

      if (nSearchStartAt < 0)
      {
        return false;
      }

      State stateStart = m_stateStartDfaM;

      nFoundBeginAt = -1;
      nFoundEndAt = -1;

      bool bAccepted = false;
      State toState = null;
      State stateCurr = stateStart;

      while (nSearchStartAt <= nSearchEndAt)
      {

        char chInputSymbol = sSearchIn[nSearchStartAt];

        toState = stateCurr.GetSingleTransition(chInputSymbol.ToString());

        if (toState == null)
        {
          toState = stateCurr.GetSingleTransition(MetaSymbol.ANY_ONE_CHAR_TRANS);
        }

        if (toState != null)
        {
          if (nFoundBeginAt == -1)
          {
            nFoundBeginAt = nSearchStartAt;
          }

          if (toState.AcceptingState)
          {
            bAccepted = true;
            nFoundEndAt = nSearchStartAt;
          }

          stateCurr = toState;
          nSearchStartAt++;
        }
        else
        {
          if (!bAccepted)  // we reset everything
          {
            nSearchStartAt = (
                nFoundBeginAt != -1 ? nFoundBeginAt + 1 : nSearchStartAt + 1);
            nFoundBeginAt = -1;
            nFoundEndAt = -1;
            stateCurr = stateStart;  // start from beginning
          }
          else
          {
            break;
          }
        }
      }  // end of while..loop 

      return bAccepted;
    }

但添加了这些功能后,此方法变得更具逻辑性。目前的实现如下所示:

    public bool FindMatch(string sSearchIn,
                          int nSearchStartAt,
                          int nSearchEndAt,
                          ref int nFoundBeginAt,
                          ref int nFoundEndAt)
    {

      if (m_stateStartDfaM == null)
      {
        return false;
      }

      if (nSearchStartAt < 0)
      {
        return false;
      }

      State stateStart = m_stateStartDfaM;

      nFoundBeginAt = -1;
      nFoundEndAt = -1;

      bool bAccepted = false;
      State toState = null;
      State stateCurr = stateStart;
      int nIndex = nSearchStartAt;
      int nSearchUpTo = nSearchEndAt;


      while (nIndex <= nSearchUpTo)
      {

        if (m_bGreedy && IsWildCard(stateCurr) == true)
        {
          if (nFoundBeginAt == -1)
          {
            nFoundBeginAt = nIndex;
          }
          ProcessWildCard(stateCurr, sSearchIn, ref nIndex, nSearchUpTo);
        }

        char chInputSymbol = sSearchIn[nIndex];

        toState = stateCurr.GetSingleTransition(chInputSymbol.ToString());

        if (toState == null)
        {
          toState = stateCurr.GetSingleTransition(MetaSymbol.ANY_ONE_CHAR_TRANS);
        }

        if (toState != null)
        {
          if (nFoundBeginAt == -1)
          {
            nFoundBeginAt = nIndex;
          }

          if (toState.AcceptingState)
          {
            // then we ignore the accepting state
            if (m_bMatchAtEnd && nIndex != nSearchUpTo)  
            {
              //toState = stateStart ;
            }
            else
            {
              bAccepted = true;
              nFoundEndAt = nIndex;
              if (m_bGreedy == false)
              {
                break;
              }
            }
          }

          stateCurr = toState;
          nIndex++;
        }
        else
        {
          if (!m_bMatchAtStart && !bAccepted )  // we reset everything
          {
            nIndex = (nFoundBeginAt != -1 ? nFoundBeginAt + 1 : nIndex + 1);

            nFoundBeginAt = -1;
            nFoundEndAt = -1;
            //nIndex++;
            stateCurr = stateStart;  // start from beginning
          }
          else
          {
            break;
          } 
        }
      }  // end of while..loop 

      if (!bAccepted)
      {
        if (stateStart.AcceptingState == false)
        {
          return false;
        }
        else // matched an empty string
        {
          nFoundBeginAt = nSearchStartAt;
          nFoundEndAt = nFoundBeginAt - 1;
          return true;
        }
      }


      return true;
    }

最初,我费力地尝试不在 FindMatch 方法中添加任何额外的逻辑,并试图通过 NFA 模型寻找这些功能的解决方案。但这些努力一无所获,最终我在此方法中添加了一些逻辑。但现在所有这些功能都运行得很好。

结语

这是一个简单的实现。如果您愿意,可以为这个演示代码添加更多功能。如果您为此处提供的演示代码添加了更多功能,如果您方便的话,我很希望能获得一份代码副本。您可以根据 CodeProject 许可证 (CPOL) 以任何您喜欢的方式使用此代码。如果您想将此演示代码移植到 C++,我认为这不会太难。

历史

  • 2008 年 8 月 1 日 -- 首次提交
  • 2009 年 2 月 24 日 -- Xawiersas 发现了代码中的一个错误(见下文),所以我修复了它并重构了代码,使其更具可读性。
  • 2009 年 6 月 24 日 -- 更新了源代码和演示项目

参考文献

© . All rights reserved.