본문 바로가기
📖 독서/오브젝트(Objects)

[오브젝트] 12장. 다형성

by 말랑한곰탱이 2022. 4. 11.

- 객체지향 패러다임이 주목받기 시작하던 초기에 상속은 타입 계층과 다형성을 구현할 수 있는 거의 유일한 방법이었다. 여기에 더해 상속을 사용하면 코드를 쉽게 재사용 할 수 있다는 과대광고가 널리 퍼지면서 상속에 대한 맹신과 추종이 자라났다. 많은 시간이 흐른 지금도 여전히 상속은 다형성을 구현할 수 있는 가장 일반적인 방법이다. 하지만 최근의 언어들은 상속 이외에도 다형성을 구현할 수 있는 다양한 방법들을 제공하고 있기 때문에 과거에 비해 상속의 중요성이 많이 낮아졌다고 할 수 있다.

- 이번 장에서는 상속의 관점에서 다형성이 구현되는 기술적인 메커니즘을 살펴보기로 한다. 이번 장을 읽고 나면 다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현되며, 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하기 위한 방법이라는 사실을 이해하게 될 것이다.

 

1. 다형성(Polymorphism)

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

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

- 강제 다형성 : 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식. 예를 들어 자바에서 이향 연산자인 '+'는 피연산자가 모두 정수일 경우에는 정수에 대한 덧셈 연산자로 동작하지만 하나는 정수형이고 다른 하나는 문자열인 경우에는 연결 연산자로 동작한다.

- 매개변수 다형성 : 제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식을 가리킨다. 예를 들어, 자바의 List 인터페이스는 컬렉션에 보관할 요소의 타입을 임의의 타입 T로 지정하고 있으며 실제 인스턴스를 생성하는 시점에 T를 구체적인 타입으로 지정할 수 있게 하고 있다.

- 포함 다형성 : 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력. 서브타입 다형성이라고도 부르며 객체지향 프로그래밍에서 가장 널리 알려진 형태의 다형성이기 때문에 특별한 언급 없이 다형성이라고 할 때는 포함 다형성을 의미하는 것이 일반적이다.

- 이번 장의 목표는 포함 다형성의 관점에서 런타임에 상속 계층 안에서 적절한 메서드를 선택하는 방법을 이해하는 것이다. 비록 상속 관계를 기준으로 설명을 진행하지만 이번 장에서 다루는 내용은 상속 이외에도 포함 다형성을 구현할 수 있는 다양한 방법에 공통적으로 적용할 수 있는 개념이라는 사실을 기억하기 바란다.

 

2. 상속의 양면성

- 단순히 데이터와 행동의 관점에서만 바라보면 상속이란 부모 클래스에서 정의한 데이터와 행동을 자식 클래스에서 자동적으로 공유할 수 있는 재사용 메커니즘으로 보일 것이다. 하지만 이 관점은 상속을 오해한 것이다.

- 상속의 목적은 코드 재사용이 아니다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다. 문제를 피할 수 있는 유일한 방법은 상속이 무엇이고 언제 사용해야 하는지를 이해하는 것뿐이다.

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

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

 

<데이터 관점의 상속>

: 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시키는 것.

- 데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있다. 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 되는 것이다.

 

<행동 관점의 상속>

: 부모 클래스에서 정의한 일부 메서드를 자동으로 자식 클래스에 포함시키는 것.

- 행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다. 런타임에서 시스템이 자식 클래스에 정의되지 않는 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색한다.

 

3. 업캐스팅과 동적 바인딩

<업캐스팅>

: 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다.

- 상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다. 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용하더라도 메시지를 처리하는 데는 아무런 문제가 없으며, 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.

- 컴파일러의 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있기 때문에 부모 클래스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력하는 것이 가능하다. 여기서 자식 클래스는 현재 상속 계층에 존재하는 자식 클래스뿐만 아니라 앞으로 추가될지도 모르는 미래의 자식 클래스들을 포함한다. 따라서 이 설계는 유연하며 확장이 용이하다.

 

<동적 바인딩>

- 컴파일 타임에 호출할 함수를 결정하는 방식을 정적 바인딩, 초기 바인딩, 컴파일 타임 바인딩이라고 부른다.

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

 

4. 동적 메서드 탐색과 다형성

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

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

- 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. 동적 메서드 탐색은 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다. 시스템은 앞에서 설명한 class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색한다.

- 시스템은 메시지를 처리할 메서드를 탐색하기 위해 self 참조가 가리키는 메모리로 이동한다. 이 메모리에는 객체의 현재 상태를 표현하는 데이터와 객체의 클래스를 가리키는 class 포인터가 존재한다. class 포인터를 따라 이동하면 메모리에 로드된 클래스의 정보를 읽을 수 있다. 클래스 정보 안에는 클래스 안에 구현된 전체 메서드의 목록이 포함돼 있다. 이 목록 안에 메시지를 처리할 적절한 메서드가 존재하면 해당 메서드를 실행한 후 동적 메서드 탐색을 종료한다. 시스템은 상속 계층을 따라 최상위 클래스인 Object 클래스에 이를 때까지 메서드를 탐색한다. 최상위 클래스에 이르러서도 적절한 메서드를 찾지 못한 경우에는 에러를 발생시키고 메서드 탐색을 종료한다.

- 메서드 탐색은 '자동적인 메시지 위임'과 동적인 문맥'이란 두 가지 원리로 구성된다.

 

<자동적인 메시지 위임>

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

 

<동적인 문맥>

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

- self 참조가 동적 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지를 예상하기 어렵게 만든다. 대표적인 경우가 자신에게 다시 메시지를 전송하는 'self 전송'이다.

- self 전송은 자식 클래스에 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다. 이로 인해 최악의 경우에는 실제로 실행될 메서드를 이해하기 위해 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 발생할 수도 있다. 결과적으로 self 전송이 깊은 상속 계층과 계층 중간중간에 함정처럼 숨겨져 있는 메서드 오버라이딩과 만나면 극단적으로 이해하기 어려운 코드가 만들어진다.

 

<self 대 super>

- self 참조의 가장 큰 특징은 동적이라는 점이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 바로 super 참조다.

- 자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다. 대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공한다.

- 사실 super 참조의 용도는 부모 클래스에 정의된 메서드를 실행하기 위한 것이 아니다. super 참조의 정확한 의도는 '지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요'다. 만약 부모 클래스에서 원하는 메서드를 찾지 못한다면 더 상위의 부모 클래스로 이동하면서 메서드가 존재하는지 검사한다.

- 부모 클래스의 메서드를 호출하는 것과 부모 클래스에서 메서드 탐색을 시작하는 것은 의미가 매우 다르다. 부모 클래스의 메서드를 호출한다는 것은 그 메서드가 반드시 부모 클래스 안에 정의돼 있어야 한다는 것을 의미한다. 그에 비해 부모 클래스에서 메서드 탐색을 시작한다는 것은 그 클래스의 조상 어딘가에 그 메서드가 정의돼 있기만 하면 실행할 수 있다는 것을 의미한다.

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

- self 전송에서 메시지 탐색을 시작하는 클래스는 미정이지만 super 전송에서는 부모 클래스에서부터 메서드 탐색을 시작한다고 정해져있다. 따라서 self 전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만 super 전송의 경우에는 컴파일 시점에 미리 결정해 놓을 수 있다.

- 동적 바인딩과 self 참조는 동일한 메시지를 수신하더라도 객체의 타입에 따라 적합한 메서드를 동적으로 선택할 수 있게 한다. super 참조는 부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 한다.