代码改变世界

趣味编程:从字符串中提取信息(参考答案 - 上)

2009-10-21 01:13 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

这次“趣味编程”的目的是解析字符串,从一个指定模式的字符串中提取信息。对于目前这个问题,解决方案有很多种,例如直接拆分,使用正则表达式,或是如现在本文这般按照顺序解析。总结果上来说,这些做法都是可取的,不过现在我打算举出的做法是我认为最为“典型”也最有“学习”和“展现”价值的解决方案:基于状态机的顺序字符解析。也欢迎您对此其他的做法进行深入分析。

您可能需要重新阅读上一篇文章来回忆字符串解析的具体规则,起始归纳起来,它只有以下三点:

  1. 一个text由一至多个token group组成;一个token group由一至多个token组成。
  2. token group的token之间使用单条横线“-”进行分割;text的token group之间使用两条横线“--”进行分割。
  3. token可以使用单引号包裹,单引号包裹内的横线作为普通字符处理,单引号包裹内使用两个单引号表示单个单引号。

至于最终结果,便是将一个text字符串,拆分成一个token group列表:

static List<List<string>> Parse(string text) { ... }

在这里,我们使用List<string>来表示一个token group(即token列表)。自然,表现方式可以有所不同。例如您的Parse方法如果返回“列表的数组”、“数组的列表”或是“数组的数组”都是没有任何问题的。

下面的做法基于winter-cn在上一篇文章后面的回复,再加以简单的修改和注释后得到的结果。这个做法的思路和我在“出题”时已经准备的“参考答案”不谋而合,但是winter-cn的实现要比我的更为简单、因此我的代码就不拿出来献丑了,我们现在一起来欣赏高手的劳动成果。

winter-cn的做法,是将“解析”工作拆分为5种状态,每种状态对应一种解析逻辑,而每种解析逻辑除了处理当前字符(改变一些公共状态)以外,还会返回处理下一个字符所使用的“解析逻辑”——这就是状态的迁移。winter-cn原有的做法是使用Func<char, object>来表示解析逻辑的类型,这样在每次得到新状态之后,还需要将其转化为Func<char, object>。不过为了更清晰地表达这样一种逻辑,我们也可以定义一个返回自身类型的“递归”的委托类型:

delegate StateParser StateParser(char ch);

在现在的实现中,我们把它解析过程分解为5个状态,分别对应不同“时刻”下的解析逻辑:

static List<List<string>> Parse(string text)
{
    StateParser p1 = null; // 用于解析token的起始字符
    StateParser p2 = null; // 用于解析作为分隔符的“-”的下一个字符
    StateParser p3 = null; // 用于解析token中或结尾的单引号的下一个字符
    StateParser p4 = null; // 用于解析单引号外的token字符
    StateParser p5 = null; // 用于解析单引号内的token字符

    var currentToken = new StringBuilder(); // 用于构建当前的token(即收集字符)
    var currentTokenGroup = new List<string>(); // 用于构建当前的token group(即收集token)
    var result = new List<List<string>>(); // 用于保存结果(即收集token group)
    ...

    return result;
}

p1至p5便是所谓的“状态”,也就是“解析逻辑”,它们都会操作currentToken,currentTokenGroup和result三个数据,并返回下一个状态。状态的划分并非只有一种,不同的状态划分方式会形成不同的逻辑。我们接下来便要根据这样的划分方式,为每个状态指定实现了。在实现的过程中,我们需要时刻遵守“当前”状态的逻辑细节,以及其他状态的职责,这样实现状态的迁移似乎也并不是一件困难的事情。

首先是p1,它的作用是解析token的第一个字符:

// 解析token的起始字符
p1 = ch =>
{
    if (ch == '-')
    {
        // 如果token中需要包含单引号或“-”,
        // 那么这个token在表示的时候一定需要用一对单引号包裹起来
        throw new ArgumentException();
    }

    if (ch == '\'')
    {
        // 如果起始字符是单引号,
        // 则开始解析单引号内的token字符
        return p5;
    }
    else
    {
        // 如果是普通字符,则作为当前token的字符,
        // 并开始解析单引号外的token字符
        currentToken.Append(ch);
        return p4;
    }

};

接着是p2:它的作用是解析分隔符“-”(不包括单引号包裹内的“-”)后的下一个字符:

// 解析作为分隔符的“-”的下一个字符
p2 = ch =>
{
    if (ch == '-')
    {
        // 如果当前字符为“-”,说明一个token group结束了(因为前一个字符也是“-”),
        // 则将当前的token group加入结果集,并且准备新的token group
        result.Add(currentTokenGroup);
        currentTokenGroup = new List<string>();
        return p1;
    }
    else if (ch == '\'')
    {
        // 如果当前字符为单引号,则说明新的token以单引号包裹
        // 则开始解析单引号内的token字符
        return p5;
    }
    else
    {
        // 如果是普通字符,则算作当前token的字符,
        // 并继续解析单引号外的token字符
        currentToken.Append(ch);
        return p4;
    }
};

接着是p3:解析token内部或结尾的单引号的下一个字符:

// 解析token内部或结尾的单引号的下一个字符
p3 = ch =>
{
    if (ch == '\'')
    {
        // 如果当前字符为单引号,则说明连续两个单引号,
        // 所以表明token中出现了“单个”单引号,并且当前token一定包裹在单引号内,
        // 因此继续解析单引号内的token字符
        currentToken.Append('\'');
        return p5;
    }
    else if (ch == '-')
    {
        // 如果当前字符为一个分隔符,则说明上一个token已经结束了
        // 于是将当前token加入当前token group,准备新的token,
        // 并解析分隔符后的下一个字符
        currentTokenGroup.Add(currentToken.ToString());
        currentToken = new StringBuilder();
        return p2;
    }
    else
    {
        // 单引号后面只可能是另一个单引号或者一个分隔符,
        // 否则说明输入错误,则抛出异常
        throw new ArgumentException();
    }
};

最后则是p4和p5,分别用于处理普通的token以及被单引号包裹的token字符:

// 用于解析单引号外的token字符,
// 即一个没有特殊字符(分隔符或单引号)的token
p4 = ch => 
{
    if (ch == '\'')
    {
        // 如果token中出现了单引号,则抛出异常
        throw new ArgumentException();
    }

    if (ch == '-')
    {
        // 如果出现了分隔符,则表明当前token结束了,
        // 于是将当前token加入当前token group,准备新的token,
        // 并解析分隔符的下一个字符
        currentTokenGroup.Add(currentToken.ToString());
        currentToken = new StringBuilder();
        return p2;
    }
    else
    {
        // 对于其他字符,则当作token中的普通字符处理
        // 继续解析单引号外的token字符
        currentToken.Append(ch);
        return p4;
    }
};

// 用于解析单引号内的token字符
p5 = ch =>
{
    if (ch == '\'')
    {
        // 对于被单引号包裹的token内的第一个单引号,
        // 需要解析其下一个字符,才能得到其真实含义
        return p3;
    }
    else
    {
        // 对于普通字符,则添加到当前token内
        currentToken.Append(ch);
        return p5;
    }
};

这些状态中的逻辑都有一个特点,它们都会通过C#编译器形成的闭包来操作“外部”状态——不过这个“外部”是指这些匿名函数的外部,但是它们统统属于Parse方法本身!这意味着,虽然我们的状态并非“纯函数”,但是Parse方法是没有任何副作用(Side Effect,即除了返回值外不会影响其他外部状态,以及相同的返回值永远相同的结果)。这个特点确保了Parse方法可以被任意多个线程同时调用。winter-cn的做法巧妙地使用了C#编译器的特性,简化了Parse方法的实现。

在定义完5种状态之后,我们便要从p1开始依次处理字符串中的每个字符,并且随着状态的迁移改变处理每个字符的逻辑。当然,最后的“收尾”工作也是必不可少的:

static List<List<string>> Parse(string text)
{
    ...

    text.Aggregate(p1, (sp, ch) => sp(ch));

    currentTokenGroup.Add(currentToken.ToString());
    result.Add(currentTokenGroup);

    return result;
}

可以看出,这种做法的优势之一是完全的“顺序处理”,只遍历一次。如果您使用字符串的分割或者正则表达式进行解析的话,一般总是会有“回溯”,以及拆分出更多的字符串。因此,根据推测,这个做法从性能上来讲应该也有一定优势,不过还是需要真实的性能比较才能得出确切的结果。本文全部代码已经存放在http://gist.github.com/214427中,您可以复制、执行,调试等等。

这次的“趣味编程”是到目前为止最为热闹的一次,在上一篇文章的回复里您还可以发现许多朋友给出的大量解决方案,不过由于时间精力有限,我无法一一浏览了。此外,由于winter-cn已经给出了与我思路接近但实现更好的做法,后来我又用F#实现了另外一个思路不同的版本,您会发现F#有一些语言特性似乎非常适合进行字符串解析工作,它对于我们编写C#代码也有一定的借鉴意义。