11 Function Operators

11.1 Introduction

이번 Chatper에서는 function operators에 대해서 배울 것이다.
function operator는, 하나 이상의 functions를 input으로 받아서, function을 output으로 return하는 function.

(기억을 복기해보면,
functionals는 function을 input으로, vector를 output으로.
function factories는 vector를 input으로, function을 output으로.)

다음의 예는, chatty()라는 간단한 function operator를 보여준다.
function을 wrap해서, 첫 번째 argument를 print하는 새로운 function을 만든다.
이러면 map_int()와 같은 functionals가 어떻게 작동하는지 볼 수 있는 창window를 만들어준다.

chatty <- function(f) {
  force(f)

  function(x, ...) {
    res <- f(x, ...)
    cat("Processing ", x, "\n", sep = "")
    res
  }
}

f <- function(x) x ^ 2
s <- c(3, 2, 1)

purrr::map_dbl(s, chatty(f))
## Processing 3
## Processing 2
## Processing 1
## [1] 9 4 1

function operators는 function factory와 밀접한 관련이 있음.
사실 그냥 function을 input으로 받는 function factory임.

factories와 마찬가지로, 이게 없다고 못할 건 하나도 없다.
Like factories, there's nothing you can't do without them.
하지만 복잡함을 factor out할 수 있도록 해줌. 그래서 너의 코드를 좀 더 readable, reusable하게끔.

function operators는 보통 functionals와 pair된다.
for 루프를 사용한다면, function operator는 조금의 이득을 위해 복잡함만 늘리기 때문에 쓸 이유가 없음.
하지만 functional과 같이 쓰면, 꽤 이득을 볼 수 있다. 11.3에서 그 예를 보게 된다.

Python에 친숙하다면, decorators는 function operators의 다른 이름일 뿐이다.

Outline

Section 11.2에서는 두 개의 매우 유용한, 존재하는 function operator를 소개하고,
real problem을 solve하는데 사용하는 법을 보여줌. safely()memoise().

Section 11.3 function operators로 해결할 수 있는 문제를 보여준다: 많은 웹 페이지 다운.

Prerequisites

function operator는 일종의 function factory다.
그래서 최소한 Section 6.2에 익숙해 있어야한다.

Chapter 9에서 배운 purrr의 몇몇 functionals을 쓸 것이고,
purrr의 몇몇 function operators에 대해서도 배울 것이다.
그리고 memoise 패키지에 있는 memoise() 연산자operator도 쓸 것이다.

library(purrr)
library(memoise)

11.2 Existing function operators

흔하게 발생하는 문제를 해결해줄 2개의 매우 유용한 function operators가 있다.
그리고 이것들은 function operators가 뭘하는지에 대해 감을 잡게 해줄 것이다.
purrr::safely(), memoise::memoise()

11.2.1 Capturing errors with purrr::safely()

for 루프의 한 가지 장점은 iteration이 실패하더라도,
    그 실패전까지의 모든 결과물들에 access할 수 있다는 것이다. 다음의 예를 보자.

x <- list(
    c(0.512, 0.165, 0.717),
    c(0.064, 0.781, 0.427),
    c(0.890, 0.785, 0.495),
    "oops"
)

out <- rep(NA_real_, length(x))
for(i in seq_along(x)) {
  out[[i]] <- sum(x[[i]])
}
## Error in sum(x[[i]]): 인자의 'type' (character)이 올바르지 않습니다

out
## [1] 1.394 1.272 2.170    NA

같은 것을 functional 가지고 한다면, 아무런 output을 얻을 수 없다.
그래서 어디가 문제인지 알아내기가 힘들다.

map_dbl(x, sum)
## Error in .Primitive("sum")(..., na.rm = na.rm): 인자의 'type' (character)이 올바르지 않습니다

purrr::safely()는 이 문제를 해결하는데 도움을 준다.
safely()는 function operator다. 에러를 데이터로 변환하도록, 함수를 transform하는.
safely() is a function operator that transforms a function / to turn errors into data.
그러니깐, 출력되는 에러를 데이터로 변환해준다.
이게 무슨 소리인지는 밑의 예를 보면 금방 이해가 된다. 어려운 얘기 아니다.
(이게 가능하도록 해주는 기본 아이디어를 Section 8.6.2에서 배울 수 있다.)

safe_sum <- safely(sum)
safe_sum
## function (...) 
## capture_error(.f(...), otherwise, quiet)
## <bytecode: 0x00000000181cf028>
## <environment: 0x00000000181ceb90>

다른 function operators와 마찬가지로, safely()는 function을 받고, wrapped function을 return한다.
이렇게 얻은 wrapped function도, 평소와 같이 호출하면 된다.

safe_sum(x[[1]])
## $result
## [1] 1.394
## 
## $error
## NULL
str(safe_sum(x[[1]]))
## List of 2
##  $ result: num 1.39
##  $ error : NULL
safe_sum(x[[4]])
## $result
## NULL
## 
## $error
## <simpleError in .Primitive("sum")(..., na.rm = na.rm): 인자의 'type' (character)이 올바르지 않습니다>
str(safe_sum(x[[4]]))
## List of 2
##  $ result: NULL
##  $ error :List of 2
##   ..$ message: chr "인자의 'type' (character)이 올바르지 않습니다"
##   ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
##   ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

safely()를 이용해서 transform된 함수가,
    항상 resultserror라는 2개의 elements를 갖고 있는 리스트를, 돌려주는걸 볼 수 있다.
만약 function이 성공적으로 실행되면, errorNULL이 되고, result는 result를 contain하고 있다.
만약 function이 실패하면, resultNULL이 되고, error는 error를 contain하고 있다.

이제, safely()를 functional과 함께 사용해보자.

out <- map(x, safely(sum))
str(out)
## List of 4
##  $ :List of 2
##   ..$ result: num 1.39
##   ..$ error : NULL
##  $ :List of 2
##   ..$ result: num 1.27
##   ..$ error : NULL
##  $ :List of 2
##   ..$ result: num 2.17
##   ..$ error : NULL
##  $ :List of 2
##   ..$ result: NULL
##   ..$ error :List of 2
##   .. ..$ message: chr "인자의 'type' (character)이 올바르지 않습니다"
##   .. ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
##   .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

여기서 output은, 좀 불편한 form으로 되어 있다.
4개의 리스트들이 있고, 각각은 result와 error를 가지고 있는 리스트다.
이 output을 좀 더 쉽게 만들 수 있음. purrr::transpose()를 사용해서 뒤집어놓음으로써.
We can make the output easier to use / by turning it "inside-out" with purrr::transpose()
그러면 result들의 리스트와, error들의 리스트를 얻게 된다.

out <- transpose(map(x, safely(sum)))
str(out)
## List of 2
##  $ result:List of 4
##   ..$ : num 1.39
##   ..$ : num 1.27
##   ..$ : num 2.17
##   ..$ : NULL
##  $ error :List of 4
##   ..$ : NULL
##   ..$ : NULL
##   ..$ : NULL
##   ..$ :List of 2
##   .. ..$ message: chr "인자의 'type' (character)이 올바르지 않습니다"
##   .. ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
##   .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

위에서는 List of 4였는데, List of 2가 된 것을 볼 수 있다.

이제 잘 작업이 된 결과물들을 쉽게 찾을 수 있다.
혹은, fail한 경우에는 그 input도 찾을 수 있고.

# 에러가 있는지 없는지를 index로 만들기
ok <- map_lgl(out$error, is.null)
ok 
## [1]  TRUE  TRUE  TRUE FALSE

x[!ok] # fail한 경우에 input 찾기
## [[1]]
## [1] "oops"

out$result[ok] # 작동한 결과물들을 찾기
## [[1]]
## [1] 1.394
## 
## [[2]]
## [1] 1.272
## 
## [[3]]
## [1] 2.17

예를 들어, 데이터 프레임들을 모아놓은 리스트가 있고,
    각각의 데이터 프레임에 GLM(Generalised Linear Model)을 fitting하는 상황을 상상해보자.
GLM은 가끔 optimisation 문제 때문에 fail할 수 있는데,
    여전히 일단 다 시도해본다음에, 나중에 fail한 것만 따로 봐보고 싶다고치자.

fit_model <- function(df) {
    glm(y ~ x1 + x2 * x3, data = df)
}

models <- transpose(map(datasets, safely(fit_model)))

# 위에서 했던 것과 같이, 에러가 있는지 없는지를 index로 만들기
ok <- map_lgl(models$error, is.null)

# 어떤 데이터셋이 converge하는데 실패했는지?
datasets[!ok]

# converge에 성공한 모델들은 어떻게 생겼는지?
models[ok]

저자 생각엔, 이게 functionals와 function operators를 결합한 훌륭한 예다.
safely()는 간결하게 표현하도록 해준다. 일반적인 데이터 분석 문제를 해결하는데 필요한 것을.
safely() lets you succinctly express / what you need / to solve a common data analysis problem.

purrr에는 비슷한 맥락의 3개 다른 function operators가 있다.
- possibly(): error가 있을 때 디폴트 값을 return해준다.
얘는 에러 발생 여부를 알 수 있는 방법이 없기 때문에, 확실한 sentinel 값(NA와 같은)이 있을 때만 쓰자

  • quietly(): output, 메세지, warning side-effects를 output의 각 component로 만들어준다.
    그러니깐, output, message, warning으로 넣어준다.

  • auto_browse(): 에러가 있을 때, 함수 안에서 browser()을 자동으로 실행해준다.

더 자세하게 알고 싶다면 documentation을 보자.

11.2.2 Caching computations with memoise::memoise()

계산을 caching해놓을 수 있다. 이러면 복잡한 계산을 두 번 할 필요가 없다.

또 다른 편리한 function operator는 memoise::memoise()이다.
이건 함수를 memoise한다.
function은 이전의 inputs를 기억하고, cached results를 return하는 것.
memoisation은 classic computer science에서, memory와 speed의 tradeoff하는 것의 한 가지 예다.
memoise된 함수는 더 빨리 실행된다. memory를 사용해서. speed를 얻은 것.

expensive operation을 simulate하는 toy function과 함께, 이 아이디어에 대해 알아보자.

slow_function <- function(x) {
    Sys.sleep(1)
    x * 10 * runif(1)
}

system.time(print(slow_function(1)))
## [1] 8.508138
##    user  system elapsed 
##       0       0       1
system.time(print(slow_function(1)))
## [1] 7.063588
##    user  system elapsed 
##    0.00    0.00    1.02

이 함수를 memoise하고 나면, 우리가 새로운 argument와 함께 호출할 때는 느리다.
하지만 이전에 해봤던 arguments와 함께 호출할 때면, 즉시 나온다.
이전에 계산된 값을 바로 되찾아온다retrieve.

fast_function <- memoise::memoise(slow_function)
system.time(print(fast_function(1)))
## [1] 1.009648
##    user  system elapsed 
##       0       0       1

system.time(print(fast_function(1))) # 해봤던 arguments니깐 즉시 나옴.
## [1] 1.009648
##    user  system elapsed 
##    0.02    0.00    0.01

system.time(print(fast_function(2))) # 이러면 새로운 argument니깐 다시 느려지고.
## [1] 3.108843
##    user  system elapsed 
##       0       0       1

이런 memoisation의 그나마 현실적인 이용으로는 피보나치 수열 계산이 있겠다.
피보나치 수열은 recursive하게 정의되는데, f(0) = 0, f(1) = 1로 정의되어 있고,
f(n) = f(n-1) + f(n-2)이다.

예를 들어, fib(10)은 fib(9), fib(8)을 계산하고, fib(9)는 fib(8), fib(7)을 계산하고 쭉쭉쭉

fib <- function(n) {
  if(n < 2) return(1)
  fib(n - 2) + fib(n - 1)
}
system.time(fib(23))
##    user  system elapsed 
##    0.03    0.00    0.03
system.time(fib(23))
##    user  system elapsed 
##    0.03    0.00    0.03

이렇게 같은 작업을 반복해도, 처음부터 다시 계산을 하기 때문에 시간이 똑같이 오래 걸린다.
이제 fib()를 memoising하면, 값은 한 번만 계산되기 때문에 implementation이 훨씬 빨라진다.

fib2 <- memoise::memoise(function(n) {
  if(n < 2) return(1)
  fib2(n - 2) + fib2(n - 1)
}
)

이 경우에는 위에 fast_function()의 경우와는 다르게, 재귀함수로 짜여있어서 그냥 memoise()로 wrap하는 것만으로는 부족하다.

system.time(fib2(23))
##    user  system elapsed 
##    0.03    0.00    0.04
system.time(fib2(23))
##    user  system elapsed 
##       0       0       0

그리고 미래의 호출에도, 이전에 계산된 값을 사용한다.

system.time(fib2(24))
##    user  system elapsed 
##       0       0       0

이건 dynamic programming의 한 가지 예.
복잡한 문제가 여러 개의 subproblems로 쪼개지고, subproblem의 결과를 remembering하는 것이 성능을 엄청나게 improve시키는.
(dynamic programming이 functional programming의 반대 개념이다. 80-functional programming에 나름 설명하려고 노력했다.)

function을 memoising하기 전에, 신중하게 생각해라.
만약에 function이 pure하지 않다면, 그러니깐 output이 input에 의해서만 바뀌지 않는다면,
    헷갈리거나 잘못된 결과를 얻게 될 것이다.

저자가 이것 때문에 devtools에서 미묘한 버그를 만든 적이 있다.
왜냐하면 CRAN에서 큰 파일을 다운로드해야되었기 때문에, available.packages의 결과물을 memoise했다.
보통 available 패키지는 자주 바뀌지 않는데, R 프로세스가 몇일 동안 돌아가게 하면, 그 변화가 중요해질 수 있었다.
그 버그는 찾기가 힘들었다.고 자신이 실수했던 썰을 풀어줌.

11.2.3 Exercises

11.3 Case study: Creating your own function operators

memoise()safely()는 매우 유용하지만 또한 꽤나 복잡했다.
여기서 좀 간단한, 너만의 function operator를 만드는 법에 대해서 배울 것이다.
URL들의 named vector가 있고, 각각을 디스크에 저장하고 싶다고 하자.

urls <- c(
  "adv-r" = "https://adv-r.hadley.nz",
  "r4ds" = "http://r4ds.had.co.nz"
  # 등등 많이많이
)

path <- paste(tempdir(), names(url), ".html")

walk2(urls, path, download.file, quiet = TRUE) # 이 quiet이 없으면 진행상황이 나옴. 저장을 하고 있네마네 등등

이러한 접근법은 URL들이 몇 개 없을 때는 문제가 없다.
하지만 벡터의 길이가 늘어나면, 몇몇 features를 더하고 싶어질 것이다.

  1. 각 작업마다 약간의 딜레이를 넣어서 서버와 충돌하는걸 피하고 싶음.
    (크롤링할 때에도 너무 빠르게 하다보면 충돌이 일어나듯이.)

  2. 대충 열개의 작업마다 .을 한개씩 출력해서, 함수가 여전히 작동하고 있음을 알 수 있게끔.

루프를 이용하고 있다면, 이러한 extra features를 추가하는 것은 쉽다.

for(i in seq_along(x)){
  Sys.sleep(0.1)
  if(i %% 10 == 0) cat(".")
  download.file(urls[i], path[i], quiet = TRUE)
}

저자 생각엔, 이 for 루프는 최선은 아니다.
몇몇 다른 관심사를 인터리브하기 때문에.
(그러니깐 일관성 있게 해당 path에다가 url 저장을 하지 않고, 갑자기 pausing도 했다, 진행상황을 보여주기도 했다, downloading도 했다 이러는게 별로다.)
이러면 코드는 읽기가 더 힘들어지고, 새로운 상황에 있어 components를 재사용하기가 어려워진다.

대신에, function operator를 이용해서 pausing과 showing progress를 추출해낼 수 있을지 봐보자.
그렇게해서 reusable하도록.

delay 추가하기

먼저, 약간의 딜레이를 추가하는 function operator를 작성해보자.
delay_by()라고 부를 것이다. 이것은 2개의 arguments를 가지고 있다.
① 감싸질 함수function to wrap, ② 그리고 얼마나 딜레이할지. amount.
이러면 더 명료해진다.

실제 implementation은 꽤나 간단하다.
가장 중요한 부분은, Section 10.2.5에서 언급한대로, 모든 arguments들을 forcing evaluation하는 것.
왜냐하면 function operator는 function factory의 특별한 타입이기 때문.

delay_by <- function(f, amount) {
  force(f)
  force(amount)

  function(...) {
    Sys.sleep(amount)
    f(...)
  }
}
system.time(runif(100))
##    user  system elapsed 
##       0       0       0
system.time(delay_by(runif, 0.1)(100))
##    user  system elapsed 
##    0.00    0.00    0.11

원래의 walk2()에도 적용을 할 수 있다.

walk2(urls, path, delay_by(download.file, 0.1), quiet = TRUE)

showing progress 추가하기

함수가 잘 작동하고 있다고 점 하나씩 찍어주는건 좀 더 어렵다.
왜냐하면 더 이상 루프의 index를 사용할 수 없기 때문.
index를 또다른 argument로 패스해놓을 수는 있는데, 이러면 encapsulation이 망가진다.
progress function에 대한 걱정이, higher level wrapper가 처리해야하는 문제가 된다.
a concern of the progress function / now becomes a problem that the higher level wrapper needs to handle.

대신에, Section 10.2.4에 있는 다른 function factory 트릭을 사용해서,
    progress wrapper가 자신의 internal counter을 관리할 수 있도록 해보겠다.

이 함수도 2개의 arguments.
① 감싸질 함수function to wrap, ② 얼마마다 한 번씩 점을 찍어줄 것인지n.

dot_every <- function(f, n) {
  force(f)
  force(n)

  i <- 0
  function(...) {
    i <<- i + 1
    if(i %% n == 0) cat(".")
    f(...)
  }
}
walk(1:100, runif) # 이러면 100번 runif()가 실행되기는 하는데 아무 결과 안 나옴
walk(1:100, dot_every(runif, 10)) # runif() 10번마다 .을 하나씩 찍어주자
## ..........

Back to download url to path

이제 위의 2개를 종합해서, 원래의 루프를 표현해보자.

walk2(
  urls, path, 
  dot_every(delay_by(download.file, 0.1), 10), 
  quiet = TRUE)
)

하지만, 여러 개의 함수드을 합성compose하니깐 슬슬 읽기가 어려워진다.
pipe를 사용해서 풀어서 써보자.

walk2(
  urls, path,
  download.file %>% 
    delay_by(0.1) %>% 
    dot_every(10),
  quiet = TRUE
)

여기서 저자가 함수 이름을 잘 정해놨기 때문에, pipe는 잘 작동한다.
코드의 의도를 함수 이름을 통해 더 쉽게, 더 명확하게 표현할 수 있다.

(사견: 원래 사이트를 보면, dot_every()delay_by()의 순서가 다르게 되어있다.
그러면 의미가 10개마다 점을 찍고, 그 때에만 delay를 넣으라는 것 아닌가?

위처럼 써야 매 다운마다 delay를 넣고, 10개마다 점을 찍으라는 뜻이 되는데...
이게 11.3.1 Exercises의 첫 번째 문제인 것으로 보아, 저자도 인지하고 있는 것 같다.)

11.3.1 Exercises