Scala 2.13から tap が標準ライブラリに追加されています。tapはメソッド・関数適用の「中間結果を覗き見る」とでも言うべき挙動を実現します。
import scala.util.chaining._
// printされるのは List(206) だが式全体の評価結果は List(412)
Seq(103).map(_ * 2).tap(println).map(_ * 2)
輸入元は……Rubyなんでしょうか?
tapの実装方法ですが、RubyではObjectクラスのメソッドにする、つまりクラス階層の最上位にtapメソッドを足す方法が選択されています。
一方Scalaにおいては、何にでもtapを生やすimplicit conversionを提供する形になっています。文章の説明を読むより実装を見るほうがわかりやすいです。
import scala.language.implicitConversions
trait ChainingSyntax {
@`inline` implicit final def scalaUtilChainingOps[A](a: A): ChainingOps[A] = new ChainingOps(a)
}
final class ChainingOps[A](private val self: A) extends AnyVal {
def tap[U](f: A => U): A = {
f(self)
self
}
}
便利なtapはたまに冗長
tapは便利なのですが、実利用でちょっとだけかゆい所に手が届かないことがありました。
Try型を扱っているときに、Success(Failure)の時はロギングをしたい、というシチュエーションです。特にFailureになった時は何かが異常だったはずなので、エラーログを吐きたくなる事があると思います。
Try(103).tap(x => if (x.isSuccess) println(x.get)) // => 103
Try(103/0).tap(x => if (x.isFailure) println(x.failed.get)) // => java.lang.ArithmeticException: / by zero
見ての通り書けはするのですが、if式が入ってくるなど若干冗長です。
さらに、中の値が直接欲しいからと抽出子を使おうとすると、もう一回り冗長な事態になってしまいます。matchの関係上、Successの時にだけprintlnしたいのだとしても、Failureの時は何もしないことを明示する必要があるのです。
scala> Try(103).tap { case Success(v) => println(v) }
^
warning: match may not be exhaustive.
It would fail on the following input: Failure(_)
// 結局こう書くことになる
Try(103).tap { case Success(v) => println(v); case Failure(_) => }
改善: ちょっとだけ特化したtap
汎用的なtapだけだと引数がSuccess/Failureどちらかわからないので面倒なことになっていそうです。そこでTry型に特化したtapを作り、メソッドレベルでtapにSuccess/Failure判定を埋め込むようにしてみました。
object TryHelper {
implicit class TryTap[A](underlying: Try[A]) {
def tapOnSuccess[B](f: A => B): Try[A] = {
if (underlying.isSuccess) f(underlying.get)
underlying
}
def tapOnFailure[B](f: Throwable => B): Try[A] = {
if (underlying.isFailure) f(underlying.failed.get)
underlying
}
}
}
このようなimplicit conversionを定義しておくと、先の例はだいぶ簡略化することができます。
Try(103).tapOnSuccess(v => println(v))
// あるいは
Try(103).tapOnSuccess(println)
Successの場合とFailureの場合、両方をまとめて書くこともできます。
Try(103)
.tapOnSuccess(println) // こちら側のprintlnのみ評価される
.tapOnFailure(println)
Try(103/0)
.tapOnSuccess(println)
.tapOnFailure(println) // こちら側のprintlnのみ評価される
これは「良い」アプローチなのか?
要するに見当外れのことをしていないか、本当に筋が良いのかは気になりますが……よくわかりませんでした。本来なんらかのもっと上手い書き方があるんじゃないかと言われると、そんな気もします。
軽く調べる限りだと、Try(あるいはEitherなどでも同様の実装を考えることはできると思いますが)に関して、同様のことをやっている実装例は見つかりませんでした。
(2021/08/31追記) ptapという便利な変種
記事を公開したところgakuzzzzさんから以下のようなコメントをいただきました。興味深かったので紹介します。
def tap[U](f: A => U): A
のかわりに、以下のような ptap を定義します。
implicit class PTap[A](underlying: A) {
def ptap[U](pf: PartialFunction[A, U]): A = {
pf.lift(underlying)
underlying
}
}
tapとの違いは、引数にPartialFunctionを取るようになった点です。
// tapの時はちゃんと動かなかった
Try(103).ptap { case Success(v) => println(v) } // => 103
ptapは、tapと同様に具体的な型を固定していないので、さまざまな型に対してジェネリックに動作します。その点について前節のTryTap
によるimplicit conversionより優れているように見えます。
Option(103).ptap { case Some(v) => println(v) } // => 103
Option(103).ptap { case None => println("none") } // => 何も出力されない
Seq(1, 2, 3).ptap { case Seq(a, b, c) => println(a, b, c) } // => (1,2,3)