함수형 프로그래밍이란 무엇일까요?
우리가 다룰 범위에서는 다음 특징들이 핵심이에요:
하지만 R은 순수 함수형 언어가 아니에요. 예를 들어 Haskell과는 달라요.
R을 실용적인 언어로 봐요. R은 함수형 기능이 어떤 일을 깔끔하고 쉽게 끝내는 방법이 될 때 (점점 더 많이) 그 기능을 제공해요. 일반적으로 목표는 데이터 분석이고, 프로그래밍은 그 목표를 이루기 위한 수단일 뿐이에요.
|> 파이프로 함수를 연결하는 방식은 여러 언어에서 점점 흔해지는 기법이에요.
간단히 말하면:
어떤 프로그래머들은 코드를 살펴볼 때 파이프를 'then'이라고 읽으면 도움이 된다고 해요: 1단계를 하고 THEN 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에 추가됐고, '네이티브 파이프'라고 부르기도 해요.
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 개념에 익숙한 학습자라면, 스칼라 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 함수도 정말 많이 있어요. 이런 혼란을 대신할 더 단순하고 일관된 대안을 제공하는 것이 Tidyverse의 핵심 목표예요.
이제 purrr 패키지가 있어서 map() 같은 여러 추가 옵션을 제공하면서, 설계의 일관성과 오류 메시지의 명확성도 높여줘요.
이 패키지는 Base R보다 하위 호환성 제약이 적어서, 다른 언어에서 볼 수 있는 현대적 함수형 프로그래밍에 R을 훨씬 가깝게 만들어줘요.
purrr에는 다양하고 많은 함수가 있어서 한마디로 요약하기 어려워요.
라이브러리를 살펴보는 데 도움이 되는 자료는 다음과 같아요:
map() 함수는 입력의 각 원소에 함수를 적용하고, 항상 배열을 반환해요.
지금까지 들으면 이름만 바꾼 lapply() 같고, 오류 메시지가 조금 더 나은 정도로 들려요.
하지만 map()은 거대한 함수 가족의 한 구성원일 뿐이에요.
함수를 고를 때 생각해볼 질문들이 있어요:
map()은 항상 배열을 반환하지만, 특정한 형태의 원자 벡터를 원한다면 문자열에는 map_chr(), 정수에는 map_int() 등을 써요.imap()처럼 함수 이름 앞에 i를 붙여요.map을 map2로 바꿔요. 두 입력(예: 값과 가중치로 가중 평균을 구할 때)에는 map2_dbl()처럼 써요.pmap으로 바꿔요. pmap_chr()처럼요. 그리고 네, 하드웨어 수준의 병렬 처리도 가능해요.map_if(), 위치 인덱스 조건을 지정하려면 map_at(), 어떤 원소가 술어를 만족하지 않을 때까지 입력을 처리하려면 head_while()을 써요.map 대신 modify() 함수를 쓰거나, 재귀적으로 적용하려면 modify_tree()를 써요.이 함수들은 파이프와 잘 맞도록, 가능하면 항상 입력 데이터를 첫 번째 인자로 받아요.
또한 purrr은 (의도적으로) Base R보다 더 깐깐해요. 구체적으로 요청하면 함수는 정확히 그걸 반환하거나, 오류 메시지를 내고 멈춰요.
가혹하지만, 찾기 어려운 버그를 줄이는 데 아주 효과적이에요.
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
흔히 쓰는 누적 유형에는 전용 함수가 있어요.
Vector Functions 개념에서는 Base R의 cumsum()과 cumprod()를 다뤘고, dplyr 라이브러리에는 수치 평균을 위한 cummean(), 논리형 벡터를 위한 cumall()과 cumany()가 있어요.
흔히 입력 중 어떤 술어에 맞는 원소만 포함하거나 제외하고 싶어요.
Vector Filtering 개념에서 이를 하는 관용적인 방법 하나를 봤어요.
v <- 1:3
v[v >= 2]
#> [1] 2 3
전통적인 R에서 많이 쓰이지만, 현대적인 함수형 파이프라인에는 잘 어울리지 않아요.
그래서 purrr은 술어 함수 모음을 제공해요. 이 함수들은 파이프와 함께 쓸 수 있도록 f(input, func, ...)라는 일반적인 인자 규칙을 따라요.
이름은 대부분의 다른 언어와 조금 달라요(R에서는 filter나 drop 같은 이름이 이미 전혀 다른 뜻으로 쓰여요).
이에 해당하는 purrr 함수로는 keep()과 discard()을 비롯해 여러 가지가 있어요.
또한 하나의 논리값을 반환할 때 all()과 any()를 대신할 every(), some(), none()도 있어요.
치즈 클럽을 시작하려고 해요. 이 클럽에서는 머신러닝을 이용해, 치즈를 좋아하는 고객의 이력과 취향을 바탕으로 새로 소개할 치즈를 골라요.
새로 가입하는 회원은 시작에 필요한 기본 데이터를 모으기 위해 먼저 간단한 설문을 작성해야 해요. 그 과정에서 비평에 미묘한 차이가 없는, 극단적인 고객 집단이 있다는 걸 알게 됐어요. 이런 고객 때문에 더 섬세한 알고리즘에 돌이킬 수 없는 편향이 생길 수 있어서, 이들을 처리할 별도의 알고리즘을 따로 만들어 두었어요. 이들의 데이터를 다루기 위한 몇 가지 도우미 함수를 만들어 주세요.
다음 과제를 푸는 방법은 여러 가지가 있겠지만, 각 과제는 고차 함수를 두 개 넘게 쓰지 않고 풀 수 있어요.
평점 시스템은 별 다섯 개를 기준으로 하고, 정수 1:5로만 이루어져 있어요. 극단적인 고객은 1 또는 5 평점만 주는데, 어떤 고객이 이런 성향을 보이는지 알고 싶어요.
all_15()를 구현해 보세요. 이 함수는 평점 벡터를 받아서, 모든 평점이 1 또는 5이면 true를, 그렇지 않으면 false를 반환해요.
all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE
all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE
고객 이름(또는 ID)을 평점과 연결해야 해요.
name_customers()를 구현해 보세요. 이 함수는 이름 벡터와 평점 리스트를 받아서, 둘을 짝지어 리스트의 리스트로 만들어요.
안쪽 리스트 원소에는 name과 rating이라는 이름이 붙어야 해요.
names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"
#> [[1]]$rating
#> [1] 2 3 5
#> [[2]]
#> [[2]]$name
#> [1] "c2"
#> [[2]]$rating
#> [1] 1 1 5
극단적인 고객을 나머지 고객과 분리해야 해요.
emphatics()를 구현해 보세요. 이 함수는 고객과 평점을 받아요.
1 또는 5 별점만 사용하는 고객만 담은 리스트를 반환해요.
# names and ratings as in the task 2 example
emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"
#> [[1]]$rating
#> [1] 1 1 5
극단적인 고객은 1과 5 평점만 사용하니까, 이걸 0과 1로 바꾸면 계산하기가 더 편해요.
to_binary()를 구현해 보세요. 이 함수는 극단적인 고객의 평점 벡터를 받아요.
1은 0으로, 5는 1로 바꾼 이진 평점을 반환해요.
to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0
고객의 취향을 더 잘 파악하게 되면서, 극단적인 고객뿐 아니라 모든 고객을 위한 추천을 계속 개선하고 싶어요.
satisfactions()를 구현해 보세요. 이 함수는 평점 벡터를 받아요.
지금까지의 누적 평균 평점을 담은 벡터를 반환해요. 결과는 소수점 둘째 자리까지 반올림해요.
행복한 고객을 위해, 이 숫자가 계속 커지기를 바라요!
satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
Exercism에 가입하고 R 트랙을 개념 21개연습 문제 111개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.