스터디/오브젝트
오브젝트 - 13장 서브클래싱과 서브타이핑
realizers
2022. 11. 4. 21:09
728x90
반응형
서론
- 상속의 첫 번째 용도는 타입 계층을 구현하는 것입니다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화이고 자식 클래스는 부모 클래스의 특수화입니다.
- 상속의 두 번째 용도는 코드 재사용입니다. 상속을 사용하면 점진적으로 기능을 확장해나갈 수 있습니다. 다만 부모 클래스와 자식 클래스 간에 강하게 결합되기 때문에 변경이 어려워집니다.
- 우리는 상속을 코드 재사용의 목적이 아닌 타입 계층을 구현하기 위해서 상속을 사용해야 합니다.
타입
- 객체지향 프로그래밍 언어에서 타입을 이해하기 위해서는 프로그래밍 언어 관점에서의 타입과 개념 관점에서의 타입을 살펴봐야합니다.
- 타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해서 사용합니다.
💡 개념 관점의 타입
- 개념 관점에서 타입이란 우리가 인지하는 세상 사물의 한 종류를 의미합니다.
- 어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스 또는 객체라고 부릅니다.
💡 프로그래밍 언어 관점의 타입
- 타입에 수행될 수 있는 유효한 오퍼레이션 집합을 정의합니다. 예를들어 '+' 연산자는 숫자 타입이나 문자열 타입의 객체에는 사용할 수 있지만 다른 클래스의 객체에 대해서는 사용할 수 없습니다. 중요한것은 모든 객체지향 언어들은 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 개발자의 실수를 막아줍니다.
- 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공합니다. 예를들어 '+' 연산자는 정수 타입에 대해서는 덧셈을, 문자열 타입에는 합쳐짐을 수행하는데 이는 문맥을 결정하는 것은 객체의 타입이라는 것을 알 수 있습니다.
💡 객체지향 관점의 타입
- 개념 관점에서의 타입은 공통의 특징을 공유하는 대상들의 분류입니다.
- 프로그래밍 언어 관점에서의 타입은 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합입니다.
- 어떤 객체들이 동일한 상태를 가지고 있더라고 퍼블릭 인터페이스가 다르면 이들은 서로 다른 타입입니다. 반면에 어떤 객체이 내부 상태는 다르더라도 동일한 인터페이스를 공유한다면 동일한 타입으로 분류됩니다. 이로써 객체를 바라볼때 항상 객체가 외부에 제공하는 행동에 초점을 맞추어야 합니다.
타입 계층
- 타입 계층을 구성하는 두 타입 간의 관계에서 더 일반적인 타입을 슈퍼타입, 더 특수한 타입을 서브타입이라 부릅니다.
💡 내연의 관점에서 일반화와 특수화
- 객체의 정의를 의미하는 내연 관점에서 일반화란 어떤 타입의 정의를 좀 더 보편적이고 추상적으로 만드는 과정을 의미하며, 반대로 특수화란 어떤 타입의 정의를 조금더 구체적이고 문맥 종속적으로 만드는 과정을 의미합니다.
💡 외연의 관점에서 일반화와 특수화
- 집합을 의미하는 외연 관점에서 일반적인 타입의 인스턴스 집합은 특수한 타입의 인스턴스 집합을 포함하는 슈퍼셋입니다. 반대로 특수한 타입의 인스턴스 집합은 일반적인 타입의 인스턴스 집합에 포함되는 서브셋입니다.
슈퍼타입
- 집합이 다른 집합의 모든 멤버를 포함합니다.
- 타입 정의가 다른 타입보다 일반적입니다.
서브타입
- 집합에 포함되는 인스턴스들이 더 큰 집합에 포함됩니다.
- 타입 정의가 다른 타입보다 구체적입니다.
💡 객체지향 프로그래밍에서의 타입 계층이란?
슈퍼타입
- 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것입니다.
서브타입
- 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것입니다.
서브클래싱과 서브타이핑
💡 언제 상속을 사용해야 하는가?
- 상속의 올바른 용도는 코드 재사용이 아닌 타입 계층을 구현하는 것입니다. 그렇다면 어떤 조건을 만족해야지 상속을 잘 사용했다고 말할 수 있을까요?
상속 관계가 is-a 관계를 모델링하는가?
- 어휘적으로 "자식 클래스는 부모 클래스다" 라고 말해도 이상하지 않으면 상속을 사용할 후보로 간주할 수 있습니다.
클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?
- 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이를 몰라야합니다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라 합니다.
💡 is-a 관계
- 마틴 오더스키의 조언에 따르면 두 클래스가 어휘적으로 is-a 관계를 모델링할 경우에만 상속을 사용해야 한다고 합니다.
- 아래의 예제는 상속이 어휘적인 관계가 아닌 기대되는 행동에 따라 타입 계층을 구성해야하는 사실을 내포하고 있습니다. 어휘적으로 펭귄은 새가 맞지만 만약 새의 정의에 날 수 있다는 행동이 포함되지 않는다면 펭귄은 새의 서브타입이 될 수 있습니다.
- 이로써 타입 계층을 구성할 때 어휘적인 관점보다 행동이라는 문맥에 집중해야 한다는 것을 알 수 있습니다.
public class Bird {
// 날다
public void fly() {
}
}
public class Penguin extends Bird {
}
💡 행동 호환성
- 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 합니다.
- 행동 호환성의 판단 기준은 클라이언트의 관점입니다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있습니다. 반면 클라이언트가 두 타입이 동일하지 않게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안됩니다.
- 위의 예제에서 펭귄이 새의 서브타입이 아닌 이유는 클라이언트는 새가 날 수 있을것이라고 기대하지만 펭귄은 날 수 없기 때문에 펭귄은 새의 서브타입이 될 수 없습니다.
😂 임시 방편의 해결 방법
- 첫 번째 방법은 메서드를 오버라이딩하여 아무 반응을 일으키지 않도록하는 것입니다. 이 방법이 잘 못된 이유는 클라이언트는 새가 날 수 있다고 생각하는데 결국 클라이언트의 기대를 만족시키지 못하게 됩니다.
public class Bird {
// 날다
public void fly() {
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// 아무 행동을 하지 않음
}
}
- 두 번째 방법은 예외를 던지는 것입니다. 이 방법 또한 클라이언트는 예외를 기대하지 않았을텐데 예상치 않게 예외를 받게 되므로 만족을 시키지 못하게 됩니다.
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}
- 세 번째 방법은 새로운 메서드를 만들어 매개변수가 펭귄이 아닌 경우메나 fly 메서드를 실행시키도록 합니다. 이 방법의 문제는 새로운 날 수 없는 타입이 추가된 경우(예를들어 타조) 타입 체크를 하는 코드를 추가적으로 작성해야합니다. 이는 새로운 타입을 추가할 때마다 코드 수정을 요구하기 때문에 OCP를 위반하게 됩니다.
public class Penguin extends Bird {
public void flyBird(Bird bird) {
if (!(bird instanceof Penguin)) {
bird.fly();
}
}
@Override
public void fly() {
}
}
🤔 해결 방법
- 클라이언트의 기대에 따라 계층을 분리하는 것입니다.
- 새는 날 수도 있고 걸을 수도 있으므로 Flyer, Walker를 구현하고 펭귄은 걸을 수만 있으므로 Walker를 구현합니다.
- 이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(ISP)라 합니다.
public interface Flyer {
void fly();
}
public interface Walker {
void walk();
}
public class Bird implements Flyer, Walker {
@Override
public void fly() { }
@Override
public void walk() { }
}
public class Penguin implements Walker {
@Override
public void walk() { }
}
💡 서브클래싱과 서브타이핑
- 서브클래싱과 서브타이핑을 나누는 기준은 상속을 사용하는 목적입니다.
- 슈퍼타입의 인스턴스를 요구하는 모든 곳에서 서브타입의 인스턴스를 대신 사용하기 위해 만족해야 하는 최소한의 조건은 서브타입의 퍼블릭 인터페이스가 슈퍼타입에서 정의한 퍼블릭 인터페이스와 동일하거나 더 많은 오퍼레이션을 가지고 있어야 합니다.
- 개념적으로 서브타입이 슈퍼타입의 퍼블릭 인터페이스를 상속받는 것처럼 보이게 되는데 그렇기 때문에 이것을 서브타이핑을 인터페이스 상속이라고 부르는 이유입니다. 반면에 서브 클래싱은 부모 클래스의 내부 구현을 상속받는 것에 초점을 맞추기 때문에 구현 상속 및 클래스 상속이라 부릅니다.
- 서브타이핑 관계를 유지시키기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 하는데 이를 행동 호환성이라 합니다.
- 자식 클래스와 부모 클래스 사이의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성을 포함합니다.
서브클래싱
- 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 말합니다. 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스를 대체할 수 없습니다. 서브클래싱을 구현 상속 및 클래스 상속이라 부릅니다.
서브타이핑
- 타입 계층을 구현하기 위해 상속을 사용하는 경우를 말합니다. 자식 클래스와 부모 클래스는 행동 호환성을 만족시키기 때문에 자식 클래스가 부모 클래스를 대체할 수 있습니다. 이를 인터페이스 상속이라고도 부릅니다.
리스코프 치환 원칙
- 리스코프 치환 원칙은 "서브타입은 부모 타입에 대해 대체 가능해야한다." 는 것으로 클라이언트가 차이점을 인식하지 못한 채 부모 클래스의 인터페이스를 통해 자식 클래스를 사용할 수 있어야 한다고 말합니다.
- 아래 예제에서 중요한 점은 클라이언트의 관점에서 행동이 호환되는지의 여부입니다. 그리고 행동이 호환될 경우에만 자식 클래스가 부모 클래스를 대신해서 사용할 수 있다고 말하고 있습니다.
@Setter @Getter
@AllArgsConstructor
public class Rectangle {
private int width, height;
public int getArea() {
return height * width;
}
}
public class Square extends Rectangle {
public Square(int width, int height) {
super(width, height);
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle(100, 50);
Square square = new Square(100, 100);
resize(rectangle, 1000, 500); // true
resize(square, 1000, 500); // false
}
private static void resize(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
System.out.println(rectangle.getWidth() == width && rectangle.getHeight() == height);
}
}
💡 클라이언트 대체 가능성
- Square가 Rectangle을 대체할 수 없는 이유는 클라이언트의 관점에서 결과물이 다르게 때문입니다.
- 리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것입니다.
- 앞에서 살펴본 is-a 관계에서 역시 클라이언트의 관점에서 is-a 일 때만 참입니다. is-a 관계는 객체지향에서 중요한 것은 객체의 속성이 아닌 객체의 행동이라는 점을 강조합니다.
- 행동을 고려하지 않고 단순히 두 타입의 이름이 is-a로 연결할 수 있다고해서 상속 관계로 묶으면 안됩니다. 중요한 것은 이름이 아닌 행동입니다.
계약에 의한 설계와 서브타이핑
- 클라이언트와 서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계라고 합니다.
- 계약에 의한 설계는 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야하는 사전조건과 메서드가 실행된 후 서버가 클라이언트에게 보장해야 하는 사후조건, 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식의 세 가지 요소로 구성됩니다.
- 리스코프 치환 원칙과 계약에 의한 설계 사이의 관계는 서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 "계약"을 준수해야 한다고 말합니다.
서브타입에 더 강력한 사전조건을 정의할 수 없습니다.
public abstract class DiscountPolicy {
public void calculateDiscount(LocalDate date) {
// 시간을 이용한 로직
}
}
public class BrokenDiscountPolicy extends DiscountPolicy {
@Override
public void calculateDiscount(LocalDate date) {
// 강화된 사전 조건
if (date != null && date.isAfter(LocalDate.of(2022, 11, 30))) {
throw new IllegalStateException("기간이 지났습니다.");
}
}
}
서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있습니다.
public abstract class DiscountPolicy {
public void calculateDiscount(LocalDate date) {
// 시간을 이용한 로직
}
}
public class BrokenDiscountPolicy extends DiscountPolicy {
@Override
public void calculateDiscount(LocalDate date) {
// 아무 검증 하지 않음 : 슈퍼타입과 사전조건 동일
}
}
서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있습니다.
public abstract class DiscountPolicy {
public Long calculateDiscount(LocalDate date) {
// 시간을 이용한 로직
return 500L; // null이 아니거나 최소 500 이상은 반환해야함
}
}
public class BrokenDiscountPolicy extends DiscountPolicy {
@Override
public Long calculateDiscount(LocalDate date) {
return 1000L; // 최소 1000이상 반환해야함(더 강한 사후조건)
}
}
서브타입에 더 약한 사후조건을 정의할 수 없습니다.
public abstract class DiscountPolicy {
public Long calculateDiscount(LocalDate date) {
// 시간을 이용한 로직
return 500L; // null이 아니거나 최소 500 이상은 반환해야함
}
}
public class BrokenDiscountPolicy extends DiscountPolicy {
@Override
public Long calculateDiscount(LocalDate date) {
return 250L; // 최소 250이상 반환해야함(더 약한 사후조건)
}
}
✔️ 정리
- 어떤 클래스가 다른 클래스를 상속받으면 그 클래스의 자식 클래스 또는 서브 클래스가 되지만 모든 서브 클래스가 서브타입은 아닙니다. 여기서 코드 재사용을 목적으로 상속을 사용했다면, 그리고 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 없다면 서브타입이라 할 수 없습니다. 서브타입이 슈퍼타입처럼 보일 수 있는 유일한 방법은 클라이언트가 슈퍼타입과 맺은 계약을 서브타입이 준수하고 대체할 수 있어야 합니다.
- 타입 계층을 위한 목적으로 상속을 사용해야 하며, 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 있는가 또는 행동 호환성이 성립하는가에 대해 깊게 생각해야 합니다. 또한 아무리 is-a 관계가 성립한다고 해서 상속을 적용하려고 하지말고 행동을 고려하여 상속을 사용해야 합니다. 여기서 행동이란 퍼블릭 인터페이스이며 객체의 속성이 아닌 객체의 퍼블릭 인터페이스를 기준으로 판단해야 합니다.
728x90
반응형