摘要:描述正则表达式在项目中的实际应用,介绍如何利用它来解析字符串。
一、一点背景
谌总和老谭这两个人,有不少相似的地方。比如说,在软件设计和实现的时候,都希望系统的逻辑能清晰地呈现出来,也就是说,使软件具备清晰的结构。
但这一次,老谭走向了自己的反面。
讨论的是公式的管理问题。项目中的节点量、指标值等数据的计算,都会用到公式。公式中包含四则运算,也可以用括弧提高运算的优先级,参加运算的对象都是系统中的其他数值,如罐量、仪表量之类。
谌总建议将公式的结构保存在数据库中,一个公式用到若干记录和字段,这样便于了解相互之间的引用关系。老谭则建议干脆一个公式一个字符串,用名字标识要引用的对象。由于老谭是具体干活的,谌总也乐于这样。
于是,定义了公式的格式,如下面的例子所示:
($B'1车间一号表'+10-$G'一罐区二号罐'+$J'N-213号节点')*5
其中 $ 是引导符,B、G、J是对象类型,分别表示仪表、罐、节点(用汉语拼音总是觉得有点土,是么?),后面跟上用引号引起来的对象名称。
计算公式值的思路是,将公式中的对象找出来,用对象的值替换后,得到一个纯数字的计算公式,再找一个工具来计算这个公式的值。
二、最初的做法
现在的问题是怎么解析公式,找出对象,再用对象的值替换公式的内容。
这活儿交给了玉龙,他最初采用的是传统的切割字符串的做法:
private double EvaluateFormula(string formula)
{
foreach (var items in formula.Split('+'))
{
foreach (var item in items.Split('-'))
{
if (item.Trim()[0] != '$')
{
continue;
}
var name = item.Replace("$", string.Empty);
string[] s = name.Split('\'');
switch (s[0])
{
case "b":
formula = formula.Replace(item, GetMeterValue(s[1]));
break;
case "g":
formula = formula.Replace(item, GetTankValue(s[1]));
break;
case "j":
formula = formula.Replace(item, GetNodeValue(s[1]));
break;
}
}
}
return EvaluateExpression(formula);
}
实现的思路是,用运算符(加号和减号)切分字符串,在切分结果中去掉引导符($)得到所引用的对象的类型和名称,据此获得对象的值。最后,通过调用第三方的函数计算替换后的表达式的值。
这种方案的缺陷是明显的。首先,只考虑了部分运算符(加号和减号),其次,没有考虑括弧;再次,没有考虑对象名称中有运算符的问题;……设想一下处理了这些问题,实现会变得比这个复杂得多。
老谭建议用正则表达式解析公式。
三、利用正则表达式
利用正则表达式,可以从公式中提取所关注的对象,称参与运算的对象为公式项。获取公式项的值后,用它替换公式中的内容。为此,首先要确定公式项的模式(Pattern)。
3.1 公式项的模式
在前面所给出的公式例子(($B'1车间一号表'+10-$G'一罐区二号罐'+$J'N-213号节点')*5))中,有三个运算对象,即三个公式项,它们的模式为:
\$[B|G|J]'.+'
其中,\$ 是原来的引导符,由于$是正则表达式中的保留字,需要转义;[B|G|J]表示B、G、J三者之一,.+表示一个或多个任意字符。
由于我们要提取公式项中的内容,需要建立分组,方法同上一篇《在VS 2012中使用正则表达式 》介绍的一样,用括号将要提取的内容括起来。我们要提取的内容包括两方面:对象类型以及对象名称:
\$([B|G|J])'(.+)'
使用正则表达式时要注意一个很隐蔽的问题。如果用上面的模式去匹配公式例子,我们本希望能匹配成功三次,匹配上的字符串分别是三个公式项,即:
$B'1车间一号表'
$G'一罐区二号罐'
$J'N-213号节点'
但实际上只匹配成功一次,匹配上的字符串是:
$B'1车间一号表'+10-$G'一罐区二号罐'+$J'N-213号节点'
其中,\$([B|G|J]) 匹配到 “$B”,这没有问题。但 (.+) 匹配到 “1车间一号表'+10-$G'一罐区二号罐'+$J'N-213号节点”。出现这个问题的原因,是正则表达式用了贪婪匹配算法,它总是设法匹配到最多的内容。阻止贪婪匹配的方法是在分组中加上问号运算符:
\$([B|G|J])'(.+?)'
3.2 实现公式解析
利用正则表达式求解公式的代码如下:
private const string FormulaItemPattern = @"\$([B|G|J])'(.+?)'";
private double EvaluateFormula(string formula)
{
foreach (Match match in new Regex(FormulaItemPattern).Matches(formula))
{
var item = match.Groups[0].ToString();
var type = match.Groups[1].ToString();
var name = match.Groups[2].ToString();
switch (type.ToLower())
{
case "b":
formula = formula.Replace(item, GetMeterValue(name));
break;
case "g":
formula = formula.Replace(item, GetTankValue(name));
break;
case "j":
formula = formula.Replace(item, GetNodeValue(name));
break;
}
}
return EvaluateExpression(formula);
}
首先根据模式创建正则表达式,即Regex对象,利用该对象的Matches方法获取公式中符合该模式的所有匹配(顺便鄙视一下.NET的命名问题,这时候你把Match当作名词还是动词用呀?)。对于每个匹配,获取我们所需要的信息,将公式项的值计算出来,用值计算替换项。
与字符串拆分的方法比起来,使用正则表达式有很多优势。我们只需要关注感兴趣的内容(公式项),把公式中的其他内容排除在代码之外,如不必理睬运算符中是否还包含乘除,是否还包含括弧,等等。这样不仅解决了上段代码中存在的问题,还使得这个版本的代码更简洁。
注意从每次匹配中获取分组中内容的方法。Groups[0]表示整个模式匹配到的内容,即公式项本身,如 $B'1车间一号表'。Groups[i],其中 i 大于0,则表示模式中第 i 个括弧中的内容。为了防止被序号搞晕,还可以对分组命名。
private const string FormulaItemPattern = @"\$(?<type>[B|G|J])'(?<name>.+?)'";
private double EvaluateFormula(string formula)
{
foreach (Match match in new Regex(FormulaItemPattern).Matches(formula))
{
var item = match.Groups[0].ToString();
var type = match.Groups["type"].ToString();
var name = match.Groups["name"].ToString();
...
}
这样修改后,我们过上了幸福的生活,直到有一天……
四、产品组要求修改格式
我们有一个神秘的组织,叫产品组。这天该组织突然发布通知,说 $ 符号已作他用,不能当作本项目的引导符,而应当用 #。而且,类型和对象名称之间应当用半角句号分割开来。原来的公式
($B'1车间一号表'+10-$G'一罐区二号罐'+$J'N-213号节点')*5
应当变成这个样子的:
(#B.'1车间一号表'+10-#G.'一罐区二号罐'+#J.'N-213号节点')*5
不管这样的要求有没有道理,开发方面遵照执行。用了正则表达式,面对这样的修改要求,变得简单了,只需要修改公式项模式即可:
private const string FormulaItemPattern = @"#(?<type>[B|G|J])\.'(?<name>.+?)'";
这样修改后,我们又过上了幸福的生活。
五、总结
实际项目中,关于公式定义的故事还有续集。公式项模式的定义,以及代码处理,也远比这里复杂。但关于正则表达式的使用,核心内容都提到了。
再喊一遍口号吧:使用正则表达式解析字符串,可以使代码编得更为简洁,可以灵活应对情况的变化,可以使程序员的生活不那么苦逼。
六、不算题外话
有一天,玉龙问老谭:“博士,上次您说的我忘了,怎么用正则表达式表示一个或多个字符呀?”
老谭没有回答他的问题,反问要用正则表达式做什么。玉龙说要拆分这样的字符串:ABC/XYZ,得到用斜杠分开的前后两部分。
那用string.Split不是更简单么?玉龙则回答:“是您说的一定要用正则表达式呀?”
老谭真不记得这样说过。