상세 컨텐츠

본문 제목

[BOOK : 오브젝트 12장] 다형성

카테고리 없음

by 조킴 2022. 11. 27. 20:15

본문

반응형

 

다형성 

  • 많음을 의미하는 'poly'와 형태를 의미하는 'morph'의 합성어로 '많은 형태를 가질 수 있는 능력'을 의미한다. 

객체지향 프로그래밍에서 사용되는 다형성은 그림과 같이 나눌 수 있다. 

 

다형성의 분류

 

오버 로딩 다형성 

  • 일반적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 카리 킨다. 

 

강제 다형성 

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

 

오버 로딩 다형성 

  • 일반적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리킨다. 
  • 유사한 역할을 하는 메서드지만 시그니처가 다른 경우 사용한다. 

 

매개변수 다형성

  • 제네릭 프로그래밍과 관련이 깊다.  변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식 

 

포함 다형성 

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

 

상속의 양면성 

  • 객체지향 패러다임의 근간을 이루는 아이디어는 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것이다. 
  • 객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두 가지 관점을 함께 고려해야 한다. 

해당 장에서는 다음과 같은 핵심 개념들을 소개하고 있었다. 

  • 업 캐스팅
  • 동적 메서드 탐색
  • 동적 바인딩 
  • self 참조 
  • super 참조 

 

데이터 관점의 상속 vs 행동 관점의 상속

 

데이터 관점의 상속 

Lecture의 인스턴스를 생성하면 시스템은 인스턴스 변수 title, pass, socres를 저장할 수 있는 메모리 공간을 할당하고 생성자의 매개변수를 이용해 값을 설정한다. 이후 생성된 인스턴스의 주소를 lecture라는 이름의 변수에 대입된다.

 

이번엔 GradeLecture의 인스턴스를 생성했다고 가정하자. 상속을 인스턴스 관점에서 바라보면 아래 그림과 같다. 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스가 포함되는 것으로 생각하는 게 유용하다.

 

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

 

 

행동 관점의 상속 

데이터 관점의 상속이 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 개념이라면 행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다.

 

대부분 부모 클래스의 모든 퍼블릭 인터페이스는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 하지만 실제로 클래스의 코드를 합치거나 복사되는 것은 아니다. 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스에서 찾는다.

 

이처럼 행동 관점에서 상속과 다형성의 기본적 개념을 이해하기 위해선 상속 관계로 연결된 클래스 사이의 메서드 탐색 과정을 이해하는 것이 가장 중요하다. 자세한 건 뒤로 미루고 객체와 클래스 사이의 관계에 초점을 맞추자.

 

객체는 서로 다른 상태를 저장할 수 있도록 인스턴스 별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드는 동일한 클래스의 인스턴스끼리 공유가 가능하다. 클래스는 한 번만 로드하고 인스턴스가 클래스를 가리키는 포인터를 갖게 하는 게 경제적이다.

 

 

업 캐스팅 

상속을 이용하면 부모의 퍼블릭 인터페이스가 자식의 인터페이스에 합쳐지기 때문에 부모의 인스턴스에게 전송할 수 있는 메시지를 자식의 인스턴스에게도 전송할 수 있다. 또한 컴파일러는 명시적 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.

 

이른 특성을 활용하는 대표적인 두 가지가 대입문과 메서드의 파라미터 타입이다. 반대로 부모 클래스의 인스턴스를 자식 클래스로 변환하기 위해는 명시적인 타입 캐스팅이 필요하다. 이를 다운 캐스팅이라고 한다.

 

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

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

 

동적 바인딩 

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

 

그리고 이 메시지는 정적 바인딩, 컴파일 타임 바인딩이 아닌 동적 바인딩으로 결정된다. 동적 바인딩은 실행될 메서드를 런타임 시에 결정하는 방식이다.

 

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

 

그렇다면 객체지향 언어는 어떤 규칙에 따라 메서드 전송과 메서드 호출을 바인딩하는 것일까?

 

동적 메서드 탐색과 다형성

동적 메서드 탐색은 두 가지 원리로 구성된다.

  • 자동적인 메시지 위임 
    • 자신이 이해할 수 없는 메시지를 받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다.
  • 메서드 탐색을 위한 동적인 문맥 
    • 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지 결정하는 것은 컴파일 시점이 아닌 실행 시점에 이뤄진다. 
    • 이렇게 동작을 결정하는 데 중요한 역할을 하는 것이 바로 self 참조 

 

자동적인 메시지 위임

상속을 이용할 경우 메시지 위임을 자동으로 처리할 수 있다.

 

즉, 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.

 

물론 상속이 아닌 다른 방법으로 자동 위임을 제공하기도 한다.

 

루비의 모듈, 스칼라의 트레이트, 스위프트의 프로토콜과 확장 등이 있다.

 

이렇게 자식에서 부모의 방향으로 처리가 위임되기에 자식 클래스에서 어떤 메서드를 구현하느냐에 따라 부모 클래스 메서드의 운명이 달라지기도 한다.

 

자식이 우선적으로 탐색되기 때문이다. 이런 방법은 시그니처를 완전히 동일하게 만든 메서드 오버 라이딩을 통해 이뤄진다. 그리고 시그니처가 완전히 같지 않은 경우를 오버 로딩이라고 한다.

 

Lecture lecture =...

lecture.average("A");
lecture.average();

이처럼 이름은 동일하나 파라미터가 다른 여러 메서드의 공존을 메서드 오버 로딩이라고 한다.

 

그런데 이는 언어마다 다르게 동작한다.

 

C++의 경우 부모 클래스의 메서드와 동일한 이름의 메서드를 오버 로딩하면 그 이름을 가진 모든 부모 클래스의 메서드를 감춰버린다.

 

같은 클래스 안에서의 오버 로딩은 허용하지만 상속 계층 사이의 오버 로딩은 금지하는 것이다. 반면 자바는 상속 계층 사이의 오버 로딩도 허용한다.

 

동적인 문맥

아무튼 이제 lecture.average()라는 메시지 전송 코드만으로는 어떤 클래스의 어떤 메서드가 실행될지 알 수 없다는 것을 알았다.

 

단, 여기서 중요한 것은 메시지를 수신한 객체가 무엇인가에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀌고 이 문맥을 결정하는 것은 메시지를 수신한 객체를 가리키는 self 참조다.

 

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

 

그런데 self 참조가 동적 문맥을 결정한다는 사실은 메서드 예상을 어렵게 한다. 대표적으로 자신에게 메시지를 전송하는 self 전송이다.

public class Lecture {
	public String state(){
		return "A" + getEvaluationMethod();
	}

	public stirng getEvaluationMethod(){
		return "PASS";
	}
}

자신의 getEvaluationMethod를 호출한다고 표현하지만 정확한 말은 아니다.

 

현재 객체에게 메시지를 전송하는 것이다. 현재 객체는 self 참조가 가리키는 객체다.

 

self 참조가 가리키는 객체에서 메시지 탐색을 다시 시작한다는 사실을 기억해야 한다.

 

여기까지는 단순하다. 그런데 상속이 끼어들면 이야기가 달라진다.

public class GradeLecture extends Lecture{
	@Override
	public stirng getEvaluationMethod(){
		return "GRADE";
	}
}

GradeLecture에게 state()를 전송하면 탐색은 여기서 시작한다.

 

그러나 state 메시지를 처리할 메서드가 없어 부모인 Lecture로 이동하고 메서드를 발견해 이를 실행할 것이다.

 

그러던 중 getEvaluationMethod 메시지를 전송하는 구문과 마주친다. 이제 메서드 탐색은 self 참조가 가리키는 객체에서 시작된다.

 

그런데 self 참조가 가리키는 객체는 GradeLecture 인스턴스다.

 

결국 Lecture의 stats와 GradeLecture의 getEvaluationMethod 메서드의 실행 결과가 나타난다.

 

self 전송은 자식에서 부모의 방향으로 이동하는 동적 메서드 탐색 경로를 다시 self로 이동시킨다. 이 때문에 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 올 수 있다.

 

이해할 수 없는 메시지

지금까지 살펴본 것처럼 클래스는 자신이 처리할 수 없는 메시지를 수신하면 부모 클래스로 처리를 위임한다.

 

 

하나 상속 계층의 정상에 와서야 메시지를 처리할 수 없다는 사실을 알면?

이런 경우를 처리하는 방법은 정적, 동적 타입 언어마다 다르다.

 

정적 타입 언어, 이해 불가한 메시지

정적 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다. 고로 이런 경우엔 컴파일 에러가 발생한다.

 

동적 타입 언어

여기선 실제 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다.

 

최상위 클래스까지 메서드를 탐색한 후 처리가 불가함을 알게 되면 self 참조가 가리키는 현재 겍체에게 이해할 수 없다는 메시지를 전송한다.

 

다른 방법으로는 “이해할 수 없음” 메시지에 응답할 수 있는 메서드를 구현하는 것이다.

상속 / 위임

앞서 self 참조가 동적인 문맥을 결정함을 알았다. 이제 자식 클래스에서 부모로 self 참조를 전달하는 메커니즘으로 상속을 바라보자.

 

위임과 self 참조

GradeLecture에서 self는 GradeLecture다.

 

그러면 GradeLecture에 포함된 Lecture 인스턴스 입장에서 self는? 이 경우에도 GradeLecture다. self 참조는 항상 메시지를 수신한 객체를 가리킨다.

 

각 객체에는 메시지를 수신한 객체를 가리키는 self 참조가 보관된다.

 

그리고 이 self 참조는 상속 계층을 따라 부모 클래스에게 계속해서 전달된다. 그렇기에 Lecture에서도 self 참조가 GradeLecture가 되는 것이다.

 

프로토 타입

클래스가 없고 객체만 존재하는 언어에서 상속을 구현하는 방법은 프로토타입을 사용해 객체 사이의 위임을 이용하는 것이다.

 

대표적인 예시로 자바스크립트가 있다.

 

자바스크립트의 모든 객체들은 다른 객체를 가리키는 용도로 prototype이라는 링크를 가진다.

 

JS에서 메시지를 수신하면 메시지를 수신한 객체의 prototype안에서 메시지에 응답할 메서드가 있는지 찾고 없다면 prototype이 가리키는 객체를 따라 메시지를 위임하게 된다.

function Lecture(name, scores){
	this.name = name;
	this.scores = scores;
}
Lecture.prototype.stats = function() {
	return "A" + this.getEvaluationMethod();
}
Lecture.prototype.getEvaluationMethod = function() {
	return "PASS";
}

메서드를 prototype이 참조하는 객체에 정의했음에 주목하자. Lecture를 이용해 생성된 모든 객체들은 prototype 객체에 정의된 메서드를 상속받는다.

function GradeLecture(name, scores){
	this.name = name;
	this.scores = scores;
}
GradeLecture.prototype = Lnew Lecture();

GradeLecture.prototype.constructor = GradeLecture;

GradeLecture.prototype.getEvaluationMethod = function() {
	return "GRADE";
}

prototype에 Lecture의 인스턴스를 할당했다. 이 과정을 통해 GradeLecture로 생성된 모든 객체들이 prototype을 통해 Lecture에 정의된 모든 속성과 함수에 접근할 수 있다. 이제 메시지를 전송하면 prototype으로 연결된 객체 사이의 경로로 메서드 탐색이 이루어진다.

 

 

 

반응형