트랙
/
R
R
/
학습 과정
/
함수형 프로그래밍
함수

함수형 프로그래밍 에서 R

1개의 연습 문제

함수형 프로그래밍 소개

함수형 프로그래밍이란 무엇일까요?

답이 아주 긴 질문이에요. (위키백과에도 답이 있고, Advanced R에도 답이 있어요.)

우리 목적에서 핵심이 되는 특징은 이래요:

  • 순수 함수: 입력을 출력으로 일관되게 매핑하고, 부수 효과가 없어요.
  • 불변 데이터.
  • 일급 함수: 다른 타입처럼 값으로 다룰 수 있어요.
  • 고차 함수: 다른 함수를 인자로 받거나 반환값으로 돌려줘요.

하지만 R은 (예를 들어 Haskell과 달리) 순수 함수형 언어는 아니에요.

R은 실용적인 언어로 봐요. 어떤 작업을 깔끔하고 쉽게 끝내는 데 함수형 기능이 도움이 된다면, 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"

이런 파이프는 v4.1.0에서 Base R에 추가됐고, "네이티브 파이프"라고 부르는 걸 볼 수 있어요.

예전에는 magrittr 패키지의 %>%로 파이프를 했어요(이름은 예술을 사랑하는 사람들을 위한 말장난이에요). "magrittr 파이프"도 여전히 쓸 수 있지만, 네이티브 파이프는 외부 패키지가 필요 없고, 구현도 몇몇 부분에서 더 단순해요.

RStudio 편집기(다른 편집기들도 마찬가지예요)에서는 Ctrl-Shift-M 단축키로 알맞은 공백과 함께 파이프를 넣을 수 있어요.

apply 함수

앞에서 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에는 다양하고 많은 함수가 있어서 간단히 요약하기 어려워요. 라이브러리를 살펴보는 데 도움이 되는 자료는 이래요:

Note

purrr 라이브러리는 거의 전부 1차원 데이터, 즉 벡터와 배열에서 동작해요.

나중에 다룰 개념에서 dplyr 라이브러리가 2차원 데이터프레임에 같은 기능을 제공한다는 걸 보게 될 거예요.

다양한 map 함수

map() 함수는 입력의 각 원소에 함수를 적용하고, 항상 배열을 반환해요.

지금까지 들으면, 이름만 바꾸고 오류 메시지만 더 나아진 lapply() 같아요.

하지만 map()은 거대한 가족의 구성원 하나일 뿐이에요.

함수를 고를 때 생각해 볼 질문들이에요:

  • 무엇을 반환하고 싶나요? map()은 항상 배열을 반환하지만, 특정 타입의 원자 벡터를 원한다면 문자열에는 map_chr(), 정수에는 map_int() 등을 써요.
  • 입력의 각 위치 인덱스가 필요한가요? 함수 이름 앞에 i를 붙여요. 예를 들어 imap()처럼요.
  • 여러 입력을 병렬로 처리하고 싶나요?
    • 이름에서 map을 map2로 바꿔요. 두 입력(예: 값과 가중치, 가중 평균을 계산할 때)에는 map2_dbl()처럼요
    • 병렬 입력들의 배열에는 pmap으로 바꿔요. pmap_chr()처럼요. 그리고 네, 하드웨어 수준의 병렬 처리도 가능해요.
  • 입력의 모든 원소를 처리하고 싶나요? 아니라면, 조건자(불리언) 필터를 적용하는 map_if(), 위치 인덱스 기준을 지정하는 map_at(), 어떤 원소가 조건자를 통과하지 못할 때까지 입력을 처리하는 head_while()을 생각해 봐요.
  • 입력 데이터가 깊게 중첩되어 있나요? 그런 상황을 위한 함수들이 있어요.
  • 반환값이 항상 입력과 같은 타입이길 원하나요? map 대신 modify() 함수를 쓰거나, 재귀 적용에는 modify_tree()를 써요.

이 함수들은 파이프와 잘 동작하도록, 가능할 때마다 입력 데이터를 첫 번째 인자로 받아요.

또한 purrr는 (의도적으로) Base R보다 더 깐깐해요. 특정한 것을 요청하면, 함수는 정확히 그것을 반환하거나 오류 메시지를 내고 멈춰요. 가혹하지만, 찾기 어려운 버그를 줄이는 데 아주 효과적이에요.

reduce 함수

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

흔한 accumulate 유형 몇 가지에는 전용 함수가 있어요. 벡터 함수 개념에서 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()를 비롯해 여러 가지가 있어요.

또한 논리값 하나를 반환하는 all()과 any()를 대신할 every(), some(), none()도 있어요.

재귀

대부분의 함수형 언어는 루프를 대신할 방법으로 재귀를 선호해요.

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에서 편집 링크가 새 창이나 탭에서 열려요

함수형 프로그래밍 배우기