14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KotlinAdvent Calendar 2018

Day 19

Kotlinでnullについてもう一度考える

Last updated at Posted at 2018-12-20

この記事はKotlin Advent Calendar 2018の19日目です。

Kotlinはnull安全な言語とよく言われます。
しかし、なぜnullだけが特別扱いされるのでしょうか?
例えば関数が準備できていないときに戻り値がnullとなる関数があるとします。

準備ができているか変数isReadyを確認し、isReadyがFalseだとnullを返し処理を行わないメソッドをJavaで書いた場合。

String result = method(); // null
String upper = result.toUpperCase(); // NullPointerException

String method(){
  if(!isReady){
      return null;
  }
  ..
}

この処理を実行すると実行時に悪名高い NullPointerException が発生します。
ということで、Kotlinはデフォルトでnullが使えなくなっています。

Kotlinで同じメソッドを書こうとするとこのプログラムはそもそもビルドが通りません。

val result = method()
val upperCase = result.toUpperCase()

fun method():String{
  if(!isReady){
    return null // 戻り値は非nullなのでnullは返せない。
  }
  ..
}

これならうっかり本番で NullPointerExceptionを発生することがなくなります。
やったね

ほんとにそれで良いのでしょうか?

上記のままではコンパイルが通らないので、ついうっかりこんな修正がされがちです。

val result = method()
val upperCase = result.toUpperCase()

fun method():String{
  if(!isReady){
    return "" // nullを返せないなら空文字を返そう
  }
  ..
}

この場合コンパイルは正常に通りますし、NullPointerExceptionも発生しません。
やったね

ほんとにそれで良いのでしょうか?

もともと NullPointerException が発生したのは呼んではいけないタイミング(isReadyがfalse)で method() を呼び、その手当をしていなかったことであり、 NullPointerException が発生したことが問題ではありません。
NullPointerException を発生しなくしたことで問題のある状態が隠蔽されメソッドの処理が実行されないまま処理が継続されてしまいました。
例えばこれがログイン処理であればうっかり脆弱性のあるルートを作り出してしまうかもしれません。
nullが入るはずがない場所にnullが入っているのは怖いけれど、null以外の間違った値が入ったままになるのはもっと怖いです。
安易にnullを無くすのは危険です。

どうするべきか?

本来nullを返すべきところでは恐れずにnullを返すようにしましょう。kotlinではnullableな値が管理されるためメソッドの呼び元にnullだったときの処理を強制できます。

Kotlinではnullableの変数にアクセスするには事前に非nullのチェックが必要となるためチェック漏れを防ぎ、NullPointerExceptionが発生することはありません。

val result = method()
if(result != null){ // resultのメソッドにアクセスする前に非nullチェックが必要。
  val upperCase = result.toUpperCase()
}

fun method():String?{ // 戻り値をNullableにする
  if(!isReady){
    return null
  }
  ..
}

結局nullは必要なのか?

とはいえ、nullは直感的でなく、扱いづらい特性を秘めています。

nullとはなにか? を正確に説明できる人は少ないはずです。

よくある誤解

nullは初期化される前の値である。という誤解

これはよくある間違いです。
これが間違いであることは以下のコードで説明できます。
変数hogeを初期化せずに呼ぶ。

class Foo(){
  void fuga(){
    String hoge;
    if(hoge == null){
      // nullが初期化される前の値ならTrueになるはず
    }
  }
}

上記のコードはコンパイルできません。
Javaにおいては、初期化されていない変数へのアクセスは禁止されており、初期化されていない変数はnullではないからです。

一方で次のコードはコンパイルが通ります。

Javaでインスタンス変数hogeを初期化せずに呼ぶ

class Foo(){
  String hoge;
  void fuga(){
    if(hoge == null){
      // True
    }
  }
}

これはJavaの言語仕様としてインスタンス変数は生成時に初期化されることとなっており、初期値を明示的に設定しなかった場合 参照型(Stringは参照型)に対してはnullが初期値として初期化される事になっているからです。
つまりnullは初期化される前の値ではなく初期化されたあとの値です。

nullは不明(未確定)な値である という誤解

これもよく聞く間違いです。
これがよく聞く間違いである原因の一つは、RDBのようにnullを不明な値として扱っているシステムも多く存在するためでしょう。
ところがJavaやKotlinにおいてはnullは不明な値ではありません。それは次のコードで確認できます。

Javaでnull同士の値を比較する。

Person hanako = new Person();
Person taro = new Person();
hanako.age = null;
taro.age = null;
if(hanako.age == taro.age){
  // true or false?
}

この答えはtrueになります。
nullを不明な値とするならば、年齢がわからない花子と太郎の年齢が同一と扱われるのは不自然です。
それどころか、次のような場合でも
Javaで異なる型のnullを比較する。

String text = null;
Float number = null;
Object textObject = text;
Object numberObject = number;
if(textObject == numberObject){
  // true of false
}

textObjectはString型、numberObjectはFloat型なので、textObjectが不明なテキスト、numberObjectが不明な数値だとしたら、両者の値はことなる値になります。
ところがこの結果はtrueになります。
つまり、nullは値が不明であることを意味していません。

ではnullってなに?

初期化前の値でも不明な値でもないなら、nullとはなに? という問いには null は null としか答えようがありません。
nullというnull型の単一のオブジェクトを参照する値です。それを初期化前のようにあつかったり、不明な値のように扱ったりするのは利用者(=プログラマー)がそういう風にあつかっているにすぎません。

NullPointerException

nullの厄介な問題に、null型は他の参照型にCast可能で参照型の変数に潜り込み、しかもあらゆるフィールドやメソッドへのアクセスを拒み NullPointerException を発生させることにあります。
このため、静的型言語でありながら型が保証されないかのような現象が発生していしまいます。

じゃあnullを撲滅しよう。

と安直にやってはいけないのは最初に空文字を返すメソッドで説明したとおりです。
nullは便利で厄介な存在です。うまく使えば便利に使え、迂闊に使うと手を噛まれます。
そこでKotlinはデフォルトでnullを設定不可能とし、必要な場合のみnullableを使うということになっています。

もう少し説明的な型を作ってみる。

ということで本題。 nullは便利ですがそれが何を表すかはとてもわかりにくいです。
準備前のときに関数が呼ばれたことを表すためにnullを戻したいかもしれません。
不明な値であることを返すためにnullを戻したくなるかもしれません。
APIからnullが戻ってきたときそれがどちらの値を意味するかはドキュメントを注意深く読む必要があります。

そこで戻り値をnullableにしてnullを戻す代わりにもっと説明的な値を複数持てる型を作ってみます。
Kotlinでは Sealed class という便利な仕組みがあります。
Sealed class は同一ファイル内でしか継承できないクラスです。それにより利用時に具象クラスの型を制限できる特徴をもっています。
これを使い、明確な値を返すクラスと、値を持たないnullのようなオブジェクトを作りともにSealed classを継承することで複数の振る舞いを持つ型を作ることが出来ます。

複数の状態を持つ型

例として次の3つの状態をもつ事ができる Undefinable 型を作ってみます。

  • Defined 値が格納されている
  • Undefined 値が格納されていない
  • NotReady 準備ができていない

SealedクラスUndefinableを作り、同一ファイル内でUndefinableを継承したDefinedクラス、Undefinedオブジェクト、NotReadyオブジェクトをつくります。

sealed class Undefinable<in Any>
class Defined<T>(var value: T) : Undefinable<T>()
object Undefined : Undefinable<Any>()
object NotReady : Undefinable<Any>()

Undefinableクラスに値を格納する

Defineクラスは任意の型のvalueプロパティを持ち、コンストラクタで値を受け取ります。
DefineクラスはUndefinable型を継承しているためUndefinable型の変数に格納することが出来ます。

val definedValue: Undefinable<String> = Defined("Result")

Undefinedクラスは値が宣言されていない状態を表します。UndefinedはObjectなのでコンストラクタを呼ぶ必要がなく単一のインスタンスをそのままセットできます。
UndefinedオブジェクトもUndefinableを継承しているためUndefinable型の変数に格納することが出来ます。

val undefined: Undefinable<String> = Undefined

NotReadyもUndefinedと同様にObjectなのでコンストラクタを呼ぶ必要がなく単一のインスタンスをそのままセットできます。
NotReadyオブジェクトもUndefinableを継承しているためUndefinable型の変数に格納することが出来ます。

val undefined: Undefinable<String> = NotReady

Undefinableに格納された値を参照する

Nullチェックの場合と同様にUndefinableも参照前にチェックを強制したいところです。
これは型チェックで実現しています。

// 型チェックを行うことなく値を使うことはできない
fun getValue(value: Undefinable<String>) {
    value.value // 参照前にチェックしていないのでこの処理は行えない。
}

値を参照するには事前に型チェックが必要です。

// Defined型であることをチェックすることで、値が格納されていることを事前チェック出来る。
fun getValue(value: Undefinable<String>) {
    if(value is Defined){
        // KotlinではSmart castが有効なためif式の中では valueをDefinedとして扱うことが出来る。
        value.value
    }
}

複数の型に応じて処理を行いたい場合はwhenを使うと網羅的に書けるので便利です。

// Sealed Classは具象クラスが限定されているためwhenで網羅的に処理を書くことが出来る。
when (value) {
    is Defined<String> -> {
        value.value
    }
    is Undefined ->{
        // Undefinedのときの処理
    }
    is NotReady ->{
        // Not Readyのときの処理
    }
    
}

Define以外の場合にも処理を追加する

nullとは違いUndefinedやNotReadyに固有の処理を追加することも出来ます。

object Undefined:UndefinableUser(){
    override fun toString(): String {
        return "Undefined"
    }
}

Define以外の場合にも値を保持する

objectではなくclassを使えば値を保持することもできるので、例えば例外が発生した場合は例外を格納する変数を作成することも可能です。
コルーチンなどで例外は渡したいが処理は止めたくない場合等に便利です。

sealed class Undefinable<in Any>
class Defined<T>(var value: T) : Undefinable<T>()
object Undefined : Undefinable<Any>()
object NotReady : Undefinable<Any>()
class Failed(throwable:Throwable): Undefinable<Any>()

fun hoge():Undefinable<String>{
    return try{
        // 例外が発生する処理
        Defined("Result")
    }catch(throwable:NumberFormatException){
        Failed(throwable)
    }
}

状態は増やせる

今回はDefinedとUndefinedとNotReadyの3つの状態を持つ値を書きましたが、OverflowやUnknowunなどもっと好きなだけ状態を増やすことができます。
例外的な値を単一のnullで表されていたときよりもっと柔軟で安全に処理を記載することができます。

それではよいnullライフを

14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?