Chapter 11 R 객체 지향 프로그래밍

11.1 객체 지향 프로그래밍

객체 지향 프로그램(Object-Oriented Programming(OOP))이 무엇인지, 그것이 왜 필요한지를 논의하려면 별도로 한 장이 할애되어야 할 것이다. 여기서는 객체 지향 프로그래밍의 개요에 대해서만 간단히 짚어본다.

OOP는 복잡한 프로그램을 구조화하는 방식에 대한 하나의 관점 또는 패러다임이라 할 수 있다. 간단한 프로그램은 어떠한 방식으로 작성하여도 이해하거나 수정이 어렵지 않다. 그러나 프로그램이 복잡해지면 프로그램을 적절한 단위로 구조화해야 프로그램 전체를 이해할 수 있고 유지보수가 가능해진다.

절차적 프로그래밍 vs. 객체 지향 프로그래밍

이러한 프로그램 구조화 패러다임 중 가장 오래된 방식이 절차적 프로그래밍(procedural programming) 패러다임이다. 절차적 프로그래밍은 프로그램을 기능적으로 유사한 부분으로 분절한다. 이러한 분절은 전체 프로그램을 기능에 따라 프로그램-모듈-함수/서브루틴/프로시저 등으로 계층적으로 구조화한다. 반복되어 사용되는 부분을 함수나 프로시저로 구현한 후, 필요할 때마다 해당 함수나 프로시저를 호출하여 사용할 수 있게 함으로써 비슷한 기능에 대한 구현이 프로그램 전체에 산재하여 유지보수하기 어렵게 되는 것을 막아 준다. 절차적 프로그래밍 방식을 비유적으로 말하자면, 어떤 복잡한 조직을 총무부, 인사부, 생산부, 자재부 등의 기능조직으로 나누어 구조화하는 방식과 비슷하다고 하겠다. 특화된 기능 조직이 비슷한 기능을 효율적으로 전담하여 반복수행하는 것처럼, 특화된 모듈과 함수는 비슷한 작업을 호출할 때마다 반복수행한다. 프로그래밍 언어와 프로그래밍 방식이 일 대 일로 대응되는 것은 아니지만, 절차적 프로그래밍 방식을 주로 사용하는 프로그래밍 언어로는 C 언어를 들 수 있다.

객체 지향 프로그래밍(OOP)는 복잡한 프로그램을 객체를 중심으로 구조화한다. 절차적 프로그래밍이 함수나 프로시저 등을 이용하여 기능 중심으로 프로그램을 분절한다면, OOP는 데이터를 중심으로 동일한 형식의 데이터를 처리하는 부분으로 프로그램을 분절한다. 이를 비유적으로 말하자면 절차적 프로그래밍이 기능 조직이어서 여러 사업과 관련된 동일한 기능이 하나의 기능 부서에 통합되어 있는 형식이라면, OOP는 사업부 조직으로서 하나의 사업이라는 내용을 중심으로 총무, 인사, 생산, 자재 등의 기능이 뭉쳐 있는 형식이라 할 수 있다. OOP를 따르는 주요 언어로는 C++, Java 등이 있다. R은 이러한 언어와는 조금 다르지만 기본적인 OOP 특성을 가지고 있다.

OOP의 기본 특징

OOP가 가져야 하는 기본 특징은 다음과 같다.

11.2 S3 클래스

R은 OOP 개념을 처음에는 S3 클래스로 구현하였다. 그러나 S3 클래스는 캡슐화 및 데이터 보안에 취약한 점이 있으므로 이를 강화할 수 있는 S4 클래스를 나중에 도입하였다. 그러나 아직까지 대다수의 R 패키지는 S3 클래스에 의해 OOP를 구현하고 있고, 아직도 많은 R 사용자가 S3 클래스로 개발하는 것을 선호하고 있다.

11.2.1 S3 클래스 객체 만들기

본질적으로 S3 클래스 객체란 R의 리스트 객체에 class 속성을 부여한 것뿐이다. 앞서 본 lm() 함수의 결과 객체 a는 lm 클래스 객체였다. 이 객체를 unclass() 함수를 이용하여 클래스 속성을 제외하면 단순한 리스트임을 확인할 수 있다.

S3 클래스 객체는 리스트에 class 속성 부여하여 만든다.

사용자도 리스트와 class() 함수를 이용하여 자신만의 S3 클래스를 만들 수 있다. 다음은 학생 관련 정보를 담는 student 클래스를 만든 예이다.

st1 <- list(name="Gildong", year=2, GPA=3.2)
class(st1) <- "student"
st1
$name
[1] "Gildong"

$year
[1] 2

$GPA
[1] 3.2

attr(,"class")
[1] "student"

student 객체를 출력해 보면 여느 리스트와 동일하게 각 요소를 출력하고, 마지막으로 부가 정보인 속성 정보를 출력하였다. 속성 정보는 오직 하나 class 속성만 있는 것을 확인할 수 있다. student 클래스는 print 메소드가 구현되어 있지 않았으므로, 객체가 출력될 때 객체의 타입인 리스트가 출력되는 형식으로 출력되었다.

11.2.2 포괄 함수(generic functions)

포괄 함수는 UseMethod()로 클래스에 따라 적절한 메소드를 호출한다.

포괄 함수란 print() 함수처럼 여러 클래스의 객체에 적용 가능한 함수를 의미한다. 포괄 함수는 인수로 주어진 객체의 클래스에 따라 클래스에 적합한 기능을 수행한다.

그러면 포괄 함수는 어떻게 객체에 따라 적절한 기능을 수행할 수 있는 것일까? 심지어 print() 함수 같은 포괄 함수는 자신이 구현된 이후에 새로운 클래스가 추가되어도 새로운 클래스에 적합한 기능을 수행할 수 있다. 포괄 함수는 사실 분배기의 역할만을 수행하기 때문에 이러한 기능이 가능한 것이다. 포괄 함수는 호출이 되면 UseMethod() 라는 분배 기능을 가지는 함수를 호출한다. 이 함수는 포괄 함수에 첫번째 인수로 부여된 객체의 클래스에 따라 포괄 함수를 대신하여 실행될 해당 클래스의 메소드를 호출한다. 그리고 포괄 함수는 해당 클래스의 메소드가 객체에 실행된 결과를 반환한다. 다음은 print() 함수가 호출되면 무엇이 수행되는지를 보여준다. UseMethod()가 호출되는 것을 볼 수 있다.

print
function (x, ...) 
UseMethod("print")
<bytecode: 0x556fd4222950>
<environment: namespace:base>

methods()

그러면 클래스별로 print 메소드는 어디에 있는 것일끼? methods() 함수를 이용하면 각 포괄 함수에 대해 구현된 모든 메소드를 확인할 수 있다. 다음은 그 중 일부만을 보여주고 있다.

head(methods(print))
[1] "print.acf"               "print.activeConcordance"
[3] "print.AES"               "print.anova"            
[5] "print.aov"               "print.aovlist"          

메소드 이름 규칙

메소드 함수는 다음과 같은 이름으로 정의되어 있다.

generic_function_name.class_name

따라서 data.frame 클래스의 print 메소드는 print.data.frame이 된다. 이 메소드를 data.frame 객체에 적용하나, 포괄 함수인 print를 data.frame 객체에 적용하나 동일한 결과를 얻는다. 사실 R 콘솔은 명령문을 평가한 후 자동으로 print() 함수를 실행하므로 클래스에 대한 print 메소드 구현은 매우 중요하다. 다음은 R 콘솔에서 print(f)나, print.data.frame(f)나, f는 동일한 결과를 주는 것을 보여준다.

print(f)
  X1.3 X6.8
1    1    6
2    2    7
3    3    8
print.data.frame(f)
  X1.3 X6.8
1    1    6
2    2    7
3    3    8
f
  X1.3 X6.8
1    1    6
2    2    7
3    3    8

앞에서 확인할 수 있듯이 지금까지 객체가 R 콘솔에 출력될 때, 해당 객체의 클래스의 print 메소드들이 적절히 호출되어 해당 클래스에 적합한 형식으로 데이터를 보여 주었던 것이다.

그러면 integer, character, list 등을 위한 메소드들도 있을까? grep() 함수로 해당 이름의 메소드를 찾아보면 그렇지 않음을 알 수 있다. 그러면 이 클래스의 객체들은 어떻게 출력이 이루어지는 걸까?

grep("integer", methods(print))
integer(0)
grep("character", methods(print))
integer(0)
grep("list", methods(print))
 [1]   6  40  97 106 130 141 194 207 209 220 247
methods(print)[grep("list", methods(print))]
 [1] "print.aovlist"                    "print.check_package_datalist"    
 [3] "print.Dlist"                      "print.dummy_coef_list"           
 [5] "print.htmltools.selector.list"    "print.listof"                    
 [7] "print.rlang:::list_of_conditions" "print.shiny.tag.list"            
 [9] "print.simple.list"                "print.summary.aovlist"           
[11] "print.xfun_strict_list"          

해당 클래스의 메소드가 없으면 UseMethod() 분배 함수는 default 메소드를 호출한다. default 메소드는 generic_function_name.default 형식으로 이름이 주어진다. print 포괄 함수의 경우 print.defualt 메소드이다.

디폴트 메소드

print.default
function (x, digits = NULL, quote = TRUE, na.print = NULL, print.gap = NULL, 
    right = FALSE, max = NULL, width = NULL, useSource = TRUE, 
    ...) 
{
    args <- pairlist(digits = digits, quote = quote, na.print = na.print, 
        print.gap = print.gap, right = right, max = max, width = width, 
        useSource = useSource, ...)
    missings <- c(missing(digits), missing(quote), missing(na.print), 
        missing(print.gap), missing(right), missing(max), missing(width), 
        missing(useSource))
    .Internal(print.default(x, args, missings))
}
<bytecode: 0x556fd278d410>
<environment: namespace:base>

보통 default 메소드는 데이터가 가지는 기본적 타입에 대해 적절한 포괄 함수 관련 처리를 수행하도록 구현되어 있다. print.default는 기본적인 데이터의 타입에 따라 적절한 출력을 보여준다.

앞서 우리는 student 클래스 객체를 만들었다. 그런나 print.student라는 메소드는 존재하지 않으므로 콘솔에서 해당 객체가 입력되었을 때, print.defualt가 student의 출력을 담당하였다. 따라서 student의 데이터 기본 타입인 list의 형식으로 데이터가 출력이 된것이다.

methods() 함수의 class 인수를 이용하면 해당 클래스를 위해 정의된 모든 메소드의 목록을 볼 수 있다. student 클래스를 위한 어떠한 메소드도 구현되지 않았음을 볼 수 있다.

methods(class="lm")
 [1] add1           alias          anova          case.names     coerce        
 [6] confint        cooks.distance deviance       dfbeta         dfbetas       
[11] drop1          dummy.coef     effects        extractAIC     family        
[16] formula        hatvalues      influence      initialize     kappa         
[21] labels         logLik         model.frame    model.matrix   nobs          
[26] plot           predict        print          proj           qr            
[31] residuals      rstandard      rstudent       show           simulate      
[36] slotsFromS3    summary        variable.names vcov          
see '?methods' for accessing help and source code
methods(class="student")
no methods found
print.default(st1)
$name
[1] "Gildong"

$year
[1] 2

$GPA
[1] 3.2

attr(,"class")
[1] "student"

메소드 정의하기

다음처럼 student 클래스를 위한 print 메소드를 만들어 보자. S3 클래스에서는 함수의 이름을 (포괄 함수명).(클래스명)으로 부여하면 된다. 그러면 UseMethod()가 해당 클래스를 위해 정의된 메소드를 호출해 준다.

print.student <- function(x) {
  cat(x$name, "\n")
  cat("year", x$year, "\n")
  cat("GPA", x$GPA, "\n")
}
methods(class="student")
[1] print
see '?methods' for accessing help and source code
print(st1)
Gildong 
year 2 
GPA 3.2 

위의 예에서 보듯이 student 클래스를 위한 print 메소드가 구현되었으므로 이제는 default 메소드가 아니라 student 클래스를 위한 print 메소드가 실행된다. 일반적으로 복잡한 클래스일수록 자신만의 print 메소드를 구현한다. 그래야 복잡한 내부 데이터를 모두 출력하지 않고 필요한 정보만 효율적으로 보여줄 수 있기 때문이다.

그리고 R을 처음 접한 사람들이 의아해 하는 사항이 있다. 그것은 대부분의 클래스에서 print 메소드보다는 summary 메소드가 더 자세한 정보를 출력한다는 것이다. 문자 그대로 해석한다면 print() 함수는 모든 내용을 출력해야 하고, summary() 함수는 좀 더 요약된 정보를 제공해야 할 것 같은데 말이다. lm 클래스도 print() 함수 보다는 summary() 함수가 훨씬 더 자세한 정보를 제공한다.

summary(a)

Call:
lm(formula = cars)

Residuals:
    Min      1Q  Median      3Q     Max 
-7.5293 -2.1550  0.3615  2.4377  6.4179 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  8.28391    0.87438   9.474 1.44e-12 ***
dist         0.16557    0.01749   9.464 1.49e-12 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 3.156 on 48 degrees of freedom
Multiple R-squared:  0.6511,    Adjusted R-squared:  0.6438 
F-statistic: 89.57 on 1 and 48 DF,  p-value: 1.49e-12

이러한 일이 발생하는 이유는 R에서 print() 함수는 R 콘솔에 사용자의 명령이 입력될 때마다 실행되는 함수이기 때문이다. 따라서 print() 함수의 결과가 자칫 필요 이상으로 길고 복잡해지면 R 콘솔 작업이 어려워질 수 있기 때문에 대부분의 클래스에서는 휠씬 요약된 정보만을 print 메소드가 제공하도록 하고 있다. 하지만 기본 데이타 타입의 경우는 print.defualt 메소드가 데이터의 내용을 가감 없이 보여주기 때문에 그러한 현상은 나타나지 않는다.

11.2.3 S3에서 상속성의 구현

상속성은 객체 지향 프로그래밍의 매력 중에 하나이다. 새로운 클래스를 만들 때 이전의 클래스를 상위 클래스로 하고 자신을 하위 클래스로 하면, 상위 클래스에 정의된 메소드들을 하위 클래스 객체가 이용할 수 있다.

상위 클래스와 하위 클래스

이 때 주의할 점은 하위 클래스 객체는 상위 클래스에 부분집합의 관계를 가져야 한다는 것이다. 예를 들어 MTB 자전거라는 하위 클래스를 자전거라는 상위 클래스를 상속받아 구현하였다면, MTB 자전거 객체는 역시 자전거 객체이기도 해야 한다는 것이다. 따라서 하위 클래스의 데이터 구조는 상위 클래스의 데이터 요소를 모두 포함하고 있어야 하고, 거기에 덧붙여 자신만의 데이터와 기능을 더 포함하는 구조이어야 한다.

상속성을 구현하는 방법

S3 클래스에서 상속성의 구현은 객체를 생성할 때 단지 class 속성에 하위 클래스 이름 다음에 상위 클래스 이름을 모두 나열하기만 하면 된다. 그러면 UseMethod() 분배 함수는 class 속성에 부여된 클래스 순으로 메소드를 찾아 실행한다. 따라서 하위 클래스를 위한 메소드가 없으면 다음에 정의되어 있는 상위 클래스의 메소드를 실행해 준다. 하위 클래스가 상속받은 상위 클래스가 또 다른 클래스를 상속 받았다면, class 속성에는 하위 클래스, 상위 클래스, 상위-상위 클래스 순으로 차례대로 기술해 주면 된다.

S3 클래스에서 상속성이 어떻게 수행되는지 다음 예로 살펴보자. 현재 student 클래스를 이용하여 대학생과 대학원생 정보를 모두 처리하고 있다고 하자. 그런데 대학원생의 경우에는 student에 있는 데이터뿐 아니라 지도교수(advisor)에 대한 정보가 추가되어 관리되는 것이 편리하다고 판단되어, 지도교수 정보를 포함하는 gradstudent 클래스를 정의하고자 한다. 다음 예에서 st2 객체를 생성할 때 리스트에 advisor 요소가 추가되었다. 그리고 class 속성에 먼저 하위 클래스 이름인 gradstudent, 그리고 상위 클래스 이름인 student가 기술되었다. 물론 아직은 gradstudent 클래스에 대한 어떠한 메소드도 존재하지 않는다.

st2 <- list(name="Gilsan", year=1, GPA=3.8, advisor="Sejong")
class(st2) <- c("gradstudent", "student")
methods(class="gradstudent")
no methods found

그러면 gradstudent 클래스인 st2를 출력해 보자. 그러면 UseMethod() 함수는 먼저 gradstudent 클래스를 위한 print 메소드를 찾는다. 해당 메소드가 없으므로 두번째로 기술되어 있는 student 클래스의 print 메소드를 찾는다. 해당 메소드가 있으므로 이 메소드로 출력을 수행한다. 이마저도 없으면 default 메소드가 수행될 것이다. 물론 student 클래스를 위한 print 메소드는 이미 구현되어 있기 때문에, 대학원생 정보인 st2도 student 클래스의 출력 형식에 맞추어 출력이 이루어진다. 따라서 gradstudent 객체는 student 클래스의 메소드를 상속 받았다고 볼 수 있다.

st2
Gilsan 
year 1 
GPA 3.8 

메소드 overiding

만약 gradstudent 객체의 출력에 지도 교수 정보도 같이 출력되기를 원한다면, 다음처럼 gradstudent 클래스의 print 메소드를 구현하면 된다. 이 경우 gradstudent 클래스만의 메소드가 상속받은 student 클래스의 메소드를 overiding 하였다고 표현한다.

print.gradstudent <- function(x) {
  print.student(x)
  cat("Advisor", x$advisor, "\n")
}
st2
Gilsan 
year 1 
GPA 3.8 
Advisor Sejong 

11.3 S4 클래스

S3 클래스의 안전성 문제

S3 클래스는 Java 같은 OOP 언어와 비교해 보면 다음과 같은 안전성(safety) 문제를 가지고 있다.

  • 클래스 정의를 사전에 하지 않으므로 객체 생성시 필요한 요소가 누락되거나 이름이 잘못 부여될 수 있다.
  • 클래스의 메소드도 이름으로 파악하므로, 메소드 구현시 함수 이름이 잘못 부여되어도 이를 바로 확인할 수 없다.
  • 데이터가 근본적으로 리스트 객체이므로 클래스 내부 데이터에 대한 잘못된 접근을 막을 수 없다.

이러한 문제를 해결하기 위해 S4 클래스가 개발되었다. 표 11.114은 두 클래스의 차이를 보여준다. 이 절에서는 S4 클래스의 몇 가지 특징에 대해서만 짚어보기로 한다.

Rows: 5 Columns: 3
── Column specification ────────────────────────────────────────────────────────
Delimiter: "&"
chr (3): 작업 ,  S3 ,  S4 

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
Table 11.1: S3 클래스 vs. S4 클래스 (Source: The Art of R Programming)
작업 S3 S4
클래스 정의 명시적 정의 없음 setClass()로 클래스 정의
객체 생성 리스트 생성 후 class 속성 부여 new()로 명시적 객체 생성
클래스 요소 접근 $ @
f 메소드 구현 f.classname() 함수 정의 setMethod()로 명시적 구현
포괄 함수 구현 포괄 함수에 UseMethod() 이용 setGeneric()로 명시적 구현

클래스 정의 setClass()

다음은 setClass() 함수로 newstudent라는 새로운 클래스를 생성한 예이다. 데이터의 요소는 앞서 S3 클래스로 생성한 student 클래스와 동일하다. setClass()의 첫번째 인수로는 클래스 이름이, representation 인수는 클래스의 데이터 요소의 이름과 각 데이터 요소의 클래스가 명시된다. 이를 통해 해당 클래스 객체가 생성될 때 객체 요소의 데이터의 이름과 타입이 같아지도록 강제할 수 있다.

setClass("newstudent", 
  representation(name="character", year="numeric", GPA="numeric" ))

객체 생성 new()

클래스로부터 새로운 객체를 생성할 때는 new() 함수를 이용한다. new() 함수는 첫번째 인수로 클래스 이름을, 나머지 인수로 객체의 각 데이터 요소를 정의한다.

st3 <- new("newstudent", name="Gildong", year=2, GPA=3.2)
st3
An object of class "newstudent"
Slot "name":
[1] "Gildong"

Slot "year":
[1] 2

Slot "GPA":
[1] 3.2

setClass()에 의해 클래스의 형태가 이미 정의되어 있어서 new()로 객체 생성 시 틀린 이름이나 틀린 타입의 데이터가 입력되면 오류가 발생하므로 항상 정확한 이름과 형식으로 객체가 생성되도록 강제할 수 있다.

st4 <- new("newstudent", sname="Gildong", year=2, GPA=3.2)
Error in initialize(value, ...): invalid name for slot of class "newstudent": sname
st4 <- new("newstudent", name="Gildong", year=2, GPA="3.2")
Error in validObject(.Object): 잘못된 클래스 "newstudent" 객체입니다: invalid object for slot "GPA" in class "newstudent": got class "character", should be or extend class "numeric"

객체 요소에 접근 slot()

S4 클래스에서 객체의 각 데이터 요소(멤버 변수라고 한다)를 슬롯(slot)이라 하며, @ 또는 slot()으로 접근한다. S4 클래스는 정의되지 않은 멤버 변수의 생성을 허용하지 않는다. 반면 S3는 단순한 리스트 객체이므로 아무 제약 없이 새로운 요소를 추가하거나 기존 요소를 삭제할 수 있다.

st3@name
[1] "Gildong"
slot(st3, "GPA")
[1] 3.2
st3@year <- 4
st3@GPa <- 4.0
Error in (function (cl, name, valueClass) : 'GPa'는 클래스 "newstudent"내에 있는 슬롯이 아닙니다
st3
An object of class "newstudent"
Slot "name":
[1] "Gildong"

Slot "year":
[1] 4

Slot "GPA":
[1] 3.2

S4 클래스 메소드 생성 setMethod()

S4 클래스에서는 메소드는 setMethod() 함수에 의해 정의된다. 앞에서 생성한 newstudent 객체에 summary() 함수를 적용하면 해당 클래스의 summary 메소드가 구현되지 않았으므로 디폴트로 제공되는 메소드에 의해 객체에 대한 매우 간략한 요약 정보만 출력한다.

summary(st3)
    Length      Class       Mode 
         1 newstudent         S4 

newstudent 클래스에 summary 메소드를 다음과 같이 추가해 보자. setMethod() 함수는 첫번째 인수로 메소드 이름, 두번째 인수로 클래스 이름, 세번째 인수로 메소드 호출시 실행될 함수 객체를 받는다.

setMethod("summary", "newstudent",
    function(object){
      cat(object@name, " is a ", 
          object@year, "th year student with GPA ", 
          object@GPA, "\n", sep="")
    })
summary(st3)
Gildong is a 4th year student with GPA 3.2

만약 명시적으로 생성한 메소드를 제거하려면 removeMethod() 함수를 이용한다.

removeMethod("summary", "newstudent")
[1] TRUE
summary(st3)
    Length      Class       Mode 
         1 newstudent         S4 

  1. Matloff, Norman. The Art of R Programming: A Tour of Statistical Software Design. No Starch Press, 2011.↩︎