앞서 상속은 코드 재사용을 목적으로 사용하면 여러 가지 문제점들을 야기한다고 설명했다. 상속의 주목적은 타입 계층 구축이다. 이를 기반으로 다형성이 구현된다.
곧, 다형성은 상속을 기반으로 동작하는 것이라 볼 수 있다. 다형성이 구현되는 매커니즘에 대해 살펴보자.
다형성은 런타임 시점(메시지를 처리할 수 있는 시점)에 적절한 메서드를 탐색하는 과정을 통해 구현된다.
상속은 이러한 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하는 방법이다.
+
연산자Movie.java
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.caculateDiscountAmount(screening));
}
DiscountPolicy
구현체에 따라 행동이 달라진다.상속은 클래스들을 계층으로 쌓아올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 매커니즘을 제공한다. 따라서, 포함 다형성은 대부분 상속을 사용한다.
중요
상속의 목적은 코드 재사용이 아닌 서브타입 계층을 구축하는 것이다.
타입 계층을 고려하지 않고 재사용을 위해 상속을 사용하면 유지보수 및 직관성이 안좋은 코드를 얻게 된다.
전제조건은 자식 클래스가 부모 클래스의 서브타입이여야 한다는 것이다. 이 경우 아래 3가지 기준을 통해 메서드를 선택한다.
다시 한 번 말하지만 상속의 목적은 코드 재사용이 아닌 타입 계층 구축이다. 이를 이해하기 위해 데이터-행동 관점으로 상속을 살펴보자.
private
제외) 메서드가 자식 인스턴스에 포함단순히 데이터-행동 관점으로 바라보면 재사용 매커니즘으로 보인다. 이 관점은 상속을 오해한 것이다.
상속을 사용하기 위해선 타입 계층에 대한 고민을 해야한다. 이를 위해 상속을 언제 사용해야 하는지 이해해야 하며 이를 고려하여 행동-데이터 관점을 생각해야 한다.
자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 설명할 수 있다. 결국 자식 클래스의 인스턴스는 자동으로 부모 클래스에 정의한 모든 인스턴스 변수를 내부에 포함한다.
실제로 상속을 받는 객체가 부모의 모든 퍼블릭 인터페이스를 포함하는건 아니다. 부모의 퍼블릭 인터페이스는 별도로 존재하며 포인터를 통해 이를 연결한다.
자식 인스턴스에서 메서드를 선택하는 과정을 아래와 같다.
self
참조라는 임시 변수를 자동 생성 후 메시지를 수신한 객체를 가리키도록 설정self
가 가리키는 객체의 클래스를 상속 계층의 역방향으로 바꿔가며 진행즉, 자식 -> 부모 순으로 탐색되므로 자식 클래스에 선언된 메서드가 더 높은 우선순위를 갖는다. 이 매커니즘 덕에 업캐스팅, 동적 바인딩이 구현된다.
참고 동적 메서드 탐색
부모 클래스의 타입에 대해 메시지를 전송하더라도 실행시에는 실제 클래스 기반으로 실행될 메서드가 선택되게 해준다.
OCP의 의도는 코드를 변경하지 않아도 기능을 추가할 수 있게 해주는 것이다. 유연하고 확장 가능한 코드를 만들도록 의존관계를 구조화한다.
업캐스팅과 동적 메서드 탐색은 상속을 이용해 OCP를 따르는 코드를 작성할 때 하부에서 동작하는 기술적 내부 매커니즘을 설명한다.
참고
그렇다고 추상화에 의존하지 않으면 DIP는 물론 OCP도 지키지 못한다 볼 수 있다.
동적 메서드 탐색에서 상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에 전달하기 위한 물리적인 경로를 정의하는 것으로 볼 수 있다.
상속 계층내에서 클래스는 메시지를 처리할 방법을 못찾으면 이를 부모 클래스에게 위임한다. 핵심은 적절한 메서드를 찾을 때 까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.
따라서, 상속 계층을 정의한다는 건 메서드 탐색 경로를 정의한다는 것과 동일하다 볼 수 있다.
self
전송이란 자기 자신에게 메시지를 전송하는 것을 말한다. 대표적으로 Java의 this
키워드 사용이 있다.
self
전송은 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self
참조가 가리키는 원래의 자식 클래스로 이동시킨다. 이로 인해 실행될 메서드를 결정할 때 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 발생할 수 있다.
결과적으로 self
전송이 깊은 상속 계층과 중간중간 메서드 오버라이딩이 만나면 극단적으로 이해하기 어려운 코드가 만들어진다.
동적 타입 언어는 상속 계층을 통해 현 객체가 이해할 수 없는 메시지를 처리할 수 있는 능력을 가짐으로써 메시지가 선언된 인터페이스와 메서드가 정의된 구현을 분리할 수 있다.
이러한 관점으로 봤을 때 객체지향 패러다임에 더 적합하다 볼 수 있다. 그러나, 코드 이해와 수정이 어렵고 디버깅 과정이 복잡하다.
객체지향 프로그래밍 언어는 이와 같은 매커니즘의 도움을 받아 동일한 메시지에 대해 서로 다른 메서드를 호출할 수 있는 다형성을 구현한다.
반면, 정적 타입 언어는 컴파일 시점에 현 객체가 메시지를 처리할 수 있는지 여부를 확인한다. 좀 더 안정적이나 유연성이 부족하다.
self
참조의 가장 큰 특징은 동적이라는 것이다. 덕분에 메서드 탐색을 위한 문맥을 실행 시점에 결정한다.
반면, 자식 클래스에서 부모 클래스의 구현을 재사용해야 되는 경우가 있다. 대부분 객체지향 언어에선 super
참조를 통해 부모 클래스의 구현을 제공한다.
그러나 super
참조가 언제나 현 객체의 부모 클래스의 구현을 제공한다고 보장할 순 없다. self
와 동일한 탐색 과정을 거치므로 더 상위에 위치한 조상 클래스의 메서드일 수 있다.
참고
super
참조는 "이 클래스의 부모 클래스부터 탐색을 시작한다."를 뜻한다.
지금까지 살펴본 다형성은 self
참조가 가리키는 현 객체에게 메시지를 전달하는 특성을 기반으로 동작한다. 동일한 타입의 객체 참조에게 동일하게 메시지를 전송하더라도 self
가 뭘 가리키느냐에 따라 메서드 탐색을 위한 문맥이 달라진다.
자신이 수신한 메시지를 다른 객체에게 동일하게 전달해 처리를 요청하는 행위를 말한다. 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드 탐색을 다른 객체로 이동시키기 위해 사용한다.
이를 위해 위임은 항상 현재 실행 문맥이 가리키는 self
참조를 인자로 전달한다. 결과적으로 자식-부모 인스턴스 간 실행 문맥을 공유할 수 있게 된다.
참고 포워딩 vs 위임
포워딩도 위임과 비슷한 목적으로 사용되나self
참조를 전달하지 않는다는 차이점이 있다.
self
를 전달하지 않는다는 건 단순히 코드를 재사용하고 싶은 경우라 볼 수 있다.
상속은 동적으로 메서드 탐색을 위해 현재의 실행 문맥을 가지고 있는 self
참조를 전달한다. 그리고 이 객체들 사이에서 메시지를 전달하는 과정이 자동으로 이뤄진다. 따라서, 상속을 자동적 메시지 위임이라 볼 수 있다. 상속은 단순히 클래스 사이의 정적인 관계로만 구현되는 건 아니다.