Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scalaのpipeでパイプライン演算子を作って遊ぶ

Scalaには2.13からpipeという面白いメソッドが追加されている。a pipe fと書くとf(a)になるという、それだけの仕組みだ。Elixirなどの他の言語では|>といった記号で導入されている。

blog.knoldus.com

記号の氾濫を避けるために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

うまいことパイプライン演算子を使ってオプショナルな値を取出せるようになった。パイプライン演算子最高!

これなんか見たことあるな?

残念!モナドでした

これ実はmapflatMapなんだよね。騙して悪いがScalaなんでな・・・

Some(42) map double // => Some(84)
Some("666") flatMap parseInt // => Some(666)

だったら元々の|演算子もモナドとなんらかの関係があるのではないか?

Pipelineモナド

実は、モナド則を守りつつ、|と全く同じように振る舞う(つまり特別な処理を何もせず素通りさせる)モナドを作ることができる。

仮にこれをPipelineモナドと呼ぶとして、これがモナドであるために必要なflatMappureを実装してみよう。

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))

実質的にmapflatMapは型を合わせているだけで中身は同じものだ。

実際に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にも収録されている:

typelevel.org

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:著作権的なアレで画像貼れないんだよね。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?