Go で値がないこと (nullable) をどう表現するか – Optional 型導入の話
はじめに
プロダクトの仕様には、値が「ある」場合もあれば「ない (null)」場合もある nullable な要素がよく登場します。
例えば、次のようなものです。
- ユーザーの二つ目のメールアドレス(ない人もいる)
- キャンセルされた予約のキャンセル理由(キャンセルされていないときは存在しない)
- 請求書の支払期日(未設定のケースがありうる)
Go で書かれたライブラリなどの実装を見ていると、nullable な要素を扱う場合は「ポインタ」や「ゼロ値」が使われていることが多くみられました。
私たちもはじめはポインタを使って nullable な値を表現していたのですが、ポインタはパフォーマンスなどの別の用途でも使われるため、「ポインタが使われているからといって nullable とは限らない」という状態にだいぶ苦しめられました。
この記事では、仕様上の nullable を型として明示的に表現するために、専用の Optional 型を導入した経緯と、その結果得られたものについて紹介します。
ポインタやゼロ値で nullable を表現すると何が困るのか
まずは、よくある実装方法のおさらいです。
ポインタで nullable を表現する
ポインタを使う場合、値が「ない (null)」ことは nil を使って表現します。
var canceledAt *time.Time // キャンセル日時
if canceledAt == nil {
// キャンセル日時は設定されていない
}
例外発生時にのみ値を持つ error などもこの方法を使っています。
ゼロ値で nullable を表現する
ゼロ値を使う場合、値が「ない (null)」ことはゼロ値 (time.Time{} など) を使って表現します。
var canceledAt time.Time // キャンセル日時
if canceledAt.IsZero() {
// キャンセル日時は設定されていない
}
Go では値がゼロ値かどうかを確認するために構造体に IsZero() メソッドを持たせることが多いです。
これらは Go の世界では非常によく使われるテクニックなのですが、次のような問題にぶつかりました。
ポインタは nullable 以外の目的でも使われる
Go では以下のように様々な理由でポインタが使われます。
- nullable を表現するため
- 関数に渡された値を直接変更できるようにするため
- メモリ効率などパフォーマンスのため
- etc.
全ての型がゼロ値を持つ
Go では全ての型がゼロ値を持っています。
数値型の 0 を「値がない」ことを表すために使うケースは稀だとは思いますが、それ以外の多くの型ではゼロ値を「値がない」状態として扱うことがあります。
型を見ただけでは null チェックが必要かどうかがわからない
そのため、コードを読んだだけではある値が nullable なのかどうかを判別できません。
type User struct {
Name string // Name は必須なのでゼロ値にならない
Nickname string // Nickname は任意なので、設定されていなければゼロ値になる
Address *Address // Address は必須なので nil にはならないが、パフォーマンスのためにポインタにしている
Avatar *Avatar // Avatar は任意なので、設定していない場合は nil になる
}
上記のようなコードを扱っていると、「このポインタは nil チェックをしなければいけないのか、してはいけないのか」を判断するのが難しくなります。
そのため、私たちのチームでも
- nullable かどうかコードを深く追わないとわからない
- null チェックが必要な箇所で null チェックを忘れて panic を発生させてしまう
- 本来 null にならないはずの箇所で過剰に null チェックをしてしまう
といった問題が発生していました。
nullable であることを明示するために専用の型を用意
この問題を解消するために、次のように方針を整理しました。
- nullable な値 は、ゼロ値やポインタではなく、専用の型で表現する
- パフォーマンスなどの理由でポインタを使う場合 は、原則として nil にはならないようにする
具体的には、nullable な値 を表現するために Optional[T] 型を導入し、仕様上 null になり得る値は全て Optional[T] を使うように修正しました。
Optional 型として go-optional を採用
Go には標準で Optional 型が用意されていません。そのため、ライブラリを利用することにしました。
今回採用したのは moznion/go-optional です。
このライブラリのいいところは、ジェネリクスを使っている点で、ひとつの Optional[T] 型であらゆる型に対応できるところです。
探したのがまだ Go を始めたばかりの頃で、コードが読みやすかったのと、日本の方が作られているということでこれを選びましたが、今選ぶとしたら samber/mo なんかもいいと思います。
はじめは go generate を使って型を生成する github.com/markphelps/optional を使おうとしていたのですが、全ての型、特にライブラリが提供する型に対応する Optional 型を生成するのが大変だったのでやめました。
moznion/go-optional の欠点は Optional[T] 型が []T の Defined Type として定義されているため、comparable ではないところです。
比較はもちろん map のキーにも使えないため注意してください。
実際のコードの例
実際に moznion/go-optional を使ったコードの例をいくつか紹介します。
値の用意
Optional を表す optional.Option 型の値は、optional.Some[T](v) と optional.None[T]() を使って用意します。
import "github.com/moznion/go-optional"
// optional.Option[string] は nullable string を表す
var name optional.Option[string]
// 値が存在する場合
name = optional.Some("Alice")
// 値が存在しない場合
name = optional.None[string]()
// 構造体に nullable な要素を持たせる
type User struct {
ID int
Name optional.Option[string] // Name は値を持つときと持たないときがある
}
ライブラリから受け取ったポインタを Optional 型に変換
ポインタ *T を使って nullable を表しているものを Optional[T] に変換したい場合は optional.FromNillable() を使います。
これはライブラリが返す値を Optional 型に変換するときによく使います。
func ParseNullableGroupID(groupID *int) optional.Option[int] {
return optional.FromNillable(groupID)
}
値の取り出し
値を取り出す方法はいくつかあるのですが、値が存在しなかった際に panic を発生させる Unwrap() は極力使わず、Take() や TakeOr() を使うようにしています。
// Take() は値が存在しなかったときに err を返す
// Go では必ず err をチェックする文化があるため、この形式は馴染みやすい
name, err := user.Name.Take()
if err != nil {
// 値が存在しなかった場合の処理
} else {
fmt.Println("name: ", name)
}
// 値が存在しなかったときにデフォルト値を使いたい場合は `TakeOr()` を使う
name := user.Name.TakeOr("Guest")
fmt.Println("greeting for: ", name)
値の有無による条件分岐
値が存在したときだけ何かの処理をしたい場合は IfSome() を使うこともできます。
この書き方の利点は、引数として渡す関数の中では、値が存在することを前提としたコードを書けることです。
// IfSome() に渡す無名関数の中では値が存在することを前提としたコードを書ける
user.Name.IfSome(func(name string) {
fmt.Println("name:", name)
})
値の変換
値が存在する場合のみ値に対して処理を行いたい場合は optional.Map() や optional.MapOr() を使います。
// Map() の戻り値は第二引数の戻り値の Optional 型になる (この例では Option[string] になる)
birthday := optional.Map(user.Birthday, func(birthday time.Time) string {
return birthday.Format(time.DateOnly)
})
// MapOr() の戻り値は第二引数の型 = 第三引数の戻り値の型になる (この例では string になる)
screenAge := optional.MapOr(user.Age, "Unknown", func(age int) string {
return fmt.Sprintf("%d years old", age)
})
ただ Go の言語上の制約のため、Map() はメソッドではなく関数として実装されているので、少し読みづらいです。
上記例くらいならいいんですけど、コードが複雑になって読みにくくなりそうだったら、Take() を使って書くのもありだとは思います。
func renderAge(user User) string {
// 他の言語ではこの書き方は避けられているが、
// Go では頻出するパターンなのでミスも少ないと思われる
age, err := user.Age.Take()
if err != nil {
return "Unknown"
}
return fmt.Sprintf("%d years old", age)
}
危険な書き方⚠️
ただし以下のような Unwrap() を使う書き方は危険なのでやめた方がいいです。
必ず存在確認をしなければ値を使えない Take() や IfSome() を使いましょう。
// 将来この条件が変わったときに IsSome() のチェックが意図せず外れる可能性がある
if user.IsActive() && user.Age.IsSome() {
// ここに長い処理が入って IsSome() と Unwrap() の距離が離れると危険
// ...
response.Age = fmt.Sprintf("%d years old", user.Age.Unwrap())
// 更にここにも長い処理が入ってしまうと、
// IsSome() で確認した値を Unwrap() して使っているという構造が見えにくくなる
// ...
}
Optional 導入によって得られたこと
nullable な値に徹底して Optional 型を使うことで、nullable かどうかが一目見てわかるようになりました。
そのため、null チェックを忘れることも、不必要な null チェックをしてしまうことも、ほとんどなくすことができました。
ポインタの扱いに自信を持っていたメンバーでも、共通化された処理で使ってる変数を nullable に変更したり、その逆の変更をするときにはミスしやすかったのですが、それもなくなりました。
例外的に context や error などの Go の慣例上変えづらい部分や、外部ライブラリに依存していてポインタやゼロ値を使わざるを得ない部分は、Optional じゃなくても nullable になり得るのですが、使う場所を限定しているので迷うことはほとんどありません。
// ラッパー関数などを用意して nullable なポインタを扱う場所を限定する
func GetBody(endpoint string) (optional.Option[io.ReadCloser], error) {
// err がなければ nil ではないパターンだけを扱えるので、Lint もしやすくミスは少ない
response, err := http.Get(endpoint)
if err != nil {
return optional.None[io.ReadCloser](), err
}
// ライブラリが nullable な値をポインタやゼロ値で扱っている場合は Optional に変換する
return optional.FromNillable(response.Body), nil
}
振り返ってみての感想
Optional 型を導入したことで、型を見るだけでぱっと仕様がわかるようになったのは、コードを書く側にとってもレビューする側にとっても、とても嬉しい効果でした。
nullable なことを明示するために Optional 型を使うというアイディアは、Java などの他の言語から着想を得たのですが、実際に使ってみるとわかりにくい部分も結構ありました。
他の言語が用意している Optional 型は様々なケースに対応するため、とても汎用的な設計になっていますが、私たちが欲しかった機能はもっと限定的でした。
「値があるか/ないかを型で表現できて、取り出すときに毎回意識的な処理が入る」くらいのシンプルさで十分だったので、今振り返ると、要件を満たす最小限の Optional 型を自作してしまうという選択肢もあったなと感じています。
Optional を使って明示的なコードを書いていくうちに、nullable にも種類があることを意識するようになってきました。
「プロダクトの仕様 (ドメイン) に存在する nullable」と、段階的に値を作っていく場合などに一時的に現れる「テクニック (手段) としての nullable」です。
もしかしたら、これらを区別できるように別々の型を用意した方がわかりやすいんじゃないか、という意見もちらほら出てきています。
まだまだ試行錯誤の途中なのですが、このようにして「仕様や意図を型で表現する」という感覚がチームの中に根づいてきました。
おのおのがなんとなく扱っていたものを明示化していくことで、コミュニケーションコストが下がり、大事な議論に集中できるようになってきたのも、よかったところだなと思います。
We are hiring!!
株式会社カウンターワークスでは、仕様や意図をコードで表現し、仕組みで間違いを減らしていくことにワクワクできるエンジニアを募集しています。
興味のある方はぜひ以下のリンクからご応募ください!
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion
Go が公式に Optional 型を提供する提案がなかなか進まずクローズされたのも、ユースケースによって求めるものが違うからという部分が大きかったのかもしれません。 Go は null 安全のための機能がほとんどないので、Optional 型含めそういう機能も充実させてほしいという考えには同意なのですが、単にライブラリとして実装するだけだと、結局
moznion/go-optionalのような言語仕様による制約が付いてしまうし、前述の通り求める形も様々なので難しいのかなと思いました。