Scalaには2.13からpipe
という面白いメソッドが追加されている。a pipe f
と書くとf(a)
になるという、それだけの仕組みだ。Elixirなどの他の言語では|>
といった記号で導入されている。
記号の氾濫を避けるためにScalaは公式には単体の演算子を提供するつもりはない*1ようだが、Scala 3から使えるextension
記法でパイプライン演算子を定義して遊んでみよう。
まずはパイプラインでくっつけたい関数を定義しておこう:
val plus1 = (n: Int) => n + 1 val double = (n: Int) => n * 2 val square = (n: Int) => n * n
パイプライン演算子 |
の導入
Scala 3でパイプライン演算子を定義するには以下のように書く:
import scala.util.chaining._ import scala.language.implicitConversions extension [A,B](a: A) def |(f: (A) => B): B = a.pipe(f)
すると、次のように関数をチェインできるようになる:
1 | plus1 // => 2 1 | plus1 | double // => 4 1 | plus1 | double | square // => 16
これはある意味メソッドチェインと等価だ。もし次のようにplus1
などが拡張メソッドで定義されていたら、メソッドチェインで書けたはずだ:
extension (n: Int) def plus1 = n + 1 1.plus1 // => 2
だがパイプライン演算子はより自由だ。チェインさせたい処理がそのオブジェクトのメソッドであるかどうかに縛られないからだ。型さえ合致させれば、任意の処理に引数を渡すことができる。
そう、型さえ合致させれば・・・
2引数関数をチェインさせる
察しの良い読者はお気付きかもしれないが、カリー化した多引数関数をパイプライン演算子はうまく扱えない。
val mul3 = (n: Int) => (m: Int) => (l: Int) => n * m * l 2 | 3 | 4 | mul3 // does not work!!!
これは|
演算子が左結合であることによる。そこで、「:
で終わるメソッドは右結合になる」というScalaの仕様を使って|:
演算子を定義しよう:
extension [A,B](a: A) def |:(f: (A) => B): B = a.pipe(f)
名前が変化しただけで中身は全く|
と同じだが、右結合になるためちゃんと動くようになる:
2 |: 3 |: 4 |: mul3 // => 24
・・・が、これは普通の|
と食い合わせが悪い:
2 |: 3 |: 4 |: mul3 | plus1 -- [E041] Syntax Error: -------------------------------------------------------- 1 |2 |: 3 |: 4 |: mul3 | plus1 | ^ ||: (which is right-associative) and | (which is left-associative) have same precedence and may not be mixed | | longer explanation available when compiling with `-explain`
カッコでくくることで|:
で表記を統一できる:
(2 |: 3 |: 4 |: mul3) |: plus1 // => 25
とはいえ普通はパイプライン処理を行うときは第一引数にしか興味が無いだろうから、|:
の出番はあまりないと思う。
Optionalな処理
次のような、パイプラインに渡ってきた値がNoneならすぐNoneを返すようなパイプラインを作れないだろうか。
Some(42) |? plus1 // => Some(43) None |? plus1 => None
これはmatch
式を使えば構成できる:
extension [A,B](a: Option[A]) def |?(f: (A) => B): Option[B] = a match case Some(x) => Some(f(x)) case None => None
Some(42) |? double // => Some(84) None |? double // => None
Optionalな処理その2
f
自体もOption
型の値を返す場合はどうだろう:
val parseInt = (s: String) => try { Some(s.toInt) } catch { case e: NumberFormatException => None }
これもmatch
式があれば構成できそうだ:
extension [A,B](a: Option[A]) def |??(f: (A) => Option[B]): Option[B] = a match case Some(x) => f(x) case None => None
Some("42") |?? parseInt Some("ababa") |?? parseInt None |?? parseInt
うまいことパイプライン演算子を使ってオプショナルな値を取出せるようになった。パイプライン演算子最高!
これなんか見たことあるな?
残念!モナドでした
これ実はmap
とflatMap
なんだよね。騙して悪いがScalaなんでな・・・
Some(42) map double // => Some(84) Some("666") flatMap parseInt // => Some(666)
だったら元々の|
演算子もモナドとなんらかの関係があるのではないか?
Pipeline
モナド
実は、モナド則を守りつつ、|
と全く同じように振る舞う(つまり特別な処理を何もせず素通りさせる)モナドを作ることができる。
仮にこれをPipeline
モナドと呼ぶとして、これがモナドであるために必要なflatMap
とpure
を実装してみよう。
type Pipeline[A] = A def flatMap[A, B](a: Pipeline[A])(f: A => Pipeline[B]): Pipeline[B] = f(a) def pure[A](a: A): Pipeline[A] = a def map[A, B](a: Pipeline[A])(f: A => B): Pipeline[B] = pure(f(a))
実質的にmap
とflatMap
は型を合わせているだけで中身は同じものだ。
実際にPipeline
モナド(モナド則を満たすことを確認する必要があるけれどこれは自明なので省略する)をplus1
に当て嵌めてみよう:
map(pure(42))(plus1) // => 43
ちゃんとplus1
が呼び出されている。
これを拡張メソッド風に実装してやる(割愛)と、見た目が全くパイプライン演算子と同じになる。
pure(42) map plus1 // => 43 // Pipeline[A] == Aなので・・・ 42 map plus1 // => 43
Id
モナド
実は、Pipeline
モナドとこれまで呼んでいたものは Id
モナド と呼ばれる特殊なモナドだ。Id
モナドはCatsにも収録されている:
Id
モナドは、モナドであるというだけで、他に何の作用も及ぼさない。
主な用途は、モナドが要求されるような場所で何の作用も欲しくないような場合の型合わせだ。例えばfor-comprehensionで値を組み立てるときはモナドである必要があるが、Id
を使うことでその制約をとりあえず骨抜きにできる:
import cats.implicits.* import cats._ val x: Id[Int] = Id(42) val y: Id[Int] = Id(123) for { xx <- x yy <- y } yield xx + yy // => 165
モナドの説明として「何らかの作用をともなった値」のような説明がなされることが多いが、「何らかの作用」があるということは「何もしないという作用をともなった値」というモナドを構成することもできるはずで、Id
モナドがまさにそれなのだ。
パイプライン演算子は実はmap
だったんだよ!!
(な・・・なんだってーーーー!!!)*2
*1:https://github.com/scala/scala/pull/7007
*2:著作権的なアレで画像貼れないんだよね。