KotlinにおけるSAMタイプの話

手元にある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の機能を存分に使ってコードがかけるのかもしれない。

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

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