언어에 대한 연산(Computing on the language)

언어에 대한 연산(Computing on the language)

R은 서브루틴이 다른 서브루틴을 만들거나 수정하고, 그 결과를 언어 자체의 일부로 평가할 수 있는 프로그래밍 언어 계열에 속해요. 이번 장에서는 파싱된 표현식과 함수에 직접 접근해 이를 바꾸고 다시 실행하거나, 아예 처음부터 함수를 새로 만드는 데 쓰는 기능들을 하나씩 살펴볼게요.

출처: R 공식 매뉴얼

본문

R은 서브루틴이 다른 서브루틴을 만들거나 수정하고, 그 결과를 언어 자체의 일부로 평가하는 기능을 갖춘 프로그래밍 언어입니다. 이는 Lisp이나 Scheme 같은 "함수형 프로그래밍" 계열 언어와 닮았죠. 반면 FORTRAN이나 ALGOL 계열과는 정반대예요. Lisp 계열은 "모든 것이 리스트"라는 패러다임으로 이 특징을 극단까지 밀어붙입니다. 그래서 프로그램과 데이터 사이에 아무 구분이 없어요.

R은 적어도 수학 공식과 C 스타일 제어 구조에 익숙한 사람에게는 Lisp보다 훨씬 친근한 프로그래밍 인터페이스를 제공합니다. 하지만 그 내부 엔진은 사실 매우 Lisp에 가까워요. R은 파싱된 표현식과 함수에 직접 접근할 수 있게 해 주고, 그것을 바꾼 뒤 다시 실행하거나 아예 처음부터 완전히 새로운 함수를 만들 수도 있게 해 줍니다.

이런 기능의 표준적인 쓰임새는 꽤 여러 가지가 있어요. 표현식의 해석적 도함수를 계산하는 일, 혹은 계수 벡터에서 다항식 함수를 만들어내는 일 같은 것들이 대표적이죠. 그런데 이보다 더 근본적으로 R의 해석(interpreted) 부분이 돌아가는 방식 자체에 기여하는 쓰임새도 많습니다. 예컨대 함수를 다른 함수의 구성 요소로 재사용하는 데 꼭 필요한 경우가 있어요. 여러 모델링·플로팅 루틴에서 만들어지는 model.frame 호출이 바로 그런 예인데, 솔직히 그리 예쁘진 않아요. 또 다른 쓰임새는 그냥 유용한 기능에 우아한 인터페이스를 달아 주는 겁니다. sin(x) 같은 표현식으로 주어진 함수의 그래프를 그려주는 curve 함수나, 수학 표현식을 플로팅하는 기능이 좋은 예시죠.

이번 장에서는 언어에 대해 연산을 수행할 수 있게 해 주는 기능 모음에 대한 소개를 드릴게요.

6.1 언어 객체 직접 다루기

수정할 수 있는 언어 객체에는 세 종류가 있어요. 호출(call), 표현식(expression), 그리고 함수(function)입니다. 여기서는 우선 호출 객체(call object) 에 집중할게요. 이걸 가끔 "평가되지 않은 표현식(unevaluated expressions)"이라고 부르기도 하는데, 용어가 조금 혼란스러운 면이 있어요. 호출 객체를 얻는 가장 직접적인 방법은 표현식 인자를 넣고 quote를 쓰는 겁니다.

> e1 <- quote(2 + 2)
> e2 <- quote(plot(x, y))

인자는 평가되지 않아요. 결과는 그저 파싱된 인자 그대로예요. e1e2 객체는 나중에 eval로 평가할 수도 있고, 그냥 데이터처럼 다루며 조작할 수도 있습니다. e2 객체가 mode가 "call"인 이유는 금방 와 닿을 거예요. 인자 몇 개와 함께 plot 함수를 호출하는 형태를 담고 있으니까요. 그런데 사실 e1도 이항 연산자 +를 인자 둘로 호출하는 것과 정확히 똑같은 구조를 가집니다. 다음 예를 보면 그 사실이 명확하게 드러나요.

> quote("+"(2, 2))
2 + 2

호출 객체의 구성 요소는 리스트와 비슷한 문법으로 접근할 수 있고, 실제로 as.listas.call을 이용해 리스트와 서로 변환할 수도 있어요.

> e2[[1]]
plot
> e2[[2]]
x
> e2[[3]]
y

키워드 인자 매칭을 쓰면 그 키워드들을 리스트 태그로 사용할 수 있어요.

> e3 <- quote(plot(x = age, y = weight))
> e3$x
age
> e3$y
weight

앞선 예들에서 호출 객체의 모든 구성 요소는 mode가 "name"이었습니다. 호출 안의 식별자들은 그렇지만, 호출의 구성 요소는 상수(constant)일 수도 있어요. 상수는 어떤 타입이든 될 수 있는데, 단지 호출을 성공적으로 평가하려면 첫 번째 구성 요소가 함수여야 한다는 조건이 붙죠. 또 구성 요소가 다른 호출 객체일 수도 있는데, 그건 부분 표현식(subexpression)에 해당해요. mode가 name인 객체는 문자 문자열에서 as.name으로 만들 수 있습니다. 그래서 e2 객체를 이렇게 수정할 수도 있어요.

> e2[[1]] <- as.name("+")
> e2
x + y

부분 표현식이 그저 자기 자신이 호출인 하나의 구성 요소에 불과하다는 사실을 보여주기 위해 이 예를 볼게요.

> e1[[2]] <- e2
> e1
x + y + 2

입력에서 사용한 모든 그룹 괄호는 파싱된 표현식에 그대로 보존됩니다. 괄호는 인자를 하나 받는 함수 호출로 표현돼요. 그래서 4 - (2 - 2)는 프리픽스 표기로 "-"(4, "("("-"(2, 2)))가 됩니다. 평가할 때는 '(' 연산자가 그냥 자기 인자를 돌려주기만 해요.

이건 좀 아쉽긴 한데, 사용자 입력을 보존하면서도 최소 형태로 저장하고, 디파싱한 표현식을 다시 파싱하면 같은 표현식이 나오도록 보장하는 parser/deparser 조합을 만들기가 쉽지 않기 때문이에요.

실제로 R의 파서는 완벽하게 역변환(invertible)되지 않고, 디파서도 마찬가지입니다. 다음 예들이 그걸 보여줘요.

> str(quote(c(1,2)))
 language c(1, 2)
> str(c(1,2))
 num [1:2] 1 2
> deparse(quote(c(1,2)))
[1] "c(1, 2)"
> deparse(c(1,2))
[1] "c(1, 2)"
> quote("-"(2, 2))
2 - 2
> quote(2 - 2)
2 - 2

다만 디파싱된 표현식은 (반올림 오차 내에서) 원래 표현식과 동등한 값으로 평가되어야 합니다.

...내부 저장 방식에 대한 흐름 제어 구조 참고... Splus와의 비호환성 참고...

6.2 치환(Substitutions)

사실 앞 절에서처럼 표현식의 내부를 직접 뜯어고치는 일은 그리 자주 하지 않아요. 더 자주 쓰는 건 표현식을 얻어서 디파싱한 뒤, 예를 들어 플롯의 라벨로 사용하는 일이죠. plot.default의 시작 부분에서 그 예를 볼 수 있어요.

xlabel <- if (!missing(x))
    deparse(substitute(x))

이 코드 덕분에 plotx 인자로 준 변수나 표현식이 나중에 x축 라벨을 붙이는 데 쓰이게 됩니다.

이 역할을 하는 함수가 substitute인데, 표현식 x를 받아 형식 인자 x를 통해 전달된 표현식으로 치환해 줍니다. 이렇게 되려면 x가 자기 값을 만들어낸 표현식에 대한 정보를 담고 있어야 한다는 점을 기억해야 해요. 이건 R의 지연 평가(lazy evaluation) 방식과 관련이 있습니다(프로미스 객체 참고). 형식 인자는 사실 하나의 프로미스예요. 이 객체는 슬롯 세 개를 갖는데, 하나는 그걸 정의한 표현식, 하나는 그 표현식을 평가할 환경, 그리고 나머지 하나는 평가된 뒤의 값입니다. substitute는 프로미스 변수를 알아보고 그 표현식 슬롯의 값으로 치환해 줍니다. 함수 안에서 substitute를 호출하면 그 함수의 지역 변수도 치환 대상이 돼요.

substitute의 인자는 단순한 식별자일 필요가 없습니다. 여러 변수를 포함하는 표현식일 수도 있고, 그 경우 각 변수마다 치환이 일어나요. 게다가 substitute에는 변수를 찾아볼 환경이나 리스트를 지정하는 추가 인자도 있어요. 예를 들면 이렇습니다.

> substitute(a + b, list(a = 1, b = quote(x)))
1 + x

x를 치환하려면 따옴표로 감싸야 했음에 주목하세요. 이런 구성은 그래프에 수학 표현식을 넣는 기능과 함께 쓸 때 아주 요긴합니다. 다음 경우를 보시죠.

> plot(0)
> for (i in 1:4)
+   text(1, 0.2 * i,
+        substitute(x[ix] == y, list(ix = i, y = pnorm(i))))

중요한 점은 치환이 철저히 어휘적(lexical)이라는 거예요. 결과로 나온 호출 객체를 실제로 평가했을 때 말이 되는지에 대한 검사는 전혀 없습니다. substitute(x <- x + 1, list(x = 2))는 거리낌 없이 2 <- 2 + 1을 돌려줘요. 하지만 R의 일부 구성 요소들은 무엇이 말이 되고 되지 않는지 자기 나름의 규칙을 가지며, 실제로 그런 비정상 표현식이 유용할 때도 있어요. 예를 들어 "그래프 속 수학" 기능을 쓰다 보면 문법적으로는 맞지만 평가하면 의미가 없어지는 구성, 이를테면 '{}>=40*" years"' 같은 걸 자주 만나게 됩니다.

substitute는 첫 인자를 평가하지 않아요. 그러면 변수 안에 담긴 객체에 치환을 적용하고 싶을 때 문제가 생기죠. 해결책은 substitute를 한 번 더 쓰는 겁니다.

> expr <- quote(x + y)
> substitute(substitute(e, list(x = 3)), list(e = expr))
substitute(x + y, list(x = 3))
> eval(substitute(substitute(e, list(x = 3)), list(e = expr)))
3 + y

치환의 정확한 규칙은 이렇습니다. 첫 번째 인자의 파스 트리에 있는 각 심볼을 두 번째 인자와 맞춰보는데, 두 번째 인자는 태그가 붙은 리스트나 환경 프레임일 수 있어요. 심볼이 단순한 지역 객체라면 그 값이 들어가는데, 단지 전역 환경과 맞춰볼 때는 예외입니다. 심볼이 프로미스(보통 함수 인자)라면 프로미스의 표현식이 치환됩니다. 심볼이 대응되지 않으면 그대로 둡니다. 최상위 수준에서 치환을 위한 특별한 예외를 두는 건 분명 특이한 점이에요. 이건 S에서 물려받은 것인데, 아마도 그 수준에는 어떤 변수가 바인딩되어 있을지 제어할 방법이 없어서, 차라리 substitutequote처럼 행동하게 만드는 게 낫다고 판단했기 때문일 거예요.

프로미스 치환의 규칙은 S와 살짝 다릅니다. substitute를 쓰기 전에 지역 변수가 수정된 경우죠. R은 그 변수의 새 값을 사용하는데, S는 인자의 표현식을 무조건 사용해요. 단 그 인자가 상수인 경우는 예외입니다. 그 때문에 S에서는 f((1))f(1)과 아주 크게 달라질 수 있다는 특이한 결과가 생기죠. R의 규칙이 훨씬 깔끔합니다. 다만 지연 평가와 엮이면 어떤 이들에게는 뜻밖의 결과를 낳기도 해요. 이 예를 볼게요.

logplot <- function(y, ylab = deparse(substitute(y))) {
    y <- log(y)
    plot(y, ylab = ylab)
}

겉보기엔 단순해 보이지만, y 라벨이 지저분한 c(...) 표현식이 되어버리는 걸 발견하게 됩니다. 지연 평가 규칙 때문에 ylab 표현식의 평가가 y가 수정된 뒤에 일어나기 때문이에요. 해결책은 ylab을 먼저 평가하도록 강제하는 겁니다.

logplot <- function(y, ylab = deparse(substitute(y))) {
    ylab
    y <- log(y)
    plot(y, ylab = ylab)
}

이 상황에서 eval(ylab)을 쓰면 안 된다는 점을 기억하세요. ylab이 언어나 표현식 객체라면, 그걸 평가하게 되면 객체 자체도 평가가 되어버려요. quote(log[e](y)) 같은 수학 표현식이 전달되는 상황이라면 전혀 바람직하지 않죠.

substitute의 변형으로 bquote가 있습니다. 이건 일부 부분 표현식만 그 값으로 바꿔치기하는 데 쓰여요. 아까 본 예를 더 간결하게 쓸 수 있습니다.

plot(0)
for(i in 1:4)
   text(1, 0.2*i, bquote( x[.(i)] == .(pnorm(i)) ))

표현식은 인용(quote)되지만, .(...) 부분 표현식의 내용만은 그 값으로 치환됩니다. 값들을 다른 환경에서 계산하도록 하는 선택적 인자도 있어요. bquote의 문법은 LISP의 백쿼트(backquote) 매크로에서 빌려온 것입니다.

6.3 평가에 대해 더

eval 함수는 이 장 초반에 호출 객체를 평가하는 수단으로 소개됐죠. 하지만 그게 전부가 아니에요. 평가가 일어날 환경 을 지정하는 것도 가능합니다. 기본값은 eval이 호출된 평가 프레임이지만, 꽤 자주 다른 값으로 설정할 필요가 생겨요.

아주 흔한 경우, 관련 평가 프레임은 현재 프레임의 부모 프레임입니다(??? 참고). 특히 평가할 객체가 함수 인자의 substitute 연산 결과라면, 그 안에는 호출자에게만 의미가 있는 변수들이 들어 있게 돼요. 호출자의 변수가 피호출자(callee)의 어휘적 스코프에 있다고 기대할 이유가 전혀 없다는 점을 기억하세요. 부모 프레임에서의 평가가 자주 일어나기 때문에, eval(expr, sys.frame(sys.parent()))의 축약어로 eval.parent 함수가 존재합니다.

또 자주 마주치는 경우는 리스트나 데이터 프레임 안에서의 평가예요. 예를 들어 data 인자가 주어졌을 때 model.frame 함수와 함께 이런 일이 일어납니다. 일반적으로 모델 수식의 항들은 data 안에서 평가해야 하는데, 때로는 model.frame의 호출자에 있는 항목을 참조하기도 해요. 이런 점이 시뮬레이션 연구와 관련해 유용할 때가 있습니다. 그래서 이 목적을 위해서는 표현식을 리스트에서 평가하는 것뿐 아니라, 변수가 리스트에 없을 때 탐색이 이어질 인클로저(enclosure) 를 지정해야 합니다. 그래서 호출 형태가 다음과 같아요.

eval(expr, data, sys.frame(sys.parent()))

주어진 환경에서 평가하는 것은 실제로 그 환경을 바꿀 수도 있습니다. 특히 할당 연산자가 관련된 경우가 그렇죠.

eval(quote(total <- 0), environment(robert$balance)) # rob Rob

리스트에서 평가할 때도 마찬가지지만, 이때는 실제로 사본 위에서 작업하기 때문에 원래 리스트는 변하지 않아요.

6.4 표현식 객체의 평가

mode가 "expression"인 객체는 "표현식 객체" 절에서 정의합니다. 이들은 호출 객체의 리스트와 아주 비슷해요.

> ex <- expression(2 + 2, 3 + 4)
> ex[[1]]
2 + 2
> ex[[2]]
3 + 4
> eval(ex)
[1] 7

표현식 객체를 평가하면 각 호출을 차례로 평가하지만, 최종 값은 마지막 호출의 값이라는 점을 알아두세요. 이 점에서 복합 언어 객체 quote({2 + 2; 3 + 4})와 거의 동일하게 행동합니다. 그런데 미묘한 차이가 하나 있어요. 호출 객체는 파스 트리에서 부분 표현식과 구별할 수 없습니다. 그래서 부분 표현식이 평가되는 것과 똑같은 방식으로 자동 평가돼요. 반면 표현식 객체는 평가 중에 인식될 수 있고, 어떤 의미에서는 자기 인용 상태(quotedness)를 유지합니다. 평가기는 표현식 객체를 재귀적으로 평가하지 않아요. 위에서처럼 eval 함수에 직접 전달될 때만 평가됩니다. 그 차이는 이렇게 확인할 수 있어요.

> eval(substitute(mode(x), list(x = quote(2 + 2))))
[1] "numeric"
> eval(substitute(mode(x), list(x = expression(2 + 2))))
[1] "expression"

디파서는 표현식 객체를, 그것을 만들어내는 호출로 표현합니다. 숫자 벡터나 그 밖에 별도의 외부 표현이 없는 여러 객체를 다루는 방식과 비슷하죠. 하지만 이 때문에 살짝 혼란스러운 일이 생겨요.

> e <- quote(expression(2 + 2))
> e
expression(2 + 2)
> mode(e)
[1] "call"
> ee <- expression(2 + 2)
> ee
expression(2 + 2)
> mode(ee)
[1] "expression"

eee는 출력했을 때 똑같아 보이지만, 하나는 표현식 객체를 만들어내는 호출이고 다른 하나는 그 객체 자체입니다.

6.5 함수 호출 다루기

함수는 sys.call의 결과를 보면 자신이 어떻게 호출되었는지 알 수 있어요. 다음은 자기 자신의 호출을 그대로 돌려주는 함수 예입니다.

> f <- function(x, y, ...) sys.call()
> f(y = 1, 2, z = 3, 4)
f(y = 1, 2, z = 3, 4)

하지만 이건 디버깅이 아니라면 그리 유용하지 않아요. 호출을 해석하려면 함수 스스로 인자 매칭을 추적해야 하기 때문입니다. 예를 들어 두 번째 실제 인자가 첫 번째 형식 인자(위에서 x)로 매칭된다는 사실을 함수가 알아야 하죠.

더 자주 필요한 건 모든 실제 인자가 대응하는 형식 인자에 바인딩된 호출입니다. 이를 위해 match.call 함수를 써요. 앞선 예의 변형으로, 인자가 매칭된 자기 호출을 돌려주는 함수를 보면 이렇습니다.

> f <- function(x, y, ...) match.call()
> f(y = 1, 2, z = 3, 4)
f(x = 2, y = 1, z = 3, 4)

이제 두 번째 인자가 x로 매칭되어 결과의 해당 위치에 나타나는 걸 볼 수 있어요.

이 기법의 주된 용도는 같은 인자로 다른 함수를 호출하되, 일부 인자를 지우거나 추가하는 겁니다. 전형적인 적용이 lm 함수의 시작 부분에 보여요.

    mf <- cl <- match.call()
    mf$singular.ok <- mf$model <- mf$method <- NULL
    mf$x <- mf$y <- mf$qr <- mf$contrasts <- NULL
    mf$drop.unused.levels <- TRUE
    mf[[1]] <- as.name("model.frame")
    mf <- eval(mf, sys.frame(sys.parent()))

결과로 나온 호출은 부모 프레임에서 평가되는데, 그곳에서야 관련 표현식들이 말이 된다고 확신할 수 있어요. 이 호출은 리스트 객체처럼 다룰 수 있는데, 첫 번째 요소는 함수 이름이고 나머지 요소는 실제 인자 표현식이며 대응하는 형식 인자 이름이 태그로 붙어 있습니다. 그래서 원하지 않는 인자를 제거하는 기법은 2번째·3번째 줄처럼 NULL을 할당하는 것이고, 인자를 추가할 때는 4번째 줄처럼 태그가 붙은 리스트 할당을 사용합니다(여기서는 drop.unused.levels = TRUE를 전달). 호출할 함수의 이름을 바꾸려면 리스트의 첫 번째 요소에 할당하되 그 값이 이름이 되도록 확실히 해야 해요. 여기서는 as.name("model.frame") 구성을 쓰거나, quote(model.frame)을 쓸 수 있습니다.

match.call 함수에는 expand.dots 인자가 있습니다. 이 스위치를 FALSE로 설정하면 모든 ... 인자를 태그가 ...인 단일 인자로 모아줘요.

> f <- function(x, y, ...) match.call(expand.dots = FALSE)
> f(y = 1, 2, z = 3, 4)
f(x = 2, y = 1, ... = list(z = 3, 4))

... 인자는 리스트(정확히는 페어리스트, pairlist)입니다. S에서는 list에 대한 호출이지만요.

> e1 <- f(y = 1, 2, z = 3, 4)$...
> e1
$z
[1] 3

[[2]]
[1] 4

이 형태의 match.call을 쓰는 이유 중 하나는, 단순히 어떤 ... 인자도 남기지 않기 위해서입니다. 그래야 알지도 못하는 함수에게 지정되지 않은 인자를 전달하지 않을 수 있죠. plot.formula에서 각색한 예를 볼게요.

m <- match.call(expand.dots = FALSE)
m$... <- NULL
m[[1]] <- "model.frame"

더 정교한 적용은 update.default에 있는데, 선택적 추가 인자 모음이 원래 호출의 인자를 더하거나, 대체하거나, 취소할 수 있습니다.

extras <- match.call(expand.dots = FALSE)$...
if (length(extras) > 0) {
    existing <- !is.na(match(names(extras), names(call)))
    for (a in names(extras)[existing]) call[[a]] <- extras[[a]]
    if (any(!existing)) {
        call <- c(as.list(call), extras[!existing])
        call <- as.call(call)
    }
}

extras[[a]] == NULL인 경우를 대비해 기존 인자를 하나씩 개별적으로 수정하는 데 조심을 기울인다는 점에 주목하세요. 보여준 것처럼 강제 변환(coercion)을 하지 않으면 c는 호출 객체에 대해 동작하지 않습니다. 이것은 논란이 있지만 버그로 볼 수 있어요.

함수 호출을 만드는 데 쓰는 함수가 두 개 더 있어요. 바로 calldo.call입니다.

call 함수는 함수 이름과 인자 리스트로부터 호출 객체를 만들어 줍니다.

> x <- 10.5
> call("round", x)
round(10.5)

보시다시피 호출에는 심볼이 아니라 x의 값이 들어갑니다. 그래서 round(x)와는 분명히 달라요. 이 형태는 좀처럼 쓰이지 않지만, 함수 이름이 문자 변수로 주어질 때 가끔 유용합니다.

do.call 함수는 이와 관련이 있는데, 호출을 즉시 평가하고 인자를 모든 인자를 담은 mode "list" 객체에서 가져옵니다. 자연스러운 쓰임새는 cbind 같은 함수를 리스트나 데이터 프레임의 모든 요소에 적용하고 싶을 때예요.

is.na.data.frame <- function (x) {
    y <- do.call(cbind, lapply(x, is.na))
    rownames(y) <- row.names(x)
    y
}

다른 쓰임새로는 do.call("f", list(...)) 같은 구성의 변형들이 있어요. 다만 이 방식은 실제 함수 호출 전에 인자를 평가한다는 점을 알아야 해요. 그 때문에 함수 내부의 지연 평가나 인자 치환 측면이 무너질 수 있습니다. call 함수에도 비슷한 주의가 적용돼요.

6.6 함수 다루기

함수나 클로저(closure)의 구성 요소를 조작할 수 있으면 참 유용할 때가 많아요. R은 이를 위한 인터페이스 함수 모음을 제공합니다.

  • body — 함수의 본문인 표현식을 돌려줍니다.
  • formals — 함수의 형식 인자 리스트를 돌려줍니다. 이것은 페어리스트예요.
  • environment — 함수와 연관된 환경을 돌려줍니다.
  • body<- — 함수의 본문을 주어진 표현식으로 설정합니다.
  • formals<- — 함수의 형식 인자를 주어진 리스트로 설정합니다.
  • environment<- — 함수의 환경을 지정한 환경으로 설정합니다.

또한 evalq(x <- 5, environment(f)) 같은 코드를 따라 함수 환경에 있는 여러 변수의 바인딩을 바꾸는 것도 가능합니다.

함수를 as.list로 리스트로 변환할 수도 있어요. 결과는 형식 인자 리스트와 함수 본문을 이어 붙인 것입니다. 반대로 그런 리스트는 as.function으로 함수로 변환할 수 있습니다. 이 기능은 주로 S 호환을 위해 포함된 것이에요. as.list를 쓰면 환경 정보가 사라지고, as.function에는 환경을 설정할 수 있게 해 주는 인자가 있다는 점을 기억하세요.

더 알아보기