让默认值保持中立

DEFAULT means NOTHING

Posted by V-ISLAND on Thursday, September 15, 2022

在计算机领域,“默认值”是一个同时存在于理想与非理想之间,常见而又深邃的概念。有的时候它代表着一种理想的大多数,通过假设使用者的一般使用情景来替 coder 剩下几个字符的编码量;有的时候它又代表着一种脱离理想的现实,在程序不能正确处理得到结果的时候,依然能存在一个可以被后继程序正常处理的值,使得整个流程的健壮性得以维持。合理的默认值设定对于越大规模的软件工程来说越为重要,一个不合理的右值可能会使得众多后来的使用者们发狂,so,在敲下等于号之后,我们应该对这个即将设下的默认值思考些什么?

默认值在中文语境当中是一个比较广泛的概念,常见的语义大致上可分为三种:对象/属性/变量初始值(initial value)、函数方法默认参数(default parameter)、处理失败时的异常降级值(fallback value)。接下来每一种情况我都想简单聊一聊,不过中心思想都是同一个——让你的默认值保持中立,除非你明确知道你在做什么。

何为中立的默认值?首先,其不应该是一个诸如 Int.MaxValue 这样的极端值;其次,其不能和普通的业务值有过大的含义交集,理想情况下,默认值应该完全独立于业务值的范围。只有符合这两点条件,默认值才能是中立的,它不持有任何立场、不代表任何其他的含义,它仅仅作为它自己、作为一个独立概念而存在。

先看看初始值的场合。对于普通的函数内部变量来说,其生命周期短暂,随着函数的每次调用朝生暮死,coder 对其持有 100% 的掌控权,所以想定义成什么都毫无所谓,只要函数功能正确即可,这是对中立要求最宽松的情景。然而对于对象属性的初始值,就需要稍微留意一点了,因为即便提供了值的修改方法,对于不关心特定属性的使用方来说,往往就会让这个默认值保持不变。如果默认值设置得不合理的话,这种时候就会容易衍生出一些比较隐晦的问题。假设我们现在存在一个坐标类定义如下:

class Coordinates {
  var longitude = 0.0
  var latitude = 0.0
}

然后我们有这么一段逻辑:

fun get_coordinates(): Coordinates {
    val coord = new Coordinates()
    val sensor_value = invoke_sensor() // 从传感器获得坐标信息
    if (sensor_value != null) {
        coord.longitude = parse_sensor_longitude(sensor_value)
        coord.latitude = parse_sensor_latitude(sensor_value)
    }
    return coord
}

不是很严谨,但很常见的直觉性写法。按照这个逻辑,如果传感器在某一时刻故障或者信号弱,sensor_value 值为空,那么本函数将会返回一个持有默认值的坐标对象。然后这个对象可能会入数据库,可能会被序列化传输到其他地方,然后终有一天某人看着这堆数据,纠结 0,0 是不是一个合法数据——因为地球上真的存在这个坐标!也正是因为被处理成这个坐标的错误数据太多了,甚至催生出了 Null Island 这一半架空的地理概念。

这组默认值与正常的数据范围产生了重叠,因此它并不是一个足够中立的选择。在条件允许的情况下,null(有的语言也叫 OptionNone,anyway you know)是最好的,任何一门现代语言都有天然的表示“没有”的方式,那为何不这么做呢?而对于一些更倾向于基础类型而非包装/指针类型的低级语言,或者一些不方便直接使用空值的序列化等场景,我们依然可以退而求其次,既然坐标的数值范围是 [-180,180],那么选择一个范围外的数字作为程序上的约束,比如 200、1000,不也能解决问题吗?当然了,如果考虑不周的下游程序只能接受一个合法的坐标值,那 0 也是一个迫不得已的选择,至少它定位在海洋,并且直观地异常。

对于上面这段逻辑,我们还可以加入 fallback 值从而清楚指出得到的是一个错误值。

fun get_coordinates(): Coordinates {
    val coord = new Coordinates()
    val sensor_value = invoke_sensor() // 从传感器获得坐标信息
    if (sensor_value == null) {
        coord.longitude = -1000
        coord.latitude = -1000
        return coord
    }
    val longitude = parse_sensor_longitude(sensor_value)
    val latitude = parse_sensor_latitude(sensor_value)
    if (valid(longitude) && valid(latitude)) {
        coord.longitude = longitude
        coord.latitude = latitude
    }
    return coord
}

由此,当最后看到坐标值为 -1000 的时候,我们可以明确地区分出这是一个因传感器故障而产生的值,从而做出更多的分析定位。如果将这个值按照错误原因再继续细分,那就是某些领域的程序员所耳熟能详的错误码概念了。现代语言普遍提倡使用异常机制或独立的 Error 对象代替错误码这种原始可读性差的处理方式,但是出于各种现实原因,在不得不赋值的情况下,将 fallback 值与初始值分离可以给予使用者更宽松的处理自由,不分离则可以带来更简洁的响应语义,各有各的优劣之处。

最后是默认参数,有一些语言本身不支持默认参数,比如 Java,但是可以通过方法重载等方式实现类似的效果,这里暂且归为一类。区别于前面两类作为结果输出,默认参数作为输入而存在,其设计几乎都是为了简化调用的同时保留自定义的灵活性。

因此,默认参数首先应该需要符合大多数场景下的直觉。一个比较著名的反直觉例子是 Python 的默认参数设计,Python 的默认参数表达式仅会在函数第一次被加载时计算求值,然后将得到的对象绑定为默认参数,即无论函数被调用多少次,其默认参数指向的都是同一个对象,如果这个对象是可变的,那么函数内对其的修改操作将会影响到后续的函数执行。因此几乎所有的教程都会告诉你不要将默认参数设为可变对象,甚至出现过大公司产品因此一落千丈的悲惨故事。定义在函数参数却拥有高于函数的生命周期,大多数语言都不会如此,因为这是反直觉的,最终给使用方带来的限制隐患已经超出了其带来的简洁便利。虽然这是语言级别的例子,不过延申到应用级别也是相似的。

此外,还有一种间于默认参数与初始值之间的形式,个人倾向于称呼其为特征值。特征值通过元数据注解、接口实现、继承等方式提供,使用者通过引入某种外部组件,使得使用者定义的代码得以与其他外部组件交互,比如各类 Web 框架。在约定大于配置理念成为主流的当下,这些特征值往往都会存在提供方已经预设好的默认值,以减少配置和学习成本。而随着代码工程的不断壮大,难免会出现一些使用者难以干涉的外部组件部分,此时外部组件所能提供的灵活性便尤为关键。一个反面例子是 Spring Framework 的 Order 注解,其默认值设置为“最低优先级(Integer.MAX_VALUE)”,并且大部分组件不会特意地去配置此项。这导致了应用开发者经常无法将他们的自定义组件的优先级置于许多外部组件之下,而不得不使用其他更复杂的手段去实现目标功能。显然这个默认值是不中立的,它将缺省场景置为极端,抹消了许多介入的灵活性,并且由于这是一个影响面非常广的配置,这个默认值也几乎不可能再修改成 0 之类的更为中立的值,成为了典型的技术债(开发团队已在 issue 中表示计划设计新机制优化这个问题)。

以上即为本文所有内容,基本基于自己工作上的经验和平时看到的一些故事帖子总结,主要是以软件工程的视角去看待这些问题。代码工程越大,依赖的第三方、开源代码越多,越容易会遇到这些不合理的值设计导致的问题。希望至少阅读过本文的大家,包括我自己,未来能在意识到这些潜在问题的前提下做出更好的选择吧。