軌道
/
R
R
/
課程大綱
/
函式程式設計
函式

函式程式設計 在 R

1 個練習

關於 函式程式設計

什麼是函式程式設計?

這是個大哉問,值得長篇大論的回答(維基百科提供了一個,Advanced R提供了另一個)。

就我們的目的而言,以下是幾個關鍵特點:

  • 純函式,能一致地把輸入對應到輸出,而且沒有副作用。
  • 不可變的資料。
  • 一級函式,可以像任何其他型別一樣被當成值來處理。
  • 高階函式,接受其他函式作為引數或回傳值。

不過,R 並不是純函式語言(不同於例如 Haskell)。

請把 R 看成務實的語言:當函式式的做法能乾淨俐落地完成工作時,它就(越來越常)提供這樣的功能。 一般來說,資料分析才是目標,寫程式只是達成目標的手段。

管道

用管道|>把函式串接起來,在好幾種語言裡都越來越常見。

簡單來說:

  • 管道左邊的結果,會成為右邊函式的第一個引數(這裡的「右邊」會忽略可有可無的換行)。
  • 其餘的引數可以一併寫在函式呼叫裡。
  • 在 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裡的函式又多又雜,很難簡單總結。 以下資源可以幫你熟悉這個套件:

Note

purrr套件幾乎只處理一維資料:向量和串列。

我們會在之後的概念看到,dplyr套件為二維的資料框提供對應的功能。

眾多的 map 函式

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 裡使用遞迴,有時也確實很有價值,但通常還有更簡單的做法。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟

學習 函式程式設計