2021/07/27 このエントリーをはてなブックマークに追加 はてなブックマーク - KotlinのContractは気をつけて使ったほうが良い

KotlinのContractは気をつけて使ったほうが良い

カテゴリ: ,

  • KotlinのContractはスマートキャストなどのコードセマンティクスを追加したい箇所をコンパイラに知らせるための言語機能
  • Contractはメソッドシグネチャに現れないし、IDEでの検出もやりにくく適宜実装を見ないと使用箇所が分からない
  • 実装者がイディオム(暗黙ルール)を知ってるか知らないかの話になるので多用は避けた方が良い

だいぶ前の話なんですが、 measureNanoTimeのlambda内の変数をスコープ外で参照したいというユースケースの話があった。

  val time = measureNanoTime {  val hoge =  execute()  }
  Log.d("Performance",  "process time ${time}")  // hogeを渡せない。困った。
  call(hoge)

ブログ記事の方(しかじろうさん)は、解決として高階関数でwrapしていたんだけど

fun  measure():  (msg: String)  -> Unit {
  val start = System.nanoTime()
    return  {
     msg -> Log.d("Performance",  "%,13d $msg".format(System.nanoTime()  - start))
    }
}
  val stop =  measure()  // 計測したい処理
  val hoge =  execute()
  stop("process time")  // hogeを渡せる。嬉しい!
  call(hoge)

直感的に、もっと簡単に書ける気がするなぁと思って調べてみると、Kotlinの標準ライブラリが簡単に書けるようにしていた。


一旦は、こういう標準ライブラリ使えばこういうイディオムで書けるよーで解決ではあるので良いのだけど。
これは別にしかじろうさんが知らないのが良くないってワケでもないし、至って正しい解決方法をとっていたように思う。
そもそも、Contractってめっちゃ気づきにくくない?っという根本的な問題が自分としては気になり出した。


例えば、拡張関数と比較してみる。

拡張関数は、レシーバーに無秩序にメソッドが生やしてしまう可能性があるし、
下手したら神Utilみたいなものと変わらないものを生み出してしまう。
拡張関数よりはクラスやパッケージで適切にモデリングした方が良い。
どうしても持たせたい関数を閉じているはずのクラスに付与する奥の手が拡張関数ぐらいに捉えている。
そのため、あまり多用するのは避けた方が良いし、使うにしてもスコープを絞って定義するなりしたほうが良い代物というのが個人的な感覚。

そんな Kotlin界のimplicit convertionやないか、と言われている(?)拡張関数であっても、
IDEの定義ジャンプや関数定義の参照(プレビューなど)が出来る。

一方、Contractは実際の関数にジャンプしてContract DSLを読まないとどういうエフェクトがあるか分からない。
しかも、Contract DSL自体を理解していないとその実装の結果どういったエフェクトがあり、どういったイディオムを我々は書けるのかが分からない。


Contractってなに?っていうところに立ち返る。

ざっくり、コンパイラにスマートキャストするポイントを教えたり変数のスコープをlambda外に持ち出すためのヒントみたいなものと言ってしまって差し支えがない気がする。

Contractが発表されたとき、「あぁ、これはOSSライブラリの提供者とかKotlinの言語開発側が主に使うんだろうな」と思った。
普段の業務実装の中でほぼ使うシーンもないだろうと。

あれから2〜3年たった今、この予想は当たっていたと思う。
だけど、Contractがこんなにも分かりにくいものだったんだなという点は予想外で、
その分かりにくさに関して少し懸念を持っている。


じゃあどうやってContractが使われているか把握するのというと今のところ自分は以下。他に良い方法あれば誰か教えてください。

  • 関数定義にジャンプしてContractが使われていないか実装を見る
  • Kotlinのgitリポジトリーをcloneしてきて、grep

ということで、こんなの気づくタイミング無いよっと思ってしまう。
細かくバージョンアップを追ってる方である自分ですらこんななので、なかなかどうしてフレンドリーではない気がする。


例のmeasureNanoTimeのように、イディオムとして都度覚えるというのが現実的ではある。

    val hoge:Int
    val time = measureNanoTime { hoge = execute() }
    call(hoge)

イディオムとして覚えたい人向けに、テキトーに書いたものを載せておきますね。


・use

try-with-resourcesのKotlin版。たしかAutoCloseableでもCloseableでもつかえる。
callsInPlaceのcontractが実装されているので、外でlambdaスコープの値を参照できる


    val inputStream: InputStream = Thread.currentThread().contextClassLoader.getResourceAsStream("hogehoge")
    val b: Int
    InputStreamReader(inputStream).use {
        b = it.read()
    }
    println(b)

・synchronized

callsInPlaceのcontractが実装されているので、synchronizedブロックした際、スコープ内での結果をlambdaの外で取得できる。

    val mutex = 1
    val lockResult: String
    synchronized(mutex) {
        lockResult = "result"
    }
    println(lockResult)

・run

callsInPlaceのcontractが実装されているので、runスコープ内での結果をlambdaの外で取得できる。

    val hoge:String
    "a".run{
        hoge = this.uppercase()
    }
    println(hoge)

・with

callsInPlaceのcontractが実装されているので、withスコープ内での結果をlambdaの外で取得できる。

    val fuga:String
    with("b"){
        fuga = this.uppercase()
    }
    println(fuga)

・apply

callsInPlaceのcontractが実装されているので、applyスコープ内での結果をlambdaの外で取得できる。

    val piyo:String
    "c".apply{
        piyo = this.uppercase()
    }
    println(piyo)

・also

callsInPlaceのcontractが実装されているので、alsoスコープ内での結果をlambdaの外で取得できる。

    val foo:String
    "d".also{
        foo = it.uppercase()
    }
    println(foo)

・let

callsInPlaceのcontractが実装されているので、letスコープ内での結果をlambdaの外で取得できる。

    val bar:String
    "e ".let{
        bar = it + "bee"
    }
    println(bar)

・takeIf

callsInPlaceのcontractが実装されているので、takeIfスコープ内での結果をlambdaの外で取得できる。

    val baz:String
    "f".takeIf {
        baz = it.uppercase()
        true
    }

・takeUnless

callsInPlaceのcontractが実装されているので、takeUnlessスコープ内での結果をlambdaの外で取得できる。

    println(baz)
    val bazz:String
    "g".takeUnless {
        bazz = it.uppercase()
        true
    }
    println(bazz)

・requireNotNull

callInPlaceのcontractが実装されているので、requireNotNullスコープ内での結果をlambdaの外で取得できる。

    val nullableValue :String?= "a"
    val notnullValue :String= requireNotNull(nullableValue)
    println(notnullValue)

    val n : Number = 1L  
    require(n is Long)  
    val l :Long = n

・Precondition系

Preconditions.ktのやつは大体contractが実装されている

  • require
  • requireNotNull
  • check
  • checkNotNull


例えば、require。contractのimpliesが実装されているので、スマートキャストが出来る

   val n : Number = 1L
   require(n is Long)
   val l :Long = n


全部網羅できているわけじゃないけど、だいたいこんな感じです。


どうしても、イディオムとして覚えるにも実装を調べるにも限界がある。
Contractを追うのにも限界があるので、Contractに頼らない実装をする方が良い気がする。
どうしても冗長でボイラープレートが気になる箇所の場合、うまくスマートキャストやContractで実装された関数が利用できないか検討する、という順番が良いように思う。
Contractな関数を作るというのは奥の手の奥の手ぐらいじゃないかという気がする(業務コード書く場合は。ライブラリ実装者とかなら別かもしれない)。
あと、Contractの実装者側(関数作ったほう)が宣伝をセットでしないと気づかれにくいという点も注意。





0 件のコメント:

コメントを投稿

GA