SmalltalkObjectsandDesign:Chapter 05

From 흡혈양파의 번역工房
Jump to navigation Jump to search
제 5 장 추상 클래스

추상 클래스

이번 장과 다음 장은 객체, 클래스, 상속에 대한 기본 원칙들을 바탕으로, 그리고 그것을 넘어서 구축되는 객체 지향 필수 개념들을 소개하고자 한다.

추상 클래스는 소프트웨어 디자인에 깊이 영향을 미치는 단순한 개념이다. 그에 대한 정의는 역설적으로 들릴 수 있다: 추상 클래스는 절대로 인스턴스를 갖지 않는 클래스다. 인스턴스를 갖지 않은 클래스를 왜 애초에 빌드할까? 이번 장에서는 그러한 이 질문에 대해 답하고자 노력할 것이며, 그러한 클래스들이 객체 지향 디자인에 없어서는 안 된다는 주장까지 도달할 것이다. 몇 가지 예제로 시작하겠다.

Magnitude 클래스는 추상적이다. (14 페이지 그림을 참고하라.) Magnitude 의 인스턴스만큼 난해한 것을 사용할 사람은 없다. 그럼에도 불구하고 Magnitude 클래스는 유용한 인스턴스를 둔 DateTime 과 같은 서브클래스들을 갖고 있다. 추상 클래스는 그것의 서브클래스에 공통적인 기대(expectation)와 행위를 수집하는 데에 중심적 역할을 할 수 있다. 이번 경우 15 페이지에서 본 바와 같이 Magnitude 의 서브클래스에 공통적인 행위는 비교 가능성(comparability)이다. 예를 들어, Date 서브클래스의 두 인스턴스는 <=와 같은 메시지를 통해 비교할 수 있다. Magnitude 에는 이러한 행위를 즐길만한 인스턴스가 없기 때문에 그것의 서브클래스가 하는 것이다.

Animal 은 또 다른 추상 클래스이다. 우리는 Animal 클래스의 인스턴스에는 관심이 별로 없다-우리가 신경 쓰는 건 WhaleDog 의 인스턴스들이다. 이는 프로그래밍의 세부 사항에 그치지 않는다; 일상 세계에서의 인지적 차이로 이어진다. 우리가 시각화하는 구체적 객체(concrete object)는 동물(animals)이 아니라 고래(whales)와 강아지(dogs)인 것이다. 어찌되었든 동물처럼 추상적인 것은 어떤 모양을 할까?

스몰토크에서 모든 추상 클래스 중 가장 추상적인 것은 Object 클래스다. Object 클래스의 인스턴스는 프로그래머가 즐겨 사용하기엔 너무 모호하다. 그렇지만 Object 클래스는 모든 스몰토크 객체에 대해 가진 기대를 모으는 데에 매우 유용한 중심(center)이다: 모든 객체는 복사 가능, 표시 가능, 다른 객체와 질(quality)의 검사 등이 가능해야 한다. 깔끔하게 들리지만 문제가 하나 있다. 객체를 복사하거나 표시하는 것은 객체의 특성에 따라 많이 좌우된다. 복사 또는 표시를 위해 Object 클래스에 코드를 작성하여 그 모든 서브클래스에 의미 있게 작동하길 바라는 건 너무 순진한 생각이다. 그래서 추상 클래스는 기대의 저장소(repository)다. 우리는 객체가 표시 가능하길 기대하고 동물들이 움직일 것을 기대하지만 어떻게 실행하는지는 객체 또는 동물의 종류에 따라 좌우된다. 표시하고 복사할 실제 코드는 추상 클래스가 아니라 구체적 서브클래스에 상주할 가능성이 크다.


객체 지향 디자인으로 연습하기

Chapter 05 01.png

Table 클래스와 그 서브클래스의 계층구조를 가상으로 만들어보자. ArrayTable 의 인스턴스가 배열로서 조직된다; 즉, 그 요소들이 메모리 내 연속 오프셋(offset)에 저장되는데, 첫 번째 요소는 첫 번째 오프셋에, 두 번째는 두 번째 오프셋에 저장되는 식이다. LinkTable 의 인스턴스는 포인터들의 사슬에 의해 조직된다; 그 요소들은 메모리에 흩어져 있어서 첫 번째 요소는 두 번째를 가리키고 두 번째는 세 번째를 가리키는 식이다. 이러한 테이블의 검색에 대한 객체 지향적 의미를 살펴보고자 한다.

여기 search 메서드에 실행 가능한 의사 코드(pseudo-code)가 있다:

search for an <item>
         start
         loop while (not end and next =/ <item>)
               end loop
         if end then return not_found
               else return found


밑줄은 메서드를 나타낸다. 따라서 search 메서드는 세 가지 다른 메서드, start, end, next를 호출한다.

❏ 네 가지 메소드는 각각 어떤 클래스에서 코딩되어야 하는가?


해결책과 논의

next 부터 시작해보자. 테이블이 다음 요소로 진행되는 방식은 테이블의 유형에 따라 좌우된다. LinkTable 이 포인터를 업데이트하는 동안 (current := next) ArrayTable 은 현재 인덱스에 1 을 더한다 (index := index +1). 로직이 다르기 때문에 두 서브클래스는 자신만의 구분된 next 메서드 버전을 필요로 할 것이다.

start 메서드에 대한 상황도 비슷하다. ArrayTable 은 첫 번째 요소로 인덱스를 초기화하는 동안 (index := 1) LinkTable 은 포인터를 사실의 head 로 초기화하여 (current := head) 시작된다. 다시 말하지만, start 의 다른 버전들이 두 개의 서브클래스에서 발생해야 한다. end 메서드도 두 가지 버전이 있어야 한다: LinkTable 이 널(null) 포인터를 검사하는 동안 (current = nil) ArrayTable 은 배열의 상한 한계를 검사해야 한다 (index > upper).

여기까지 ArrayTableLinkTable 두 개의 클래스에 각각 start, next, end 메서드의 버전들을 작성해야했다:

Chapter 05 02.png


search 메서드도 두 개의 버전이 필요할까? 두 버전의 코드가 동일할 것이기 때문에 무모한 일이다; 두 코드는 단순히 위에 설명된 의사 코드를 기반으로 할 것이기 때문이다. 대신 SequentialTable 클래스에 한 번만 작성한 다음 서브클래스가 그것을 상속하도록 둘 것이다:

Chapter 05 03.png


여기서 search 를 더 높게 Table 클래스로 이동시키지 않는 이유는 뭘까? search 메서드는 강력하게 순차적인데(sequential-flavor) 일부 유형의 테이블은 비순차적(non-sequential)으로 행동하기 때문이다. 해시 테이블을 예로 들어보자. 해시 테이블은 순차적으로 검색하는 대신 직접 검색한다. 검색 항목이 주어지면 테이블은 항목 자체를 이용해 항목이 상주할지 모르는 위치를 계산한다 ("해시한다"). 이러한 테이블 유형은 그 항목들을 하나씩 반복(iterate through)하지 않는다. 해싱 계산은 위의 순차적 의사 코드와 전혀 닮지 않았다. 따라서 해시 테이블은 고유의 search 버전을 필요로 하므로, 우리는 아래처럼 계층구조에 분배된 메서드들을 만들게 된다:

Chapter 05 04.png


순수 가상 (subclassResponsibilty) 메서드

여기까지 Table 은 어떤 행위도 갖고 있지 않다. 그것의 서브클래스는 고유의 버전을 요하기 때문에 감히 search 를 작성할 수가 없었다. 반면 Table 과 같은 이름을 가진 클래스는 검색이 가능해야 한다고 주장하는 이도 있을 것이다; 어떤 테이블도 검색 가능한 것으로 기대하기 때문이다. 객체 디자이너는 Table 에 과감하게 search 메서드를 작성하되 어떤 일도 하지 않도록 만들어 이러한 곤경을 해결했다. 이렇게 비체화된(disembodied) 메서드는 순수 가상 함수 (C++), implementedBySubclass 또는 subclassResponsibility 메서드 (Smalltalk), 지연(deferred) 루틴 (Eiffel), 추상 메서드(Java)로 다양하게 불린다. 필자는 이러한 용어를 번갈아 사용할 것이며, 심지어 C++의 용어 순수 가상도 사용할 것인데, 그 이유는 무척이나 단어가 연상적이기 때문이다. (순수하게 가상적인 것보다 적게 행하는 것이 무엇이 있으랴?)

아무 일도 하지 않는 Table>>search 와 같은 메서드는 왜 만든 걸까? 두 가지 이유가 있다: Table 의 서브클래스의 잠재적 소비자들에게 search 라는 메서드를 이용해 어떤 테이블에서든 대상을 찾을 수 있다고 알리기 위해서다. 그리고 Table 서브클래스를 작성하게 될 프로그래머들에게 그들이 search 메서드를 제공할 의무가 있음을 알리기 위해서다. 따라서 순수 가상 메서드는 적어도 효과적인 문서임은 틀림없다. 단, 스몰토크에서는 이것 이상이다; 순수 가상 searchTable 소비자들과 Table 개발자들 간에 비형식적 계약에 해당한다. 무엇도 이 계약을 강요하지는 않는다. 하지만 C++와 같이 컴파일러가 계약 분쟁을 해결하는 언어에서는, 디자이너가 순수 가상 함수를 명시하는 순간 서브클래스는 함수의 구체적인 ("~를 하라") 구현부를 제공해야 하는데, 그렇지 않으면 코드는 컴파일을 실패할 것이다. 즉, C++에서 순수 가상 함수는 소비자와 개발자 간 계약을 강요한다.

순수 가상 메서드의 한 논리적 결과를 주목하라: 그러한 메서드를 가진 클래스는 필연적으로 추상 클래스여야 한다. Table 의 인스턴스는 그것의 search 메서드에 작동하는 코드를 포함하지 않기 때문에 무의미하다.


연습: 순수 가상 메서드 발견하기

❏ 우리 예제로 돌아가서, 계층구조는 Table 외에 다른 추상 클래스를 하나 포함한다. 이는 어떤 클래스이며, 이것은 순수 가상 메서드의 또 다른 기회를 의미하는가?


해결책과 논의

계층구조에서 인스턴스가 없는 클래스가 또 존재하는가? SequentialTable 이 그러한 클래스다. 그것의 서브클래스들은 인스턴스를 가질 수 있는데, 이는 상속된 search 를 비롯해 start, next, end 와 같은 전체 보완(full complement)을 지원하기 때문이지만, SequentialTable 자체는 의미가 없다. 따라서 SequentialTable 은 추상 클래스다.

이제 추상 클래스에서 기대하는 바가 따로 있지 않는 한 이 추상 클래스는 비어 있다. 이러한 기대를 기록하기 위한 매커니즘은 순수 가상 메서드를 선언하는 것이다. 이러한 메서드들은 SequentialTable 을 위해 무얼 할 수 있을까? 당연히 start, next, end 는 포함되겠다. search 의사 코드는 이러한 순차성(sequentialness)의 자연적인 특성뿐만 아니라 사실 그들의 존재를 요구한다. ArrayTableLinkTable 개발자들에게 이것을 순수 가상 subclassResponsibility 메서드로 선언하기보다 구현하도록 상기시키는 데에 어떤 방법이 좋을까? 따라서 최종 계층구조는 아래와 같다:

Chapter 05 05.png


인스턴스가 없도록 확보하기

Table 은 추상 클래스라고 결정을 내렸다; 그것의 인스턴스에는 충분한 실행가능 행위가 존재하지 않을 것이므로 쓸모가 없을 것이다. 프로그래머가 실수로 그러한 인스턴스를 생성하지 않도록 객체 지향 언어들이 보호해준다면 정말 좋을 것이다. 스몰토크에는 그런 기능이 없다. 최선의 방법은 다루기 힘든 Table 의 인스턴스가 생성되면 인스턴스가 search 메시지를 수신하는 당시에 프로그래머에게 알리는 방법이다. Table>>search 가 없다면, 메시지는 객체가 그것을 해결할 수 없다는 메시지를 수신할 때마다 발생하는 익숙한 doesNotUnderstand: walkback 을 트리거한다. Walkack은 적절한 때에 맞춰 프로그래머에게 알리지만 선호되는 기법은 search 를 생략하는 것이 아니라 아래와 같은 메서드를 작성하는 방법이다:

search: anItem
          self subclassResponsibility


이후 Table 의 인스턴스로 search 메시지를 전송하려고 시도하면 subclassResponsibility 메서드가 호출될 것이다. 이러한 메서드 또한 walkback 을 생성하지만 이는 search 의 올바른 구현부가 없음을 구체적으로 설명한다. 앞서 논한 바와 같이 이러한 방식으로 search 를 작성하면 순수 가상 메서드의 가장 중요한 서비스-추상 클래스의 문서화-를 제공한다

C++와 같이 컴파일된 객체 지향 언어는 오히려 더 낫다. 클래스에 하나의 순수 가상 함수만 명시하여도 컴파일러가 그 클래스의 객체를 선언하는 코드는 모두 거절한다. 따라서 순수 가상 함수의 존재만으로 클래스가 인스턴스를 갖지 않을 것이며, 정말로 추상적이도록 보장한다.

스몰토크든 C++이든 목표는 같다: 추상 클래스는 행위의 전체 보완이 결여되어 있으므로 프로그래머가 추상 클래스의 인스턴스를 사용하지 않길 바라는 것이다. 이는 기대를 나타내지만 뒷받침할 자원이 없다.

기술적인(technical) 여담: 때때로 상황 판단이 빠른 디자이너는 코드를 전혀 사용하지 않고, 심지어 self subclassResponsibility 도 사용하지 않고 추상 클래스에 친절한(benign) 메시지를 작성할 것이다. 예를 들자면 다음과 같다:

customInitialize
         "This method does nothing, not even cause a walkback!
         It executes harmlessly, but if you wish to provide
         some subclass-specific initialization code, feel free to
         override it in your subclass."


추상 클래스 내 일부 메서드는 (아마도 추상 클래스의 initialize 메서드) 표준 코드를 어느 정도 실행하지만 그 동안에 self customInitialize 또한 실행한다고 가정하자. 서브클래스의 프로그래머는 특별한 일을 하기 위해 선택적으로 customInitialize 를 오버라이드할 수 있지만, 만일 그럴 경우, 상속된 customInitialize 메서드는 무해하게 실행된다. 따라서 디자이너는 메인 프레임 시스템 소프트웨어의 전성기 시절 사용자 출구(user exit)라 불리던 것과 동일한, 커스텀 코드에 대한 선택적 백도어(back door)를 열었다. 오늘날 이러한 백도어는 훅(hook) 메서드라고 부르기도 한다.


추상 클래스 내 구체적 행위

내용물(Substance)이 없는 추상 클래스를 작성하지 않도록 우선 구체적 행위를 채우는 것도 가능하며, 때로는 그 편이 유익하기도 하다는 사실을 알아야 한다. 다시 말해, 추상 클래스 내 모든 메서드가 꼭 순수 가상 메서드일 필요는 없다는 말이다. 클래스에는 여전히 그것의 서브클래스들이 공유 가능한 코드가 많을 수도 있다. 예를 들어, 스몰토크의 Collection 클래스는 그의 구체적 서브클래스가-Array, Set, SortedCollection 외 다수-기꺼이 상속하는 행위를 많이 가진 추상 클래스이다.

추상 클래스 내 구체적 메서드가 때로는 하나 또는 그 이상의 순수 가상 메서드를 호출하는 것처럼 보이기도 할 것이다. 이상하게 들리겠지만 사실은 그렇지 않다. 앞서 공부한 테이블 예제에서, SequentialTable 클래스 내 search 메서드는 SequentialTable 의 순수 가상 메서드 next, start, end 를 호출하는 것으로 보이는 구체적 메서드이다. 이러한 순수 가상 버전은 사실 절대로 실행되지 않는다. 대신 우리는 SequentialTable 의 서브클래스들의 인스턴스만 사용하기 때문에 구체적인 오버라이딩 버전만 실행된다. 이는 온전한 객체 지향 디자인의 일반적인 특성으로서 초보자들이 흔히 가지는 오해인데, 필자가 다른 예제를 살펴봄으로서 이 점을 정확히 논하고자 한다.

두 개의 magnitude 객체에서 최대를 리턴하는 max: 메서드를 살펴보자. 우리는 어려움 없이 추상 클래스 Magnitudemax: 를 구체적으로 구현할수 있다:

max: anotherMag
         self < anotherMag
               ifTrue: [ ^anotherMag ]
               ifFalse: [ ^self ]


이 코드에서 이상한 점은, 그것이 Magnitude 클래스 내에 있는데도 Magnitude 클래스에서 구현될 수 없는 메서드 <를 사용한다는 점이다. (< 와 같은 비교 연산자는 객체의 내부 표현에 의존하는데, 이러한 표현들은 서브클래스마다 다르기 때문이다. 예를 들어, 객체에 대한 Time 서브클래스의 표현은 Float 서브클래스의 표현과 다르다. 이것이 바로 15 페이지에서 논한 "한 가지 조건(catch)"에 해당한다.)

이를 배경으로 할 때- max:가 서브클래스에서 정의되어야 하는 메서드를 사용할 때- Magnitude 계층구조 내 어떤 종류의 객체들이 max: 의 코드를 실행할 수 있을까? Magnitude 는 추상 클래스이고 인스턴스를 가지면 안 되기 때문에 Magnitude 의 인스턴스는 분명히 아니다. 따라서 서브클래스의 인스턴스만이 자격에 부합한다. Float 의 인스턴스 13.7와 아래 메시지를 고려해보라:

13.7 max: 17.3


클래스 계층구조에서 관련된 부분은 다음과 같다:

Chapter 05 06.png


이제 13.7 의 클래스 Floatmax: 를 구현하지 않는다. 따라서 13.7은 max: 를 구현하는 슈퍼클래스-이번 경우 Magnitude-를 찾을 때까지 상속 트리를 상향으로 검색한다. 그리고 위의 max: 코드가 실행된다. 첫 행은 이항 메시지 <self 로 (13.7) 전송한다. 이번에는 13.7이 < 를 그것의 고유 클래스 Float 가 구현되는 메서드로 인식하고, 그에 따라 상속에 의지하지 않고 실행된다. 그 결과 true 가 발생하고 (anotherMag17.3 이므로), 코드는 최종 결과로 17.3을 (anotherMag) 리턴하는 brach 를 실행한다.

간단하게 요약하자면, max: 에 대한 코드는 Magnitude 에서는 올바르게 작성될 수 없는 순수 가상 메서드들에 따라 좌우된다는 사실을 완전히 이해한 후에 Magnitude 에서 작성되었다. Magnitude 의 어떠한 인스턴스도 max:를 성공적으로 실행할 수 없으며, Magnitude 의 서브클래스의 인스턴스만 max: 를 실행할 수 있다. 마지막으로, max: 내 코드는 self 를 나타내기 때문에 실행은 슈퍼클래스 메서드(max:)로부터 서브클래스 메서드(<)까지 하향하여 흐를 수 있다.

이는 온전한 객체 지향 디자인의 전형적인 예이다. 또 재사용을 최대화하는데, 그 이유는 max: 메서드는 하나이고 <의 버전은 필요한 만큼 많으나 필요량 이상은 초과하지 않기 때문이다. 더 중요한 것은 서브클래스 구현자(implementor)가 < 와 같은 서브클래스 특정적 메서드로 주의를 돌릴 수 있다는 점이다; 생각하지 않고 max:의 이점을 즐길 수 있다.

자신의 애플리케이션에서 max: 와 같은 재사용 가능한 메서드들을 작성할 수 있는 기회를 모색해야 한다. 개발자들마다 다른 서브클래스를 작성하는 큰 프로젝트에서는 불행히도 공통 함수(common function)를 통일할 수 있는 기회를 인지하는 경우가 덜하다. 사실상 각 개발자는 약간씩 다른 max: 버전을 작성한다. 따라서 생각하고, 디자인하고, 코딩하며, 특히 관리하는 데에 비용이 중복된다. 문제가 되는 메서드가 max: 보다 더 복잡할 때 그 중복에 몇 시간에서 며칠까지 소요되기도 한다.


요약: 추상 클래스 내 메서드들

추상 클래스 내 메서드들은 세 가지 주요 형태를 가진다. 먼저 순수 가상이 있다 (subclassRespnsibility, deferred, abstract, implementedBySubclass); 서브클래스 개발자들은 이들을 구체적 구현부로 오버라이드해야만 한다. 일부는 구체적이고 독립적(self-contained)이다; 서브클래스 개발자들은 어떠한 의무 없이 이를 상속한다. 그리고 일부는 위의 max: 와 같다 (또는 SequentialTable>>search); 서브클래스 개발자들은 메서드가 필요로 하는 구체적 행위를 어느 정도 제공할 의무가 있지만 전체 메서드는 오버라이드하지 않는다.

세 가지 형태를 구별하는 용어에 대한 합의는 이루어지지 않았지만 [Wirfs-Brock et al. 1990]과 [Johnson and Russo 1991]은 이들을 각각 추상 메서드(Java 용어), 기반(base) 메서드, 템플릿(template) 메서드라고 부른다. [Gamma et al. 1995] 또한 마지막 종류를 템플릿 메서드라고 부른다. 필자는 서브클래스와 관련해 실행이 튕겨 올라갔다가 내려오기 때문에 요요(yo-yo) 메서드라고 생각하며, 프레임워크 디자이너 Kirk Wolf는 이러한 현상을 분명한 하향 호출(down-call)이라 칭했다.

19장에서는 추상 클래스와 이러한 세 가지 종류의 메서드들이 객체 지향 프레임워크에서 핵심임을 보게 될 것이다.


Notes