しばらくは忙しく過ごしていてなかなか技術書を読む余裕はありませんでしたが、ようやく一冊読めたのでメモを残しておきたいと思います。『Kotlin in Action』という本の第2版です。未邦訳らしかったので、原著を読みました。
なお、気になったところだけつまんで読んだので、すべての章のメモが記されているわけではありません。ほとんどの章はScalaの頃の経験で概念としては知っている状態だったので、あまり印象に残りませんでした。後半戦は比較的Kotlin固有の概念が多くそちらは印象に残りました。
大前提ですが、Android側のKotlinの話はわかりません。したがって、いわゆるサーバーサイドKotlinというかよく想起されるJVMでの使用を前提としています。
書籍の内容
Part1は正直大丈夫かなと思ったので、Part2からちゃんと読み始めています。
1章
Kotlinというプログラミング言語についての概略です。Kotlinの設計思想を知ることができます。「おそらくこのような思想で設計された言語なのだろうな」という考えを持って読んではいたのですが、その答え合わせができる章だったように思います。
Kotlinは「実用的」「明示的」「安全」の3つを特徴とするプログラミング言語であると解説されています。
Kotlinの「実用的」は、実際にプログラミングをしている際に直面するユースケースから必要な機能を絞って提供していることを意味します。また、何か特定のプログラミングパラダイムに必ずユーザーを従わせるような言語ではなく、ユーザーが自身の慣れたパラダイムでプログラムを記述できるよう機能を提供しています。あるいはIDEをはじめとするツール周りの充実を徹底しており、ユーザーがすぐに開発者体験のよい開発に着手できることにこだわっているようです。
「明示的」というのは要するに読みやすいことです。Kotlinは意図的に記号を排しているように感じていたのですが、やはり設計思想的にもそうだったことが確認できました。たとえば遅延初期化をさせる際にはlateinit
というキーワードを使用するわけですが、このようにキーワードを使った機能のオン・オフはKotlinを書いている上では散見されます。Swiftを書いたときもキーワードが多いと感じましたが、似たような設計になっていると思います。記号が少ないのはググラビリティが担保されるのでよいことだと個人的には思っています。
また、標準ライブラリの充実度合いも「明示的」に一役買っていると言えます。標準ライブラリが少ないと、何をするにもたくさんコードを書いて目的を達成する必要が出てきます。しかしその目的をメソッド一つで達成できるようなものが標準で提供されていれば、それだけコード中の情報量を圧縮でき読みやすいコードにつながるというわけです。
「安全性」は、たとえばJVMが担保するようなメモリ安全性やオーバーフローをはじめとする未定義動作を防ぐ意味での安全性がまずひとつ挙げられます。Kotlin固有のものとして特徴的だと思うのは、null安全とキャストの安全(つまり、型安全)です。このあたりは先日解説したスマートキャストと呼ばれる機能で静的かつ安全に処理できるのがKotlinの大きな特徴です。
型安全に関連して読んだんですが、KotlinはScalaなどとは違いコード中に現れる明示的な型付けというよりは、コンパイラが裏で暗黙的に処理する型付け(Kotlinではnon-denotable typesとかいう)が大きな役割を果たしている印象です。non-denotable typesはユーザー側から自由にいじれない点は確かにデメリットではあるのですが、一方でユーザーが使う分には軽めの型付け(?)をはじめとする言語の使い心地のライトさに貢献できていると思っています。
その他の推しポイントとしてツール周りがあがっています。個人的にはKotlinのツール周りはもう少し充実してほしいかなと思っていて、たとえばLanguage Serverを提供してほしいなと思っています。非公式のものはあるのですが、頻繁に不具合でクラッシュしたり、大きなKotlinプロジェクトを読み込むと処理がハングしたりするなど、あまり実用レベルにないなと感じています。JetBrains製のエディタを使ってほしい気持ちはわからなくはないんですが、最近だとたとえばGitHub上でVS Codeを起動したいなというときに、きちんと動作するLanguage Serverがなくてよく困っています。IntelliJのプラグインで全部対応できてしまいそうですが。
9章
演算子オーバーロードの話です。演算子オーバーロード自体は他のプログラミング言語と大差はなく、他の同様の機構を持つプログラミング言語でできることは大半できると思っておいてよさそうです。
一方で少々驚きかつ注意が必要だと思ったのが、destructuringです。destructuringはdata classで利用可能で、component1
、component2
といった関数を経由して提供される機能です。ScalaやRustに慣れている場合、当然の権利として享受したい機構でもあります。
注意点として、Kotlinのdata classに対するdestructuringはpositional destructuringと呼ばれるものだ、ということです。positionalなので、destructureする先の変数の順序が大事です。つまり、たとえば次のようなミスが起こりえます。日々の運用で普通にミスしそうですね。
data class A(a: String, b: String, c: String) // a, b, cはフィールド名に対応するのではなく、この順番にAのフィールドの値が埋め込まれる。 // たとえば、b, cの順序が入れ替わったとしてもおそらく気づかない。 val (a, b, c) = A(...)
そういうわけで、とくにフィールドの数が多いクラスに対してdestructuringを使用することを、本書は推奨していないようです。個人的にもこの仕様を見た感じ、フィールドの数は2、3個が限度かなと思いました。
ScalaやRustではいわゆるフィールド名ないしはエイリアス先の一時変数名を使ったname-based destructuringが採用されており、混乱が少ないように設計されているように思います。Kotlinにも一応その予定はあるようで、value classに実装されることになるようです。
正直なところ少々中途半端な仕様に感じていて、できればdata class側もname-based destructuringを採用しておいて欲しかったなというのがあります。一方でKotlinの場合、apply
をはじめとするスコープ関数が用意されている関係で、実はほとんどdestructuringが欲しくなる機会はないかもしれない、とも思います。これまでの経験上destructuringが無性に欲しくなる場面に出会ったことはほぼなかったです。だいたいスコープ関数で済む印象を持っています。
11章
reified
がなぜ必要なのかあまり理解できていませんでしたが、この章を読んでよくわかったという感想を持ちました。
知らなかったのですが、JVMでは基本的にジェネリクスの型はコンパイル時に消費され、実行時には消去されるんですね。これはつまり、実行時にはジェネリック型の情報をまったく持っていないことになります。が、この仕様で困るのはたとえばジェネリック型に対してリフレクションを行いたい場合でしょうか。実際実務でも困ったんですが、その際エラーメッセージか何かでreified
を使うよう指示された記憶があります。
inline
とreified
はセットで組み合わせて使用する必要があります。これはinline
関数がいわゆるインラインコード展開を行うからで、コードの展開が行われるタイミングでそのジェネリクスに埋められるべき具体的な型情報も同時に埋め込まれ、バイトコードが生成されるためです。裏の仕組みを理解したので、次からはセットで使うのを忘れなそうです。
13章
DSLに関する解説です。KotlinはDSLを書きやすくしている印象がありますが、実際ライブラリを利用していても多々DSLが登場します。DSLは使っているうちは「使いやすい!」で進んでいけるのですが、いざ実装に足を踏み込むと急にわけのわからなさがやってきます。
この章でとくに学んだのは Receiver Type ならびに、Receiver Type Object です。どういうことかは次のコードを見るとわかりやすいのではないかと思います。
package org.yuk1ty.sandbox.chapter13 fun buildString(builderAction: StringBuilder.() -> Unit): String { val sb = StringBuilder() sb.builderAction() return sb.toString() } fun main() { val s = buildString { this.append("Hello, ") append("world!") } print(s) }
buildString
関数内でStringBuilder
を操作する処理を受け取り、中で実行させるという関数になっています。ここでStringBuilder.()
という型が出てきますが、これがReceiver Typeと呼ばれるものです。この型で定義されたブロック内は、StringBuilder
型をレシーバとして使用することができます。これにより、main関数内に記述されているように、this.append
のようにStringBuilder
オブジェクトに紐づくメソッドを呼び出せます。
また、いわゆる関数オブジェクトもKotlinでは実装可能です。これを利用するとGradleの設定ファイルを実装できます。
package org.yuk1ty.sandbox.chapter13 class DependencyHandler { fun implementation(coordinate: String) { println("Added dependency on $coordinate") } operator fun invoke(body: DependencyHandler.() -> Unit) { body() } } fun main() { val dependencies = DependencyHandler() dependencies.implementation("org.jetbrains.kotlin:kotlin-test") dependencies { implementation("org.jetbrains.kotlin:kotlin-test") } // invokeは下記と同じことをしている。Scalaのapplyと同じで、関数オブジェクトを呼び出すためのメソッドだと思う。 // dependencies.invoke { implementation("org.jetbrains.kotlin:kotlin-test") } }
DependencyHandler
のインスタンスを作っておき、あとはそれを関数として利用できるようにinvoke
という特別なオペレータを実装します。9章でも見たようにオペレータはKotlinで登場する特別視される関数のようなもので、たとえば足し算や引き算などの演算から、インデックスアクセスを可能にするget
演算子、デストラクタまで幅広くあるようです。
dependencies
変数で保持したDependencyHandler
のインスタンスを実質的に関数呼び出しとさせることで、dependencies {}
という呼び出し方ができるようになりました。DependenciesHandler
のinvoke
関数が引数にとるのはDependencyHandler.()
です。つまり、レシーバを中で呼び出し可能ということです。これにより、dependencies {}
ブロック内でimplementation
関数を呼ぶことができます。
14章以降
Coroutinesに関する話だったのですが、こちらは別で記事にして概念を整理しようと思っています。
個人的なKotlinに対する印象
まだ使い始めて2ヶ月程度ですが、現状持っている印象を記しておきます。
モダンな言語機能がまずまず揃っている
私はScalaやRustのユーザーではありますが、そうしたユーザーから見ても一通り必要な機能は(そのままの形で導入されているわけではないにせよ)揃っていると感じています。というか、総じて機能は多めですね。もちろんいくつかはKotlinのコンパイラの思想に合うようにカスタマイズされて導入されているように見受けられます。
たとえばパターンマッチと他のプログラミング言語では呼ばれるwhen
があるでしょう。これはスマートキャストという概念と組み合わせて利用できるように、Kotlin向けにカスタマイズされて輸入され使用されているように思われます。あるいはScalaにあるようなコンパニオンオブジェクト等の概念も一通り揃っており、Kotlinの言語設計に合うようにカスタマイズされています。
もちろんこの「カスタマイズ」には好き嫌いがあるような感想を聞くことはありますが、私は達成したい目的が達成されていれば手段はなんでもよいタイプなので、そんなに気になっていません。それよりそうした非常に便利で保守性を高められるような優れた機能がそもそも「ない」ことの方が気になります。
思ったより静的に色々解決できる
思ったよりもきちんと静的解析に倒されている処理が多いです。型情報にすべてを落とす文化でコードを書いていた側からすると、思ったよりコンパイラによって裏で暗黙的に処理されている型付けが多く(non-denoted typesといいます)違和感があるかもしれません。
たとえばスマートキャストは一見するとキャストの類なので動的な型付けが起こり、静的に解決されていないように見えるかもしれません。しかしスマートキャストそれ自体はフローから型情報を取得し、対象の型付けを解決する静的解析な処理です。
本書の最初の章でも「安全性」に対してフォーカスが当たっていましたが、安全性(たとえば不意にNPEしないとか、キャストの結果ランタイムエラーが投げられないとか)についてはよく担保されているように思います。
coroutineはすばらしいが
Kotlinにはスタックレスコルーチンが導入されています。これにより並行処理を非常に楽に実装することができます。関数にsuspend
と記述するだけで、自動的にその関数の処理はコルーチンのキューに入れられ、処理されるようです。suspend fun
はsuspend fun
内でしか呼び出せませんが、呼び出し箇所に都度await
のような記述をする必要はありません[*1]。これは開発者体験が少し優れていると思います。
一方で運用していてしんどく感じるのはコルーチン内で送出されたエラーのスタックトレースです。呼び出し元となった関数とは別のスレッドで実行されるからか、呼び出し元の関数の情報は切り離され、例外が送出されたスレッドを起点にスタックトレースが吐き出されてしまうように見受けられます。ツールを使った緩和策などあるようですが、そもそもこの辺りは言語側でちゃんと追跡して欲しいなという気持ちは正直なところあります。なお、私が深く知らないだけでたとえばコンパイラオプションなどがあるのかもしれません。
サーバーサイドKotlinへの所感
いわゆるサーバーサイドKotlinについては、思ったよりPure Kotlinなエコシステムが充実していない印象を持っています。現状だとJavaの資産を使える関係で、たとえばSpring類を使えば幅広く要件に対応できるという可用性を持っているといえば持っています。しかし、Pure Kotlin側のエコシステムが現状はイマイチです。たとえばKtorのgRPC対応がまだ、などです。
ではJavaの資産に乗っかればよいのでは、という意見もあると思います。しかし各ライブラリを使用した際にJavaの資産に引っ張られるのも一長一短あると考えています。先ほども言及したようにJavaの大きなエコシステムにすぐに乗れるのはメリットです。一方でデメリットは、たとえばJavaの標準ライブラリないしはサードパーティライブラリから提供されるレガシーで微妙なデザインのAPIを使用する必要があるケースです。レガシーなデザインは、モダンなプログラミング言語ではそもそも踏む必要のないステップを踏んでいたり、保守性や運用性に難があることが多いと思います。そうしたbad habitに引っ張られる傾向にあるのは明確なデメリットでしょう。
エラーハンドリングのチグハグさ
Javaの資産に引っ張られる関連で他に問題なのは検査例外の扱いだと思います。現状Kotlinでは(Scalaと同じように)検査例外のキャッチを強制しない方向に倒しているようです。その際問題になってくるのが、Javaの資産を呼び出した時の対応です。こうした資産側からの例外は、検査例外をキャッチしない手前すべてスローされ握りつぶされることはないといえばないのですが、これだと実アプリケーションの保守や運用上困るケースがあります。たとえば関数のシグネチャを見ただけでは例外の送出の可能性をすぐに認知できない、コードを奥深くまで辿る必要があるなどです。これはただ、Javaとのインテグレーションを前提としている以上、多少仕方のない仕様なのかなと思います。
Kotlinはエラーハンドリングにしばらく悩んでいるように見受けられます。Result
型をKotlinの標準ライブラリで提供したものの、デザインがイマイチな関係からかあまり利用されている印象は持っておらず、多くのサーバーサイドKotlinの実プロジェクトでは、kotlin-resultやarrow-ktといったライブラリの使用が検討されているようです。一方で、Result
型ないしはEither
型はたしかに優れた解決策ではあるのですが、こうしたライブラリは単なる複雑な型パズルを生んでいるだけという見方もできてしまいます。加えて初期化ブロックinit
内でのエラーハンドリングも困りものです。
解決策としてKotlin 2.0以降での導入が行われているユニオン型を利用したエラーハンドリングが検討されているようです。つまり、T | Error
という型を返すということです。ここにさらに言語側からのエラーハンドリングの支援が加わるような提案もなされており、これが承認されると、Kotlinのエラーハンドリング周りのデザインはかなり前進することになるのではないかと思われます。ユニオン型によるエラーハンドリングは、個人的にはKotlinのような手続的書き方を多く使用するプログラミング言語とは相性がよく、そもそも複雑な型付けをユーザーに押し付けないKotlinの設計思想と良くあっていると思っていて今後に期待できそうです。一方で、Kotlinにおけるエルゴノミックなエラーハンドリングには初期化ブロックやrequire
のような標準ライブラリにいるアサーション関連の関数に対しても改善が必要になると思っています。なので、統一的な議論がなされるとよいなと思っています。
関数型プログラミング
Kotlinで関数型プログラミングをする動きは、実際記事などでよく見かけます。たとえば次のような書籍を見かけており、個人的にも試してみています。
Arrow-ktというライブラリもあり、こちらを利用するとEither
型などを利用できて嬉しい!といった感じのようです。
個人的な意見としては、Kotlinでも十分関数型プログラミングを使ったアプリケーションの構築は楽しめると思っています。他のプログラミング言語でいわゆるモナドをはじめとする概念を利用する際、専用記法がないために逆に型パズルを生んで大変になっているケースが散見されますが、Kotlinの場合DSLがあります。これが意外によく効いてくるように思っており、少なくとも型パズルはDSLである程度防げる感じがしています。
ただ、それをするのであれば、そちら方面のエコシステムがより充実した、歴史のあるScalaも選択肢となってくるわけです。ScalaにはAkkaなどの実績あるフレームワークやライブラリも多くあります。「関数型プログラミング」という新しい概念を学ぶ必要がある場合、であれば最初からScalaを選択してもよいのでは?そちらの方が安全択では?と私はなってしまうのです。Kotlinでわざわざ関数型に取り組みたい理由が見つからないのです。[*2]
そういうわけで、私個人はKotlinでいい感じに手続型、オブジェクト指向、関数型あたりを融合したマルチパラダイムなプログラミングをやっていきたいなと思っています。一昔前、Scalaで言われていた「Better Java」という言葉がこれに近いでしょうか。そういうわけでKotlinのエラーハンドリングが、現状のKotlinが持つResult
やEither
のようなコンビネータ方式のものではなく、ユニオン型と言語側から支援を受けた脱出機構を組み合わせたエラーハンドリングだと嬉しいと思っているというわけです[*3]。スマートキャストとの相性もいいですしね。
*1:main関数に至るまでには、一度以上Rustでいうtokio::spawn_blockingに相当するrunBlockingブロック内に入れ込む必要がありますが。
*2:「Scalaエンジニア」で募集をかけてしまうと採用市場で大変だから、という理由はあると思います。会社の技術戦略的な都合でKotlinを採用しているものの、関数型プログラミングにチャレンジしたい場合にこうなるのかなとは推察しています。ちなみにですが、Kotlinも情報発信されている量も求人も少なく、Scalaを採用する場合と実は大差ないのではと個人的には思っています。この辺りは定量的に分析してみると興味深い結果が得られるかもしれません。
*3:RustのResult型は同様に?という脱出機構を言語側が用意しており、これにより手続的なプログラミングスタイルを損なわずにコードを書けるようになっていると思っています。もちろんmapやand_thenのようにコンビネータを利用する記法も使えます。