我们应该根除空字符串——原因如下






4.54/5 (8投票s)
消除空字符串(以及空集合)可提高软件可靠性并带来其他优势。
目录
引言
你有没有想过空字符串和null
之间有什么区别?
例如,以下两者有什么区别?
email = ""
... 和
email = null
这两个语句的意思是否相同?
使用空字符串和使用null
的优缺点是什么?
我们需要这两个版本,还是可以去掉其中一个以简化问题?
让我们来看看。
问题出在哪里?
在揭示上一节中提出的问题的答案之前,首先调查一下臭名昭著的“测试空和/或null?”问题是很有帮助的,这个问题在许多流行的编程语言(C家族语言、Java、JavaScript、Python等)中都会遇到。你很可能在各种情况下亲身遇到过这个问题。
假设一家服务提供商公司需要发送一封重要的电子邮件,通知所有客户即将发生的变化。在发送电子邮件之前,工作人员希望检查客户的电子邮件地址,并确保每个客户都在数据库中定义了电子邮件地址。
下面是一段完成此任务的代码摘录,用Java编写(其他语言中的代码也会类似)
for ( Customer customer : customers ) {
String id = customer.getId();
String email = customer.getEmail();
if ( /* TODO: no email address defined */ ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
}
这段代码中有趣的部分是检查客户是否存在电子邮件地址的if
语句。
实践表明,不同的开发人员可能会以不同的方式检查电子邮件地址的缺失。让我们看看六种可能的变体。
开发人员A遵循普遍的建议,即函数不应返回null
,而应返回空字符串(和空集合),以简化代码并避免可怕的空指针错误。因此,他假设如果不存在电子邮件地址,customer.getEmail()
会返回一个空字符串。代码如下所示:
if ( email.isEmpty() ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
开发人员B是日益壮大的接受null
的开发人员群体中的一员。只有null
才应该用来表示值的缺失,因此她假设如果不存在电子邮件地址,customer.getEmail()
会返回null
if ( email == null ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
开发人员C希望确保安全,并测试null
或空字符串
if ( email == null || email.isEmpty() ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
开发人员D也想确保安全,但他运气不好,代码写错了
if ( email.isEmpty() || email == null ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
注意
上面的代码是错误的,因为它首先测试
email.isEmpty()
,然后再测试null
,这意味着如果null
,就会抛出空指针错误。
开发人员E也搞错了,但不是因为操作数的顺序错误。她没有使用短路逻辑或运算符||
,而是使用了按位包含或运算符|
,如果两个操作数都是布尔类型,其效果是非短路逻辑或运算符。因此,如果email
指向null
,就会抛出空指针错误。
if ( email == null | email.isEmpty() ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
开发人员F今天运气非常不好,忘记检查是否有电子邮件地址了
writeInfo ( id + ": " + email );
注意
如果
null
,则上述代码的结果取决于语言。
在 Java 中,将
null
附加到字符串会附加字符串"null"
— 因此会写入类似C123: null
的消息。其他语言(例如 C#)不附加任何内容,这会产生
C123:
。某些语言可能会在运行时抛出异常。
最安全的方法(在空安全语言中)是:编译器会生成错误,要求我们决定何时尝试将可空值附加到字符串。
现在让我们看看运行时会发生什么。
除了考虑customer.getEmail()
消费者端的上述六种代码变体之外,我们还需要考虑供应商端可能的变体。
如果未定义电子邮件地址,则customer.getEmail()
可能会
-
返回
null
-
返回空字符串
由于消费者端有六种变体,供应商端有两种变体,因此组合的数量(出奇地高)是:6 x 2 = 12。
下表显示了供应商/消费者代码所有组合的结果。您无需仔细研究此表——只需快速浏览即可,因为此示例的目标是提供关于此简单示例所涉及的复杂性和易错性的概念。
供应商 | 消费者 | 输出 | ||
---|---|---|---|---|
正确 | 运行时 | 静默 | ||
空字符串 | A: 空 | ✔ | ||
B: 空 | ✗ | |||
C: 空,然后空 | ✔ | |||
D: 空,然后空 | ✔ | |||
E: 错误操作符 | ✔ | |||
F: 无 | ✗ | |||
null | A: 空 | ✗ | ||
B: 空 | ✔ | |||
C: 空,然后空 | ✔ | |||
D: 空,然后空 | ✗ | |||
E: 错误操作符 | ✗ | |||
F: 无 | ✗ | |||
Count | 6 | 3 | 3 |
关注点
-
50% 的情况下结果是正确的。
出现最坏结果的可能性为25%:一个被静默忽略的错误。
-
只有开发人员 C 的代码,它首先检查
null
,然后检查空字符串,并使用正确的运算符(||
),才能在所有情况下正确工作。 -
如果供应商端或消费者端的代码稍后更改,结果也可能会改变。原来正确的代码可能会变得有bug,反之亦然。
-
如果
customer.getEmail()
有时返回null
,有时返回空字符串(取决于数据库中存储的值),那么代码可能对某些客户有效,但对其他客户无效。
注意
除了
null
和空字符串之外,有些语言还支持其他值。例如,Javascript 还有undefined
。VBScript 有四个值:Nothing
、Empty
、Null
和空字符串。当我写这篇文章时,我最初打算提供一个更具问题性的示例,包括undefined
以及null
和空字符串。然而,由于组合数量呈指数级增长,我迅速放弃了这个想法。
上面的例子说明了你可能已经知道的事情:检查null
和空字符串既麻烦又容易出错。
如果我们能摆脱这种反复出现的烦恼,那该多好?
检查电子邮件地址是否存在应该很简单,而且应该只有一种正确的方法,最好由编译器强制执行。
有解决方案吗?
“删除,”他说。“删除,删除,删除。”
——艾萨克森,沃尔特。《埃隆·马斯克》,2023年,第402页
因为检查null
和空字符串是一个常见的模式,C#提供了一个名为IsNullOrEmpty
的静态String
方法。
而不是写:
if ( email == null || email == "" )
... 你可以简单地写
if ( String.IsNullOrEmpty ( email ) )
Java在其标准库中没有提供这样的方法,但一些第三方库有。例如,Google Guava提供了Strings.isNullOrEmpty()
,Apache Commons提供了StringUtils.isEmpty()
。
这些实用程序很有用,但世界上没有任何编译器能强迫我们使用它们。我们无法避免编写错误代码——上一节中显示的所有变体仍然是允许的。我们需要一个更好的解决方案。
我们能否根除null
,只使用空字符串来表示字符串值的缺失?如果你阅读过我之前的文章(尤其是实用类型系统(PTS)中的联合类型和实用类型系统(PTS)中的空安全),那么你已经知道这不是一个选项。
我们需要null
!
如果我们根除了空字符串呢?
我们能做到吗?
我们应该这样做吗?
是的,都能!
最初看起来像是不可原谅的野蛮破坏行为,最终将被证明是一种极好的简化,它提高了可靠性,让我们晚上睡得更香!
我们甚至可以更进一步。
字符串是字符或Unicode代码点的序列/集合(例如,"foo"
是字符'f'
、'o'
和'o'
的集合)。因此,如果我们决定根除空字符串,提出一个合理的问题:这是否意味着我们也应该根除空集合(list
、set
、map
等)?
再次,答案是全心全意的是的,我们可以也应该!
然而,正如我们稍后将看到的,我们需要正确地做,并保持一切实用。
重要
备注本文是如何在软件开发项目中设计实用类型系统以最大化可靠性、可维护性和生产力系列文章的一部分。我关于根除空字符串和空集合的建议仅适用于实现实用类型系统(PTS)或类似旨在提高可靠性的范式的新语言。
我不建议在C、C++、C#、Java、JavaScript、Python和Rust等现有主流语言中移除空字符串/集合。
在下一节中,您将了解为什么我们可以删除空字符串和空集合,尽管这与既定实践和指南相悖。在评估利弊之后,您将清楚为什么我们也应该删除它们。最后,您将看到它在PTS中是如何工作的,一个实际示例将展示其优点。
我们能做到吗?
如果你认为消除空字符串和集合是一个坏主意,要知道你并不孤单。我们已经习惯了它们,以至于我们认为它们是理所当然的,无法想象没有它们的生活。在本节中,我们将探讨一些反对我建议的论点。
注意
本节和下一节中的源代码示例以 Java 形式显示,但所讨论的概念也适用于其他编程语言。
论点一:空字符串和空集合在所有流行语言中都得到支持,并且几乎所有类型的应用程序中都使用它们。这肯定有很好的理由。我们不能消除它们。
诸如“人人都这样做,所以一定是正确的”或“一直以来都这样做,所以我们也应该这样做”之类的论点可能存在缺陷。
对新想法保持开放心态,敢于挑战根深蒂固的概念(包括那些看似不可动摇的概念),对于推动进步至关重要。
注意
当我决定在 PPL(PTS 的概念验证实现,目前处于休眠状态)中消除空字符串和集合时,我预计我稍后会后悔我的想法,在遇到一些情况会清楚地告诉我为什么需要空字符串和集合(除了
null
)。尽管如此,我决定尝试一下,看看会发生什么。发生的事情是,我从未后悔这个决定。在接下来的章节中,我将解释为什么这最终是一个有益的想法(不像我最终在实验后不得不放弃的其他几个想法)。
论点二:使用空字符串/集合代替null
可以简化代码并消除一些空指针错误。
普遍的观点认为,函数应该返回空字符串和集合,而不是null
。关于这个主题的文章很多,并且这个建议得到了许多知名和有影响力的人的支持。例如,微软在集合指南中指出:“不要从集合属性或返回集合的方法中返回空值。而是返回一个空集合或空数组。”
这一指南的理由很容易理解。
假设我们想遍历冰箱里的食物。如果fridge.getFoods()
返回一个空集合来表示“冰箱里没有食物”,我们可以简单地写:
for ( Food food : fridge.getFoods() ) {
System.out.println ( food.toString() );
}
如果冰箱里没有食物,循环体将不会执行。我们不需要编写
List<Food> foods = fridge.getFoods();
if ( ! foods.isEmpty() ) {
for ( Food food : foods ) {
System.out.println ( food.toString() );
}
}
另一方面,如果fridge.getFoods()
返回null
,那么一个简单的循环
for ( Food food : fridge.getFoods() ) {
System.out.println ( food.toString() );
}
... 如果冰箱里没有食物(即当fridge.getFoods()
返回null
时),会导致空指针错误。
我们必须写
List<Food> foods = fridge.getFoods();
if ( foods != null ) {
for ( Food food : foods ) {
System.out.println ( food.toString() );
}
}
显然,看起来使用null
(而不是空集合)确实增加了不必要的复杂性并增加了错误发生的可能性,不是吗?
是的,但是……这并不是故事的全部——我们必须从不同的角度来看待它。我们必须在更可靠的软件开发的背景下重新审视这个论点,这是PTS的首要目标。
目前,我们只需说我们可以使用null
而不是空集合,即使看起来我们不应该这样做。
在下一节中,我们将回到这个关键点。
论点三:有时,空字符串/集合的含义与null
不同,在这种情况下,它们必须在代码中区别对待。因此,我们需要两者。
在我们的入门示例中,空字符串的含义与null
相同,因为我们以相同的方式处理这两种情况
if ( email == null || email.isEmpty() ) {
无论email
是null
还是空,都会执行相同的代码:if
语句的“then”分支。两种情况下的含义都相同:客户没有定义电子邮件地址。
事实证明,在实践中,空字符串/集合和null
的含义总是相同的,除非我们在特定情况下赋予它们不同的含义。
例如,我们可以指定一个空字符串表示“客户没有电子邮件地址”,而null
表示“我们尚不确定客户是否有电子邮件地址”。
然而,这是不好的做法,因此我们不应该这样做。为null
和空字符串/集合分配不同的含义只是一个约定,必须在代码库中由每个参与者记录和应用。这容易出错,并且编译器无法强制执行此类约定。
如果我们需要区分两种(或更多)情况,那么一种安全的方法是为不同的情况使用不同的类型。
例如,考虑一个函数,它返回给定人员的过敏情况。显然,区分“此人没有过敏症”和“此人尚未进行过敏症测试”至关重要。将其简化并指定函数返回的空列表表示此人没有过敏症,而null
表示此人尚未进行过敏症测试,这可能很诱人。然而,这将是一个糟糕的想法,因为每个开发人员都需要了解此约定并正确应用它。客户端代码将如下所示:
List<Allergy> allergies = person.allergies();
if ( allergies == null ) {
// the person has not yet been tested for allergies
} else if ( allergies.isEmpty() ) {
// the person has no allergies
} else {
// the person has allergies
}
极易出错——我们不应该这样做。
相反,person.allergies()
应该返回三种类型之一:AllergiesNotTested
(未测试过敏),NoAllergies
(无过敏),或HasAllergies
(包含非空过敏列表的类型)。现在客户端代码清晰且类型安全,未来可以轻松添加其他情况(例如AllergyTestPending
),并且编译器会检查我们可能遗漏的情况。代码如下所示:
switch ( person.allergies() ) {
case AllergiesNotTested notTested -> {
// the person has not yet been tested for allergies
}
case NoAllergies noAllergies -> {
// the person has no allergies
}
case HasAllergies hasAllergies -> {
// the person has allergies
}
}
简而言之,这样的代码
if ( collection == null ) {
// do this
} else if ( collection.isEmpty() ) {
// do something else
} else {
// ...
}
... 是一种 代码异味。更准确地说,它是一种数据设计异味。这意味着“集合为空”和“集合为空”这两种情况被赋予了不同的语义(含义),而不是为这些不同的情况使用不同的类型。
我们可以得出结论
-
空集合(或空字符串)和
null
的含义相同,除非我们在特定情况下赋予不同的含义,但我们不应该这样做。
如果我们不去做我们不应该做的事情,那么结论可以简化为
-
空集合(或空字符串)和
null
的含义相同。它们在代码中以相同的方式处理。
微软在集合指南中如此表示:“一般规则是,null和空(0项)集合或数组应被同等对待。”
因此,我们从不需要null
和空集合/字符串来表示语义上不同的值缺失情况。
注意
我们能否仓促得出结论,认为整数零和
null
(或布尔值false
和null
)也具有相同的含义,就像空字符串和null
含义相同一样?不,那将是一个可怕的谬误。零和null
,以及false
和null
具有截然不同的含义。例如,accountBalance = 0
意味着账户里没有钱,而accountBalance = null
意味着我们不知道账户里有多少钱。
论点四:有时我们需要可变集合,而且它们必须允许为空——例如用于实现栈、队列、双端队列等。
是的,这是一个有效的论点。简短的回答(在PTS中)是,不可变集合不能是空的,但标准库也提供可变集合,这些集合可以是空的。这将在后面的章节中介绍。
论点五:当我们需要使用同时使用空集合和null
的库和框架(可能用不同的语言编写)时,我们需要空集合和null
。
使用第三方API不是问题,因为我们可以简单地在“null和空”与“只有null”世界之间转换数据。示例将在后面展示。
结论
如本节所示,我们可以消除空字符串和空集合,转而使用null
。
然而,这并不意味着我们应该这样做。如果鲍勃可以只用 Windows 记事本编写一个大型应用程序,这并不意味着他应该这样做。
我们应该这样做吗?
在本节中,我们将探讨消除空字符串/集合的优缺点,从优点开始。
潜在问题值已消除
第一篇PTS文章介绍了以下PTS编码规则:“软件项目中的所有数据类型都应该具有尽可能低的基数。”
提醒
类型的基数是该类型中允许的值的数量。例如,类型
boolean
的基数是二,因为它允许两个值:true
和false
。
通过消除空字符串和空集合,每个应用程序中所有字符串和所有集合的基数都减少了一个。
这很好。
更棒的是,我们已经消除了这些类型中最麻烦的值。
正如每个有经验的开发人员所知,空字符串和空集合通常是无效值,或者它们必须区别对待。例如:每个名称至少有一个字符;每个班级至少有一名学生;每个在线零售商至少销售一种产品等。通过设计消除空值,可以消除与这些值相关的潜在错误。
更简洁的代码
还记得问题出在哪里?一节中的源代码示例吗?其中六名程序员编写了不同的代码,而唯一正确的版本是这个:
if ( email == null || email.isEmpty() ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
通过消除空字符串,在类似情况下(许多项目中常见)出现错误代码的风险已被消除。正确的代码变得更简单,而且只有一种正确的方法。在空安全语言中,编译器甚至要求进行null
检查。我们必须编写:
if ( email == null ) {
writeError ( id + ": no email" );
} else {
writeInfo ( id + ": " + email );
}
你不再需要疑惑:“我应该检查空值、null
还是两者都检查?”我们总是只需要检查null
,如果(在糟糕的一天)我们忘记了这样做,编译器会提醒我们。
更可靠的代码
本文中最重要的一点是:使用null
代替空字符串/集合可以提高软件可靠性。让我们看看为什么。
处理集合时,我们常常需要区分以下两种语义不同的情况,并且必须单独处理它们
-
没有元素。
-
有一个或多个元素。
一些伪代码示例
if directory_is_not_empty then
copy_files_in_directory
else
report_missing_files_error
.
if no_students_in_the_classroom then
close_the_windows
switch_off_the_lights
else
if its_hot then
open_the_windows
.
switch_on_the_lights
.
// real-life example
if there_are_bugs_in_the_code then
fix_bugs
else
work_on_new_features
.
有时,我们忘记区分这两种情况——通常,我们忘记为“无元素”的边缘情况编写特定代码。这可能导致在开发和测试阶段未被发现的错误,尤其是对于很少发生的边缘情况。
好消息是:如果我们在空安全语言中使用null
来表示“没有元素”,那么我们就不能再忽略这些边缘情况了,因为编译器会温和地提醒我们处理它们。换句话说,我们总是被要求处理这两种情况(即“有元素”和“没有元素”)。这消除了许多潜在的错误。
如果我们使用空集合,我们被允许编写这样的代码
for ( Object element : collection ) {
// handle element
}
空集合只会导致“什么都不做”的行为。有时这是正确的做法,但通常不是。很容易忽略“没有元素”的边缘情况。此外,如果代码是由其他人编写的,我们不知道作者是否打算在空集合的情况下什么都不做。
另一方面,在强制使用null
而非空集合的空安全语言中,上述代码将无法编译。我们必须编写:
if ( collection != null ) {
for ( Object element : collection ) {
// handle element
}
}
... 或
if ( collection != null ) {
for ( Object element : collection ) {
// handle element
}
} else {
// handle edge case
}
是的,如果不需要对“无元素”情况进行特殊处理,代码会稍微冗长一些,但有以下两个优点:
-
我们不会意外遗漏“无元素”的边缘情况。
在糟糕的一天,我们仍然可能会忘记添加
else
分支(无论何时需要处理边缘情况),但由于要求进行collection != null
的测试,这种错误的风险大大降低。 -
程序员处理或忽略边缘情况的意图在代码中清晰地声明。
稍后您将看到一个实际的PTS示例,展示这些好处。
注意
要查看一些实际的Java示例,您还可以阅读我的文章返回空列表而不是null真的更好吗?——第二部分。该文章说明了空集合导致的错误结果。例如:你可以免费得到一座宏伟的房子,以及在选举中被宣布为获胜者的亚军。
更简洁、更可靠的API
如果字符串和集合不能为空,它们的API会变得更简单,更不容易出错。
例如
-
不需要
isEmpty
方法。 -
size
方法(又称length
,返回集合中元素的数量)从不返回零。这消除了某些错误的潜在风险,例如计算数字列表中平均值时除以零。 -
first
和last
方法总是返回一个元素(而不是在集合为空时返回null
或抛出异常)。 -
像
allMatch
、noneMatch
和anyMatch
这样的方法不会返回意外的和有争议的结果,这些结果可能导致边缘情况下的微妙错误。关于这些方法在集合为空时应该返回什么,人们有不同的看法——正如CodeProject休息室的以下长篇讨论中所见:.NET有时荒谬的逻辑。
-
计算聚合值(例如
sum
、average
、min
、max
)的方法也变得更加直接。例如,我们不需要讨论“如果数字列表为空,函数
average
应该做什么?返回零?返回null
?抛出异常?”这样的问题。
更高效的代码
在时间效率和空间效率方面,没有什么能比得上null
。
在大多数语言中,null
速度超快。
将null
分配给对象引用通常通过简单地将零(所有位为0)分配给指针来实现,而检查null
则通过将指针与零进行比较来完成。两者都是CPU操作,速度极快。
更好的代码分析
如果集合不能为空,那么涉及它们的循环和迭代总是保证至少执行一次。这种确定性可以被高级编译器(以及静态代码分析器等辅助工具)利用,做出在循环体可能执行时无法做出的假设。例如,编译器可能能够优化保证至少执行一次的循环的目标代码。
缺点
到目前为止我们谈论了优点。那么有没有缺点呢?
我能想到的唯一缺点是偶尔增加的冗余。例如,我们不得不写
int elementCount = collection.size();
boolean weHaveCheese = foodsInFridge.contains ( "cheese" );
...我们必须写
int elementCount = collection == null ? 0 : collection.size();
boolean weHaveCheese = foodsInFridge == null ? false : foodsInFridge.contains ( "cheese" );
是的,有时代码会更冗长——但为了我们迄今为止看到的所有好处,这只是一个很小的代价。我们不能鱼与熊掌兼得。从好的方面看,代码也更具表现力,并且(在某些情况下)更不容易出错,因为,“没有元素”的边缘情况在代码中得到了明确处理。
物理世界中的集合
每当我费尽心思设计数据或编写代码时,我常常觉得查看物理世界中事物如何运作是很有用的。
那么,在现实世界中,我们是如何使用集合的呢?
想象一下鲍勃收集彩色石头,存放在一个贴有“石头”标签的盒子里。
爱丽丝不收集石头。这是否意味着她有一个贴有“石头”标签的空盒子?不,当然不是。爱丽丝根本就没有盒子。
这很容易转化为数字世界:物理世界中的空盒子就像数字世界中的空列表;根本没有盒子就像null
。
我们可以举出更多例子,但经过一番思考后,我们会得出结论
-
物理世界中的大多数集合(列表)都是不可变的且非空的。
例如:您的计算机型号中的组件集;奶奶圣诞蛋糕中的配料列表;2023年诺贝尔奖获得者名单等。
-
有时我们使用可变集合,它们最初是空的,随着时间的推移被填充,最终可能会被丢弃或保留为不可变、非空的列表。
例如:鲍勃的“石头”盒子;到目前为止语言课程中已注册学生的名单;您计算机上安装的应用程序等。
正如您将在下一节中看到的,PTS 集合的设计与物理世界中的集合类似。
它是如何工作的?
在本节中,我将简要展示空字符串/集合在PTS中是如何运作的。需要注意的是,这里描述的方法并非唯一可行的方法——它是我在PTS概念验证实现中使用的。为了使本节简短,许多实现细节被省略。
以下各节中的源代码示例使用以前PTS文章中介绍的PTS语法。
注意
请注意,PTS 是一种新范式,仍在开发中。正如 实用类型系统 (PTS) 的本质与基础 的 历史 部分所解释的,我创建了一个概念验证实现,该实现现在有些过时——因此您无法尝试本文中所示的 PTS 代码示例。
非空不可变集合
PTS字符串和集合是不可变且非空的。标准库中定义了以下类型:
-
类型
string
:一个不可变的字符串,不能为空(即字符串必须至少包含一个字符)。 -
类型
list
、set
、map
等:不可变集合,不能为空(即集合必须至少包含一个元素)。
这些是在函数签名中主要使用的类型(即不可变且非空)。例如,以下函数接受一个非空、不可变的字符串列表作为输入,并返回一个非空、不可变的整数列表:
fn foo ( strings list<string> ) -> list<integer>
// body
.
如果输入和输出允许为null
(即可能“没有元素”),则签名将包含联合类型(t1 or t2
),如下所示
fn foo ( strings list<string> or null ) -> list<integer> or null
// body
.
如果输入和输出列表也允许包含null
元素,则签名如下
fn foo ( strings list<string or null> or null ) -> list<integer or null> or null
// body
.
无空字面量
由于字符串不能为空,因此没有空字符串字面量,如下所示
const name = "Bob" // OK
const name = "" // compile-time error
const name string or null = null // OK
也没有任何空集合字面量
const numbers = [list 1 2 3] // OK
const numbers = [list ] // compile-time error
const numbers list<number> or null = null // OK
不可变集合构建器
字面量便于硬编码预定义值,而构建器类型允许我们通过编程方式创建字符串和集合。例如,我们使用list_builder
来构建list
。
构建器应用构建器模式,这在面向对象语言中很常见。在内部,构建器使用可变数据结构(例如可变列表)来构建集合。不可变集合分三步构建
-
创建一个构建器对象(例如
list_builder.create
) -
添加元素(例如
builder.append ( ... )
) -
通过调用
builder.build
(或builder.build_or_null
,如果可能没有元素)创建一个非空、不可变集合
这是一个创建整数范围的函数示例
fn int_range ( start integer, end integer ) -> list<integer>
in_check: end >= start
const builder = list_builder<integer>.create
repeat from i = start to end
builder.append ( i )
.
return builder.build
.
可为空的可变集合
有时我们需要可变集合,这些集合可以为空。例如:栈、队列、双端队列、由多个函数填充的集合等。
为了保持这些数据结构高效且实用,我选择在 PTS 中提供专用的可变集合。
可变集合类型的名称总是以mutable_
前缀开头,后跟其不可变对应类型的名称。因此,标准PTS库提供了
-
类型
mutable_string
:一个可变的字符串,可以为空(即其字符长度可能为零)。 -
类型
mutable_list
、mutable_set
、mutable_map
等:可变集合,可以为空(即其大小可能为零)。
这是一个将一个或两个元素附加到作为参数传递的可变列表的简单函数示例
fn append_elements ( strings mutable_list<string> )
if strings.is_empty
strings.append ( "first" )
.
strings.append ( "foo" )
.
我们可以通过调用方法to_immutable_or_null
(例如return customers_found.to_immutable_or_null
)将可变集合转换为其不可变对应项(如果集合为空,则为null
)。
如果在给定上下文中,可变集合被假定包含至少一个元素,则应使用方法to_immutable_or_throw
:此方法在可变集合为空时抛出错误(异常),而不是返回null
。
循环语法
我们可以通过经典循环(命令式风格)、流(函数式风格)或递归遍历集合。本节仅介绍经典循环结构。
这是一个使用repeat
语句遍历集合的简单示例
repeat for each number in [list 1 2 3]
out.write_line ( number.to_string )
.
输出
1
2
3
在可能没有元素的情况下,编译器要求进行null
检查
const commands list<command> or null = get_commands()
if commands is not null
repeat for each command in commands
log_info ( """Executing command {{command.to_string}}.""" )
// code to execute command
.
else
log_warning ( "There are no commands to execute." )
.
else
分支是可选的
const commands list<command> or null = get_commands()
if commands is not null
repeat for each command in commands
log_info ( """Executing command {{command.to_string}}.""" )
// more code
.
.
我们可以通过在repeat
语句中使用if_null
子句来缩短上述代码
repeat for each command in get_commands() if_null: skip
log_info ( """Executing command {{command.to_string}}.""" )
// more code
.
如果一个集合被声明为可空(无论是显式还是通过类型推断),但在给定上下文中应该是非空的,我们可以使用if_null: throw
子句来在集合为null
时中止程序执行,尽管我们假设情况并非如此
repeat for each command in get_commands() if_null: throw "'commands' is not supposed to be 'null'."
log_info ( """Executing command {{command.to_string}}.""" )
// more code
.
上面的代码是以下代码的简写;如果集合为null
,两者都会抛出错误
const commands list<command> or null = get_commands()
if commands is not null
repeat for each command in commands
log_info ( """Executing command {{command.to_string}}.""" )
// more code
.
else
throw null_iterator_in_loop_error.create (
message = "'commands' is not supposed to be 'null'.",
id = "NULL_ITERATOR_IN_LOOP" )
.
使用非PTS库
空字符串和空集合在非PTS库中无处不在。我们如何在一个不允许字符串和集合为空的语言中使用这些库?
这在很大程度上取决于PTS的实现,但让我们考虑一个生成Java目标代码并允许Java源代码嵌入在java
和end java
语句之间的PTS实现。那么,从PTS应用程序内部使用Java库至少有三种解决方案
- 转换输入/输出参数
在调用不允许
null
作为输入但需要空集合的Java库函数之前,我们需要将null
转换为一个空集合。这是一个使用嵌入式Java的PTS代码示例java sendCommands ( commands == null ? Collections.emptyList() : commands ); end java
在调用可能返回空集合的外部Java函数后,我们需要将空集合转换为
null
,例如java List<Command> commands = getCommands(); if ( commands.isEmpty() ) { commands == null; } end java
如果经常需要这些转换,我们可以创建充当包装器的实用函数,以便客户端代码保持惯用和简洁。
- 使用可以为空的可变集合
除了转换集合之外,另一种解决方案是使用可变的 PTS 集合(之前在可以为空的可变集合一节中介绍过)来处理非 PTS 库。
然而,不推荐此解决方案,因为我们失去了不可变、非空集合的优点。
- 使用专用类型来处理非PTS库
一个标准的PTS库可以提供可以为空的不可变集合,专门用于处理非PTS库。
例如,在我的概念验证PTS实现中,我创建了类型
emptyable_string
(除了string
和mutable_string
)。这种特定类型有时很方便——它简化了与外部Java库协作的任务,尤其是在null
和空字符串具有不同含义的罕见情况下。
示例
错误是生活中不可避免的事实。重要的是对错误的反应。
— 尼基·乔瓦尼
在本节中,您将看到一个实际示例,演示使用不允许字符串和集合为空的空安全语言的好处。我们将研究一个简单函数在两种不同范式下的情况:不安全和安全。
不安全范式
PTS 是空安全的,并且不可变字符串和集合不能为空。然而,为了本练习的目的,让我们首先假设 PTS 的设计像许多其他语言一样
-
支持
null
,但非空安全 -
字符串和集合可以为空
现在假设我们要计算用户在数据输入表单中输入的备注的平均长度。考虑以下 PTS 函数来实现此目的:
fn average_length_of_remarks ( remarks list<string> ) -> decimal
variable sum = 0.0
repeat for each remark in remarks
sum = sum + remark.length
.
return sum / remarks.size
.
在许多其他语言中,代码会类似。这是一个 Java 示例
static double averageLengthOfRemarks ( List<String> remarks ) {
double sum = 0.0;
for ( String remark : remarks ) {
sum+= remark.length();
}
return sum / remarks.size();
}
注意
我们也可以在PTS和Java中采用函数式风格。例如,使用Java流,代码将是
static double averageLengthOfRemarks2 ( List<String> remarks ) { return (double) remarks .stream() .mapToInt ( String::length ) .sum() / remarks.size(); }然而,我们采用命令式还是函数式风格与当前主题无关。我们将继续使用经典循环来完成这个练习。
如果我们将上述 PTS 函数以 [list "f" "fo" "foo"]
作为输入调用,它将返回正确的结果:2.0
。
不幸的是,尽管它很小很简单,但存在三个问题
-
如果函数以
remarks = null
调用,则在repeat
语句(Java版本中的for
语句)中会发生空指针运行时错误。 -
如果函数以空列表作为输入调用,则在最后一个语句中会发生除以零的错误(因为
remarks.size
为零)。 -
假设应忽略空备注,如果输入参数
remarks
包含空字符串,则结果将是错误的。例如,以[list "foo" "" "foo" ]
调用函数将返回2.0 (=6/3)
而不是3.0 (=6/2)
。发生静默忽略的错误(最坏情况)。
安全范式
现在让我们看看在PTS中实际会发生什么(PTS是空安全的,并且不允许空字符串和集合)
-
不允许调用
remarks = null
的函数,因为所有类型默认都是非空类型,这会导致编译时错误。 -
我们也不允许以空列表作为输入调用函数,因为集合不能为空。因此,不会发生除以零的错误。
-
输入参数
remarks
不能包含空字符串,因为PTS字符串也是非空的。因此,不会发生因空备注而导致的静默忽略错误。
如您所见,所有三个问题都消失了。上述函数不支持“无备注”和“一些空备注”的情况,编译器确保没有人使用无效输入调用该函数。该函数需要一个非空、非空的备注列表,并且列表中不允许有空备注。
现在假设函数应该处理空备注。这是新版本
fn average_length_of_remarks ( remarks list<string or null> ) -> decimal or null
variable sum = 0.0
variable remarks_count = 0
repeat for each remark in remarks
if remark is not null
sum = sum + remark.length
remarks_count = remarks_count + 1
.
.
return if remarks_count =v 0 then null else sum / remarks_count
.
请注意
-
参数
remarks
中包含的元素类型从string
变更为联合类型string or null
,明确表示现在支持空备注字段。 -
编译器要求进行
if remark is not null
检查,因为PTS是空安全的,如果remark
在运行时为null
,则remark.length
会导致空指针错误。 -
返回值的表达式
if remarks_count =v 0 then null else sum / remarks_count
也是编译器要求的。如果我们简单地写return sum / remarks_count
,代码将无法编译,因为remarks_count
可能为零,从而导致除以零(请注意,这是一个超出本文范围的高级编译器功能)。由于函数可能会返回
null
,因此我们也被要求将输出类型从decimal
更改为decimal or null
。反过来,这意味着函数的调用者不能忘记处理所有备注字段为空的边缘情况,因此无法计算备注的平均长度。我们总是安全的。
那么变量remarks_count
呢,它是在有空备注字段时计算正确结果所需的。假设我们错误地写了
fn average_length_of_remarks ( remarks list<string or null> ) -> decimal
variable sum = 0.0
repeat for each remark in remarks
if remark is not null
sum = sum + remark.length
.
.
return sum / remarks.size
.
编译器会报告错误吗?
不,它不会,因为如果我们真的想将空备注包含在它们的平均长度计算中,那么上面的代码将是正确的。
正如在之前的PTS文章中已经说过的那样,口号“如果它编译通过,它就能工作”只是一厢情愿。我们仍然需要编写单元测试来检测逻辑错误。
现在让我们考虑“没有备注”的边缘情况。该函数目前不处理这种情况。因此,函数调用者被要求明确处理这种情况(如果发生)。然而,如果希望函数本身也处理这种情况,我们可以轻松做到:
fn average_length_of_remarks ( remarks list<string or null> or null ) -> decimal or null
if remarks is null then return null
// rest of code
.
现在,当函数以remarks = null
调用时,它会返回null
。
结论
希望这些简单的例子能展示空安全语言不允许字符串和集合为空的优点。
想象一个拥有数百甚至数千个类似上述边缘情况的大型应用程序。拥有一个能够发现所有边缘情况(包括最难以捉摸的)的编译器,就像拥有一个乐于助人、可靠的伙伴,始终在我们身边,时不时地轻拍我们的肩膀,告诉我们:“看!这里有一个需要明确处理或忽略的边缘情况。”
尼基·乔瓦尼明智地说
“错误是生活中不可避免的事实。重要的是对错误的反应。”
我们可以很容易地将这个洞察力应用于软件开发领域
“编码错误是生活中不可避免的事实。重要的是编译器对错误的反应。”
快速失败!
其他语言
本文重点讨论支持null
并确保空安全的语言。其他编程语言呢?应用本文提出的想法仍然有意义吗?
非空安全语言
许多流行的编程语言支持null
,但不是空安全的。采用这种范式的新语言是否也应该限制不可变字符串/集合为非空?
是的,如果软件可靠性很重要的话。
空安全语言和非空安全语言的区别在于:在空安全语言中,如果null
在代码中处理不当,我们会得到编译时错误;而在非空安全语言中,我们得到的是运行时错误——当指针指向null
被解引用时,就会出现可怕的空指针错误。
注意
在某些语言中,解引用空指针会导致未定义行为(而不是抛出空指针错误)。本节只考虑抛出空指针错误的语言。
空指针错误是“糟糕的”。但由于空字符串或集合而导致的静默忽略错误是“非常糟糕的”。“糟糕的”总比“非常糟糕的”好。对于应用程序来说,立即抛出空指针错误并中止程序执行,通常比忽略问题并静默地继续使用被污染或损坏的数据在系统中蔓延,迟早(也许很久以后)导致难以识别和修复的神秘错误要好得多。
许多人讨厌空指针错误,但这些错误实际上非常有用,因为
-
它们支持快速失败!原则(在运行时),因此更有可能在开发过程的早期发现错误。程序会立即响亮地崩溃,而不是忽略问题并默默地继续执行错误/损坏的数据。
空指针错误的最终结果通常不那么严重,也更容易预测。
想象一下一个噩梦般的场景:应用程序A将错误数据写入数据库。稍后,应用程序B、C和D读取并处理这些数据。尽管错误源于应用程序A,但它却在应用程序B、C和D中显现出来。
-
空指针错误通常易于识别和修复,因为它们的因果关系很短。
-
对于大多数流行的编程语言,都有能够识别代码库中潜在空指针错误的有用工具。
使用Optional
/Maybe
类型的语言
有些语言不支持null
。相反,它们为可能缺失值的情况提供了一个专用类型。例如,Rust 提供了Option
类型,F# 提供了Option
单子,Haskell 提供了Maybe
单子。
非空字符串和集合的思想也可以应用于使用这种方法的新语言。在这种情况下,不使用空字符串/集合,而是使用Option
/Maybe
类型的None
/Nothing
值,并且模式匹配确保我们区分“没有元素”和“一个或多个元素”的情况,从而获得类似于本文所示的好处。
摘要
防止不可变字符串和集合为空提供以下优点
-
更可靠的代码,因为可以在编译时发现更多错误。编译器会提醒我们处理我们可能容易忽略的边缘情况。
-
更简单、更不容易出错的代码,因为无需测试“
null
或空”。 -
非空、不可变
string
和集合类型的 API 更简单、更不容易出错(例如,element_count
从不返回零,因此没有除以零的风险)。
这些优点在处理大型代码库时尤其有价值。
PTS中采用以下方法
-
字符串和集合是不可变的,且不能为空。
-
用
null
代替空字符串/集合来表示“没有元素”。 -
使用构建器来构建(创建)不可变字符串和集合。
-
PTS 还提供可变字符串和集合,这些集合可以为空,但很少使用。
致谢
非常感谢Tristano Ajmone提供的宝贵反馈,以改进本文。
历史
- 2024-06-28:初始版本
- 2024-07-02:修正“开发人员E”示例