備忘録. 走り書き.
Kotlin化するときに苦しんだお話.
Java → Kotlin化する時によく困るのがNullabilityの判断.
ある日, こんな感じのコードに出会った.
class Hoge {
public final String id;
protected String foo;
public static Hoge from(proto HogeProto) {
if (proto == null) throw new IllegalArgumentException(...);
Hoge hoge = Hoge(proto.id)
hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
return hoge;
}
private Hoge(String id) {
this.id = id;
}
...
}
APIコールの応答として HogeProto
を受け取り, それをモデル Hoge
に変換させるコード. (Protocol Buffers と Wireライブラリを使ってる)
直したい部分がいくつかある.
まず, proto.id
がJavaのString型なので null
の可能性を捨てきれない.
もし, とってもラッキーなことに, 全く正しく疑う余地のない最新のドキュメントが存在していて「idは絶対にnullにならない」って明記されていたり, サーバサイドのコードが assert id != null
の不変条件を表明していたりする場合は, requireNotNull(...)
の一文を事前条件として追加できるかもしれない.
public static Hoge from(proto HogeProto) {
requireNotNull(proto, "...");
String id = requireNotNull(proto.id, "...");
Hoge hoge = Hoge(id);
....
でも, 残念なことに今回はそんな状況じゃなかった.
ビジネス上, proto.id
が null
である可能性が限りなく乏しい状況だけれど, 数千万のユーザを抱えるサービスのエンジニアとしての責任を考えると「大丈夫でしょ♪」と根拠のない自信だけで例外を投げるチェックコードを追加する訳にもいかないし, そんなコードをリリースした夜はきっと眠れない(私は少し心配性).
サービスやコードの規模が大きくなった後で, こうしたチェックを追加するのはかなり苦労する. この問題は, Hogeクラスのコードを書いたプログラマがちょっと気を利かせて, Hogeクラスの不変条件をコードで表明しておいてくれれば助かるケースだった.
不変条件が追加できると判断できれば, Kotlin化もスムーズに滞りなくできる.
class Hoge (
val id: String
) {
companion object {
fun from(HogeProto proto): Hoge {
requireNonNull(proto) {...}
val id = requireNotNull(proto.id) {...}
Hoge(id)
...
}
IDの不変条件の話はこれぐらいにして, Hogeクラスにはもう一つ問題があった.
でもそれはIDの問題と比べればとっても小さい問題. 相手はフィールド foo
.
class Hoge {
...
protected String foo;
public static Hoge from(proto HogeProto) {
...
Hoge hoge = Hoge(proto.id)
hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
return hoge;
}
...
}
foo
のアクセス修飾子は protected
. たぶん書いた当時はユニットテストからアクセスさせるためにスコープを広くとったんだと思う.
だったら @VisibleForTesting
をつけてほしいけど, まぁ本題じゃないのでそれは横に置いといて…(よくないけど)
実はHogeクラスはモデルというより POJO なクラス. ビジネスロジックを持っている訳でもないし, DTO 的な使われ方をする. Kotlin化で data class になるようなヤツ.
なので, 実際に foo
は一度初期化されれば, その後変更されない.
だけれども foo
はコンストラクタで初期化されていない.
なので, この状態で フィールドfoo
の宣言文に @NonNull
アノテーションをつけるとIDEが @Not-null fields must be initialized
ってWarningを表示する(そりゃそうだ).
何も考えずKotlin化すると lateinit
になっちゃうとこだけど, それはこのクラスの実態にあっていないから, そんなことはしたくない.
Kotlin化するときは引数やフィールドのNullabilityをはっきりとさせるためのチェックとして Javaコードに @NonNull
, @Nullable
を付けてからKotlin化するようにしているけれど, こういう foo
のようなコードを書かれると, 問題解消のための一手間が必要になる.
でもこれはIDの問題と比べればとっても小さい問題. foo
を final
にすれば解消できる(もちろん, そうできるなら… だけど)
class Hoge {
@NonNull public final String id;
@NonNull public final String foo;
public static Hoge from(proto HogeProto) {
requireNotNull(proto, "...");
String id = requireNotNull(proto.id, "...");
return Hoge(
id,
Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
);
}
private Hoge(String id, String foo) {
this.id = id;
this.foo = foo;
}
...
}
ここまでくれば, すぐにでもkotlin化に着手できる.
ちょっと id
の話に戻るけど,,,
HogeProto
側にある “デフォルト値” の意味を考えると id
も Wire.get(proto.id, HogeProto.DEFAULT_ID)
って形で取得した方がいいのかな〜って思ったりする.
でも, 大抵 DEFAULT_ID
って空文字だろうし, モデル Hoge
としてはIDに空文字を許したくないので, 結局次のようなコードが必要になる.
String id = Wire.get(proto.id, HogeProto.DEFAULT_ID);
if (StringUtil.isNullOrEmpty(id)) throw new IllegalArgumentException(...);
“空文字を許さない” って部分をちゃんと事前条件として表明できているのはいいことだし, もし DEFAULT_ID
が “空文字ではない何か” に置き換わるアップデートが proto
にあったとしてもうまく対応できそうだ.
大切にしたいのは, proto
はあくまで “APIレスポンスの仕様” ってだけで, クラス Hoge
はアプリ内で使われるモデルやデータとして正しい形で存在してなきゃいけないってところ.
Hoge
クラスに「こうあってほしい」って考えをコードに落とし込むってところ.
“理解なんてものは概ね願望に基づくものだ” ってセリフがある.
自分の理解をコードを通して他人にも理解させるってとこは, 願望のコード化でもあるなと思った.
閑話休題. 本題のkotlin化に戻る.
Nullabilityもハッキリして, 不変条件まで付いてくればKotlin化はかなり楽になる.
ただ, Hoge
のコードには現れていないけれど, Javaの頃によくやった NullObject Pattern
が意外とKotlin化する上で厄介だったりする.
例えば, もし次のようなコードがあった場合にちょっと困る.
final Hoge EMPTY = Hoge(null);
だいたい, UNKNOWN
とか EMPTY
みたいな名前と一緒に NullObject Pattern
が使われている.
↑のケースだと, Hoge.id
は nullable (String?)
にしなきゃいけなくなるし, わざわざ Hoge
が EMPTY
かどうかを各所でチェックする必要が出てくる.
null
を許容して Hoge?
なプロパティや引数をとる方が isEmpty
チェック漏れを心配する必要もない.
Javaの頃はこういうオブジェクトがあればnull-safeなコードが書けたので重宝したけど, Kotlinだと言語レベルでnull-safeをサポートしているので, NullObject Pattern
の必要性がかなり下がると思った.
むしろ, あると邪魔なケースが多くて, ?
や ?:
で解決できるようなところをわざわざif (obj !== UNKNOWN)
みたいにしなきゃいけないし, null
であってくれたほうが, 型チェック(nullable
or not-nullable
)の機構が働くので嬉しいことが多い.
特に何もしないデフォルトリスナーとかを NullObject
にしているケースなんかは nullable
にしてもさほど困らなさそう.
ただ, null
とNullObject
を明確に区別する使い方をしているケースだと簡単に nullable
にはできないので厄介だったりする.
以上, 走り書きでした.