ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Go实现PascalCase校验算法:从零手写高效命名检查

Go实现PascalCase校验算法:从零手写高效命名检查 最近在折腾一个Go代码生成器需要把用户填写的字段名直接落成结构体导出字段。第一个版本图省事直接用正则去匹配PascalCase——也就是大家常说的“大驼峰”。结果没用两天就被打脸了“HTTPServer”这类写法到底算不算“Version2”里的数字要不要放行“HELLO”明明首字母大写为什么看着就别扭正则越改越长最后干脆自己写了个几十行的判断算法。这篇就完整拆一下这个算法规则怎么定、源码怎么写、测试怎么补、以后接到项目里要怎么扩展。这个算法本身不大核心就一个for循环但它能解决的问题比想象中多代码生成器里的命名校验、lint工具里的规范检查、API入参的字段名验证全都用得上。文章适合两类人一类是正在写代码生成器或者参数校验急需一个不依赖第三方库的纯函数判断另一类是刚入门Go想看看一个“看起来很简单”的判断逻辑在真实工程里到底要处理多少边界情况。文末会给出完整源码和测试用例可以直接复制到项目里跑。1. 为什么手写一个PascalCase判断从代码生成器被坑说起先说说我遇到的具体问题。我们内部有个工具接收一份字段清单自动生成Go语言的结构体定义。清单来自运营同事填的Excel里面什么写法都有“user_name”“userName”“姓名”“User NAME”混在一起。生成之前第一步就是校验凡是不符合PascalCase的字段要么改清单要么走一层转换。我一开始的直觉是写正则。PascalCase的判断正则其实不难写^[A-Z][a-z]*(?:[A-Z][a-z]*)*$。配上regexp.MustCompile几行代码就完了。这个方案在项目里跑了一周就开始变味原因下面细说。1.1 正则方案的问题先说性能。regexp虽然好用但它不是免费的每次MatchString都要做编译后的状态机匹配短字符串的开销动辄是普通循环的几十倍。如果只是在RPC接口入口偶尔校验一次那无所谓但生成器要循环检查几千个字段每个字段再调用几个不同规则的正则体感就很明显了。更要命的是可维护性。需求一变更正则就变成灾难。比如产品说“数字后缀可以接受”你打算放在词尾规则变成^[A-Z][a-z]*(?:[A-Z][a-z]*)*[0-9]*$那“Hello2World”这种中间夹数字的就会被错误放行。你只能继续叠加更多(?!)之类的负向断言。改到第三轮你自己都说不清这个正则匹配的是什么。还有一个隐蔽的坑Go的regexp基于RE2不支持回溯引用很多看起来能用的正则写出来直接报错或行为诡异。命名校验这种规则清晰、状态有限的问题用正则属于杀鸡用牛刀还砍不干净。1.2 判断算法与转换算法是两回事想清楚之后我做了个界定这里要的是“判断”不是“转换”。判断算法接收一个字符串返回true或false纯函数、无副作用转换算法则是把user_name变成UserName那是另一套逻辑。两者不要混在一起写否则测试都无从写起。判断算法的输入输出很干净输入任意字符串输出布尔值。这样我可以在测试里放几十个用例一行一个true/false跑挂了立刻知道是哪个输入出了问题。这种可测性是正则方案不具备的。也正是因为接口简单我才能放心地把它塞进代码生成器的前置校验阶段。2. 先把规则定死严格模式下什么才算PascalCase动手写代码之前最值得做的事情是先把“什么算PascalCase”写成明确的规则。很多bug不是代码写错而是规则没定义清楚。不同团队对PascalCase的理解出入很大我在这里采用最严格、也最好判定的定义后面第5章再讨论怎么放宽。严格模式的规则可以拆成下面几条每一条都对应算法里的一个判断分支规则点要求反例非空字符串长度必须大于0首字符必须是大写字母A-ZhelloWorld单词边界新单词用大写字母直接标记没有分隔符hello_world词内字符除首字母外单词内其余字母必须是小写字母a-zHELLO禁止字符数字、下划线、连字符、空格、标点等一律不接受Hello2World这套规则的直接推论是任何一个大写字母要么出现在字符串开头要么必须紧跟在前一个单词最后一个小写字母后面。反过来说一旦出现了连续的大写字母例如“HTTPServer”里的“T”、“T”、“P”、“S”就说明某个单词没有“其余小写字母”的部分严格模式下直接判非法。2.1 规则逐条拆解为什么非空空字符串没有词汇结构任何一个命名规范都不会把空串当作合法命名。为什么首字符必须大写PascalCase和camelCase小驼峰的唯一区别就是第一个单词的首字母大小写首字母一旦小写整体变成小驼峰不是我们需要的风格。“词内字符”这条规则是判断的核心。Hello合法HELLO不合法区别就在于第二个字符开始到底是大写还是小写。很多人会误以为“只要首字母大写就算PascalCase”实际上HELLO这种全大写字符串在严格模式下是非法命名因为除了第一个字母之外其他字母全都不符合“词内小写”的要求。“禁止字符”这条规则在工程里争议最大尤其是数字。有些动态语言允许Version2这种写法但在代码生成场景里字段名带数字往往会引出转换歧义Version2到底应该读作Version2还是VersionTwo为了避免这种不确定性我直接把数字和标点全部拒之门外。如果你的项目确实需要数字后缀请在规则层面明确“允许尾随数字”再改算法不要在严格模式里悄悄放行。2.2 常见误判场景踩过的坑简单列几个只判断首字符大小写HELLO首字符大写但不是PascalCase先被情绪带跑了。用strings.TrimSpace之后只检查空格Hello_World没有空格照样非法。用全大写或全小写比较判断strings.ToUpper(s) s会把HELLO判成“合法”方向完全反了。用strings.ContainsAny排除部分字符只能慢慢打补丁规则越补越难看。把这些规则想清楚之后写代码就只是时间问题了。下面是我的实现。3. 核心实现一次扫描、零正则算法核心概括成一句话只要记住“前一个字符是什么”就能在O(n)时间内完成扫描。严格模式的规则3和规则5可以合并成两条转移条件当前字符是大写字母时要求前一个字符是小写字母证明这是一个新单词的合法开始当前字符是小写字母时要求前一个字符是字母证明它处在一个单词内部除此之外的任何字符直接失败。不需要栈不需要回溯不需要状态机表一个for循环加一个“前一个字符”变量就够了。3.1 源码全文下面是完整的pascal.go包名用pascal方便直接放进项目里作为工具包引用。// Package pascal 提供 PascalCase 字符串判断函数。 package pascal // IsPascalCase 返回输入的字符串是否符合严格的 PascalCase 命名规范。 // // 严格模式的定义 // - 字符串非空 // - 首字符必须是大写字母 A-Z // - 每个单词首字母大写单词内其余字母全部小写 // - 单词之间没有分隔符 // - 只允许大小写英文字母不允许数字、下划线、连字符、空格等 func IsPascalCase(s string) bool { if len(s) 0 { return false } if !isUpper(s[0]) { return false } for i : 1; i len(s); i { c : s[i] switch { case isUpper(c): // 大写字母出现只能是新单词的词首 // 新单词必须紧跟在上一个单词末尾的小写字母之后。 if !isLower(s[i-1]) { return false } case isLower(c): // 小写字母出现它前面必须是一个字母 // 如果是大写说明这是词首开的头如果是小写说明还在同一个词内。 if !isLetter(s[i-1]) { return false } default: // 数字、下划线、空格、标点等一切非字母字符直接拒绝。 return false } } return true } func isUpper(c byte) bool { return c A c Z } func isLower(c byte) bool { return c a c z } func isLetter(c byte) bool { return isUpper(c) || isLower(c) }如果你想立刻跑起来把package pascal改成package main再补一个main函数即可。这里给一个命令行示例package main import ( fmt os ) func main() { for _, v : range os.Args[1:] { fmt.Printf(%-20s %v\n, v, isPascalCase(v)) } }编译运行后可以直接传参数go run main.go HelloWorld HELLO helloWorld Hello_World输出会显示只有HelloWorld是true。3.2 逐行拆解为什么“看前一个字符”就够了这段代码不长但几个关键分支值得逐行说清楚。先做空串判断长度0直接返回false这是所有后续下标访问的安全前提。然后检查s[0]是否为大写字母。注意这里用byte下标而不是rune是因为ASCII字母在UTF-8编码下只占一个字节且任意合法UTF-8字符串中ASCII字母的字节值不会出现在多字节字符的中间字节里。所以我可以在纯ASCII判断场景里直接用byte扫描安全且快。循环从i 1开始因为首字符已经在上面处理过了。循环体里的三个分支是对规则最直接的映射case isUpper(c)当前字符是大写字母。它只可能是新单词的词首。新单词要成立它前面必须是某个单词的结尾也就是一个小写字母。所以这里检查s[i-1]是否isLower。“HTTPServer”里的第二个T前一个字符是H大写于是直接返回false。case isLower(c)当前字符是小写字母。它必须紧跟在一个字母后面。如果前一个是小写字母说明它是同一个单词内部的延续如果前一个是说明它是紧跟词首的普通字母。两种情况都合法所以只需要isLetter(s[i-1])。default非字母字符包括数字、下划线、空格、中文等全部走这里返回false。这个分支同时防御了非ASCII输入比如中文字符在UTF-8编码下是多个0x80以上的字节不会落在A-Z/a-z区间自动被拒绝。整个遍历走完都没有返回false说明每个字符都满足“词首大写、词内小写、无分隔符”字符串就是严格的PascalCase。3.3 复杂度和正确性分析时间复杂度O(n)字符串长度是多少就遍历多少次每个字符只访问一次。空间复杂度O(1)只用了几个局部变量没有额外的切片、map或者正则表达式编译缓存。和正则方案对比这版代码没有任何编译期开销也没有回溯。正则的RE2引擎在最坏情况下其实也是线性的但那是针对“正则表达式大小固定”的线性实际匹配过程中要维护DFA状态短字符串上常数因子远高于手写循环。更重要的是正则一旦写错排查起来需要对着正则语法逐字符分析而这版代码的每个分支都对应一条规则出bug时直接看分支条件就能定位。4. 测试驱动验证边界用例全跑一遍算法写完了但我不信任任何没跑过测试的代码。先用Go的table-driven test把核心函数测一遍把能想到的边界情况全部塞进去。4.1 Table-Driven测试package pascal import testing func TestIsPascalCase(t *testing.T) { tests : []struct { name string in string want bool }{ {empty string, , false}, {single upper, H, true}, {single lower, h, false}, {single word, Hello, true}, {two words, HelloWorld, true}, {three words, HelloWorldAgain, true}, {lower first letter, helloWorld, false}, {all upper, HELLO, false}, {upper block in middle, HelloWORLD, false}, {trailing lower, HelloWorldx, true}, {digit inside, Hello2World, false}, {leading digit, 2Hello, false}, {trailing digit, Hello2, false}, {underscore, Hello_World, false}, {hyphen, Hello-World, false}, {space, Hello World, false}, {uppercase acronym, HTMLParser, false}, {mixed acronym, HtmlParser, true}, {chinese chars, 你好, false}, } for _, tt : range tests { t.Run(tt.name, func(t *testing.T) { if got : IsPascalCase(tt.in); got ! tt.want { t.Fatalf(IsPascalCase(%q) %v, want %v, tt.in, got, tt.want) } }) } }这段测试覆盖了三大类合法输入、非法输入、空串和中文字符。合法输入里包含了单单词、双单词、三单词和末尾带额外小写字母的情况非法输入覆盖了首字母小写、全大写、词中出现大写块、各种分隔符和数字。t.Run子测试的好处是挂掉的时候能直接看到是哪个用例失败。4.2 边界结果解释几个容易有疑问的case说明一下。H返回true可能有人觉得奇怪。严格规则只要求“每个单词首字母大写”并没有要求单词内必须有小写字母。单个大写字母H满足所有条件非空、首字符大写、没有非法字符所以是合法的。同理A、I这种单字母词都算true。HTMLParser返回false这是严格模式最典型的误伤场景。四个连续大写字母HTML中间T、T、P、S都紧跟大写字母不满足“词首大写紧跟上一词尾小写”的条件。整个串看起来像一个缩写词接一个正常词但严格模式不认“缩写词”这种特殊形态。如果你需要认HTMLParser这种写法看第5章的宽松模式处理。HelloWorldx返回true因为末尾的x是World单词内部的延续属于合法的小写字母。注意它和HelloWorldX的区别后者末尾变成大写按规则判断X的前一个字符应该是小写字母d这里确实满足isLower所以HelloWorldX也算true。如果最后一个词是以单独大写字母结尾的按照定义其实也说得通这点取决于你的规则是否把单字母词算作合法单词。4.3 简单基准测试性能和正则做个直观对比。下面这段benchmark代码可以放到pascal_test.go里import ( regexp testing ) func BenchmarkIsPascalCase(b *testing.B) { for i : 0; i b.N; i { IsPascalCase(HelloWorld) } } func BenchmarkRegexpPascalCase(b *testing.B) { re : regexp.MustCompile(^[A-Z][a-z]*(?:[A-Z][a-z]*)*$) for i : 0; i b.N; i { re.MatchString(HelloWorld) } }跑go test -bench. -benchmem在我的机器上手写循环的耗时大约是正则方案的几十分之一内存分配为0而正则方案每次匹配都要走RE2的匹配逻辑。如果你的判断逻辑不在热路径上正则的性能差距可以忽略但如果要在循环里检查几千个字段手写循环的优势就是实打实的。需要注意benchmark里的regexp.MustCompile放在函数内只是为了对比每次都重新编译的极端情况。真实项目里如果非用正则不可请把编译结果提到函数外否则热路径上会重复编译慢得更明显。5. 工程化扩展Unicode、宽松模式与接入项目严格模式跑通之后接下来是工程化的问题Unicode字符怎么处理、缩写词要不要放行、怎么把这个函数优雅地接进现有项目。这几个问题我逐个说说。5.1 当输入不再是纯ASCIIUnicode版本上面实现只判断A-Z/a-z。如果你的字段可能是法语、德语这类带重音字母的命名例如Bézier、Éditionbyte扫描会把那些特殊字母的UTF-8字节全部拒掉因为它们不在ASCII区间。要支持Unicode大小写判断可以改用range遍历字符串配合unicode标准库。下面的版本逐个解码rune并保留“前一个rune”作为上下文import unicode // IsPascalCaseUnicode 判断字符串是否符合PascalCase支持Unicode字母。 func IsPascalCaseUnicode(s string) bool { prev : rune(0) first : true for _, r : range s { if first { if !unicode.IsUpper(r) { return false } first false } else { switch { case unicode.IsUpper(r): if !unicode.IsLower(prev) { return false } case unicode.IsLower(r): if !unicode.IsLetter(prev) { return false } default: return false } } prev r } return !first // 至少处理过一个字符 }注意unicode.IsUpper对中文、日文这类没有大小写概念的字符返回false所以这个版本并不会让你好World变成合法输入它仍然会在“你”字上走到default分支返回false。这是符合预期的命名规范通常以字母体系为主中文场景需要单独设计不在PascalCase讨论范围内。这个版本和byte版的区别在于byte版处理“你好”时第一个字节0xE4不在ASCII字母区间直接返回falseUnicode版则在解码出完整rune后同样返回false。两者结果一致但Unicode版能正确处理É、Å这类带重音的大写字母byte版不能。5.2 宽松模式与缩写词处理策略工程里最常被问的问题就是缩写词怎么办HTTP、XML、ID这些词全大写是很多公司命名规范里的合法形态比如XmlParser还是XMLParser不同团队标准完全不一样。严格模式把它们全部拒掉有时候会误伤。我的建议是不要用算法去猜缩写。缩写词没有通用规则微软官方的PascalCase指南都只是建议不同语言社区还有自己的变体。与其在判断函数里堆叠一堆“如果连续大写就放行”的逻辑不如在项目里维护一个缩写词白名单判断时先查表。白名单方案的大致思路var acronyms map[string]bool{ HTTP: true, XML: true, ID: true, IO: true, } // IsPascalCaseWithAcronyms 带缩写词白名单的宽松判断。 // 实现思路遍历时把连续大写字母提取为一个段若段在白名单内则允许否则按严格模式判非法。 func IsPascalCaseWithAcronyms(s string) bool { // 这里只演示思路完整实现需要维护段的起始和结束下标。 // 判断逻辑非空、首字符大写随后扫描时把连续大写字母归为一组 // 白名单命中的组视为一个“缩写单词”继续扫描未命中的直接返回false。 return true }为什么尽量不用“允许所有连续大写”这种一刀切方案因为全大写的HELLO也会被放行而它显然不符合命名习惯。白名单方案的优点是规则明确、可审计哪天团队觉得JSON应该写成Json了改白名单比改判断逻辑容易得多。如果实在不想维护白名单另一个折中方案是参考微软规则两个字母的缩写全大写三个字母以上的缩写只保留首字母大写。例如IOStream合法XmlWriter合法XMLWriter不合法。但这条规则本身也是约定不是标准落地前一定要和团队确认好。5.3 集成到校验工具的落地建议真正把函数接进项目时我习惯再做一层封装让错误信息带上字段名方便定位。import fmt // ValidatePascalCase 对字段名做校验返回带上下文的错误信息。 func ValidatePascalCase(field, value string) error { if !IsPascalCase(value) { return fmt.Errorf(field %q must be PascalCase, got %q, field, value) } return nil }这样在生成器里循环检查字段时一旦出错错误信息能直接告诉用户是Excel里的哪一列有问题而不是一个光秃秃的false。批量处理时建议顺序执行而不是并行校验因为错误信息需要保持原始顺序否则用户面对乱序的报错会很难受。另外别在校验阶段做自动转换。xmlParser到底该转成XmlParser还是XMLParser算法说不清楚硬转必然在某个边界上产出让用户意外的东西。校验就只负责报错转换交给单独的工具并且转换规则要和校验规则保持一致。最后聊一点点个人体会。当初我也纠结了很久要不要把“允许连续大写”加进去后来用几个月的数据回头看发现严格模式几乎没有误伤团队里真正合法的写法都是XmlWriter、IdCard这种没人写XMLWriter。倒是不少Excel里的脏数据靠这个判断被拦了下来省去了后续一堆转换麻烦。所以我的建议是默认不要给缩写开口子等有人提出真实需求、能给出明确的缩写清单时再在白名单表里逐个加。算法简单、规则严格反而会让团队的命名风格更统一。上面的代码可以直接拿走用祝你的项目少踩几个命名校验的坑。
返回列表