SmalltalkObjectsandDesign:Chapter 14
- 제 14 장 다형성
다형성
객체 지향에서 가장 중요한 특성 중 하나는 바로 다형성이다. 다형성은 객체 지향 코드의 명확성과 확장성을 증가시킨다. 이는 객체 지향 디자인에 깊은 영향을 미치기 때문에 많은 권위자들은 다형성을 제 1장과 2장에서 논했던 객체, 클래스, 상속에 더불어 기본적인 객체 지향 원칙으로 취급한다.
다형성은 서브타이핑 (제 17장), 타입 검사, 동적 바인딩을 포함한 여러 개념과 관련이 있다. 우리는 객체 지향이나 비객체 지향 모두에 적용되는 개념인 동적 바인딩부터 살펴보겠다.
동적 바인딩
Alan Kay는 컴퓨팅의 진화에서 결정을 마지막 가능한 순간까지 연기하는 것이 가장 유익하고 특징적인 추세 중 하나임을 관찰했다 [Kay 1993]. 컴퓨팅 환경에서 연기(postponement)의 예로는 링커, 재배치 가능 프로그램, 가상 메모리가 있다. 또 실행 시간까지 연산의 선택을 지연시키는 동적 바인딩이 있다.
Pascal이나 PORTRAN과 같은 대부분의 전통 언어들은 동적 바인딩을 지원하지 않는다: 이러한 언어에서 foo(x)를 작성하면 컴파일러(또는 링커)는 정확히 실행될 foo 함수를 결정하고 그 함수를 찾기 위한 방향(directions)을 컴파일한다. 프로그램에 하나 이상의 foo 함수가 있다면, 컴파일러는 그 중 하나를 고르기 위해 언어의 규칙을 이용한다. 프로그램이 마침내 실행되면 선택된 규칙이 실행된다[1].
동적으로 바인딩된 언어들은 실행되는 함수를 예정하지 않는다. 대신 프로그램이 이미 실행되는 동안 대개 foo라는 이름으로 된 다수의 함수들로부터 함수가 선택된다. 동적으로 바인딩된 스몰토크 예제에서 볼 수 있듯이 일찍이 함수 또는 메서드를 선택하는 것이 불가능한 경우도 종종 있다.
aCondition
ifTrue: [y := 3]
ifFalse: [y := #(5 7 eleven)].
y printOn: someStream.
스몰토크에는 수많은 printOn: 메서드-인스턴스들이 텍스트 폼으로 스스로를 인쇄하거나 표시하는 방식을 맞춤설정하기 위해 클래스가 종종 고유의 printOn: 메서드를 정의한다-가 있다. (어떤 객체든 텍스트로 만드는 56 페이지 연습을 상기시켜보라.) "true" 경로가 실행되는 일이 발생하면 실행되는 printOn:은 3, 즉 클래스 Integer에 적절하겠다. 만일 "false" 경로가 실행되면 #(5 7 eleven), 즉 클래스 Array에 적절하다.
스몰토크 컴파일러는 실행 시간에 사실상 어떤 branch가 발생할 것인지 예측할 수 없기 때문에 선택을 예정할 수 없다. 이것이 동적 바인딩-특정 printOn:의 선택을 실행시간까지 지연시킬 수 있는 기능-이다. 애플리케이션이 대화형일수록 컴파일러가 미리 함수를 알지 못하는 상황은 더 많이 발생하고, 그러한 연기는 회피하기가 더 어려워진다.
동적 바인딩이....다형성을 가능케 하다
초보자들은 때때로 동적 바인딩이 후보 메서드 중에서 선택하는 조건부 코드 방식으로 작동해야 한다고 가정하고, 동적 바인딩은 꼭 느린 것으로 생각한다. 하지만 항상 그런 것은 아니다. 조건부 코드의 유무와 메서드 선택의 속도는 특정 언어 시스템-컴파일러와 런타임 환경 모두-이 어떻게 동적 바인딩을 구현하는지에 따라 좌우된다. 오늘날 스몰토크의 구현은 빠른 편이며, 심지어 C++가 이용하는 다른 기법은 더 빠르다. (제 16장에서는 C++가 어떻게 조건부 코드를 미리 말함으로써 빠른 속도를 얻는지 설명하겠다.)
동적 바인딩의 기법을 하나 소개하겠다. 이 기법은 비객체 언어에서 사용하곤 하지만 객체 지향 언어에서는 불만족스러움을 발견할 것이다. 자신의 애플리케이션에 메일박스의 각 항목을 표시할 사용자 인터페이스가 있다고 가정하자. 항목 또는 "객체"는 메모, 스프레드시트 혹은 문서가 될 수 있다. 자신의 언어에 따라 CASE, SELECT 또는 SWITCH와 같은 조건부를 사용할 수 있겠다:
case y.type of
m : showMemo(y) |
s : showSpread(y) |
d : showDocum(y)
end;
이것은 Pascal이지만, 프로그램이 실행될 때 "객체"와 함께 "타입" 정보도 이용할 수 있는 한 어떤 언어에서든 이와 같이 스타일은 작동하기 마련이다. 이 예제에서 프로그래머는 추가 "타입" 필드를 빌드하고 유지해야 할 것이다. 비객체 언어에서 이를 실행할 수 있다면 객체 언어에서도 실행할 수 있다. 게다가 스몰토크에서는 y class 메시지만 전송하면 y의 클래스나 타입을 즉시 이용 가능하다. 그럼에도 불구하고 스몰토크나 다른 객체 언어에서 이러한 동적 바인딩 방식이 바람직하지 못한데, 그 이유를 설명하자면 다음과 같다.
첫째, 성능이다. 많은 컴퓨터에서는 조건부 갈림, 특히 case가 많은 조건부 갈림은 직선 코드(straight line code)에 비해 속도가 느리다. (오늘날 대부분 RISC 컴퓨터에서 branch는 별 문제가 되지 않는데, branch 상태를 평가하는 동안 branch보다 먼저 코드를 사전 실행(pre-execute)할 수 있기 때문이다.)
둘째, 공학적 결함으로 좀 더 심각한 문제다. 머지않아 당신은 애플리케이션이 다른 종류의 객체를 수용하도록 확장하길 원할 것이다-소프트웨어의 변경과 향상은 불가피하다. 가령, 이제 메일박스에서도 그래픽을 표시할 수 있다고 치자. case문이 다른 case(g :showGraphic)를 수용하도록 변경해야 할 것이다; 이 정도는 어쩔 수 없다. 게다가 만일 파일 폴더에서 항목을 표시하기 위해 동일한 case 문이 발생할 경우, 그러한 발생(occurrence)은 복구되어야 한다. case문은 이보다 훨씬 더 더 서서히 퍼진다. 이 항목들을 표시할 뿐만 아니라 메일로 전송(mail), 인쇄, 저장, 실행, 또는 읽을 가능성이 크다. 이러한 연산들은 거의 동일한 case 문을 수반하는데, 이들 각각도 변경해야 한다. 그러한 모든 문들을 검색하고 변경하기란 지루하고 오류가 발생하기 쉬운 작업이다.
재사용 가능한 컴포넌트로 구성된 라이브러리로 새로운 종류의 객체를 추가할 때마다 클라이언트 코드를 복구해야 하는 일은-우리의 스프레드시트와 메모 객체를 이용하는 코드-피하고 싶을 것이다. 그래픽 객체를 추가 시 새로운 라이브러리 코드, 즉 Graphics 클래스를 필요하게 만든다는 사실은 기꺼이 인정하지만, 넓은 변경 범위를 초래하지 않고 클라이언트 코드가 이런 새로운 객체로부터 이익을 얻을 수 있길 바란다.
이러한 스몰토크 문제에 대한 해결책은 아래와 같다:
y showIt
이것이 전부다. 이제 그래픽 객체의 클래스를 애플리케이션에 추가하면 위의 코드 행은 변경되지 않는다. 행이 클라이언트 코드의 여러 지점에서 발생할 경우, 그러한 발생 중 어떤 것도 변경되지 않는다. 그리고 비슷한 코드 행이 발생하여 객체의 메일 전송, 인쇄, 실행, 혹은 읽기를 한다해도 그러한 발생은 변경되지 않는다. 우리는 조건부으로부터 클라이언트 코드를 자유롭게 만들었고, 그러면서 급속히 퍼지는 변경 대상이 되는 전체 범주에 대해 면역을 제공한 셈이다.
조건부가 실제로 사라지는 것일까? 클라이언트 코드로부터 유해한 조건부를 단일 메서드 showIt 내부에 숨겨진 하나의 조건부로 팩토링했다고 의심할 수도 있겠다. 사실 이러한 접근법도 가능하다. 하지만 이는 복합 메서드 howIt에 이익이 되면서도 여전히 너무 많은 오류가 발생하기 쉬운 조건부다. 따라서 불필요하고 바람직하지 못하다고 말할 수 있겠다.
조건부가 전혀 없다면 객체 지향 디자인은 다음과 같이 작용한다. 실제로 여러 개의 메서드가 있는데, 각각은 동일한 이름, showIt을 갖고 있으며, 우리가 관심 있는 객체의 클래스마다 하나씩 해당한다: 스프레드시트, 문서, 그래픽 등. 변수 y에게 showIt을 표시하도록 요청함으로써 우리는 y 하에서 숨어 있는 객체에게 showIt 하도록 요청하는 것이다:
객체의 종류가 무엇이든 자신의 showIt 메서드에 직접 응답한다. 타입 검사나 branching은 없다. 실행되는 프로그램은 어떤 종류의 객체가 그 곳에 있는지도 알지 못하며, 신경 쓰지도 않는다. 단지 객체가 showIt을 이해한다고 믿을 뿐이다.
프로그래밍에 대한 이러한 조건부가 없는(conditional-free) 스타일의 우아한 이름이 바로 다형성이다. "Poly+morphism"의 기원은 그리스어로부터 시작되며, "다중+형태"의 의미를 지닌다. 변수 y는 그것이 가리키는 객체 종류에 따라 "다중 형태"을 가진 것으로 가정한다. 각 객체 유형은 고유의 showIt 메서드를 관리하므로, 메시지 y showIT에 대한 응답 또한 "다중 형태"를 효과적으로 가정한다. 다형성-다중 형태-은 조건부를 클래스로 대체하는 방식으로, 읽기도 쉽고 수정하기도 쉬운 코드를 야기한다.
용어에 대하여
객체 지향 시스템에서 동적 바인딩 개념은 두 가지 측면을 지닌다: 객체를 (그리고 그 타입을) 결정하고, 이를 통해 슈퍼클래스의 사슬에서 메서드를 검색하는 것이다. 후자는 종종 메서드 검색(method lookup)이라는 이름으로 적절하고 분명하게 진행된다. 필자는 가장 폭넓고 느슨한 의미로 "동적 바인딩"을 사용하여 두 가지 측면을 모두 포함하고자 한다. 많은 저자들은 "동적 바인딩"을 둘 중 하나의 범위로 제한한다; 즉 타입 선택이나 [Booch 1991] 메서드 검색 [Meyer 1988; de Champeux et al. 1993], 둘 중 하나로 제한한다.
동적 바인딩이란 객체 지향 언어마다 다른 방식으로 구현되는 메서드 선택을 지연하기 위한 매커니즘으로 생각하면 되겠다. 다형성은 동적으로 바인딩된 언어를 필요로 하긴 하지만 매커니즘이라기보단 소프트웨어의 명료성을 향상시키기 위한 디자인 기법에 해당한다. 다형성과 동적 바인딩 간 관계는 디자인을 구현으로부터 구분할 수 없음을 보여주는 훌륭한 예가 된다. 언어가 동적 바인딩을 지원하지 않고는 다형적으로 디자인할 수 없는 것이다.
연습: 다형성
❏ 수입업자가 자신이 수입하는 자동차에 대한 관세를 계산하길 원한다고 치자. 그녀의 소프트웨어는 차량이 트럭이냐, 승용차냐에 따라 다른 계산을 적용해야 한다고 가정하자. 고수준 디자인은 어떤 모습일까? 두 가지 디자인을 생각해보자.
해결책과 논의
해결책 1. 추상 클래스 Vehicle을 정의하고 두 개의 구체적 서브클래스, Truck과 Car를 정의하라. 아무 것도 하지 않는 메서드 Vehicle>>tariff를 정의하라. 그러한 메서드를 64 페이지에서 "순수 가상" 또는 "subclassResponsibility"라고 부른 바 있다. 그 다음으로 두 가지 차종에 따라 각각의 계산을 적용하는 메서드 Truck>>tariff와 Car>>tariff를 정의하라.
해결책 2. type이란 이름의 인스턴스 변수로 Vehicle 클래스를 정의하라. 메서드 Vehicle>>tariff의 형태는 다음과 같다:
type = 'truck'
ifTrue: ["truck calculation"]
ifFalse: ["car calculation"]
객체 지향 개발자들은 분명 1번 해결책을 먼저 생각했을 것이다. 하지만 객체 지향의 훈련을 받지 않은 사람들이라면 사실상 비객체 지향에 속하는 2번 해결책에만 의지할 것이다. 지금쯤이면 해결책 2의 단점이 보일 것이다: 가령 관세 계산이 다르게 적용되는 오토바이 등 다른 기종의 차량이 나타날 경우 유지가 까다롭다는 점이다.
요약하자면 다음과 같다: 다형적인, 객체 지향의 디자인은 확장이 가능한데, 클라이언트 코드가 아래로만 구성되기 때문이다:
v tariff
위의 코드 행은 오토바이가 수입된다 하더라도 변경되지 않는다. 다형성은 문제를 해결하기 위해 조건부 대신 클래스를 사용한다.
연습: 스몰토크의 if-less-ness
아래 예제와 같이 ifTrue:ifFalse: 메시지를 전송하는 코드를 고려해보자:
X := ...
...
X ifTrue: ["true path"]
ifFalse: ["false path"].
스몰토크 브라우저를 참고하지 말고 아래 질문을 생각해보라. 그 다음 자신의 답을 검증하기 위해 스몰토크 브라우저를 사용하라.
❏ ifTrue:ifFalse: 메서드는 어떤 클래스에 있을까?
❏ ifTrue:ifFalse: 메서드는 어떤 일을 해야 하는가? 특히, 그 코드는 조건부여야 하는가? 얼마나 많은 ifTrue:ifFalse: 메서드가 존재하는가?
해결책
ifTrue:ifFalse: 메서드를 구현하는 클래스에 대해 실행 가능한 첫 번째 추측은 Boolean 클래스이다. 하지만 브라우저는 그러한 메서드를 표시하지 않는다:
하지만 Boolean 클래스를 가까이 보면 True와 False라는 이름의 서브클래스 두 개가 있다. True 클래스를 먼저 살펴보면 메서드들 사이에 ifTrue:ifFalse:를 발견할 수 있다. 이제 이 메서드의 body에서 조건부 코드를 어느 정도 볼 수 있을 것으로 기대할 것이다. 하지만 예상과 달리 찾아볼 수 없다:
"trueAlternativeBlock"의 평가는 무조건적이다. 그 이유는 메시지의 수신자, 즉 스스로를 true 객체로 알고 있는 수신자는 메시지의 "falseAlternativeBlock"가 필요하지 않기 때문이다. true 객체는 false 대안(alternative)를 무시하고, true 대안만 단순히 실행한다.
이제 False 클래스에 완전히 다른 메서드가 있는데, 이름은 역시 ifTrue:ifFalse:이다. 그러한 메서드 내에 로직은 "falseAlternativeBlock"을 무조건적으로 실행한다. 결국 false 객체는 true 대안을 필요로 하지 않으며 즉석에서 그것을 무시한다는 사실을 대단히 잘 알고 있다.
이러한 상황은 적어도 처음엔 역설적으로 보인다. 어떻게 ifTrue:ifFalse:라는 메서드만큼 표면상으로 조건적(conditional)인 것이 사실상 어떠한 조건부 코드도 실행하지 않을 수 있을까? 이에 대한 답은, 앞의 예제에서 tariff 메서드가 보여준 것과 정확히 동일한 원리다: 각 서브클래스가 고유의 메서드 버전을 지원하는 한 서브클래스는 조건부를 대체할 수 있다는 원리 말이다. 스몰토크는 이 원리를 철저히 취하여 boolean 객체 수준까지 적용시켰을 뿐이다.
요약 팁
경험으로 보아 코드에서 조건문은 두 번 정도 생각하길 바란다. 그 중 다수는 서브클래싱과 다형성의 기회가 될지도 모른다. "IF" 문은 객체 지향 프로그래머를 불안하게 만든다. 당신이 추가 case로 "IF"문을 확장시킬 일은 절대 없을 것이라고 확신하지 않는 한 기존 프로그래밍에서의 "GOTO"만큼 의심스럽게 다루어야 할 것이다.
논평: 성능
성능은 특이한 주제다. 사람들이 장황하게 열띤 논쟁을 하는 내용은 종종 그 중요성이 가장 덜한 주제들이다. 주로 측정이 쉬운 것들이다-조건문이 얼마나 많은지, 주소 간접 수준이 얼만지, 매우 특수화된 벤치마크 등등.
정말로 중요한 것은-언제 정적 SQL이 동적 SQL보다 더 빠른 자릿수가 될 수 있는지, 언제 앞이 아니라 뒤에서부터 큐를 검색 시 CPU 시간을 반으로 줄일 수 있는지, 또는 언제 작업 세트 크기가 가상 메모리를 초과하는지에 관한-측정이 힘들다. 대신 이 문제들은 실용적인 디자인을 위한 것이다. 요령이 있는 디자이너는 즉시 문제를 포착하고 그것에 적용할 대안책들에 대한 마음속 무기를 가진 동시 그 상충관계에 집중하고 무엇이 최선인지, 얼만큼 차이가 나는지 예상하는 경험에서 비롯된 직관까지 갖추고 있다.
이러한 통고를 유념한 채 객체 지향 언어에서 다형성에 대한 성능의 의의는 무엇인지 답해보라. 그것의 측정 가능한 효과는 두 가지 상반된 힘(force)으로 축소된다:
1. 빠름: 새로운 서브클래스를 정의하여 조건부를 대체하기.
2. 느림: 메서드 검색. 속도와 상관없이 메서드를 구현하는 클래스를 상속 계층구조에서 검색하는 작업은 컴파일 시 위치가 알려진 함수를 호출하는 것보다 느리다. 언어 구현자들이 빠른 검색을 위해 사용하는 기법들은 제 16장에서 다룬다.
이러한 힘을 틀리게 추정했을 때 발생할 수 있는 위험으로는 우리를 중요한 사건에 집중할 수 없도록 주의를 분산시킬 가능성이 크다는 점이다. 무엇보다 위의 다형성은 모두 정신적으로 다루기 쉬운 분명한 시스템에 기여한다. 그리고 디자이너들은 다루기 쉬운 시스템을 이해하고 그 성능을 향상시키기 위한 방법들에 대해 가설을 세울 수 있다. 더 빠른 코드를 이해하지 않는 한 그것을 작성할 수 없기 마련이다.
일리노이 대학에서 C++로 개발한 CHOICES 운영체제의 초기 버전에서 실행된 기준(benchmark) 실험들은 UNIX에 비해 불리하게 수행되었음을 보였다. 일 년 후, 기준 결과는 CHOICES가 UNIX보다 일관되게 뛰어남을 보였다. 개발자들은 이러한 개선의 이유로 두 가지를 들었다. 첫 째는 성능 병목문제가 캡슐화되어 있었기 때문에-캡슐화는 객체의 장점을 가장 잘 과시하기 때문에 그다지 놀라운 점은 아니다-수정이 쉬었다는 점이다. 두 번째는 조건문을 서브클래스로 대체하는 것이었다-다형성은 성능을 향상시킬 수도 있다.
논평: 스몰토크, C++, 그리고 타입 검사
타입 검사라는 표현은 특정 객체가 다른 타입이나 클래스의 객체에만 적절한 상황에서 사용되는 경우의 검출을 시도하는 것을 의미한다. (이 책에서 17장 이전에는 클래스와 타입을 구별하지 않는다는 사실을 기억하라.) 메시지를 이해하지 못하는 객체에게 메시지를 보내는 것은 객체 지향 언어의 타입 검사에 발생하는 오류의 예제이다:
Whale new + 2.71828
Whale 인스턴스는 + 2.71828과 같은 산술(arithmetic) 메시지는 이해하지 못하므로 스몰토크는 walkback으로 이러한 타입 오류를 우리에게 알려줄 것이다.
C++와 스몰토크에서 타입 검사 규칙은 꽤 다르다. 스몰토크에서 아래와 같은 문을 살펴보자:
R := Rectangle new.
T := Triangle new.
R := T.
R이나 T와 같은 스몰토크 변수들은 객체를 가리킨다. 이들은 어떤 특정 타입(클래스)에 충성도(allegiance)를 전혀 갖지 않는다; 한 때는 어떤 타입의 객체로, 또 다른 때는 완전히 다른 타입의 객체를 가리킬 수 있다.
R := T라는 지정문은 R을 바꿔 그 또한 triangle 객체를 가리키도록 한다. 이것은 스몰토크에선 타입 오류에 해당하지 않는다.
이와 동일한 상황을 C++에서 실행해보자. (C++에서 assignment 구문은 r = t;이다):
Rectangle *r;
Triangle *t;
...
r = t;
변수 r과 t가 서로 다른, 예상컨대 호환되지 않는 클래스에 대해 선언되었음을 인지한 C++ 컴파일러는 지정문이 오류임을 즉시 감지한다. 사실상 r(또는 t)과 같은 변수가 가리킬 수 있는 객체의 타입은 컴파일러에 의해 최종적으로 고정된다. 따라서 이 변수를 다른 타입의 객체에 사용하려는 코드가 있으면 컴파일조차 하지 않는다[2].
이러한 제약을 문자 그대로 받아들이면-C++는 변수가 보유하거나 가리킬 수 있는 객체 타입을 고정시킨다는-61 페이지에 설명된 다형적 메일박스 예제가 과연 C++에서 작동하는지 궁금할 것이다. 다시 말하자면 y showIt에서 (또는 C++ 구문에 따르면 y->showIt();) 변수 y가 하나의 객체 유형으로 제한되는지 걱정될 것이다. 다행히도 그렇지 않다. C++에서는 y를 T 타입의 객체를 가리키는 포인터로 선언할 경우, 컴파일러 또한 y가 T의 서브타입 객체를 가리키도록 허용한다! 이는 놀라운 기능이지만 언어의 직관적이고 자연스러운 특징이다. 이는 shape 변수가 rectangle 객체(또는 triangle 등)를 참조하는 것이 가능함을 의미한다. 하지만 그 반대의 경우, 즉 rectangle 변수가 어떤 모양의 객체든 참조할 수 있도록 기대하는 것은 말이 안 된다.
이와 비슷하게, insect 변수는 어떤 butterfly도 참조할 수 있어야 하지만 butterfly 변수는 임의의 insect를 참조할 수 없다. 이것이 바로 C++, Eiffel, Java와 같은 객체 지향 언어들이 작동하는 방식이다: 변수는 그것의 그 서브타입의 객체로 가리킬 수 있지만 그 슈퍼타입(들)의 객체로는 가리킬 수 없다.
구체적인 C++ 예제를 들어보겠다. 첫째, rectangle과 triangle이 shape의 서브타입이 되도록 선언하고, (상세한 내용은 생략) do-nothing 메서드를 오버라이드하는 메서드를 shape 슈퍼클래스에 공급하라. 이러한 메서드는 면적이나 둘레를 계산할 수 있는데, 예를 들자면 다음과 같다.
class Rectangle : public Shape { ...
class Triangle : public Shape { ...
다음으로, 이러한 타입 각각에 변수를 선언하고, (다시 상세한 내용은 생략) r과 t가 rectangle과 triangle 객체를 가리키도록 만들어라:
Shape* s; Rectangle* r; Triangle* t;
...
아래 두 개의 지정문은 모두 적합하다-insect는 butterfly를 참조할 수 있다:
if (...) s = r;
else s = t;
반면, r=s;와 같은 지정문은 컴파일러에 의해 금지될 것이다-butterfly는 임시 insect를 참조할 수 없다. 다음으로, 아래 메시지를 이용해 s의 면적을 요청하라:
answer = s->area();
어떤 area 메서드가 실행되는가? Rectangle에 대한 메서드인가 아니면 triangle에 대한 메서드인가? 물론 올바른 것이 실행된다. 즉 특정 C++ 세부 사항(niceties)이 area() 선언에서 발견된다고 가정하면, s가 사실상 가리키는 대상에 대한 area 메서드가 실행된다: 조건부가 true라면 s는 rectangle을 가리키고, Rectangle의 area()가 실행된다; 조건부가 false라면 s는 triangle을 가리키고 Triangle의 area()가 실행된다.
이것이 바로 C++가 다형성을 지원하는 방식으로, 스몰토크의 유연한 역동성과 전형적인 타입 검사된 언어의 엄격한 안전성 사이의 중간에 해당한다. 다른 객체 지향 언어에서도-Eiffel, Java, Modula-3 등-이와 똑같이 프로그래머에게 변수의 타입을 선언해야 할 의무를 지우는 접근법이 사용된다. 스몰토크는 이 모든 언어들과 다르다고 할 수 있는데, 스몰토크 변수에 대한 타입을 선언할 방법이 없기 때문이다; 어떤 변수든 모든 종류의 객체를 참조할 수 있다.
논평: 메일박스 안의 토마토
우편물을 처리하고 있다고 가정하자. 대량 우편은 파기하지만 패키지는 즉시 열어 본다. 이는 꽤 일반적인 객체 지향 문제다. 다형성을 이용한 해결책이라면 Package와 BulkMail의 Mail 슈퍼타입을 디자인하고 각 메일 종류에 적절한 handleMail을 작성할 것이다. 이미 알고 있듯이 이 해결책은 런타임 타입 검사의 필요성을 회피한다 ("객체의 타입이 BulkMail이라면...").
하지만 항상 런타임 타입 검사를 피할 수 있는 것은 아니다. Mail의 서브타입의 인스턴스에 해당하지 않는 무언가, 예를 들어 당신의 이웃으로부터 온 갓 수확한 토마토가 메일박스에서 발견되었다고 가정하자. 이러한 문제에 대해 다형적 해결책이 있는가?
C++에서라면 '아니오'가 답이 된다. 다형성은 슈퍼타입을 공유하는 타입에만 적용된다. Mail과 Tomato의 인위적 슈퍼타입의 개발을 제외하고 유일하게 의지할 방법은, 실행 시간 시 당신의 프로그램이 객체가 메일이 아니라 토마토라는 것을 확인한 후 그것으로 유효한 Tomato 메시지를 전송하는 것이다. 이러한 난관 때문에 ANSI C++ 위원회는 C++ 표준에 런타임 타입 검사를 추가하게 되었다. (이 기능의 실용적 사용은 [Lea 1992]를 참조.)
이 상황을 스몰토크에 적용해보자. 토마토와 메일은 클래스 계층구조에서 서로 상관되지 않은 부분들이지만 다형성을 적용할 수 있다. 다시 말해, 토마토를 먹는 Tomato>>handleMail 메서드를 작성할 경우, y handleMail과 같은 클라이언트 코드는 y가 패키지를 가리키든 토마토를 가리키든 상관없이 잘 작동한다. 스몰토크 변수 y는 어떠한 타입에도 제약되지 않는다. 스몰토크 개발자는 이 문제를 해결하기 위해 런타임 타입 검사를 필요로 하지 않는다.
다형성에 관해, 스몰토크는-C는 지원하지 않음-암시적 다형성을 지원한다고 설명할 수 있다. 이는 다양하게 지칭되는데, ad hoc, 서명 기반, 또는 명백한(apparent) 다형성이라 불리기도 한다. 이는 전문 용어(terminology)에서 아직 결정적으로 정하지 않은 개념이다. 암시적(implicit) 다형성은 슈퍼타입을 공유하지 않고 명시적 타입 관계가 필요 없는 객체 유형에 적용된다. 이는 타입 계층구조 이내가 아니라, 타입 계층구조에 걸친 다형성이다.
일반적인 다형성-명시적 계층구조 내 다형성-은 포괄(inclusion) 다형성이라 불리기도 한다. 다형성의 여느 변형(variant)이든 그에 대한 규칙들은 언어가 특정 클래스의 인스턴스를 다른 클래스의 인스턴스로 대체해도 괜찮은지 결정하는 방식에 해당한다. 이러한 규칙들은 클래스들 간 일관성을 측정하는 것과 관련되는데, 이 주제는 제 17장에서 상세히 논하겠다.
요약하자면, C++는 포괄 다형성만 지원하는 반면 스몰토크는 포괄 다형성과 암시적 다형성을 모두 지원한다. 암시적 다형성이 필요할 경우 C++ 개발자들은 런타임 타입 검사를 다시 시작한다. 따라서 런타임 타입 검사에 대한 필요성은 스몰토크보다 C++에서 더 큰데, C++에선 암시적 다형성이 없기 때문이다. 아이러니하게도 스몰토크는 런타임 타임 검사를 쉽게 만들지만 (어느 객체로든 메시지 class를 전송) 그것을 필요로 하는 경우는 적다. 어떤 언어에서든 런타임 타입 검사의 사용은 되도록 삼가도록 한다. (다시 [Lea 1992]를 참조.) 이들은 걸핏하면 유지하기 힘든 조건문을 소개하고 객체 지향의 프로그래밍 스타일에 상반되기 마련이다.
❏ 마지막으로 이 점을 강조하기 위해 아래에 예를 든 런타임 타입 검사가 바람직한지를 결정하고 (힌트: 바람직하지 않다.) 이를 바로잡으려면 어떻게 해야 하는지 결정하라:
mailbox do: [ :m |
m class = BulkMail
ifTrue: [ m discard].
m class = Package
ifTrue: [ m open].
m class = Tomato
ifTrue: [ m eat]
]
논평: 다중 메서드
가끔은 전통적인 객체 모델이 충분하지 않을 때가 있다. 어떤 문제에 있어서는 해결책을 객체 상에 메서드로서 개념화하는 것이 거북스럽기도 하다. 또 어떤 때는 행위에 두 개의 동료(peer) 객체들이 수반되고, 둘 중 어느 것도 행위에 대한 전적인 책임을 받을 자격이 없는 경우도 있다. 예를 들어, 두 partner 객체 중 나머지 partner 객체를 argument로 받아들이는 메서드를 선호되는(preferred) partner에 작성함으로써 dancing couple을 모델링해야 하는 이유가 뭘까? 그냥 두 partners에게 다중 메서드를 작성하는 것이 더 자연스럽지 않은가? 물론 스몰토크를 비롯해 대부분의 객체 지향 언어에는 다중 메서드라는 것이 없다. 다중 메서드가 있는 유일한 상업용 언어는 CLOS이다. 그렇다면 이러한 문제에 직면했을 때 어떻게 해야 할까?
해답은 Dan Ingalls [Ingalls 1986]이 처음 발표한 기법을 사용하는 것이다. 첫째, dancers 중 하나를 선호하는 대상 x로 지정한다. 여기에 dance: y를 전송하고, 다른 객체 y를 argument로서 전달한다. 이제 x는 y 상에 행위의 도움을 필요로 할 것이기 때문에 danceX:x 메시지를 y로 재전송하여 이 행위를 호출하면서 아래와 같이 자신을 argument로서 전달할 것이다:
두 개의 dancer를 서로 다른 클래스에 속하도록 허용한다는 사실을 주목하라. 어떤 것도 두 개의 구분된 dancer가 춤을 추지 못하게 방해하지 않으며 X가 BalletDancer이고 Y가 SwingDancer일 경우도 마찬가지다. 또 self를 y에게 argument로 전달하는 의의를 간과하지 마라: y는 통제력(control) 얻을 뿐만 아니라 y는 x로의 전체 접근성을 얻는다. 따라서 객체 x와 y는 객체 지향이 두 객체에게 허용하는 만큼 친밀하다.
여기까지 논의 중 어떤 것도 정확히 다형적인 느낌을 갖지 못했다; 단지 통제력이 변경되었다는 강한 느낌과 함께 객체 지향의 친밀감만 전달했을 뿐이다. 다형성은 우리가 변수를 서로 다른 타입의 객체로 다루도록 내버려 둘 때 발생하는데, 이 가능성은 아직 살펴보지 않았다. 따라서 가변성을 어느 정도 더해보자. x 와 y 중 하나를 가변적으로 만들 수 있지만 흥미를 돋우기 위해 둘 다 가변성을 허용하겠다. 그리고 나서 앞의 스케치를 일반화시켜야 한다:
메시지 흐름은 앞과 동일하다-x dance: y로 시작하여 danceX: x 형태로 된 메시지를 y에게 재전송한다. 이제서야 춤이 발생 가능한 네 가지 변형(variations)을 볼 수 있다. 하지만 구현부에선 조건문을 전혀 사용하지 않는다! 수신하는 변수 x와 argument 변수 y가 모두 독립적으로 다형적인 다중 다형성의 예제를 갖고 있는데, 그 이유는 x와 y 둘 중 어떤 것이든 두 클래스의 인스턴스를 참조할 수 있기 때문이다.
통제력의 변화는 댄서와 발레리나가 차례로 무대를 펼치는 전형적인 파드되(pas de deux; 발레에서 두 사람이 추는 춤)에서 발생하는 것과 비슷하기 때문에 필자는 이러한 객체 지향적 방식을 파드되 또는 듀엣이라 부른다. 하지만 문헌에서 가장 많이 사용되는 이름은 이중 전달(double dispatch)이다. 이름이 어떻든 이러한 방식은 객체 지향 시스템에 널리 발생하며, 제 18장에서 패턴으로서 소개하고자 한다.
마지막으로, 다중 메서드를 앞에서 논한 런타임 타입 검사에 비해보자. C++에는 메서드를 비롯해 전통적인 함수가 없으므로 dance(x, y) 형태의 함수, 즉 실제 세계에서 두 명의 댄서들 간 대칭을 더 충실히 설명하는 함수를 이용할 수 있겠다. C++ 표준에서는 런타임 타입 검사를 지원하므로 이 dance 함수는 아래와 같은 if문으로 구성할 수 있겠다:
if ((BalletDancer*)(x) && (SwingDancer*)(y)) wild_combo(x, y);
이는 "x가 발레 댄서이고 y가 스윙 댄서라면 둘이 함께 와일드 콤보를 추도록 하라,"는 의미다. 우리가 관심을 가지는 댄서 타입의 모든 조합에 대해서도 유사한 if문을 추가할 것이다. 이러한 기법은 위에서 보인 댄싱에 대한 이중 전달 해결책의 대안책이다.
이 접근법에서 함수 dance(x, y)의 대칭은 둘 중 임의로 선호하는 하나에 강하게 의존하는 이중 전달보다 더 매력적이다. 하지만 런타임 타입 검사는 유지보수 프로그래머들로 하여금 이번 장에서 그토록 피하고자 했던 조건부 코드의 불안전성을 경험하게 한다는 단점이 있다. 게다가 대칭의 해결책은 스몰토크에선 불가능하다. 스몰토크에는 메서드만 존재한다. 따라서 둘 중 하나의 댄서를 주요(primary) 객체로 지정하여 댄스의 책임을 부여해야 한다.
Notes
- ↑ C 혹은 Ada와 같은 언어에서 컴파일러가 이름은 동일하나 argument 타입이 다른 함수들 중에서 고를 수 있는 특수 상황, 가령 정수에 foo(3)인데 반해 문자열엔 foo(“hi”)인 경우는 다중 정의(overloading)로 알려진다. 이는 연기의 예제로 볼 수 없는데, 컴파일러가 함수가 실행되기 오래 전부터 argument 타입을 검사함으로써 두 함수를 구별하기 때문이다. 오다중 정의는 단연코 동적 바인딩이 아니다.
- ↑ 타입 불일치를 초기에 발견하기 위한 C++ 정책은 안전에 대한 우리의 본능에 호소한다. 반면, 안전에는 그만한 대가를 치러야 한다. 스몰토크와 같이 변수의 타입이 정해지지 않은 언어에서 작성된 프로그램은 속도가 빠르고 수정이 쉬우며 더 유연한데, 그 변수들이 더 많은 값의 타입을 받아들일 수 있기 때문이다. 초기 타입 검사를 찬성하고 반대하는 논쟁은 감정 소모가 심하고, 감정적이고, 끝이 없으며, 오래 싸울 만큼 유익한 경우가 드물다. C++와 스몰토크가 논쟁에서 상반된 입장을 대변하고, 둘 다 훌륭한 이유로 뒷받침한다고만 해두자. 안전이 중요한 것은 맞지만 유연성 역시 중요하다.