Kotlin
KotlinDay 19

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

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

Kotlinはnull安全な言語とよく言われます。

しかし、なぜnullだけが特別扱いされるのでしょうか?

例えば関数が準備できていないときに戻り値がnullとなる関数があるとします。

Java

String result = method();

String upper = result.toUpperCase();

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 ""
}
..
}

この場合コンパイルは正常に通りますし、NullPointerExceptionも投げられません。

やったね

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

もともと NullPointerException が発生したのは、呼んではいけないタイミングで method() を呼び、その手当をしていなかったことであり、 NullPointerException が発生したことが問題ではありません。

NullPointerException を発生しなくしたことで問題のある状態が隠蔽され想定外の状態のまま処理が継続するようになってしまいました。

例えばこれがログイン処理であればうっかり脆弱性のあるルートを作り出してしまうかもしれません。

nullが入るはずがない場所にnullが入っているのは怖いけれど、null以外の間違った値が入ったままになるのはもっと怖いです。

安易にnullを無くすのは危険です。


どうするべきか?

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

val result = method()

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

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


結局nullは必要なのか?

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

nullってなに? という質問に説明できる人はあまりいないのではないでしょうか?


よくある誤解


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

これはよくある間違いです。

これが間違いであることは以下のコードで説明できます。

Java

class Foo(){

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

上記のコードはコンパイルできません。

Javaにおいては、初期化されていない変数へのアクセスは禁止されており、初期化されていない変数はnullではないからです。

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

Java

class Foo(){

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

これはJavaの言語仕様としてインスタンス変数は生成時に初期化されることになっていて、

初期値を明示的に設定しなかった場合 参照型(Stringは参照型)に対してはnullが初期値として初期化される事になっているからです。

つまりnullは初期化される前の値ではなく初期化されたあとの値です。


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

これもよく聞く間違いです。

これがよく聞く間違いである原因の一つは、RDBのようにnullを不明な値として扱っているシステムも多く存在するためでしょう。

ところがJavaやKotlinにおいてはnullは不明な値ではありません。 それは次のコードで確認できます。

Java

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

String text = null;

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

textObjectはString型、numberObjectはFloat型なので、その値が不明だろうと両者はことなる値なのは明確です。

ところがこの結果はtrueになります。

つまり、nullが不明な値というのは間違いと言えます。


ではnullってなに?

初期化前の値でも不明な値でもないなら、nullとはなに? という問いには null は null としか答えようがありません。

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


NullPointerException

nullの厄介な問題に、null型は他の参照型にCast可能で参照型の変数に潜り込み、しかもあらゆるフィールドやメソッドへのアクセスを拒み NullPointerException を発生させることにあります。

このため、静的型言語でありながら型が保証されないかのような現象が発生していしまいます。


じゃあnullを撲滅しよう。

とやってはいけないのは最初に説明したとおりです。

nullは便利で厄介な存在です。うまく使えば便利に使え、迂闊に使うと手を噛まれます。

そこでKotlinではデフォルトではnullを設定不可能なnon nullとし、必要な場合のみnullableを使うということになっています。


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

ということで本題。 nullは便利ですがそれが何を表すかはとてもわかりにくいです。

APIが準備前のときに呼ばれたときに準備前であることを表すためにnullを戻したいかもしれません。

一方で、結果の値として、不明な値であることを返すためにnullを戻したくなるかもしれません。

APIからnullが戻ってきたときそれがどちらの値を意味するかはドキュメントを注意深く読む必要があります。

そこで戻り値をnullableにしてnullを戻す代わりにnullのかわりにもっと説明的な値を複数持てる型を作ってみます。

Kotlinでは Sealed class という便利な仕組みがあります。

Sealed class は同一ファイルからしか継承できないクラスです。同時に外部で利用するときに具象クラスを制限できるというメリットがあります。

これを使い、明確な値を返すクラスと値を持たないnullのようなオブジェクトを作りともにSealed classを継承することで複数の振る舞いを持つ型を作ることが出来ます。


複数の状態を持つ型

例として


  • 値を持つ

  • NotReady

  • Undefined

の3つの状態をもつUndefinableクラスを作ってみます。

sealed class Undefinable<in Any>

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


値の格納

Defineクラスは任意の型のvalueプロパティを持ち、コンストラクタで値を受け取ります。

DefineクラスはUndefinable型を継承しているためUndefinable型の変数に格納することが出来ます。

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

未定義な状態を表すにはUndefinedを格納します。

val undefined: Undefinable<String> = Undefined

こちらも同様にUndefinable型を継承しているためUndefinable型の変数に格納することが出来ます。

UndefinedはObjectなのでコンストラクタを呼ぶ必要がなく単一のインスタンスをそのままセットできます。

NotReadyもUndefinedと同様です。

状態の種類はすきなだけ増やすことが出来ます。

val undefined: Undefinable<String> = NotReady


値を参照する。

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

これは型をチェックさせることで実現しています。

fun getValue(value: Undefinable<String>) {

value.value // 参照前にチェックしていないのでこの処理は行えない。
}

事前に型チェックを行うことで参照前に値が格納されていることをチェック出来る。

fun getValue(value: Undefinable<String>) {

if(value is Defined){
// KotlinではSmart castが有効なためif式の中では valueをDefinedとして扱うことが出来る。
value.value
}
}

Define以外の場合に処理を行いたい場合は whenを使うと便利

when (value) {

is Defined<String> -> {
value.value
}
is Undefined ->{
// Undefinedのときの処理
}
is NotReady ->{
// Not Readyのときの処理
}

}

もし、汎用的な型ではなく、特定の型のみで使用したい場合、ジェネリクスを使わない形でも実現可能。

sealed class UndefinableUser

class Defined(val name:String,val userId:String,val password:String):UndefinableUser()
object Undefined:UndefinableUser()
object NotReady:UndefinableUser()


値を設定

val definedValue:UndefinableUser = Defined(name = "taro", userId = "taro@firespeed.org", password = "lkjdoiwaeoijv")

val undefined:UndefinableUser = Undefined
val notReady:UndefinableUser = NotReady

値の取得

if(value is Defined){

value.name
value.userId
}


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

また、この方式の場合、nullとは違い必要に応じて処理を追加することが出来ます。

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)
}
}

それではよいnullライフを