코드 재사용을 목적으로 상속을 사용하면 변경이 어렵고 유연하지 못한 설계에 이른다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다. 타입 계층은 다형성의 기반을 제공한다.
상속은 인스턴스를 동일하게 행동하는 그룹으로 묶기 위한 관점으로 사용되어야 한다.
다형성
다형성 Polymorphism은 많은 형태를 가질 수 있는 능력을 의미한다. 컴퓨터 과학에서 다형성은 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결 할 수 있는 능력으로 정의한다.
다형성의 구분은 위 그림과 같다.
- 오버로딩 다형성 : 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우 (다른 타입의 파라미터 또는 그의 수)
- 강제 다형성 : 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식. (자바의
+
연산자) - 매개변수 다형성 : 클래스의 인스턴스 변수나 메서드의 매개변소타입을 임으의 탕비으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식. 제네릭 프로그래밍과 관련이 있다.
- 포함 다형성 : 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. 서브 타입 이기도 함.
오버로딩 다형성
public class Money {
public Money plus(Money amount) { }
public Money plus(BigDecimal amount) { }
public Money plus(long amount) { }
}
포함 다형성
public Class Movie {
private DiscountPolicy discountPolicy;
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
포함 다형성을 위해 상속을 사용하는 가장 큰 이유는 상속이 클래스들을 계층으로 쌓아올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다. 객체가 메시지를 수신하면 객체지향 시스템은 메시지를 처리할 적절한 메서드를 상속 계층 안에서 탐색한다. 실행할 메서드를 선택하는 기준은 어떤 메시지를 수신햇는지에 따라, 어떤 클래스의 인스턴스인지에 따라, 상속 계층이 어떻게 구성돼 있는지에 따라 달라진다.
상속의 양면성
객체지향의 패러다임은 데이터와 행동을 객체라고 불리는 하나의 실행 단위 안으로 통합하는 것인데 상속도 마찬가지다.
- 데이터 관점의 상속 : 상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다.
- 행동 관점의 상속 : 부모 클래스에서 정의한 일부 메서드를 자동으로 자식 클래스에 포함시킬 수 있다.
상속의 메커니즘을 이해하는데 필요한 개념들
- 업캐스팅
- 동적 메서드 탐색
- 동적 바인딩
self
참조super
참조
상속을 사용한 강의 평가
Lecture 클래스 살펴보기
public class Lecture {
private int pass;
private String title;
private List<Integer> scores = new ArrayList<>();
public Lecture(String title, int pass, List<Integer> scores) {
this.title = title;
this.pass = pass;
this.scores = scores;
}
public double average() {
return scores.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0);
}
public List<Integer> getScores() {
return Collections.unmodifiableList(scores);
}
public String evaluate() {
return String.format("Pass:%d Fail:%d", passCount(), failCount());
}
private long passCount() {
return scores.stream().filter(score -> score <= pass).count();
}
private long failCount() {
return scores.size() - passCount();
}
}
이를 사용한 코드는 다음과 같다.
Lecture lecture = new Lecture ("객체지향 프로그래밍",
70,
Arrays.asList(81,95,75,50,45));
String evaluration = lecture.evaluate();
상속을 이용해 Lecture 클래스 재사용하기
Lecture 클래스를 상속해 등급을 관리하는 클래스를 새로 만든다.
public class GradeLecture extends Lecture {
private List<Grade> grades;
public GradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
super(name, pass, scores);
this.grade = grades;
}
}
public class Grade {
private String name;
private int upper, lower;
public Grade(String name, int upper, int lower) {
this.name = name;
this.upper = upper;
this.lower = lower;
}
public String getName() {
return name;
}
public boolean isName(String name) {
return this.name.equals(name);
}
public boolean include(int score) {
return score >= lower && score <= upper;
}
@Override
public String evaulate() {
return super.evaulate() + ", " + gradesStatistics();
}
private String gradesStatustics() {
return grades.stream()
.map(grade -> format(grade))
.collect(joining(" "));
}
private String format(Grade grade) {
return String.format("%s:%d", grade.getName(), gradeCount(grade));
}
private long gradeCount(Grade grade) {
return getScores().stream()
.filter(grade::include)
.count();
}
}
메서드 오버라이딩 Method overriding
GradeLecture
와 Lecture
에 구현된 두 evaluate()
의 시그니처가 완전히 동일하다. 부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 더 높다.
결과적으로 동일한 시그니처를 가진 자식 클래스의 메슫가 부모 클래스의 메서드를 가리게 된다. 이처럼 자식 클래스 안에 상속받은 메서드와 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것을 메서드 오버라이딩이라고 부른다.
GradeLecture
클래스의 인스턴스 변수에게 evaluate()
메시지를 전송하면 Lecture
의 evaluate()
를 오버라이딩 한 GradeLecture
의 evaluate()
가 실행된다.
메서드 오버로딩 Method overloading
evaluate()
와 달리 GradeLecture
의 average()
는 부모 클래스인 Lecture
에 정의된 average()
와 이름은 같지만 시그니처는 다르다. 두 메서드의 시그니처가 다르기 때문에 GradeLecture
의 average()
는 Lecture
의 average()
를 대체하지 않으며, 결과적으로 두 메서드는 별도로 존재하며, 호출하는 쪽에서 두 메서드 모두 호출할 수 있다. 이처럼 부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것을 메서드 오버로딩이라고 부른다.
public class GradeLecture extends Lecture {
public double average(String gradeName) {
//...
}
public double gradeAverage(Grade grade) {
//...
}
}
데이터 관점의 상속
다음과 같이 Lecture
의 인스턴스를 생성했다.
Lecture lecture = new Lecture("객체지향 프로그래밍",
70,
Arrays.asList(81, 95, 75, 50, 45));
Lecture
의 인스턴스를 생성하면 시스템은 인스턴스 변수 title
, pass
, scores
를 저장할 수 있는 메모리 공간을 할당하고 생성자의 매개변수를 이용해 값을 설정한 후 인스턴스의 주소를 lecture
라는 변수에 대입한다.
Lecture lecture = new GradeLecture("객체지향 프로그래밍",
70,
Arrays.asList(new Grade("A", 100, 95),
new Grade("B", 94, 80),
new Grade("C", 79, 70),
new Grade("D", 69, 50),
new Grade("F", 49, 0)),
Arrays.asList(81, 95, 75, 50, 45));
GradeLecture
역시 마찬가지로 생성할 수 있다. 인스턴스를 참조하는 lecture
는 GradeLecture
의 인스턴스를 가리키기 때문에 특별한 방법을 사용하지 않으면 GradeLecture
안에 포함된 Lecture
의 인스턴스에 직접 접근할 수 없다.
행동 관점의 상속
데이터 관점의 상속이 자식 클래스의 인스턴스 ㅇ나에 부모 클래스의 인스턴스를 포함하는 개념이라면 행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드에 포함시키는 것을 의미한다.
- 부모 클래스의 public 메서드는 자식 클래스의 public 인터페이스에 포함된다.
- 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색한다.
객체의 경우 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당 받아야 한다. 하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는것이 경제적이다.
Lecture
의 인스턴스를 두개 생성했다. 그러나 클래스는 단 하나만 메모리에 로드외었다.
- 각 객체는 자신의 클래스의 위치를 가리키는
class
라는 이름의 포인터를 가진다. 이 포인터를 가지고 자신의 클래스 정보에 접근할 수 있다. Lecture
클래스가 자신의 부모 클래스인Object
의 위치를 가리키는parent
라는 이름의 포인터를 가진다. 이 포인터를 사용하면 클래스의 상속 계층으 ㄹ따라 부모 클래스의 정의로 이동하는것이 가능하다.
자식 클래스에서 부모 클래스로의 메서드 탐색이 가능하기 때문에 자식 클래스는 마치 부모의 클래스에 구현된 메서드의 복사본을 가지고 있는 것 처럼 보이게 된다.