65.9K
CodeProject 正在变化。 阅读更多。
Home

‘返回空列表而不是 null’真的更好吗?/ 第 4 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.65/5 (13投票s)

2014年10月30日

LGPL3

37分钟阅读

viewsIcon

37848

downloadIcon

587

本系列文章旨在回答这个问题:我们应该从函数返回空列表还是“null”?

目录

第四部分:源代码示例

简介

欢迎来到本系列文章的最后一期。

第一部分中,我们探讨了返回空集合而不是 null 的流行建议(在“无数据”可返回的情况下)。

第二部分中,我们发现此建议需要重新考虑。我们查看了一些示例,表明事实恰恰相反:返回 null 比返回空集合更好。

第三部分中,我们探讨了列表在物理世界中的使用方式,并证实了第二部分的结论。

在本期中,我们将深入探讨涵盖非常常见的编程任务的源代码示例,例如验证输入和处理资源错误。

我们将从一个简单的示例开始,并在后续的每个步骤中增加一些复杂性。

每个示例都将以两种不同的编程语言展示,并且我们将比较不同的方法。

最重要的是,我们将尝试找到以下问题的答案

  • 如果我们编写了“糟糕的代码”,会发生什么——无论是在两种语言中还是使用不同的方法?

    [Note] 注意
    所谓“糟糕的代码”,我指的是诸如不检查 null 或是否为空、不验证函数中的输入参数、忽略运行时可能出现的资源错误等情况。
  • 编程语言的设计及其标准库能否帮助我们用更少的时间编写更可靠、更易于维护的代码?

因此,本文不仅仅是关于“如何做……”,还关于“为什么做……”。

似是而非,但又不尽相同

泰国人很幽默。当他们看到两个相似的物体时,你不会总是听到他们说“这两个物体很相似”。不,他们会说:“似是而非,但又不尽相同”。

同样的话也可以用来形容本文将使用的两种编程语言:Java 和 PPL。

如果你从未听说过 PPL,那么你并不孤单。虽然 Java 是编程史上最成功的语言之一,拥有庞大的开发者社区支持,并且成熟到可以开发大型的、任务关键型的企业应用程序,但 PPL 的情况恰恰相反。

在写了多年糟糕的代码(例如包含大量变量和深度嵌套指令的庞大函数;忽略函数返回的错误;忘记检查 null 等等)并遭受其后果之后,我想知道是否存在一种编程语言可以帮助我写出更好的代码。我没有找到我真正想要的,但我发现了很多好想法,有时惊讶地发现一些非常有效且久经考验的“快速失败!”(Fail-Fast!)功能(例如“契约式设计”,Design by Contract)在大多数流行编程语言中并不支持。最后,我思考了一种新语言并开始创建它。我给它命名为实用编程语言 (PPL),以强调它应该对开发现实世界的应用程序有用且适用。愿景和最终目标是提供一个编程环境,帮助“用更少的时间编写更可靠的代码”。我有很多实现这一目标的想法。有些是好的。有些是坏的。我保留了那些运行良好的功能——并丢弃了那些不行的。

今天,我坚信编程语言的设计是普通程序员在合理时间内编写高质量代码的难易程度的决定性因素。当然,这并不意味着编程语言可以完全阻止程序员设计糟糕的数据结构或编写有 bug 且难以维护的代码。关键在于:

通过语言提供的设计选择和功能,可以有效地防止某些 bug 和某些糟糕的编程实践。

例如,非常常见的忘记检查 null(导致 null 指针错误)的 bug,可以通过语言原生内置的编译时 null 安全性完全消除。本文中的源代码示例(尤其是最后一个示例)应该能说明我所谓的“用更少的时间帮助编写更可靠的代码”。

[Note] 注意

PPL 仍在开发中,尚未准备好开发任务关键型的业务应用程序。

有关更多信息,请参阅网站并阅读常见问题解答

许多基本概念在 Java 和 PPL 中非常相似。例如,这两种语言都是面向对象的、编译型的、静态类型的,并且应用程序运行在 Java 虚拟机 (JVM) 上。然而,两种语言应用了许多截然相反的设计原则。下表绝不是详尽的。它显示了一些差异(以及一个相同点)——仅那些与本文相关并且我们将在源代码示例中遇到的。

表 1. Java 和 PPL 中一些设计选择的比较

  Java PPL
集合 - 集合默认是可变的
- 所有集合都可以是空的
- 集合默认是不可变的
- 只有可变集合才能是空的
字符串 - 字符串默认是不可变的
- 任何字符串都可以是空的
- 字符串默认是不可变的
- 只有可变字符串可以为空
返回集合的函数 有些函数返回空集合,有些返回 null 来表示“无数据”
(取决于作者)
所有函数都一致地返回 null 来表示“无数据”
Null 处理 - 可空类型和非空类型之间没有区别
- 所有类型都可为空
- Null 可以赋给任何对象引用
- 可空类型和非空类型之间有明确的区别
- 类型默认非空
- Null 只能赋给可空对象引用
编译时 Null 安全 不原生支持,
但一些第三方工具为 null 处理提供有限支持
原生支持
Optional 模式 自 Java 8 版本起支持 不支持
(使用 null 和编译时 Null 安全)
契约式设计 不原生支持,
但存在第三方扩展
原生支持
单元测试 不原生支持,
但存在第三方扩展
原生支持
错误处理 使用异常机制处理程序错误(例如堆栈溢出)和运行时错误(例如文件读取错误) - 使用类似异常的机制处理程序错误(例如堆栈溢出)
- 命令在发生运行时错误(例如文件读取错误)时返回一个“错误”对象(除了一个可空的“结果”对象)
类型推断 不支持 支持局部脚本常量和变量

值得注意的是,据我所知,许多(如果不是大多数)流行编程语言倾向于应用“Java”列中的设计原则。PPL 中的一些设计选择可能看起来很奇怪(例如,PPL 中没有空的不变字符串)。但这些选择都是经过深思熟虑并刻意为之的,因为它们都支持重要的快速失败!原则

错误应优先在编译时自动检测,否则应尽早(在运行时)检测。

我们将看到这是如何实现的。

示例 1:返回非空列表

为了热身,我们从一个简单的示例开始,这是一个返回包含大陆名称的不可变字符串列表的函数。

[Note] 注意
这是一篇很长的文章(可能太长了)。如果你是一位经验丰富的程序员并且时间紧迫,那么你可能想跳过前两个示例,直接跳到示例 3

Java 版本

这是 Java 代码

public static List<String> getContinents() {

   List<String> result = new ArrayList<String>();

   result.add ( "Africa" );
   result.add ( "America" );
   result.add ( "Antarctica" );
   result.add ( "Asia" );
   result.add ( "Australia" );
   result.add ( "Europe" );

   return Collections.unmodifiableList ( result );
}

上面的示例显示了返回集合的函数通常涉及的三个基本步骤

  1. 创建一个空的、可变的集合。
  2. 将元素添加到可变集合中。
  3. 将可变集合转换为不可变集合并作为函数的返回值。
[Note] 注意

一些读者可能会问,为什么我们不能直接写……

return result;

……而不是

return Collections.unmodifiableList ( result );

原因是函数应始终返回不可变集合,除非有特定需要可变性。不熟悉不可变对象好处的读者,鼓励他们在网上搜索“不可变数据结构的优势”等术语。有一些优秀的文章可供参考。简而言之:不可变数据具有更简单的 API,更不容易出错(不仅仅是在多进程环境中),没有状态转换需要处理,并且可以自由共享而无需同步。

Java 没有列表字面量,但可以使用方便的 Arrays.asList 方法简化上述代码,该方法返回一个不可变列表

public static List<String> getContinents() {

   return Arrays.asList ( "Africa", "America", "Antarctica", "Asia", "Australia", "Europe" );
}

实际上,上述实现可能导致严重的性能瓶颈,因为每次调用该方法时都会创建一个新列表。这会花费时间创建不必要的列表副本,并在之后由垃圾回收器进行回收。并且需要内存来存储它们。为了避免这种情况,我们可以使用数据缓存。基本思想是:函数第一次调用时创建列表,将列表保存在局部变量中,然后在后续调用中简单地返回同一个列表。下面是一个示例

private static List<String> continents = null;

public static List<String> getContinents() {

   if ( continents == null ) {
      continents = Arrays.asList ( "Africa", "America", "Antarctica", "Asia", "Australia", "Europe" );
   }

   return continents;
}

第一次调用 getContinents() 时,私有字段 continentsnull。然后创建列表并将其分配给私有字段。后续调用仅返回第一次调用时创建的列表。

[Note] 注意

一个更简单的解决方案是定义一个持有列表的 public static 字段,如下所示

public static List<String> continents = 
   Arrays.asList ( "Africa", "America", "Antarctica", "Asia", "Australia", "Europe" );

但这个解决方案有一个缺点:它不使用延迟初始化。假设该列表从未被应用程序使用。那么它将在类加载器加载包含该字段的类时首次创建。这会消耗时间和内存,因此如果频繁使用此模式,可能导致高内存需求和缓慢的启动时间。

PPL 版本

这是 Java 方法 getContinents() 的等价实现,用 PPL 编写

command get_continents
   out list<string> result

   script
      const r = mutable_list<string>.create

      r.append ( "Africa" )
      r.append ( "America" )
      r.append ( "Antarctica" )
      r.append ( "Asia" )
      r.append ( "Australia" )
      r.append ( "Europe" )

      result = r.make_immutable
   .
.

由于大多数读者不熟悉 PPL,让我们深入了解一下它的语法。

  • command get_continents

    PPL 中的 command 类似于 Java 中的 method。它有一个名称(在本例中为 get_continents)、零个或多个输入参数、零个或多个输出参数,并执行一个操作。

    请注意,PPL 不使用花括号来嵌入代码块。在 PPL 中,嵌入在块中的代码是缩进的,块由单行上的点 (.) 终止。因此,像这样的代码在一个使用花括号的语言中……

    foo {
       // body
    }

    ……在 PPL 中写成这样

    foo
       // body
    .

    还请注意,为了提高可读性,PPL 使用下划线 (_) 来分隔标识符中的单词。PPL 不使用驼峰命名法。所以,如果你习惯了……

    thisIsALongIdentifier

    ……你会在 PPL 中看到这样

    this_is_a_long_identifier
  • out list<string> result

    此指令定义了命令的输出。PPL 提供多个输出参数——这是多个输入参数的对应项。因此,每个输出参数也有一个名称。在本例中,我们有一个类型为 list<string> 的单个输出参数,名为 result

    [Note] 注意

    可以在单行上编写多个指令,用指令分隔符 | 分隔。因此,我们也可以写

    command get_continents | out list<string> result
  • script

    script 是命令实现的容器(即执行操作的指令)。

  • const r = mutable_list<string>.create

    此指令声明一个名为 r 的局部常量,并将其初始化为 string 的空可变列表。

  • r.append ( "Africa" )

    字符串 "Africa" 被追加到 r

  • result = r.make_immutable

    此指令将 r 中包含的可变列表转换为不可变列表,然后将其赋给输出参数 result

PPL 提供列表字面量。并且 script 指令是可选的。代码可以缩短如下

command get_continents
   out list<string> result

   result = ["Africa", "America", "Antarctica", "Asia", "Australia", "Europe" ]
.

我们现在可以改进代码并使用缓存和延迟初始化,就像我们在上一节的 Java 版本中所做的那样。但这没有必要。PPL 默认对 static 常量字段使用延迟初始化(除非我们在源代码中显式禁用延迟初始化)。我们可以简单地定义一个常量静态字段,如下所示

const list<string> continents default:["Africa", "America", "Antarctica", "Asia", "Australia", "Europe" ]

如果 continents 在应用程序中从未使用过,它将永远不会被初始化,因此没有时间或内存的浪费。列表将在 continents 首次在应用程序中实际访问时创建,并且后续访问将重用同一个列表。

示例 2:可能没有数据可返回

虽然上一个示例展示了一个总是返回非空列表的函数,但现在我们将看一个可能返回“无数据”的函数。

此示例中的函数接受一个可空的字符串作为输入,并返回一个字符列表,其中包含输入字符串中找到的所有数字。如果输入字符串为 null 或不包含数字,则函数返回 null

Java 版本

以下代码显示了 Java 的实现

public static List<Character> getDigitsInString ( String string ) {

   if ( string == null ) return null;

   List<Character> result = new ArrayList<Character>();

   for ( char ch : string.toCharArray() ) {
      if ( Character.isDigit ( ch ) ) {
         result.add ( ch );
      }
   }

   if ( result.isEmpty() ) {
      return null;
   } else {
      return Collections.unmodifiableList ( result );
   }
}

这段代码有趣的部分当然是最后的 if 语句。如果没有在输入字符串中找到数字(即 result.isEmpty()true),则函数返回 null,否则返回包含数字的不可变列表。

如果这种模式经常使用,我们可以编写一个如下实用程序

public static <T> List<T> toUnmodifiableListOrNull ( List<T> list ) {

   if ( list == null || list.isEmpty() ) {
      return null;
   } else {
      return Collections.unmodifiableList ( list );
   }
}

那么最后一个 if 语句在 getDigitsInString 中可以被简单地写成

return toUnmodifiableListOrNull ( result );
[Note] 注意
当然,另一种解决方案是在未找到数字时返回空列表。但我们不会在这里考虑这个解决方案,因为在本文系列第二部分中解释了在“无数据”情况下返回空集合的缺点。

要测试我们的函数,我们可以使用第三方测试框架(如 JUnit)。但为了简单起见,我们将只使用标准的 Java 功能并编写一个小的测试方法,如下所示

public static void test() {

   // case 1: there are digits
   List<Character> digits = getDigitsInString ( "asd123" );
   assert digits.size() == 3;
   assert digits.get(0) == '1'; // first element
   assert digits.get(digits.size()-1) == '3'; // last element
   assert digits.toString().equals ( "[1, 2, 3]" );

   // case 2: there are no digits
   digits = getDigitsInString ( "asd" );
   assert digits == null;

   // case 3: input is null
   digits = getDigitsInString ( null );
   assert digits == null;
}

如果你想在不使用 IDE 的情况下尝试整个代码,可以按照以下步骤进行(确保你的系统上已安装 Java)

  • 在任何目录中创建文件 ListExample_02.java,内容如下

    import java.util.ArrayList;
    import java.util.Collections;
    import java.util.List;
    
    public class ListExample_02 {
    
       public static List<Character> getDigitsInString ( String string ) {
    
          if ( string == null ) return null;
    
          List<Character> result = new ArrayList<Character>();
    
          for ( char ch : string.toCharArray() ) {
             if ( Character.isDigit ( ch ) ) {
                result.add ( ch );
             }
          }
    
          if ( result.isEmpty() ) {
             return null;
          } else {
             return Collections.unmodifiableList ( result );
          }
       }
    
       public static void test() {
    
          System.out.println ( "ListExample_02:" );
    
          // case 1: there are digits
          List<Character> digits = getDigitsInString ( "asd123" );
          assert digits.size() == 3;
          assert digits.get(0) == '1'; // first element
          assert digits.get(digits.size()-1) == '3'; // last element
          assert digits.toString().equals ( "[1, 2, 3]" );
          System.out.println ( digits );
    
          // case 2: there are no digits
          digits = getDigitsInString ( "asd" );
          assert digits == null;
          System.out.println ( digits );
    
          // case 3: input is null
          digits = getDigitsInString ( null );
          assert digits == null;
          System.out.println ( digits );
       }
    
       public static void main ( String[] arguments ) {
          test();
       }
    }
  • 通过在终端窗口中输入以下操作系统命令来编译文件

    javac ListExample_02.java
  • 通过键入以下命令来执行程序

    java ListExample_02
  • 将显示以下输出

    ListExample_02:
    [1, 2, 3]
    null
    null
[Note] 注意
你可以下载所有示例的源代码(*.zip 文件)(参见文章开头的链接)。

PPL 版本

在 PPL 中,代码如下所示

command get_digits_in_string
   in nullable string string

   out nullable list<character> result

   script
      if string is null
         result = null
         return
      .

      const r = mutable_list<character>.create

      repeat for each character in string
         if character.is_digit then
            r.append ( character )
         .
      .

      result = r.make_immutable_or_null
   .
.

这里有一些需要考虑的点

  • in nullable string string

    此指令定义了一个名为 string 的输入参数,类型为 nullable string。在 PPL 中,所有对象引用默认都是非空的。因此,我们必须使用 nullable 关键字来明确说明 null 是一个有效输入值。

  • out nullable list<character> result

    该命令返回一个可空的字符列表。

  • if string is null
       result = null
       return
    .

    如果命令的输入为 null,则它以 null 作为结果返回。

  • repeat for each character in string
       if character.is_digit then
          r.append ( character )
       .
    .

    这是检查输入字符串中每个字符的循环。如果字符是数字,则将其添加到 r 中的可变列表中。

  • result = r.make_immutable_or_null

    如果 r 中的列表为空,则命令返回 null 作为结果,否则返回一个不可变列表。

PPL 原生支持单元测试。我们可以编写一个如下的测试脚本

test
   // case 1: there are digits
   test "asd123"
   verify result is not null
   verify result.size =v 3
   verify result.first =v '1'
   verify result.last =v '3'
   verify result.to_long_string =v "[1, 2, 3]"

   // case 2: there are no digits
   test "asd"
   verify result is null

   // case 3: input is null
   test null
   verify result is null
.

一些解释

  • test

    test 块内的指令用于单元测试命令 get_digits_in_string

  • test "asd123"

    每个 test 指令启动一个测试用例。在本例中,命令 get_digits_in_string"asd123" 作为输入调用。

  • verify result is not null

    verify 指令用于检查 get_digits_in_string 返回的结果。在本例中,我们确保结果非 null

  • verify result.size =v 3

    列表中必须有 3 个元素。注意比较运算符 =vv 代表 value(值)比较(即比较对象的,而不是它们的引用)。如果我们想比较引用,我们将不得不使用引用比较运算符 =r

  • verify result.first =v '1'

    结果列表中的第一个元素必须是字符 1

如果你想尝试所有代码,可以按照以下步骤进行(安装 PPL 后)

  • 在任何目录中打开一个终端窗口。

  • 通过输入以下命令创建一个名为 list_examples 的 PPL 项目

    ppl create_project project_id=list_examples

    PPL 会创建一个名为 list_examples 的子目录,其中包含一些子目录和文件。

  • 在现有的子目录 work/ppl/source_code/list_examples/ 下创建目录 examples

    在此新目录中,创建文件 se_list_example_02.ppl,内容如下

    service list_example_02
    
       command get_digits_in_string
          in nullable string string
    
          out nullable list<character> result
    
          script
             if string is null
                result = null
                return
             .
    
             const r = mutable_list<character>.create
    
             repeat for each character in string
                if character.is_digit then
                   r.append ( character )
                .
             .
    
             result = r.make_immutable_or_null
          .
          test
             // case 1: there are digits
             test "asd123"
             verify result is not null
             verify result.size =v 3
             verify result.first =v '1'
             verify result.last =v '3'
             verify result.to_long_string =v "[1, 2, 3]"
             %write_line ( result.to_long_string )
    
             // case 2: there are no digits
             test "asd"
             verify result is null
             %write_line ( se_any_type_to_string.to_long_string ( result ) )
    
             // case 3: input is null
             test null
             verify result is null
             %write_line ( se_any_type_to_string.to_long_string ( result ) )
          .
       .
    
    .
  • 通过执行位于项目根目录的 compile_and_build 系统文件来编译和构建项目(即 Linux/Unix 上的 compile_and_build.sh 和 Windows 上的 compile_and_build.bat)。

  • 要执行单元测试,请运行 PPL 项目根目录中的系统命令文件 run_tests(即 Linux/Unix 系统上的 run_tests.sh,Windows 系统上的 run_tests.bat)。

    将显示类似以下的消息

    Running unit tests in list_examples
    testing list_examples.examples.se_list_example_02
    [1, 2, 3]
    #null#
    #null#
    
    Objects tested: 1
    
    BRAVO AND CONGRATULATIONS!
    All tests passed without errors.
[Note] 注意

有关创建 PPL 项目的更多信息,请参阅教程中的开发独立 PC 应用程序一章。

你可以下载包含所有示例的 PPL 项目(*.zip 文件)(参见文章开头的链接)。

示例 3:无效的输入数据

将输入与无效值进行检查是应用防御性编程最有效的方法之一。它是实现健壮、高质量软件的绝对要求。在这种情况下,输入可以指从源到目标的任何类型的数据,例如用户输入、从外部源读取的数据、发送到函数的输入等。在获取输入时立即检查有效性非常重要。对于函数而言,这意味着检查输入应该是函数中的第一件事。

实际上,有两种常见的方法可以保护函数/方法/命令免受无效输入的调用

  • 异常机制:函数中的第一条语句检查输入值,如果找到无效值,则抛出异常。

    这是 Java 和许多其他流行编程语言使用的方式。

  • 契约式设计(也称为契约式编程):对于每个输入参数,都可以定义一个前置条件。最简单的形式是,前置条件是一个布尔表达式,对于输入值,该表达式必须为 true 才能使输入有效。如果不满足前置条件,则会抛出程序错误(异常)。

起初,这两种方法似乎非常相似,因为在这两种情况下,如果输入值无效,都会抛出异常。然而,有一个重要的区别使得契约式设计明显是更好的技术。

在第一种情况(异常机制)中,输入参数的条件定义在对象的实现代码中,而不是在定义对象接口的代码中。所以,在 Java(例如)中,如果一个字符串输入长度不能超过 256 个字符,那么这个条件是在类级别上测试的,而不是在接口级别上。

另一方面,前置条件是对象接口(类型)的一部分,而不是实现(工厂)的一部分。

这带来了巨大的优势

  • 前置条件是调用者和被调用者之间官方契约的一部分。它们出现在 API 文档中。这意味着查看 API 的程序员可以立即看到有效输入参数的要求。例如,我们不会只看到……

    String name

    ……对于一个输入参数,而是……

    String name check: name.size <= 256
    

    ……并可靠地得知,如果函数以超过 256 个字符的名称调用,则会发生程序错误。

  • 前置条件会在子类型中自动继承,并且可以根据需要使其变弱(但不能变强)。这是重要Liskov 替换原则的应用,该原则指出:

    如果 ST 的子类型,那么 T 类型的对象可以被 S 类型的对象替换,而不会改变程序的行为。
  • 前置条件会在给定类型的 But 中自动强制执行。所以,如果一个接口/类型的 But 有三个工厂/类实现,那么就不会有风险在其中一个工厂中忘记编写前置条件,或者在工厂中意外定义不同的前置条件。此外,没有代码重复的风险。

  • 可以以编程方式在运行时评估单个前置条件。因此,客户端代码可以在调用具有前置条件的函数之前以编程方式检查输入数据。

[Note] 注意
有关契约式设计的更多信息,请参阅维基百科文章或阅读PPL 语言手册

为了看到这两种方法的实际应用,我们将编写一个函数,该函数创建一个给定大小的列表,其中每个位置都包含同一个单词。该函数有两个输入参数

  1. 结果列表中元素的数量。

    这个数字必须是一个大于零的正整数。这个输入应该是可选的。如果调用者没有指定值,则默认值为 1。

  2. 用于填充列表的单词。

    这是一个不能为 null 且必须匹配正则表达式 "\w+"(即由一个或多个字母、数字和下划线组成的字符串)的字符串。

函数的返回值为字符串列表。

Java 版本

这是 Java 中一种可能的实现

public static List<String> createFilledWordList ( int num_elements, String word ) {

   if ( num_elements <= 0 )
      throw new IllegalArgumentException ( "'num_elements' must be greater than 0" );
   if ( word == null )
      throw new IllegalArgumentException ( "'word' cannot be null" );
   if ( ! word.matches ( "\\w+" ) )
      throw new IllegalArgumentException ( "'" + word + "' is not a word." );

   List<String> result = new ArrayList<String>();

   for ( int i = 0; i < num_elements; i++ ) {
      result.add ( word );
   }

   return Collections.unmodifiableList ( result );
}

在 Java 中,方法重载(不要与方法覆盖混淆)允许我们定义具有相同名称的另一个方法,只要输入参数的类型不同即可。这可以用来模拟默认输入参数值。除了上述方法,我们还必须添加以下方法,该方法可用于创建包含一个元素的列表

public static List<String> createFilledWordList ( String word ) {

   return createFilledWordList ( 1, word );
}

下面是一个说明 createFilledWordList 单元测试的方法

public static void test() {

   // case 1: standard case
   List<String> foos = createFilledWordList ( 3, "foo" );
   assert foos.toString().equals ( "[foo, foo, foo]" );

   // case 2: use default value for 'num_elements'
   foos = createFilledWordList ( "foo" );
   assert foos.toString().equals ( "[foo]" );

   // case 3: illegal input value
   // Note: this test would be easier to be written in a real unit-test framework
   boolean exceptionRaised = false;
   try {
      foos = createFilledWordList ( "foo bar" );
   } catch ( Exception e ) {
      exceptionRaised = true;
   }
   assert exceptionRaised;
}

PPL 版本

command create_filled_word_list
   in positive_32 num_elements default:1
   in string word check:word.matches_regex ( regex.create ( '''\w+''' ) )

   out list<string> result

   script
      const r = mutable_list<string>.create

      repeat num_elements times
         r.append ( word )
      .

      result = r.make_immutable
   .
.

对以上代码的说明

  • in positive_32 num_elements default:1

    在这里,我们声明输入参数 num_elements 的类型为 positive_32,默认值为 1

    除了带符号整数,PPL 还提供无符号整数。positive_32 表示一个 32 位正整数,不包括零(还有一个 zero_positive_32 类型,包括零作为值)。在本例中,使用 positive_32 类型有两个优点:

    • 我们无需显式检查输入值是否大于零。

    • 通过将有符号整数赋值给无符号整数(无显式转换)存在调用 create_filled_word_listnum_elements 设置为零或负值(从而导致运行时错误)的风险被消除了,这是一种类型兼容性违规,编译器会报告为错误。

    default 子句用于指定一个表达式,该表达式表示输入 num_elements 的默认值。调用 create_filled_word_list 时不为 num_elements 指定值,与使用值 1 调用命令赋给 num_elements 相同。

    没有必要显式检查 num_elements 是否为 null,因为如前所述,null 默认是不允许的。

  • in string word check:word.matches_regex ( regex.create ( '''\w+''' ) )

    第二个输入参数是(非空的)字符串 word,其值必须匹配正则表达式 \w+

    此指令演示了前置条件(契约式设计)的使用。我们使用 check 子句定义一个布尔表达式,该表达式在命令执行前必须满足。如果 create_filled_word_listword 输入不包含单词的方式调用,则会立即抛出程序错误。

    注意嵌入在 ''' 之间的正则表达式。这是一个三引号字符串字面量的示例,它可以包含换行符和在标准字符串字面量中需要转义的字符。因此 '''\w+''' 等同于 "\\w+"

  • out list<string> result

    输出是一个非空的、包含非空字符串的列表。

  • 命令的脚本应该是不言自明的。我们创建一个大小为 num_elements 的可变列表,其中每个位置都包含 word。最后,返回一个不可变列表。

测试脚本可以这样编写

test
   // case 1: standard case
   test ( num_elements = 3
      word = "foo" )
   verify result.to_long_string =v "[foo, foo, foo]"

   // case 2: use default value for 'num_elements'
   test ( word = "foo" )
   verify result.to_long_string =v "[foo]"

   // case 3: illegal input value
   test ( word = "foo bar" )
   verify_error // check that a program error is thrown
.

示例 4:资源错误

现在来介绍最有趣、最有启发性的示例。除了前几个示例中已涵盖的要点外,我们将增加另一个常见的困难:一个可能在运行时发生的系统错误,必须在源代码中进行预测。这些问题的典型示例包括:文件读取错误、数据库不可用、网络连接错误等。

最重要的是,我们将看看如果我们编写“糟糕的代码”,会发生什么,并将比较不同的方法并分析它们如何影响软件的可靠性和可维护性。

我们将要编写的函数读取一个文本文件,并返回不匹配正则表达式的行。该函数有两个输入参数

  • 要读取的文件
  • 用于检查每一行的正则表达式

函数返回一个字符串列表,表示不匹配正则表达式的行。

此函数的一个实际用例是检查文件的有效性。

例如,假设一个配置文件包含由键/值对组成的参数,如下所示

start_line: 10
end_line:20
verbose:yes

那么可以使用以下正则表达式来查找此类配置文件中的无效条目

\w+:[ \t]*\w+

此正则表达式翻译为:行必须以一个或多个单词字符(字母、数字或下划线)开头,后跟冒号(:),后跟可选的空格,再后跟一个或多个单词字符。

Java 版本

这是 Java 的源代码建议

public static List<String> findLinesNotMachingRegexInFile ( File file, Pattern lineRegex ) throws IOException {

   // read lines from file and store result in 'lines'
   List<String> lines = Files.readAllLines ( file.toPath() );

   // store all lines that don't match the regex in a mutable string list
   List<String> result = new ArrayList<>();
   for ( String line : lines ) {
      if ( ! lineRegex.matcher ( line ).matches() ) {
         result.add ( line );
      }
   }

   return result;
}

这段代码 OK 吗?

假设我们有一个文件,内容如下

start_line: 10
illegal
verbose:yes

如果我们使用正则表达式 \w+:[ \t]*\w+ 调用 findLinesNotMachingRegexInFile,那么该函数将正确返回一个包含无效行 illegal 的列表。

所以,代码是 OK 的,不是吗?

不,不是。

任何有经验的程序员都会立即指出,上面的代码只是我们应该写的代码的“粗略快速”版本。是的,上面的代码是我们大多数人想要写的代码(“请让我写简单的代码,不要打扰我那些不太可能发生的异常情况!”)。可悲的事实是,这确实是我们实际代码中经常看到的那种源代码(是的,我本人也经常写这样的代码,我对此并不自豪)。

这段代码的问题在于以下六个边界情况没有明确处理

  1. 函数以输入参数 file 设置为 null 调用。
  2. 函数以输入参数 lineRegex 设置为 null 调用。
  3. 文件不存在。
  4. 文件无法读取(例如,用户没有访问权限)。
  5. 文件为空。
  6. 文件包含空行。
[Note] 注意

在理想世界中,我们对函数的初始规范将提及所有这些边界情况,并确切地告诉我们在每种情况下该怎么做。但我们生活在一个不理想的世界里,现实世界的规范通常不提及边界情况。但这并不能成为我们在源代码中也忽略它们的正当理由。

[Note] 注意

另一个边界情况是为二进制文件调用该函数,该文件不包含文本。但是,我们不会讨论这种情况,因为不幸的是,没有简单可靠的方法可以知道文件是否包含文本。

有关更多信息,请查看 Stackoverflow 问题Java 中确定二进制/文本文件类型?

另一 G 点是,上述方法在未找到行时返回空列表,而返回 null 更好,正如我们在本文系列第二部分中看到的。

处理所有边界情况并不困难。下面是一个改进版本

public static List<String> findLinesNotMachingRegexInFile ( File file, Pattern lineRegex )
   throws IOException {

   // check input arguments
   if ( file == null )
      throw new IllegalArgumentException ( "Input argument 'file' cannot be null." );
   if ( ! file.exists() )
      throw new IllegalArgumentException ( "File " + file + " doesn't exist." );
   if ( lineRegex == null )
      throw new IllegalArgumentException ( "Input argument 'lineRegex' cannot be null." );

   // read lines from file and store result in 'lines'
   List<String> lines;
   try {
      lines = Files.readAllLines ( file.toPath() );
   } catch ( IOException e ) {
      throw new IOException ( "File " + file + " cannot be read.", e );
   }

   // if there are no lines in the file then throw an exception
   if ( lines.isEmpty() ) {
      throw new IllegalArgumentException ( "File " + file + " is empty." );
   }

   // store all lines that don't match the regex in a mutable string list
   List<String> result = new ArrayList<>();
   for ( String line : lines ) {
      if ( ! line.isEmpty() ) { // ignore empty lines
         if ( ! lineRegex.matcher ( line ).matches() ) {
            result.add ( line );
         }
      }
   }

   // if lines have been found then return an immutable non-empty list, else return null
   if ( result.isEmpty() ) {
      return null;
   } else {
      return Collections.unmodifiableList ( result );
   }
}

现在所有边界情况都已明确处理,但是……方法体的大小增加了三倍多!

写这么长的代码值得吗?这段代码真的更好吗?

在本文的引言中,我们问过“如果我们编写了‘糟糕的代码’,会发生什么?”

为了找到答案,让我们看看每一个边界情况,并比较软件根据我们编写的代码是如何表现的。

  • 函数以输入参数 file 设置为 null 调用。

    • 在版本 1 中,当在语句中执行 file.toPath() 时,会抛出 NullPointerException

      List<String> lines = Files.readAllLines ( file.toPath() );
    • 在版本 2 中,会抛出 IllegalArgumentException

    版本 2 明显更好,因为

    • IllegalArgumentExceptionNullPointerException 提供了更多信息,并有助于调试。假设该函数位于第三方库中,并且我们无法访问源代码。那么在 findLinesNotMachingRegexInFile 中看到 NullPointerException 会使查找 bug 更加困难。另一方面,具有清晰错误消息的 IllegalArgumentException(输入参数“file”不能为 null)会立即告诉我们 bug 的原因。

    • 版本 1 中的行为取决于函数的实现(即函数体的代码)。在本例中,bug 导致 NullPointerException。如果代码稍后更改,可能会抛出另一个异常,或者根本不抛出异常,但函数简单地返回空列表或 null

    • 行为可以在方法的文档注释中说明(上面未显示),并且是方法用户有用的 API 信息。

    • 以文件输入参数设置为 null 来调用该方法是非法的,这是调用者和被调用者之间官方契约的一部分。

  • 函数以输入参数 lineRegex 设置为 null 调用。

    此情况与情况 1 类似。版本 2 因相同原因更好。

  • 文件不存在。

    • 在版本 1 中,如果文件不存在,则取决于 Files.readAllLines ( file.toPath() ) 的行为。如果我们查看 Java API 文档,我们会发现关于这种情况的信息不多。所以,我们不得不查看 JDK 的源代码或者尝试一下。我做了后者,抛出了 NoSuchFileException。然而,我们不能保证在未来的版本中也会发生同样的情况。

    • 在版本 2 中,会抛出 IllegalArgumentException。这再次更好,因为异常不依赖于函数内部使用的语句。有一个清晰可控的错误消息。

      此外,使用不存在的文件调用该方法是一个错误,必须归咎于调用者。因此,抛出 IllegalArgumentException 而不是依赖于方法体的任意异常在语义上是更正确的。

  • 文件无法读取(例如,用户没有访问权限)

    • 与前一种情况类似,在版本 1 中,取决于 Files.readAllLines() 的行为。根据 Java API 文档,会抛出 IOExceptionSecurityException

    • 在版本 2 中,我们控制这种情况并抛出特定的异常。我们的意图在代码和 API 中得到了清楚的记录。

  • 文件为空

    • 在版本 1 中,Files.readAllLines() 返回一个空列表,这意味着从函数返回的结果也将是一个空列表。因此,版本 1 在语义上不同的情况下返回相同的结果——在文件为空的情况下,以及在包含不匹配正则表达式的行的非空文件的情况下。

    • 在版本 2 中,我们显式地抛出带有清晰错误消息的异常

    版本 2(至少在大多数情况下)更好,因为执行一个空文件并不合理,而且文件很可能是由于系统之前的异常而变为空的。客户端代码被迫采取适当的措施。

    版本 2 也有助于调试。假设一个空文件是不应该在正常条件下出现的情况。在版本 1 中,函数只是返回一个正常值(而不是错误),这意味着空文件异常在软件测试期间不太可能被检测到。

  • 文件包含空行
    • 在版本 1 中,所有空行都包含在结果中,这很可能不是我们想要的。
    • 在版本 2 中,空行被显式忽略。

我们可以看到版本 2 在所有边界情况下的表现都更好。

我们单独控制每一个潜在的运行时问题,并以适当的方式进行处理。这导致行为一致,并使代码更易于维护,因为返回给客户端代码的错误信息不依赖于对其他函数的内部调用,并且错误消息是具体的且清晰的。

缺点当然是我们必须编写大量的错误处理代码。而且没有什么能阻止我们忘记恰当地处理任何一个棘手的边缘情况。

但这不应该令人惊讶。众所周知,编写健壮且易于维护的代码困难的,需要大量的纪律和经验。

如果编程语言能帮助我们,让它变得不那么困难,那不是很棒吗?

在回答这个问题之前,让我们先看一个单元测试方法

public static void test() {
   try {
      // case 1: lines found

      List<String> params = Arrays.asList
      ( "start_line: 10", "end_line:20", 
      "verbose:yes", "illegal", "missing_value:", ":missing_name" );
      File testFile = createTemporaryTextFile ( params );
      Pattern regex = Pattern.compile ( "\\w+:[ \\t]*\\w+" );
      List<String> linesFound = findLinesNotMachingRegexInFile ( testFile, regex );
      assert linesFound.toString().equals ( "[illegal, missing_value:, :missing_name]" );

      // case 2: no lines found

      params = Arrays.asList ( "start_line: 10", 
      "end_line:20", "", "verbose:yes", "" );
      testFile = createTemporaryTextFile ( params );
      linesFound = findLinesNotMachingRegexInFile ( testFile, regex );
      assert linesFound == null;

      // case 4: empty file

      testFile = createTemporaryTextFile ( new ArrayList<String>() );
      boolean exceptionOccured = false;
      try {
         linesFound = findLinesNotMachingRegexInFile ( testFile, regex );
      } catch ( IllegalArgumentException e ) {
         exceptionOccured = true;
      }
      assert exceptionOccured;

      // case 4: error (file doesn't exit)

      testFile = new File ( "C:\\sdhkdjhgkjdhgkdfgdkghsdfgdfghhdf.txt" );
      exceptionOccured = false;
      try {
         linesFound = findLinesNotMachingRegexInFile ( testFile, regex );
      } catch ( IllegalArgumentException e ) {
         exceptionOccured = true;
      }
      assert exceptionOccured;

   } catch ( IOException e ) {
      e.printStackTrace();
      assert false;
   }
}

private static File createTemporaryTextFile ( List<String> lines ) throws IOException {

   File file = File.createTempFile ( "temp", null );
   file.deleteOnExit();
   Files.write ( file.toPath(), lines, StandardOpenOption.WRITE );

   return file;
}

PPL 版本

PPL 中的实现如下所示

command find_lines_not_maching_regex_in_file
   in file file check:file.exists
   in regex line_regex
   %system_error_handler_input_argument

   out nullable list<string> result
   out nullable file_error error
   out_check: not ( result #r null and error #r null ) // 'result' and 'error' cannot both be non-null

   script

      // read lines from file and store result in constant 'lines'
      se_text_file_IO.restore_lines_from_text_file (
         file = i_file
         error_handler = i_error_handler ) \
         ( const lines = result
         const file_read_error = error )

      // if there was an error reading the file then return immediately
      if file_read_error is not null then
         result = null
         error = file_error.create (
            description = """File {{file.path}} cannot be read. 
            Reason: {{file_read_error.description}}"""
            cause = file_read_error
            resource = file )
         return
      .

      // if there are no lines in the file then return an error
      if lines is null then
         result = null
         error = file_error.create (
            description = """File {{file.path}} is empty."""
            resource = file )
         return
      .

      // store all lines that don't match the regex in a mutable string list
      const r = mutable_list<string>.create
      repeat for each line in lines
         if line is not null then // ignore empty lines
            if not line.matches_regex ( line_regex ) then
               r.append ( line )
            .
         .
      .

      // if lines have been found then return an immutable list, else return 'null'
      result = r.make_immutable_or_null
      error = null
   .
.

以下是一些解释

  • in file file check:file.exists
    in regex line_regex

    命令的第一个输入参数名为 file。它的类型是 non-nullable filecheck 子句定义了一个前置条件,要求在调用命令时文件必须存在。如果文件不存在,则会抛出程序错误。

    第二个输入参数名为 line_regex,类型为 non-nullable regex

  • %system_error_handler_input_argument

    这是一个 PPL 中的源代码模板示例。在本文中,我们不探讨模板的工作原理。如果您想了解更多信息,请参阅语言手册。只需知道编译器将此模板标识符展开为

    in system_error_handler error_handler default:se_system_utilities.default_system_error_handler

    这意味着存在一个名为 error_handler 的第三个输入参数。我们也不会讨论这个输入参数的作用。基本思想是,在 PPL 中,可能在运行时失败的命令会接受一个错误处理程序,该处理程序默认会将错误消息发送到操作系统的错误设备。

  • out nullable list<string> result
    out nullable file_error error

    PPL 支持多个输出参数。此命令有两个可空的输出参数:resulterror

    每次调用命令时,可能有三种结果

    1. 在文件中找到了不匹配正则表达式的行

      在这种情况下,输出参数 result 包含找到的行列表,而 errornull

    2. 未找到任何行

      在这种情况下,resulterror 都为 null

    3. 发生运行时错误

      在这种情况下,resultnull,而 error 指向一个描述问题的错误对象。

    [Note] 注意
    与 Java 和其他编程语言不同,PPL 不使用异常机制将资源错误信号传递给客户端代码。相反,它使用多个输出参数,如上所述。这样做的理由在此不详述——它可能是未来另一篇文章的主题。
  • out_check: not ( result #r null and error #r null ) // 'result' and 'error' cannot both be non-null

    这是一个前置条件(契约式设计)的示例。它规定命令永远不会在输出参数 resulterror 都指向非空值的情况下返回。如果命令的实现违反此条件,将在运行时抛出程序错误。

  • // read lines from file and store result in constant 'lines'
    se_text_file_IO.restore_lines_from_text_file (
       file = i_file
       error_handler = i_error_handler ) \
       ( const lines = result
       const file_read_error = error )

    se_text_file_IO 是一个 PPL 服务(类似于 Java 中只有 static 成员的类)。要读取文件的文本内容,我们在此服务中使用命令 restore_lines_from_text_file。此命令有两个输入参数

    • 要读取的 file - 我们将其赋给命令 find_lines_not_maching_regex_in_file 的输入参数 file。注意:i_file 中的 i_ 前缀是可选的,这里使用它来明确说明我们赋的是一个输入参数。

    • 用于处理可能发生的任何文件错误的 error_handler

    它还有两个输出参数

    • result 是一个字符串列表,每个字符串代表文本文件中的一行。此值存储在名为 lines 的局部脚本常量中。

    • error 指向一个错误对象,以防发生文件读取问题。此输出值存储在名为 file_read_error 的局部脚本常量中。

脚本的其余部分应该是不言自明的。

在之前一篇题为为什么我们应该喜欢 'null' 的文章中,我们看到了现实世界应用程序中如此多的 null 指针 bug 的原因在于,我们常常忘记检查 null。在我们当前的示例中,同样有很多事情我们可能忘记检查。因此,现在让我们分析一下如果我们忘记显式处理我们在上一节看到的六个边界情况中的任何一个,会发生什么。

  1. 我们忘记检查输入参数 file 是否为 null

    这不会发生,因为在 PPL 中,默认情况下所有输入参数(更广泛地说,所有对象引用)都是非空的。

  2. 我们忘记检查输入参数 line_regex 是否为 null

    同样的原因不会发生:line_regex 默认是非空的。

  3. 我们忘记检查文件是否存在

    在这种情况下,se_text_file_IO.restore_lines_from_text_file 将报告一个错误,该错误将转发给客户端代码。这不是一个理想的解决方案,因为转发给客户端的错误取决于供应商的实现,这意味着错误类型可能会在实现更改时发生变化。但更重要的是,“文件必须存在”这个条件没有在客户端和供应商之间的契约中指定,因此在 API 文档中也不可见。最好为输入参数指定 check:file.exists,就像我们在上面的代码中所做的那样。

  4. 我们忘记检查文件读取错误时报告的错误

    假设,而不是写……

    se_text_file_IO.restore_lines_from_text_file (
       file = i_file
       error_handler = i_error_handler ) \
       ( const lines = result
       const file_read_error = error )

    ……我们不考虑文件错误,而只是写

    se_text_file_IO.restore_lines_from_text_file (
       file = i_file
       error_handler = i_error_handler ) \
       ( const lines = result )
    [Note] 注意

    另一种语法(也忽略了错误输出)是

    const lines = se_text_file_IO.restore_lines_from_text_file.result (
       file = i_file
       error_handler = i_error_handler )

    这不会发生,因为 PPL 编译器会在客户端代码中忽略命令的错误输出时发出警告。这类似于 Java 和其他编程语言中的受检异常。供应商代码中抛出的受检异常必须在客户端代码中捕获,否则会发生编译器错误。

    有人可能会想,如果我们不忘记将 restore_lines_from_text_file 报告的错误存储到局部常量中(即,我们实际上写的是 const file_read_error = error),但随后我们忘记检查 file_read_error 是否报告了错误,也就是说,我们不写……

    if file_read_error is not null then
     ...

    这个 bug 也不会发生,因为编译器在声明了局部常量或变量但从未在脚本中使用时会报告错误。

  5. 我们忘记检查文件是否为空,即我们不写

    if lines is null then
     ...

    这不会发生,因为编译器会在指令中报告错误

    repeat for each line in lines
    

    解释repeat for each 指令中用于集合的表达式(在本例中为 lines)的类型必须是非空的,或者它必须已在源代码中进行了非空检查。

    在本例中,常量 lines 是一个可空类型(即 nullable list<nullable string>:它要么是 null,要么是字符串列表,其中可能包含 null)。原因是 PPL 中没有不可变的空列表。如果文件为空,restore_lines_from_text_file 会将 null 作为 result 返回(并且输出参数 error 也为 null)。因此,编译器知道 lines 可能是 null,因此只有在先前检查过非 null 时才会在 repeat for each 指令中接受它。

    [Note] 注意
    在内部,编译器使用静态代码分析来进行此类代码验证。
  6. 我们忘记检查文件中的空行

    同样,这不会发生,因为编译器会报告错误。

    解释:

    看看

    if not line.matches_regex ( line_regex ) then

    表达式 line.matches_regex 仅在 line 是非空类型时有效,或者 line 在运行时已检查为非 null。这是 PPL 编译器内嵌的最重要的规则之一——在编译时 null 安全的上下文中。它消除了运行时出现 null 指针错误的风险。

    在本例中,line 是一个可空类型(即 nullable string)。原因是 PPL 中没有不可变的空字符串。所以,如果文件中的第三行是空行,那么 lines 中的第三个元素将是 null,并且在 repeat for each 指令的第三轮循环中,循环常量 line 将指向 null。因此,省略写入……

    if line is not null then

    ……会导致编译器报告错误。

我们可以看到,只有一种边界情况(情况 3,如上所述:文件必须存在)可能被程序员遗忘。

在所有其他边界情况下,编译器都会协助我们编写更健壮的代码,因为忘记处理特殊情况会导致编译器错误。代码的“粗略快速”版本在 PPL 中无法编译。

重要的是要注意,这种编译器辅助之所以可能,仅仅是因为本文引言中提到的设计选择。在本例中,以下规则的组合是相关的

  • 不可变集合不能为空(即,使用 null 来表示“无数据”)
  • 不可变字符串不能为空(即,使用 null 来表示“无数据”)
  • 对象引用默认非空
  • 编译器原生支持 null 安全
  • 命令返回的错误对象不能在客户端代码中被忽略

没有这些设计选择,忘记正确处理边界情况的风险会更大。

除了命令实现中的 bug 外,编译器还能检测到调用命令的客户端代码中的一些 bug。例如,以下 bug 会导致编译器错误消息

  • 命令以输入参数 file 设置为 null 调用,或者设置为 nullable file 类型但尚未在调用命令时检查为非 null 值的对象。
  • 编译器对输入参数 line_regex 执行类似的检查
  • 命令返回的 error 对象在客户端代码中被忽略。

可以使用以下测试脚本对命令 find_lines_not_maching_regex_in_file 进行单元测试

      test

         // case 1: lines found

         var params = '''start_line: 10
end_line:20
verbose:yes

illegal
missing_value:
:missing_name
'''
         se_text_file_IO.create_temporary_text_file (
            delete_file_on_exit = yes
            text = params ) (
            var test_file = result
            var file_error = error )
         verify file_error is null
         verify test_file is not null

         const regex = regex.create ( '''\w+:[ \t]*\w+''' )

         test ( file = test_file
            line_regex = regex )

         verify error is null
         verify result is not null
         verify result.to_long_string =v "[illegal, missing_value:, :missing_name]"

         // case 2: no lines found

         params = '''start_line: 10
end_line:20

verbose:yes

'''
         file_error = se_text_file_IO.store_string_to_existing_file (
            string = params
            file = test_file )
         verify file_error is null

         test ( file = test_file
            line_regex = regex )

         verify error is null
         verify result is null

         // case 3: empty file

         file_error = se_empty_file_utilities.empty_existing_file (
            file = test_file )
         verify file_error is null

         test ( file = test_file
            line_regex = regex )

         verify error is not null
         verify result is null

         // case 4: error (file doesn't exit)

         test_file = file.create ( file_path.create ( '''C:\sdhkdjhgkjdhgkdfgdkghsdfgdfghhdf.txt''' ) )
         test ( file = test_file
            line_regex = regex )
         verify_error
      .
   .

结论

本文的最后一个示例突显了编写健壮可靠软件如此之难的主要原因之一:边界情况

它们很容易被忘记,并且需要大量的纪律才能正确处理。

它们是导致软件项目失败以及大大超出时间和预算估计的常见原因(以及其他原因)。

不幸的是,边界情况经常出现在各种算法和领域中。

我们在上一章中的“粗略快速”示例代码是一个只有 7 条语句的 Java 方法。然而,有不下 6 个边界情况被忽略,导致行为不正确或软件难以维护。这是一个异常高的“每指令边界情况”比率。但假设平均每 10 条指令有一个边界情况。那么一个由数千条指令组成的小型应用程序就包含数百个边界情况。此外,边界情况可以与其他边界情况交互,从而导致更高级别的边界情况,这些边界情况可以……(你懂的)。即使是经验最丰富的程序员,在编写良好代码的最好意图下,也忘记一些边界情况。其中一些不会被软件测试覆盖,其中一些会在生产模式下出现。

因此,我们需要的是一个能够技术上可行地自动检测未处理边界情况的编程环境。

好消息是,正如本文所示,语言的设计可以促进对某些边界情况的自动检测。忘记处理它们的风险可以大大降低。

编译时 null 安全是一种非常有效的方法。它消除了 null 指针错误,这是许多应用程序中最常见的 bug。此外,指向 null 的对象引用几乎总是一个边界情况,而 null 安全消除了忘记显式处理该边界情况的风险。

很多时候,空列表和空字符串代表了相当多的边界情况。通过不使用空列表和空字符串,而是使用建议的 null,所有这些边界情况也都被 null 安全性覆盖了。

还有更多技术可以发现其他类型的编译时边界情况。例如,编译器也可以报告除零的风险,并且在允许执行除法之前,我们可以被强制检查除数的非零性。注意:这是 PPL 的待办事项之一。

总之,最终结论是我们引言中提出的第二个问题的答案

是的,编程语言的设计及其标准库绝对可以帮助我们用更少的时间编写更可靠、更易于维护的代码。

个人经验

一年前,PPL 与今天不同。

它没有 null 安全。所有对象都可以为空。集合和字符串可以为空。我只是应用了我二十多年编程经验的习惯,并没有质疑其基本原理。

然而,经过一些深思熟虑后,我应用了本文引言中列出的设计选择。然后,我渴望看到这些更改将如何影响我需要重构的现有代码,以服从新规则。

我的代码会变得更好吗?

答案是明确的“是”。

我常常欣喜若狂地发现,我被迫修复了糟糕的代码。编译器不再允许我编写“粗略快速”的代码,例如忽略潜在的运行时错误、不检查 null 或不处理边界情况。一些潜伏在源代码中但尚未在运行时出现的 bug 现在被编译器自动检测到,我不得不修复它们。毫无疑问——我的代码质量提高了。我再也不想回去了。

这些观察当然不具有代表性。也许它们只是作者的偏见。没关系!我的信念非常坚定,因此我想在本文中分享并解释它们。

© . All rights reserved.