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

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

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

c 复制代码
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设计模式的好处。

示例代码

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

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

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()方法,只不过方法里什么也没有做。

php 复制代码
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 复制代码
<?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中的情况。

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

go 复制代码
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调用方法不会报错呢?比如这段代码

go 复制代码
// 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>
相关推荐
晨米酱5 小时前
JavaScript 中"对象即函数"设计模式
前端·设计模式
数据智能老司机10 小时前
精通 Python 设计模式——分布式系统模式
python·设计模式·架构
数据智能老司机11 小时前
精通 Python 设计模式——并发与异步模式
python·设计模式·编程语言
数据智能老司机11 小时前
精通 Python 设计模式——测试模式
python·设计模式·架构
数据智能老司机11 小时前
精通 Python 设计模式——性能模式
python·设计模式·架构
BingoGo11 小时前
PHP 如何利用 Opcache 来实现保护源码
后端·php
使一颗心免于哀伤11 小时前
《设计模式之禅》笔记摘录 - 21.状态模式
笔记·设计模式
影灵衣丶11 小时前
Go 1.25 实战解读:绿茶 GC 与容器核心数感知(工程影响与落地)
后端·go
一直_在路上11 小时前
突发高流量应对之道:Go语言限流、熔断、降级三板斧
面试·go
程序员爱钓鱼12 小时前
Go语言100个实战案例-项目实战篇:股票行情数据爬虫
后端·go·trae