妥当なNullの使い道

「Null」ってあるじゃないですか。参照が何も指し示していないときに利用するアレ。

Nullが発明された当時はそれなりの事情があったのだと思いますが、昨今Nullは忌避すべきものという扱いが主流かなーと思います。

事実プログラミング言語においてはNullが存在しないものもあります。あるいはNullがあったとしても、Nullable/Non-nullを型で明確に区別していたり。いわゆるNull安全ですね。

こういったNull安全は間違いなく頼もしい存在です。ですが、そもそもNullの使い道が適切ではないケースには無力です。

私自身、最近では「Null安全はたしかに嬉しいし、あるに越したことはないけど、そもそもNullを使わないで済むならそうした方が良いよな……」と思っています。

とまあ、そんな悶々とした中で、Nullを用いるのが適切・不適切なケースが見えてきた気がするので、まとめてみます。

任意項目を意味するとき、Nullは適切

Nullを用いるのが適切なケースは、値が任意項目を意味するときです。

例えば年齢という値があったとします。年齢は未入力とすることも可能です。

このとき、年齢をNullable Integerとし、非Nullのときはそのまま年齢を、Nullのときは未入力とするなら、Nullを用いるのは適切だろう、ということですね。

任意項目以外を意味するとき、Nullは不適切

反対にNullを用いるのが不適切なケースはどんなときでしょう。私は任意項目であることを意味するとき以外すべて不適切だと思っています。単純明快、任意項目 or notで分けられるということですね。

そもそもNullという値はそれ自体に意味がありません。ただ指し示すものが何もないというだけです。そのNullにコンテキストに応じた意味を持たせると、それはたちまち暗黙知になります。

Nullを用いるのが適切・不適切を判断するポイントは単純ですが、どういうものが不適切で、どういう困りごとがあるのか、例にしてみます。

Nullにコンテキストに応じた意味を持たせる

例えば容量という概念があるとします。容量は1, 2, 3, ...のように整数を取りますが、無制限という値も取ることが可能です。容量同士は加算することが出来、結果は整数の加算と同様になります。無制限が絡む加算の結果は常に無制限となります。

このとき、容量をどう表現するのが適切でしょうか?

Nullを用いるなら、Nullable Integerとし、非Nullのときはそのまま容量を、Nullのときは無制限とする、といったことも可能でしょう。Kotlinで書くなら以下のような感じでしょうか。

val capacity: Int? = readCapacity()

if (capacity != null) {
    println("Limited capacity: $capacity")
} else {
    println("Unlimited capacity")
}

しかし、これはまさしく不適切なケースに該当します。Nullが無制限という意味を担ってしまっているからです。

事前に容量の定義を知っていれば、Null = 無制限と推察することも可能かもしれませんが、あくまで推察レベル。裏付けがない以上コードを読み込むなり、有識者に聞くなりして調べる必要は出て来ます。少なくとも私はそうしないと不安だな!

あと単純な話ですが、Nullは計算が不可能です。容量には加算が定義されていますが、1 + nullコンパイラが許してくれません。

ではどうするか。私は2つほど解決策が思い浮かびました。

  • 解決策1: 制限ありと無制限をはっきり区別する
  • 解決策2: Nullable Integerをクラスで包み、隠蔽する

そのうちの解決策1を実践してみます。解決策2でも大分マシになりますが、Nullable Integerである以上、どこかで漏れ出てしまうので。

というわけで解決策1の実践。まずは容量の定義に立ち返りましょう。

  • 容量は制限ありと無制限がある
  • 制限ありは整数をひとつ持つ
  • 無制限は何も持たない
  • 容量の加算は整数の加算に等しい
  • 容量の加算に無制限が絡むと、結果は常に無制限となる

そしてこれをそのままクラスにしましょう。

sealed interface Capacity {

    operator fun plus(other: Capacity): Capacity

    data class Limited(val value: Int) : Capacity {

        override fun plus(other: Capacity): Capacity =
            when (other) {
                is Limited -> Limited(value + other.value)
                Unlimited -> Unlimited
            }
    }

    object Unlimited : Capacity {

        override fun plus(other: Capacity): Capacity =
            Unlimited
    }
}

どうでしょう?容量の定義そのままを表現できていると思います。そしてNullは一切現れません。実際に使ってみましょう。

val aCapacity = Capacity.Limited(1)
val bCapacity = Capacity.Unlimited

println(aCapacity + bCapacity) // => Capacity$Unlimited@6182ffef

objectを用いたので出力結果がおもしろいことになっていますが、Nullは現れませんね。永続化するときも以下のように書けます。

fun save(capacity: Capacity) {
    when (capacity) {
        is Capacity.Limited -> saveAsLimited(capacity.value)
        Capacity.Unlimited -> saveAsUnlimited()
    }
}

とまあこんな感じで、Nullを使わなくても書けましたね。むしろこちらの方が定義通りでより伝わるかと思います。

不要な属性にNullを入れてごまかす

他にどんなケースがあるでしょうか。例えばメッセージという概念があるとします。メッセージにはテキストまたは画像を添えられます。テキストおよび画像の両方があったり、両方がなかったりするのは許されません。

このとき、どのようにメッセージを表現するでしょうか。以下のようなのがすぐ思い浮かぶかもしれません。

class Message(
    val text: String?,
    val image: ByteArray?
)

たしかにテキストまたは画像を持つ、ということを表現できます。しかし、両方を持つ、両方を持たないも表現できてしまいます。それなら以下のようにすれば良いでしょうか。

class Message(
    val text: String?,
    val image: ByteArray?
) {

    init {
        val hasText = text != null
        val hasImage = image != null
        val hasOnlyOne = hasText xor hasImage
        if (!hasOnlyOne) {
            throw IllegalArgumentException("The message must have only one of a text or an image")
        }
    }
}

これでめでたくテキストまたは画像のどちらか一方のみを持つことが許されるようになりました!

でもちょっと待ってほしい。結局のところ、ルールに則っているかという検査を、値が妥当であるかという方法で守っているだけです。それはつまり、「実行しないと分からない」ってことです。実行しないと分からないということは、コンパイルエラーではなくランタイムエラーなわけだし、問題のあるなしはコンパイラではなくテストで担保する必要があります。

さきほどのように値の妥当性で保証するのもひとつの手だとは思います。でも私はちょっとイヤかな〜。だってルールに則っているかの検査している箇所を読み解かないと、どういうルールか分からないし……。

そもそもインスタンス生成時にルールに則っているか検査していても、そのインスタンスを用いる箇所でNullかそうでないかを見てあげる必要があり、もにょもにょしてしまう……。

fun save(message: Message) {
    if (message.text != null && message.image == null) {
        saveAsText(message.text)
    } else if (message.text == null && message.image != null) {
        saveAsImage(message.image)
    } else {
        // ここはどうすれば良い?
    }
}

こうなってしまう根本的な原因はなんでしょう。

その答えを出す前に、メッセージの定義に立ち返りましょう。

  • メッセージはテキストまたは画像を持つ
  • テキストおよび画像を両方持つことは出来ない
  • テキストおよび画像を両方持たないことは出来ない

大切なのは1つ目です。このルールを素直に実装すれば以下のようになるんじゃないでしょうか。

class Message(
    val content: MessageContent
)

sealed interface MessageContent {

    class Text(val value: String) : MessageContent

    class Image(val value: ByteArray) : MessageContent
}

MessageはMessageContentを持ち、MessageContentはTextまたはImageという定義になっています。これはメッセージの定義そのものです。両方持つ・持たないはクラスの定義上、表現不可能になっています。

もにょもにょしていたコードも以下のようにすっきり記述できます。

fun save(message: Message) {
    when (val content = message.content) {
        is MessageContent.Text -> saveAsText(content)
        is MessageContent.Image -> saveAsImage(content)
    }
}

さきほど答えを保留した根本的な原因についてです。私はこの問題を「属性そのものを持つべきではないのに、Nullを入れてごまかしているから」と捉えています。まあね、「ごまかす」ってちょっと人聞き悪いように聞こえますが……。まあ、だからこそごまかさないで向き合おうぜ、って心持ちでいたいわけですよ(?)。

Null安全の向こう側

今回の話はあくまで「Nullはどういう場面で用いた方が良いのか」です。

ですので、Null安全とは直接関係のない話です。「Null安全の向こう側」という言い回しは、もっともらしく聞こえます。だけどそもそも「向こう側」ですら無いよねって。どちらかと言うと横並び?

とは言え、気軽にNullable/Non-nullを指定でき、その差を明確に区別するNull安全だからこそ、Nullの使い方が不適切なときにはより強い違和感になるのかもしれません。

Null安全は冴えたやり方だと思っています。Nullable/Non-nullを区別しない言語には戻れないなあ、とさえ思っています。

でもそこからさらに一歩を踏み込んで、「そもそもNullableにする必要なくない?」と考えることが出来ると、よりハッピーになれるんじゃないかなあ、とも思います。

解決策のコードは奇しくも(全然奇しくない)代数的データ型(ADTs)なわけですが、やれ直積だの直和だの持ち出さなくても、「Nullやめてみよっか」の一言をきっかけにしても良いんじゃないかななんて。ほら、RDBでもNOT NULL制約を徹底すれば、うまく事が運びやすいってのあるじゃないですか。あんな感じ。たぶん。

というわけで、まずはNullをやめてみるところから始めてみるのも良いんじゃないでしょうか。