方格精選

我們對 Null Object 的使用合理嗎?

閱讀時間約 14 分鐘

最近工作上以及私下跟朋友討論時,剛好都遇到了 Null(不存在)類型的處理,通常我不會特別去在意這件事情,然而近年讀了一些關於 Null 的文章後(如:The worst mistake of computer science)對於這件事情的看法有不少改觀。

是「沒有」還是「空的」

相信很多人都看過 0 vs null 這張迷因圖,如果衛生紙用完了是「空的(Empty)」狀態,如果一開始就沒有衛生紙,那就是「沒有(Null)」的情境。

然而在中文上的翻譯,我們通常用來描述 Null 的情境,那就讓「空的」和「空值」看起來非常相似,在維基百科 - 空值(SQL)上的描述則是用「空值(Null)」和「零值(0)」來區分,還是非常類似的。

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

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

大多是空的

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

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

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> // 這裡被警告
</>)
}

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

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

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

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

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

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

# ...
end

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

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

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

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

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 的情境又會是如何呢?

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(數值物件)的方式處理。

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)那麼即使 balancenil 的狀況下,也會被當作 #<Money amount=0, currency=:TWD> 的方式操作,就能避免需要使用 if @user.balance 的判斷情境,因為對於一個數值來說具有「預設值」比「不存在」更加合理(或者說更常見)

Null Object 的應用

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

從維基百科對 Null Object Pattern 的介紹來看,最早是使用 Void Value 來描述這種物件,是不是跟某個數值不存在時,給予一個「預設數值」的意思非常接近。

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

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

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

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

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

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

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

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

另一方面,我們還有一個 score 的數值確實有可能不存在,那麼該如何處理呢?因為實作上使用 Golang 來實現,可以參考一下 Golang 的 database/sql 做了怎樣的處理。

type NullString struct {
String string
Valid bool
}

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

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

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

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

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 中,再做各種不同類型處理的時候就可以不用做額外的判斷,像是下面這樣使用。

@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? 方法)
avatar-img
55會員
40內容數
軟體工程師逐漸變成一個熱門的職業,當我們進入這個職業之後應該要具備怎樣的技能才會在工作上更加順利呢?這系列的專欄會分享日常工作中的經驗以及一些案例分析,讓我們一起努力成為一位更優秀的軟體工程師吧!
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
蒼時弦也的沙龍 的其他內容
執行力低下的宅宅們想要去奇美博物館總是需要鼓起很大的勇氣,在一年後我們終於下定決心出發去台南。然而,因為疫情的爆發加上確診,原本應該在涼爽的春夏交錯之時抵達,一直延後到了即將迎來鬼怪狂歡的盛夏。
因為經常有面試人的機會,然而在不同的面試條件中有一個「Problem Solving」的項目讓我一直在思考代表怎樣的意義,剛好在 LeSS in Action 的課程中有了一些想法。
這系列大概花了快兩個月的時間快速的把學到的一些知識記錄下來,然而還是有許多內容很難用文章簡單的說明。
雖然這系列的課程是設計給工程師的,然而在學習敏捷開發(Scrum 為主)的過程中,我們是從如何做「產品」的角度去做切入,也因此在課程接近尾聲的時候我們再次討論了產品跟專案的差異,也是這一週課程中各種安排的理由所在。
我們已經了解到了驗收驅動開發、持續整合以及壞味道這幾個概念,要減少技術債的方式就是重構,然而在實踐重構的時候並非我們所想像的必須「安排時間」重構,而是在開發的過程中不斷的進行。
當我們使用主幹開發(Trunk-based Development)、以及驗收測試驅動開發(A-TDD)之後,所撰寫的程式碼會逐漸的變多,也因此我們會開始注意到程式碼有壞味道(Code Smell)的出現。
執行力低下的宅宅們想要去奇美博物館總是需要鼓起很大的勇氣,在一年後我們終於下定決心出發去台南。然而,因為疫情的爆發加上確診,原本應該在涼爽的春夏交錯之時抵達,一直延後到了即將迎來鬼怪狂歡的盛夏。
因為經常有面試人的機會,然而在不同的面試條件中有一個「Problem Solving」的項目讓我一直在思考代表怎樣的意義,剛好在 LeSS in Action 的課程中有了一些想法。
這系列大概花了快兩個月的時間快速的把學到的一些知識記錄下來,然而還是有許多內容很難用文章簡單的說明。
雖然這系列的課程是設計給工程師的,然而在學習敏捷開發(Scrum 為主)的過程中,我們是從如何做「產品」的角度去做切入,也因此在課程接近尾聲的時候我們再次討論了產品跟專案的差異,也是這一週課程中各種安排的理由所在。
我們已經了解到了驗收驅動開發、持續整合以及壞味道這幾個概念,要減少技術債的方式就是重構,然而在實踐重構的時候並非我們所想像的必須「安排時間」重構,而是在開發的過程中不斷的進行。
當我們使用主幹開發(Trunk-based Development)、以及驗收測試驅動開發(A-TDD)之後,所撰寫的程式碼會逐漸的變多,也因此我們會開始注意到程式碼有壞味道(Code Smell)的出現。
你可能也想看
Google News 追蹤
在處理數據時,最可能會遇到數據中含有None的時候,若沒有處理就進行運算就會造成程式崩潰或者報錯 數據中含有None input_list = [(42, 292), (28, 296), (999, 92), (993, 46), (219, 4), (279, 2), (None, None
因為直觀現象視於本質,再無語言定義,但一個人若未能入此狀態,語言名相還是需要存在去輔助,但需要一直輔助的人就是執念太重,幾乎變成掛著收藏學識,另外,作為師者空性二字是方便使用,因為包含難以說明的巨量涵義。我會增加這段說明其實是為了閱文者,我自己不需要這種額外的解釋,因為我在描述的主意是「破認知相
Thumbnail
這些章節的目的是為了介紹JavaScript中的各種數據類型,包括基礎類型和物件類型,以及如何將數據從一種類型轉換為另一種類型。此外,還介紹了如何創建自定義類型,以及如何使用JavaScript中的陣列、集合和字典。
Thumbnail
作者 Only 系列文章,【一天一千字,進化每一次】,談論時間管理容易讓人誤解,我們能管理時間,增加效率,但是那樣的作用並不大,我們真正能管理的是,什麼對我們來說最重要,這要回推到以終為始,你想成為什麼樣的人,就是做那樣的事!
Thumbnail
當我們在做很多處理時,結果可能會是List包住一些數值,例如找輪廓或連通域分析時,沒有剛好的特徵可能就會有List含(空值得)形式出現。 為了避免報錯,我們就要額外先做一些處理,先做判斷是否有值在往下一個階段。 all 和 any 是 Python 中用於檢查可迭代物件(如清單、元組、集合等)
Thumbnail
而這也是我所認知的,那些前輩或長輩們的價值所在。許多的事情和經歷,真的需要時間的累積。有沒有經體驗過、是否曾經走過哪一遭,真的很樣。比如說,但就看懂「局」這件事而言,除了不會看走眼的視力、閱讀空氣的嗅覺與判斷能力之外,還關乎人生經驗的累積。
生活實驗 六三六 就是要把記事本打開, 讓空白螢幕呼吸新鮮空氣, 錯過的話,會有時差, 有時差也不是什麼大事, 就是刪掉、刪掉、再刪掉, 時差裡面,沒有東西。
數學中的除法常常讓人困惑,特別是為什麼不能除以0,本文以生動的例子與情境來解釋除法的概念,讓讀者更容易理解。
生活實驗 六○四 無論如何要放的假 我 有 無論如何要上的課 她 有 無論如何要刻的字 他 有 倒是沒有無論如何要講的話
在處理數據時,最可能會遇到數據中含有None的時候,若沒有處理就進行運算就會造成程式崩潰或者報錯 數據中含有None input_list = [(42, 292), (28, 296), (999, 92), (993, 46), (219, 4), (279, 2), (None, None
因為直觀現象視於本質,再無語言定義,但一個人若未能入此狀態,語言名相還是需要存在去輔助,但需要一直輔助的人就是執念太重,幾乎變成掛著收藏學識,另外,作為師者空性二字是方便使用,因為包含難以說明的巨量涵義。我會增加這段說明其實是為了閱文者,我自己不需要這種額外的解釋,因為我在描述的主意是「破認知相
Thumbnail
這些章節的目的是為了介紹JavaScript中的各種數據類型,包括基礎類型和物件類型,以及如何將數據從一種類型轉換為另一種類型。此外,還介紹了如何創建自定義類型,以及如何使用JavaScript中的陣列、集合和字典。
Thumbnail
作者 Only 系列文章,【一天一千字,進化每一次】,談論時間管理容易讓人誤解,我們能管理時間,增加效率,但是那樣的作用並不大,我們真正能管理的是,什麼對我們來說最重要,這要回推到以終為始,你想成為什麼樣的人,就是做那樣的事!
Thumbnail
當我們在做很多處理時,結果可能會是List包住一些數值,例如找輪廓或連通域分析時,沒有剛好的特徵可能就會有List含(空值得)形式出現。 為了避免報錯,我們就要額外先做一些處理,先做判斷是否有值在往下一個階段。 all 和 any 是 Python 中用於檢查可迭代物件(如清單、元組、集合等)
Thumbnail
而這也是我所認知的,那些前輩或長輩們的價值所在。許多的事情和經歷,真的需要時間的累積。有沒有經體驗過、是否曾經走過哪一遭,真的很樣。比如說,但就看懂「局」這件事而言,除了不會看走眼的視力、閱讀空氣的嗅覺與判斷能力之外,還關乎人生經驗的累積。
生活實驗 六三六 就是要把記事本打開, 讓空白螢幕呼吸新鮮空氣, 錯過的話,會有時差, 有時差也不是什麼大事, 就是刪掉、刪掉、再刪掉, 時差裡面,沒有東西。
數學中的除法常常讓人困惑,特別是為什麼不能除以0,本文以生動的例子與情境來解釋除法的概念,讓讀者更容易理解。
生活實驗 六○四 無論如何要放的假 我 有 無論如何要上的課 她 有 無論如何要刻的字 他 有 倒是沒有無論如何要講的話