ソフトウェアである値を扱いたい。しかし、その値は「無効値」を含む。どのように扱うべきか。
というのはよく遭遇する課題です。
例えば、「気温」を扱うことを考えましょう。簡単化のため整数で扱うものとします。
この気温はセンサーの異常や点検・修理で停止し、計測できない状況を想定しなければなりません。そのとき「無効値」を取ります。
さて、この無効値、どのように表現しましょうか?
よくある方法は、通常取りえない値を無効な値をINVALID
として定義する方法ですね。
負の値を取らない値の場合、-1
がよく使われます。残念ながら気温として-1
は正常値として取り得る値なので、例えば999
を無効値とする。というのも一つの方法です。Int.MAX_VALUE
/Int.MIN_VALUE
を使用する場合もあるでしょう。
また、値がないということで、null
を「無効値」として使うこともできますね。
この無効値、JSONなどでシリアライズされているときの表現はこれで良いとして、ソフトウェア内部で受け渡しをする際に、この表現のままだと、チョットした不注意からバグを引き起こす危険性があります。
あり得ない数値を無効値とする
たとえば999
を無効値として扱うとしましょう。以下のようなクラスで扱います。
data class Temperature(val value: Int) {
fun isValid(): Boolean = value != INVALID
companion object {
const val INVALID: Int = 999
}
}
この値を表示しましょう
binding.textView.text = "${temperature.value}℃"
無効値が入っていると、
やってしまいました。「999 ℃」と表示されてしまいます。くっ……トラウマが……
チェックすればすむ話ではありますが、定義の経緯を知らない人が気づけるでしょうか?クラス定義を確認すれば、無効値を定義していたり、判定メソッドが生えていたりするので、気づく余地はあるでしょう。しかし、valueが参照できた時点で、これで値が参照できるんだな。と思ってしまいますよね。定義までしっかり確認しなければ気づけない時点でリスクは高いです。
そして、そもそもこのような定義をしたのが自分だったとして「うっかり」忘れてしまうのが人間です。
しっかり確認していればミスが起こらない。というのは、裏を返せば、チョットした思い込み、見落としがあっただけでミスが発生するということです。
nullを無効値とする
同じ整数値を入れておくから危ないんだ、Nullableにしてnullにすればいいじゃん。
data class Temperature(val value: Int?)
そうですね。KotlinではNullableな値は、nullチェックをしてからでないと値にアクセスできません。
うっかりチェックせずに使っても、コンパイルエラーになって気づけますよねー
binding.textView.text = "${temperature.value} ℃"
無効値が入っていると、
はい、「null ℃」いただきました。Xとかに晒される奴です
いやいや、文字列テンプレート使っているからで、toString()
使えば
binding.textView.text = temperature.value.toString() + " ℃"
これ、コンパイルエラーになず、同じく「null ℃」になっちゃうんですよね。
public fun Any?.toString(): String
数値計算にはチェックしなければ使えないですし、nullableであれば、多くのエンジニアはチェックが必要であると気づけると思いますので、あり得ない数値を無効値として扱うよりはいくらか安全な方法とは言えますが、もう一押しほしいところ。
文字列で扱う
文字列化したときに問題が起こるんなら、オブジェクトを作る段階で文字列にしておけばいいじゃん
data class Temperature(val value: String) {
override fun toString(): String = value
companion object {
const val INVALID: Int = 999
fun of(value: Int?): Temperature = when (value) {
null, INVALID -> Temperature("--")
else -> Temperature("%d".format(value))
}
}
}
確かにこうしておけば、都度判定は不要になりますね。
ただ、気温が氷点下の場合はテキストの色を変える。とか、気温の変化をグラフで表示する。のように数値として扱う要件がある場合、さらに工夫が必要になります。うっかりtoInt()
してNumberFormatException
が発生してしまう、というリスクもあります。
sealed interface を使う
こういう場合に便利なのが sealed interface ですね。
以下のように表現してみましょう。
sealed interface Temperature {
object Invalid : Temperature {
override fun toString(): String = "--"
}
data class Valid(val value: Int) : Temperature {
override fun toString(): String = "%d".format(value)
}
companion object {
private const val INVALID: Int = 999
fun of(value: Int?): Temperature = when (value) {
null -> Invalid
INVALID -> Invalid
else -> Valid(value)
}
}
}
toString()
を呼び出すと、無効値は無効値としての文字列表現、正常値はその文字列表現が返ります。チェックがそもそも不要です。
文字列テンプレートでも直接使えます。
binding.textView.text = "${temperature} ℃"
無効値が入っていると、
(この表現の是非は置いておいて)内部表現が直接表示されることもありません。
一方で、具体的な値を参照するには、Temperature
のままではアクセスできません。型チェックを行い、Temperature.Valid
であることを確認する必要があります。
if (temperature is Temperature.Valid) {
val belowZero = temperature.value < 0
チェックせずにアクセスするとコンパイルエラーとなるため、「うっかり」を防ぐことができます。
おまけ
以下の記述は少し型チェックの記述が長くなりがちです。
if (temperature is Temperature.Valid) {
val belowZero = temperature.value < 0
以下のように書けた方がスマートのように思います。
if (temperature.isValid()) {
val belowZero = temperature.value < 0
これが実現できる拡張関数は以下のようになります。ExperimentalContracts
ではありますが、型チェックと同等であることをコンパイラーに伝えることができます。
@OptIn(ExperimentalContracts::class)
fun Temperature.isValid(): Boolean {
contract {
returns(true) implies (this@isValid is Temperature.Valid)
}
return this is Temperature.Valid
}
必要に応じて以下のような拡張関数を用意しておくと便利かもしれません。
inline fun <T> Temperature.fold(
ifValid: (Int) -> T,
ifInvalid: () -> T,
): T = when (this) {
is Temperature.Valid -> ifValid(value)
is Temperature.Invalid -> ifInvalid()
}
fun Temperature.getOrNull(): Int? = fold(
ifValid = { it },
ifInvalid = { null }
)
fun Temperature.getOrDefault(default: Int): Int = fold(
ifValid = { it },
ifInvalid = { default }
)
チェックが必要な値を、チェックしなくても使える状態にしていると、「うっかり」ミスをしてしまいます。
人間である以上、どんなに気をつけていても、見落とし、うっかりを完全に防ぐことはできません。
チェックしなければコンパイルが通らない、とか、チェックしなければ100%異常が起こる仕組み、を作ることでミスを防ぎましょう。
以上です。