関数

関数合成 の Julia

1個の演習

関数合成について

Juliaでは、できるだけ多くのコードをJITコンパイルできる関数の中に書くことが勧められています。そして、小さな関数をたくさん作ることは、設計上、性能がよくなるようにできています。

その結果、小さくて単純な関数がたくさん残ることになります。それらを組み合わせて、込み入った処理をこなす必要が出てきます。

ひとつのわかりやすい方法は、関数呼び出しを入れ子にすることです。 次の例はかなり無理やりですが、要点は伝わるでしょう。

julia> first.(titlecase.(reverse.(["my", "test", "strings"])))
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

この方法の欠点は、入れ子が深くなるほど読みやすさが急速に落ちることです。

もっと単純で柔軟な方法が必要です。

合成

これは数学者たちが好んで使う手法で、Juliaはその数学的な構文を取り入れています。

任意の数の関数を、∘演算子(\circと入力してからタブキー)でcomposedできます。その結果は、ひとつの関数として使えます。

julia> compfunc = first ∘ titlecase ∘ reverse
first ∘ titlecase ∘ reverse

julia> compfunc.(["my", "test", "strings"])
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

# alternative syntax, giving the same result
julia> (first ∘ titlecase ∘ reverse).(["my", "test", "strings"])

いくつか注意すべき点があります。

  • 並べる関数の順序は入れ子にしたときと同じで、実行されるのは右から左の順です。
  • 合成するときにブロードキャストを使うのは簡単ではありませんが、合成した関数を呼び出すときに適用できます。

パイプライン

もうひとつの方法は、_数学者_のやり方ではなく、_プログラマー_のやり方と考えることができるでしょう。

Pipelinesは、Unixのシェルスクリプトで昔から使われており、近年では一般的なプログラミング言語でも広く使われるようになりました(普及の先駆けとなったのはF#だとよく言われます)。

基本的な考え方は、まず何らかのデータがあり、それを一連の関数に通していって結果を得る、というものです。

パイプ演算子は|>です(F#や最近のバージョンのRと同じです)。ただしJuliaにはブロードキャスト版の.|>もあります。

julia> ["my", "test", "strings"] .|> reverse .|> titlecase .|> first
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

実行の順序は完全に左から右になり、それぞれの関数の出力が矢印の向きに流れて、次の関数の入力になります。

制約、回避策、そのほかの選択肢

合成とパイプラインの説明に使った関数が、どれも_ひとつ_の入力しか受け取らないのは、偶然ではありません。

純粋関数型の言語の中には、_最初の_入力を関数に流し込みつつ、それ以外の入力も一緒に渡せるものがあります。

それに対してJuliaのパイプラインは、関数の_名前_(またはそれに相当するもの)だけを想定していて、追加の入力は受け付けません。

これには重要な技術的な理由があります(curryingがこの言語の設計に標準的に組み込まれていないことと関係しています)。 カリー化を知らない_多くの_人は、この制約がうっかりした見落としではなく、今後のJuliaのバージョンでも変わる見込みがほとんどないことだけ、受け入れておけばよいでしょう。

回避策

必要な処理を行う、入力がひとつの関数が必要です。幸い、Juliaで新しい関数を定義するのは簡単です。

もっとも簡単なのは、anonymous functionを使う方法です。 たとえば、入力の文字列がひとつあって、それをアンダースコアで分割したいとします。

julia> "my_test_strings" |> (s -> split(s, '_'))
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

そのベクトルは、これまでと同じように、ほかの関数へパイプで渡せます。

この場合、無名関数を括弧で囲むのは省略できますが、一般には曖昧さを減らすのに役立つ方法です。

同様に、プログラムの前のほうで名前付きの関数を作っておき、必要に応じて再利用するという手もあります。

Closuresはこのコンセプトの範囲を超えていますが、ほかの言語で親しんだことがある人なら、入力がひとつの関数をより柔軟に作れる仕組みだとすぐにわかるでしょう。

julia> function makesplit(sep)
           fs(str) = split(str, sep)
           fs
       end
makesplit (generic function with 1 method)

julia> f_us = makesplit('_')
(::var"#fs#32"{Char}) (generic function with 1 method)

julia> "my_test_strings" |> f_us
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

# alternatively:
julia> "my_test_strings" |> makesplit('_')
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

一度makesplit()を定義すれば、どんな区切り文字でも扱えるようになります。 makesplit('_')は、別の関数へと評価される_関数呼び出し_であり、その関数がパイプから入力を受け取ることに注意してください。

難しく感じても、最初はふつうのことです(練習すればはっきりわかるようになります)。

そのほかの選択肢

ベースのJuliaでパイプをもっと柔軟にするための議論は長く続いていますが、さまざまな提案は互いに両立せず、合意には至っていません。

その間、ユーザーはいつもどおりのやり方で、特定のニーズに応えるさまざまなインストール可能なパッケージを作ってきました。どれもExercismの中では動きませんが、興味があれば見てみてください。

GitHubで編集 リンクは新しいウィンドウまたはタブで開きます

関数合成を学習する