본문 바로가기

카테고리 없음

13) 서브클래싱과 서브타이핑

상속은 두 가지 용도로 사용된다. 첫 번째 용도는 타입 계층을 구현하는 것이다. 두 번째 용도는 코드 재사용이다. 여기서 중요한 점은 상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 상속은 코드의 재사용할 수 있는 방법을 제공하지만 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 설계의 변경과 진화를 방해한다.

 

1. 타입

1.1 개념 관점의 타입

일반 적으로 우리가 인지하는 세상의 사물의 종류를 의미한다. 객체들에 적용하는 개념이나 아이디어를 가리켜 타입이라고 부른다. 사자, 호랑이, 펭귄, 강아지를 동물이라고 부를 때 이것들은 동물이라는 타입으로 분류하고 있다. 사자, 호랑이, 펭귄, 강아지는 동물의 인스턴스(객체)다.

 

1.2 프로그래밍 언어 관점의 타입

프로그래밍 언어 관점에서 타입은 연속적인 비트에 의미와 제약을 부여하기 위해 사용된다. 프로그래밍  언어에서 타입은 두 가지 목적을 위해 사용된다.

  • 타입에 수행될 수 있는  유효한 오퍼레이션의 집합을 정의한다.
  • 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다.

개념 관점에서 타입이란 공통의 특징을 공유하는  대상들의 분류이고 프로그래밍 언어 관점에서 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합이라고 정의할 수 있다. 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 같다. 객체의 타입을 결정하는 것은 내부의 속성이 아니라 객체가 외부에 제공하는 행동이다.

 

2. 타입 계층

타입 계층을 구성하는 타입  간의 관계에서 더 일반적인 타입을 슈퍼타입이라고 부르고 더 특수한 타입을 서브타입이라고 부른다. 대한민국 타입은 경기도, 강원도의 슈퍼타입이다. 간단하게 슈퍼타입은 부모 클래스, 서브타입은 자식 클래스로 보면 될 거 같다.

 

  • 슈퍼타입
    • 집합이 다른 집합의 모든 멤버를 포함한다.
    • 타입 정의가 다른 타입보다 좀 더 일반적이다.
  • 서브타입
    • 집합에 포함되는 인스턴스들이 더 큰 집합에 포함된다.
    • 타입 정의가 다른 타입보다 좀 더 구체적이다.

 

3. 서브클래싱과 서브타이핑

  • 서브클래싱 : 코드를 재사용할 목적으로 상속을 사용하는 경우
  • 서브타이핑 : 타입 계층을 구성하기 위해 상속을 사용하는 경우

객체지향 프로그래밍 언어에서 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것이다. 상속을 이용해 타입 계층을 구현한다는 것은 부모 클래스가 슈퍼타입의 역할을, 자식 클래스가 서브타입의 역할을 수행하도록 클래스 사이의 관계를 정의하는 것을 의미한다.

3.1 언제 상속을 사용해야 할까?

반복하지만 코드 재사용 목적이 아닌 타입 계층을 구현하는 것이다. 아래에서 살펴볼 질문에 적합하다면 상속을 사용하는 것을 조언한다.

 

3.1.1 상속 관계가 is-a 관계를 모델링하는가?

간단히 말하면 'A는 B의 종류다'라는 개념을 나타낸다. 예를 들어, 자동차라는 클래스가 있고 스포츠카라는 클래스가 자동차 클래스를 상속받는다고 가정하면 스포츠카는 자동차의 한 종류이다. 즉 스포츠카자동차라는 'is-a' 관계가 성립한다.

class Car {}

class SportsCar extends Car {}

 

 

3.1.2 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?

이 말은 SOLID의 L 리스코프 치환 원칙으로 볼 수 있다. 이후 뒤에 나오니 뒤에서 설명하겠다. 상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 부른다.

class Car {
    void run() {
        System.out.println("부릉부릉");
    }
}

class SportsCar extends Car {
    @Override
    void run() {
        System.out.println("슝슝슝");
    }

    void sportsMode() {
        System.out.println("스포츠 모드 작동");
    }
}

class Main {
    public static void main(String[] args) {
        Car myCar = new SportsCar();

        myCar.run() // 슝슝슝 출력
        myCar.sportsMode() // 컴파일에러!!
        ((SportsCar)myCar).sportsMode(); // 스포츠 모드 작동 출력
    }
}

myCar는 자동차 타입의 참조변수이지만, 실제로는 스포츠카 객체를 참조하고 있다. myCar는 자동차의   모든 속성과 메서드에 접근할 수 있다. 하지만 주의할 점이 있다. 부모 클래스에는 정의되어 있지만 자식 클래스에서 추가로 정의한 속성이나 메서드에는 접근할 수 없기 때문에 자식 클래스 타입으로 캐스팅해야 한다.

 

행동의 연관성이 없다면 is-a 관계를 사용하지 말아야 한다. 펭귄과 새를 예를 들면 새는 날 수 있지만 펭귄은 날지 못한다. 하지만 펭귄은 새다. 여기서 중요한 것은 새와 펭귄의 서로 다른 행동 방식은 둘을 동일한 타입 계층으로 묶어서는 안 된다. 즉 클라이언트의 관점에서 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다.

 

4. 리스코프 치환 원칙

"서브타입은 그것의 기반타입에 대해 대체 가능해야  한다"로 한마디로 정의할 수 있다. 리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기  위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것을 강조한다. 이전 상속이 적합한지 판단하는 두 질문을 떠올려보자. 자식 클래스의 행동이 부모 클래스의 행동과 호환되지 않고 대체 불가능하다면  어휘적으로 is-a라고 말할 수 있다고 해도 그 관계는 is-a 관계가 아니다. is-a는 클라이언트 관점에서 is-a일 때만 참이다. 여기서 중요한 것은 객체의 속성이 아니라 객체의 행동이라는 점이다. 

 

5. 계약에 의한 설계와 서브타이핑

계약에 의한 설계는 사전조건, 사후조건, 클래스 불변식으로 구성된다. 계약의  관점에서 상속이 초래하는 가장 큰 문제는 오버라이딩할 수 있다는 것이다. 아래의 조건을 확인해 보자.

  • 서브타입에 더 강력한 사전조건을 정의할 수 없다.
  • 서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있다.
  • 서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다.
  • 서브타입에 더 약한 사후조건을 정의할 수 없다.

계약에 의한 설계는 클라이언트 관점에서의 대체 가능성을 계약으로 설명할 수 있다는 사실을 잘 보여준다. 추후 계약에 의한 설계는 부록에서 자세히 다뤄보도록 하자.