当你的“常数”实际上其实是别人的法则时,该怎么办?
有一次,一位同事问我,为什么我的代码中有一个长达三行的注释。那个数字就是 18 。 那条注释说明了这个数字来源于哪条规则、哪个部分规定了它的使用方式,同时还提醒人们这个数值可能会在未经通知的情况下发生变化。对于一个两位数的整数来说,这样的注释似乎有些过分了——直到我想起了另一种情况。 在大多数软件中,常量其实是开发者自己做出的决定;但在那些需要遵守相关法规的软件系统中,常量往往是由 其他人 制定的,他们可以随时更改这些常量的值,而不会通知开发者。而且,这些常量的数值往往具有法律上的权威性,但开发者可能并不完全理解它们的含义。 我在开发一套金融计算器的时候,才深刻体会到了这两点。下面我就讲讲当时
有一次,一位同事问我,为什么我的代码中有一个长达三行的注释。那个数字就是18。
那条注释说明了这个数字来源于哪条规则、哪个部分规定了它的使用方式,同时还提醒人们这个数值可能会在未经通知的情况下发生变化。对于一个两位数的整数来说,这样的注释似乎有些过分了——直到我想起了另一种情况。
在大多数软件中,常量其实是开发者自己做出的决定;但在那些需要遵守相关法规的软件系统中,常量往往是由其他人制定的,他们可以随时更改这些常量的值,而不会通知开发者。而且,这些常量的数值往往具有法律上的权威性,但开发者可能并不完全理解它们的含义。
我在开发一套金融计算器的时候,才深刻体会到了这两点。下面我就讲讲当时出了什么问题,以及我现在在处理那些涉及他人制定的规则的代码时所遵循的三个习惯。
这篇文章是为那些需要编写遵守他人制定规则的代码的开发者们准备的,比如处理税务、工资发放、福利计算、贷款业务等方面的代码。如果你曾经将某个费率硬编码到代码中,而后来这个费率发生了变化,那么这篇文章肯定适合你阅读。
先决条件:
你需要掌握足够的JavaScript和Go语言知识,才能理解下面的示例代码。这些语言知识在这里只是用于举例说明,并不需要你深入掌握它们。
我假设读者没有任何金融或税务方面的背景知识,因此会在每次出现相关术语时进行解释。这些例子都是以印度的GST系统为例的,因为我在使用该系统的时候遇到了这些问题,但所描述的三种错误情况并不局限于印度、税务领域或任何特定的司法管辖区。
我们将涵盖的内容:
会返回看似合理的数值的错误
让我们先从最糟糕的那种情况开始讨论,因为这种错误看起来根本不像是编程错误。
在印度,如果延迟缴纳商品和服务税,就需要支付利息。通常情况下,计算利息的方法是:将应缴税款乘以年利率,再根据延迟的天数进行相应的调整。
const interest = taxOwed * (ratePct / 100) * (days / 365);
从数学角度来看,这样的代码没有问题;但在很多实际情况中,计算出的利息会高出实际应该支付的金额好几倍。
原因在于,“应缴税款”这个数值其实并不是一个固定的数字。在GST系统中,企业需要申报的总税款包括其销售的所有商品和服务所对应的税额。但是,大多数企业都会用已支付的进项税来抵扣这部分税款,因此实际上只需要支付剩余的部分。而根据相关规则,利息是按照实际支付的现金税款来计算的,而不是根据总税款来计算的。所以我们使用的是相同的公式、相同的利率,但输入的数据却有两种不同的情况。其中一种输入是错误的:
如果延迟90天支付,年利率为18%:
对于实际支付的现金金额(₹50,000),计算结果为 ₹2,219;
对于总负债金额(₹3,00,000),计算结果为 ₹13,315。
这两个计算结果都符合公式的计算规律,也没有出现明显错误。如果你是代码的编写者,你的测试也会通过,因为这些测试也是基于同样的误解设计的。
这种错误类型恰恰体现了监管代码与普通代码的区别:普通的错误会生成明显的错误信息或错误的数值,从而被人发现;而这种错误虽然计算结果“看起来”正确,但实际上却是错误的。
究竟什么才能真正解决这个问题
更完善的测试并不能解决这个问题,但更好的文档注释却能起到作用。
正确的解决办法是在参数的定义处明确说明该参数代表的是哪个具体的数值,以及这一要求的具体来源:
// GSTInterest函数用于计算延迟缴纳的GST税款所产生的利息。
//
// 根据规则88B(1),利息应按照从电子现金账簿中实际扣除的税款来计算,
// 而不是根据总负债金额来计算。如果计算错误,得出的利息数值会大大偏高。
// 参数cashTaxPaid表示实际支付的现金金额,annualRatePct表示年利率。
func GSTInterest(cashTaxPaid float64, days int, annualRatePct float64) float64
注意,这个参数被命名为cashTaxPaid,而不是amount。这样的命名方式能够有效避免误解:如果调用者手头有总负债金额,他们就会停下来思考这个数值是否正确,从而及时发现错误。
我还要进一步强调:如果某个参数容易与类似的数值混淆,那么它的名称就必须确保这种混淆不可能发生。amount>这个名称很容易导致错误,而cashTaxPaid>则能有效防止这种错误的发生。
那些在不知不觉中就会发生变化的“常量”
第二种错误的出现速度较慢,但后果更为严重。
税收等级会随着年度预算的变化而调整,法定上限也会通过官方通知进行修改。某些补贴或奖金的上限可能会多年保持不变,然后突然发生变化。这些变化都与你的开发计划无关。
一种常见的错误做法是使用一个固定的常量来表示这些数值:
const gratuityCeiling = 2000000
但这样会带来什么后果呢?不仅使用者会看到过时的数值,就连开发者也会在某个周二发现这个数值已经发生了变化,而这个错误的数值已经被嵌入到了代码中,开发者除了提交问题等待修复外,也没有其他办法。
还有一个更隐蔽的问题:法定数值往往并不是对所有人来说都相同的。例如,某些政府雇员的补贴上限会更高,定期合同员工的最低服务期限也会更短。使用隐藏的常量不仅会导致数值过时,还会让某一类用户永远使用错误的数值。
因此:应该将法定数值设定为带有明确默认值的选项,而绝不能将其设置为隐藏的常量。
// GratuityOptions包含了会根据通知变化的法定数值。值为0表示“使用默认值”。
type GratuityOptions struct {
Ceiling float64 // 0表示20,000,000;对于中央政府的公务员,应使用25,000,000。
MinYears float64 // 0表示5年;对于定期雇员,应使用1年。
}
当值为0时,在大多数情况下仍然能够达到预期的效果,因此人们在刚开始使用时不会遇到任何问题。但一旦这些数值发生变化,使用者可以立即自行进行修改,而无需等待我发布新的版本。
最后这一点才是关键所在。当你的代码中包含了你无法控制的规则时,用户需要一种能够绕过你的发布流程来进行自定义设置的机制。
存在于表单中的验证机制
在有一次代码重构的过程中,我发现了第三个例子,这个例子具有很高的普遍性。
我曾经编写了一个用于生成贷款分期偿还计划的函数:该函数会为每个月生成一行数据,逐步计算整个还款周期内的各项费用。在应用程序内部使用这种函数时,它是安全的——因为表单中设置了最大值限制,用户界面也会自动阻止用户输入超出这个范围的数值,因此没有人能够申请200年的贷款期限。
但后来我将这些计算逻辑提取到了一个独立的库中,此时表单本身已经不存在了。
rows, _ := Amortisation(principal, rate, months) // 这里的months是从哪里获取来的呢?
这个循环的作用范围仅限于传入的还款期限。如果输入一个荒谬的数值,该函数既不会返回错误的结果,也不会运行得特别慢,它只会继续执行计算,直到调用者的程序结束为止。这就是我的代码中的错误导致了别人的程序出现内存溢出问题,而这个程序我根本就没有看过。
解决方法其实很简单:在函数中添加相应的检查逻辑即可。
const MaxMonths = 1200 // 最大期限为1200个月,即10年
rows, err := Amortisation(principal, rate, 2000)
// 错误信息:months的最大值应为1200,但输入值为2000
将上限设定得如此夸张其实是有意为之的。实际上,最长期限的住房贷款也只有30到40年,因此设置100年的上限只会出现在那些根本不可能发生的输入情况下。这种限制机制的正意义就在于:它应该是不可能被意外触发的,这样就不会有人会对这个限制值提出异议。
这个经验教训的意义并不仅仅局限于某个特定的函数。当你将某段代码提取出来形成一个独立的库时,你就会继承掉用户界面原本为你提供的所有输入验证机制,而这些错误如果发生,就会由其他人来承担后果。
三大习惯
如果你编写的代码实现了那些并非你亲自制定的规则,那么现在你有三个习惯需要养成:
1. 在文档注释中明确指出规则的制定者。
不要只写“利率”这样的字眼,而要具体说明是哪一条规则、属于哪个章节,并且要注明这些规则可能会发生变化。我在上面的注释中明确指出了规则88B(1)和第50章第1节的内容,这样任何检查我的代码的人都知道应该依据什么标准来进行验证;同时,如果有不同意见的人也可以直接阅读源代码,而不是仅仅相信我的说法。
2. 将法定数值设置为带默认值的选项,而非固定常量。
当值为零时,可以处理常见的情况;而当法律发生变化,或者某些用户群体原本就不适合使用默认值时,就需要对这些设置进行修改。
3. 将验证机制从表单中移至函数内部。
用户界面所进行的任何验证工作,其实并不能替代你的代码库本身所执行的验证。要假设后续的使用者可能根本没有任何用户界面来辅助他们进行操作。
这些做法并没有什么特别之处,它们只是遵循了“不可轻信输入数据”这一基本原则,并将其应用到了那些输入数据的定义由他人制定、且这些定义会随时间被修改的领域中而已。
之所以需要单独强调这些措施,是因为在这个领域里,错误的体现方式非常特殊:没有异常信息,也没有堆栈跟踪记录,输出结果看起来也似乎并没有明显错误。实际上,问题只是在于某个数值的大小不符合预期——这个数值虽然格式正确,但其实际值却是应该值的六倍,而这样的错误会静静地出现在报告中,等待有人根据它来采取行动。
总结
归根结底,问题就在于以下几点:
那些用于实现法规要求的代码,其出错的表现方式也有特殊性:没有异常信息或堆栈跟踪,只会有一个看起来似乎正确的数值,但实际上这个数值是错误的。
我们通过三种情况来了解这一现象:一是参数的实际含义与规则规定的意义不同;二是法定数值被固定为常量,但后来法律却对这些数值进行了修改;三是当逻辑代码被提取到函数库中后,用户界面仍然保留着那些验证机制。
以上这三点建议能够依次解决这些问题。首先,要明确规定数据的定义来源,这样后续的阅读者就能知道应该依据什么标准来检查你的代码;其次,将法定数值设置为带默认值的选项,这样当法律发生变化时,你只需要修改相应的参数值即可,而无需去寻找那些固定的常量;最后,要把验证机制放在函数内部,因为后续的使用者可能根本没有任何用户界面来辅助他们进行操作。
如果必须选择一条建议作为重点,那就是关于命名的问题。使用amount这样的名称很容易导致错误的发生,而使用cashTaxPaid这样的名称则可以有效避免这种情况。此外,参数的命名也是最简单、最有效的文档编写方式。
感谢您的阅读!
我开发了EMICalcs——这是一套专为印度用户设计的免费金融计算工具,涵盖了贷款、SIP投资、GST税费计算、退休金计算等多种功能。上面提到的那些公式就是从EMICalcs中提取出来并作为开源代码库发布的,因此任何想要验证我的工作的人都可以查看这些代码。
相关文章
Grafana将服务水平目标及错误预算机制应用于人工智能代理的行为分析中。
Grafana Labs提出了一种熟悉的可靠性工程方法,用来解决现代软件中一个相对不为人所熟知的问题:如何判断某个人工智能代理是否真的表现良好。 作者:Craig Risi
阅读全文
Instagram采用了基于人工智能的架构对用户界面进行了优化,由此使相关成本降低了33%。
Instagram Direct将其Android应用程序的用户界面从传统的Android View系统迁移到了Jetpack Compose,从而创建了一个更适合人工智能编程工具理解和修改的UI代码库。据Instagram的工程师们表示,这一迁移使得UI代码的数量减少了50%,同时每个人工智能程序会话所消耗的资源成本也降低了33%。 作者:Sergio De Simone
阅读全文
如何阻止人工智能代理生成尼日利亚的邮政编码
2026年10月1日,尼日利亚的邮政编码从六位数字改为由11个字符组成的代码,这种编码能够唯一标识某栋建筑,例如 EK-01-A03-FK-01 。 在此之前训练的语言模型仍然会自信地使用旧格式来处理邮政编码。这会影响两种类型的程序:一种是负责生成验证代码的编程程序,另一种是在运行时从客户信息中读取邮政编码或填写配送表格的程序。 本文将介绍如何解决这个问题。对于那些生成验证代码的程序,你需要检测它们目前生成的代码是否存在错误,并为它们提供相应的规则来纠正这些错误;而对于在运行时处理邮政编码的程序,则需要为它们设定明确的边界:当模型提出一个邮政编码时,由特定的程序来判断该编码是否符合规范、是否已
阅读全文
调查显示,由人工智能生成的代码会提高调试的难度,增加程序出现故障的概率,同时还会导致人们在理解这些代码时产生障碍。
由独立研究机构Coleman Parkes代表专注于利用人工智能技术进行根本原因分析的Undo公司开展的一项调查发现,虽然人工智能编码助手加快了代码生成的速度,但它们却将主要的瓶颈转移到了调试、代码理解以及代码维护环节。 作者:Sergio De Simone
阅读全文