半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

golang

Golang 當中的預設值以及 zero value

在 golang 當中,如果在初始化時沒有賦值,就會使用 zero value

不過用了一段時間會發現,如果每次都用 zero value 來代替,我們會分不清楚到底是使用者沒有輸入值導致 zero value,還是使用者原本就輸入了 zero value?

type User struct {
    ID    string
    Email string
    Name  string
}

func main() {
    user := &User{
        ID: "1",
    }
}

這時因為 Email, Name 都沒有輸入,所以預設都會是 ""。到目前為止看起來都還好,但如果我們想要序列化為 JSON 呢?

package main
import (
  "fmt"
  "encoding/json"
)
type User struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}

func main() {
    user := &User{
        ID: "1",
    }
    d, _ := json.Marshal(user)
    fmt.Println(string(d))
}

這時會輸出:

{
    "id":"1",
    "email":"",
    "name":""
}

我們的需求時常會是 email, name 在沒有值的時候改用 null 而非 "" 表示,應該要怎麼修改比較好呢?

1. 改用指標

我們知道 primitive type 的初始值不可能是 nil,也不能用像是 if !str {...} 的操作來判斷,會出現 mismatched types string and nil(被動態語言養慣了QQ)。

所以如果 field 要能夠接收 nil value,可以考慮使用指標,例如:

package main

import (
  "fmt"
  "encoding/json"
)

type User struct {
    ID    *string `json:"id"`
    Email *string `json:"email"`
    Name  *string `json:"name"`
}

func ToStringPtr(str string) *string {
    return &str
}

func main() {
    user := &User{
        ID: ToStringPtr("1"),
		Email: ToStringPtr("kalan@gmail.com"),
    }
    d, _ := json.Marshal(user)
    fmt.Println(string(d))
}

這時輸出會是:

{
    "id":"1",
    "email":"kalan@gmail.com",
    "name":null
}

這樣看起來好多了,不過這樣真的是個好方法嗎?讓我們繼續看下去。

由於現在欄位全部都變成指標了,所以如果直接用 user.Email 來取值的話:

package main

import (
  "fmt"
)

type User struct {
    ID    *string `json:"id"`
    Email *string `json:"email"`
    Name  *string `json:"name"`
}

func ToStringPtr(str string) *string {
    return &str
}

func main() {
    user := &User{
        ID: ToStringPtr("1"),
    		Email: ToStringPtr("xxx@gmail.com"),
    }
    fmt.Println(user.Email) // 0x1040c130
  	val, _ := json.Marshal(user)
}

直接取值的話會回傳指標位址,要獲取值的話就要用 *user.Email 的方式改寫。不過看到 *user.Email 是否覺得有點抖抖的?比如說如果我們直接拿值 *user.Name

func main() {
    user := &User{
        ID: ToStringPtr("1"),
    }
    fmt.Println(*user.Name)
}

這時就噴 panic 了。panic: runtime error: invalid memory address or nil pointer dereference,因為初始值為 nil,代表他是一個空指標,對一個空指標取值會導致 panic。因此雖然我們可以用 nil 來代表空值,不過這顯然有幾個很重大的缺陷。我們需要先檢查指標是否為空值再來使用。

另外如果這個 strcuct 是打算給 sql 掃描值的話,可以搭配 sql.NullString 或是搭配套件 null

sql.NullString / sql.NullInt 等等

如果我們定義的 struct 打算讓資料庫把值映射過去的話,需要實作一個叫做 Scanner 的介面,如果 string 為空值,在 sql 當中就沒辦法把值映射過去。sql.NullString 實作了 Scanner 介面。如果直接轉成 json 會長這樣:

{
  "email": {
    "Valid": true,
    "String": "xxx@gmail.com"
  }
}

你需要再用 MarshalJSON 將他轉換成正確的 json 格式(其實長這樣也可以啦...)

null

null 套件幫你定義常見的資料型別,方便搭配 sql 或 JSON 使用。

type User struct {
    ID    null.String `json:"id"`
    Email null.String `json:"email"`
    Name  null.String `json:"name"`
}

2. 改寫 MarshalJSON

在 golang 當中,我們可以將原始型別自定義為一個 type,並且為這個 type 加上方法。我們可以自行改寫 MarshalJSON 來讓預設應該是 zero value 的 output 為 null,像是這樣:

type nullString string

func (str nullString) MarshalJSON() ([]byte, error) {
	raw := string(str)
	if string(str) == "" {
		return json.Marshal(nil)
	}
	val := raw
	return json.Marshal(val)
}

func main() {
	str := nullString("")
	val, _ := json.Marshal(str)
	fmt.Println(string(val))
}

3. default value + tag

golang 並沒有像是比較高階語言的語法糖,像是:

const getPerson = (id = 1, name = '') => (
)
def get_person(id=1, name='', is_admin=False):
    ...

也沒有好用的 tenary operator 來幫助判斷 defualt value。因此在 golang 實作 default value 比較麻煩一些,曾經也有人在論壇上問過,不過被打搶了,原因是:

Why does this need additional, special support in the language? Go has ... to support a variable number of arguments, from which you could figure out which type of call it means, and supply defaults for missing arguments. Or you could pass some out-of-range or zero value like nil or -1 or even 0 to required arguments to ask for a default value. This has a long tradition in C, so why are function-specific calling conventions not enough? I think I'd rather see myfunc(nil, nil, nil, nil, nil) than myfunc(, , , ,) to say "do what you do with all default values", since while I'm developing a program it conflates whether I missed a parameter completely or meant to ask for a default value. This can cause silent errors in cases where the default value is no acceptable to the overall program. (As the code base grows we won't always be able to control the defaults for code we use in others' packages.) I also don't see why it's necessary to add a special token to represent "default value", since we can accomplish this without adding more symbols to the language.

原因大概就是 google 不希望你傳了一堆 func(nil, nil, nil, nil) 進來只為了讓 default value 起作用,或是你可以直接用動態的 args… 來計算參數的數量及型別,而不需要使用到 default value。

不過如果真的需要 default value 怎麼辦?我就是不想要初始化的時候是 0, "" 等等怎麼辦?這時可以試試看 golang built-in 的 tag 功能,像是定義:

type People struct {
  Name string `default:""`
  Age int64 `default:10`
}

之類的方式來達到這件事,不過要實作一個功能完善的 tag,要考慮的事情也不少。改天再來實作一次試試看。

結論

本篇文章試著總結一下在 golang 如果要使用 default value 以及 zero value 時可能會遇到的問題,並嘗試提出解決辦法。