Post

다형성

12. 다형성

코드 재사용을 목적으로 상속을 사용하면 변경하기 어렵고 유연하지 못한 설계에 이를 확률이 높아진다.

상속은 타입 계층을 구조화하기 위해 사용

타입 계층은 객체지향 프로그래밍의 중요한 특성 중 하나인 다형성의 기반을 제공

최근의 언어들은 상속 이외에도 다형성을 구현할 수 있는 다양한 방법들을 제공하고 있기 때문에 과거에 비해 상속의 중요성이 많이 낮아짐

12.1 다형성

다형성(Polymorphism): 많은 형태를 가질 수 있는 능력

하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의

다형성은 유니버셜(Universal) 다형성임시(Ad Hoc) 다형성으로 분류

유니버셜 다형성은 매개변수(Parametric) 다형성포함(Inclusion) 다형성으로 분류

임시 다형성은 오버로딩(Overloading) 다형성강제(Coercion) 다형성으로 분류

하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우 -> 오버로딩 다형성

1
2
3
4
5
public class Money {
  public Money plus(Money amount) { ... }
  public Money plus(BigDecimal amount) { ... }
  public Money plus(long amount) { ... }
}

강제 다형성: 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식

ex. 정수 + 문자열 에서 정수가 문자열로 자동으로 바뀌는 것

매개변수 다형성: 클래스의 인스턴스 변수 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식

ex. java의 list

포함 다형성: 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력(서브타입 다형성)

포함 다형성을 구현하는 가장 일반적인 방법은 상속을 사용하는 것

포함 다형성을 위한 전제조건은 자식 클래스가 부모 클래스의 서브타입이어야 한다는 것

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

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

실행할 메서드를 선택하는 기준은 어떤 메시지를 수신했는지에 따라, 어떤 클래스의 인스턴스인지에 따라, 상속 계층이 어떻게 구성돼 있는지에 따라 달라진다.

12.2 상속의 양면성

객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두 가지 관점을 함께 고려해야 함

상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함

데이터뿐만 아니라 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다.

단순히 데이터와 행동의 관점에서만 바라보면 상속이란 부모 클래스에서 정의한 데이터와 행동을 자식 클래스에서 자동적으로 공유할 수 있는 재사용 매커니짐으로 보임 -> 하지만, 오해!

상속의 목적은 코드 재사용이 아님.

상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것

타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다.

상속을 사용한 강의 평가

Lecture 클래스 살펴보기

수강생들의 성적을 계산하는 예제 프로그램

상속을 이용해 Lecture 클래스 재사용하기

일반적으로 super는 자식 클래스 내부에서 부모 클래스의 인스턴스 변수나 메서드에 접근하는 데 사용됨

부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 더 높다.

우선순위가 더 높다는 것은 메시지를 수신했을 때 부모 클래스의 메서드가 아닌 자식 클래스의 메서드가 실행된다는 것

자식 클래스 안에 상속받은 메서드와 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것 -> 메서드 오버라이딩

부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것 -> 메서드 오버로딩

데이터 관점의 상속

데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있다.

따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 되는 것이다.

행동 관점의 상속

부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함 시키는 것

부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함됨

외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송가능

런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문에 자식 클래스의 인스턴스에서 실행 가능

행동 관점에서 상속과 다형성의 기본적인 개념을 이해하기 위해서는 상속 관계로 연결된 클래스 사이의 메서드 탐색 과정을 이해하는 것이 가장 중요

객체의 경우에는 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 함

하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적

자식 클래스의 인스턴스를 통해 어떻게 부모 클래스에 정의된 메서드를 실행하는가

메시지를 수신한 객체는 class 포인터로 연결된 자신의 클래스에서 적절한 메서드가 존재하는지를 찾는다.

만약 메서드가 존재하지 않으면 클래스의 parent 포인터를 따라 부모 클래스를 차례대로 훑어가면서 적절한 메서드가 존재하는지를 확인

12.3 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

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

  • 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다 -> 업캐스팅
  • 선언된 변수의 타입이 아니라 메시지를 수신하는 객채의 타입에 따라 실행되는 메서드가 결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다 -> 동적 바인딩

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

개방-폐쇄 원칙과 의존성 역전 원칙

업캐스팅과 동적 메서드 탐색은 코드를 변경하지 않고도 기능을 추가할 수 있게 해주며 이것은 개방-폐쇄 원칙의 의도와도 일치

개방-폐쇄 원칙이 목적이라면 업캐스팅과 동적 메서드 탐색은 목적에 이르는 방법

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다.

이런 특성을 활용할 수 있는 대표적인 두 가지가 대입문과 메서드의 파라미터 타입

모든 객체지향 언어는 명시적으로 타입을 변환하지 않고도 부모 클래스 타입의 참조 변수에 자식 클래스의 인스턴스를 대입할 수 있게 허용

1
Lecture lecture = new GradeLecture(...);

부모 클래스 타입으로 선언된 파라미터에 자식 클래스의 인스턴스를 전달하는 것도 가능

반대로 부모 클래스의 인스턴스를 자식 클래스 타입으로 변환하기 위해서는 명시적인 타입 캐스팅이 필요함 -> 다운캐스팅(downcasting)

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

-> 유연하며 확장이 용이

동적 바인딩

함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일타임에 결정 -> 코드를 작성하는 시점에 호출될 코드가 결정

컴파일타임에 호출할 함수를 결정하는 방식 -> 정적 바인딩(static binding), 초기 바인딩(early binding), 또는 컴파일타임 바인딩(compile-time binding)

객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다.

실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩(dynamic binding) 또는 지연 바인딩(late binding)

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

객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사. 존재하면 메서드를 실행하고 탐색을 종료
  • 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속됨
  • 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단

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

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

메서드 탐색은 두 가지 원리로 구성됨

  1. 자동적인 메시지 위임: 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임
  2. 메서드를 탐색하기 위해 동적인 문맥을 사용: 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지를 결정하는 것은 실행 시점에 이뤄지며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정함

자동적인 메시지 위임

적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임됨

상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없음

메서드 오버라이딩은 자식 클래스의 메서드가 동일한 시그니처를 가진 부모 클래스의 메서드보다 먼저 탐색되기 때문에 벌어지는 현상

동일한 시그니처를 가지는 자식 클래스의 메서드는 부모 클래스의 메서드를 감추지만 이름만 같고 시그치너가 완전히 동일하지 않은 메서드들은 상속 계층에 걸쳐 사이좋게 공존 가능 -> 메서드 오버로딩

메서드 오버라이딩

동적 메서드 탐색이 자식 클래스에서 부모 클래스의 순서로 진행된다

자식 클래스와 부모 클래스 양쪽 모두에 동일한 시그니처를 가진 메서드가 구현돼 있다면 자식 클래스의 메서드가 먼저 검색된다.

따라서 자식 클래스의 메서드가 부모 클래스의 메서드를 감추게 됨

메서드 오버로딩

시그니처가 다르기 때문에 동일한 이름의 메서드가 공존하는 경우를 메서드 오버로딩

상속 계층 사이에서 같은 이름을 가진 메서드를 정의하는 것도 메서드 오버로딩

동적인 문맥

동적인 문맥을 결정하는 것은 self 참조

동일한 코드여도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다.

self 참조가 동적으로 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지를 예상하기 어렵게 만든다. 대표적인 경우 self 전송(self send)

현재 클래스의 메서드를 호출하는 것이 아니라 현재 객체에게 메시지를 전송하는 것

self 전송에서도 self 참조가 가리키는 바로 그 객체에서부터 메시지 탐색을 다시 시작한다.

이해할 수 없는 메시지

정적 타입 언어와 이해할 수 없는 메시지

컴파일 에러 발생

동적 타입 언어와 이해할 수 없는 메시지

실제로 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단 불가능

탐색 후 에러 던짐

하지만 동적 타입 언어에서는 이해할 수 없는 메시지에 대해 예외를 던지는 것 외에도 선택할 수 있는 방법이 하나 더 있다.

응답할 수 있는 메시지를 구현하는 것

동적 타입 언어는 이해할 수 없는 메시지를 처리할 수 있는 능력을 가짐으로써 메시지가 선언된 인터페이스와 메서드가 정의된 구현을 분리할 수 있다.

메시지 전송자는 자신이 원하는 메시지를 전송하고 메시지 수신자는 스스로의 판단에 따라 메시지를 처리

메시지를 기반으로 협력하는 자율적인 객체라는 순수한 객체지향의 이상에 좀 더 가까움

하지만, 코드를 이해하고 수정하기 어렵게 만들뿐만 아니라 디버깅 과정을 복잡하게 만들기도 함

정적 타입 언어에는 이런 유연성이 부족하지만 좀 더 안정적

self와 super

self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정

self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 바로 super 참조

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우 super 참조라는 내부 변수 사용

super에 의해 호출되는 메서드는 부모 클래스의 메서드가 아니라 더 상위에 위치한 조상 클래스의 메서드일 수 있음

super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공

그 메서드가 조상 클래스 어딘가에 있기만 하면 성공적으로 탐색될 것을 의미

super 참조를 통해 메시지를 전송하는 것은 마치 부모 클래스의 인스턴스에게 메시지를 전송하는 것처럼 보이기 때문에 super 전송(super send)라고 부름

super 전송의 경우에는 컴파일 시점에 미리 결정 가능

12.5 상속 대 위임

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

위임과 self 참조

메서드 탐색 중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를 공유하는 것으로 봐도 무방하다.

모든 객체지향 언어는 자동으로 self 참조를 생성하고 할당하기 때문에 메서드의 첫 번째 파라미터로 this를 받을 필요가 없다.

자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것 -> 위임(delegation)

위임은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용

위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달

포워딩과 위임

객체가 다른 객체에게 요청을 처리할 때 인자로 self를 전달하지 않을 수도 있다. 이것은 요청을 전달받은 최초의 객체에 다시 메시지를 전송할 필요는 없고 단순히 코드를 재사용하고 싶은 경우 -> 포워딩

이와 달리 selft 참조를 전달하는 경우에는 위임

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

프로토타입 기반의 객체지향 언어

클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은 객체 사이의 위임을 이용한 것

프로토타입 기반의 객체지향 언어들 역시 위임을 이용해 객체 사이에 self 참조를 자동으로 전달

prototype은 언어 차원에서 제공되기 때문에 self 참조를 직접 전달하거나 메시지 포워딩을 번거롭게 직접 구현할 필요가 없다.

자바스크립트에서 인스턴스는 메시지를 수신하면 먼저 메시지를 수신한 객체의 prototype 안에서 메시지에 응답할 적절한 메서드가 존재하는지 검사

만약 메서드가 존재하지 않는다면 prototype이 가리키는 객체를 따라 메시지 처리를 자동적으로 위임

메서드를 탐색하는 과정은 클래스 기반 언어의 상속과 거의 동일

단지 정적인 클래스 간의 관계가 아니라 동적인 객체 사이의 위임을 통해 상속을 구현하고 있을 뿐

클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며 상속 없이도 다형성을 구현하는 것이 가능하다.

참조

  1. 오브젝트
This post is licensed under CC BY 4.0 by the author.