この記事は Retty Advent Calendar 6日目です。
昨日は圭さん(@r4-keisuke)のAthenaを早速試してみたでした。
最近、巷ではにわかにKotlin人気が高まっていますね。
でもなぜか、KotlinといえばAndroid開発というイメージがあって、あんまり他のプロダクトをKotlinで書く話は聞かないような感じがしています。
Rettyでは、一部でJava製の内部APIが動いているのですが、社内Androidエンジニアに触発されてここ数ヶ月でKotlin化を進めてきました。Kotlin化して良かったことや、気をつけたいことなどをざっくり書きたいと思います。
Kotlin化することを決めた理由
何かの言語で書かれたプロダクトを別の言語に書き換える作業は、一般的には非生産的でかつ時間のかかる作業ですので、書き換えをするにはそれなりの理由が必要です。今回の移行では主に次のような理由で移行することに決めました。
NPEを減らせる
Javaでプロダクトをつくると、必ずと言っていいほどNullPointerException(NPE)という実行時例外に遭遇します。これを防ぐには、nullが入るかもしれない変数に対して常にnullチェックを入れなければならず、コードの見通しが悪くなりがちです。
String[] queries = queryString!=null ? queryString.split("&") : new String[0];
なお、Java8からOptionalが実装されており、nullチェックの代わりに使うこともできますが、あまり推奨されていないようです1。
一方、Kotlinは、null許容型を言語レベルで実装しており、それを使うことで簡潔に記述できます。
val queries: List<String> = queryString?.split("&") ?: listOf()
また、このnull許容型のおかげでNPEから解放されます2。
リストの操作が書きやすい
APIのロジックを書くときは、その性質上色々なモデルから情報を取得し、それらを組み合わせて、新しいデータホルダを作るケースが良くあります。それを毎回for文で回して書くのは結構骨が折れます。
Map<Long, Restaurant> restaurants;
List<Review> reviews;
List<Result> results = new ArrayList<>();
List<Review> addedReviews = new ArrayList<>();
for (Review review: reviews) {
if (!restaurants.containsKey(review.getRestaurantId())) continue;
if (addedReviews.contains(review)) continue;
Result r = new Result(review);
r.setRestaurant(restaurants.get(review.getRestaurantId()));
results.add(r);
addedReviews.add(review);
}
なお、Java8からはstreamAPIとラムダが実装され、だいぶ直感的な書き方ができるようになっています。
Map<Long, Restaurant> restaurants = <何か>;
List<Review> reviews = <何か>;
List<Result> results = reviews
.filter(r -> restaurants.containsKey(r.getRestaurantId()))
.distinct()
.map(r -> {
Result result = new Result(r);
result.setRestaurant(restaurants.get(r.getRestaurantId()));
return result;
}).collect(Collectors.toList());
Kotlinの場合はstreamにしなくてもいいのと、その他もろもろ細かいsyntaxの違いで、より見やすく書けます。
val restaurants: Map<Long, Restaurant> = <何か>;
val reviews: List<Review> = <何か>;
List<Result> results = reviews
.filter { restaurants.containsKey(it.restaurantId) }
.distinct()
.map {
val result: Result = Result(it)
result.restaurant = restaurants[r.restaurantId]
return@map result
}
検査例外がない
Kotlinには検査例外がありません3。つまり、try-catchを一度も書かなくてもコンパイルは通ります。(もちろん、コンパイルが通ると言うだけであって、それでプロダクトがOKな状態かどうかは別の話です。)
APIのロジックを書くときは、データベースやファイルシステムなど外部とのデータのやり取りが頻繁に発生します。これをJavaで書こうとすると、SQLExceptionとIOExceptionのtry-catchを大量に書く必要が出て来るわけです。メソッド内でcatchするにしろ、そのままthrowするにしろ、煩雑になるのは目に見えてますよね。
検査例外がないと不安に思う方もいらっしゃるかと思いますが、検査例外だからといって訳も分からずtry-catchを書いて握りつぶすぐらいであれば、そのまま気づかなかったふりをしてそのままthrowされたほうが幸せだと思います4。
導入障壁が低い
KotlinはJavaとの100%Interoperabilityを謳っており、今使っているJavaのライブラリをそのまま使ってプロダクト開発を進めることができます。地味かもしれませんがかなり重要なポイントですよね。
Kotlin化してはまったところ
Kotlinはすばらしい言語です。かわいいです。導入してよかったと心から思っています。が、Javaのライブラリを使わざるを得ない現状では、注意しておきたいポイントもいくつかありますのでご紹介します。
null許容型の使い方
さっきの検査例外で書いた事象が、逆にnull許容型において起こりうる可能性があります。
nullかどうか分からないからと言って、適当にnull許容型を使ってしまうと、呼ばれないと困るのに呼ばれないケースが発生します。
class Hoge {
private var name: String
fun updateName(name: String?) {
name?.let { this.name = it }
}
}
もちろん、nullを許容しないパラメータであれば本来null許容型を使用するべきではないのですが、Javaと共存させる場合には必ずしもそのようにできないケースもあります。それ以外でも、APIでクエリパラメータをメソッドのパラメータにマッピングする仕組みを使っている場合には、メソッドのパラメータに対してnull許容型を強制される場合があります。
こういう場合は、可能な限りメソッドの最初でスマートキャストをするのが良さそうです。
fun getRestaurant(@QueryParam("restaurant_id") restaurantId: Long?): Restaurant? {
@Suppress("NAME_SHADOWING")
val restaurantId: Long = restaurantId ?: throw BadRequestException("invalid restaurant_id")
...
}
検査例外が発生するロジックをKotlinで書くとJavaでcatchできなくなる
Javaには検査例外があり、検査例外をthrowするメソッドはcatchしないとコンパイルが通らないようになっています。
逆に、throwされない検査例外はcatchできないようになっています。
一方、Kotlinには検査例外がなく、Javaでは検査例外となるようなExceptionも暗黙的にthrowされるだけでthrows節は通常ありません。
つまり下記のようなコードはコンパイルが通りません。
public static void main(String[] args) {
try {
new Loader().load(); // IOExceptionをthrowしていないのでcatchできない
} catch (IOException e) {
// do nothing
}
}
}
class Loader {
fun load() = Files.readAllLines(Paths.get("file.txt")) // 本当はIOExceptionをthrowする
}
こういう事象は、Kotlin側のメソッドにJVMアノテーションの@Throwsをつけてあげることで解消できますが、それでもなお、@Throwsの中に書く例外クラスはエンジニアに委ねられますので注意が必要です。
関数型プログラミングしたくなる
よくありそうなのは、やたらvalで頑張りたくなるのと、ラムダの中が巨大になる傾向にあることでしょうか。このあたりは好みもあるかと思いますが。
valはJavaでいうfinalな変数です。宣言してから中身が変わらない変数というのは凄い安心感があるのですが、だからといって、valで書くために巨大でわかりづらいイニシャライザやラムダを書くのは何か違う気がします。
そんなことをするくらいなら、varにして簡潔に書いたほうが良さそうです。
エンジニア全員にいきなりKotlinを強制するのは無理
JavaからKotlinに移行する場合、当然それまで開発していた人はJavaを書いてきたわけですから、突然「明日からKotlinしか受け付けません」とか言っても誰もきっと書いてくれないと思います。また、既存のクラスにメソッドを追加する際にKotlinで都度クラスを書き直すのは現実的ではないなので、当面は「Kotlin推奨、でもJavaでもOK」という姿勢を続けるのが無難だと思われます。
次のような小さな抵抗をすることで、Javaでは書き/読みづらく、Kotlinなら書きやすいコードを実現することができますので、よければ導入してみて下さい。
@JvmNameを書かない
@JvmNameアノテーションは、拡張関数やトップレベルの関数をJavaから使うときに、そのメソッドをstaticに持つクラス名を指定するものです。一般的にはこれをちゃんと指定することで、Javaでも違和感なくメソッドを使えるようにしますが、指定しなくてもJavaからコールすることができます。
- 指定した場合
@file:JvmName("StringUtils")
infix operator fun String.times(times: Int): String {
return 0.rangeTo(times - 1).map { this }.joinToString("")
}
StringUtils.times("A", 5); // AAAAA
- 指定しない場合
infix operator fun String.times(times: Int): String {
return 0.rangeTo(times - 1).map { this }.joinToString("")
}
StringUtilitiesKt.times("A", 5); // AAAAA
このように、JavaにもKotlinの存在アピールをしながら使ってもらうことができます。
@JvmStaticを書かない
@JvmNameも同様ですが、Javaのstaticメソッドのようなものを定義するのに使われるcompanion object内のメソッドを、クラスのstaticメソッドと知らせるためのものです。これを省略することで、
StringUtils.staticMethod();
とコールできていたものが
StringUtils.Companion.staticMethod();
となります。Kotlinで書きたくなりそうですね。
でも@Throwsはちゃんと書く
上で述べたとおりですが、書かないとJavaで検査例外が絶対にcatchできなくなってしまいます。ここまでやったらいじめです。絶対にやめましょう。
まとめ
いかがでしたでしょうか?
JavaからKotlinへの移行に際してはいくつか注意点もありますが、Kotlinは基本的にJavaと100%Compatibleを謳っていて、導入の敷居も低いです。
この記事が、JavaのプロダクトをメンテしているけどKotlinのことをよく知らない方や、Kotlinを導入してみようか迷っている方の参考になれば幸いです。
-
もともとOptionalは返り値があるかないかわからないときに返り値が分かりやすくなるように作られたもので、パラメータやメンバ変数に使うのには向いていないようです。 http://blog.joda.org/2015/08/java-se-8-optional-pragmatic-approach.html ↩
-
多くの場合は、プロダクトを完全にKotlinのみで動かすことは困難で、実績のあるJava製のライブラリを採用するケースが多いと思われます。その場合は、そのライブラリを使う箇所でNPEが発生するケースもあります。 ↩
-
Kotlinでは、検査例外が必ずしも品質面で意味のあるものではない(むしろ生産性を下げることもある)などの理由から、導入していないようです。https://kotlinlang.org/docs/reference/exceptions.html#checked-exceptions ↩
-
「ほんとは動いてないけど何故か動いているように見える」より「明確に理由があって動いてない」ほうがみんな幸せなのではないかなぁと思います。 ↩