Ch.13 서브클래싱과 서브타이핑

상속

상속에 대한 해묵은 불신과 오해를 풀기 위해 상속이 두 가지 용도로 사용된다는 사실을 이해해보자.

  1. 타입 계층을 구현하는 것 - 서브타이핑(subtyping)

    • 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고, 자식 클래스는 특수한 개념을 구현한다.

    • 따라서 타입 계층 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)이고 자식 클래스는 부모 클래스의 특수화(specialization)다.

    • 서브타이핑을 인터페이스 상속(interface inheritance)이라고 부르기도 한다.

    • 정확히 말하면 상속이 서브타이핑을 위해 사용될 경우에만 is-a 관계다. LSP를 준수해야만 서브타이핑 관계라고 말할 수 있다. 서브클래싱을 위해 상속을 사용했다면 is-a 관계라고 말할 수 없다.

    • 서브타이핑을 사용할 때 코드도 재사용하고 싶다면 합성을 이용하는 방법이 있겠다.

  2. 코드 재사용 - 서브클래싱(subclassing)

    • 간단한 선언으로 부모 클래스의 코드를 재사용할 수 있다.

    • 하지만 재사용을 위해 상속을 사용한다면 부모 클래스와 자식 클래스가 강하게 결합되어 변경되기 어려운 코드를 얻게 될 확률이 높다.

    • 서브클래싱을 구현 상속(implementation inheritance) 또는 클래스 상속(class inheritance)이라고 부르기도 한다.

이번 장에서는 올바른 타입 계층을 구성하는 원칙을 살펴보자.

타입

객체지향 프로그래밍에서의 타입의 의미를 이해하기 위해 개념 관점과 프로그래밍 언어 관점에서의 타입을 함께 살펴보자.

개념 관점의 타입

개념 관점에서 타입은 우리가 인지하는 사물의 종류를 의미한다. 다시 말해, 우리가 인식하는 객체들에 적용되는 개념이나 아이디어를 가리켜 타입이라고 부른다.

  • e.g., 자바, 루비, 자바스크립트, C를 프로그래밍 언어라고 부를 때 우리는 프로그래밍 언어라는 타입으로 분류하는 것이다.

  • 어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스(instance)라고 부른다.

    • e.g., 자바, 루비, 자바스크립트, C는 언어의 인스턴스인 것이다.

  • 일반적으로 타입의 인스턴스를 객체라고 부른다.

위의 설명으로부터 타입은 다음 세 가지 요소로 구성된다는 것을 알 수 있다.

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

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

프로그래밍 언어에서 타입은 두 가지 목적을 위해 사용된다.

  1. 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다.

    • 모든 객체지향 언어에서는 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 프로그래머의 실수를 막아준다.

  2. 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다.

    • 각 변수에 대해 부여된 타입이 연산자, 오퍼레이션 등이 어떻게 수행될지에 대한 문맥을 정의한다.

정리하자면 타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다.

객체지향 패러다임 관점의 타입

지금까지의 내용을 바탕으로 타입을 다음과 같은 두 가지 관점으로 정의할 수 있다.

  • 개념 관점에서 타입이란 공통의 특징을 공유하는 대상들의 분류다.

  • 프로그래밍 언어 관점에서 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합이다.

이 두 정의를 객체지향 패러다임의 관점에서 조합해보자.

객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것이다.

  • 객체가 수신할 수 있는 메시지의 집합은 '퍼블릭 인터페이스'이다. 즉, 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일하다.

객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류된다.

다시 한 번 강조하지만 객체에게 중요한 것은 속성이 아니라 행동이다.

  • 객체들이 동일한 상태를 갖고 있더라도 퍼블릭 인터페이스가 다르다면 이는 서로 다른 타입으로 분류된다.

  • 반대로 객체가 내부 상태는 다르지만 같은 퍼블릭 인터페이스를 공유한다면 동일한 타입으로 분류된다.

타입 계층

일반화 (Generalization)

다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다.

특수화 (Specialization)

다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다.

서브타입과 슈퍼타입

타입을 구성하는 두 타입 간의 관계에서 좀 더 일반적인 타입을 슈퍼타입(supertype), 더 특수한 타입을 서브타입(subtype)이라고 부른다.

내연과 외연의 관점에서 서브타입과 슈퍼타입을 다음과 같이 정의할 수 있다.

  • 슈퍼타입은 다음과 같은 특징을 갖는 타입을 가리킨다.

    • 집합이 다른 집합의 모든 멤버를 포함한다.

    • 타입 정의가 다른 타입보다 좀 더 일반적이다.

  • 서브타입은 다음과 같은 특징을 갖는 타입을 가리킨다.

    • 집합에 포함되는 인스턴스들이 더 큰 집합에 포함된다.

    • 타입 정의가 다른 타입보다 좀 더 구체적이다.

퍼블릭 인터페이스의 관점에서 서브타입과 슈퍼타입을 다음과 같이 정의할 수 있다.

  • 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것이다.

  • 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것이다.

서브클래싱과 서브타이핑

OOP 언어에서 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것이고, 타입 계층을 구현하는 일반적인 방법은 상속을 이용하는 것이다.

  • 상속을 이용해 타입 계층을 구현한다는 것은 부모 클래스가 슈퍼타입의 역할을,

  • 자식 클래스가 서브타입의 역할을 수행하도록 클래스 사이의 관계를 정의한다는 것을 의미한다.

타입 계층을 구현할 때 지켜야 하는 제약사항을 클래스와 상속의 관점에서 살펴보자.

언제 상속을 사용하는가

마틴 오더스키는 다음과 같은 두 질문을 해보고 모두 '예'라고 말할 수 있을 경우에만 상속을 사용하라고 한다.

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

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

다음 예시를 보자.

  • 펭귄은 새다

  • 새는 날 수 있다

하지만 펭귄은 날 수 없다. 이 예는 어휘적인 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 보여준다. 따라서 슈퍼타입과 서브타입 관계에서는 is-a보다 행동 호환성이 더 중요하다.

행동 호환성을 판단하는 기준은 클라이언트 기준이다. '펭귄이 새다'라는 말에 현혹당해 상속 계층을 유지하려 들지 말고 클라이언트의 기대에 따라 계층을 분리하자. 다음 방법이 있다.

  • 상속 방식: Bird를 상속하는 FlyingBird, Penguin을 구현하자.

  • 인터페이스 방식: 또는, Flyer, Walker 인터페이스를 만들고, Bird는 두 인터페이스를 구현하도록 하고, PenguinWalker를 구현하도록 하자.

  • 합성 방식: 인터페이스 방식에서 PenguinBird를 참조하도록 해서 Bird의 코드를 재사용 가능하도록 하자.

이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)이라고 부른다.

리스코프 치환 원칙(Liskov Substitution Principle, LSP)

서브타입은 그것의 기반 타입에 대해 대체 가능해야한다.

클라이언트가 "차이점을 인식하지 못한 채" 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다.

다음 예시를 보자.

정사각형은 직사각형이다.(Square is-a Rectangle)

그러면 코드로 구현한다면 Square extends Rectangle이 되겠다. 하지만 정사각형은 직사각형이 아닐 수 있다.

  • 직사각형은 너비와 높이가 달라도 된다고 가정하지만, 정사각형은 너비와 높이가 길이가 같아야 한다.

  • 너비와 높이를 독립적으로 변경하는 resize 메서드가 있다고 가정할 때 Rectangle의 자리에 Square를 전달하면 가정은 무너진다.

두 클래스는 LSP를 위반하므로 서브타이핑 관계가 아니라 서브클래싱 관계다.

중요한 것은 "클라이언트 관점에서" 행동이 호환되는지 여부이다.

일반적으로 리스코프 치환 원칙(LSP) 위반은 잠재적인 개방-폐쇄 원칙(OCP) 위반이다.

LSP 더 잘 이해하기: 계약에 의한 설계 (Design By Contract, DBC)

클라이언트와 서버 사이의 협력을 의무(obligation)와 이익(benefit)으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계라고 부른다.

계약에 의한 설계는 다음 세 가지 요소로 구성된다.

  • 사전조건(precondition): 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 조건.

  • 사후조건(postcondition): 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 조건.

  • 클래스 불변식(class invariant): 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 식.

LSP는 어떤 타입이 서브타입이 되기 위해서는 슈퍼타입의 인스턴스와 협력하는 '클라이언트'의 관점에서 서브타입의 인스턴스가 슈퍼타입을 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다. 따라서 계약에 의한 설계를 사용하면 LSP가 강제하는 조건을 계약의 개념을 이용해 좀 더 명확하게 설명할 수 있다.

서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 '계약'을 준수해야 한다.

  • 서브타입에 더 강력한 사전조건을 정의할 수 없다. (e.g., 직사각형과 정사각형)

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

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

  • 서브타입에 더 약한 사후조건을 정의할 수 없다.

Last updated