🚰

Julia パイプ演算の優先度にご用心

2021/09/16に公開
2

みなさんJuliaしてますか?
Juliaは簡単に言うと超プログラマブル電卓です。よくある計算などは他の言語より10倍くらい簡単に書ける(当社比)ので是非使いましょう

ところでパイプ演算|>ってありますよね?
私はあの書き方が気に入っていてよく使うんですが、引数を2つ以上取る関数を扱う場合はクロージャーを噛ませる必要があります
その際、思う通りに動かず困っていたのですが、今回その原因がわかったので共有したいと思います

パイプ演算なんて知らんけどって人へ

Juliaには パイプ演算 (pipe, piping) というものがあります
Linuxのパイプラインを想像していただければわかりやすいですが、それと同じようなことを関数で行えます

例えば以下のような処理を考えます

inv(parse(Int, "57") ÷ 19)

この処理や以下のようなステップから構成されます

  1. 文字列をIntへ変換する
  2. 変換した数字を19で割る
  3. 割った数字の逆数をとる

関数呼び出しや数式が入り混じっていて少し読みづらいと思いませんか?
また、関数の特性上 仕方のないことではありますが、人間が実際に考えている動作手順と関数を書く順番が逆になってしまっています

そこで活躍するのがパイプ演算です
パイプ演算を使うと、先ほどの処理を以下のように書き換えることができます

parse(Int, "57") |> x->x÷19 |> inv

どうですか?見やすくなったと思いませんか?
今回はそんなパイプ演算に関するお話です

また、本記事ではブロードキャスト演算についても同時に扱います
簡単にいうと、配列の各要素に対する計算を簡単に構文です
単純なmap演算を超簡単に、読みやすく書けます
気になった方は是非調べてみてください

TL;DL

クロージャーを括弧で囲め!

これを

"271828182846" |> x->split(x,"") .|> x->parse(Int,x) |> sort


こうする

"271828182846" |> (x->split(x,"")) .|> (x->parse(Int,x)) |> sort

背景

例えば、数字の文字列を1文字ずつのIntに分割してソートしたいみたいな場合、直感的に書くと大体以下のようなコードになると思います

"271828182846" |> x->split(x,"") .|> x->parse(Int,x) |> sort
# ERROR: MethodError: no method matching sort(::Int64)

しかしこれをそのまま実行すると怒られてしまいます
よく分からないので、1度パイプ演算するごとに括弧で囲んだりしてたんですが、これではせっかく見やすいパイプ演算が見づらくなってしまいます

(("271828182846" |> x->split(x,"")) .|> x->parse(Int,x)) |> sort

私は.|>が変な挙動をしているのかなとぼんやり考えていたんですが、これは単純に演算子の優先度による影響でした

原因究明編

なぜ背景のようなエラーが発生してまうのか、それはパイプ演算がクロージャーよりも優先度が高いことが原因でした
式のシンボルをdump関数に渡すとわかりますが、先ほどのコードは以下のように解釈されます

"271828182846" |> (
	x -> (
		split(x,"") .|> (
			x -> (
				parse(Int,x) |> sort
			)
		)
	)
)

Intにパースした後の単体の数字をソートしてしまっているんですね
そりゃエラー出ますわ

ここで先ほどのエラーを見返してみると、ERROR: MethodError: no method matching sort(::Int64)とありますので確かにそうだとわかります
今ならコンパイラーくんの怒る気持ちもわかる

実際、生の式と括弧で括った式をシンボル化して比較すると、真に一致していることがわかります

:("271828182846" |> x->split(x,"") .|> x->parse(Int,x) |> sort) == :("271828182846" |> ( x -> (split(x,"") .|> (x -> (parse(Int,x) |> sort)))))
# true

解決編

見やすさを保持したまま思い通りにする方法は無いんでしょうか?
落ち込まないでください。クロージャーの優先度が低くなってしまうのであれば、無理矢理優先度を上げてしまえばいいんです
つまり、クロージャーを括弧で囲ってしまえば良いのです

"271828182846" |> (x->split(x,"")) .|> (x->parse(Int,x)) |> sort

こうすることで、コンパイラーは以下のように解釈してくれます

(
	(
		"271828182846" |> (x->split(x,"")) 
	) .|> (x->parse(Int,x))
) |> sort

「文字列を分割し、それぞれIntへパースし、ソートする」という思っていた動作が達成できてそうですね
実際に実行してみてもソート済みの配列を得ることができます

責任追求編

しかしまたなんでこんなエラーが多発してしまうんでしょうか
原因はどうやら公式ドキュメントにありそうです

JuliaのREPLで?|>と入力して使用方法を確認すると、以下のような文章が出てきます

|>(x, f)

Applies a function to the preceding argument. This allows for easy function chaining.

Examples
≡≡≡≡≡≡≡≡≡≡

julia> [1:5;] |> x->x.^2 |> sum |> inv
0.01818181818181818

問題なのは1番下のサンプルコードです
これを読むほとんどの人は次のような解釈をすると思います

[1:5;] |> (x->x.^2) |> sum |> inv

「ふむふむ、配列を『各要素を2乗する関数』に渡して、配列の総和を取り、逆数を取るんだな」と
違います。ここまで読んでくださった方ならお察しの通り、このサンプルコードは厳密には以下のように動作します

[1:5;] |> x -> (x.^2 |> sum |> inv)

「配列を『各要素を2乗して総和をとって逆数をとる関数』に渡す」ということです
この場合は問題なく同じ計算結果が得られますが、多くの人が想定していた動作ではないはずです

では先ほどの問題のコードとこのサンプルコードは何が違うんでしょうか
この問題をややこしく、わかりづらくしてしまった原因は、ブロードキャストパイプ演算.|>[1]にあります
例えば先ほどのサンプルコードを次のように書き換えると、やはり思った通りに動いてくれません

[1:5;] .|> x -> x^2 |> sum |> inv
# 5-element Vector{Float64}:
#  1.0
#  0.25
#  0.1111111111111111
#  0.0625
#  0.04

普通のパイプ演算だけの場合、全ての関数に配列が渡されていたため全く同じ動作になっていましたが、ブロードキャスト版を使う場合は"各要素が渡される関数"と"配列全体が渡される関数"が入り混じっていたため思っていた動作にならなかったようです
今までの予想は一応当たっていたのか!

今後について

この問題は割とよくあるらしく、公式の議論サイトでも話題に上がっていたようです
https://discourse.julialang.org/t/changing-precedence-of-the-piping-operator/40730
GitHubの方でもissueに上がってました
https://github.com/JuliaLang/julia/issues/38761
話を見る感じだと、演算子の優先度を変えるわけにはいかなさそうですね
ですがドキュメントが変更されるわけでもなさそう?

また、別のアプローチですが、関数のカリー化を簡単にする機能の提案もされているようです
この機能が追加されればこういった問題が日の目を見ることもほぼなくなるでしょう
カリー化とかよく分からん!って人もいるでしょうが、要はx->f(x,y)f(_,y)と書けるようになるということです。嬉しいですね
今後に期待しましょう
https://github.com/JuliaLang/julia/pull/24990

脚注
  1. こう呼ぶのかはわかりませんが、明確に何と呼んでいるドキュメントが見つかりませんでした。何か情報があれば教えてください ↩︎

Discussion

tenfu2teatenfu2tea

この場合は、2引数関数に対するcurry化関数 Base.Fix1, Base.Fix2 が使えますね。

(x-> ...) のような書き方が、なんとなくつらい場合に使ったりします。

例えば、冒頭のは、こんな感じです。

"271828182846" |> Base.Fix2(split,"") .|> Base.Fix1(parse,Int) |> sort
SGThr7SGThr7

補足ありがとうございます。おっしゃる通りです
ただ個人的には、(x-> ...)の方が普段の構文に近い形でカリー化できるので好きです