환경과 스코프 - 클로저가 값을 기억하는 방법
이 장에서 배우는 것
편의점의 판매 금액을 계산하는 함수는 짧게 만들 수 있다. 그러나 지점마다 할인율이 다르고, 재고 보충 기준도 다르다면 함수에 어떤 값을 전달하고 어떤 값을 남겨 둘지 결정해야 한다. 모든 계산에 할인율과 기준을 다시 전달할 수도 있지만, 지점별 설정을 기억하는 함수를 만들어 두면 사용하는 쪽의 코드가 간결해진다. 이 장에서는 함수가 만들어진 장소와 이름을 찾는 규칙을 살펴보고, 이를 이용해 지점마다 다른 설정을 가진 계산 함수를 만든다.
기본서에서 배운 함수의 인자와 반환값은 그대로 사용한다. 새로 살펴볼 부분은 함수가 반환된 뒤에도 남아 있는 환경과 그 안의 값이다. 같은 함수 본문을 사용하더라도 어느 환경을 가지고 있느냐에 따라 계산 결과가 달라질 수 있다.
- 렉시컬 스코프(lexical scope)에 따라 함수가 이름을 찾는 순서를 설명한다.
- 환경(environment)에 값을 저장하고, environment()와 parent.env()로 함수의 환경 관계를 확인한다.
- 클로저(closure)를 이용해 지점별 할인율과 재고 보충 기준을 기억한다.
- <<-가 수정할 대상을 찾는 방식과 전역 상태를 바꿀 위험을 구분한다.
- 함수 팩토리(function factory)로 설정이 다른 함수를 만들고 서로 독립적인지 검사한다.
문제 상황
동네 편의점 체인에는 골목 지점과 역앞 지점이 있다. 두 지점은 같은 상품을 팔지만 행사 조건은 다르다. 골목 지점은 판매 금액의 10%를 할인하고, 역앞 지점은 20%를 할인한다. 재고 보충 기준도 다르다. 골목 지점은 상품별 재고가 5개보다 적으면 보충 대상으로 보고, 역앞 지점은 8개보다 적으면 보충 대상으로 본다.
처음에는 discount_rate라는 변수를 하나 만들고 모든 함수에서 이 값을 읽게 할 수 있다. 하지만 역앞 지점을 계산하려고 이 변수를 바꾸면 골목 지점 계산에도 영향을 줄 수 있다. 보고서를 다시 만드는 순서에 따라 결과가 달라지는 셈이다. 지점별 설정은 계산할 때마다 우연히 선택되는 값이 아니라, 해당 지점의 계산 함수에 연결된 값이어야 한다.
이 장에서 만들 코드 묶음은 판매 자료 여섯 행으로 시작한다. 한 행은 한 지점에서 판매한 한 상품을 나타낸다. 판매 수량과 단가로 할인 전 금액을 계산하고, 지점별 함수로 할인 후 금액과 보충 대상 상품 수를 구한다. 상품별 할인 배분이나 세금 계산은 포함하지 않는다. 재고는 판매 후 확인한 수량이라고 가정한다.
판매 금액을 계산하는 함수는 설정을 읽기만 한다. 별도로 만든 호출 횟수 함수는 값을 바꾼다. 두 사례를 나란히 두면 값을 기억하는 것과 값을 수정하는 것이 서로 다른 설계 선택임을 확인할 수 있다.
함수는 만들어진 환경에서 이름을 찾는다
함수 본문에 나온 이름을 해석할 때 R은 먼저 현재 함수 호출의 실행 환경을 살펴본다. 인자와 함수 안에서 만든 지역 변수가 여기에 놓인다. 그곳에 이름이 없으면 함수가 만들어질 때 연결된 환경으로 이동하고, 다시 그 환경의 부모를 따라간다. 이처럼 코드가 정의된 환경 관계를 기준으로 이름을 찾는 규칙이 렉시컬 스코프다.
다음 함수는 인자 total은 현재 호출에서 찾고, rate는 함수가 만들어진 환경에서 찾는다. 함수를 호출하는 쪽에 같은 이름의 변수가 있다고 해서 그 변수가 자동으로 선택되지는 않는다.
rate <- 0.10
net_amount <- function(total) {
total * (1 - rate)
}
calculate_elsewhere <- function() {
rate <- 0.50
net_amount(10000)
}
calculate_elsewhere()
# 9000
net_amount는 최상위에서 만들어졌으므로 그 함수의 포함 환경은 전역 환경이다. calculate_elsewhere 안의 rate는 별도 호출 환경에 있는 지역 변수다. net_amount가 참조하는 환경 사슬에 이 호출 환경이 들어 있지 않으므로 50% 할인은 적용되지 않는다.
여기서 주의할 점이 있다. 함수가 만들어진 환경을 기억한다는 말은 그 환경에 있는 모든 값을 생성 시점에 복사한다는 뜻이 아니다. 위 코드 뒤에서 전역 변수 rate를 0.20으로 바꾸면 net_amount(10000)은 8000을 반환한다. 함수는 전역 환경과의 연결을 유지하고, 호출할 때 그곳에 있는 rate를 읽는다.
따라서 지점별 할인율을 유지하려면 전역 변수 하나를 함께 읽게 하는 대신 각 함수가 사용할 환경을 따로 마련해야 한다. 이때 환경은 단순한 변수 저장소가 아니라 이름 탐색의 경로를 구성하는 객체가 된다.
| 이름의 종류 | 처음 살펴볼 위치 | 예제의 이름 |
|---|---|---|
| 함수 인자 | 현재 호출의 실행 환경 | total |
| 함수 내부의 지역 변수 | 현재 호출의 실행 환경 | calculate_elsewhere의 rate |
| 본문에서 참조하는 바깥 변수 | 포함 환경과 그 부모 사슬 | net_amount가 읽는 rate |
환경은 이름과 값의 연결을 담는 객체다
환경에는 이름과 값의 연결인 바인딩(binding)이 들어 있다. new.env()로 환경을 만들고, $로 값을 넣거나 읽을 수 있다. 리스트와 비슷한 문법을 사용하지만 수정이 전달되는 방식은 다르다. 두 변수가 같은 환경을 가리키면 한쪽에서 바꾼 내용이 다른 쪽에서도 보인다.
settings <- new.env(parent = emptyenv())
settings$rate <- 0.10
same_settings <- settings
same_settings$rate <- 0.20
settings$rate
# 0.2
same_settings에 settings를 대입해도 독립된 설정 저장소가 만들어지지는 않는다. 두 이름이 같은 환경 객체를 가리킨다. 환경을 복사된 설정 목록처럼 다루면 여러 계산이 같은 상태를 공유하게 된다. 독립된 저장소가 필요할 때는 new.env()를 각각 호출해야 한다.
환경에는 부모 환경이 하나 연결되어 있다. parent.env(settings)는 이 연결을 반환한다. 예제에서 부모로 지정한 emptyenv()는 더 올라갈 부모가 없는 빈 환경이다. 여기서는 값을 $로 직접 읽으므로 부모를 통한 함수 탐색이 필요하지 않다. 계산 함수를 이런 환경에 연결하면 +나 * 같은 함수조차 찾지 못할 수 있으므로, 빈 환경을 모든 환경의 부모로 선택하는 것은 적절하지 않다.
environment(함수)는 그 함수의 포함 환경을 반환한다. 함수 안에서 인자 없이 environment()를 호출하면 현재 실행 환경을 얻는다. 두 사용법은 이름이 같지만 관찰하는 대상이 다르다. 완성 코드에서는 environment(pricers$alley)로 할인 함수가 보관하는 환경을 확인한다.
parent.env()가 알려 주는 부모는 이름 탐색을 위한 관계다. 누가 현재 함수를 호출했는지를 알려 주는 관계와는 다르다. 포함 환경, 실행 환경, 호출자의 환경을 같은 것으로 생각하면 이름이 어디서 선택되는지 설명하기 어려워진다.
환경의 내용을 확인할 때는 탐색 범위도 명시할 수 있다. exists("rate", envir = e, inherits = FALSE)는 e 자체에 rate라는 이름이 있는지만 확인한다. inherits를 생략하면 부모 환경까지 검색하므로, 해당 환경에 저장된 설정을 검사하려는 목적과 달라질 수 있다.
e <- new.env(parent = globalenv())
e$threshold <- 5L
exists("threshold", envir = e, inherits = FALSE)
# TRUE
parent.env(e)
# 전역 환경을 반환한다.
환경을 화면에 그대로 출력하면 실행마다 달라질 수 있는 식별 정보가 나타날 수 있다. 완성 코드에서는 환경의 출력 표현 대신 부모 관계와 이름의 존재 여부를 TRUE 또는 FALSE로 출력한다. 이렇게 하면 실행 결과를 비교하기 쉽다.
클로저와 함수 팩토리로 지점 설정을 기억한다
일반적인 R 함수는 인자 목록, 본문, 포함 환경을 함께 가진 클로저다. 이 장에서 특히 활용하는 형태는 함수 안에서 다른 함수를 만들고 반환하는 형태다. 바깥 함수의 실행이 끝나도 반환된 함수가 그 실행 환경을 참조하면, 환경에 있는 설정을 계속 사용할 수 있다.
make_pricer <- function(rate) {
force(rate)
function(total) {
round(total * (1 - rate))
}
}
alley_pricer <- make_pricer(0.10)
station_pricer <- make_pricer(0.20)
alley_pricer(10000)
# 9000
station_pricer(10000)
# 8000
make_pricer를 호출할 때마다 새로운 실행 환경이 만들어진다. 첫 호출의 환경에는 0.10인 rate가, 두 번째 호출의 환경에는 0.20인 rate가 놓인다. 반환된 두 함수의 본문은 같지만 포함 환경은 다르다. 이 구조가 지점별 설정을 분리한다.
force(rate)는 인자를 그 자리에서 평가한다. R의 인자는 보통 필요한 시점까지 평가를 미루는 약속(promise)으로 전달된다. 팩토리에 외부 변수 이름을 넘긴 뒤 그 변수를 바꾸면, 아직 평가되지 않은 인자가 나중에 달라진 값을 읽을 수 있다. 설정을 받은 시점의 값으로 확정하려고 force()를 사용한다.
force()가 환경이나 다른 참조형 객체를 독립적으로 복사하는 것은 아니다. 이 장의 설정은 길이 1인 수치이므로 인자를 평가해 값으로 확정하는 것으로 목적을 충족한다. 환경 자체를 설정으로 전달하는 설계라면 그 환경을 공유할지 따로 만들지 추가로 결정해야 한다.
<<-는 현재 호출 바깥의 값을 수정한다
지금까지의 할인 함수는 rate를 읽기만 했다. 호출 횟수를 기억하려면 저장된 값을 수정해야 한다. 다음 팩토리는 n을 만든 뒤, n을 하나씩 늘리는 함수를 반환한다.
make_counter <- function() {
n <- 0L
function() {
n <<- n + 1L
n
}
}
반환된 함수 안에서 n <- n + 1L을 사용하면 그 호출의 지역 변수 n이 만들어진다. 다음 호출은 다시 바깥 환경의 0에서 시작하므로 계속 1을 반환한다. <<-는 현재 실행 환경을 건너뛰고 바깥 환경에서 기존 n을 찾아 그 연결을 수정한다. 여기서는 make_counter 호출 환경의 n을 바꾸므로 다음 호출에서도 증가한 값을 읽는다.
그러나 <<-가 언제나 바로 위 환경에 값을 넣는 것은 아니다. 기존 이름이 없으면 더 바깥으로 탐색하며, 찾지 못하면 전역 환경에 대입한다. 이름을 잘못 적거나 저장할 변수를 미리 만들지 않으면 의도하지 않은 전역 상태가 생길 수 있다. 잠긴 연결을 수정하려 하면 오류가 발생할 수도 있다.
이 장의 카운터는 바깥 함수에서 n을 먼저 만들고, 반환 함수가 수정하는 이름을 n 하나로 제한한다. 할인율과 재고 기준에는 <<-를 사용하지 않는다. 값이 변해야 하는지 먼저 판단하고, 변경이 필요할 때에만 그 대상과 범위를 좁힌다.
사실 확인이 더 필요하면 R의 환경 관련 문서, 대입 연산자 문서, 인자 평가 문서를 참고할 수 있다. 아래 코드와 그림은 이 장의 편의점 상황에 맞추어 구성했다.
완성 코드
아래 내용을 main.R로 저장한다. 외부 파일과 패키지는 필요하지 않다. 금액은 원 단위로 반올림하며, 재고가 기준과 같을 때는 보충 대상에 포함하지 않는다. 검사는 모두 stopifnot()으로 수행한다. 검사가 통과하면 별도 메시지는 나오지 않는다.
make_pricer <- function(rate) {
force(rate)
stopifnot(is.numeric(rate), length(rate) == 1L,
is.finite(rate), rate >= 0, rate <= 1)
function(total) {
round(total * (1 - rate))
}
}
make_reorder_rule <- function(threshold) {
force(threshold)
stopifnot(is.numeric(threshold), length(threshold) == 1L,
is.finite(threshold), threshold >= 0)
function(stock) {
stock < threshold
}
}
make_counter <- function() {
n <- 0L
function() {
n <<- n + 1L
n
}
}
sales <- data.frame(
branch = rep(c("alley", "station"), each = 3L),
product = rep(c("rice", "milk", "water"), times = 2L),
quantity = c(4L, 3L, 5L, 2L, 6L, 4L),
unit_price = c(1500L, 2000L, 1200L, 1500L, 2000L, 1200L),
stock = c(3L, 7L, 5L, 6L, 2L, 9L),
stringsAsFactors = FALSE
)
pricers <- list(
alley = make_pricer(0.10),
station = make_pricer(0.20)
)
reorder_rules <- list(
alley = make_reorder_rule(5L),
station = make_reorder_rule(8L)
)
branch_order <- c("alley", "station")
report <- data.frame(
branch = branch_order,
gross = numeric(2L),
net = numeric(2L),
reorder_items = integer(2L),
stringsAsFactors = FALSE
)
for (i in seq_along(branch_order)) {
key <- branch_order[i]
rows <- sales[sales$branch == key, , drop = FALSE]
gross <- sum(rows$quantity * rows$unit_price)
report$gross[i] <- gross
report$net[i] <- pricers[[key]](gross)
report$reorder_items[i] <- sum(reorder_rules[[key]](rows$stock))
}
alley_env <- environment(pricers$alley)
station_env <- environment(pricers$station)
parent_ok <- identical(parent.env(alley_env),
environment(make_pricer))
rate_local <- exists("rate", envir = alley_env, inherits = FALSE)
counter_a <- make_counter()
counter_b <- make_counter()
a_first <- counter_a()
a_second <- counter_a()
b_first <- counter_b()
stopifnot(
identical(report$gross, c(18000, 19800)),
identical(report$net, c(16200, 15840)),
identical(report$reorder_items, c(1L, 2L)),
!identical(alley_env, station_env),
identical(alley_env$rate, 0.10),
identical(station_env$rate, 0.20),
parent_ok, rate_local,
identical(c(a_first, a_second, b_first), c(1L, 2L, 1L))
)
cat("branch gross net reorder_items\n")
for (i in seq_len(nrow(report))) {
cat(sprintf("%s %.0f %.0f %d\n",
report$branch[i], report$gross[i],
report$net[i], report$reorder_items[i]))
}
cat(sprintf("stored_rates: %.2f %.2f\n",
alley_env$rate, station_env$rate))
cat(sprintf("factory_parent: %s\n", parent_ok))
cat(sprintf("rate_in_own_environment: %s\n", rate_local))
cat(sprintf("counter_a: %d %d\n", a_first, a_second))
cat(sprintf("counter_b: %d\n", b_first))
cat("checks: OK\n")
줄별 해설
첫 줄의 make_pricer는 계산 함수 자체가 아니라 계산 함수를 만드는 함수다. 두 번째 줄에서 rate를 평가한 뒤, 다음 두 줄에서 길이 1인 유한한 수치인지와 할인율 범위를 검사한다. 이 검사는 지점 함수를 만드는 순간에 수행된다. 반환된 함수를 매번 호출할 때 같은 설정을 다시 검사하지 않는다.
안쪽 function(total)은 total을 인자로 받는다. 본문의 rate는 이 안쪽 함수의 인자가 아니다. make_pricer 호출 환경에서 읽는 값이다. round()는 최종 금액을 원 단위로 반올림한다. R의 반올림은 정확한 중간값에서 짝수 쪽을 택하는 규칙을 사용하므로, 별도의 정산 규칙이 있다면 계산식을 그 규칙에 맞추어야 한다. 이 예제의 금액에는 소수 부분이 생기지 않는다.
make_reorder_rule도 같은 구조다. threshold는 설정이며 stock은 실행할 때 들어오는 자료다. stock < threshold는 논리 벡터를 반환한다. 기준이 5일 때 재고 5는 FALSE다. 반환값을 곧바로 합산하면 TRUE의 개수, 즉 보충 대상 상품 수를 얻는다.
make_counter의 n <- 0L은 팩토리를 호출할 때 한 번 실행된다. 안쪽 함수의 n <<- n + 1L은 호출할 때마다 실행된다. 마지막 n은 증가한 값을 반환한다. 정수 상수 0L과 1L을 사용하므로 예제의 반환값도 정수다.
sales를 만드는 부분에서 지점과 상품의 순서를 고정한다. quantity와 unit_price는 판매 금액 계산에 사용하고, stock은 보충 판단에 사용한다. 상품 이름은 행의 의미를 드러내기 위해 넣었다. 이 장의 보고서는 상품별 결과를 출력하지 않지만, 이후 코드 묶음을 확장할 때 상품을 구별할 수 있다.
pricers와 reorder_rules는 지점 이름을 키로 사용하는 리스트다. 각 원소를 만들 때 팩토리를 따로 호출하므로 설정 환경도 따로 생긴다. 리스트에 함수를 보관해 두면 pricers[[key]]처럼 지점 이름으로 계산 함수를 선택할 수 있다. 이 대괄호 표현은 함수 하나를 꺼내고, 바로 뒤의 괄호가 그 함수를 호출한다.
branch_order는 출력 순서를 정한다. 자료에 나타난 순서나 이름 정렬에 의존하지 않도록 골목 지점, 역앞 지점 순서를 직접 지정했다. report의 열은 결과를 받을 빈 벡터로 시작한다. 금액 열은 수치형이고 보충 대상 수는 정수형이다.
첫 번째 반복문은 지점별 행을 고른 뒤 quantity와 unit_price를 곱해 합산한다. drop = FALSE는 부분 자료가 데이터 프레임 형태를 유지하게 한다. 총액을 할인 함수에 넣고, 재고 벡터를 보충 함수에 넣어 보고서의 해당 행을 채운다.
alley_env와 station_env를 만드는 줄은 반환된 함수의 포함 환경을 얻는다. parent_ok는 골목 지점 설정 환경의 부모가 팩토리의 포함 환경과 같은지 확인한다. 이 프로그램에서는 팩토리가 최상위에서 정의되었으므로 그 부모는 전역 환경이다. 함수 호출이 만들어 낸 환경이 정의 환경과 연결되는 관계를 검사한 것이다.
rate_local은 부모를 검색하지 않고 골목 지점 환경 자체에 rate가 있는지 확인한다. 두 환경이 다른 객체인지 검사하는 줄과 함께 보면, 지점 설정이 전역 변수 하나에 의존하지 않고 각각 보관되어 있음을 확인할 수 있다.
counter_a와 counter_b는 별도 호출로 만든 함수다. A를 두 번 호출한 결과는 1과 2이고, B의 첫 호출 결과는 1이다. 같은 팩토리를 사용했다고 해서 상태까지 공유하지는 않는다. 반대로 counter_b <- counter_a라고 대입하면 같은 함수를 가리키므로 카운터 상태도 함께 사용한다.
stopifnot()은 계산값과 환경 관계를 함께 검사한다. 금액만 맞는지 확인하면 두 지점이 우연히 같은 전역 설정을 읽는 구조를 놓칠 수 있다. 환경이 분리되어 있는지와 카운터 상태가 독립적인지도 검사하는 이유다. 마지막 출력에서는 sprintf()로 금액의 소수 자릿수와 설정의 표시 자릿수를 고정한다.
실행 결과
터미널에서 main.R이 있는 디렉터리로 이동한 뒤 다음 명령을 실행한다. 난수를 사용하지 않으며, 환경의 식별 정보를 출력하지 않으므로 정상 실행의 출력은 아래와 같다.
Rscript main.R
branch gross net reorder_items
alley 18000 16200 1
station 19800 15840 2
stored_rates: 0.10 0.20
factory_parent: TRUE
rate_in_own_environment: TRUE
counter_a: 1 2
counter_b: 1
checks: OK
골목 지점의 세 상품은 할인 전 금액이 각각 6000원이므로 합계가 18000원이다. 여기에 10% 할인을 적용하면 16200원이다. 재고 3, 7, 5 가운데 기준 5보다 작은 값은 하나다. 역앞 지점의 할인 전 합계는 19800원이고, 20% 할인 후 금액은 15840원이다. 재고 6, 2, 9 가운데 기준 8보다 작은 값은 둘이다.
stored_rates는 각 함수가 가지고 있는 할인율을 확인한 결과다. 두 TRUE는 환경의 연결 관계와 설정의 저장 위치를 보여 준다. 카운터 출력은 한 함수의 연속 호출과 서로 다른 함수의 첫 호출을 비교한다. 마지막 checks: OK는 그 앞의 모든 검사가 통과한 뒤에만 출력된다.
실무에서 자주 틀리는 것
공통 전역 변수에 지점별 설정을 덮어쓴다
다음 코드는 두 함수가 모두 전역 환경의 rate를 읽는다. 함수 이름이 달라도 설정은 분리되지 않는다. rate를 바꾸고 나면 alley(10000)도 8000을 반환한다.
rate <- 0.10
alley <- function(total) total * (1 - rate)
rate <- 0.20
station <- function(total) total * (1 - rate)
각 팩토리 호출에 설정을 넘겨 환경을 따로 만든다. 함수의 이름보다 그 함수가 참조하는 환경이 중요하다.
alley <- make_pricer(0.10)
station <- make_pricer(0.20)
stopifnot(alley(10000) == 9000,
station(10000) == 8000)
설정 인자의 평가를 반환 이후까지 미룬다
다음 팩토리에서 rate는 함수 생성 시점에 사용되지 않는다. 따라서 외부 변수 campaign을 인자로 넘긴 뒤 값을 바꾸면 첫 계산이 바뀐 값을 사용할 수 있다. saved(10000)은 7000을 반환한다.
make_delayed_pricer <- function(rate) {
function(total) total * (1 - rate)
}
campaign <- 0.10
saved <- make_delayed_pricer(campaign)
campaign <- 0.30
saved(10000)
팩토리 안에서 설정을 평가한다. 이렇게 만든 함수는 이후 campaign의 재대입과 무관하게 받은 수치 설정을 사용한다.
make_fixed_pricer <- function(rate) {
force(rate)
function(total) total * (1 - rate)
}
campaign <- 0.10
saved <- make_fixed_pricer(campaign)
campaign <- 0.30
stopifnot(saved(10000) == 9000)
지역 대입만으로 상태가 누적된다고 생각한다
다음 함수의 n은 호출할 때마다 새로 만들어지는 지역 변수다. 오른쪽의 n은 바깥 환경에서 읽지만, 왼쪽의 n은 현재 호출에 저장된다. 두 번 호출해도 결과는 1과 1이다.
make_flat_counter <- function() {
n <- 0L
function() {
n <- n + 1L
n
}
}
바깥 환경에 미리 만든 n을 수정하도록 한다. 변경할 이름이 팩토리 안에 실제로 존재하는지 함께 확인해야 한다.
make_counter <- function() {
n <- 0L
function() {
n <<- n + 1L
n
}
}
count <- make_counter()
stopifnot(count() == 1L, count() == 2L)
<<-를 새 변수 생성용으로 사용한다
다음 함수는 자신이 소유한 저장소를 준비하지 않았다. 바깥에 last_total이 있으면 그것을 바꾸고, 없다면 전역 환경에 이름을 만든다. 호출한 곳의 지역 변수에 기록하는 동작으로 해석해서는 안 된다.
record_total <- function(total) {
last_total <<- total
total
}
기록이 필요하면 저장소를 명시적으로 만들고 접근 함수를 함께 반환할 수 있다. $ 대입은 지정한 환경의 연결을 수정하므로 어떤 저장소가 바뀌는지 코드에 드러난다. 아래 저장소의 부모가 빈 환경이어도 함수 자체의 포함 환경은 정상적인 팩토리 실행 환경이다.
make_recorder <- function() {
state <- new.env(parent = emptyenv())
state$last_total <- NA_real_
list(
record = function(total) {
state$last_total <- total
total
},
read = function() state$last_total
)
}
recorder <- make_recorder()
invisible(recorder$record(18000))
stopifnot(recorder$read() == 18000)
한눈에 보기
| 도구 또는 개념 | 하는 일 | 편의점 코드의 역할 | 주의할 점 |
|---|---|---|---|
| 렉시컬 스코프 | 정의 환경을 따라 이름을 찾는다 | rate와 threshold 선택 | 호출자의 지역 설정을 자동으로 읽지 않는다 |
| environment(f) | 함수의 포함 환경을 얻는다 | 지점별 설정 확인 | 현재 호출 환경과 구분한다 |
| parent.env(e) | 환경의 부모를 얻는다 | 이름 탐색 관계 확인 | 호출자 관계를 뜻하지 않는다 |
| new.env() | 새 환경 객체를 만든다 | 명시적인 상태 저장소 | 대입으로 같은 환경을 공유할 수 있다 |
| 함수 팩토리 | 설정을 가진 함수를 반환한다 | 지점별 계산 규칙 생성 | 독립성이 필요하면 따로 호출한다 |
| force() | 인자를 즉시 평가한다 | 수치 설정을 생성 시점에 확정 | 참조형 객체의 복사는 수행하지 않는다 |
| <<- | 바깥의 기존 연결을 찾아 수정한다 | 카운터의 n 증가 | 이름이 없으면 전역 대입이 생긴다 |
계산 함수는 입력 자료와 고정 설정을 사용하고, 상태를 바꾸는 함수는 변경 범위를 좁혀 설계한다. environment()로 내부 값을 직접 바꾸는 일은 기술적으로 가능하지만, 일반적인 사용 경로로 삼으면 설정 검사를 우회할 수 있다. 할인율을 변경해야 한다면 새로운 할인 함수를 만들어 교체하는 편이 이 장의 구조에 맞는다.
여기서는 base R만 사용했다. 환경, 포함 환경, <<-의 의미는 다른 데이터 처리 문법으로 바뀌지 않는다. 다음 장에서는 이렇게 만든 함수를 여러 입력에 적용하는 방법을 다룬다. 함수가 어떤 설정을 기억하는지 먼저 분명히 해 두면 여러 함수를 조합할 때도 결과를 추적하기 쉽다.
연습 문제
- 완성 코드의 make_pricer를 이용해 15% 할인 함수를 만든다. 할인 전 금액 24000원에 대한 결과와, 10% 할인 함수의 같은 입력에 대한 결과를 검사한다.
- make_reorder_rule(5L)로 만든 함수에 재고 c(0L, 4L, 5L, 8L)을 전달한다. 논리 벡터와 보충 대상 수를 적고 검사한다. 재고가 기준과 같을 때의 판단을 설명한다.
- make_counter()로 만든 함수 a를 b <- a로 대입한다. a(), b(), a() 순서의 결과를 예상하고 검사한다. b를 독립된 카운터로 만들려면 어느 줄을 바꾸어야 하는지 설명한다.
- 고정된 보충 기준 대신 목표 재고까지 채워야 할 수량을 반환하는 make_order_quantity(target)를 만든다. 목표는 길이 1인 유한한 음이 아닌 정수 값이어야 한다. 목표가 8이고 재고가 c(3L, 8L, 10L)일 때 c(5, 0, 0)을 반환하도록 검사한다.
정답과 해설
1. 서로 다른 할인율의 함수
discount15 <- make_pricer(0.15)
discount10 <- make_pricer(0.10)
stopifnot(
discount15(24000) == 20400,
discount10(24000) == 21600,
!identical(environment(discount15), environment(discount10))
)
할인율은 두 팩토리 호출의 실행 환경에 따로 놓인다. 할인율 변수 하나를 바꾸어 가며 계산할 필요가 없다. 마지막 검사는 계산 결과뿐 아니라 두 함수가 독립된 설정 환경을 갖는지도 확인한다.
2. 기준과 같은 재고의 판단
rule <- make_reorder_rule(5L)
flags <- rule(c(0L, 4L, 5L, 8L))
stopifnot(
identical(flags, c(TRUE, TRUE, FALSE, FALSE)),
sum(flags) == 2L
)
비교 연산자가 <이므로 재고 5는 보충 대상이 아니다. 기준 이하를 대상으로 삼으려면 <=로 바꾸어야 하지만, 그것은 구현상의 사소한 차이가 아니라 업무 규칙의 변경이다. 경계값을 포함한 검사는 이 차이를 드러낸다.
3. 함수 대입과 상태 공유
a <- make_counter()
b <- a
values <- c(a(), b(), a())
stopifnot(identical(values, c(1L, 2L, 3L)))
a <- make_counter()
b <- make_counter()
values <- c(a(), b(), a())
stopifnot(identical(values, c(1L, 1L, 2L)))
첫 번째 b는 a와 같은 포함 환경을 가진 함수를 사용한다. 따라서 n도 함께 수정한다. 두 번째 예제에서는 b를 만들 때 팩토리를 다시 호출했으므로 새로운 n이 생긴다. 함수에 다른 이름을 붙이는 것과 새로운 상태를 만드는 것은 다르다.
4. 목표 재고까지 채우는 함수
make_order_quantity <- function(target) {
force(target)
stopifnot(
is.numeric(target), length(target) == 1L,
is.finite(target), target >= 0,
target == floor(target)
)
function(stock) {
stopifnot(
is.numeric(stock), all(is.finite(stock)),
all(stock >= 0), all(stock == floor(stock))
)
pmax(target - stock, 0)
}
}
order_to_eight <- make_order_quantity(8L)
quantities <- order_to_eight(c(3L, 8L, 10L))
stopifnot(identical(quantities, c(5, 0, 0)))
target은 팩토리 생성 시점에 확정되고, stock은 실제 계산 때 검사한다. 목표에서 현재 재고를 뺀 값이 음수라면 추가 주문이 필요하지 않으므로 pmax()로 0을 선택한다. 재고가 목표를 넘었다고 해서 음수 주문량을 반환하지 않는다. 이 함수도 상태를 변경하지 않으며, 목표가 달라지면 팩토리를 다시 호출해 다른 함수를 만들 수 있다.