5 Object-oriented programming
5 Object-oriented programming (객체 지향 프로그래밍)
R의 객체 지향 프로그래밍은 흔히 생각하는 완전체 형태와는 조금 달라요. 클래스와 메서드라는 개념을 바탕으로 하고 있지만, 실제 구현은 매우 단순한 방식으로 이루어져 있죠. 이 챕터에서는 R이 어떤 객체 시스템을 갖고 있는지, 그리고 그 안에서 메서드가 어떻게 호출되고 상속이 어떻게 이뤄지는지 하나씩 살펴볼게요.
출처: R 공식 매뉴얼
본문
객체 지향 프로그래밍(Object-oriented programming)은 최근 몇 년 사이 크게 유행한 프로그래밍 스타일이에요. 인기가 많아진 가장 큰 이유는 복잡한 시스템을 작성하고 유지보수하기가 훨씬 쉬워지기 때문인데요, 이를 위해 이 스타일은 몇 가지 다른 메커니즘을 사용합니다.
객체 지향 언어의 중심에는 클래스(class)와 메서드(method)라는 개념이 있어요. 클래스는 객체(object)의 정의라고 볼 수 있어요. 보통 클래스는 클래스 고유의 정보를 담기 위한 여러 슬롯(slots) 을 갖는데, 언어 안의 어떤 객체든 반드시 어떤 클래스의 인스턴스(instance)여야 합니다. 프로그래밍은 곧 이 객체들, 즉 클래스의 인스턴스들을 중심으로 진행되죠.
계산은 메서드를 통해 수행돼요. 메서드는 기본적으로 특정 클래스, 특히 어떤 한 클래스의 객체에 대해 특정 계산을 수행하도록 특화된 함수입니다. 바로 이 점이 그 언어를 객체 지향적으로 만드는 핵심인데요, R에서는 제네릭 함수(generic functions) 가 적절한 메서드를 결정해 줍니다. 제네릭 함수는 자기 인자의 클래스가 무엇인지 판별하고, 그 정보를 이용해 알맞은 메서드를 골라 호출해요.
대부분의 객체 지향 언어가 공통으로 갖는 또 하나의 특징은 상속(inheritance) 이에요. 대부분의 프로그래밍 문제에서는 서로 연관된 객체가 많기 마련이고, 어떤 구성 요소를 재사용할 수 있다면 프로그래밍이 훨씬 단순해지죠.
어떤 클래스가 다른 클래스로부터 상속받으면, 일반적으로 부모 클래스의 모든 슬롯을 물려받고 여기에 새 슬롯을 추가해 확장할 수 있어요. 메서드 디스패치(제네릭 함수를 통한 호출) 시에는 해당 클래스에 대한 메서드가 없으면 부모 클래스의 메서드를 찾습니다.
이 챕터에서는 이 일반적인 전략이 R에서 어떻게 구현됐는지, 그리고 현재 설계 안에 있는 몇 가지 한계는 무엇인지 다뤄볼게요. 대부분의 객체 시스템이 주는 장점 중 하나는 더 큰 일관성(consistency)인데, 이는 컴파일러나 인터프리터가 검사하는 규칙들을 통해 이루어지죠. 그런데 안타깝게도 객체 시스템이 R에 통합된 방식 때문에 이 장점은 얻을 수 없어요. 그래서 사용자들은 R의 객체 시스템을 정직하고 단순한 방식으로 사용하도록 주의를 받는데요, 아무리 흥미로운 묘기를 부릴 수 있다 하더라도 그런 코드는 대개 난해해지기 쉽고, 앞으로도 유지될지 보장되지 않는 구현 세부 사항에 의존하게 되기 때문입니다.
R에서 객체 지향 프로그래밍이 가장 많이 쓰이는 곳은 print 메서드, summary 메서드, 그리고 plot 메서드예요. 이 메서드들 덕분에, 예를 들어 plot이라는 제네릭 함수 호출 하나만으로 인자의 타입에 따라 디스패치가 일어나고, 주어진 데이터에 특화된 플로팅 함수가 호출되죠. 즉 데이터마다 다른 함수를 기억할 필요가 없다는 뜻이에요.
개념을 분명하게 하기 위해, 학생들에게 확률(probability)을 가르치기 위해 설계된 작은 시스템을 구현하는 경우를 생각해 볼게요. 이 시스템에서 객체는 확률 함수이고, 우리가 다룰 메서드는 모멘트(moments)를 구하는 메서드와 플로팅을 담당하는 메서드예요. 확률은 항상 누적 분포 함수(cumulative distribution function)로 표현할 수 있지만, 다른 방식으로도 나타낼 수 있는 경우가 많아요. 예를 들어 존재할 때는 밀도(density)로, 또 존재할 때는 모멘트 생성 함수(moment generating function)로 나타낼 수 있죠.
5.1 Definition (정의)
R은 완전체 격의 객체 지향 시스템을 갖고 있는 게 아니라, 클래스 시스템과 객체의 클래스에 기반해 디스패치하는 메커니즘을 갖고 있어요. 해석된(interpreted) 코드를 위한 디스패치 메커니즘은 평가 프레임(evaluation frame)에 저장되는 네 가지 특별한 객체에 의존하는데, 그 객체들이 바로 .Generic, .Class, .Method, .Group이에요. (내장 함수와 타입을 위한 별도의 디스패치 메커니즘이 있고, 그것은 다른 곳에서 다룹니다.)
클래스 시스템은 class 속성(attribute)을 통해 동작해요. 이 속성은 클래스 이름들의 문자 벡터예요. 그래서 클래스 "foo"의 객체를 만들려면 "foo"라는 문자열이 들어 있는 class 속성을 붙이기만 하면 됩니다. 다시 말해, 거의 모든 것이 얼마든지 클래스 "foo"의 객체로 바뀔 수 있어요.
이 객체 시스템은 두 개의 디스패치 함수, UseMethod와 NextMethod를 통해 제네릭 함수 를 활용합니다. 객체 시스템의 전형적인 사용법은 제네릭 함수를 호출하는 것으로 시작해요. 제네릭 함수는 보통 아주 단순해서 코드 한 줄로 이루어져 있죠. 시스템 함수 mean이 바로 그런 함수예요:
> mean
function (x, ...)
UseMethod("mean")
mean이 호출될 때 인자는 몇 개든 가질 수 있지만 첫 번째 인자는 특별해요. 그 첫 인자의 클래스를 이용해 어느 메서드를 호출할지 결정합니다. 변수 .Class는 x의 class 속성으로 설정되고, .Generic은 문자열 "mean"으로 설정된 다음, 호출할 올바른 메서드를 찾기 위한 탐색이 시작돼요. mean의 다른 인자들의 class 속성은 무시됩니다.
만약 x의 class 속성이 순서대로 "foo"와 "bar"를 담고 있다고 해볼게요. 그러면 R은 먼저 mean.foo라는 함수를 찾고, 없으면 mean.bar를 찾아요. 그것마저 없으면 마지막으로 mean.default를 찾습니다. 마지막 탐색마저 실패하면 R은 오류를 보고해요. 그래서 기본 메서드(default method)는 항상 작성해 두는 것이 좋아요. 참고로 mean.foo 같은 함수들은 이 맥락에서 메서드라고 불립니다.
NextMethod는 또 하나의 디스패치 메커니즘을 제공해요. 함수는 어디든 NextMethod에 대한 호출을 포함할 수 있는데, 이후 어떤 메서드가 호출될지는 주로 현재 .Class와 .Generic의 값에 기반해 결정됩니다. 여기엔 다소 문제가 있는데요, 메서드는 실제로는 평범한 함수라서 사용자가 직접 호출할 수 있기 때문이에요. 그렇게 직접 호출하면 .Generic이나 .Class의 값이 존재하지 않게 되죠.
메서드가 직접 호출됐는데 그 안에 NextMethod 호출이 포함돼 있다면, NextMethod의 첫 번째 인자가 제네릭 함수를 결정하는 데 쓰입니다. 만약 이 인자가 제공되지 않으면 오류가 발생하므로, 항상 이 인자를 넣어 주는 것이 좋아요.
메서드가 직접 호출된 경우에는 메서드의 첫 인자의 class 속성이 .Class의 값으로 사용됩니다.
메서드 자체는 상속의 한 형태를 제공하기 위해 NextMethod를 사용해요. 보통 특정 메서드는 데이터를 준비하는 몇 가지 작업을 수행한 다음, NextMethod 호출을 통해 다음으로 적절한 메서드를 호출하죠.
간단한 예를 하나 들어볼게요. 2차원 유클리드 공간의 한 점은 데카르트 (x-y) 좌표나 극 (r-theta) 좌표로 지정할 수 있어요. 따라서 점의 위치 정보를 저장하기 위해 "xypoint"와 "rthetapoint" 두 개의 클래스를 정의할 수 있겠죠. 모든 'xypoint' 데이터 구조는 x 성분과 y 성분을 가진 리스트이고, 모든 'rthetapoint' 객체는 r 성분과 theta 성분을 가진 리스트예요.
이제 어느 타입의 객체에서든 x 위치를 얻고 싶다고 해볼게요. 이것은 제네릭 함수를 통해 쉽게 이루어집니다. 제네릭 함수 xpos를 다음과 같이 정의해요:
xpos <- function(x, ...)
UseMethod("xpos")
이제 메서드를 정의할 수 있어요:
xpos.xypoint <- function(x) x$x
xpos.rthetapoint <- function(x) x$r * cos(x$theta)
사용자는 어느 표현으로든 인자를 넘겨 xpos 함수를 호출하기만 하면 돼요. 내부 디스패치 메커니즘이 객체의 클래스를 찾아 알맞은 메서드를 호출해 줍니다.
다른 표현 방식을 추가하는 것도 아주 쉽습니다. 제네릭 함수를 새로 쓸 필요 없이 메서드만 추가하면 되죠. 이렇게 하면 기존 시스템에 확장을 붙이기 쉬워지는데, 사용자는 새 표현 방식만 다루면 되고 기존 표현 방식을 전부 알 필요가 없기 때문이에요. 이 방법론이 가장 많이 쓰이는 곳은 타입이 다른 객체에 특화된 출력(printing)을 제공하는 일인데, print에는 대략 40개의 메서드가 있어요.
5.2 Inheritance (상속)
객체의 class 속성은 여러 요소를 가질 수 있어요. 제네릭 함수가 호출될 때 일차적 상속은 주로 NextMethod를 통해 처리됩니다. NextMethod는 현재 평가 중인 메서드를 확인하고, class 속성에서 다음 클래스를 찾아 그 작업을 계속 진행해요. 자세한 내용은 ?NextMethod를 참고하세요.
5.3 Method dispatching (메서드 디스패치)
제네릭 함수는 단일 문장으로 이루어져야 해요. 보통 foo generic.class 형태인데, 이 이름을 가진 함수를 탐색해서 찾으면 그 함수를 사용합니다. 그런 함수를 찾지 못하면 class 속성의 두 번째 요소를 사용하고, 이런 식으로 class 속성의 모든 요소를 소진할 때까지 계속해요. 그래도 메서드를 찾지 못하면 generic.default 메서드를 사용합니다. 제네릭 함수의 첫 인자에 class 속성이 없는 경우에도 generic.default가 사용돼요. 네임스페이스(namespaces)가 도입된 이후로는 메서드가 이름으로 직접 접근되지 않을 수 있는데요(get("generic.class")는 실패할 수 있어요), getS3method("generic","class")로는 접근할 수 있습니다.
어떤 객체든 class 속성을 가질 수 있고, 이 속성은 몇 개의 요소든 담을 수 있어요. 각 요소는 클래스를 정의하는 문자열이죠. 제네릭 함수가 호출되면 그 첫 인자의 클래스가 검사됩니다.
5.4 UseMethod
UseMethod는 특별한 함수라서 다른 함수 호출과는 다르게 동작합니다. 호출 문법은 UseMethod(generic, object)인데, generic은 제네릭 함수의 이름이고 object는 어떤 메서드를 고를지 결정하는 데 쓰는 객체예요. UseMethod는 함수 본문에서만 호출할 수 있어요.
UseMethod는 평가 모델을 두 가지 방식으로 바꿔놓습니다. 첫째, 호출되면 다음으로 호출할 메서드(함수)를 결정해요. 그런 다음 현재 평가 환경(current evaluation environment)을 사용해 그 함수를 호출하는데, 이 과정은 곧 자세히 설명할게요. 둘째로, UseMethod가 평가 환경을 바꾸는 방식은 호출 함수로 제어권(control)을 돌려보내지 않는다는 점이에요. 즉 UseMethod 호출 뒤에 있는 문장들은 실행되지 않는다는 것이 보장됩니다.
UseMethod가 호출되면 제네릭 함수는 그 호출에서 지정된 값이 돼요. 디스패치 대상 객체는 두 번째 인자로 제공되었거나, 아니면 현재 함수의 첫 인자입니다. 그 인자의 클래스가 결정되고, 그 첫 번째 요소가 제네릭 이름과 결합되어 적절한 메서드를 정해요. 예를 들어 제네릭 이름이 foo이고 객체의 클래스가 "bar"라면, R은 foo.bar라는 이름의 메서드를 찾게 됩니다. 그런 메서드가 없으면 위에서 설명한 상속 메커니즘이 알맞은 메서드를 찾는 데 사용됩니다.
메서드가 결정되면 R은 그것을 특별한 방식으로 호출해요. 새 평가 환경을 만드는 대신, R은 현재 함수 호출(즉 제네릭에 대한 호출)의 환경을 사용합니다. UseMethod 호출 전에 이루어진 할당이나 평가들은 그대로 유효해요. 제네릭 호출에서 사용된 인자들은 선택된 메서드의 형식 인자(formal arguments)에 다시 대응(rematch)됩니다.
메서드가 호출될 때 인자는 제네릭 호출 때와 개수가 같고 이름도 같아요. 그것들은 R의 표준 인자 대응 규칙에 따라 메서드의 인자에 대응됩니다. 다만 객체, 즉 첫 인자는 이미 평가된 상태입니다.
UseMethod 호출은 평가 프레임에 몇 가지 특별한 객체를 놓는 효과가 있어요. 그 객체들이 .Class, .Generic, .Method입니다. 이 특별한 객체들은 R이 메서드 디스패치와 상속을 처리하는 데 사용해요. .Class는 객체의 클래스, .Generic은 제네릭 함수의 이름, .Method는 현재 호출 중인 메서드의 이름입니다. 만약 메서드가 내부 인터페이스 중 하나를 통해 호출됐다면 .Group이라는 객체도 있을 수 있어요. 이것은 5.6절 Group methods에서 설명할게요. 최초의 UseMethod 호출 이후에는 객체 자체가 아니라 이 특별한 변수들이 이후 메서드 선택을 제어합니다.
그러면 메서드 본문이 표준 방식으로 평가돼요. 특히 본문 안에서 변수를 찾는 것은 메서드의 규칙을 따릅니다. 메서드에 연관된 환경(environment)이 있다면 그 환경이 사용되죠. 사실상 우리는 제네릭에 대한 호출을 메서드에 대한 호출로 대체한 셈이에요. [R 4.4.0 이전에는 제네릭 프레임에서 이루어진 지역 할당이 메서드 호출로 그대로 이어졌지만, 더 이상 그렇지 않습니다.] 제어가 결코 제네릭으로 돌아오지 않으므로, UseMethod 호출 뒤의 표현식은 실행되지 않는다는 점을 아는 것이 중요합니다.
UseMethod 호출 이전에 평가된 제네릭의 인자들은 평가된 상태로 유지됩니다.
UseMethod의 첫 인자가 제공되지 않으면 현재 함수의 이름으로 간주돼요. UseMethod에 두 인자가 제공되면 첫 번째는 메서드의 이름이고 두 번째는 디스패치 대상이 될 객체로 가정됩니다. 필요한 메서드를 결정할 수 있도록 이 객체는 평가돼요. 이 경우 제네릭 호출에서의 첫 인자는 평가되지 않고 버려집니다. 메서드 호출에서 다른 인자들을 바꿀 방법은 없는데, 그 인자들은 제네릭 호출 때 그대로 유지되기 때문이에요. 이는 다음 메서드 호출에서 인자를 변경할 수 있는 NextMethod와는 대조적입니다.
5.5 NextMethod
NextMethod는 간단한 상속 메커니즘을 제공하는 데 사용됩니다.
NextMethod 호출의 결과로 호출되는 메서드들은 이전 메서드에서 호출된 것처럼 동작해요. 상속받은 메서드의 인자는 현재 메서드 호출과 순서가 같고 이름도 같아요. 즉 제네릭 호출과도 동일하다는 뜻이죠. 다만 인자들의 표현식(expression)은 현재 메서드의 대응하는 형식 인자들의 이름입니다. 그래서 인자들은 NextMethod가 호출된 시점의 값에 대응하는 값들을 갖게 됩니다.
평가되지 않은 인자는 평가되지 않은 채로 남고, 빠진(missing) 인자는 빠진 채로 남아요.
NextMethod 호출의 문법은 NextMethod(generic, object, ...) 입니다. generic이 제공되지 않으면 .Generic의 값이 사용됩니다. object가 제공되지 않으면 현재 메서드 호출에서의 첫 인자가 사용돼요. ... 인자에 있는 값들은 다음 메서드의 인자들을 수정하는 데 사용됩니다.
다음 메서드의 선택이 객체가 아니라 현재 .Generic과 .Class의 값에 달려 있다는 점을 이해하는 것이 중요해요. 따라서 NextMethod 호출에서 객체를 바꾸면 다음 메서드가 받는 인자에는 영향이 있지만, 다음 메서드를 무엇으로 선택할지에는 영향을 주지 않습니다.
메서드는 직접 호출될 수 있어요. 그렇게 되면 .Generic, .Class, .Method가 존재하지 않게 되죠. 이 경우 NextMethod의 generic 인자가 반드시 지정되어야 합니다. .Class의 값은 현재 함수의 첫 인자인 객체의 class 속성으로 취해집니다. .Method의 값은 현재 함수의 이름이에요. 이렇게 기본값을 정해 두면 메서드가 직접 호출되든 제네릭을 통해 호출되든 그 동작이 달라지지 않는다는 것이 보장됩니다.
논의할 만한 쟁점 하나가 NextMethod의 ... 인자가 어떻게 동작하느냐입니다. 화이트북(White Book)은 그 동작을 다음과 같이 설명해요:
- 이름 있는(name) 인자는 현재 메서드 호출에서의 대응하는 인자를 대체한다. 이름 없는 인자는 인자 목록의 맨 앞에 온다.
제가 하고 싶은 방식은 다음과 같아요:
- 먼저
NextMethod에 대한 인자 대응을 수행한다. - 객체나 제네릭이 바뀌어도 상관없다.
- 먼저, 이름 있는 리스트 요소가 인자(이름이 있든 없든)와 대응되면 리스트 값이 인자 값을 대체한다.
- 그리고 첫 번째 이름 없는 리스트 요소.
조회를 위한 값들:
Class: 첫째로 .Class에서, 둘째로 메서드의 첫 인자에서, 마지막으로 NextMethod 호출에서 지정된 객체에서 온다.
Generic: 첫째로 .Generic에서, 거기에 없으면 메서드의 첫 인자에서, 여전히 없으면 NextMethod 호출에서 온다.
Method: 이것은 현재 함수의 이름이면 된다.
5.6 Group methods (그룹 메서드)
여러 종류의 내장 함수에 대해, R은 연산자(operator)를 위한 디스패치 메커니즘을 제공해요. 이것은 ==나 < 같은 연산자들이 특별한 클래스의 구성원에 대해 그 동작을 바꿀 수 있다는 뜻입니다. 함수와 연산자는 세 범주로 묶여 있고, 각 범주에 대해 그룹 메서드를 작성할 수 있어요. 현재 그룹을 새로 추가할 메커니즘은 없습니다. 그룹 안에 있는 어떤 함수에 대해서든 특화된 메서드를 작성하는 것은 가능해요.
다음 표는 서로 다른 그룹들에 대한 함수들을 나열한 것입니다.
| 그룹 | 함수 |
|---|---|
Math |
abs, acos, acosh, asin, asinh, atan, atanh, ceiling, cos, cosh, cospi, cumsum, exp, floor, gamma, lgamma, log, log10, round, signif, sin, sinh, sinpi, tan, tanh, tanpi, trunc |
Summary |
all, any, max, min, prod, range, sum |
Ops |
+, -, *, /, ^, <, >, <=, >=, !=, ==, %%, %/%, &, |, ! |
Ops 그룹의 연산자에 대해서는, 두 피연산자(operand)를 합쳐 봤을 때 단일 메서드를 암시하면 특별한 메서드가 호출됩니다. 구체적으로, 두 피연산자가 같은 메서드에 해당하거나, 한 피연산자가 다른 피연산자의 메서드보다 우선하는 메서드에 해당하는 경우예요. 그러한 단일 메서드가 암시되지 않으면 기본 메서드가 사용됩니다. 다른 피연산자에 대응하는 메서드가 없으면 그룹 메서드나 클래스 메서드 중 하나가 우세합니다. 클래스 메서드는 그룹 메서드보다 우세해요.
그룹이 Ops일 때 특별 변수 .Method는 두 요소를 가진 문자 벡터입니다. .Method의 요소들은, 대응하는 인자가 메서드를 결정하는 데 사용된 클래스의 구성원이면 메서드의 이름으로 설정되고, 그렇지 않으면 길이 0의 문자열 ""로 설정됩니다.
5.7 Writing methods (메서드 작성하기)
사용자는 자신만의 메서드와 제네릭 함수를 쉽게 작성할 수 있어요. 제네릭 함수는 단순히 UseMethod 호출을 포함한 함수이고, 메서드는 메서드 디스패치를 통해 호출된 함수입니다. 이것은 UseMethod나 NextMethod 호출 중 하나의 결과로 발생할 수 있어요.
메서드는 직접 호출될 수 있다는 점을 기억해 둘 만해요. 즉 UseMethod 호출 없이도 진입할 수 있고, 그 경우 특별 변수 .Generic, .Class, .Method는 인스턴스화되지 않을 것입니다. 그런 경우에는 위에서 자세히 설명한 기본 규칙이 그것들을 결정하는 데 사용돼요.
제네릭 함수가 가장 흔히 쓰이는 곳은 통계 객체들, 일반적으로 어떤 모델 적합(model fitting) 과정의 출력물을 위한 print와 summary 메서드를 제공하는 일입니다. 이를 위해 각 모델은 자기 출력물에 class 속성을 붙이고, 그 출력물을 받아 읽기 좋은 버전으로 만들어 주는 특별한 메서드를 제공해요. 그러면 사용자는 어떤 분석의 결과든 print나 summary가 멋진 출력을 제공해 준다는 것만 기억하면 됩니다.
더 알아보기
- 제네릭 함수와 디스패치의 핵심 동작 원리는
UseMethod,NextMethod,class,getS3method문서에서 더 자세히 확인할 수 있어요. - R에는 이 장에서 다룬 S3 객체 시스템 외에도 S4, R6, reference class 등 다른 객체 시스템이 있는데, 각각 설계 목적과 장단점이 달라요.
- 네임스페이스 도입 이후 메서드가 이름으로 접근되지 않을 수 있으니,
get("generic.class")가 실패할 때는getS3method("generic","class")를 시도해 보세요.