関数型プログラミングとは何でしょうか?
これは大きな問いで、長い答えが必要です(Wikipediaが1つの答えを、Advanced Rがもう1つの答えを与えてくれます)。
ここでは、重要な特徴をいくつか挙げます。
ただし、Rは純粋な関数型言語ではありません(たとえばHaskellとは対照的です)。
Rは実用的な言語だと捉えてください。関数型の機能は、それを使うとすっきり簡単に仕事を片付けられる場面で(ますます多く)提供されています。ふつう、目的はデータ分析であり、プログラミングはそのための手段にすぎません。
関数をつなぐパイプ|>の使用は、いくつかの言語でますます一般的になっている手法です。
要点は次のとおりです。
())が必要です(これは他のいくつかの言語とは異なります)。コードを読むときに、パイプを「then」と読むとわかりやすいと感じるプログラマーもいます。つまり、ステップ1を行い、それからステップ2を行う、というわけです…
次の例は、その構文を示しています。
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
このタイプのパイプは、Rのバージョン4.1.0でBase Rに追加されました。これは「ネイティブパイプ」と呼ばれることもあります。
以前は、magrittrパッケージの%>%でパイプを行っていました(この名前は、美術好きな人向けのダジャレです)。「magrittrパイプ」も今なお利用できますが、ネイティブパイプなら外部パッケージが不要で、実装もいくつかの点でよりシンプルです。
RStudioのエディター(に限りませんが)では、Ctrl-Shift-Mが適切なスペース付きのパイプを挿入するキーボードショートカットになっています。
以前見たように、Rの多くの関数は「ベクトル化」されています。つまり、明示的にループを書かなくても、ベクトル全体にわたって処理を行います。
ベクトル化された関数だけを使っている関数は、それ自体も自動的にベクトル化される可能性が高いです。簡単な例を見てみましょう。
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
しかし、より複雑な関数はベクトル入力を受け付けないことがあります。たとえば、ループやif-elseブロックを含む場合です。
ベクトルに関数を適用する、より一般的な方法が必要です(他の言語のmap()に似ています)。
Rには基本言語に多種多様なデータ構造が備わっているため、それらを操作する*apply()関数の一族も存在します。より技術的に言えば、これらは関数を引数として受け取るため、プログラマーからは「高階関数」、数学者からは「汎関数」と呼ばれることがよくあります。
ここでは、lapply()とsapply()を見てみましょう。
lapply()「list-apply」はリストの入力に対して動作するように設計されていますが、必要に応じてベクトルは暗黙のうちにリストへ変換されます。
出力はリストなので、ベクトルを得るには展開が必要になることがあります。
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
Switchのコンセプトに慣れている学習者なら、スカラーのswitchを、dplyrライブラリのベクトル化されたrecode_values()に置き換えるという選択肢もあることに気づくでしょう。ベクトル化はよいことであり、現代のRにはそれを実現する方法がいくつも用意されています。
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()「simplified-apply」はlapplyと似た処理を行いますが、戻り値を単純化しようともします。たとえば、リストではなく名前付きベクトルを返します。状況によっては、unname()を使う必要があるかもしれません。
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrrライブラリlapplyとsapplyはシンプルで、ほとんどのRユーザーにとってなじみ深く、ベクトルや単純なリストに対して今でもかなりよく使われています。
Base Rには、他にもいくつかのapply系関数があります。
vapply()はsapply()に似ていますが、出力の形式を指定する必須の引数があります。apply()は行列やより高次元の配列に使います。mapply()は複数のリストやベクトルの引数を並行して処理します。tapply()は説明するのが難しい関数ですが、グループ化操作や不揃いな配列を扱います。vectorize()はかなり謎めいた関数で、mapply()のラッパーのようです。もう十分です!
こうした混乱に代わる、よりシンプルで一貫したものを提供することが、Tidyverseの主要な目標です。
現在はpurrrパッケージがあり、map()のようないくつかの追加の選択肢を提供するとともに、設計の一貫性とエラーメッセージのわかりやすさを高めています。このパッケージは、Base Rよりも後方互換性の制約が少ないため、他の言語にある現代的な関数型プログラミングにRを大きく近づけます。
purrrには多種多様な関数があり、簡単にまとめることはできません。ライブラリを理解するのに役立つ資料には、次のようなものがあります。
purrrライブラリは、ほとんどが1次元のデータ、つまりベクトルとリストに対して動作します。
後のコンセプトで見るように、dplyrライブラリは2次元のデータフレームに対して同等の機能を提供します。
map()関数は、入力の各要素に関数を適用し、常にリストを返します。
ここまでは、名前を変えただけの、しかもおそらくエラーメッセージがより親切なlapply()のように聞こえます。
しかし、map()は_大きな_一族のほんの一員にすぎません。
関数を選ぶときに考えるとよい問いをいくつか挙げます。
map()は常にリストを返しますが、特定の型の原子ベクトルが欲しい場合は、文字列ならmap_chr()、整数ならmap_int()などを使います。iを付けます。たとえばimap()です。mapをmap2に置き換えます。2つの入力(たとえば、加重平均を計算するための値と重み)にはmap2_dbl()を使います。pmapに置き換えます。たとえばpmap_chr()です。ええ、ハードウェアレベルの並列処理も可能です。map_if()、位置インデックスの条件を指定するmap_at()、要素が述語を満たさなくなるまで入力を処理するhead_while()を検討してください。mapの代わりにmodify()系の関数を、再帰的な適用にはmodify_tree()を使ってください。これらの関数は、パイプとうまく組み合わせられるよう、可能なかぎり入力データを最初の引数に取ります。
また、purrrは(意図的に)Base Rよりも細かいところにうるさいです。何か具体的なものを求めた場合、関数は_まさに_それを返すか、エラーメッセージを出して停止します。厳しいですが、見つけにくいバグを減らすのにはとても効果的です。
map系の関数は、一般に、入力と同じ長さの戻り値を持ちます。
他のいくつかの関数は、データの次元を_減らします_。たとえばsum()です。
sum(1:100)
#> [1] 5050
上の例では、長さ100のベクトルを長さ1の整数に変換しています。次元を減らすという考え方がより一般的であることは、行列と配列に関する今後のコンセプトで見ていきます。
sum()関数は組み込みです(他の多くの統計関数も同様です)。しかし、map()に相当する何らかの高階関数を使って、任意の次元削減関数をデータ構造全体に適用する方法が必要です。
他のいくつかの言語では、fold、foldl、foldrといった関数名が使われます。Rでは、該当する関数はreduce()で、(アルゴリズム好きには)よく知られたMapReduceの枠組みに対応します。
最初の引数は、いつもどおり入力データです。2番目の引数は2引数の関数です。多くの場合、無名関数ですが、+のような算術演算子のように単純なこともあります。
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
入力の先頭から関数を適用するか、末尾から適用するかは、違いを生むのでしょうか?
+や*のような結合的な演算子では違いません。明らかに、(1 + 2) + 3 == 1 + (2 + 3)です。
対照的に、-と/は結合的ではありません。
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
方向が重要な関数には、省略可能な.dir引数があります。
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
map()やmap2()と同様に、2つの入力ベクトルを並行して処理するreduce2()関数もあります。
Rでは、ベクトルを扱うのが好まれます。最終的な値が1つだけではなく、途中のすべてのステップを見たいとしましょう。
そのためには、accumulate()とaccumulate2()という対応する関数があります。構文はreduce()に似ていますが、出力の型を指定するための追加の省略可能な引数があります。
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
よくあるaccumulateの種類には、それぞれ専用の関数があります。ベクトル関数のコンセプトでは、Base Rのcumsum()とcumprod()を扱いました。dplyrライブラリには、数値の平均のためのcummean()、論理ベクトルのためのcumall()とcumany()があります。
よくあるのは、何らかの述語に一致する入力の要素を含めたい(または除外したい)場合です。
ベクトルのフィルタリングのコンセプトで、そのための慣用的な方法を1つ見ました。
v <- 1:3
v[v >= 2]
#> [1] 2 3
これは伝統的なRでよく使われますが、現代的な関数型のパイプラインにはあまりなじみません。
そのため、purrrは述語関数のセットを提供しています。これらはパイプと組み合わせられるよう、f(input, func, ...)という通常の引数の慣習に従います。
名前は他のほとんどの言語とは少し異なります(filterやdropのような名前は、Rではすでにまったく別の意味を持っています)。
対応するpurrrの関数には、keep()とdiscard()、その他いくつかがあります。
また、every()、some()、none()があり、単一の論理値を返すという点でall()やany()を置き換えます。
ほとんどの関数型言語は、ループの代わりに好んで再帰を使います。
Rには、別の、しばしばよりよい選択肢があります。ベクトル化された関数と高階関数です。
しかし、Rでも再帰はサポートされており、木構造をたどるような場面では役に立ちます。
再帰の最大深度はデフォルトで5000で、比較的余裕があります(Pythonはデフォルトで1000です)。デフォルトを上書きすることもできます。
末尾呼び出しの最適化はRでは標準ではありませんが、いくつかの回避策が開発されています。
慣例に従い、ここで階乗の例を、再帰的定義の古典的な例として示しましょう。
fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120
# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120
Rでも再帰は使えますし、時に役立ちますが、よりシンプルな方法があることもよくあります。