当你的常量受他人法规制约:应对策略
曾有同事问我,为什么我代码里的一个数字配了三行长的注释。那个数字是 18。
注释里写明了这个数字出自哪条法规、由哪个条款规定,还提醒它可能随时变更。当时我觉得,给一个两位数整数搞这么大阵仗实在小题大做——直到我想起另一种写法的下场。
在大多数软件里,常量代表你自己做的决定。而在实现法规的软件里,常量代表的是别人做的决定——对方可以不经通知就改掉它,而且这个值还有一个法律上的"正确答案",你可能从一开始就没读对。
这两点我都是在开发一套金融计算器时吃了亏才明白的。下面说说当时踩过的坑,以及如今我在编写任何"规则由他人掌控"的代码时坚持的三个习惯。
本文面向那些编写"实现他人规则"代码的开发者,比如税务、薪资、福利、信贷,或任何数字由监管机构或标准组织设定、且对方可以不征求你意见就修订的领域。如果你曾经硬编码过一个后来变了的比例,这篇文章就是为你写的。
前置要求:
你需要能看懂 JavaScript 和 Go 的简短代码片段。两种语言都会在文中出现,但仅作示例。本文内容不要求深入了解其中任何一门语言。
我不假设你有任何金融或税务背景,所有领域术语首次出现时都会解释。示例来自印度 GST,因为我是そこ踩坑的,但这三种失败模式都不限于印度、不限于税务,也不限于任何特定司法辖区。
本文内容:
返回"看似合理"数字的 Bug
先从最糟糕的一种说起,因为它压根看起来不像个 Bug。
在印度,如果你逾期缴纳商品与服务税(GST),就需要支付利息。最直觉的实现是:用应缴税额乘以年利率,再按逾期天数折算。
const interest = taxOwed * (ratePct / 100) * (days / 365);
这段代码在算术上无懈可击。但在大量实际场景中,计算结果却可能高出好几倍。
原因在于,“欠税”并不是一个单一的数值。GST 申报表包含总产出税负(gross output liability),即销售商品总额对应的税款。但大多数企业会通过进项税抵扣(input tax credit)抵消其中大部分,进项税是指他们自身采购时已缴纳的税款。只有剩余部分才需以现金支付。而根据管辖该事项的法规,利息是基于实际以现金支付的税款计算的,而非基于总负债。
因此,我们拥有相同的公式、相同的利率,却有两个不同的输入值。其中一个就是错的:
逾期 90 天,年利率 18%
基于实际现金支付 (₹50,000) → ₹2,219
基于总负债 (₹3,00,000) → ₹13,315
利息相差六倍。这两个数值都是公式返回的结果。两者都没有抛出异常,乍看之下也都不显得荒谬。如果你是一名开发者,你的测试可能会通过,因为你和代码基于同样的误解编写了测试用例。
这正是合规代码与普通代码不同的失效模式。常规 Bug 会产生堆栈跟踪,或者产生错误得让人一眼就能发现的数字。而这类错误会产生数值形态正确、数量级合理,却自信满满地错误的结果。
真正的解决方案
更严格的测试无法解决此问题,更好的文档注释才行。
修复方法是在定义处明确写明,参数代表哪个具体量,以及该要求的来源:
// GSTInterest 返回 GST 逾期支付的利息。
//
// 根据规则 88B(1),利息基于从电子现金账簿中实际扣除的税款计算——
// 而非基于总产出税负。若搞错此项,会导致利息计算过高,
// 往往偏高数倍。请传入 cashTaxPaid 作为现金账簿部分,而非总账单。
//
// 除非公告利率已调整,否则将 annualRatePct 设为 DefaultGSTInterestRate;
// 目前根据第 50(1) 条,公告年利率为 18%。
func GSTInterest(cashTaxPaid float64, days int, annualRatePct float64) float64
注意,参数名为 cashTaxPaid,而非 amount。这个命名本身就是完整的防线。当调用者手中持有总账单时,这个名称会迫使他们停下来思考:这是否是正确的数值(这恰恰是防止 Bug 的关键停顿)。
我想更进一步:如果某个参数容易与类似数值混淆,命名时必须从根上杜绝这种歧义。 amount 极易引发误用,而 cashTaxPaid 则彻底排除了这种可能。
你睡觉时悄悄变化的常量
第二种失效模式更隐蔽,也更令人尴尬。
税级随年度预算调整,法定上限通过公告修订。犒赏限额可能多年保持同一数值,随后却突然改变。这些变化从不关心你的发布日程。
一种诱人的实现方式是定义常量:
const gratuityCeiling = 2000000
现在想想这种方案伤害了谁。不只是看到过时数值的用户,还有开发者——他们在周二发现数值已更新,却发现该值被硬编码在依赖库中,除了提 Issue 等待别无他法。
还有一个更微妙的问题。法定数值往往不是单一的。它们只是对大多数人而言是单一值。同样的上限对部分政府职员更高。同样的最低服务年限对固定期限员工更短。一个隐藏的常量不仅可能过时,而且对整整一类用户永远都是静默错误的。
因此:将法定数值设为带默认值的选项,而非隐藏常量,并附上文档说明。
// GratuityOptions 承载那些随公告变化的法定数值。
// 零值意味着"使用默认值"。
type GratuityOptions struct {
Ceiling float64 // 0 -> 20,00,000。中央政府部门文职人员用 25,00,000。
MinYears float64 // 0 -> 5。固定期限员工用 1。
}
零值仍能为常见场景做对事,使用户无需付出复杂度成本即可上手。但一旦数值变动,调用方可以自行修正——立即生效,无需等待我发布新版本。
最后那句是关键。当你的代码编码了你无法控制的规则时,你的用户需要一个不经过你发布流程的覆盖机制。
活在表单里的校验
第三种情况在一次重构中暴露,也是最通用的一种。
我写过一个函数,用来生成贷款分期还款表:按还款期逐月推进,每月生成一行。在应用内部,这是安全的。表单有 max 属性,UI 会限制输入。没人能申请一笔 200 年的贷款,因为界面不允许。
后来我把计算逻辑抽成了一个库,表单就不复存在了。
rows, _ := Amortisation(principal, rate, months) // months 从哪来的?
这个循环唯一的边界就是传进来的还款期。遇到一个离谱的值,它不会返回错误结果,也不会运行缓慢——它只是一直分配内存,直到调用方的进程崩溃。我的 bug,变成别人的 OOM 崩溃,而且发生在一个我从没见过的程序里。
修复方法不体面但有效:把校验放进函数里。
const MaxMonths = 1200 // 100 年
rows, err := Amortisation(principal, rate, 2000)
// err: months must be at most 1200, got 2000
这个上限故意设得离谱。市面上实际存在的最长期房贷是 30 到 40 年,所以 100 年这个门槛只会在毫无意义的输入上触发。这正是这类校验该有的性质:它应该不可能被误触,因此永远不会变成一个有人来跟你争辩的限制。
更普遍的教训不止于此。当你把某个库从应用里抽出来时,UI 之前默默替你做的一切输入校验,都会成为你的责任——而且是以别人程序崩溃的形式。
三个习惯
如果你的代码实现的是别人定下的规则,那么从现在起就该养成三个习惯:
1. 在文档注释里写明权威来源。
不要只写"利率"。要写清楚是哪条规则、哪个章节,并注明规则可能变动。上面那段注释明确指出了 Rule 88B(1) 和 Section 50(1),这样任何审查我代码的人都知道该对照什么去查,不同意的人也可以自己去读原始条文,而不是选择相信我。
2. 把法定数值做成带默认值的选项,而不是常量。
零值覆盖常见场景,可覆盖的参数应对法律变更的那一天,以及那些默认值从来就不适合的用户。
3. 把校验从表单挪到函数里。
UI 在做的任何校验,都意味着你的库没在做。要假设下一个调用方根本没有 UI。
这些都不算什么稀罕事。这不过是「不信任输入」这一常规纪律的延伸,只不过应用的领域里,输入的定义由他人制定,并按他们的时间表修订。
之所以值得单独点名,是因为该领域有其特有的失效特征:没有异常,没有堆栈追踪,也没有明显错误的输出。只有一个数值,格式看似正常,却大了六倍,正静静躺在某份即将被人据此行动的报告中。
总结
归结起来,要点如下。
实现法规的代码有其独特的失效特征:没有异常或堆栈追踪,只有一个看似正确、实则自信满满地错误的数值。
我们看到了三种情形:一个参数悄无声息地指向与法规意图不同的量;一个法定值被冻结在常量中,后来法规变动了;以及当逻辑被提取到库中时,校验逻辑却留在了 UI 里。
三种习惯依次对应这三种失效。在定义处标明权威来源,让后来的读者知道该对照什么来检查你的工作。将法定值暴露为带默认值的选项,这样当法规变动时,你只需修改参数,而无需去搜索字面量。最后,把校验逻辑放入函数内部,因为下一个调用者可能根本没有任何 UI。
如果你只记住一点,那就记住命名。 amount 会诱发 bug,cashTaxPaid 则会杜绝它。参数名是你写过的最便宜的文档。
感谢阅读!
我开发了 EMICalcs,这是一套面向印度的免费金融计算器——涵盖贷款、SIP、GST、奖金和税务。上述公式取自其中,并以开源库的形式发布,因此任何想要核查我工作的人都能审计其中的算术逻辑。