1. 从一次数据清洗翻车说起:为什么“匹配整数和小数”远没有想象中简单
前阵子帮一个做工业数据采集的朋友处理一批传感器日志,需求听起来特别简单:从一堆混杂着时间戳、设备编号、温度读数的文本里,把所有的整数和小数提取出来。我当时的反应是,这有什么难的,一个正则下去不就完事了。结果第一版代码跑完,输出结果里混进了日期里的“2024”、IP地址里的“192”、甚至设备型号里的“A3”被拆成了“3”。朋友看着结果哭笑不得,我也意识到,“匹配整数和小数”这个看似入门级的正则需求,实际上藏着非常多的边界情况。
这篇文章就是那次翻车之后,我重新梳理C#正则表达式处理数值匹配的完整总结。核心关键词就一个:C#正则表达式匹配整数和小数。我会从最基础的模式讲起,一路覆盖正负数、科学计数法、千分位分隔符、前后边界控制,再到性能优化和实际项目中的取舍。适合已经会用C#写基本正则、但在数值提取上总是漏掉边界情况的开发者,也适合刚接触正则、想找一个完整参考案例的朋友。你不需要背下所有模式,但看完之后应该能根据自己的数据特点,快速拼出一个靠谱的匹配方案。
先说结论:没有一种“万能正则”能适配所有场景。整数和小数的形态太多了——42、-42、+42、3.14、-0.5、.5、5.、1e10、1.5E-3、1,234.56,甚至还有全角数字和中文语境下的“三点一四”。你的正则长什么样,完全取决于你的数据源长什么样、你要不要捕获符号、要不要允许前导零、要不要处理千分位。下面我按从简到繁的顺序,把每一种情况的考量和写法都拆开讲。
2. 基础模式拆解:整数和小数的最小可用正则
2.1 匹配纯整数的三种写法与取舍
最朴素的整数匹配就是\d+。在C#里写出来是:
string pattern = @"\d+";这行代码能匹配0、7、123、999999,但它的问题也很明显:它会把3.14拆成3和14两段,把-5匹配成5,把abc123def里的123也捞出来。所以\d+只适合一种场景:你明确知道文本里只有独立存在的非负整数,且不需要考虑边界。
稍微严谨一点,如果你要匹配一个“独立的整数”,需要加上边界控制。C#正则支持\b单词边界:
string pattern = @"\b\d+\b";\b的含义是“单词字符和非单词字符之间的位置”。数字属于单词字符,所以\b\d+\b能保证数字前后不是字母或下划线。实测下来,abc123不会被匹配,但abc 123 def里的123会被匹配。这里有个坑:\b对中文的处理取决于正则引擎的Unicode设置,在C#默认模式下,中文汉字被视为非单词字符,所以温度25度里的25是能被\b\d+\b匹配到的,这一点比很多其他语言的正则要友好。
如果你要匹配带符号的整数,模式要扩展成:
string pattern = @"[+-]?\d+";[+-]?表示正号或负号出现零次或一次。注意这里我把+放在字符类的最前面,避免它被解释为量词。这个模式能匹配+42、-42、42。但如果你只想要负号不想要正号,就写成-?\d+。
注意:
[+-]?\d+在匹配3-5这样的表达式时,会把-5整体匹配走,而不是匹配3和-5。如果你的文本是数学表达式,这个行为可能不符合预期,需要额外处理。
2.2 小数匹配的核心难点:小数点前后的可选性
小数的正则比整数复杂,因为小数点前后的数字都可能省略。先看最严格的写法,要求小数点前后都有数字:
string pattern = @"[+-]?\d+\.\d+";这个模式匹配3.14、-0.5、+2.0,但不匹配.5和5.。在大多数业务场景里,这种严格写法是首选,因为.5和5.在正式数据中很少见,而且严格模式能避免把句号误判为小数点。比如我买了3个。这句话,严格模式不会把3。里的句号当成小数点,因为中文句号是全角字符,和半角.不同——但如果文本里混入了英文句号,比如version 3.后面跟空格,严格模式就不会匹配,这是好事。
如果你需要兼容.5这种省略整数部分的写法,模式要改成:
string pattern = @"[+-]?(?:\d+\.\d+|\.\d+)";这里用了非捕获组(?:...)和分支|。\d+\.\d+处理常规小数,\.\d+处理省略整数部分的情况。注意我没有加\d+\.这个分支,因为5.这种写法在数据里通常是句子结尾或缩写,误匹配风险太高。如果你确实需要匹配5.,可以加上,但一定要配合后续的边界控制。
2.3 整数和小数合并匹配的推荐模式
实际项目里最常见的需求是“整数和小数都要,统一提取”。这时候推荐的基础模式是:
string pattern = @"[+-]?\d+(?:\.\d+)?";拆开看:[+-]?可选符号,\d+必须有的整数部分,(?:\.\d+)?可选的小数部分。这个模式能匹配42、-42、3.14、-0.5,且不会匹配.5和5.。我在多个数据清洗项目里用的都是这个版本,它在“覆盖常见情况”和“避免误匹配”之间取得了很好的平衡。
用C#代码验证一下:
using System; using System.Text.RegularExpressions; class Program { static void Main() { string pattern = @"[+-]?\d+(?:\.\d+)?"; string input = "温度-3.5度,湿度42%,电压+220V,误差0.01"; foreach (Match m in Regex.Matches(input, pattern)) { Console.WriteLine(m.Value); } } }输出结果是-3.5、42、+220、0.01。注意+220带上了正号,如果你不想要符号,可以在匹配后用TrimStart('+')处理,或者把模式里的[+-]?改成-?。
3. 进阶场景:科学计数法、千分位与前导零的处理
3.1 科学计数法的完整匹配模式
传感器数据、金融报表、科研数据里经常出现1.5e-3、2E10这种科学计数法。完整的匹配模式是:
string pattern = @"[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?";在基础模式后面加了(?:[eE][+-]?\d+)?,表示可选的指数部分。[eE]兼容大小写,[+-]?是指数的符号,\d+是指数的数字。这个模式能匹配1.5e-3、2E10、-3.2e+5,也能匹配普通的42和3.14。
这里有个细节值得说:科学计数法的指数部分不允许有小数点,所以1.5e2.3不会被完整匹配,只会匹配到1.5e2。这是符合科学计数法规范的,因为指数必须是整数。如果你遇到1.5e2.3这种畸形数据,说明数据源本身有问题,正则不应该去兼容它。
3.2 千分位分隔符的处理策略
金融数据里1,234.56这种写法很常见。直接匹配的话,基础模式会把1和234.56拆开。要完整匹配,模式需要扩展:
string pattern = @"[+-]?\d{1,3}(?:,\d{3})*(?:\.\d+)?";\d{1,3}匹配开头1到3位数字,(?:,\d{3})*匹配零个或多个“逗号加三位数字”的组合。这个模式能匹配1,234.56、12,345,678、999。但它有个已知缺陷:1234这种没有千分位的四位数,会被\d{1,3}匹配成123,然后(?:,\d{3})*匹配零次,最后剩下4匹配不到。所以这个模式只适合确定有千分位分隔符的数据。
如果你要同时兼容有千分位和无千分位的情况,需要用分支:
string pattern = @"[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?";\d{1,3}(?:,\d{3})+要求至少有一组千分位,\d+处理没有千分位的情况。这样1,234.56和1234.56都能正确匹配。实测下来这个模式在混合数据里表现很稳,但正则长度上去了,可读性下降,建议加注释说明。
3.3 前导零要不要保留:一个业务决策问题
007这种前导零,在正则层面\d+会完整匹配。但匹配到之后要不要保留,是业务问题不是技术问题。设备编号007和数值7含义完全不同。我的做法是:正则负责完整提取,后处理负责业务判断。如果字段明确是数值,匹配后用int.Parse或decimal.Parse转换,前导零自然消失;如果字段是编号,就保留字符串原样。
有一种情况需要正则层面处理:如果你要匹配的是“规范数值”,不允许前导零(除了0本身),模式要写成:
string pattern = @"[+-]?(?:0|[1-9]\d*)(?:\.\d+)?";(?:0|[1-9]\d*)表示要么是单个0,要么是1到9开头后面跟任意数字。这样007只会匹配到0和7两段,而不是007。这个模式在解析严格格式的配置文件时很有用。
4. C#正则实操:从匹配到提取的完整代码实现
4.1 Regex.Matches与MatchCollection的正确用法
C#里提取所有匹配项的标准做法是Regex.Matches:
using System; using System.Text.RegularExpressions; using System.Collections.Generic; class NumberExtractor { private static readonly Regex NumberRegex = new Regex( @"[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?", RegexOptions.Compiled); public static List<decimal> ExtractNumbers(string input) { var results = new List<decimal>(); foreach (Match match in NumberRegex.Matches(input)) { if (decimal.TryParse(match.Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out decimal value)) { results.Add(value); } } return results; } }这段代码有几个关键点。第一,Regex对象被声明为static readonly并加了RegexOptions.Compiled,避免每次调用都重新编译正则。第二,用decimal.TryParse而不是double.Parse,因为decimal在金融和工业数据里精度更可靠,double会有浮点误差。第三,指定了CultureInfo.InvariantCulture,避免在某些区域设置下小数点被解析成千分位。
提示:
RegexOptions.Compiled会增加首次编译的开销,但如果你的正则要执行成千上万次,整体性能提升非常明显。实测在10万次匹配的场景下,Compiled模式比非Compiled快大约30%到40%。
4.2 用命名捕获组区分整数和小数
有时候你不仅想提取数值,还想知道它是整数还是小数。用命名捕获组可以做到:
string pattern = @"(?<sign>[+-]?)(?<integer>\d+)(?<decimal>\.\d+)?";匹配之后可以这样判断:
foreach (Match m in Regex.Matches(input, pattern)) { string sign = m.Groups["sign"].Value; string integerPart = m.Groups["integer"].Value; string decimalPart = m.Groups["decimal"].Value; bool isDecimal = decimalPart.Length > 0; Console.WriteLine($"符号:{sign} 整数:{integerPart} 小数:{decimalPart} 是小数:{isDecimal}"); }命名捕获组的好处是代码可读性高,不用靠索引Groups[1]、Groups[2]去猜。缺点是正则稍微长一点,匹配性能有极微小的影响,但在业务代码里可读性优先。
4.3 边界控制的实战:避免匹配到日期和版本号
回到开头那个翻车案例,日志里的2024-01-15被拆出了2024、01、15。要避免这种情况,需要在正则前后加否定环视:
string pattern = @"(?<![\d\-])([+-]?\d+(?:\.\d+)?)(?![\d\-])";(?<![\d\-])是负向后顾,表示匹配位置前面不能是数字或连字符。(?![\d\-])是负向前瞻,表示匹配位置后面不能是数字或连字符。这样2024-01-15里的2024后面跟着-,会被排除;01前后都是-,也会被排除。而独立的42和3.14不受影响。
负向环视是C#正则里非常强大的工具,但要注意环视不消耗字符,所以多个环视可以叠加。另外,环视里的字符类要仔细设计,加多了会误杀,加少了会漏网。我的经验是:先列出你不想要的相邻字符,把它们放进环视的字符类里,然后用真实数据跑一遍看结果。
5. 性能与避坑:那些文档里不会写的经验
5.1 回溯失控:一个让程序卡死的正则写法
有一种正则写法会导致灾难性回溯,让程序CPU飙满然后卡死。比如:
string badPattern = @"(\d+)+";这个模式在匹配123456789时,理论上有很多种分组方式,正则引擎会尝试所有组合,导致指数级的时间消耗。虽然(\d+)+看起来和\d+等价,但性能天差地别。我实测过,用(\d+)+匹配一个20位的数字字符串,耗时超过2秒,而\d+是微秒级。
避免回溯失控的原则是:不要嵌套量词。(\d+)*、(\d*)+、(\d+)+都是危险写法。如果你需要分组,用非捕获组(?:\d+)并且确保量词不嵌套。另外,C#的Regex支持设置超时:
var regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1));设置超时后,如果匹配超过1秒会抛RegexMatchTimeoutException,至少不会让整个程序卡死。这个技巧在处理不可信输入时特别重要。
5.2 全角数字与Unicode数字的坑
C#的\d默认匹配的是Unicode数字,包括全角数字0123和阿拉伯-印度数字٠١٢٣。如果你只想匹配半角数字0-9,要用[0-9]而不是\d。这个差异在中文数据里特别明显:温度25度里的全角25会被\d+匹配到,但int.Parse不一定能正确解析。
我的建议是:明确用[0-9]代替\d,除非你确实需要兼容全角数字。这样代码意图更清晰,也避免了后续解析的意外。如果数据里确实有全角数字,先做一次字符规范化,把全角转半角,再用正则提取。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日期被拆成多个数字 | 缺少边界控制 | 加负向环视(?<![\d\-])和(?![\d\-]) |
.5匹配不到 | 模式要求小数点前有数字 | 加分支\.\d+ |
5.被误匹配 | 模式允许小数点后无数字 | 确保小数部分为\.\d+而非\.\d* |
| 千分位数据被拆开 | 模式不支持逗号 | 用\d{1,3}(?:,\d{3})* |
| 程序CPU飙满 | 回溯失控 | 避免嵌套量词,设置匹配超时 |
| 全角数字被匹配 | \d匹配Unicode数字 | 改用[0-9] |
| 科学计数法只匹配一半 | 缺少指数部分 | 加(?:[eE][+-]?\d+)? |
| 正号被保留 | 模式包含[+-]? | 改用-?或匹配后Trim |
5.4 一个我踩过的坑:RegexOptions.IgnorePatternWhitespace
有一次我写了一个很长的正则,为了可读性加了空格和注释,然后用了RegexOptions.IgnorePatternWhitespace。结果匹配全部失败。排查了半天才发现,这个选项会让正则里的所有空白字符被忽略,包括字符类里的空格。如果你的模式里有[ ]想匹配空格,开了这个选项后[ ]会变成空字符类,导致匹配异常。解决办法是用\s或\x20代替字面空格。这个坑文档里有写,但很容易忽略,我那次花了两个小时才定位到。
6. 不同数据场景下的模式选型建议
6.1 工业传感器日志:优先严格模式
工业日志里的数值通常格式规范,但混杂着时间戳和设备ID。推荐模式:
string pattern = @"(?<![\d\-:.])([+-]?\d+(?:\.\d+)?)(?![\d\-:.])";在环视里加了冒号和小数点,避免匹配时间12:34:56里的数字和IP地址里的点分数字。这个模式在我处理的温度、湿度、压力数据里表现很稳,误匹配率极低。
6.2 金融报表:必须处理千分位和括号负数
金融数据里负数有时用括号表示,比如(1,234.56)表示负的1234.56。这种场景需要单独处理:
string pattern = @"[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?";先提取出数值,然后在后处理里判断是否被括号包裹。如果被括号包裹,取负值。正则本身不处理括号,因为括号在报表里还可能是其他含义,交给业务逻辑判断更安全。
6.3 用户输入的自由文本:宽松模式加后验证
用户输入是最不可控的,可能有3.14、3.14、三点一四、3,14(欧洲写法)。这种场景我的策略是:正则用宽松模式先捞出来,然后用decimal.TryParse做后验证,解析失败的丢弃。正则模式用最基础的[+-]?\d+(?:[.,]\d+)?,把逗号和点都当作可能的小数点,后续根据区域设置决定哪个是小数点。
提示:欧洲一些地区用逗号作小数点,
3,14表示3.14。如果你的应用面向多区域用户,正则里要把[.,]都考虑进去,然后在解析时根据CultureInfo判断。
7. 收尾:我个人在实际项目中的几点体会
正则匹配整数和小数,写出来只要一行,但写好需要理解数据。我现在的习惯是:拿到数据先抽样看100条,把里面所有数值形态列出来,再决定正则怎么写。不要一上来就写一个“万能正则”,那东西不存在,强行写只会又长又慢还容易出错。
另外,正则提取出来的字符串,一定要用TryParse做一次验证。正则只能保证“长得像数字”,不能保证“是有效数字”。999999999999999999999999能匹配\d+,但int.Parse会溢出。用decimal.TryParse加NumberStyles.Float是最稳妥的组合,能覆盖绝大多数业务场景。
最后分享一个小技巧:如果你要频繁匹配同一批模式,把Regex对象缓存起来,用static readonly字段或者ConcurrentDictionary做缓存。我见过太多代码在循环里new Regex(...),性能差得离谱。这个优化改动量极小,但效果立竿见影。