什麼是函式程式設計?
這是個大哉問,值得長篇大論的回答(維基百科提供了一個,Advanced R提供了另一個)。
就我們的目的而言,以下是幾個關鍵特點:
不過,R 並不是純函式語言(不同於例如 Haskell)。
請把 R 看成務實的語言:當函式式的做法能乾淨俐落地完成工作時,它就(越來越常)提供這樣的功能。 一般來說,資料分析才是目標,寫程式只是達成目標的手段。
用管道|>把函式串接起來,在好幾種語言裡都越來越常見。
簡單來說:
有些程式設計師在讀程式碼時,會把管道唸成「然後」,這樣比較順:先做步驟 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 概念 的學生可能會發現,我們也可以用dplyr套件裡向量化的recode_values()來取代純量switch。
向量化是好事,而現代的 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套件幾乎只處理一維資料:向量和串列。
我們會在之後的概念看到,dplyr套件為二維的資料框提供對應的功能。
map()會把函式套用到輸入裡的每一個元素,而且一律回傳串列。
到目前為止,這聽起來就像lapply()換了個名字,也許還附帶更好的錯誤訊息。
不過,map()只是個_龐大_家族裡的其中一員。
挑選函式時,可以想想這些問題:
map()一律回傳串列,但如果你想要特定型別的原子向量,字串用map_chr()、整數用map_int(),以此類推。i加在函式名稱前面,例如imap()。map換成map2,例如用map2_dbl()處理兩個輸入(例如值和權重,用來計算加權平均)pmap來處理一整組平行的輸入,例如pmap_chr()。沒錯,硬體層級的平行處理也做得到。map_if()套用述詞(布林)篩選、用map_at()指定位置索引條件,或用head_while()處理輸入,直到某個元素不符合述詞為止。modify()系列函式代替map,要遞迴套用的話則用modify_tree()。這些函式都盡可能把輸入資料放在第一個引數,以確保它們能和管道好好搭配。
另外,purrr刻意比 Base R 更吹毛求疵。如果你要求某個特定的東西,函式就會_精確_回傳那個東西,不然就直接停下來並附上錯誤訊息。
雖然嚴厲,但對於減少難以找出的 bug 非常有效。
map 系列的函式,回傳值通常和輸入長度相同。
有些其他函式會_縮減_資料的維度,例如sum()。
sum(1:100)
#> [1] 5050
上面的例子,把長度 100 的向量轉成長度 1 的整數。 我們之後會在談矩陣與陣列的概念時看到,降低維度這個概念其實更為一般。
sum()函式是內建的(很多其他統計函式也是)。
不過,我們需要一種方法,用某個等同於map()的高階函式,把任意能降低維度的函式套用到整個資料結構上。
在其他幾種語言裡,會用到fold、foldl、foldr這類函式名稱。在 R 裡,對應的函式是reduce(),也就是演算法愛好者熟知的 MapReduce 框架。
第一個引數一如往常是輸入資料。第二個引數是接受兩個引數的函式:通常是匿名函式,但也可能簡單到只是像+這樣的算術運算子。
# 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()一樣,也有reduce2()函式可以平行處理兩個輸入向量。
在 R 裡,我們喜歡用向量工作。 假設你不想只看見最後的單一值,而是想看到所有中間步驟。
為此,對應的函式是accumulate()和accumulate2()。
語法和reduce()類似,但多了幾個選用引數可以指定輸出型別。
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
有些常見的累積類型有自己的專屬函式。
向量函式這個概念談過 Base R 裡的cumsum()和cumprod(),而dplyr套件則有計算數值平均的cummean(),以及用在邏輯向量上的cumall()和cumany()。
我們常常想保留(或排除)輸入中符合某個述詞的元素。
在向量篩選這個概念裡,我們看過一種道地的做法。
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 裡使用遞迴,有時也確實很有價值,但通常還有更簡單的做法。