メインコンテンツまでスキップ

KotlinにおけるSAMタイプの話

Sato Taichi
yak shaver

手元にある Java のフレームワークをせっせと Kotlin に置き換えているのだけども、やはり釈然としないことは色々と出てくる。

本日の話は、Java と Kotlin の間で確保されているというInteroperabilityについて。

尚、記事中で使っている Kotlin のコンパイラは 1.0.0。 これらの問題は将来的には改善されるかもしれない。

interface 定義

こういう Java の interface を定義する。

package aaa;
public interface JavaSAM {
String doIt(int i, String s);

static void call(JavaSAM ms) {
System.out.println(ms.doIt(1, "zzzz"));
}
}

Kotlin から Java のメソッドを呼ぶ

これについては、マニュアル通りなので特に変な部分は無いように思う。

fun java_interop() {
// IntelliJ says me `Convert to Lambda`
JavaSAM.call(object : JavaSAM {
override fun doIt(i: Int, s: String?): String? {
return "zzz$i$s"
}
})
JavaSAM.call { i: Int, s: String? -> "zzz$i$s" }
JavaSAM.call { i, s -> "zzz$i$s" }
}

最初の呼び出しは、object 記法で JavaSAM を実装するオブジェクトをアドホックに作ってJavaSAM#callに渡すやり方。 冗長で誰も得しない感じなので IntelliJ はラムダ式にしろって煽ってくる。

二番目は、引数の型を明示しつつラムダ式にしてみた。

三番目は、省略できるものを全部省略した形。

既存の Java API が SAM タイプを要求してくる場合、Kotlin のラムダ式をアドホックに渡してあげれば、足りない部分は Kotlin が適宜補ってくれるという素晴らしい形。

Java6 相当の環境である Android アプリを作るなら喉から手が出る程欲しい記法だろう。

Java の SAM タイプを引数にとる Kotlin の関数を使う

この辺からやや納得感のない感じになり始める。まずは、主題となる関数の定義。

引数に Java の interface をとる感じに定義してある。

fun callJavaSAM(sam: JavaSAM) = println(sam.doIt(1, "a"))

これを呼び出す Kotlin のコードを色々書いてみた。

コードだけ見てコンパイルエラーになるコードを即座に見分けられるなら、Kotlin コンパイラを脳に積んでると言えるかもしれない。

fun sam_conversions() {
val ms0: JavaSAM = JavaSAM { i: Int, s: String? -> "zzz$i$s" }
callJavaSAM(ms0)

val ms1 = JavaSAM { i: Int, s: String? -> "zzz$i$s" }
callJavaSAM(ms1)

val ms2: JavaSAM = { i: Int, s: String? -> "zzz$i$s" } // Error
callJavaSAM(ms2)

val ms3 = JavaSAM { i, s -> "zzz$i$s" }
callJavaSAM(ms3)

val ms4: JavaSAM = { i, s -> "zzz$i$s" } // Error
callJavaSAM(ms4)
}

これらのコードはSAM Conversionsに基づいて書かれている。

基本的には、Java の SAM タイプを Kotlin のコードで変数として取り扱う方法が分かれば、これらのコードが何をしたいのか分かるはず。

ms0は、型の宣言を出来るだけ冗長に書いてみた。完全に誰も得しないやつだ。

ms1は、右辺値として型の宣言があるので、変数の型がコンパイラによって推論されることで自動的に決定する。

ms2は、変数の型を明示的に宣言することで、右辺値の内容がそれに適合するのかをチェックして欲しいなと思って書いたコードだ。しかし、無情にもこのように怒られてしまう。

Type mismatch:
inferred type is (kotlin.Int, kotlin.String?) -> kotlin.String?
but aaa.JavaSAM was expected

そこまで分かってんなら型変換してくれよ……。

ms3は、ms1から引数の型宣言を外したものだ。だから、問題ない。

ms4は、ms2と同類の問題だが確かに型を明示していない以上、類推出来ずにエラーってのは納得出来なくもない。しかし、変数の型としては意図が書いてあるんだから念力で何とかしてほしいと思うのは甘えだろうか。

無名な Java の SAM タイプを引数にとる Kotlin の関数を使う

上記の続きだけども、変数に受けないところが主な違いになる。

fun anonymous_sam_conversions() {
// IntelliJ says me `Convert to Lambda`
callJavaSAM(object : JavaSAM { // (1)
override fun doIt(i: Int, s: String?): String? {
return "zzz$i$s"
}
})
callJavaSAM(JavaSAM { i: Int, s: String? -> "zzz$i" })// (2)
callJavaSAM(JavaSAM { i, s -> "zzz$i" }) // (3)

callJavaSAM { i: Int, s: String? -> "zzz$i" } // (4) Error
callJavaSAM { i, s -> "zzz$i" } // (5) Error
}

(1)のコードは、例によって一番冗長な形。IntelliJ はラムダ式にしろって煽ってくる。

(2)のコードは、SAM Conversion をアドホックに利用した形。引数の型は明示してある。

(3)のコードは、引数の型を明示しない形。ここまでなら省略してもよい。

(4)のコードは、SAM Conversion を使っていないラムダ式なのでエラーになる。引数の型と戻り値の型は明示しているのだから、よしなに変換して欲しいものだ。

(5)のコードもまた SAM Conversion を使っていないラムダ式なのでエラーになる。

Kotlin の interface に SAM Conversion は無い

機械的に Java の SAM interface を Kotlin に置き換えていくと、ここでハマる。というか、僕がハマった。

なんでやねん…?リファレンスに明記してある以上、この仕様は変わらないものと考えた方がいいんだろうか。

Note that SAM conversions only work for interfaces, not for abstract classes, even if those also have just a single abstract method.

Also note that this feature works only for Java interop; since Kotlin has proper function types, automatic conversion of functions into implementations of Kotlin interfaces is unnecessary and therefore unsupported.

SAM Conversions

冒頭の Java で定義した interface を Kotlin に持ってくるとこういう感じになる。

package aaa
interface KtSAM {
fun done(i: Int, s: String?): String?
}

Java と出来るだけ同じになるよう定義すると、null 許容型を連打する感じになる。

JavaSAM に生えている call メソッドだけは敢えて移植していない。

この interface を引数にとる関数を定義するとこうなる。

fun callKtSAM(sam: KtSAM) = println(sam.done(2, "b"))

これを使うコードを書いてみる。

fun kotlin_not_support_sam_conversions() {
val ms0 = object : KtSAM {
override fun done(i: Int, s: String?): String? = "zzz$i$s"
}
callKtSAM(ms0)
callKtSAM(object : KtSAM {
override fun done(i: Int, s: String?): String? = "zzz$i$s"
})

// error. SAM conversions works only for Java interop
val ms1 = KtSAM { i, s -> "zzz$i$s" }
callKtSAM(ms1)
callKtSAM(KtSAM { i, s -> "zzz$i$s" })
}

Kotlin で定義された interface には SAM Conversion は動作しないので、ms0やそれに続くやり方のように object 記法でアドホックにインスタンスを作るしかない。object 記法を使っているがラムダ式にしろと IntelliJ は煽ってこない。

ms1のような書き方が出来れば望ましいのだが、そういうわけにはいかない。しかし、実は抜け道もある。

fun kotlin_fake_sam_conversions() {
// but define adapter function.
fun KtSAM(fn: (i: Int, s: String?) -> String?) = object : KtSAM {
override fun done(i: Int, s: String?): String? = fn(i, s)
}

val ms2 = KtSAM({ i, s -> "zzz$i$s" })
callKtSAM(ms2)

val ms3 = KtSAM { i, s -> "zzz$i$s" }
callKtSAM(ms3)

callKtSAM(KtSAM { i, s -> "zzz$i$s" })
}

ここでは、interface と意図的に同じ名前の関数を定義したうえで、interface に定義されたメソッドと同じ型の引数の無名ラムダを引数としていている。このアダプタ関数では object 記法を使ってKtSAM型のインスタンスをアドホックに作った上で渡された引数に処理を委譲している。

つまり、ms2には、KtSAM関数を呼び出して、そこで生成されたKtSAM型のインスタンスを格納している。

Kotlin の関数呼び出しは、最後の引数にラムダ式を設定するなら、()を省略できる。これによって、ms3という変数へ宣言する代入文の右辺値は、見た目上 SAM Conversion とまったく同じになる。

これを一時変数に受けない形で記述すると、最後のようになる。

Kotlin では SAM タイプを作らない方がいいのかもしれない

ところで Kotlin では、関数の引数として関数の引数や戻り値を宣言できる。 文章で書くとややこしいがようはこうだ。

fun call(fn: (Int, String?) -> String?) = println(fn(3, "c"))

call 関数はIntString?を引数にとり、戻り値がString?である関数を引数にとる。

これなら、以下のようにラムダ式をガシガシ渡せるし型推論も効く。

fun function_types() {
val fn: (Int, String?) -> String? = { i, s -> "zzz$i$s" }
call(fn)

call { i: Int, s: String? -> "zzz$i$s" }

call { i, s -> "zzz$i$s" }
}

しかしもって、複数の引数をとる SAM っぽい関数に名前を付けないでやりとりすると、その設計について議論する際に面倒なので出来れば名前を付けたい。

まとめ

Kotlin のラムダ式は Java より強力なのかと思いきや、実はそうでもない部分もあるようだ。

これが単に仕様上の抜け漏れなのか、意図した通りなのかは、今のところよくわからない。

何にせよ SAM タイプっぽい interface を現状の Kotlin で定義するとコードは短くならないどころかややこしくなるだけなので避けた方がいいだろう。

現時点においては、Java から Kotlin へフレームワークなりライブラリなりを移行するなら契約となる interface だけは Java のままにしておいた方が Kotlin の機能を存分に使ってコードがかけるのかもしれない。

今回のコードはここに置いたので追試がしたい方はどうぞ。

このエントリを書く際に参考にした記事は、これです。