函數型程式設計

R, at its heart, is a functional programming (FP) language.

Hadley Wickham

我們在自訂函數探討了很多自訂函數的相關主題,也並沒有諱言 R 語言的核心其實就是函數型程式設計(Functional Programming,FP),這代表除了許多用於創建函數的知識以外,還有相應來操作函數的各種工具。雖然我們早已經知道如何使用迴圈迭代解決重複執行的任務,但這個小節將試著以函數型程式設計的視角切入,提供另一種重複執行的解決方案。

解決重複執行任務的三種方案

利用 R 語言解決重複執行任務是非常簡單的,原因是 R 的基本單位是資料結構(data structure)而非純量,實踐的方式有三種方案:

  1. 向量運算
  2. apply() 系列函數(函數型程式設計)
  3. 迴圈與迭代

先以一個極度單純(Supersimple?)的例子來看,該如何將一個數列 11:20 中的每個數字都進行平方運算。

num_seq <- 11:20
# Solution 1: 向量運算
num_seq**2
# Solution 2: apply() 系列函數
sapply(num_seq, FUN = function(x) x**2)
# Solution 3: 迴圈與迭代
seq_length <- length(num_seq)
num_seq_squared <- rep(NA, times = seq_length)
for (i in 1:seq_length) {
  num_seq_squared[i] <- num_seq[i]**2
}
num_seq_squared
1
2
3
4
5
6
7
8
9
10
11
12
## > num_seq <- 11:20
## > # Solution 1: 向量運算
## > num_seq**2
##  [1] 121 144 169 196 225 256 289 324 361 400
## > # Solution 2: apply() 系列函數
## > sapply(num_seq, FUN = function(x) x**2)
##  [1] 121 144 169 196 225 256 289 324 361 400
## > # Solution 3: 迴圈與迭代
## > seq_length <- length(num_seq)
## > num_seq_squared <- rep(NA, times = seq_length)
## > for (i in 1:seq_length) {
## +   num_seq_squared[i] <- num_seq[i]**2
## + }
## > num_seq_squared
##  [1] 121 144 169 196 225 256 289 324 361 400
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

既然三種解決方案都可以達成目標,又該如何著手?我們考量的面向是程式碼的多寡與執行速度的快慢;程式碼的多寡在範例程式中已經一目瞭然,接著將數列延展長度為 100,000,再利用 system.time() 函數來衡量執行速度的快慢(所需系統時間。)

num_seq <- rep(10, times = 100000)
# Solution 1: 向量運算
system.time(
  num_seq_squared <- num_seq**2
)
# Solution 2: apply() 系列函數
system.time(
  num_seq_squared <- sapply(num_seq, FUN = function(x) x**2)
)
# Solution 3: 迴圈與迭代
seq_length <- length(num_seq)
num_seq_squared <- rep(NA, times = seq_length)
system.time(
  for (i in 1:seq_length) {
    num_seq_squared[i] <- num_seq[i]**2
  }
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
## > num_seq <- rep(10, times = 100000)
## > # Solution 1: 向量運算
## > system.time(
## +   num_seq_squared <- num_seq**2
## + )
##    user  system elapsed 
##       0       0       0 
## > # Solution 2: apply() 系列函數
## > system.time(
## +   num_seq_squared <- sapply(num_seq, FUN = function(x) x**2)
## + )
##    user  system elapsed 
##   0.072   0.001   0.073 
## > # Solution 3: 迴圈與迭代
## > seq_length <- length(num_seq)
## > num_seq_squared <- rep(NA, times = seq_length)
## > system.time(
## +   for (i in 1:seq_length) {
## +     num_seq_squared[i] <- num_seq[i]**2
## +   }
## + )
##    user  system elapsed 
##   0.015   0.001   0.016
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

綜合考量程式碼的多寡與執行速度的快慢這兩個面向,我們很武斷地建議,倘若在三種解決方案都適用的情況下,採用的優先順序為向量運算、apply() 系列函數最後才是迴圈與迭代。

如何實踐函數型程式設計

採用三種方案中的第二項:apply() 系列函數來解決重複執行的任務,就被視作是一種函數型程式設計(Functional Programming),其中 Functional 可被解釋為將函數作為輸入或將函數作為輸出的手法,更具體的說明就是將自訂函數作為 apply() 系列函數的輸入之一,來將自訂函數的功用映射(apply)至指定資料結構中的每一個元素,而為了方便搭配 apply() 系列函數,有時我們會將簡單的函數省略命名,改以匿名函數作為輸入,例如像是先前舉的例子:該如何將一個數列 11:20 中的每個數字都進行平方運算。

# 有命名的函數
get_squared <- function(x) {
  return(x**2)
}
num_seq <- 11:20
# 映射命名函數
sapply(num_seq, get_squared)
# 映射匿名函數
sapply(num_seq, function(x) x**2)
1
2
3
4
5
6
7
8
9
## > # 有命名的函數
## > get_squared <- function(x) {
## +   return(x**2)
## + }
## > num_seq <- 11:20
## > # 映射命名函數
## > sapply(num_seq, get_squared)
##  [1] 121 144 169 196 225 256 289 324 361 400
## > # 映射匿名函數
## > sapply(num_seq, function(x) x**2)
##  [1] 121 144 169 196 225 256 289 324 361 400
1
2
3
4
5
6
7
8
9
10
11

在程式中我們使用的 sapply() 函數是 apply() 系列函數的成員之一,這是 R 語言為了因應多樣資料結構的輸入以及輸出,打造出各司其職的系列函數,像是 apply()lapply()sapply() 等。

印製超級球星的球衣

接著我們考量一個不若「將一個數列 11:20 中的每個數字都進行平方運算」這麼單純的任務:印製球星的球衣。在球衣的設計上,除了會印製背號以外,亦會印製球員的姓氏(family name);像是 LeBron James 的球衣,除了 23 號還會有「JAMES」字樣。

NBA Store

假如在 super_nba_stars 這個文字向量中儲存了多位超級 NBA 球星(退役或現役,)我們這時需要想辦法將每個球員的姓氏從 super_nba_stars 中取出,再把姓氏所有字母轉換為大寫(upper-cased。)

# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
super_nba_stars
1
2
3
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > super_nba_stars
## [1] "Steve Nash"      "Michael Jordan"  "LeBron James"   
## [4] "Dirk Nowitzski"  "Hakeem Olajuwon"
1
2
3
4
5

這時可以使用 strsplit() 函數將每個球星的名字與姓氏分開,得到一個長度為 5 的 list 為輸出,裡面的每一筆資料都是一個長度為 2 的文字向量:索引值 1 是名字、索引值 2 是姓氏。

# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
split_names <- strsplit(super_nba_stars, split = " ")
split_names
1
2
3
4
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > split_names <- strsplit(super_nba_stars, split = " ")
## > split_names
## [[1]]
## [1] "Steve" "Nash" 
## 
## [[2]]
## [1] "Michael" "Jordan" 
## 
## [[3]]
## [1] "LeBron" "James" 
## 
## [[4]]
## [1] "Dirk"      "Nowitzski"
## 
## [[5]]
## [1] "Hakeem"   "Olajuwon"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

由於輸出資料結構為 list,得先排除解決重複執行任務方案中的第一種:向量運算,考慮使用 apply() 系列函數或迴圈迭代。我們先從熟悉的迴圈迭代著手,讓一個 iterator 從 list 中由 [[1]] 迭代至 [[5]] ,在每一次迭代中取出向量中的第二個資料,並以 toupper() 函數轉換為大寫後儲存至一個新的文字向量中。

# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
split_names <- strsplit(super_nba_stars, split = " ")
# Solution: 迴圈迭代
star_jerseys <- c()
for (i in 1:length(split_names)) {
  family_name <- split_names[[i]][2]
  star_jerseys[i] <- toupper(family_name)
}
star_jerseys
1
2
3
4
5
6
7
8
9
10
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > split_names <- strsplit(super_nba_stars, split = " ")
## > # Solution: 迴圈迭代
## > star_jerseys <- c()
## > for (i in 1:length(split_names)) {
## +   family_name <- split_names[[i]][2]
## +   star_jerseys[i] <- toupper(family_name)
## + }
## > star_jerseys
## [1] "NASH"      "JORDAN"    "JAMES"     "NOWITZSKI"
## [5] "OLAJUWON"
1
2
3
4
5
6
7
8
9
10
11
12

接著我們採用 apply() 系列函數,定義一個函數 get_star_jersey() ,這個函數的功能是取出文字向量中的第二個文字並轉換為大寫。

# 取出文字向量中的第二個文字並轉換為大寫
get_star_jersey <- function(x) {
  family_name <- x[2]
  upper_cased <- toupper(family_name)
  return(upper_cased)
}
1
2
3
4
5
6

get_star_jersey() 函數的功能並不複雜,假如我們偏好簡潔大過於可讀性,可以將這個功能以匿名函數撰寫。

# 取出文字向量中的第二個文字並轉換為大寫: 匿名函數
function(x) toupper(x[2])
1
2

接著是選擇從 apply() 系列函數中選出合適成員作為 get_star_jersey() 函數或者匿名函數的輸入對象,「將函數輸入函數」聽起來像是繞口令一般,不過這確實是 Functionals 拗口的定義。

認識 apply() 系列函數

apply() 系列函數中第一個最該被認識的成員是 lapply() 函數,全名為 list apply,字面上的意思是接受一個函數作為輸入,並將它應用於 list 中的每筆資料,並以 list 資料結構回傳結果。

# 取出文字向量中的第二個文字並轉換為大寫
get_star_jersey <- function(x) {
  family_name <- x[2]
  upper_cased <- toupper(family_name)
  return(upper_cased)
}

# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
split_names <- strsplit(super_nba_stars, split = " ")
# Solution: lapply(FUN = get_star_jersey)
star_jerseys <- lapply(split_names, FUN = get_star_jersey)
star_jerseys
# Solution: lapply(FUN = 匿名函數)
star_jerseys <- lapply(split_names, FUN = function(x) toupper(x[2]))
star_jerseys
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
## > # 取出文字向量中的第二個文字並轉換為大寫
## > get_star_jersey <- function(x) {
## +   family_name <- x[2]
## +   upper_cased <- toupper(family_name)
## +   return(upper_cased)
## + }
## > 
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > split_names <- strsplit(super_nba_stars, split = " ")
## > # Solution: lapply(FUN = get_star_jersey)
## > star_jerseys <- lapply(split_names, FUN = get_star_jersey)
## > star_jerseys
## [[1]]
## [1] "NASH"
## 
## [[2]]
## [1] "JORDAN"
## 
## [[3]]
## [1] "JAMES"
## 
## [[4]]
## [1] "NOWITZSKI"
## 
## [[5]]
## [1] "OLAJUWON"
## 
## > # Solution: lapply(FUN = 匿名函數)
## > star_jerseys <- lapply(split_names, FUN = function(x) toupper(x[2]))
## > star_jerseys
## [[1]]
## [1] "NASH"
## 
## [[2]]
## [1] "JORDAN"
## 
## [[3]]
## [1] "JAMES"
## 
## [[4]]
## [1] "NOWITZSKI"
## 
## [[5]]
## [1] "OLAJUWON"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

apply() 系列函數中第二組該被認識的成員是 sapply()vapply() 函數,全名分別為 simplify apply 及 vector apply,字面上的意思是接受一個函數作為輸入,並將它應用於 list 中的每筆資料,並以向量資料結構回傳結果。值得注意的是 vapply() 函數中有一個難懂的FUN.VALUE 參數,這個參數使用者必須指定預期輸出向量的型別與長度;我們必須輸入 FUN.VALUE = character(1) ,因為球星姓氏大寫是長度為 1 的文字向量。

# 取出文字向量中的第二個文字並轉換為大寫
get_star_jersey <- function(x) {
  family_name <- x[2]
  upper_cased <- toupper(family_name)
  return(upper_cased)
}
# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
split_names <- strsplit(super_nba_stars, split = " ")
star_jerseys <- vapply(split_names, FUN = get_star_jersey, FUN.VALUE = character(1))
star_jerseys
star_jerseys <- sapply(split_names, FUN = get_star_jersey)
star_jerseys
1
2
3
4
5
6
7
8
9
10
11
12
13
## > # 取出文字向量中的第二個文字並轉換為大寫
## > get_star_jersey <- function(x) {
## +   family_name <- x[2]
## +   upper_cased <- toupper(family_name)
## +   return(upper_cased)
## + }
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > split_names <- strsplit(super_nba_stars, split = " ")
## > star_jerseys <- vapply(split_names, FUN = get_star_jersey, FUN.VALUE = character(1))
## > star_jerseys
## [1] "NASH"      "JORDAN"    "JAMES"     "NOWITZSKI"
## [5] "OLAJUWON" 
## > star_jerseys <- sapply(split_names, FUN = get_star_jersey)
## > star_jerseys
## [1] "NASH"      "JORDAN"    "JAMES"     "NOWITZSKI"
## [5] "OLAJUWON"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

apply() 系列函數中第三個該被認識的成員是 Map() 函數,字面上的意思是接受一個函數作為輸入,並接受兩個以上的 list 作為其他輸入與參數,並以 list 資料結構回傳結果。當我們要映射的函數需要兩個以上的輸入時就會派上用場,例如將 get_star_jersey() 函數加入新的參數 n ,當我們指定 n = 1 就是取出名字(given name)轉換為大寫,指定 n = 2 則依然維持取出姓氏(family name)轉換為大寫。在以下的範例中我們輸入 n_list 作為參數 n ,因此 Michael Jordan 與 Dirk Nowitzski 會回傳姓氏大寫,而其他三位球星會回傳名字大寫。

# 取出文字向量中的第 n 個文字並轉換為大寫
get_star_jersey <- function(x, n) {
  name <- x[n]
  upper_cased <- toupper(name)
  return(upper_cased)
}
# 超級球星
super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
split_names <- strsplit(super_nba_stars, split = " ")
n_list <- list(1, 2, 1, 2, 1)
star_jerseys <- Map(get_star_jersey, split_names, n_list)
star_jerseys
1
2
3
4
5
6
7
8
9
10
11
12
## > # 取出文字向量中的第 n 個文字並轉換為大寫
## > get_star_jersey <- function(x, n) {
## +   name <- x[n]
## +   upper_cased <- toupper(name)
## +   return(upper_cased)
## + }
## > # 超級球星
## > super_nba_stars <- c("Steve Nash", "Michael Jordan", "LeBron James", "Dirk Nowitzski", "Hakeem Olajuwon")
## > split_names <- strsplit(super_nba_stars, split = " ")
## > n_list <- list(1, 2, 1, 2, 1)
## > star_jerseys <- Map(get_star_jersey, split_names, n_list)
## > star_jerseys
## [[1]]
## [1] "STEVE"
## 
## [[2]]
## [1] "JORDAN"
## 
## [[3]]
## [1] "LEBRON"
## 
## [[4]]
## [1] "NOWITZSKI"
## 
## [[5]]
## [1] "HAKEEM"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

apply() 系列函數中第四個該被認識的成員是 apply() 函數,接受一個函數作為輸入,並能夠選擇映射至二維資料結構(矩陣、資料框)的列或欄,並以向量資料結構回傳結果,當 MARGIN 參數指派為 1 時表示將函數映射至矩陣或資料框的所有列,MARGIN 參數指派為 2 時表示將函數映射至矩陣或資料框的所有欄。

# apply() 函數可以映射函數至矩陣或資料框的列或欄
my_mat <- matrix(11:20, nrow = 5)
col_1 <- 11:15
col_2 <- 16:20
df <- data.frame(col_1, col_2)
apply(my_mat, MARGIN = 1, FUN = sum) # 映射 sum() 至 row
apply(my_mat, MARGIN = 2, FUN = sum) # 映射 sum() 至 column
apply(df, MARGIN = 1, FUN = sum)     # 映射 sum() 至 row
apply(df, MARGIN = 2, FUN = sum)     # 映射 sum() 至 column
1
2
3
4
5
6
7
8
9
## > # apply() 函數可以映射到矩陣或資料框
## > my_mat <- matrix(11:20, nrow = 5)
## > col_1 <- 11:15
## > col_2 <- 16:20
## > df <- data.frame(col_1, col_2)
## > apply(my_mat, MARGIN = 1, FUN = sum)  # 映射 sum() 至 row
## [1] 27 29 31 33 35
## > apply(my_mat, MARGIN = 2, FUN = sum)  # 映射 sum() 至 column
## [1] 65 90
## > apply(df, MARGIN = 1, FUN = sum)      # 映射 sum() 至 row
## [1] 27 29 31 33 35
## > apply(df, MARGIN = 2, FUN = sum)      # 映射 sum() 至 column
## col_1 col_2 
##    65    90
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在這個程式範例中我們創建的矩陣與資料框外觀都是 5 x 2、映射的函數是 sum(),當 MARGIN = 1 的時候會得到五個列的加總,當 MARGIN = 2 的時候會得到兩個欄的加總。

小結

在這個小節中我們簡介如何以函數型程式設計(Functional Programming)的思維解決需要重複執行的任務,比較解決重複執行任務的三種方案、探討如何實踐函數型程式設計、利用一個印製超級球星球衣的案例認識 apply() 系列函數,包含 lapply()vapply()sapply()Map()apply()

練習

請分別用向量運算、Map() 函數與迴圈來計算這 50 萬筆身高體重資料的 BMI,並且都以 system.time() 函數觀察執行時間。

heights <- ceiling(runif(500000) * 50) + 140
weights <- ceiling(runif(500000) * 50) + 40
h_w_df <- data.frame(heights, weights)
1
2
3

延伸閱讀