상속에는 두 가지 목적이 있다.
이 중 코드 재사용은 부모 클래스의 구현에 자식 클래스가 의존적이게 되므로 지양하는 것이 좋다.
그렇다면 타입과 타입 계층은 무엇일까?
→ 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해서 사용된다.
→ 객체에게 중요한 것은 속성이 아니라 행동(퍼블릭 인터페이스)이다.
동일한 상태를 갖고 있어도 퍼블릭 인터페이스가 다르면 다른 타입으로 분류된다.
내부 상태는 다르지만 동일한 퍼블릭 인터페이스를 공유한다면 동일한 타입으로 분류된다.
수학에서 집합은 다른 집합을 포함할 수 있다. 타입도 집합이므로 다른 타입을 포함하는 것이 가능하다.
프로그래밍 언어
- 자바, 루비, C, C++, 자바스크립트 …
이 원소들을 더 상세한 기준에 따라 분류할 수 있다. 객체지향 언어/절차적 언어 등…, 클래스 기반 언어/프로토타입 기반 언어 등으로 분류할 수도 있다. (분류 기준이 다양하다.)
- 프로그래밍 언어
- 객체지향 언어
- 클래스 기반
- 자바, 루비, C++
- 프로토타입 기반
- 자바스크립트, 셀프
- 절차적 언어
- C, 파스칼 …
두 타입 간의 관계에서 더 일반적인 타입을 슈퍼타입, 더 특수한 타입을 서브타입이라고 부른다.
정리하자면
객체의 타입을 결정하는 것은 퍼블릭 인터페이스다.
즉, 정리하자면
서브타입 인스턴스는 슈퍼타입 인스턴스로 간주될 수 있다. (반대는 쪼끔 어려움. 다운캐스팅)
객체지향 언어에서 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것.
타입 계층을 구현하는 일반적인 방법은 상속을 이용하는 것.
부모 클래스가 슈퍼타입 역할을, 자식 클래스가 서브타입 역할을 수행하도록 클래스 사이의 관계를 정의한다.
상속의 올바른 용도는 타입 계층을 구현하는 것이다. 어떤 조건을 만족시켜야 타입 계층을 위해 상속했다고 말할 수 있을까?
마틴 오더스키의 질문 중 후자에 초점을 맞추자.
is-a 관계는 어휘적 표현이므로 이를 표현할 수 있다고 해도 두 클래스에 대해 기대하는 행동이 다르다면 상속을 사용하면 안 된다.
객체지향 언어는 프로그래밍 언어다. 클래스 기반 언어는 객체지향 언어다. 라고 표현할 수 있어야 한다.
하지만 is-a 관계가 직관적이고 명쾌한 것은 아니다.
새와 펭귄의 예를 들어 is-a 관계가 직관을 쉽게 배신할 수 있다. [스콧 마이어스, 이펙티브 C++]
그러나 펭귄은 날 수 없다. 이 예시는 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 보여준다.
→ 어휘적으로 펭귄은 새가 맞다. 그러나 새의 정의에 날 수 있다는 행동이 포함되면 펭귄은 새의 서브타입이 될 수 없다. 새의 정의에 날 수 있다가 포함되지 않으면 펭귄은 새의 서브타입이 될 수 있다.
즉, 타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있다.
슈퍼타입과 서브타입 관계에서는 is-a 관계보다 행동 호환성이 더 중요하다. (자연어에 낚일 수 있다)
너무 성급하게 상속을 적용하려고 서두르지 마라.
타입이 행동과 관련이 있다는 사실에 주목하자.
타입의 이름 사이에 개념적으로 어떤 연관성이 있다고 하더라도 행동에 연관성이 없다면 is-a 관계를 사용하지 말아야 한다.
두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다. 행동이 호환된다는 것을 판단하는 기준은 클라이언트의 관점이라는 것이다.
클라이언트가 두 타입이 동일하게 행동할 것이라고 기대하면 두 타입을 타입 계층으로 묶을 수 있다. 그렇지 않다면 타입 계층으로 묶어서는 안 된다. (클라이언트의 기대 만족, 행동 호환성)
is-a 자연어에 속아 상속 관계를 유지하면서도 일부 타입은 행동에 연관성이 없는 문제를 해결하기 위한 방법은 여러 개가 있다. 나머지는 대놓고 문제가 있어서 마지막 방법만 설명함.
flyBird()
를 수정해서 인자로 전달된 bird의 타입이 Penguin이 아닐 경우에만 fly메시지를 전송하도록 하는 방법이다.
public void flyBird(Bird bird) {
if (!bird instanceof Penguin) {
bird.fly();
}
}
이 방법은 Penguin 외에 다른 날 수 없는 새가 상속 계층에 추가되면 새로운 코드를 추가해야 한다. 구체적인 클래스에 대한 결합도를 높이는 방식이다.
→ 일반적으로 instanceof처럼 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때마다 코드 수정을 요구하므로 개방-폐쇄 원칙을 위반한다.
행동 호환성을 만족시키지 않는 상속 계층을 그대로 유지한 채 클라이언트의 기대를 충족시킬 수 있는 방법을 찾기 어렵다.
문제 해결을 위해서 클라이언트의 기대에 맞게 상속 계층을 분리해야 한다.
위의 펭귄의 경우 애초에 타입 계층을 구분할 때 날 수 있는 새와 날 수 없는 새를 명확하게 구분할 수 있게 상속 계층을 분리하면 서로 다른 요구사항을 가진 클라이언트를 만족시킬 수 있을 것이다.
public class Bird {
...
}
public class FlyingBird extends Bird {
public void fly() {...}
}
public class Penguin extends Bird {
...
}
flyBird 메서드는 FlyingBird 타입을 이용해서 날 수 있는 새만 인자로 전달해야 한다는 사실을 코드에 명시할 수 있다.
만약 날 수 없는 새와 협력하는 메서드가 있다면 파라미터 타입을 Bird로 선언하면 된다.
public void flyBird(FlyingBird bird) {
bird.fly();
}
💡 새로운 문제 발생!
만약 Bird가 날 수 있으면서 걸을 수도 있어야 하고 Penguin은 오직 걸을 수만 있다고 가정하자. Bird는 fly와 walk를 구현하고 Penguin은 walk만 구현해야 한다.
오직 fly 메시지만 전송하는 클라이언트와 오직 walk 메시지만 전송하는 또 다른 클라이언트가 존재한다.
→ 인터페이스는 클라이언트가 기대하는 바에 따라 분리돼야 한다.
다른 클라이언트가 오직 walk 메시지만 전송하길 원하면 이 클라이언트에게는 walk 메시지만 보여야 한다. 가장 좋은 방법은 fly 오퍼레이션을 가진 flyer 인터페이스와 walk 오퍼레이션을 가진 Walker 인터페이스로 분리하는 것이다.
만약 Penguin이 Bird의 코드를 재사용해야 한다면 어떻게 할까? 상속을 받는건 문법상으로는 문제가 없겠지만 fly 오퍼레이션이 퍼블릭 인터페이스에 추가되므로 사용할 수 없다. 애초에 재사용을 위한 상속은 위험하다.
합성을 사용하면 된다. 물론 Bird의 퍼블릭 인터페이스를 통해 재사용이 가능하다는 전제를 만족시켜야 한다.
대부분 불안정한 상속 계층을 계속 껴안고 가는 것보다 Bird를 재사용 가능하도록 수정하는 것이 더 좋은 방법이다.
클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있게 된다. 대부분 인터페이스는 클라이언트 요구가 바뀜에 따라 변경된다. 클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있다.
인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(ISP)이라고 부른다.
ISP는 비대한 인터페이스의 단점을 해결한다. (응집성 없는 인터페이스를 갖는 클래스)
비대한 클래스는 클라이언트 사이에 이상하고 해로운 결합이 생기게 만든다. 한 클라이언트가 이 비대한 클래스에 변경을 가하면 나머지 모든 클래스가 영향을 받게 된다. 그러므로 클라이언트는 자신이 실제로 호출하는 메서드에만 의존해야 한다. … 이렇게 하면 호출하지 않는 메서드에 대한 클라이언트의 의존성을 끊고, 클라이언트가 서로에 대해 독립적이 되게 만들 수 있다.
언제 상속을 사용해야 할까? 어떤 상속이 올바른 상속이고, 어떤 상속이 올바르지 않은 상속일까? 상속을 사용하는 두 가지 목적에 대해 특별한 이름을 붙였다.
그간 책에서 소개한 상속은 구현을 재사용하기 위해 사용된 서브클래싱에 속한다.
서브타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성을 만족해야 한다.
행동 호환성이 필요한 이유는 다른 서브타입으로 대체되어도 시스템이 문제없이 동작할 것이라는 것을 보장해야 하기 때문이다. 즉 대체 가능성을 포함한다.
→ 리스코프 치환 원칙은 행동 호환성과 대체 가능성을 통해 올바른 상속 관계를 구축하는 것이당.