likes
comments
collection
share

使用Null Object设计模式时没注意到这一点就相当于白用

作者站长头像
站长
· 阅读数 12

使用Null Object设计模式时没注意到这一点就相当于白用

在各种项目中,我们可能会反复看到类似下面这样的代码

returnReference = call_a_function()
if returnReference == null {
  // 如果函数返回的引用/指针为null,表示需要获取的对象/值不存在
  // 执行处理异常情况的逻辑
} else {
  // 需要的对象/值存在
  // 执行处理正常情况的主逻辑
  returnReference.doSomething()
}

也就是说,我们往往必须检查函数或方法的返回值,先确保其不为Null再调用上面的方法(向其发送消息)。这是因为对Null调用方法通常会报错(是的,存在不会报错的特殊情况)。当然,对于函数的参数,局部或全局变量等也需要相同的检查。

注:本文用首字母大写的Null泛指各种语言中的空指针或空引用;用代码体的null(PHP、Java)或nil(Go)特指对应语言中的空指针或空引用。

对Null调用方法有可能不报错吗?试试文末那段Go代码吧^_^

为了防止这种判断是否为Null的if else在项目中遍地开花,前辈程序员们发明了Null Object这一设计模式。该模式是用称为null object(空对象)的特殊对象来取代Null,改用这种仅带有空值和空方法的对象来表示不存在、未知、无意义等异常数据

如图所示,用null object取代Null就能消除大量if else,使程序员聚焦于主逻辑,提升程序的可读性。

使用Null Object设计模式时没注意到这一点就相当于白用

这样说可能太过抽象,我们还是先通过一个具体例子来体会一下Null Object设计模式的好处。

示例代码

假设我们要编写一个给小朋友使用的能播放各种动物叫声的程序。

使用Null Object设计模式时没注意到这一点就相当于白用

我们先用PHP来编写这段程序。

interface Animal {
    public function makeSound();
}

class Dog implements Animal {
    public function makeSound() {
        echo "WOOF\n";
    }
}

class Cat implements Animal {
// ...
  
function makeAnimalFromAnimalType(string $animalType): Animal {
    switch ($animalType) {
        case 'dog':
            return new Dog();
        case 'cat':
            return new Cat();
        // ...
    }
}
  
makeAnimalFromAnimalType('dog')->makeSound();

我们这里用输出语句(echo语句)代替播放动物的叫声。如果小朋友想听小狗叫,这个程序就会通过makeAnimalFromAnimalType('dog')创建出Dog类的对象,然后调用上面的makeSound()方法,这样就会”听“到“汪汪汪“(WOOF)的叫声。小猫喵喵叫也是同样的逻辑。

但如果小朋友想知道小兔子怎么叫呢?

我们没有定义Rabbit类,而且也不知道兔子怎么叫。就算问了专家,知道了兔子的叫声,那狐狸怎么叫呢?小朋友总能想出叫声未知的动物。

这时Null Object模式就派上用场了。

首先我们定义出一个NullAnimal类①,并实现了makeSound()方法,只不过方法里什么也没有做。

class NullAnimal implements Animal { // ①
    public function makeSound() {
        // silence...
    }
}

function makeAnimalFromAnimalType(string $animalType): Animal {
    switch ($animalType) {
        case 'dog':
            return new Dog();
        case 'cat':
            return new Cat();
        default:
            return new NullAnimal();    // ②
    }
}

$animalType = 'rabbit';
makeAnimalFromAnimalType($animalType)->makeSound(); // ③ ..the null animal makes no sound

然后我们在函数makeAnimalFromAnimalType()中为所有叫声未知的动物统统返回new NullAnimal()②。

只需这两步,我们就可以放心对makeAnimalFromAnimalType()的返回值调用makeSound()了。因为该函数不会返回null③,也就防止后续代码对null调用makeSound()了。这就意味着无须再通过if makeAnimalFromAnimalType($animalType) == null进行判断了。

Java、Go等语言的代码与此大同小异,这里就不一一列举了。

怎么样,这个设计模式很简单吧。

需要特别注意的点在哪里

Null Object模式看似简单,但正如本文标题说的,里面有一个特别需要注意的点,一旦没处理好就等于前功尽弃

在揭晓答案之前,我们再来重点看一看这个根据动物的名称返回动物类实例的makeAnimalFromAnimalType()的函数。

那么问题来了,如果把default分支返回的null object NullAnimal改为nullnil,会怎么样呢?

接下来会以PHP、Java和Go这三种主流语言为例。首先从PHP的代码看起。

<?php
function makeAnimalFromAnimalType(string $animalType): Animal {
    switch ($animalType) {
        case 'dog':
            return new Dog();
        case 'cat':
            return new Cat();
        default:
            return null;    // <--
    }
}

makeAnimalFromAnimalType("rabbit");
// Fatal error: Uncaught TypeError: makeAnimalFromAnimalType(): Return value must be of type Animal, null returned in ...

报错了!(有点意外吧)因为makeAnimalFromAnimalType()之后的类型提示: Animal强制要求该函数返回一个类型为Animal的值,而null不是。

再来看看Java中的情况。

interface Animal {
    void makeSound();
}

class Dog implements Animal {
// ..

class NullAnimal implements Animal {
// ..

public class AnimalSound {
    public static Animal makeAnimalFromAnimalType(String animalType) {
        switch (animalType) {
            case "dog":
                return new Dog();
            default:
                return null; // <--
        }
    }

    public static void main(String[] args) {
        Animal rabbit = makeAnimalFromAnimalType("rabbit");    // ①
        rabbit.makeSound(); // ②
    }
}
  
// Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Animal.makeSound()" because "<local1>" is null at AnimalSound.main(...

可以看到,不同于PHP,在Java中,makeAnimalFromAnimalType()可以返回null①,但是在null上调用makeSound()会抛出空指针异常②。

关于一个声明返回类型为T的方法能否返回null这个问题,在2015年于上海举办的PHP大会上,一位程序媛小姐姐还问过PHP大神鸟哥,鸟哥当时感叹道:人家这是认真听讲了。这一幕的视频在🎬www.bilibili.com/video/BV1v6…

最后再来看看Go语言的makeAnimalFromAnimalType()能否返回nil

type Animal interface {
    makeSound()
}

func makeAnimalFromAnimalTypes(animalType string) Animal {
    switch animalType {
    case "dog":
        return &Dog{}
    default:
        return nil
    }
}

func main() {
    rabbit := makeAnimalFromAnimalTypes("rabbit")
    rabbit.makeSound()
}
// panic: runtime error: invalid memory address or nil pointer dereference

Go和Java一样,makeAnimalFromAnimalTypes()可以返回nil,但是不能在它上面调用makeSound()

绕了这么半天,一会能返回null/nil,一会又不能,到底想说明什么问题呢?不要着急。

结论

回想一下,我们就是为了避免项目中if reference == null {} else {}这样的代码遍地开花,才使用Null Object设计模式的。但使用该设计模式后,一旦本该返回null object(如new NullAnimal()的地方返回了null/nil,就可能绕过编译器的类型检查(PHP看似没绕过去,但试试去掉函数后面的: Animal呢,是不是就绕过去了),导致又有可能对Null调用方法,进而是不是还要再if-else提前检查一下是不是Null呢,这不又回到了最初的情况!

所以使用Null Object设计模式时,要注意的点就是:应该返回null object的地方绝不能再返回null/nil了。

可明明定义出了NullAnimal,又怎么可能在makeAnimalFromAnimalTypes()default分支返回null/nil呢?那不白定义了,不可能犯这种低级错误的。

但不要忘了,这里给出的毕竟只是最简单的示例代码。在实际的项目中,可能有各种类型的null object,有的表示不存在的订单,有的表示查无此人的用户,有的表示……。

而且考虑到历史遗留代码、技术债等原因,产生null object的地方可能散布在代码中的各个位置,可不一定都集中在类似makeAnimalFromAnimalTypes()这样的函数中。这就意味着本该返回null object却返回了Null的地方散布在整个项目中,我们必须确保每一处都没有返回Null。

也就是说,在确保每一处都没有返回Null之前,还是不能相信函数的返回值、参数、局部变量等绝不是Null。更糟糕的是,编译器不会帮助我们检查该不该返回Null,一切潜在错误都发生在程序运行时。

怎么样,这样看来是不是没注意到这一点,Null Object设计模式就相当于白使用了。

在Go语言中什么时候对nil调用方法不会报错呢?比如这段代码

// https://go.dev/play/p/QAugLYf2lE2
package main

import "fmt"

type dummyWriter struct{}

func (w *dummyWriter) Write(p []byte) (n int, err error) {
    // do nothing
    return 0, nil
}

func main() {
    var nullDummyWriter *dummyWriter
    fmt.Printf("%T %v\n", nullDummyWriter, nullDummyWriter)
  fmt.Fprintln(nullDummyWriter, "Hello World!") // <-- 在Fprintln内部对nil调用了Write()
}

// *main.dummyWriter <nil>