본문 바로가기

기술서적 정리/오브젝트 - 코드로 이해하는 객체지향 설계

12 CHAPTER 다형성

https://wikibook.co.kr/object/

 

오브젝트: 코드로 이해하는 객체지향 설계

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라! 객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두 번째 걸음은 객체를

wikibook.co.kr

 

 

상속을 사용하려는 목적이 단순히 코드를 재사용하기 위해서인가?

질문에 대한 답이 ‘예’라면 상속을 사용하지 말아야 한다.

 

이번 장에서는 상속의 관점에서 다형성이 구현되는 기술적인 메커니즘을 살펴보자.

다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현되며, 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로클래스 계층의 형태로 구현하기 위한 방법이라는 사실을 이해하게 될 것이다.

 

 

 

 

◈ 다형성

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

간단하게 말해서 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이다.

 

다형성은 유니버셜 다형성과 임시 다형성으로 분류할 수 있다.

유니버셜 다형성은 다시 매개변수 다형성과 포함 다형성으로 분류할 수 있다.

임시 다형성은 오버로딩 다형성과 강제 다형성으로 분류할 수 있다.

 

일반적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리켜 오버로딩 다형성이라고 부른다.

 

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

 

매개변수 다형성은 제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식을 가리킨다.

 

포함 다형성은 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. 포함다형성은 서브타입 다형성이라고도 부른다.

 

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

두 클래스를 상속 관계로 연결하고 자식 클래스에서 부모 클래스 메서드를 오버라이딩한 후 클라이언트는 부모 클래스만 참조하면 포함 다형성을 구현할 수 있다.

 

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

 

 

 

 

◈ 상속의 양면성

이번 장에서는 상속의 메커니즘을 이해하는 데 필요한 몇가지 개념을 살펴보겠다.

1. 업캐스팅.

2. 동적 메서드 탐색.

3. 동적 바인딩.

4. 셀프 참조.

5. 슈퍼 참조.

 

 

메서드 오버라이딩, 메서드 오버로딩

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

 

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

 

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

두 메서드의 시그니처가 다르기 때문에 사이좋게 공존할 수 있고 두 메서드 모두를 호출할 수 있다.

 

 

데이터 관점의 상속

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

 

 

행동 관점의 상속

어떻게 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있는 것일까?

그 이유는 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문이다.

 

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

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

 

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

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

 

 

 

 

◈ 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

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

이를 업캐스팅이라고 부른다.

 

선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다.

이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다.

이를 동적 바인딩이라고 부른다.




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

 

업캐스팅서로 다른 클래스의 인스턴스를 동일한 타입에 할당하는 것을 가능하게 해준다.

 

동적 메서드 탐색은 부모 클래스의 타입에 대해 메시지를 전송하더라도 실행 시에는 실제 클래스를 기반으로 실행될 메서드가 선택되게 해준다.

따라서 코드를 변경하지 않고도 실행되는 메서드를 변경할 수 있다.

 

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

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

개방-폐쇄 원칙은 유연하고 확장 가능한 코드를 만들기 위해 의존관계를 구조화하는 방법을 설명한다.

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

 

 

업캐스팅

부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용하더라도 메시지를 처리하는 데는 아무런 문제가 없으며, 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.

 

반대로 부모 클래스의 인스턴스를 자식 클래스 타입으로 변환하기 위해서는 명시적인 타입 캐스팅이 필요한데 이를 다운캐스팅이라고 부른다.

 

 

동적 바인딩

전통적인 언어에서 함수를 실행하는 방법은 함수를 호출하는 것이다.

객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다.

함수 호출과 메시지 전송 사이의 차이는 생각보다 큰데 프로그램 안에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적인 메커니즘이 완전히 다르기 때문이다.

 

전통적인 언어들은 컴파일타임에 호출할 함수를 결정하며 이러한 방식을 정적 바인딩, 초기 바인딩, 또는 컴파일타임 바인딩이라고 부른다.

 

객체지향 언어에서는 실행될 메서드를 런타임에 결정하는데 이러한 방식을 동적 바인딩 또는 지연 바인딩이라고 부른다.

 

객체지향 언어가 제공하는 업캐스팅과 동적 바인딩을 이용하면 부모 클래스 참조에 대한 메시지 전송을 자식 클래스에 대한 메서드 호출로 변환할 수 있다.

 

 

 

 

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

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

 

1. 메시지를 수신한 객체는 먼저 자신을 생성한 클래스의 적합한 메서드가 존재하는지 검사한다.

존재하면 메서드를 실행하고 탐색을 종료한다.

 

2. 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다.

이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.

 

3. 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.



메시지 탐색과 관련해서 이해해야하는 중요한 변수는 셀프 참조이다.

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

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

 

정적 타입 언어에 속하는 씨플러스플러스, 자바, 씨샾은 셀프 참조를 디스라고 부른다.

 

메서드 탐색 과정을 살펴보자.

시스템은 메시지를 처리할 메서드를 탐색하기 위해 셀프 참조가 가리키는 메모리로 이동한다.
이 메모리에는 객체의 현재 상태를 표현하는 데이터와 객체의 클래스를 가리키는 클래스 포인터가 존재한다.
클래스 포인터를 따라 이동하면 메모리에 로드된 클래스의 정보를 읽을 수 있다.
클래스 정보 안에는 클래스 안에 구현된 전체 메서드의 목록이 포함돼 있다.
이 목록 안에 메시지를 처리할 적절한 메서드가 존재하면 해당 메서드를 실행한 후 동적 메서드 탐색을 종료한다.

적절한 메서드를 찾지 못했다면 패런트 참조를 따라 부모 클래스로 이동한 후 탐색을 계속한다.
시스템은 상속 계층을 따라 최상위 클래스인 오브젝트 클래스에 이를 때까지 메서드를 탐색한다.
최상위 클래스에 이르러서도 적절한 메서드를 찾지 못한 경우에는 에러를 발생시키고 메서드 탐색을 종료한다.

자식 클래스의 메서드가 부모 클래스의 메서드보다 먼저 탐색되기 때문에 자식 클래스에 선언된 메서드가 부모 클래스의 메서드보다 더 높은 우선순위를 가지게 된다.

 

 

지금까지의 설명을 종합해보면 동적 메서드 탐색은 두 가지 원리로 구성된다는 것을 알 수 있다.

 

첫 번째 원리는 자동적인 메시지 위임이다.

클래스 사이의 위임은 프로그래머의 개입 없이 상속 계층을 따라 자동으로 이뤄진다.

 

두 번째 원리는 동적인 문맥을 사용한다는 것이다.

메시지를 수신했을 때 실제로 어떤 메서드를 실행할지를 결정하는 것은 컴파일 시점이 아닌 실행 시점에 이뤄지며, 메서드를 탐색하는 경로는 셀프 참조를 이용해서 결정한다.

 

메시지가 처리되는 문맥을 이해하기 위해서는 런타임에 실제로 메시지를 수신한 객체가 어떤 타입인지를 추적해야 한다.

이 객체의 타입에 따라 메서드를 탐색하는 문맥이 동적으로 결정되며, 여기서 가장 중요한 역할을 하는 것이 바로 셀프 참조다.

 

 

자동적인 메시지 위임

메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다.

이런 관점에서 상속계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.

 

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

 

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

 

씨플러스플러스는 상속 계층 사이의 메서드 오버로딩을 지원하지 않는다.

씨플러스플러스에서는 부모 클래스의 메서드와 동일한 이름의 메서들 자식 클래스에서 오버로딩하면 그 이름을 가진 모든 부모 클래스의 메서드를 감춰버린다.

씨플러스플러스는 같은 클래스 안에서의 메서드 오버로딩은 허용하지만 자바와 달리 상속 계층 사이에서의 메서드 오버로딩은 금지한다.

 

씨플러스플러스는 상속 계층 안에서 동일한 이름을 가진 메서드가 공존해서 발생하는 혼란을 방지하기 위해 부모 클래스에 선언된 이름이 동일한 메서드 전체를 숨겨서 클라이언트가 호출하지 못하도록 막는다.

이를 이름 숨기기라고 부른다.

 

 

동적인 문맥

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

따라서 셀프 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.

 

셀프 참조가 동적 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지를 예상하기 어렵게 만든다.

대표적인 경우가 자신에게 다시 메시지를 전송하는 셀프 전송이다.

셀프 참조가 가리키는 자기 자신에게 메시지를 전송하는 것을 셀프 전송이라고 부른다.

 

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

이로 인해 최악의 경우에는 실제로 실행될 메서드를 이해하기 위해 상속 계층 전체를 훑어 가며 코드를 이해해야 하는 상황이 발생할 수 도 있다.

결과적으로 셀프 전송이 깊은 상속 계층과 계층 중간중간에 함정처럼 숨겨져 있는 메서드 오버라이딩과 만나면 극단적으로 이해하기 어려운 코드가 만들어진다.

 

 

이해할 수 없는 메시지

상속 계층의 정상에 오고 나서야 자신이 메시지를 처리할 수 없다는 사실을 알게 됐다면 어떻게 할까?

프로그래밍 언어가 정적 타입 언어에 속하는지, 동적 타입 언어에 속하는지에 따라 달라진다.



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

 

상속 계층 전체를 탐색한 후에도 메시지를 처리할 수 있는 메서드를 발견하지 못했다면 컴파일 에러를 발생시킨다.



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

 

동적 타입 언어에는 컴파일 단계가 존재하지 않기 때문에 실제로 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다.

 

몇가지 동적 타입 언어는 최상위 클래스까지 메서드를 탐색한 후에 메서드를 처리할 수 없다는 사실을 발견하면 셀프 참조가 가리키는 현재 객체에게 메시지를 이해할 수 없다는 메시지를 전송한다.

 

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

더즈낫언더스탠드나 메서드 미싱 메시지에 응답할 수 있는 메서드를 구현하는 것이다.

 

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

 

이해할 수 없는 메시지와 도메인-특화 언어

이해할 수 없는 메시지를 처리할 수 있는 동적 타입 언어의 특징은 메타 프로그래밍 영역에서 진가를 발휘한다.
특히 동적 타입 언어의 이러한 특징으로 인해 동적 타입 언어는 정적 타입 언어보다 더 쉽고 강력한 도메인-특화 언어(디 에스 엘)을 개발할 수 있는 것으로 간주된다.
마틴 파울러는 동적 타입 언어의 이러한 특징을 이용해 도메인-특화 언어를 개발하는 방식을 동적 리셉션이라고 부른다.

 

 

셀프 대 슈퍼

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다.

자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 슈퍼 참조라는 내부 변수를 제공한다.

 

‘메서드를 호출’한다고 표현하지 않고 슈퍼 참조를 이용해 ‘메시지를 전송’한다고 표현한다.

인터페이스 메시지에 의해 호출되는 메서드는 부모 클래스의 메서드가 아니라 더 상위에 위치한 조상 클래스의 메서드일 수도 있다.

 

사실 슈퍼 참조의 용도는 부모 클래스에 정의된 메서드를 실행하기 위한 것이 아니다.

슈퍼 참조의 정확한 의도는 ‘지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요’다.

 

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

 

부모 클래스의 메서드를 호출하는 것과 부모 클래스에서 메서드 탐색을 시작하는 것은 의미가 매우 다르다.

메서드를 호출한다는 것은 그 메서드가 반드시 부모 클래스 안에 정의돼 있어야한다는 것을 의미한다.

그에 비해 부모 클래스에서 메서드 탐색을 시작한다는 것은 그 클래스의 조상 어딘가에 그 메서드가 정의돼 있기만 하면 실행할 수 있다는 것을 의미한다.

 

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

 

슈퍼 참조의 문법.

자바는 슈퍼, 씨샾은 베이스라는 예약어를 사용한다.
씨플러스플러스의 겨우 부모 클래스 이름과 범위 지정 연산자인 쌍점 두 개를 조합해서 부모 클래스에서부터 메서드 탐색을 시작하게 할 수 있다.

 

 

지금까지 살펴본 것처럼 동적 바인딩, 셀프 참조, 슈퍼 참조는 상속을 이용해 다형성을 구현하고 코드를 재사용하기 위한 가장 핵심적인 재료다.

동적 바인딩과 셀프 참조는 동일한 메시지를 수신하더라도 객체의 타입에 따라 적합한 메서드를 동적으로 선택할 수 있게 한다.

슈퍼 참조는 부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 한다.

 

 

 

 

◈ 상속 대 위임

위임과 셀프 참조

자식 클래스의 인스턴스를 생성하면 개념적으로 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 표현할 수 있다.

 

그렇다면 자식 인스턴스에 포함된 부모 인스턴스 입장에서 셀프 참조는 무엇을 가리킬까?

처음에는 다소 의아하게 생각될 수도 있겠지만 자식 인스턴스이다.

셀프 참조는 항상 메시지를 수신한 객체를 가리키기 때문이다.

 

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



자신이 수신한 메시지를 다른 객체에게 동일하게 전달해서 처리를 요청하는 것을 위임이라고 부른다.

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

이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 셀프 참조를 인자로 전달한다.

바로 이것이 셀프 참조를 전달하지 않는 포워딩과 위임의 차이점이다.

 

포워딩과 위임

객체가 다른 객체에게 요청을 처리할 때 인자로 셀프를 전달하지 않을 수도 있다.
이것은 요청을 전달받은 최초의 객체에 다시 메시지를 전송할 필요는 없고 단순히 코드를 재사용하고 싶은 경우라고도 할 수 있다.
이처럼 처리를 요청할 때 셀프 참조를 전달하지 않은 경우를 포워딩이라고 부른다.
이와 달리 셀프 참조를 전달하는 경우에는 위임이라고 부른다.
위임의 정확한 용도는 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현하는 것이다.

 

 

이번 절을 시작할 때 상속 관계로 연결된 클래스 사이에는 자동적인 메시지 위임이 일어난다고 설명했었다.

이제 왜 위임이라는 단어를 사용했는지 이해할 수 있을 것이다.

상속은 동적으로 메서드를 탐색하기 위해 현재의 실행 문맥을 가지고 있는 셀프 참조를 전달한다.

그리고 이 객체들 사이에서 메시지를 전달하는 과정은 자동으로 이뤄진다.

따라서 자동적인 메시지 위임이라고 부르는 것이다.

 

 

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

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

 

자바스크립트의 모든 객체들은 다른 객체를 가리키는 용도로 사용되는 프로토타입이라는 이름의 링크를 가진다.

 

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

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

자바스크립트는 프로토타입으로 연결된 객체들의 체인을 거슬러 올라가며 자동적으로 메시지에 대한 위임을 처리한다.

 

자바스크립트에는 클래스가 존재하지 않기 때문에 오직 객체들 사이의 메시지 위임만을 이용해 다형성을 구현한다.

이것은 객체지향 패러다임에서 클래스가 필수 요소가 아니라는 점을 잘 보여준다.

 

프로토타입 기반의 객체지향 언어는 객체 사이의 자동적인 메시지 위임을 통해 상속을 구현한다.

현재 대부분의 객체지향 언어들이 클래스에 기반하고 있기 때문에 다형성을 위해 클래스 기반의 상속이 널리 사용되지만 프로토타입 언어처럼 위임을 통해 객체 수준에서 상속을 구현하는 언어들도 존재한다는 사실을 기억하기 바란다.