12장 : 다형성

코드 재사용을 목적으로 상속을 사용하면 변경하기 어렵고 유연하지 못한 설계에 이를 확률이 높아진다. 상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다. 타입 계층은 객체지향 프로그래밍의 중요한 특성 중 하나인 다형성의 기반을 제공한다.

이번 장에서는 상속의 관점에서 다형성이 구현된느 기술적인 매커니즘을 살펴보기로 한다. 이번 장을 일고 나면 다형성이 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현되며, 상속이 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하기 위한 방법이라는 사실을 이해하게 될 것이다.

1. 다형성

  • 다형성(Polymorphism)이라는 단어는 그리스어에서 ‘많은’을 의미하는 ‘poly’와 ‘형태’를 의미하는 ‘morph’의 합성어로 ‘많은 형태를 가질 수 있는 능력’을 의미한다.

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

  • 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법

객체 지향 프로그래밍에서 사용되는 다형성

  1. 유니버설(Universal) 다형성

    public class Money {
    	public Money plus(Money amount) { ... }
    	public Money plus(BigDecimal amount) { ... }
    	public Money plus(long amount) { ... }
    }
  2. 강제 다형성

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

  3. 매개변수 다형성

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

  4. 포함 다형성

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

    • 포함 다형성은 서브타입 다형성이라고 부른다.

Movie 클래스는 discountPolicy에게 calculateDiscountAmount 메시지를 전송하지만 실제로 실행되는 메서드는 메시지를 수신한 객체의 타입에 따라 달라진다.

public class Movie {
	private DiscountPolicy discountPolicy;
	
	public Money calculateMoviefee(Screening screening) {
		return fee.minus(discountPolicy.calculateDiscountAmount(screening));
	}
}

초함 다형성을 구현하는 가장 일반적인 방법은 상속을 사용하는 것이다. 두 클래스를 상속 관계로 연결하고 자식 클래스에서 부모 클래스의 메서드를 오버라이딩한 후 클라이언트는 부모 클래스만 참조하면 포함 다형성을 구현할 수 있다.

포함 다형성을 위한 전제조건은 자식 클래스가 부모 클래스의 서브타입이어야 한다. 그리고 상속의 진정한 목적은 코드 재사요이 아니라 다형성을 위한 서브타입 계층을 구축하는 것이다.

이번 장의 목표는 포함 다형성의 관점에서 런타임에 상속 계층 안에서 적절한 메서드를 선택하는 방법을 이해하는 것이다.

2. 상속의 양면성

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

상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다. 이것이 데이터 관점의 상속이다. 데이터 뿐만 아니라 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다. 이것이 행동 관점의 상속이다. 단순히 데이터와 행동의 관점에서만 바라보면 상속이란 부모 클래스에서 정의한 데이터와 행동을 자식 클래스에서 자동적으로 공유할 수 있는 재사용 메커니즘으로 보일 것이다. 하지만 이 관점은 상속 오해한 것이다.

상속의 목적은 코드 재사용이 아니다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다. 문제를 피할 수 있는 유일한 방법은 상속이 무엇이고 언제 사용해야 하는지를 이해하는 것 뿐이다.

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

  • 업캐스팅

  • 동적 메서드 탐색

  • 동적 바인딩

  • self 참조

  • super 참조

상속을 사용한 강의 평가

✔️ Lecture 클래스 살펴보기

이번 장에서는 수강생들의 성적을 계산하는 간단한 예제 프로그램을 구현해 보자.

Pass : 3 Fail : 2 A : 1 B : 1 C : 1 D : 0 F : 2
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 Colelctions.ummodifiableList(scores);
	}

	public String evaluate() {
		return String.format("Pass:%d Fail:%d, passCount(), failCount()");
	}

	private long passCount() {
		return score.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.evalute(); // 결과 => "Pass:3 Fail:2"

✔️ 상속을 이용해 Lecrue 클래스 재사용하기

Lecture 클래스는 새로운 기능을 구현하는 데 필요한 대부분의 데이터와 메서드를 포함하고 있다. 따라서 Lecture 클래스를 상속받으면 새로운 기능을 쉽고 빠르게 추가할 수 있을 것이다.

원하는 기능은 Lecture의 출력 결과에 등급별 통계를 추가하는 것이므로 클래스의 이름으로는 GradeLecture 가 적절할 것 같다. GradeLecture 클래스에는 Grade 인스턴스들을 리스트로 보관하는 인스턴스 변수 grades를 추가하자.

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.grades = grades;
	}
}
public class Grade {
	private String name;
	private int upper, lower;

	private 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;
	}
}
public class GradeLecture extends Lecture {
	@Override
	public String evaluate() {
		return super.evalute() + ", " + gradeStatistics();
	}

	private String gradeStatistics() {
		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();
	}
}

GradeLecture의 evaluate 메서드에서는 예약어 super를 이용해 Lecture 클래스의 evalute 메서드를 먼저 실행한다.

일반적으로 super는 자식 클래스 내부에서 부모 클래스의 인스턴스 변수나 메서드에 접근하는 데 사용된다.

GradeLecture와 Lecture에 구현된 두 evaluate 메서드의 시그니처가 완전히 동일하다. 부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 더 높다.

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

GradeLecture 클래스의 인스턴스 변수에게 evaluate 메시지를 전송하면 Lecture의 evalute 메서드를 오버라이딩한 GradeLecture의 evaluate 메서드가 실행된다.

Lecture lecture = new GradeLecture("객체지향 프로그래밍", 
											70,
											Arrays.asList(new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
											Arrays.asList(81, 95, 75, 50, 45)));

lecture.evaluate();

자식 클래스에 부모 클래스에는 없던 새로운 메서드를 추가하는 것도 가능하다.

public class GradeLecture extends Lecture {
	public double average(String gradeName) {
		return grades.stream()
								.filter(each -> each.isName(gradeName))
								.findFirst()
								.map(this::gradeAverage)
								.orElse(0d);
	}

	private double gradeAverage(Grade grade) {
		return getScores().stream()
						.filter(grade::include)
						.mapToInt(Integer::intValue)
						.average()
						.orElse(0);
	}
}

데이터 관점의 상속

다음과 같이 Lecture의 인스턴스를 생성했다고 가정하자.

Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50, 45));

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

이번에는 GradeLecture의 인스턴스를 생성했다고 가정하자. GradeLecture 클래스의 인스턴스는 직접 정의한 인스턴스 변수뿐만 아니라 부모 클래스인 Lecture가 정의한 인스턴스 변수도 함께 포함한다.

Lecture lecture = new GradeLecture("객체지향 프로그래밍", 
											70,
											Arrays.asList(new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
																		new Grade("A", 100, 95),
											Arrays.asList(81, 95, 75, 50, 45)));

lecture.evaluate();

자식 클래스의 인스턴스에서 부모 클래스의 인스턴스로 접근 가능한 링크가 존재하는 것처럼 생각해도 무방하다.

행동 관점의 상속

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

부모 클래스에 정의된 어떤 메서드가 자식 클래스에 포함될지는 언어의 종류와 각 언어가 정의하는 접근 제어자의 의미에 따라 다르지만 공통적으로 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에게 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다.

어떻게 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있는 것일까 ? 그 이유는 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문이다.

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

3. 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

  • 지금까지 작성한 성적 계산 프로그램에 각 교수별로 강의에 대한 성정 통계를 계산하는 기능을 추가해보자.

public class Professor {
	private String name;
	private Lecture lecture;

	public Professor(String name, Lecture lecture) {
		this.name = name;
		this.lecture = lecture;
	}

	public String compileStatistics() {
		return String.format("[%s] %s - Avg: %.1f", name, 
			lecture.evaluate(), lecture.average());
	}
}
  • 다음은 다익스트라 교수가 강의하는 알고리즘 과목의 성적 통계를 계산하는 코드다.

Professor professor = new Professor("다익스트라", 
														new Lecture("알고리즘", 
															70, 
															Arrays.asList(81, 95, 75, 50, 45)));

String statistics = professor.campileStatistics();
  • Lecture 클래스 대신 GradeLecture 인스턴스 전달

Professor professor = new Professor("다익스트라", 
												new GradeLecture("알고리즘", 
														70, 
														Arrays.asList(new Grade("A", 100, 95),
														              new Grade("A", 100, 95),
														              new Grade("A", 100, 95),
														              new Grade("A", 100, 95),
														              new Grade("A", 100, 95),
														              new Grade("A", 100, 95),
														Arrays.asList(81, 95, 75, 50, 45)));
String statistics = professot.compileStatistics();

이처럼 코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅과 동적 바인딩 매커니즘이 적용하기 때문이다

  • 부모 클래스(Lecture) 타입으로 선언된 변수에 자식 클래스(GradeLecture)의 인스턴스를 할당하는 것이 가능하다. 이를 업캐스팅이라고 부른다.

  • 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 개체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다. 이를 동적 바인딩이라고 부른다.

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다.

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

Lecture lecture = new GradeLecture(...);
  • 부모 클래스 타입으로 선언된 파라미터에 자식 클래스의 인스턴스를 전달하는 것도 가능하다.

public class Professor {
	public Professor(String name, Lecture lecture) { ... }
}

Professor professor = new Professor("다익스트라", new GradeLecture(...));

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

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

동적 바인딩

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

함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일타임에 결정한다. 코드 상에서 bar함수를 호출하는 구문이 나타난다면 실제로 실행되는 코드는 바로 그 bar라는 함수다. bar 이외의 어떤 코드도 아니다. 다시 말해 코드를 작성하는 시점에 호출될 코드가 결정된다.

  • 이처럼 컴파일타임에 호출할 함수를 결정하는 방식을 정적 바인딩(static bindling), 초기 바인딩(early binding), 또는 컴파일타임 바인딩(compile-time-bindling)이라고 부른다.

객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. foo.bar()라는 코드를 읽는 것만으로는 실행되는 bar가 어떤 클래스의 어떤 메서드인지를 판단하기 어렵다.

  • 이처럼 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩(dynamic binding) 또는 지연 바인딩(late binding)이라고 부른다.

4. 동적 메서드 탐색과 다형성

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

  • 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.

  • 메서드를 찾지 목했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.

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

메시지 탐색과 관련해서 self 참조(self reference)가 중요하다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. 동적 메서드 탐색은 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순산 self 참조는 자동으로 소멸된다. 시스템은 앞에서 설명한 class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색한다.

  • 동적 메서드 탐색

    • 자동적인 메시지 위임

    • 동적인 문맥

자동적인 메시지 위임

  • 동적 메서드 탐색의 입장에서 상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에게 전달하기 위한 물리적인 경로를 정의하는 것으로 볼 수 있다.

  • 여기서 핵심은 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.

상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없음에 주목하라. 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다.

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

메서드 오버라이딩

메서드 오버로딩

  • 메서드 로버라이딩은 자식 클래스가 부모 클래스에 존재하는 메서드와 동일한 시그니처를 가진 메서드를 재정의해서 부모 클래스의 메서드를 감추는 현상을 가리킨다.

  • 시그니처가 다른, 동일한 이름의 메서드가 공존하는 경우를 메서드 오버로딩이라고 부른다.

동적인 문맥

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

  • 동일한 코드라고 하더라도 self 참조가 가리키는 객체 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있따.

이해할 수 없는 메시지

이해할 수 없는 메시지를 처리하는 방법은 프로그래밍 언어가 정적 타입 언어에 속하는지, 동적 타입 언어에 속하는지에 따라 달라진다.

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

  • 정적 타입 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단하고, 상송 계층 전체 탐색 후 메시지를 처리할 수 없는 메서드를 발견하지 못했다면 컴파일 에러를 발생시킨다.

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

  • 동적 타입 언어 역시 메시지를 수신한 객체의 클래스부터 부모 클래스의 방향으로 메서드를 탐색한다. 차이점이라면 동적 타입 언어에는 컴파일 단계가 존재하지 않기 때문에 실제로 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다는 점이다.

self 대 super

  • self 참조의 가장 큰 특징은 동적이라는 점이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 바로 super 참조(super reference)다.

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

5. 상속 대 위임

지금까지 살펴본 것처럼 다형성은 self 참조가 가리키는 현재 객체에게 메시지를 전달하는 특성을 기반으로 한다.

위임과 self 참조

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

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

    • 위임 : 객체 사이의 동적인 연결 관계를 이용해 상속을 구현하는 방법

    • 상속이 매력적인 이유는 우리가 직접 구현해야 하는 이런 번잡한 과정을 자동으로 처리해 준다.

    • self 참조가 자동으로 전달

  • 위임은 본질적으로 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 가용한다. 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다.

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

  • 클래스 기반의 객체지향 언어들이 상속을 이용해 클래스 사이에 self참조를 자동으로 전달하는 것처럼 프로토타입 기반의 객체지향 언어들 역시 위임을 이용해 객체 사이에 self 참조를 자동으로 전달한다.

클래스 기반의 상속과 객체 기반의 위임 사이에 기본 개념과 메커니즘을 공유한다는 점이다. 이 사실을 이해한다면 다형성과 상속, 나아가 객체지향 언어를 바라보는 여러분의 시각이 달라질 것이다.

Last updated