R 프로그래밍

함수 생성하기

학습 목표

  • 인자(argument)를 받는 함수를 작성한다.
  • 함수에서 값을 반환한다.
  • 함수를 테스트한다.
  • 콜 스택(call stack)이 무엇인지 설명하고 함수가 호출될때 콜 스택에 변경사항을 추적한다.
  • 함수 인자에 대한 디폴트 값을 설정한다.
  • 왜 프로그램을 작게, 단일 목적 함수로 잘게 쪼개는지 설명한다.

만약 분석할 데이터셋이 하나라면, 파일을 스프레드쉬트에 올려서 간단한 통계치를 구하고, 그래프를 그리는 것이 아마도 휠씬 빠르다. 하지만, 확인할 파일이 12개나 되고, 앞으로 더 늘어난다면 얘기는 달라진다. 이번 학습에서 함수를 어떻게 작성하는지 배워서 하나의 명령어로 작업 몇개를 반복처리할 수 있다.

함수 정의 하기

화씨(Fahrenheit)에서 절대온도(Kelvin)로 온도를 변환하는 fahr_to_kelvin 함수를 정의하는 것부터 시작하자:

fahr_to_kelvin <- function(temp) {
  kelvin <- ((temp - 32) * (5 / 9)) + 273.15
  return(kelvin)
}

fahr_to_kelvin 함수를 정의하고, function 출력결과를 함수에 할당한다. 인자이름 목록은 괄호에 포함된다. 다음에 함수 몸통부문(body)은 함수가 수행될 때 실행되는 문장(스테이트먼트, statement)으로 중괄호({}) 내부에 포함된다. 몸통부문의 문장은 공백 2개로 들여쓰기 된다. 들여쓰기는 코드를 읽기 쉽게 하지만, 코드가 어떻게 동작하는지에는 영향을 주지 않는다.

함수를 호출할 때, 함수에 전달하는 값은 변수에 할당되어서 함수 내부에서 사용할 수 있다. 함수 내부에, 반환 문장(return statement)을 사용해서 요청하는 곳에 결과를 반환한다.

상기 함수를 실행하자. 본인이 작성한 함수를 호출하는 것은 다른 어떤 함수를 호출하는 것과 별반 차이가 없다:

# 물에 대한 냉각점
fahr_to_kelvin(32)
[1] 273.15
# 물에 대한 끓는점
fahr_to_kelvin(212)
[1] 373.15

정의한 함수를 성공적으로 호출해서, 반환한 값에 접근할 수 있다.

함수 조합하기

화씨온도를 절대온도로 어떻게 변환하는지 봤기 때문에, 절대온도를 섭씨온도로 바꾸는 것은 쉽다:

kelvin_to_celsius <- function(temp) {
  celsius <- temp - 273.15
  return(celsius)
}

# 섭씨에서 영도
kelvin_to_celsius(0)
[1] -273.15

화씨온도에서 섭씨온도로 변환하는 것은 어떤가요? 공식을 적을 수도 있지만, 그럴 필요가 없다. 이미 작성한 두개의 함수를 조합(compose) 할 수 있다:

fahr_to_celsius <- function(temp) {
  temp_k <- fahr_to_kelvin(temp)
  result <- kelvin_to_celsius(temp_k)
  return(result)
}

# 섭씨온도로 물에 대한 냉각점
fahr_to_celsius(32.0)
[1] 0

어떻게 더 커다란 프로그램이 만들어지는지 첫번째로 맛을 봤다: 기본 연산을 정의하고, 함수를 조합해서 더큰 덩어리로 만들어 원하는 효과를 얻었다. 실제 함수는 상기 보여진 것보다 더 크다 – 일반적으로 대략 6줄에서 20~30줄 정도 한다. 하지만 이보다 함수가 더 길거나 하면, 함수를 읽는 사람이 어떻게 동작하는지 이해할수 없게 되면 곤란하다.

도전 과제 - 함수 생성하기

  • 마지막 학습에서 c 함수를 사용해서 벡터에 요소(element)를 결합(concatenate)하는 것을 배웠다. 예를 들어, x <- c("A", "B", "C") 문장은 요소 3개를 갖는 벡터 x를 생성한다. 좀더 나아가, c를 사용해서 상기 벡터를 확장할 수 있다. 예를 들어, y <- c(x, "D") 문장은 요소 4개를 갖는 벡터 y를 생성한다. originalwrapper 벡터 두개를 인자로 받는 fence 함수를 작성하세요. fence 함수는 original 앞과 뒤를 감싸는 새로운 벡터를 반환한다:
best_practice <- c("Write", "programs", "for", "people", "not", "computers")
asterisk <- "***"  # R은 단일 값을 갖는 변수를 요소 하나를 갖는 벡터로 해석한다.
fence(best_practice, asterisk)
[1] "***"       "Write"     "programs"  "for"       "people"    "not"      
[7] "computers" "***"      
  • 변수 v가 벡터를 참조한다면, v[1]은 벡터의 첫번째 요소이고, v[length(v)]은 벡터의 마지막 요소가 된다. 함수 length는 벡터의 요소 갯수를 반환한다. 입력값의 첫번째와 마지막 요소로만 구성된 벡터를 반환하는 outer 함수를 작성하세요.
dry_principle <- c("Don't", "repeat", "yourself", "or", "others")
outside(dry_principle)
[1] "Don't"  "others"

콜 스택(Call Stack)

fahr_to_celsius(32)을 호출할 때 무슨 일이 생기는지 좀더 자세히 살펴보자. 좀더 명확하기 하기 위해서, 변수에 초기값을 32로 설정하고 결과를 final에 저장해서 출발해 봅시다:

original <- 32
final <- fahr_to_celsius(original)

다음 다이어그램은 첫번째 행이 실행된 다음에 메모리가 어떻게 되지는 보여준다:

Call Stack (Initial State)

함수 fahr_to_celsius을 호출할 때, R은 변수 temp를 바로 생성하지는 않는다. 대신에 스택 프레임(stack frame)에 무언가를 생성해서, fahr_to_kelvin함수가 정의한 변수를 추적한다. 초기에 스택 프레임은 temp 값만을 가지고 있다:

Call Stack Immediately After First Function Call

fahr_to_celsius 함수 내부에 fahr_to_kelvin 함수를 호출할 때, R은 또 다른 스택 프레임을 생성해서 fahr_to_kelvin의 변수를 저장한다:

Call Stack During First Nested Function Call

이제 temp로 불리는 살아있는 변수가 두개 있다: 하나는 fahr_to_celsius 함수에 대한 인수이고, 다른 하나는 fahr_to_kelvin 함수에 대한 인수다. 프로그램의 같은 부분에 동일한 이름을 가진 변수 두개가 있는 것이 애매모호해서, R(그리고 다른 최신 프로그래밍 언어)은 각 함수 호출에 대해, 새로운 스택 프레임을 생성해서, 다른 함수에서 정의된 변수와 구별되게 함수의 변수를 보관한다.

fahr_to_kelvin 함수에 대한 호출이 값을 반환할 때, R은 fahr_to_kelvin 함수 스택 프레임을 사용한 후 버리고, 절대 온도 정보를 보관하기 위해서 fahr_to_celsius에 대한 스택 프레임에 새로운 변수를 생성한다:

Call Stack After Return From First Nested Function Call

그리고 나서 kelvin_to_celsius을 호출하는데, 함수의 변수를 저장할 스택 프레임을 생성한다는 의미다:

Call Stack During Call to Second Nested Function

다시 한번, R은 kelvin_to_celsius 함수가 수행완료될 때 스택 프레임을 폐기한다. 그리고 fahr_to_celsius 함수를 위해 스택 프레임에 result 변수를 생성한다:

Call Stack After Second Nested Function Returns

마지막으로, fahr_to_celsius 함수 수행이 완료될 때, R은 자신의 스택 프레임을 폐기하고 초기 시작한 스택 프레임에 있는 신규 변수 final에 결과값을 넣는다:

Call Stack After All Functions Have Finished

이 마지막 스택 프레임은 항상 존재한다; 작성한 코드 중에 함수 외부에서 정의한 변수를 간직한다. 간직하지 않는 것은 다양한 스택 프레임에 있었던 변수다. 만약 함수가 수행 종료된 후에 temp 값을 얻고자 한다면, R은 그런 것은 없다고 회답한다:

temp
Error in eval(expr, envir, enclos): object 'temp' not found

왜 이 모든 어려움으로 갈가요? 배열의 최대값과 최소값의 차이를 계산하는 span이라는 함수가 다음에 있다:

span <- function(a) {
  diff <- max(a) - min(a)
  return(diff)
}

dat <- read.csv(file = "data/inflammation-01.csv", header = FALSE)
# 염증 데이터에 대한 범위
span(dat)
[1] 20

span 함수는 값을 diff 변수에 할당함에 주목한다. 동일한 이름의 변수(diff)를 매우 잘 사용해서 염증 데이터 정보를 담을 수도 있다:

diff <- read.csv(file = "data/inflammation-01.csv", header = FALSE)
# 염증 데이터에 대한 범위
span(diff)
[1] 20

함수를 호출한 뒤에 변수 diff가 값 20을 갖게 되는 것이 예상되지 않는다. 그래서 (R이 전역 환경(global environment)으로 부르는) 프로그램 메인 몸통부문에서 하는 것처럼, 변수명 diffspan 내부에 정의된 동일한 변수를 참조할 수 없다. 이 경우에 변수에 diff와 다른 이름을 아마도 선택할 수 있지만, 변수의 값이 변경되는 경우마다, 무슨 변수명이 사용되었는지를 살펴보기 위해 호출하는 R함수의 모든 코드 행을 읽고 싶지는 않다.

기본적인 아이디어는 캡슐화(encapsulation)이고, 정확하고 이해하기 쉬운 프로그램을 작성하는 열쇠다. 함수가 하는 일은 몇개의 작업을 하나로 변환하는 것이어서, 무언가를 하고자 할 때마다 수십개에서 수백개의 문장을 수행하는 대신에, 함수 호출 단 하나를 생각한다. 함수가 서로에게 간섭하지 않는다면, 이 방식은 동작한다; 만약 서로 간섭하게 되면, 다시 한번 세부사항에 주의를 기울여야 하고, 이는 급격하게 단기 기억에 과부하를 주게된다.

도전 과제 - 콜 스택 따라가기

  • 이전에 fenceouter 함수를 작성했다. 다음을 실행할 때 콜 스택(call stack)이 어떻게 변하는지 다이어그램을 그려보세요:
inner_vec <- "carbon"
outer_vec <- "+"
result <- outside(fence(inner_vec, outer_vec))

테스팅과 문서화

함수에 명령어들을 넣어서 재사용할 수 있게 되면, 작성한 함수가 제대로 동작하는지 테스트할 필요가 있다. 수행을 어떻게 하는지 알아보기 위해, 데이터셋 중앙을 특정한 값 주위에 위치하게 하는 함수를 작성하자:

center <- function(data, desired) {
  new_data <- (data - mean(data)) + desired
  return(new_data)
}

실제 데이터에 작성한 함수를 바로 테스트할 수도 있으나, 값이 무엇이 되어야하는지 모르기 때문에, 결과와 부합되는지 구분하기가 어렵다. 대신에, 0으로 구성된 벡터를 생성하고, 3 주위가 중심이 되게 하자. 매우 간단하게 만들어서 함수가 예상한 대로 동작하는지 살펴보자:

z <- c(0, 0, 0, 0)
z
[1] 0 0 0 0
center(z, 3)
[1] 3 3 3 3

맞는 것처럼 보여서, 실제 데이터에 중심을 잡도록 하자. 염증 데이터의 4번째 날을 0 주위에 중심을 잡게 한다:

dat <- read.csv(file = "data/inflammation-01.csv", header = FALSE)
centered <- center(dat[, 4], 0)
head(centered)
[1]  1.25 -0.75  1.25 -1.75  1.25  0.25

결과가 맞는지 기본디폴트 상기 출력으로부터 분간하기 어렵다. 하지만, 확인을 할 수 있는 몇가지 테스트가 있다:

# 원래 최소값
min(dat[, 4])
[1] 0
# 원래 평균값
mean(dat[, 4])
[1] 1.75
# 원래 최대값
max(dat[, 4])
[1] 3
# 중심으로 변환한 최소값
min(centered)
[1] -1.75
# 중심으로 변환한 평균값
mean(centered)
[1] 0
# 중심으로 변환한 최대값
max(centered)
[1] 1.25

거의 맞는 것처럼 보인다. 원 평균은 약 1.75였다. 그래서 0에서 하한은 약 -1.75이다. 중앙값이 바뀐 데이터의 평균은 0이다. 좀더 나아가 표준편차가 바뀌었는지 확인하자:

# 원래 표준편차
sd(dat[, 4])
[1] 1.067628
# 중심으로 변환한 표준편차
sd(centered)
[1] 1.067628

두 값이 동일해 보인다. 하지만, 6번째 소수점에서 차이가 있다면 알아채지 못할 것이다. 대신에 다음을 수행하자:

# 변환 전과 후 표준편차 차이
sd(dat[, 4]) - sd(centered)
[1] 0

때때로, 매우 작은 차이가 소수점 아래에서 반올림 때문에 탐지될 수 있다. R에는 반올림 오차를 고려한 해서 두 객체를 비교하는 유용한 함수(all.equal)가 있다:

all.equal(sd(dat[, 4]), sd(centered))
[1] TRUE

함수가 잘못될 가능성은 여전히 있다. 하지만, 분석으로 되돌릴 정도는 아닐 듯 하다. 하지만, 한 가지 더 작업이 있다: 후에 작성한 함수가 무엇을 위한 것이고, 어떻게 사용하는지에 대해서 우리 자신에게도 되새김되도록 함수를 문서화(documentation)한다.

소프트웨어에 문서를 넣는 일반적인 방법은 다음과 같은 주석(comments)을 추가하는 것이다:

center <- function(data, desired) {
  # 원하는 값 주위로 원데이터에 대한 중심정보를 담고 있는 새로운 벡터를 반환한다.
  # 사용례: center(c(1, 2, 3), 0) => c(-1, 0, 1)
  new_data <- (data - mean(data)) + desired
  return(new_data)
}

도전 과제 - 더 고급 함수

  • 인자로 파일 이름을 받아서 앞선 학습 결과(시간에 따른 염증의 평균값, 최소값, 최대값)를 그래프로 화면에 출력하도록 하는 analyze 함수를 작성한다. analyze("inflammation-01.csv") 결과는 이미 보여진 그래프를 생성해야 하지만, analyze("inflammation-02.csv") 결과는 두번째 데이터셋에 상응하는 그래프를 생성해야 한다. 주석으로 함수를 문서화하도록 확인한다.
  • 입력값으로 벡터를 받고, 0에서 1사이의 범위로 조정되게 상응하는 벡터를 반환하는 rescale함수를 작성한다. 만약 LH가 원래 벡터 하한과 상한이라면, v의 치환값은 (v − L)/(H − L)이 되어야 한다. 주석으로 함수를 문서화하도록 확인한다.
  • min, max, plot 함수를 사용해서 rescale 함수가 정상적으로 동작하는지 테스트한다.

초기 설정(Default) 정의

두가지 방식으로 함수에 인자를 넘겼다: dim(dat)처럼 직접적으로, read.csv(file = "inflammation-01.csv", header = FALSE)처럼 이름으로 넘겼다. 사실 인자를 이름없이 read.csv 함수에 넘길 수 있다:

dat <- read.csv("data/inflammation-01.csv", FALSE)

하지만, 인자가 이름이 매칭되지 않는다면 인자 위치가 문제가 된다.

dat <- read.csv(header = FALSE, file = "data/inflammation-01.csv")
dat <- read.csv(FALSE, "data/inflammation-01.csv")
Error in read.table(file = file, header = header, sep = sep, quote = quote, : 'file' must be a character string or connection

어떻게 진행되고 있는지 이해하기 위해서, 작성한 함수를 좀더 사용하기 쉽도록, 다음과 같이 center 함수를 다시 정의하자:

center <- function(data, desired = 0) {
  # 원하는 값(기본설정으로 0) 
  # 주위로 원데이터에 대한 중심정보를 담고 있는 새로운 벡터를 반환한다.
  # 사용례: center(c(1, 2, 3), 0) => c(-1, 0, 1)
  new_data <- (data - mean(data)) + desired
  return(new_data)
}

변경된 주요사항은 두번째 인자가 이제 desired 대신에 desired = 0이 되었다. 두 인자를 갖는 함수를 호출하면, 전과 동일한 방식으로 동작한다:

test_data <- c(0, 0, 0, 0)
center(test_data, 3)
[1] 3 3 3 3

하지만, 단지 하나의 인자로 center() 함수를 호출할 수도 있다. 이 경우에 desired는 자동적으로 초기 설정값 0이 할당된다:

more_data <- 5 + test_data
more_data
[1] 5 5 5 5
center(more_data)
[1] 0 0 0 0

매우 편리하다: 만약 한 방식으로 동작하는 함수를 원하지만 때때로 다르게 동작시킬 필요가 있다면, 필요할 때만 인자를 넘기게 하는 방식으로, 초기 설정값을 넣어서 정상적인 경우를 좀더 쉽게 처리할 수 있다.

다음 예제는 어떻게 R이 인자에 값을 매칭하는지 보여준다:

display <- function(a = 1, b = 2, c = 3) {
  result <- c(a, b, c)
  names(result) <- c("a", "b", "c")  # This names each element of the vector
  return(result)
}

# 인자 없음
display()
a b c 
1 2 3 
# 인자 하나
display(55)
 a  b  c 
55  2  3 
# 인자 둘
display(55, 66)
 a  b  c 
55 66  3 
# 인자 셋
display (55, 66, 77)
 a  b  c 
55 66 77 

예제가 보여주듯이, 인자는 왼쪽에서 오른쪽으로 매칭된다. 그리고 명시적으로 값이 주어지지 않는 것은 초기 설정된 값을 갖는다. 인자를 넘길 때 값에 이름을 줌으로써 이런 행동을 치환(오버라이드, override)할 수 있다:

# c 값만 별도 설정
display(c = 77)
 a  b  c 
 1  2 77 

상기 내용을 가지고, read.csv() 함수 도움말을 살펴 보자:

?read.csv

많은 정보가 있지만, 가장 중요하는 부분은 처음 몇줄이다:

read.csv(file, header = TRUE, sep = ",", quote = "\"",
         dec = ".", fill = TRUE, comment.char = "", ...)

read.csv()는 하나의 인자 file만 초기 설정을 갖지 않고, 다른 인자 6개는 초기 설정값을 갖는 것을 나타낸다. 이제 왜 다음 명령문에 오류가 생성되는지 이해된다:

dat <- read.csv(FALSE, "data/inflammation-01.csv")
Error in read.table(file = file, header = header, sep = sep, quote = quote, : 'file' must be a character string or connection

FALSEfile에 할당되고, 파일명이 header 인자에 할당되기 때문에 실패한다.

도전 과제 - 디폴트 기본인자값을 갖는 함수

  • rescale 함수를 재작성해서 초기 설정으로 벡터를 0에서 1 사이에 놓게 한다. 하지만 호출자가 원한다면, 하한과 상한을 지정할 수 있게 한다. 옆 사람과 구현한 것을 비교한다. 두 함수가 항상 같은 방식으로 동작하나요?