---
title: "我們對 Null Object 的使用合理嗎？"
date: 2023-07-26T00:00:00+08:00
publishDate: 2023-07-26T00:00:00+08:00
lastmod: 2023-07-26T09:39:16+08:00
tags: ["經驗","Domain-Driven Design","Null Object"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/07/26/does-our-null-object-reasonable/"
language: "zh-tw"
---


最近工作上以及私下跟朋友討論時，剛好都遇到了 `Null`（不存在）類型的處理，通常我不會特別去在意這件事情，然而近年讀了一些關於 `Null` 的文章後（如：[The worst mistake of computer science](https://www.lucidchart.com/techblog/2015/08/31/the-worst-mistake-of-computer-science/)）對於這件事情的看法有不少改觀。

<!--more-->

## 是「沒有」還是「空的」{#null-or-empty}

相信很多人都看過 [0 vs null](https://www.reddit.com/r/ProgrammerHumor/comments/6f68rv/difference_between_0_and_null/) 這張迷因圖，如果衛生紙用完了是「空的（Empty）」狀態，如果一開始就沒有衛生紙，那就是「沒有（Null）」的情境。

然而在中文上的翻譯，我們通常用[空](https://terms.naer.edu.tw/detail/ed0081f720729228a209db53914b60bd/?seq=8)來描述 `Null` 的情境，那就讓「空的」和「空值」看起來非常相似，在[維基百科 - 空值（SQL）](https://zh.wikipedia.org/zh-tw/%E7%A9%BA%E5%80%BC_(SQL))上的描述則是用「空值（Null）」和「零值（0）」來區分，還是非常類似的。

尤其在 Ruby、JavaScript 這類語言，想要區分出「不存在」跟「沒有」的使用情境，通常會更加困難，如果是 Golang、C#、Java 這類語言，因為型別檢查的關係是不會有這樣的情境發生。

> 如果在 Golang 中給定一個欄位 `age` 型別為 `int` 那麼預設就會是 `0` 而不會是 `Null` 在 Ruby 中則會因為不知道型別，那麼就會維持「不存在」的狀況，自然變成「沒有」

## 大多是空的{#usually-is-empty}

實務上來說，我們在軟體開發遇到的大多數情況都會是 Empty 的狀況，雖然會出現 Null 的情境，主要還是在找不到資料這類狀況為主，通常我們想處理的都是 Empty 的狀況。

舉個案例，近期有人跟我討論 ESLint 的警告問題，其中一個是 TypeScript 的實作。

```ts
type TimeLeft = {
  hours?: number;
  minutes?: number;
  seconds?: number;
}
// ...

const Countdown = ({ expiredTime }) => {
  var timeLeft = {}
  calculateTimeLeft(timeLeft, expiredTime)
  // ...

  return (<>
    <Text>{{ timeLeft.hours || 0 }}</Text> // 這裡被警告
    <Text>{{ timeLeft.minutes || 0 }}</Text> // 這裡被警告
    <Text>{{ timeLeft.seconds || 0 }}</Text> // 這裡被警告
  </>)
}
```

當我們在處理這個狀況時，我們讓他保持「沒有」的狀態直到實際使用，那麼就需要在每一個地方「檢查是否存在」然後再賦予預設值。

然而，預設值不應該是在物件初始化階段就定義好的嗎？因此實際上應該這樣做。

```ts
type TimeLeft = {
  hours: number;
  minutes: number;
  seconds: number;
}

const newEmptyTimeLeft = (): TimeLeft => { hours: 0, minutes: 0, seconds: 0}

// ...
const calculateTimeLeft = (expiredTime: Date): TimeLeft {
  var timeLeft = newEmptyTimeLeft()
  // ...
  return timeLeft
}

const Countdown = ({ expiredTime }) => {
  const timeLeft = calculateTimeLeft(timeLeft, expiredTime)
  // ...

  return (<>
    <Text>{{ timeLeft.hours }}</Text>
    <Text>{{ timeLeft.minutes }}</Text>
    <Text>{{ timeLeft.seconds }}</Text>
  </>)
}
```

在真實的開發情境裡面，有不少地方是可以這樣調整去消除掉 Null 的情況，我們需要的通常是一個預設值。

## 哪裡有 NULL {#where-has-null}

我們以 Domain-Driven Design（領域驅動設計）的 Domain Model（領域模型）來來看，其中 Entity（實體）就是一個會有 Null 情況的物件類型。

首先，Entity 的定義上通常會具備一個 Identity（識別，或者說 ID）來表示這是一個獨立個體，假設有一個「使用者」的概念存在於一個以 Rails 開發的系統，我們通常會定義一個 Model 如下。

```ruby
class User < ApplicationRecord
  # attribute :id, type: :integer
  # attribute :name, type: :name
  # attribute :age, type: :integer

  # ...
end
```

在 Ruby on Rails 中我們要找到一個使用者，可以像這樣進行查詢。

```ruby
class ProfileController < ApplicatonController
  def show
    @user = User.find_by(id: params[:id])
  end
end
```

在上述的程式碼中，當我們找不到某個 ID 的使用者時，就會得到 Null 的結果。然而，這樣的寫法很容易讓後續的程式出錯，最後需要大量的 `if @user` 判斷式來檢查是否有資料。

因此，更多會採用這樣的做法。

```ruby
class ProfileController < ApplicatonController
  def show
    @user = User.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    render :user_not_found
  end
end
```

如果找不到，對 Rails 來說是一種「錯誤（Error）」的情境，因此會拋出 `ActiveRecord::RecordNotFound` 的訊息，我們還能直接善加利用，顯示出找不到使用者的畫面。

那麼，如果是 Null Object 的情境又會是如何呢？

```ruby
class ProfileController < ApplicatonController
  def show
    @user = User.find_or_initialize_by(id: params[:id]) do |user|
      user.name = 'Guest'
    end
  end
end
```

在 Rails 還提供了 `#find_or_initialize_by` 的方法，當遇到 Null 的狀況時，產生一個新的 `User` 物件並且設定預設值。

這樣的寫法似乎還是有點不夠優雅，因為在 Rails 中我們可以對資料庫欄位設定預設值，或者利用 `attribute` 的 DSL（領域特定語言）來設定預設值，那麼在大多數的狀況下只需要使用 `#find_or_initialize_by` 幾乎就涵蓋大多情境。

從概念上來反推，會發生 Null 的情境主要是我們想去尋找某個「實際存在的物體（Entity，實體）」卻因為實際不存在而找不到。

然而，如果是某個實體上的數值（Value）大多是可以存在預設值的，像是 User 身上的 `name` 或者 `age` 都可以給定預設的數值，如果是一個複雜的物件，也能夠利用 Value Object（數值物件）的方式處理。

```ruby
class User < ApplicationRecord
  # attribute :id, type: :integer
  # attribute :name, type: :name
  # attribute :age, type: :integer

  composed_of :balance, class_name: 'Money', mapping: %w(balance amount)
end

# app/models/money.rb
class Money
  attr_reader :amount, :currency

  def initialize(amount, currency = :TWD)
    @amount = amount || 0
    @currenncy = currency
  end

  # ...
end
```

以上面的例子來看，使用者身上的 `balance` 欄位是一種 `Money`（金錢）的概念，會轉換成一個 Value Object 來處理，這個 `Moeny` 物件身上也會預設特定的幣種（Currency）那麼即使 `balance` 是 `nil` 的狀況下，也會被當作 `#<Money amount=0, currency=:TWD>` 的方式操作，就能避免需要使用 `if @user.balance` 的判斷情境，因為對於一個數值來說具有「預設值」比「不存在」更加合理（或者說更常見）

## Null Object 的應用{#usage-of-null-object}

從前面針對 Empty、Null 兩種情境的說明，大致上可以看出來 Null Object 有點類似「預設值」的感覺，當我們找不到某個實體的時候，給予一個預設的行為，而且很高的機率是不做任何事情。

從維基百科對 [Null Object Pattern](https://en.wikipedia.org/wiki/Null_object_pattern) 的介紹來看，最早是使用 Void Value 來描述這種物件，是不是跟某個數值不存在時，給予一個「預設數值」的意思非常接近。

假設大多數情境都是對「找不到」做備援，那麼我們更應該思考的是「預設值」的設定，如果不應該有預設值，那應該要設計為建立某個實體的必填欄位，進而確保我們的行為是一致的。

以最近工作上的例子，我們原本設計 `tags` 在沒有任何資料時，會在 API 回應中不回傳這個欄位，那麼在使用者端呼叫時，就需要這樣處理。

```ruby
@status = @api.status_of(params[:report_id])

if @status.tags.present?
  @status.tags.each do |tag|
    # ...
  end
end
```

然而，我們實際上是可以給 `[]` 做為預設值的，那麼實作上就會變成

```ruby
@status = @api.status_of(params[:report_id])

@status.tags.each do |tag|
  # ...
end
```

這樣讓使用者端更簡潔，並且能夠減少多餘的判斷，因為我們將判斷的邏輯隱含到了提取陣列元素的程式語言底層中，並且具有相同的意義。

另一方面，我們還有一個 `score` 的數值確實有可能不存在，那麼該如何處理呢？因為實作上使用 Golang 來實現，可以參考一下 Golang 的 [database/sql](https://pkg.go.dev/database/sql) 做了怎樣的處理。

```go
type NullString struct {
	String string
	Valid  bool
}
```

對 Golang 來說，是不能有 Null 的數值存在的，除非他是一個指標（Pointer）指向一個沒有數值的位址（跟 Entity 的 ID 找不到對應的物件概念相同）但是在資料庫中存在著 Null 的概念，因此對應的方式就是用 `Valid`（正確）來表示是否存在資料，這剛好就是一種 Null Object 或者 Value Object 的變體。

也因此，我們可以將 API 的回傳設計成這樣。

```json
{
  "id": 1,
  "score": {
    "value": 0,
    "verified": false
  },
  "tags": []
}
```

在使用端，我們可以設計一個 Value Object 叫做 `Score` 來處理。

```ruby
class Score
  attr_reader :value, :verified

  def initialize(value, verified=false)
    @value = value
    @verified = verified
  end

  def allowed?(score)
    @verified && @value > score.value
  end

  # ...
end
```

像這樣，我們就可以直接把 Null Object 要做的預設行為隱含在 Value Object 中，再做各種不同類型處理的時候就可以不用做額外的判斷，像是下面這樣使用。

```ruby
@status = @api.status_of(params[:report_id])

return upload if @status.allowed?(UPLOAD_SCORE)
return refresh_later unless @status.valid?

# others ...
```

這樣一來，程式中就可以減少很多 Null 類型的檢查，也符合 Null Object 設計的目的。

> 在 Rails 裡面經常遇到字串可能是 `""` 或者 `nil`（Null） 的情況，因此在 `ActiveSupport` 對 Ruby 做的擴充，還有一個叫做 `#blank?` 的方法，會檢查 `""` 或者 `nil` 來回傳 `true`，然而如果能保證不會有 `nil` 的狀況出現，使用 Ruby 內建的 `#empty?` 方法即可（`nil` 在 Ruby 是物件，但沒有 `#empty?` 方法）

