Table of contents
Open Table of contents
다형성
- ’많은 형태를 가질 수 있는 능력’을 의미한다.
- 컴퓨터 과학에서는 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다.
다형성의 분류
- 오버로딩 다형성: 일번적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리킨다.
- 강제 다형성: 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식을 가리킨다.
일반적으로 오버로딩 다형성과 강제 다형성을 함께 시용하면 모호해질 수 있는데 실제로 어떤 메서드가 호출될지를 판단하기가 어려워지기 때문이다. => + 기호가 어떤 결과를 낼지는 언어의 지원에 따라 다르다
- 매개변수 다형성: 제네릭 과 관력이 높다. 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식을 가리킨다.
- 포함 다형성(서브타입 다형성): 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. (가장 널리 알랴진 형태의 다형성이기 때문에 특별한 언급이 없으면 서브타입 타형성을 의미하는 것이 일반적이다.)
상속의 양면성
상속의 목적은 코드 재사용이 아니다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다.
- 업캐스팅
- 동적 메서드 탐색
- 동적 바인딩
- self 참조
- super 참조
상속을 사용한 강의 평가
public class GradeLecture extends Lecture {
private List<Grade> grades;
public GradeLecture(String name, int pass, List<Grade> grades) {
super(name, pass);
this.grades = grades;
}
@Override
public double evaluate() {
return grades.stream()
.mapToInt(Grade::getPoint)
.average()
.orElse(0);
}
}
public class Lecture {
private String name;
private int pass;
public Lecture(String name, int pass) {
this.name = name;
this.pass = pass;
}
public String evaluate() {
return "Pass: " + pass + ", Fail: " + (grades.size() - pass);
}
public String stats() {
return "Pass: " + pass + ", Fail: " + (grades.size() - pass);
}
public String getName() {
return name;
}
public int getPass() {
return pass;
}
public List<Grade> getGrades() {
return grades;
}
public void setGrades(List<Grade> grades) {
this.grades = grades;
}
public void setPass(int pass) {
this.pass = pass;
}
}
데이터 관점의 상속
- 상속을 인스턴스 관점에서 바라볼 때는 개념적으로 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스가 포함되는 것으로 생각하는 것이 유용하다.
- 자식 클래스의 인스턴스에서 부모 클래스의 인스턴스로 접근 가능한 링크가 존재한다고 생각해도 무방하다.
- 실제로 객체를 메모리에 생성하는 방식이나 구조는 언어나 실행 환경에 따라 다르다.
Java와 JavaScript로 생각해보는 데이터 관점의 상속
Java
- 상속을 통해 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 안에 포함시킨다
- 객체 구조가 컴파일 타임에 결정되기 때문에 메모리 레이아웃을 고정 시킬 수 있다.
- 부모 클래스의 필드와 메서드가 자식 클래스의 인스턴스 내에 포함된다.
JavaScript
- 프로토타입을 통해 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 안에 포함시킨다.
- 객체 구조가 런타임에 결정되기 런타임에 프로토타입 체이닝을 통해서 부모 클래스의 인스턴스를 찾아간다.
- 인스턴스는 부모 클래스와 자식 클래스의 프로퍼티와 메서드에 접근할 수 있지만, 이는 proto 체인을 통해 이루어진다.
- 부모 객체의 프로퍼티와 메서드를 복사하지 않고 참조하기 때문에 메모리 효율성이 높다.
- 런타임에 객체의 구조를 변경할 수 있다.
행동 관점의 상속
- 행동 관점에서의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다.
- 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색한다.
- 객체의 경우에는 서로 다른 상태로 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 한다.
- 메서드의 경우 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스 별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다.
모든 인스턴스가 동일한 몇가지 동일한 속성을 공유하는 경우, 프로토타입의 강점이 드러납니다. 이는 특히 메서드를 공유할 경우 더욱 두드러집니다. 각 인스턴스에는 중복되고 불필요한 작업을 수행하는 고유한 함수 속성이 있기 때문에, 기대에 미치지 못하는 코드가 됩니다. 모든 상자의 getValue 메서드가 동일한 함수를 참조하므로, 메모리의 사용량이 줄어듭니다. 그러나 모든 객체 생성에 대해 proto를 수동으로 바인딩하는 것은 여전히 매우 불편합니다. 이것은 생성된 모든 객체에 대해 [[Prototype]]을 자동으로 설정하는 constructor 함수를 사용하는 경우입니다.
업캐스팅과 동적 바인딩
- 업캐스팅: 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것이 가능하다.
- 동적 바인딩: 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다.
개방-폐쇄 원칙과 의존성 역전 원칙 업캐스팅과 동적 메서드 탐색은 코드를 변경하지 않고도 기능을 추가할 수 있게 해준다.
업캐스팅
컴파일러 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있다.
동적 바인딩
함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일 타임에 결정한다. 코드 상에서 bar라는 함수를 호출하면 실제로 실행되는 코드는 bar라는 함수다
- 정적 바인딩: 코드를 작성하는 시점에 호출될 코드가 결정된다. 컴파일 타임에 호출할 함수를 결정한다.
- 동적 바인딩: 실행될 메서드를 런타임에 결정한다.
동적 메서드 탐색과 다형성
객체지향 시스템이 실행할 메서드를 선택하는 규칙
- 메시지를 수신한 객체는 먼저 자식을 생성한 클래스의 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
- 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
- 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중지한다.
객체가 메시지를 수신하면 self
참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다.
- 자동적인 메시지 위임: 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다.
- 동적인 문맥: 메시지를 수신했을 때 어떤 메서드를 실행할지는 런타임에 이뤄진다.
메서드 오버로딩
대부분의 사람들은 하나의 클래스 안에서 같은 이름을 가진 메서드들을 정의하는 것음 메서드 오버로딩으로 생각하고 상속 계층 사이에서 같은 이름을 가진 메서드를 정의하는 것은 메소드 오버로딩으로 생각하지 않는 경향이 있다.
C++에서는 같은 클래스 안에서의 메서드 오버로딩을 허용하지만 자바와 달리 상속 계층 사이에서의 메서드 오버로딩은 금지한다.
동적인 문맥
public class Lecture {
public String stats() {
return "Pass: " + pass + ", Fail: " + (grades.size() - pass);
}
public String getEvaluationMethod() {
return "Pass or Fail";
}
}
public class GradeLecture extends Lecture {
@Override
public String getEvaluationMethod() {
return "Grade";
}
}
public class Main {
public static void main(String[] args) {
Lecture lecture = new GradeLecture();
System.out.println(lecture.stats());
}
}
self 전송은 깊은 상속 계층과 계층 중간중간에 함정처럼 숨겨져 있는 메서드 오버라이딩과 만나면 극단적으로 이해하기 어려운 코드가 만들어진다.
이해할 수 없는 메시지
정적 타입 언어와 이해할 수 없는 메시지
코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다.
동적 타입 언어와 이해할 수 없는 메시지
- 코드를 실행해보기 전까지 메시지 처리 가능 여부를 판단할 수 없다.
- 런타임에 예외를 던질 수 있지만 예외에 응답할 수 있는 메서드를 구현할 수 있다.
- 이해할 수 없는 메시지를 처리할 수 있다는 건 순수한 관점에서 객체지향 패러다임을 구현한다고 볼 수 있다.
협력을 위해 메시지를 전송하는 객체는 메시지를 수신한 내부구현에 대해선 알지 못하기 때문이다. 하지만 동적인 특성과 유연성은 코드를 이해하고 수정하기 어렵게 만들고 디버깅 과정을 복접하게 만들기도 한다.
동적 타입 언어에서 이해할 수 없는 메시지를 어떻게 처리할 수 있을까?
class Apple {
getApple() {
return "apple";
}
}
const apple = new Apple();
apple.getApple(); //=> apple
apple.getBanana(); //=> TypeError: apple.getBanana is not a function
const newApple = new Proxy(apple, {
get: (target, prop, receiver) =>
prop in target
? target[prop]
: () => console.log(`Method ${prop} does not exist on target`),
});
newApple.getApple(); //=> apple
newApple.getBanana(); //=> Method getBanana does not exist on target
prop in target vs target.hasOwnProperty(prop) >
prop in target
은 target 객체의 프로퍼티를 상속받은 프로퍼티까지 검사한다. (프로토타입 체인까지 체크)target.hasOwnProperty(prop)
은 target 객체의 프로퍼티만 검사한다.
self 대 super
super
는 자식 클래스에서 부모 클래스의 구현을 재사용할 때 사용한다.super
참조의 의도는 ‘지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요’다.self
전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만super
전송의 경우에는 컴파일 시점에 미리 결정해 놓을 수 있다.
상속 대 위임
class Lecture
def initialize(name, scores)
@name = name
@scores = scores
end
def stats(this)
"Name: #{@name}, Evaluation Method: #{this.getEvaluationMethod ()}"
end
def get_evaluation_method()
"Pass or Fail"
end
end
class GradeLecture
def initialize(name, canceld, scores)
@parent = Lecture.new(name, scores)
@canceld = canceld
end
def stats(this)
@parent.stats(this)
end
def get_evaluation_method()
"Grade"
end
end
- 실행 문맥을 자식 클래스에서 부모 클래스로 전달 하는 상속 관계를 흉내 내기 위해 인자로 전달받은 this를 그대로 전달한다.
GradeLecture
클래스는stats
메서드를 직접 처리하지 않고Lecture
의stats
메서드에게 처리를 전달(위임)한다.- 위임은 본질적으로는 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용한다.
- 프로토타입 기반의 객체지향 언어는 객체 사이의 메시지 위임을 자동으로 처리해준다.
프로토타입 기반의 객체지향 언어
- 클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은 객체 사이의 위임을 이용하는 것이다.
- 메서드를 탐색하는 과정을 클래스 기반 언어의 상속과 거의 동일하다.
- 정적인 클래스 간의 관계가 아니라 동적인 객체 사이의 위임을 통해 상속을 구현하고 있을 뿐이다.
객체지향은 객체를 지향하는 것이다. 클래스는 객체를 편리하게 정의하고 생성하기 위해 제공하는 프로그래밍 구성 요소일 뿐이며 중요한 것은 메시지와 협력이다. 클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며 상속 없이도 다형성을 구현하는 것이 가능하다. 중요한 것은 클래스 기반의 상속과 객체 기반의 위임 사이에 기본 매커니증을 공유한다는 점이다.