본문 바로가기

General

[오브젝트] 12장 다형성

 

01 다형성

다형성(Ploymorhpism)은 객체지향 프로그래밍의 중요한 특성 중 하나이다. 많은 poly 형태 morph 를 가질 수 있는 능력을 뜻하는 그리스어에서 유래했다. 컴퓨터 과학에서는 하나의 추상 인터페이스에 대해 코드를 작성하고, 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력 뜻한다고 한다. 일반적으로 객체지향에서 다형성이라고 하면, '포함 다형성'을 의미한다.

포함 다형성(Inclusion Polymorphism)이란 메시지가 동일해도 수신한 객체의 타입에 따라 수행되는 행동이 달라지는 경우를 말한다. 가장 일반적으로 상속으로 구현 할 수 있다. 이때 자식 클래스가 부모 클래스의 서브타입임을 전제로 한다. (그래서 서브타입 다형성이라고도 부른다.)

  1. 두 클래스를 상속 관계로 연결한다.
  2. 자식 클래스에서  부모 클래스 메서드 오버라이딩한다.
  3. 클라이언트는 부모 클래스만 참조한다.

 

상속은 클래스들을 계층으로 쌓아 올린 후 상황에 따라 적절한 메서드를 처리할 수 있는 메커니즘을 제공한다.

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

실행 메서드는, 어떤 메시지를 수신했는지, 어떤 클래스의 인스턴스인지, 상속 계층이 어떻게 구성되어있는지에 따라 결정된다.

 

상속의 목적은 다형성의 기반이 되는 서브타입 계층을 구축하는 것이다. 다형성을 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정이라고 볼 수 있는데, 상속은  탐색 경로를 클래스 계층 형태로 구현하는 방법이라고도 할 수 있다.

상속을 단순히 부모 클래스에서 정의한 데이터와 행동을 자식 클래스에서 자동적으로 공유하는 메커니즘으로 바라보는 관점은 상속을 오해한 것이다. 코드를 재사용 하기 위한 목적이라면 상속을 사용해서는 안된다. 타입 계층에 대한 고민 없이, 재사용을 위해 상속을 사용하면 나쁜 코드를 작성할 확률이 높다.

 

다형성 분류

  • 다형성 Polymorphism
    • 유니버설 다형성 Universal Polymorphism
      • 매개변수 다형성 Parametric Polymorphism
        • 인스턴스변수/매개변수 타입을 임의로 선언한 후, 사용하는 시점에 구체적인 타입으로 지정하는 경우: <T>
      • 포함 다형성 / 서브타입 다형성 Inclusion Polymorphism / Subtype Polymorphism
        • 메시지가 동일해도 수신한 객체의 타입에 따라 수행되는 행동이 달라지는 경우
    • 임시 다형성 Ad-hoc Polymorphism
      • 오버로딩 다형성 Overloading Polymorphism
        • 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우
      • 강제 다형성 Coercion Polymorphism
        • 동일한 연산자에 다양한 타입의 피연산자를 쓸 수 있는 경우: '1' + 0

 

02 상속의 양면성

 

객체지향 패러다임의 근간은 데이터행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것이다. 따라서, 객체지향에서는 데이터 관점, 행동 관점 2가지 관점에서 모두 고려해야 할 필요가 있다.

 

메서드 오버라이딩은 부모 클래스와 자식 클래스에 이름도 같고, 동일한 시그니처를 가진 메서드가 존재할 경우이다. 자식 클래스의 메서드 우선순위가 더 높아 메시지를 수신했을 때 자식 클래스의 메서드가 실행된다.

메서드 오버로딩은 부모 클래스와 자식 클래스에 이름은 같지만 서도 다른 시그니처를 가진 메서드가 존재할 경우이다. 자식 클래스의 메서드는 부모 클래스의 메서드를 대체하지 않고, 사이좋게 공존할 수 있다. 클라이언트는 두 메서드를 모두 호출할 수 있다.

 

데이터 관점의 상속은 포함관계로 생각할 수 있다. (부모 클래스 인스턴스 ⊂ 자식 클래스 인스턴스) 예를 들어, GradeLecture 클래스의 인스턴스는 Lecture가 정의한 인스턴스 변수도 모두 포함한다. 또는, 접근 가능한 링크가 있는 것으로 생각할 수도 있다. (자식 클래스의 인스턴스 → 부모 클래스의 인스턴스) gradeLecture 에서는 GradeLecture의 인스턴스에는 직접 접근할 수 있지만, Lecture의 인스턴스에는 직접 접근할 수 없다.

행동 관점의 상속 역시 포함관계로 생각할 수 있는데, (부모 클래스의 모든 퍼블릭 메서드 ⊂ 자식 클래스의 퍼블릭 인터페이스) 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지게 된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는, 자식 클래스에게도 전송할 수 있다.

 

 

03 업캐스팅과 동적 바인딩

업캐스팅(upcastiong)은 자식 클래스가 부모 클래스를 대체할 수 있다는 개념이다. 컴파일러 관점에서는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다는 의미이다. 클라이언트 관점 부모 클래스와 협력하고 있다면 (심지어 아직 태어나지도 않은) 다양한 자식 클래스 인스턴스와도 협력 가능하는 의미이다.

대입문과 파라미터 타입의 경우를 예로 들 수 있다.

  • 대입문
    • const lecture: Lecture = new GradeLecture(...);
    • 부모클래스(Lecture) 타입으로 선언된 변수에 자식 클래스(GradeLecture)의 인스턴스를 할당할 수 있다.
  • 파라미터 타입
    • const professor = new Professor("다있었더라", new GradeLecture(...));

업캐스팅의 반대 개념인, 다운캐스팅(downcasting)은 부모 클래스의 인스턴스를 자식 클래스의 타입으로 변환하기 위해 명시적으로 타입 캐스팅이 필요하다는 개념이다.

 

동적바인딩(dynamic binding)은 (객체지향 언어에서) 실행될 메서드가 런타임에 결정되는 방식을 가리킨다.  지연 바인딩(late binding)이라고도 한다. 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다.

  • foo.bar()라는 코드를 읽는 것만으로 실행되는 bar가 어떤 클래스의 어떤 메서드인지 판단하기 어렵다.
  • Professor의 compileStatistics 메서드가 호출하는 lecture의 evaluate 메서드는 어떤 클래스에 정의되어있는지 알 수 있을까?
    • 클래스 정의만 봐서는 알 수 없다. 실행 시점에 어떤 클래스의 인스턴스를 생성해서 전달하는지를 알아야만 실제로 실행되는 메서드를 알 수 있다.

동적바인딩의 반대 개념인, 정적 바인딩(static binding)은 (전통적인 언어에서) 호출될 함수가 컴파일타임에 결정되는 방식을 가리킨다.

 

업캐스팅과 동적 메서드 탐색은 개방-폐쇄 원칙(Open-Closed Principle)을 달성하기 위한 수단이다.

객체지향의 5원칙, SOLID 원칙 중 O에 해당하는 개방-폐쇄 원칙은, "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다."라는 원칙이다.

객체지향 프로그래밍 언어는 업캐스팅, 동적 메서드 탐색 같은 메커니즘의 도움을 받아 동일한 메시지에 대해 서로 다른 메서드를 실행할 수 있는 다형성을 구현한다. 코드를 변경하지 않고도 기능을 추가할 수 있게 된다. 부모 클래스에 대해 작성된 코드를 전혀 수정하지 않고도 자식 클래스에 적용할 수 있게 된다. 어떤 자식 클래스와도 협력할 수 있는 무한한 확장 가능성을 가진다. 따라서 이 설계는 유연하며 확장이 용이하다.

 

 

04 동적 메서드 탐색과 다형성

동적 메서드 탐색과 관련된 규칙은 언어마다 다를 수 있다. (예를 들어 C++에서는, 부모 클래스의 메서드와 동일한 이름의 메서드를 자식 클래스에서 오버로딩하면 그 이름을 가진 모든 부모 클래스의 메서드를 감춰버린다: name hiding)

동적 메서드 탐색 과정은 다음과 같다.

  1. 객체가 메시지를 수신한다.
  2. self 참조라는 임시변수를 생성한 후, 메시지를 수신한 객체를 가리키도록 설정한다.
  3. self 참조가 가리키는 메모리로 이동한다.
  4. 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다.
    • 존재하면 메서드를 실행하고 탐색을 종료한다.
    • 메서드 탐색이 종료되면 self 참조도 자동으로 소멸된다.
  5. 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. (예제: class 포인터)
  6. 상속 계층을 따라 올라가며 적합한 메서드를 찾을 때까지 이 과정을 반복한다. (예제: parent 포인터)
  7. 상속 계층의 가장 최상위 클래스에 도달한다.
  8. 그럼에도 메서드를 발견하지 못한 경우, 예외를 발생시키며 탐색을 중단한다. // 마치 자바스크립트의 프로토타입 체이닝

동적메서드 탐색의 원리는 "자동적인 메시지 위임", "동적인 문맥 사용" 2가지로 정리할 수 있다.

 

여기서는 메시지가 위임되는 과정을 설명하기 위해 상속을 사용하고 있지만, 자동적인 메시지 위임을 지원하는 방법은 언어마다 다를 수 있다.

자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우, 상속 계층을 따라 부모 클래스에게 처리를 위임한다. 클래스 사이의 위임은 프로그래머의 개입 없이 상속 계층을 따라 자동으로 이뤄진다.

상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에게 전달하기 위한 물리적인 경로라고 할 수 있다. 상속을 정의하는 것은 메서드 탐색 경로를 정의하는 것이라고 표현할 수도 있다. 상속을 이용할 경우, 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없다.

 

메서드 탐색을 위한 문맥은 동적으로 결정된다. 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지를 결정하는 것은 컴파일 타임이 아닌 런타임에 이뤄진다.

self 참조는 메시지를 수신한 객체를 가리키는 포인터 이다. 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조이다. 즉, 어떤 메서드 탐색 경로를 따를지 self 참조를 이용해서 결정한다. 따라서 동일한 코드라고 해도 self 참조 가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다.

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

  1. GradeLecture에 stat 메시지를 전송한다
  2. self 참조가 GradeLecture의 인스턴스를 가리키도록 설정된다
  3. 메서드 탐색이 GradeLecture 클래스부터 시작된다.
  4. GradeLecture 클래스에는 stats 메서드를 처리할 적절한 메서드가 존재하지 않음을 확인한다.
  5. 부모 클래스인 Lecture 클래스에서 메서드 탐색을 계속한다.
  6. Lecture 클래스의 stats 메서드를 발견하여 이를 실행한다.
  7. stats를 실행 중 self가 가리키는 객체에게 getEvaluationMethod 메시지를 전송한다.
  8. getEvaluationMethod 메서드 탐색을 Lecture를 벗어나 GradeLecture부터 다시 시작한다.
  9. GradeLecture 클래스에서 getEvaluationMethod 메서드를 발견하여 이를 실행한다.
  10. (동적 메서드 탐색 종료)

단, 이러한 동적인 특성과 유연성은 코드를 이해하기 어렵게 만든다. 깊은 상속 계층에 중간중간에 함정처럼 숨겨진 메서드 오버라이딩과 만나면 이해하기 어려운 코드가 된다.

 

self참조와 함께 알아두면 좋은 super참조는 부모 클래스(부터)의 인스턴스 변수나 메서드에 접근하기 위해 사용하는 내부 변수를 가리키는 용어이다. "지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요"라는 의미로 호출된다.

self 참조와 다르게 컴파일 시점에 미리 결정해 놓을 수 있다. 항상 해당 클래스의 부모 클래스에서부터 메서드 탐색을 시작한다. 대부분의 객체지향 언어에서는 상속을 사용하는 경우에는 super가 컴파일타임에 결정된다. 단, 사용하는 언어의 특성에 따라 컴파일 시점이 아닌 실행 시점에 super의 대상이 결정될 수도 있다.

부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 한다. super 참조를 통해 메시지를 전송하는 것은, 마치 부모 클래스의 인스턴스에게 메시지를 전송하는 것처럼 보이기 때문에 "super 전송"이라고 한다.

 

05 상속 대 위임

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

위임(Delegation)이란 자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것이다. self 참조를 함께 전달한다.

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

상속은 동적으로 메서드를 탐색하기 위해 현재의 실행 문맥을 가지고 있는 "self 참조"를 전달하고, 그리고 이 객체들 사이에서 "메시지"를 전달하는 과정은 "자동"으로 이루어진다는 것이다. 따라서 자동적인 메시지 위임이라고 표현할 수 있다.

클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며, 상속 없이도 다형성을 구현하는 것이 가능하다. 프로토타입 언어처럼 위임을 통해 객체 수준에서 구현할 수도 있다.

프로토타입 기반 객체지향 언어에서는 프로토타입 체인을 따라 자동적으로 메시지에 대한 위임을 처리한다. 프로토타입 체인은 prototype 으로 연결된 객체들의 체인이다. 객체 사이에 self 참조를 자동으로 전달하는 위임 과정은 클래스 기반 상속과 거의 동일하다.

 

 

출처: 오브젝트(코드로 이해하는 객체지향 설계) - 조영호 님