FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

《Scala》Future[Unit]は気をつけて使おうの話

※ 2017/2/16 24:00 サンプルコードをより意図が分かりやすいと思われるコードに変更しました。 元の文章は後ろの方に置いてあります。

こんにちは、杉谷と申します。

ちょと前までエグゼクティブエンジニアというよく分からない肩書きだったのですが、よく分からなくて不便なのでCTOと名乗ることになりました。 やっていることは変わらず、コミックスマート株式会社*1GANMA!を開発しつつ、セプテーニ・オリジナル株式会社*2こんなことこんなことをやっています。引き続きよろしくお願いいたします。

Future[Unit] の罠

みなさま副作用はあるけど非同期なメソッドってdef hoge:Future[Unit]と書きたくなりませんか?

結構書きたくなる場面があるのですがFuture[Unit]には うっかり.mapで繋げてしまうと意図しない順番で動作する、という罠がありました。

object TrapExample  {

  def funcA(): Future[Unit] = Future {
    println("a")
  }

  def funcB(): Future[Unit] = Future {
    Thread.sleep(100)
    println("b")
  }

  def funcC(): Future[Unit] = Future {
    println("c")
  }

  def A_B_Cのつもりで書いた間違っているのにビルドが通る例(): Future[Unit] = {
    funcA()
      .map { _ =>funcB()}
      .map { _ =>funcC()}
    // ※たとえでこう書いていますが、本来はfor文とかを使いましょう
    // ※返値の型は本当はFuture[Future[Unit]]です。
  }

  def A_B_Cにするつもりなら動く例(): Future[Unit] = {
    funcA()
      .flatMap { _ =>funcB()}
      .flatMap { _ =>funcC()}
  }
}

A_B_Cのつもりで書いた間違っているのにビルドが通る例()を実行すると

a
c
b

になります。

これはUnit型の注釈がついた値や式において、本来返されるはずのUnit型ではない値は返さずに捨てられる、という特別ルールにより発生する仕様です。

逆に、これはUnit以外にすればコンパイルは通らないので、誤用に対して多少安全になります。

object SafeExample {

  case class Hoge()

  def funcA(): Future[Hoge] = Future {
    println("a")
    Hoge()
  }

  def funcB(): Future[Hoge] = Future {
    Thread.sleep(100)
    println("b")
    Hoge()
  }

  def funcC(): Future[Hoge] = Future {
    println("c")
    Hoge()
  }

  /* 
  def もうビルドはとおらない(): Future[Hoge] = {
    funcA()
      .map { _ =>funcB()}
      .map { _ =>funcC()}
  }
  */

}

実際にはまったコードは以下のような雰囲気でした

// 消してから何かを再計算、のつもりが再計算してから消すになっている、的なことを伝えたいコード
def delete(hogeId: HogeId):Future[Unit] = {
    hogeAsyncRepository
      .getBy(hogeId)
     .map{
          case Some(hoge) =>  hogeAsyncRepository.delete(hogeId)
          case _ => Future.successful(Unit)
      }
     .map {
          hogeScoreService.recalc()
     }
}

回避策

“空"を表す型を作る

型があれば大丈夫なのであれば、空を表す新しい型を定義してしまえばよい、というのが最初に思いつきました。

case class VoidResult()

object SafeExample {

  def funcA(): Future[VoidResult] = Future {
    println("a")
    VoidResult()
  }

  def funcB(): Future[VoidResult] = Future {
    Thread.sleep(100)
    println("b")
    VoidResult()
  }

  def funcC(): Future[VoidResult] = Future {
    println("c")
    VoidResult()
  }
}

この方式は

  • いろんなところで似たような実装が発生しそうで危なそう
  • あちらこちらで明示的に [return] VoidReult() と書かなくてはいけない箇所がでて格好悪い

という欠点があるので、ないよねーとなりました。

コンパイラオプション

コンパイルオプション -Ywarn-value-discardを付けるとコンパイラが警告をしてくれるようになります。

$ scalac FutureUnit.scala  -Ywarn-value-discard

〜

FutureUnit.scala:16: warning: discarded non-Unit value
      funcB().map { b =>
                  ^
four warnings found

ただし副作用として

def hoge : Unit = {
   getString() // : String
}

なコードも

def hoge : Unit = {
   getString() // : String
   ()
}

こう書かないとワーニングがでてしまうようになります。

最初からこのオプションを有効にしていれば有りかも、な対策です。

Future[Unit]が駄目なわけではない

弊社技術顧問のお一人である麻植さん曰く

taisukeoe [1:25 AM]
ちょっと乗り遅れ気味ですが、興味深い議論なので。 > Future[Unit]

ざくっと調査した感じ、Future[Unit] 自体は割と広くScala界(ってなにかはさておき)では受け入れられてそうです。

http://stackoverflow.com/questions/19097458/converting-a-side-effect-only-function-to-async-using-futures-which-is-the-retu

回答者のTravis Brownはcirceの作者だったり、Scalaz Contributorだったけど今は猫派の急先鋒だったりするStackOverFlow Godです。

https://github.com/travisbrown?tab=repositories

上のStackOverFlowの回答内でもありますが、TwitterのライブラリにもFuture[Unit]ありますね

https://github.com/twitter/util/blob/master/util-core/src/main/scala/com/twitter/util/Closable.scala

Future[Unit]は仰るとおりの危険性はありますが、それを言うとM[Unit] m:Monadな型は同じ問題があるといえばあるので、局所的に大きな問題の発生するケースでのみVoidResultみたいな特殊な型でというのも選択肢にはありえそうですが、大域的には @*** さんの仰る通りコンパイラオプションやlintツールでカバーするのが現実的な気がしております。

しかし、Monad合成のbind operator ⁠⁠⁠⁠>>=⁠⁠⁠⁠ が欲しくなりますね… :)

とのこと。

結局の所、この件はテストをちゃんと書けば検出できるので GANMA!ではコンパイラオプションもVoidResultも使わず、気をつけながらそのまま使う、となりました。

終わりに

如何でしたでしょうか? できれば普通にandThenを使っていきたいところですね!

セプテーニ・オリジナルでは、いつでもScalaやってみたい方を募集しております。

我々はScalaを採用し、ドメイン駆動設計(DDD)で開発をしている企業です。 最近入社される方は、次のような方が多いです。

  • Scala未経験だが、Scalaには興味があり、興味がある理由も分かっているが、現状の会社では経験できない
  • テストや設計などをちゃんとやりたいし、やる方法論も知っているが、現状の会社では重視されない

セプテーニ・オリジナルではScalaやDDD未経験でも問題ありません。 入社された方のために以下のカリキュラムを通してScala・DDDのトレーニングを積むことが出来ます。

  1. Scalaに慣れるため、TDD研修用の教材をScalaでやってみる
  2. Play Frameworkに慣れるため、簡単な掲示板を作成してみる
  3. DDDに慣れるため、掲示板をDDDで作り直してみる

レビュアーに選出された5名がつくので、適宜アドバイスを受けながら進められます。

最近は他社様とのコラボ企画が多かったので、間が空いていますが 勉強会イベント http://septeni-scala.connpass.com/もくもく会 https://sep-ori-mokumoku-day.connpass.com/ も不定期に行っています。 ご興味をもたれた場合、connpassにご登録の上、上記グループにご参加していただくとイベント予定の通知が届き便利です。

もし我々に興味をもっていただけたのであれば http://septeni-original.co.jp よりエントリーいただだけると幸いです。

社内見学イベント http://tour.septeni.net/ も毎月実施しています。 まずは様子を見たい、というときはこちらもご検討ください。

以上です。読んで戴きましてありがとうございました!

編集の記録 - 2/16 24:00変更前の冒頭

以下の文章は書き換え前の冒頭の文章です。 実行順の事は気にせず、コンパイルが通ってしまうよ、としか言っていないコードだったので変更しました。

object TrapExample {

  def funcA(): Future[Unit] = Future.successful(())

  def funcB(): Future[Unit] = Future.successful(())

  def funcC(): Future[Unit] = Future.successful(())


  def A_B_CのつもりならflatMapを使うべきなのにビルドが通る例(): Future[Unit] = {
    funcA().map { a =>
      funcB().map { b =>
        funcC()
      }
    }
  }

  // ※この場合、本来はfor文とかandThenを使うのが正解

}

これはUnitでなければコンパイルが通らないので安全です

object SafeExample {

  case class Hoge()

  def funcA(): Future[Hoge] = Future.successful(Hoge())

  def funcB(): Future[Hoge] = Future.successful(Hoge())

  def funcC(): Future[Hoge] = Future.successful(Hoge())

  /*
  def コンパイルエラー(): Future[Hoge] = {
    funcA.map { a =>
      funcB.map { b =>
        funcC
      }
    }
  }
  */

  def 通る(): Future[Hoge] = {
    funcA().flatMap { a =>
      funcB().flatMap { b =>
        funcC()
      }
    }

   
  // ※本来はfor文とかandThenを使おう!!
}

*1:セプテーニ・マンガ事業部、みたいなもの。セプテーニはホールディングス制なので、だいたい1事業1会社

*2:セプテーニ・開発部、みたいなもの