이 글은 오브젝트라는 책을 읽고 정리한 글입니다.
12장에서는 객체지향의 핵심 메커니즘 중 하나라고 불리는 다형성에 관해 다룹니다.
상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다. 타입 계층은 객체 지향 프로그래밍의 중요한 특성 중 하나인 다형성의 기반을 제공한다.
이번 장을 읽고 나면 다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현 되며 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하기 위한 방법이라는 사실을 이해할 것이다.
다형성
다형성(Polymorphism) 이라는 단어는 ‘많은’을 의미하는 ‘poly’와 ‘형태’를 의미하는 ‘morph’의 합성어로 ‘많은 형태를 가질 수 있는 능력’을 의미한다. 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다. 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이다.
다형성의 분류
- 유니버셜 다형성
- 매개변수 다형성 : 제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식
- ex) 자바의 List 인터페이스는 컬렉션에 보관할 요소를 임의의 타입 T로 지정하고 있으며 실제 인스턴스를 생성하는 시점에 T를 구체적인 타입으로 지정할 수 있게 하고 있다.
- 포함 다형성 : 메시지가 동일 하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력, 서브타입(Subtype) 다형성 이라고도 부름, 가장 널리 알려진 형태의 다형성
- 매개변수 다형성 : 제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식
- 임시 다형성
- 오버로딩 다형성 : 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우, 유사한 역할을 하는 메서드지만 시그니처가 다른 경우 사용
- 강제 다형성 : 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식
- ex) ‘+’ 연산자 :피연산자가 모두 정수일 때는 덧셈 연산자로 동작하고, 피연산자 중 하나가 문자열일 때는 연결 연산자로 동작한다.
상속의 진정한 목적은 코드 재사용이 아니라 다형성을 위한 서브타입 계층을 구축하는 것이다.
상속의 양면성
객체지향 패러다임의 근간을 이루는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것이다. 따라서 객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두 가지 관점을 함께 고려해야 한다.
- 데이터 관점의 상속 : 상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다.
- 행동 관점의 상속 : 데이터뿐만 아니라 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다.
상속을 사용한 강의 평가
- 메서드 오버라이딩: 자식 클래스 안에 상속받은 메서드와 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것
- 메서드 오버로딩 : 부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것
데이터 관점의 상속
데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스터스를 포함하는 것이다. 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 되는 것이다.
행동 관점의 상속
데이터 관점의 상속이 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 개념이라면 행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다.
부모 클래스에 정의된 어떤 메서드가 자식 클래스에 포함될지는 언어의 종류와 각 언어에서 제공하는 접근 제어자의 의미에 따라 다르지만 공통적으로 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메세지는 자식 클래스의 인스턴스에도 전송할 수 있다.
객체는 서로 다른 상태를 저장할 수 있도록 인스턴스 별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드는 동일한 클래스의 인스턴스끼리 공유가 가능하다. 클래스는 한 번만 로드하고 인스턴스가 클래스를 가리키는 포인터를 갖게 하는 게 경제적이다.
업캐스팅과 동적 바인딩
같은 메세지, 다른 메서드
- 업캐스팅 : 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다.
- 동적 바인딩 : 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다.
업캐스팅과 동적 메서드 탐색은 코드를 변경하지 않고도 기능을 추가할 수 있게 해주며 이것은 개방-폐쇄 원칙의 의도 와도 일치한다.
업캐스팅
상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다. 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용하더라도 메시지를 처리하는 데는 아무런 문제가 없으며, 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다
부모 클래스의 인스턴스를 자식 클래스로 변환하기 위해는 명시적인 타입 캐스팅이 필요하다. 이를 다운 캐스팅이라고 한다.
동적 바인딩
전통적인 언어에서 함수를 실행하는 방법은 함수를 호출하는 것이다. 객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다. 함수 호출과 메시지 전송 사이의 차이는 생각보다 큰데 프로그램 안에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적인 매커니즘이 완전히 다르기 때문이다.
- 함수 호출
- 코드를 작성하는 시점에 호출될 코드가 결정된다.
- 컴파일 타임에 호출할 함수를 결정하는 방식 (정적 바인딩, 초기 바인딩, 컴파일타임 바인딩)
- 메시지 전송
- 메시지를 수신했을 때 실행될 메서드가 런타임에 결정
- 실행될 메서드를 런타임에 결정하는 방식 (동적 바인딩, 지연바인딩)
동적 메서드 탐색과 다형성
객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.
- 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
- 메서드를 찾이 못했다면, 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
- 상속 계층의 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.
동적 메서드 탐색은 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다.
동적 메서드 탐색 원리
- 자동적인 메시지 위임 : 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층에 따라 부모 클래스에게 처리를 위임한다.
- 동적인 문맥 : 메시지를 수신 했을 때 실제로 어떤 메서드를 실행 할지를 결정하는 것은 컴파일 시점이 아닌 실행 시점에 이뤄지며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정한다.
자동적인 메시지 위임
상속 계층 안의 클래스는 메시지를 처리할 방법을 알지 못할 경우 메시지에 대한 처리를 부모 클래스에 위임한다. 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.
메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다.
C++에서는 상속 계층 내 동일한 이름의 메서드가 공존해 발생하는 혼란을 방지하기 위해 부모 클래스에 선언된 이름이 동일한 메서드 전체를 숨겨 클라이언트가 호출하지 못하도록 한다. 이를 이름 숨기기라고 부른다.
동적인 문맥
메시지를 수신한 객체가 무엇인가에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀌고 이 문맥을 결정하는 것은 메시지를 수신한 객체를 가리키는 self 참조다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.
self 참조가 동적 문맥을 결정한다는 사실은 메서드 예상을 어렵게 한다. 대표적으로 자신에게 메시지를 전송하는 self 전송이다.
self 전송은 자식에서 부모의 방향으로 이동하는 동적 메서드 탐색 경로를 다시 self로 이동시킨다. 이 때문에 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 올 수 있다.
이해할 수 없는 메시지
정적 타입 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다. 따라서 상속 계층 전체를 탐색한 후에도 메시지를 처리할 수 있는 메서드를 발견하지 못했다면 컴파일 에러를 발생시킨다.
동적 타입 언어 역시 메시지를 수신한 객체의 클래스부터 부모 클래스의 방향으로 메서드를 탐색한다. 차이점이라면 동적 타입 언어에는 컴파일 단계가 존재하지 않기 때문에 실제로 코드를 실행 해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다는 점이다.
이해할 수 없는 메시지를 처리할 수 있는 동적 타입 언어의 특징은 메타 프로그래밍 영역에서 진가를 발휘한다. 마틴 파울러는 동적 타입 언어의 이러한 특징을 이용해 도메인-특화 언어를 개발하는 방식을 동적 리셉션이라고 부른다.
self 대 super
self 참조의 가장 큰 특징은 동적이라는 점이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 참색을 위한 문맥을 실행 시점에 결정한다.
대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공한다.
super 참조의 정확한 의도는 '지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요'다.
super 참조를 통해 메시지를 전송하는 것은 마치 부모 클래스의 인스턴스에게 메시지를 전홍사는 것처럼 보이기 때문에 이를 super 전송이라고 부른다.
상속 대 위임
위임과 self 참조
메서드 탐색 중에는 자식 클래스의 인스턴스와 부모 클래스의 인스턴스가 동일한 self 참조를 공유하는 것으로 봐도 무방하다. 상속 계층을 구성하는 객체들 사이에서 self 참조를 공유하기 때문에 개념적으로 각 인스 턴스에서 self 참조를 공유하는 self라는 변수를 포함하는 것처럼 표현할 수 있다.
자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것을 위임이라고 한다.
- 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용한다.
- 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현하기 위해 사용한다.
- 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다.
프로토타입 기반의 객체지향 언어
클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은 객체 사이의 위임을 이용하는 것이다.
'📖 > 오브젝트' 카테고리의 다른 글
[오브젝트] 14장 일관성 있는 협력 (0) | 2024.03.14 |
---|---|
[오브젝트] 13장 서브클래싱과 서브타이핑 (0) | 2024.03.11 |
[오브젝트]11장 합성과 유연한 설계 (0) | 2024.03.04 |
[오브젝트] 10장 상속과 코드 재사용 (0) | 2024.03.04 |
[오브젝트] 9장 유연한 설계 (1) | 2024.02.26 |