Ch12 요약(다형성)

Ch12 다형성

본문


들어가며

챕터 12 다형성의 내용은 객체지향 설계에 대한 내용이라기 보다는 상속이 다형성을 구현하는 메커니즘에 대한 내용이다.

객체지향 패러다임이 성공을 거둔 핵심적인 이유 중 하나는 지속 가능한 설계가 가능한 것이라고 생각한다. 그리고 이 지속 가능성이란 유지보수의 용이성확장의 용이성에서 기인한 것이라고 할 수 있다.

다형성이 객체지향을 논할 때 빠질 수 없는 핵심적인 키워드인 이유가 바로 지속 가능한 설계를 가능하게 하기 때문이다.

이번 챕터의 주제는 상속의 관점에서 다형성이 구현되는 메커니즘 살펴보기 이다.

다형성이란 동일한 메시지에 대하여 서로 다른 메서드를 실행할 수 있는 것 이라고 하였다. 그렇다면 객체지향 프로그래밍에서는 어떤 메커니즘으로 동일한 메시지에 대해서 서로 다른 메서드를 실행할 수 있을까?

핵심을 먼저 간추리자면

  1. 런타임에 메시지와 메서드를 바인딩하는 동적 바인딩
  2. 런타임에 메시지를 처리할 메서드를 탐색하는 동적 메서드 탐색

이 두 가지에 유념하여 챕터를 살펴보도록 하자.


핵심.

  • 상속의 목적은 코드 재사용이 아니다. 상속타입 계층 구조화를 위해서 사용해야 한다.

  • 상속을 사용하려는 목적은 ‘클라이언트’ 관점에서 객체들을 동일하게 행동하는 그룹으로 묶기 위함이다.

  • 다형성런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현된다.

  • 상속은 동적으로 메서드를 탐색하기 위한 일종의 탐색 경로를 클래스 계층 형태로 구현하기 위한 방법이다.

  • 상속의 진정한 목적은 코드 재사용이 아니라 다형성을 위한 서브타입 계층을 구축하는 것이다.

  • 상속을 사용하는 가장 큰 이유는 상속이 클래스를 계층으로 쌓아올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다.

  • 객체가 메시지를 수신하면 객체지향 시스템은 메시지를 처리할 적절한 메서드를 상속 계층 안에서 탐색한다.

  • 상속의 메커니즘을 이해하는데 업캐스팅, 동적 메서드 탐색, 동적 바인딩, self 참조, class 참조, super 참조가 중요한 키워드다.

  • 자식 클래스의 인스턴스는 직접 정의한 인스턴스 변수 뿐 아니라 부모 클래스가 정의한 인스턴스 변수도 함께 포함한다.

  • 상속을 인스턴스 관점에서 바라볼 때 개념적으로 자식클래스의 인스턴스가 부모 클래스의 인스턴스를 포함하고 있는 것으로 바라보는 것이 유용하다.

  • 상속을 하면 부모 클래스의 퍼블릭 인터페이스자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다.

  • 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐진다고 표현했지만 실제로 클래스의 코드가 합쳐지거나(.class에 합쳐지거나) 부모 클래스의 퍼블릭 인터페이스 코드를 자식 클래스에 복사하는 것이 아니다.

  • 런타임에 자식 클래스의 인스턴스에게 전송한 메시지에 대해서 메서드가 자식 클래스에 정의되지 않은 경우, 부모 클래스에 위임하여 부모 클래스에서 메서드 탐색을 한다.

  • 인스턴스의 경우에는 서로 다른 상태를 저장할 수 있도록 각 인스턴스 별로 독립적인 메모리(Heap)를 할당 받아야 하지만, 메서드의 경우 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한번만 메모리에 로드(Class Area or Method Area) 하고 각 인스턴스들이 클래스(Class 메타 정보)를 가리키는 포인터를 갖게 하는것이 경제적이다.

  • 서로 다른 상태를 갖는 인스턴스들은 Heap에 개별 할당되고, 메서드는 공유할 수 있도록 Class Area에 할당된다. 그리고 각 인스턴스들은 이 Class Area에 존재하는 Class의 위치를 가리키는 Class Pointer를 갖는다.

  • 메시지를 수신한 객체는 갖고있는 Class Pointer를 이용하여 연결된 자신의 Class 메타정보를 찾아 적절한 메서드가 존재하는지를 검색 한다. 만약 메서드가 존재하지 않는다면, Parent Pointer를 따라 부모 클래스를 차례로 훑어가면서 적절한 메서드가 존재하는지를 검색 한다.

  • 자식 클래스에서 부모 클래스로의 메서드 탐색이 가능하기 때문에 자식 클래스는 마치 부모 클래스의 구현된 메서드 복사본을 갖고있는 것 처럼 보인다.

  • Class PointerParent Pointer를 조합하면 현재 인스턴스의 클래스에서 최상위 부모 클래스에 이르기 까지 모든 부모 클래스에 접근하는 것이 가능하다.

  • 코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신한 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 ‘업캐스팅’과 ‘동적 바인딩’이라는 메커니즘이 작용하기 때문이다.

  • 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다(ex: List = new ArrayList<>();). 이를 업캐스팅이라고 부른다.

  • 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아닌 런타임에 결정하기 때문에 가능하다. 이를 동적 바인딩이라고 한다.

  • 동일한 수신자에게 동일한 메시지를 전송하는 동일한 코드를 이용하여 서로 다른 메서드를 실행할 수 있는 이유는 업캐스팅동적 메서드 탐색이라는 메커니즘이 존재하기 때문이다.

  • 업캐스팅은 서로 다른 클래스의 인스턴스를 동일한 타입에 할당하는 것을 가능하게 해준다. 따라서 부모 클래스에 대해 작성된 코드를 전혀 수정하지 않고도 자식 클래스에 적용할 수 있다.

  • 동적 메서드 탐색부모 클래스의 타입에 대해 메시지를 전송하더라도 실행 시에는 실제 클래스를 기반으로 실행될 메서드가 선택되게 해준다.

  • 객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩이라 부른다.

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적절한 메서드가 존재하는지 탐색한다. 존재하면 메서드를 실행하고 탐색을 종료한다. 만일 존재하지 않는다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때 까지 상속 계층을 따라 올라가며 계속된다.

  • 객체가 메시지를 수신하면 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다.

  • 동적 메서드 탐색self 참조가 가리키는 객체의 클래스에서 시작하여 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동 소멸된다.

  • 시스템은 self 참조, class pointer, parent pointer를 조합하여 메서드 탐색을 한다.

  • 동적 메서드 탐색자동적인 메시지 위임동적 문맥이라는 원리로 구성된다.

  • 자동적인 메시지 위임이란 자식 클래스가 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 메시지 처리를 자동으로 위임 하는 것이다.

  • 동적 문맥이란 메시지를 수신했을 때 실제 어떤 메서드를 실행할지는 컴파일 시점이 아닌 런타임에 이뤄진다. 그리고 메서드 탐색 경로는 self 참조를 이용해서 결정한다.

  • ‘런타임’에 실제로 메시지를 수신한 객체가 어떤 타입인지에 따라 메서드 탐색 경로가 결정된다. 여기서 가장 중요한 역할을 하는 것이 바로 메시지를 수신한 객체를 가리키는 ‘self 참조’이다.

  • 동일한 코드라고 하도라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 변경할 수 있다.

  • 자기 자신에게 다시 메시지를 전송하는 것을 self 전송(this.operation())이라고 부른다.

  • self 전송을 이해하기 위해서는 메서드 호출이 아니라 메시지 전송에 유념해야 한다.

  • self 전송self 참조가 가리키는 바로 그 객체에서 부터 메시지 탐색을 다시 시작한다는 사실을 기억해야 한다.

  • self 전송을 통해 메서드 탐색이 처음 시작했던 self 참조가 가리키는 클래스 부터 다시 시작한다.

  • self 전송자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다.

  • 동적 바인딩, self 참조, class pointer, super 참조는 상속을 이용해 다형성을 구현하고 코드 재사용을 위한 가장 핵심적인 재료다.

  • 동적 바인딩, self 참조동일한 메시지를 수신하더라도 객체의 타입에 따라 적합한 메서드를 동적으로 선택할 수 있게 한다.

  • 다형성self 참조가 가리키는 현재 객체에게 메시지를 전달하는 특성을 기반으로 한다. 동일한 타입의 객체 참조에게 동일한 메시지를 전달하더라도 self 참조가 가리키는 객체의 클래스가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 달라진다.

  • self 참조동적인 문맥을 결정한다는 사실을 이해하고 나면 상속을 바라보는 새로운 시각이 형성된다. 바로 자식 클래스에서 부모 클래스로 ‘self 참조’를 전달하는 메커니즘으로 상속을 바라보는 것이다.

  • 메서드 탐색중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를 공유한다.

  • 위임은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드 탐색 과정을 다른 객체로 이동시키기 위해 사용된다. 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다. 이것이 self 참조를 전달하지 않는 포워딩과 위임의 차이점이다.

  • 위임의 정확한 용도는 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현하는 것이다.


다형성

다형성이란 동일한 메시지에 대해 서로 다른 메서드를 실행할 수 있는 것이다. 다시말해, 메시지를 수신한 객체의 구체적인 클래스 타입에 따라 실행되는 메서드를 변경할 수 있다.

이러한 메커니즘이 가능한 이유는

  1. 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하게끔 하는 업캐스팅
  2. 런타임메시지를 처리할 메서드를 결정하는 동적 바인딩
  3. 런타임에 메시지를 수신한 객체에서부터 탐색이 시작되는 동적 메서드 탐색
  4. 런타임에 메시지를 수신한 객체를 가리키는 변수인 self 참조
  5. 클래스의 메서드와 같은 메타 정보를 저장하는 Class와 이 위치를 가리키는 class pointer

과 같은 요소들이 있기 때문이다.

아래에서 하나씩 살펴보며 다형성이 어떤 기술적 메커니즘으로 구현되는지 살펴보자.

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에 전송할 수 있는 메시지를 자식 클래스의 인스턴스에 전송할 수 있다. 즉, 컴파일러는 명시적인 타입 변환이 없어도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.

컴파일러의 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있기 때문에 부모 클래스의 인스턴스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력이 가능하다.


동적 바인딩

객체지향 시스템에서 메서드를 실행하는 유일한 방법은 메시지를 전송하는 것이다. 메서드 호출을 위해서는 메시지 전송이 먼저 선행되어야 한다.

그리고 객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. foo.bar()라는 코드를 읽는 것만으로는 실행되는 bar가 어떤 클래스의 어떤 메서드인지 판단하기가 어렵다. foo가 가리키는 객체가 실제로 어떤 클래스의 인스턴스인지를 알아야 하고, bar() 메서드가 해당 클래스의 상속 계층의 어디에 위치하는지를 알아야 한다.

이처럼 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩이라고 부른다.

객체지향 언어가 제공하는 업캐스팅동적 바인딩을 사용하면 부모 클래스 참조 변수에 대한 메시지 전송을 자식 클래스 메서드 호출로 변환할 수 있다.


동적 메서드 탐색

런타임메시지를 실행할 메서드를 바인딩하는 것이 동적 바인딩이라고 하였다. 그리고 이러한 기술적인 메커니즘을 가능하게 하는 규칙이 바로 동적 메서드 탐색이다.

동적 메서드 탐색동적 바인딩을 가능하게 하는 규칙이다. 그리고 아래의 규칙에 따라 실행할 메서드를 선택한다.

  1. 메시지를 수신한 객체(self 참조가 가리키는 객체)는 먼저 자신을 생성한 클래스(class pointer가 가리키는 클래스)에 메시지를 처리할 적합한 메서드가 있는지를 탐색한다. 만일 존재한다면 메서드를 실행하고 탐색을 종료한다.
  2. 메서드를 찾지 못했다면 부모 클래스(parent pointer가 가리키는 클래스)에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때 까지 상속 계층을 따라 올라가며 계속된다.

self 참조 변수

메서드 탐색과 관련해서 기억해야 할 중요한 변수가 하나 있다. 바로 메서드 탐색의 시작점이자 경로를 결정하는 ‘self 참조’ 변수 이다.

self 참조 변수는 객체가 메시지를 수신하면 컴파일러가 자동으로 생성하여 메시지를 수신한 객체를 가리키도록 설정한다. 이 때문에 동적 메서드 탐색은 self가 가리키는 객체의 class부터 시작하여 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다.

시스템은 self 참조, class pointer, parent pointer를 함께 조합하여 메서드를 탐색한다.


동적 메서드 탐색의 두 가지 원리

지금까지의 내용을 종합해보면 동적 메서드 탐색두 가지 원리로 구성된다는 것을 알 수 있다.

  1. 자동적인 메시지 위임 : 자식 클래스는 자신이 처리할 수 없는 메시지를 전송받으면 상속 계층을 따라 부모 클래스에게 메시지 처리를 위임한다.
  2. 동적 문맥 : 메시지를 수신했을 때 어떤 메서드를 실행할지는 컴파일 시점이 아닌 런타임 시점에 결정되며, 메서드를 탐새하는 경로는 ‘self 참조’를 통해서 결정한다.

메시지가 처리되는 문맥을 이해하기 위해서는 런타임에 실제로 메시지를 수신한 객체가 어떤 타입인지를 추적해야 한다. 이 객체의 타입에 따라 메서드를 탐색하는 문맥이 동적으로 결정되며, 여기서 가장 중요한 역할을 하는 것이 바로 ‘self’ 참조다.

자동적인 메시지 위임

동적 메서드 탐색의 관점에서 상속 계층을 바라본다면, 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지는 부모 클래스에게 전달하기 위한 물리적인 경로를 정의하는 것으로 볼 수 있다. 상속 계층 안의 클래스는 메시지를 처리할 방법을 알지 못할 경우 메시지에 대한 처리를 부모 클래스에게 위임한다.

여기서 핵심은 적절한 메서드를 찾을 때 까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 사실이다.

상속을 사용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없다. 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다. 이런 관점에서 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.


동적인 문맥

중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀐다는 것이다. 그리고 이 동적인 문맥을 결정하는 것이 메시지를 수신한 객체를 가리키는 ‘self 참조’ 이다.

동일한 코드라고 하더라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행 될 문맥을 동적으로 변경할 수 있다.


self 전송

객체지향 시스템에서 어떤 메서드 호출 구문을 보았을 때는 항상 메시지 전송 이후 메서드 호출이 됨을 유념해야 한다.

이를테면 this.method()현재 클래스의 메서드를 호출하는게 아니라 현재의 (self 참조가 가리키는)객체에게 메시지를 전송하는 것이다.

많이 사용되는 패턴인 템플릿 메서드 패턴을 떠올리면 쉽다. 템플릿 메서드 패턴에서는 추상화된 퍼블릭 인터페이스를 정의하고 부모 클래스에서 호출한다. 근데 실제로 호출되는 메서드는 추상 메서드를 구현한 자식 클래스의 메서드가 호출된다. 이는 실제로 메시지를 수신한 객체가 자식 클래스의 객체로, ‘self 참조’가 가리키는 객체는 자식 클래스의 객체이기 때문이다.

이와 같이 어떤 클래스에서 자신의 메서드를 호출하는 것 처럼 보이더라도 런타임에 ‘self 참조’가 가리키는 객체가 무엇이냐에 따라 자식 클래스의 메서드가 호출될 수 있다.

결론적으로 self 전송을 이해하기 위해서는 self 참조가 가리키는 바로 그 객체(메시지를 수신한 객체)에서 부터 메시지 탐색을 다시 한다는 사실을 이해해야 한다.



상속 vs 위임

지금까지 살펴본 바와 같이 다형성self 참조가 가리키는 현재 객체에게 메시지를 전달하는 특성을 기반으로 한다.

동일한 타입의 객체 참조(참조 변수)에게 동일한 메시지를 전송하더라도 런타임에 self 참조가 가리키는 객체의 클래스가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 달라진다.

일단 self 참조가 동적인 문맥을 결정한다는 사실을 이해하고 나면 상속을 바라보는 새로운 시각이 형성된다.

바로 자식 클래스에서 부모 클래스로 ‘self 참조’를 전달하는 메커니즘으로 상속을 바라보는 것이다.

아래와 같은 Ruby로 구현한 코드를 살펴보자. 미리 말하지만 클래스 기반의 Java와 같은 객체지향 언어에서는 본질적인 위임을 코드로 구현하기 어렵다.

위임은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드 탐색 과정을 다른 객체로 이동시키기 위해 사용된다. 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다.

다시말해 위임객체 사이의 동적인 연결 관계를 이용해 ‘상속’을 구현하는 방법 이다.


나머지

이번 장에서는 다형성을 구현하기 위해 상속이 어떠한 메커니즘을 갖는지와 위임이 갖는 의미에 대해서 살펴보았다.

클래스 기반의 객체지향 언어든, 프로토타입 기반의 객체지향 언어든 본질은 같다.

  1. 메시지를 수신한 객체를 가리키는 self 참조로 부터 런타임에 메서드를 동적으로 탐색하기 위한 경로인 동적인 문맥이 형성되고,
  2. 탐색 과정에서 필요에 따라 부모 클래스에게 self 참조를 전달하여 메시지 처리를 위임하는 과정을 통해 다형성이 구현된다.

참고 및 출처

  • 오브젝트