• 상속의 용도
    1. 타입 계층의 구현
      • 부모 클래스: 자식 클래스의 일반화(generalization)
      • 자식 클래스: 부모 클래스의 특수화(specialization)
    2. 코드 재사용
      • 장점: 코드의 재사용
        • 점진적인 애플리케이션 확장이 가능
      • 단점: 자식-부모 클래스가 강하게 결합되어 변경에 어려운 코드가 될 수 있음
  • 상속 사용의 일차적 목표: 타입 계층의 구현⭕️ (코드 재사용 ❌)
    • 코드 재사용이 중점: 부모-자식 클래스 간의 강한 결합을 초래
      • 설계의 변경과 진화를 방해
    • 타입 계층에 중점: 다형적으로 동작하는 객체들의 관계에 기반
      • 확장 가능하고 유연한 설계 획득 가능
  • 동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서 객체의 행동을 기반으로 타입 계층을 구성해야 한다.

객체지향 프로그래밍과 객체기반 프로그래밍

  • 객체기반 프로그래밍(Object-Based Programming)
    • 상태와 행동을 캡슐화한 객체를 좋바해서 프로그램을 구성하는 방식
    • 객체지향 프로그래밍을 포함하는 개념
    • ex) 초기버전의 비쥬얼 베이직(Visual Basic)
  • 객체지향 프로그래밍(Object-Oriendted Programming)
    • 객체기반의 개념을 가지며, 동시에 상속과 다형성을 지원한다는 점에서 차별화된다.
    • ex) C++, 자바, 루비, C#
  • cf. 객체기반 프로그래밍: 자바스크립트와 같이 클래스가 존재하지 않는 프로토타입 기반 언어(Prototype-Based Language)를 사용한 프로그래밍 방식을 지칭하기 위해 사용되기도 한다.

01. 타입

타입 계층이란 무엇인가? 상속을 이용해 타입 계층을 구현한다는 것은 무엇을 의미하는가?

개념 관점의 타입

  • 개념 관점에서 타입
    • 우리가 인지하는 세상의 사물의 종류
    • 우리가 인식하는 객체들에 적용하는 개념이나 아이디어
    • 사물을 분류하기 위한 틀
      • ex) 자바, 루비, 자바스크립트, C를 프로그래밍 언어라고 부름.
        • 우리는 이들을 프로그래밍 언어라는 타입으로 분류하고 있는 것.
  • 인스턴스(instance)
    • 어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스라고 부른다.
    • 일반적으로 타입의 인스턴스를 객체라고 부른다.
      • ex) 자바, 루비, 자바스크립트, C는 프로그래밍 언어의 인스턴스다.
  • 타입의 구성요소
    1. 심볼(symbol)
      • 타입에 이름을 붙인 것
        • ex) 프로그래밍 언어: 타입의 심볼
    2. 내연(intension)
      • 타입의 정의.
      • 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동
      • 내연의 구성: 타입에 속하는 객체들이 공유하는 속성과 행동의 집합
        • ex) 프로그래밍 언어의 정의: 컴퓨터에 특정한 작업을 지시하기 위한 어휘와 문법적 규칙의 집합
    3. 외연(extension)
      • 타입에 속하는 객체들의 집합
        • ex) 프로그래밍 언어의 외연: 자바, 루비, 자바스크립트, C

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

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

하드웨어는 데이터를 0과 1로 구성된 일련의 비트 조합으로 취급한다. 하지만 비트 자체에는 타입이라는 개념이 존재하지 않는다. 비트에 담긴 데이터를 문자열로 다룰지, 정수로 다룰지는 전적으로 데이터를 사용하는 애플리케이션에 의해 결정된다. 프로그래밍 언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다.

  • 프로그래밍 언어에서 타입의 두 가지 목적
    1. 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다.
      • 모든 객체지향 언어들은 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 프로그래머의 실수를 막아준다.
    2. 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다.
      • 자바에서 a+b라는 연산은, a와 b의 타입이 int라면 두수를 더하고, String이면 두 문자열을 하나로 합친다.
        • 즉, a와 b에 부여된 타입이 + 연산자의 문맥을 정의한다.
  • 타입적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다.

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

  • 개념 관점에서 타입이란 공통의 특성을 공유하는 대상들의 분류다.
  • 프로그래밍 언어 관점에서 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합이다.

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

프로그래밍 언어의 관점

  • 프로그래밍 언어의 관점에서 타입: 호출 가능한 오퍼레이션의 집합
  • 객체지향
    • 오퍼레이션: 객체가 수신 가능한 메시지.
      • 따라서, 객체의 타입: 객체가 수신할 수 있는 메시지를 정의하는 것
    • 메시지 집합: 퍼블릭 인터페이스
      • 객체지향 프로그래밍에서 타입을 정의하는 것: 객체의 퍼블릭 인터페이스를 정의하는 것.

개념 관점

  • 개념 관점에서 타입: 공통의 특성을 가진 객체들을 분류하기 위한 기준
    • 공통의 특성: 동일한 퍼블릭 인터페이스를 가지는 것

📌 객체지향 프로그래밍 관점에서의 타입

  • 객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류한다.
  • 객체지향의 타입에서 부터 다시금 확인할 수 있는 사실들
    • 객체에게 중요한 것은 행동이다.
      • 어떤 객체의 타입을 판단하는 것은 상태가 아니라 행동이다.
    • 항상 객체가 외부에 제공하는 행동에 초점을 맞춰야 한다.
      • 객체의 타입을 결정하는 것은 내부의 속성이 아니라, 외부에 제공되는 행동이다.

02. 타입 계층

타입 사이의 포함관계

  • 타입 = 객체의 집합
    • 다른 타입을 포함할 수 있다.

  • 타입은 더 세분화된 타입의 집합을 부분집합으로 포함할 수 있다.
    • 다른 타입을 포함하는 타입: 더 일반화된 타입
    • 다른 타입에 포함되는 타입: 더 구체화된 타입
  • 다른 타입을 포함하는 타입은 포함되는 타입보다 더 많은 인스턴스를 가진다.
    • 포함하는 타입은 외연 관점에서는 더 크고, 내연 관점에서는 더 일반적이다.

  • 일반화와 특수화 관계를 가진 계층
    • 슈퍼타입(supertype): 더 일반적인 타입
      • 더 위쪽에 표현된다
    • 서브타입(subtype): 더 특수한 타입
      • 더 아래쪽에 표현된다.
  • 내연과 외연 관점에서 일반화와 특수화
    • 내연 관점 (= 객체의 정의)
      • 일반화: 어떤 타입의 정의를 더 보편적이고 추상적으로 만드는 과정
      • 특수화: 어떤 타입의 정의를 좀 더 구체적이고 문맥 종속적으로 만드는 과정
    • 외연 관점 (= 집합)
      • 슈퍼셋(superset): 일반적인 타입의 인스턴스 집합은 특수한 타입의 인스턴스 집합을 포함
      • 서브셋(subset): 특수한 타입의 인스턴스 집합은 일반적인 타입의 인스턴스 집합에 포함
        • 특수한 타입에 속한 인스턴스는 동시에 더 일반적인 타입의 인스턴스 (서브셋은 슈퍼셋의 부분집합이 된다)

일반화와 특수화 by [Martin98].

  • 일반화: 다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의 결과
  • 특수화: 다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과

슈퍼타입과 서브타입 by [Martin98].

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

내연의 관점에서 서브타입의 정의가 슈퍼타입의 정의보다 더 구체적이고,
외연의 관점에서 서브타입에 속하는 객체들의 집합이 슈퍼타입에 속하는 객체들의 집합에 포함된다.


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

  • 객체지향의 타입
    • (일반적으로) 클래스를 이용
  • 타입 계층의 구현
    • (일반적으로) 상속을 이용
  • ∴ 상속을 이용한 타입 구현
    • 부모 클래스가 슈퍼타입의 역할을, 자식 클래스가 서브 타입의 역할을 수행하도록 클래스 사이의 관계를 정의

❓ 의문사항

서브 타입이 되기 위해서는 어떤 조건을 만족해야 할까?
서브 타입의 퍼블릭 인터페이스가 슈퍼 타입의 퍼블릭 인터페이스보다 특수하다는 것은 어떤 의미일까?

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

언제 상속을 사용해야 하는가?

  • 상속의 올바른 용도: 타입 계층의 구현
    • 타입 계층을 위해 올바르게 상속한 기준은 무엇일까?

상속 사용 판단 기준

  1. 상속 관계가 is-a 관계를 모델링 하는가?
    • app을 구성하는 어휘에 대한 관점을 반영
    • 자식 클래스는 부모 클래스다라는 명제가 성립하면, 상속을 사용할 수 있다.
  2. 클라이언트 입장에서 부모 클래스의 자식 타입으로 자식 클래스를 사용해도 무방한가?
    • 클라이언트 입장에서 부모-자식 클래스의 차이점을 몰라야 한다.
      • 이를 자식클래스, 부모 클래스의 행동 호환성이라고 부른다.

상속 사용시 2번 째 질문에 집중해야 한다.
클라이언트 관점에서 두 클래스에게 기대하는 행동이 다르다면(2번 충족❌), 어휘적으로 is-a 관계더라도(1번 충족⭕️) 상속을 사용해서는 안된다.

1. is-a 관계

  • 두 클래스가 어휘적으로 is-a 관계를 모델링할 경우에만 상속을 사용할 수 있다. (by. 마틴 오더스키)
    • 어떤 타입 S가 타입 T의 일종이라면, 타입 T는 타입 S다(S is-a T)라고 말할 수 있어야 한다.
  • 주의사항) is-a 관계는 직관적이거나 명쾌하지 않다. (쉽게 직관을 배신할 수 있다.) (by. 스콧 마이어스 <<이펙티브 C++>)

💻 새와 팽귄 예시

  • 2가지 사실
    1. 팽귄은 새다
    2. 새는 날 수 있다.
  • 사실을 조합한 코드
    • 펭귄은 분명 새지만, 날 수 없는 새다.
    • 그러나 코드에서는 "펭귄은 새고, 따라서 날 수 있다." 라고 주장하고 있다.
public class Bird {
	public void fly() { ... }
    ...
}

public class Penguin extends Bird {
	...
|    

예시의 시사점

  • 타입 계층은 어휘적인 정의가 아니라, 기대되는 행동에 따라 구성되어야 한다.
    • if) 새의 정의에 날 수 있는 행동이 포함 => 펭귄은 새의 서브타입이 아니다.❌
    • else) 새의 정의에 날 수 있는 행동이 불포함 => 펭귄은 새의 서브타입이다.⭕️
      • 이 경우, 어휘적인 관점과 행동 관점이 일치한다.
  • 어떤 두 대상이 언어적으로 is-a로 표현 가능하더라도, 성급하게 상속을 적용하지 마라.
    • 애플리케이션 안에서 두 구현 개념이 어떤 방식으로 사용되고 협력하는지 살펴본 후에 상속의 적용여부를 결정해라.
  • is-a라는 말을 단편적으로 받아들이면, 명확하지 않은 자연어, 즉 사람의 말에 낚일 수 있다. [Meyers05]
    • 슈퍼타입-서브타입 관계는 is-a 보다 행동호환성이 중요하다.

📌 결론

  • 타입 계층의 의미는 행동이라는 문맥에 따라 달라진다.
    • 슈퍼타입의 관계는 is-a 보다 행동호환성이 더 중요하다.

🤔 is-a 관계가 성립되지 않고, 행동호환성만 성립될 때 상속을 사용할 수 있을까? => ❌

행동호환성에 초점을 맞추더라도, is-a 관계는 여전히 상속을 사용하기 위한 기본적인 조건이다.
is-a 관계 없이 행동호환성만으로 상속을 사용하면 아래와 같은 문제점들이 발생할 수 있다.
-> 문제점: (의미론적 불일치, 코드의 가독성 저하, 예상치 못한 동작 위험)

  • 💻 is-a 관계 비성립, 행동호환성 성립 예시
    • 예시에서 Bird와 Airplane은 is-a 관계가 성립하지 않지만, 둘다 fly()를 가지고 있어 행동호환성이 존재한다.
    • 그러나 이런 경우에도 상속보다는 인터페이스나 컴포지션을 사용하는 것이 더 적합할 것이다.
class Bird:
	def fly(self):
    	print("Flying...")
class Airplane:
	cef fly(self):
    	pirnt("Flying with engines...")

2. 행동 호환성

  • 펭귄은 새가 아니다: 타입이 행동과 관련이 있다.
    • 행동에 연관성이 없다면 is-a 관계를 사용하지 말아야 한다.
  • 두 타입 사이에 행동이 호환될 때에만 타입 계층으로 묶을 수 있다.
  • 행동이 호환된다
    • (단순히) 동일한 메서드 구현
    • 클라이언트 관점에서 동일한 행동을 수행 ⭕️
      • 즉, 클라이언트 관점에서 두 타입이 동일하게 행동할 것으로 기대되는 것.

💻 새와 펭귄 예시

개요📝

  • PenguinBird의 서브타입이 아닌 이유:
    • 클라이언트 입장에서 모든 새가 날 수 있다고 가정하기 때문이다.
  • 올바른 타입 계층은, 클라이언트의 기대를 충족시키는 구성을 가져야 한다.
    • 타입 계층을 이해하기 위해서는 그 타입 계층이 사용될 문맥을 이해하는 것이 중요하다.

예시

가정) 클라이언트가 날 수 있는 새만을 원한다.

public void filyBird(Bird bird) {
	// 인자로 전달된 모든 bird는 날 수 있어야 한다.
    bird.fly();
}

상황정리

  • PenguinBird의 자식이다.
    • 컴파일러는 업캐스팅을 허용한다.
      • flyBird 메서드의 인자로 Penguin의 인스턴스가 전달되는 것을 막을 수 있는 방법이 없다.
    • Penguin은 날 수 없고, 클라이언트는 모든 bird가 날 수 있기를 기대하므로, flyBird 메서드로 전달되어서는 안된다.
  • Penguin클라이언트의 기대를 저버리기 때문에 Bird의 서브타입이 아니다.
    • 이 둘을 상속 관계로 연결한 위 설계는 옳지 않다.

상속계층 유지 방법

펭귄은 새다라는 이해때문에, 상속 계층을 유지하려고 노력해볼 수 있다.
상속 관계를 유지하면서 문제를 해결하기 위해서는, 3가지 방법을 시도해볼 수 있다.

  1. Penguinfly를 오버라이딩해서 구현을 비워두기
  • Penguin에게 fly를 전송하더라도 아무일도 발생하지 않는다.
    • 하지만 이 방법은 bird가 날 수 있다는 클라이언트의 기대를 충족하지 못한다.
public class Penguin extends Bird {
	...
    @Override
    public void fly() {}
}    
  1. Penguinfly메서드를 오버라이딩한 후 예외 던지기
  • flyBird에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게 된다.
    • 하지만 이 방법은 flyBird 메서드가 모든 bird에 대해 날 수 있다고 가정하는 사실을 충족하지 못한다.
      • 따라서, 이 방법 역시 클라이언트의 기대와 일치하지 않는다.
public class Penguin extends Bird {
	...
    @Override
    public void fly() {
    	throw new UnsuppotedOperationException();
    }
}
  1. flyBird를 수정해 인자로 전달된 bird의 타입이 Penguin이 아닐 경우에만 fly 메시지를 전송하도록 한다.
  • 날 수 없는 또 다른 새가 상속계층에 추가되는 경우 문제가 될 수 있다.
    • flyBird안에 instanceof를 통한 타입 체크 코드가 추가되므로, 구체적인 클래스에 대한 결합도를 높인다.
public void flyBird(Bird bird) {
	// 인자로 전달된 모든 bird가 Pneguin의 인스턴스가 아닐 경우에만
    // fly() 메서드를 전송한다
    if (!(bird instanceof Penguin)) {
    	bird.fly();
    }
}

클라이언트의 기대에 따라 계층 분리하기

  • 행동 호환성 성립 ❌ 상속 계층 유지 && 클라이언트 기대를 충족 => 💣 난이도 높음
    • 해결 방법) 클라이언트의 기대에 따라 맞게 상속계층 분리
  • flyBird(): 해당 메서드와 관계하는 클라이언트는 날 수 있다고 가정할 것
  • Penguin: 날 수 없는 새와의 협력을 가정
    • 날 수 있는 새와 날 수 없는 새의 상속계층 분리 => 서로 다른 요구를 가진 클라이언트의 요구를 충족한다.

해결 예시1. 상속계층 분리

public class Bird {
	...
}

public class FlyingBird extends Bird {
	public void fly() { ... }
    ...
}

public class Penguin extends Bird {
	...
}

문제해결

  • 변경 후, 모든 클래스들이 행동 호한성을 만족시킨다.
    • Bird의 클라이언트는 자신과 협력하는 객체들이 fly()를 수행할 수 없음을 알고있다.
      • 따라서 PenguinBird를 대체해도 놀라지 않는다.
    • Bird에게 fly메서드를 전송할 수 없으므로, Bird대신 FlyingBird 인스턴스를 전달하더라도 문제가 되지 않는다.

해결 예시2. 클라이언트에 따라 인터페이스 분리

  • 요구사항 변경
    1. Bird가 날 수 있으면서 동시에 걸을 수 있어야 한다.
    2. Penguin은 걸을 수만 있어야 한다.
      • Birdflywalk를, Penguinwalk만 구현하면 된다.
    3. 클라이언트는 fly만 전송할 수도, walk만 전송할 수도 있다.
  1. 클라이언트의 기대에 따른 인터페이스 분리
    • 이 상황에서 PenguinBird의 코드를 재사용해야 한다면?
  2. 합성을 이용한 코드 재사용
    • 클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있다.
      • 대부분 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다.
      • 이때 변경의 영향은 Bird에서 끝난다.
        • Client2FlyerBird에 대해 전혀 알지 못하므로 영향을 받지 않는다.

📗 인터페이스 분리 원칙

  • 인터페이스 분리 원칙 (ISP, Interface Segragation Policy)
    • 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계원칙
  • 비대한 인터페이스
    • 특징
      • 다른 메서드 목록으로 분해 가능하다.
      • 이 그룹들은 각기 다른 클라이언트의 집합을 지원한다. (단일의 요구사항이 여러 클라이언트를 지원)
    • 문제점
      • 클라이언트 간 해로운 결합
        • 한 클라이언트가 변경을 가하면, 나머지 모든 클래스가 영향을 받는다.
        • 따라서, 클라이언트는 자신이 실제로 호출하는 메서드에만 의존해야 한다.
          • 이는 비대한 클래스의 인터페이스를 여러 개의 클라이언트에 특화된 인터페이스로 분리함으로써 성취할 수 있다.
            • = 인터페이스 분리 원칙 (ISP, Interface Segragation Policy)
    • 비대한 인터페이스의 분해
      • 결과적으로 호출하지 않는 메서드에 대한 클라이언트의 의존성을 끊고, 클라이언트끼리 독립적이게 만들 수 있다.

요구사항 기반 설계 (not 현실)

  • 설계가 꼭 현실 세계를 반영할 필요는 없다.
    • 중요한 것은 설계가 반영할 도메인의 요구사항이고, 그 안에서 클라이언트가 객체에게 요구하는 행동이다.
  • 현실을 정확하게 묘사하는 것이 아니라 요구사항을 실용적으로 수용하는 것을 목표로 삼아야 한다.
    • 현재의 요구사항이 날 수 있는 행동에 관심 없다면, 상속계층에서 FlyingBird를 추가하는 것은 설계를 불필요하게 복잡하게 만든다.
  • 자연어에 현혹되지 말고 요구사항 속에서 클라이언트가 기대하는 행동에 집중해라
    • 클래스의 이름 사이에 어떤 연관성이 있다는 사실은 아무 의미가 없다.
    • 두 클래스 사이 행동이 호환되지 않는다면, 올바른 타입 계층이 아니므로 상속을 사용해서는 안된다.

서브클래싱과 서브타이핑

상속의 2가지 목적: 서브클래싱, 서브타이핑

서브클래싱(subclassing)

  • 자식 클래스가 부모 클래스의 코드를 재사용할 목적으로 상속을 사용
  • 자식-부모 클래스 간 행동이 호환되지 않는다.
    • 자식 클래스가 부모 클래스를 대체할 수 없다.
  • = 구현상속(implementation inheritance), 클래스 상속(class inheritance)

서브타이핑(subtyping)

  • 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적으로 상속을 사용
    • 즉, 타입 계층을 구성하기 위해 상속을 사용
  • 자식-부모 클래스 간 행동이 호환된다.
    • 자식 클래스가 부모 클래스를 대체할 수 있다.
    • 이때, 부모 클래스를 슈퍼타입, 자식 클래스를 서브타입이라고 부른다.
  • = 인터페이스 상속(interface inheritance)

by. [GOF 1994].

  • 클래스 상속
    • 이미 정의된 객체의 구현을 바탕으로 한다.
    • 코드 공유의 방법
  • 인터페이스 상속(서브타이핑)
    • 다른 곳에서 사용 가능함을 의미한다.
    • 프로그램에는 슈퍼타입으로 정의하지만, 러타임에 서브타입 객체로 대체 가능하다.
  • 추상 클래스의 상속
    • 코드 재사용을 위한 상속
    • 추상 클래스가 정의하고 있는 인터페이스의 상속 ⭕️

서브타이핑의 조건

  1. 행동 호환성 (behavioral substitution)
    • 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다.
  2. 대체 가능성 (substitutability)
    • 자식 클래스가 부모 클래스가 사용되는 모든 문맥에서 자식 클래스와 동일하게 행동할 수 있어야 한다.
    • 부모-자식 클래스간 행동 호환성이 요구된다.

04. 리스코프 치환 원칙

  • 리스코프 치환 원칙(LSP, Liskov Substitution Principle)
    • 1988년, 바바라 리스코프에 의해 정의됨.
    • 상속 관계의 두 클래스가 서브타이핑 관계를 만족시키기 위한 조건
      • 서브 타입은 그것의 기반 타입에 대해 대체 가능해야 한다.[Martin 2002a]
      • 클라이언트가 차이점을 인식하지 못한 채 파생 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다.[Hunt99]

여기서 요구되는 것은 다음의 치환 속성과 같은 것이다.
S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고, T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때, P의 동작이 변하지 않으면 S는 T의 서브타입이다. [Liskov88]

  • 리스코프 치환 원칙 준수
    • 행동 호환성을 기반으로 부모를 대체한 상속 관계만을 서브 타이핑이라고 부른다.
    • 어기는 예시
      1. Stack -> Vector: 자식이 부모 대체 못 함
      2. Penguin -> Bird: 자식이 부모 대체 못 함

더 미묘한 예시) "직사각형은 사각형이다."

  • 직사각형-사각형 상속관계: 리스코프 치환 원칙을 위반하는 고전적인 사례
    • "직사각형은 사각형이다.(Square is-a Rectagle)"
      • 직사각형은 사각형이 아닐 수 있다.

Rectangle.java

  • 필드 변수: 왼족 상단 모서리 위치(x, y), 너비(width), 높이(height)
public class Rectangle {
	private int x, y, width, height;
    
    public Rectangle(int x, int y, int width, int height) {
    	this.x = x;
        this.y = y;
        this.widht = width;
        this.height = height;
    }
    
    // getter, setter...
    
    public int getArea() {
    	return widht * height;
    }
}

Square.java

  • 정사각형은 너비와 넢이가 동일해야 한다.
    • 제약사항을 강제하도록, width와 height중 하나만 가지며, 항상 동일한 값으로 설정한다.
public class Square extends Rectangle {
	public Square(int x, int y, int size) {
    	super(x, y, size, size);
    }
    
    @Override
    public void setWidth(int width) {
    	super.setWidth(width);
        super.setHeight(width);
    }
    
    @Override
    public void setHeight(int height) {
    	super.setWidth(height);
        super.setHeight(height);
    }

}   

💣 문제상황

  • SqureRectangle의 자식 클래스이므로, Rectangle이 사용되는 모든 곳에서 Rectangle로 업캐스팅 될 수 있다.
    • Rectangle과 협력하는 클라이언트는 사각형 너비와 높이가 다르다고 가정한다.
// 사각형의 너비와 높이를 독립적으로 변경할 수 있다고 가정한다.
public void resize(Rectangle rectangle, int width, int height) {
	rectangle.setWidth(width);
    rectangle.setHeight(height);
    assert rectangle.getWidth() == width && rectangle.getHeight() == height;
}

Square square = new Square(10, 10, 10);
// Rectangle(사각형) 자리에 Square(정사각형) 전달
resize(square, 50, 100); // -> 메서드 실행 실패

📌 결론

  • resize 메서드의 관점에서 Rectangle 대신 Square를 사용할 수 없으므로, SqaureRectangle이 아니다.
    • SqaureRectangle의 구현을 재사용하고 있을 뿐이다.
    • 두 클래스는 리스코프 치환 원칙을 위반하므로, 서브타이핑 관계가 아니라 서브클래싱 관계이다.
  • Rectangle은 is-a라는 말이 얼마나 우리의 직관을 벗어날 수 있는지 보여준다.
    • 중요한 것은 클라이언트 관점에서의 행동 호환 여부다.
      • 행동이 호환될 경우에만 자식이 부모 클래스를 대신할 수 있다.

클라이언트와 대체 가능성

  • 리스코프 치환 원칙
    • 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다.

리스코프 치환원칙을 어기는 예시

1. Square(자식) -> Rectangle(부모)

  • 클라이언트 입장에서 Square(정사각형 추상화)는 Rectangle(사각형 추상화)와 동일하지 않다.
    • Rectangle(부모) 클라이언트: 너비와 높이가 다를 것을 가정한다.
    • Square(자식) 클라이언트: 너비와 높이가 같을 것을 가정한다.
      • 너비와 높이가 다르다는 가정(Rectangle)하게 클라이어트 코드가 개발되었을 때, Rectangle을 Square로 대체할 경우, Rectangle에 대해 세워진 가정을 위반할 확률이 높다.

2. Stack(자식) -> Vector(부모)

  • 상속 관계 시, Stack에 포함되어서는 안되는 부모의 인터페이스가 포함하게 된다.
    • Vector(부모) 클라이언트: 임의의 위치에 요소를 추가하거나 제거할 것을 기대한다.
    • Stack(자식) 클라이언트: 임의의 위치에 요소 조회나 추가를 금지할 것을 기대한다.
      • StackVector와 협력하는 클라이언트는, 각각에 대해 전송 가능한 메시지와 기대하는 행동이 다르다.
        • 이는 StackVector서로 다른 클라이언트와 협력해야 한다는 것을 의미한다.

⭐️ 리스코프 치환 원칙의 함의

  • 클라이언트와 격리한 채로 본 모델은 의미있게 검증하는 것이 불가능하다.
    • 즉, 어떤 모델의 유효성클라이언트 관점에서만 검증 가능하다.
  • 상속관계의 대체 가능성(행동 호환성)을 결정하는 것은, 클라이언트다.

is-a 관계 다시 살펴보기

  • 상속 적합성 판단 질문 2가지 [by. 마틴 오더스키]
    1. 어휘적으로 is-a 관계를 모델링한 것인가?
    2. 클라이언트 입장에서 부모 클래스 대신 자식 클래스를 사용할 수 있는가?
      • 두 질문은 동시에 성립해야 한다.
        • is-a 관계는 클라이언트 관점에서 is-a 관계일 때만 참이다.
  • is-a 관계로 표현된 문장을 볼 때마다 문장앞에 (클라이언트 입장에서) 라는 말이 빠져있다고 생각해라.
    • 예시) (클라이언트 입장에서) 정사각형은 직사각형이다., (클라이언트 입장에서) 펭귄은 새다
  • is-a 관계
    • 객체지향에서 중요한것은 객체의 속성이 아니라 객체의 행동임을 강조
    • 행동이 호환되는 타입에 어떤 이름을 붙여야 되는지를 설명하는 가이드(정도로 생각해라)
      • (전제조건) 슈퍼타입-서브타입이 클라이언트 입장에서 행동이 호환되는 경우
        • 두 타입을 is-a 관계로 연결해 문장을 만들어도 어색하지 않은 단어로 타입을 결정

📌 결론

  • 이름이 아니라 행동이 먼저다.
  • 상속이 서브타이핑을 위해 사용될 경우에만 is-a 관계다.

리스코프 치환 원칙은 유연한 설계의 기반이다

  • 리스코프 치환 원칙
    • 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현하기 위한 가이드라인을 제공한다.
      • 새로운 자식 클래스 추가 시, 클라이언트 입장에서 동일하게 작동하기만 하면 클라이언트 수정 없이 상속계층 확장이 가능하다.
      • 클라이언트 입장에서, 퍼블릭 인터페이스의 행동 방식이 변경되지 않는다면, 클라이언트의 코드를 변경하지 않고 새로운 자식 클래스와 협력할 수 있게 된다.

💻 DiscountPolicy 예시로 알아보는 리스코프 치환 원칙

리스코프 치환 원칙을 따르는 설계는 유연할 뿐만 아니라 확장성이 높다.

DiscountPolicy 상속 계층에 OverlappedDiscountPolicy 추가 (without. 클라이언트 코드 수정)

public class OverlappedDiscountPolicy extends DiscountPolicy {
	private List<DiscountPolicy> discountPolicies = new ArrayList<>();
    
    public OverlappedDiscountPolicy(discountPolicy ... discountPolicies) {
    	this.discountPolicies = Arrays.asList(discountPolicies);
    }
    
    @Override
    protected Money getDiscountAmont(Screening screening) {
    	Money result = Money.ZERO;
        for(DiscountPolicy each : disocuntPolicies) {
        	result = result.plus(each.calculateDiscountAmount(screening));
        }
        return result;
    }
}   

위의 설계는 의존성 역전 원칙, 계방-폐쇄 원칙, 리스코프 치환 원칙이 한데 어우러져 설계를 확장 가능하게 만들었다.

  1. 의존성 역전 원칙(DIP: Dependency Inversion Principle) 준수
    • 상위 수준의 모듈(Movie)과 하위 수준의 모듈(OverlappedDisocuntPolicy) 모두 추상 클래스인 DiscountPolicy에 의존한다.
  2. 리스코프 치환 원칙(LSP: Liskov Substitution Principle) 준수
    • DiscountPolicy와 협력하는 Movie의 입장에서 DiscountPolicy(부모) 대신 OverlappedDiscountPolicy(자식)와 협력해도 아무런 문제가 없다.
      • 자식이 클라이언트에 대한 영향 없이 부모를 대체할 수 있다.
  3. 계방-폐쇄 원칙(OCP: Open-Closed Principle) 준수
    • 새로운 기능인 중복 할인 정책OverlappedDisocuntPolicy를 추가해도, Movie에 영향이 끼쳐지지 않아 수정이 필요없다.
      • Movie(클라이언트)에게 영향을 미치지 않을 수 있었던 것은, 리스코프 치환 원칙 덕분이다.
    • 즉, (기능)확장에 열려있고, 변경에 닫혀있다.
  • 리스코프 치환 원칙은 OCP를 지원한다.
    • 자식 클래스가 클라이언트 관점에서 부모를 대체할 수 있따면, 기능확장을 위해 자식 클래스가 추가되어도 코드를 수정할 필요가 없다.
    • 따라서, 리스코프 치환 원칙 위반은 잠재적인 계방-폐쇄 원칙 위반이다.

타입 계층과 리스코프 치환 원칙

타입 계층을 구현하는 방법은 클래스 상속 외에도 다양한 방법이 있다.
그러나, 반드시 리스코프 치환 원칙을 준수해야만 서브타이핑 관계라고 말할 수 있다.
핵심은 (구현 방법과 무관하게) 클라이언트 관점에서 슈퍼타입에 대해 기대하는 모든 것이 서브타입에게도 적용되어야 한다는 것이다.

클라이언트의 관점에서 서로 다른 구성요소를 동일하게 다뤄야 한다면 서브타이핑 관계의 제약을 고려해서 리스코프 원칙을 준수해야 한다.
클라이언트 관점에서 자식 클래스가 부모 클래스를 대체할 수 있다는 것은 무엇을 의미하는가?
클라이언트 관점에서 자식 클래스가 부모 클래스의 행동을 보존한다는 것은 무엇을 의미하는가?


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

  • 계약에 의한 설계(DBC, Design By Contract)
    • 클라이언트와 서버 사이의 협력을 의무(obligation)와 이익(benefit)으로 구성된 계약의 관점에서 표현하는 것
    • 구성요소
      • 사전조건(precondition): 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 조건
      • 사후조건(postcondition): 메서드 실행 후 서버가 클라이언트에게 보장해야 하는 조건
      • 클래스 불변식(class invariant): 메시드 실행 전 후 인스턴스가 만족시켜야 하는 조건

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

  • 계약에 의한 설계(Design By Contract)를 따르면, 클라이언트와 슈퍼타입 인스턴스 사이에는 마치 '약속'과 같은 계약이 맺어져 있다.
    • 클라이언트와 슈퍼타입은 이 계약을 준수할 때만 정상적으로 협력할 수 있다.
  • 리스코프 치환 원칙(Liskov Substitution Principle)에 따르면, 클라이언트의 입장에서 서브타입이 정말 슈퍼타입의 '한 종류'여야 하며, 완전히 대체 가능해야 한다.
  • 이 두 원칙을 조합해보면, 서브타입(자식 클래스)이 슈퍼 타입(부모 클래스)처럼 제대로 작동하기 위한 유일한 방법은,
    • 클라이언트가 슈퍼타입과 맺은 계약을 서브타입이 준수하는 것 뿐이다.

자식 클래스는 부모 클래스의 계약을 반드시 존중하고 따라야 한다.
그렇지 않으면 프로그램은 예상치 못한 방식으로 동작하게 된다.

예시) 💻 DiscountPolicy - Movie

Movie.java

public class Movie {
	...
    public Money calculateMovieFee(Screening screening) {	
    	return fee.minus(discountPolicy.calcualteDiscountAmount(screening)); // ** 
    }
}    

DiscountPolicy.java

public abstract class DiscountPolciy {
	public Money calculateDiscountAmount(Screening screening) { // **
    	for(DiscountCondition : conditions) {
        	if (each.isSatisfiedBy(Screening)) {
            	return getDiscountAmount(screening);
            }
		}
        
        return screening.getMovieFee();
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

이 예시에는 암묵적으로 다음과 같은 조건들이 존재한다.

사전조건

  1. Screeningnull이 아니다.
  2. 영화 시간이 아직 지나지 않았다.

DiscountPolicycalculateDiscountAmount는 메서드 인자로 전달된 Screeningnull 여부를 체크하지 않는다.
screeningnull이 전달되면 screening.getMovie()가 실행될 때 NullPointException이 발생할 것이다.

따라서, 단정문(assertion)을 이용해 사전조건을 다음과 같이 표현할 수 잇다.

assert screening != null && screening.getStartTime().isAfter(LocalDateTime.now());

사후조건

  1. 반환값은 항상 null이 아니어야 한다.
  2. 반환되는 값은 청구되는 요금이므로, 최소한 0보다는 커야 한다.
assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);

✒️ 계약을 준수한 예시

DiscountPolicy.java

public abstract class DisocuntPolicy {
	public Money calculateDiscountAmount(Screening screening) {
    	checkPrecondition(screening); // 사전조건
        
        Money amount = Money.ZERO;
        for (DiscountCondition each : conditions) {
        	if (each.isSatisfiedBy(screening) {
            	amount = getDiscountAmount(screening);
                checkPostcondition(amount); // 사후조건
                return amount;
            }
        }
        
        amount = screening.getMovieFee();
        checkPostcondition(amount);
        return amount;
    }
    
    protected void checkPreconditon(Screening screening) {
    	assert screening != null && screening.getStartTime().isAfter(LocalDateTime.now());
    }
    
    protected void checkPostcondition(Money amount) {
    	assert amount != null && amount.isGreaterThanOrEqaul(Money.ZERO);
    }
    
    abstract protected Money getDiscountAmount(Screening screening);
}

Movie.java

public class Movie {
	public Money calculateMovieFee(Screening screening) {
    	if (screening == null || screening.getStartTime().isBefore(LocalDateTome.now())) {
        	throw new InvalidScreeningException();
        }
        
        return fee.minus(discountPolicy.calculateDiscountAMount(screening));
	}
}    

서브 타입과 계약

  • 계약 측면에서 상속의 문제점:
    • 자식클래스의 부모 클래스 메서드 오버라이딩

사전조건

  • 💻 BrokenDiscountPolicy
    • DiscountPolicy 상속 -> calculateDiscountAmount 메서드 오버라이딩
      • 새로운 사전 조건(checkStrongPrecondition) 추가
        • 종료 시간이 자정이 넘는 영화를 예매할 수 없다.
public class BrokenDiscountPolicy extends DiscountPolicy {
	pubic BrokenDiscountPolicy(Discountcondition ... conditions) {
    	super(conditions);
    }
    
    @Override
    public Money calculateDisocuntAmount(Screening screening) {
    	checkPrecondition(screening);			// 기존의 사전조건
        checkStrongPrecondition(screening);		// 더 강력한 사전조건
        
        Money amount = screening.getMovieFee();
        checkPostcondition(amount); // 기존의 사후조건
        return amount;
   }
   
   private void checkStrongerPrecondition(Screening screening) {
   		// => 더 강화된 사전조건
        // 종료시간이 자정을 넘는 영화는 예매할 수 없다.
   		assert screening.getEndTime().toLocalTime().isBefore(LocalTime.MIDNIGHT);
   }
   
   @Override
   protected Money getDiscountAmount(Screening screening) {
   		return Money.ZERO;
   }
}

서브타입에 더 강력한 사전조건을 정의할 수 없다.

  1. MovieBrokenDiscountPolicyDiscountPolicy로 간주한다.
    • 문제없이 업캐스팅이 가능하다.
  2. MovieDiscountPolicy의 사전조건만 알고 있다.
    • MovieDiscountPolicy가 정의하는 사전조건을 만족시키기 위한 노력"만" 한다.
    • 따라서, BrokenDiscountPolicy가 요구하는 협력을 위한 노력(자정 이후의 영화 불허)은 하지 않는다.
      • 결과적으로 협력은 실패한다. ❌

현재 설계에서, 클라이언트 관점에서 BrokenDiscountPolicyDiscountPolicy를 대체하는 경우 협력이 실패한다.
따라서 BrokenDiscountPolicyDiscountPolicy의 서브 타입이 아니다.

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

public class BrokenDiscountPolicy extends DiscountPolicy {
	...
    @Override
    public Money calculateDiscountAmount(Screening screening) {
    	// checkPrecondition(screening); 	// 기존의 사전조건 제거
        Money amount = screening.getMovieFee();
        checkPostcondition(amount);			// 기존의 사후조건
        retrun amount;
    }
    ...
}

기존의 사전조건을 제거했으나, MovieDiscountPolicy와의 협력을 준수하기 위한 노력을 유지하고 있다.
기존의 조건을 체크하지 않는 것이 협력에 영향을 미치지 않는다.

📌 결론

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

사후조건

  • 💻 BrokenDiscountPolicy
    • DiscountPolicy 상속 -> calculateDiscountAmount 메서드 오버라이딩
      • 새로운 사후 조건(checkStrongerPostcondition) 추가
        • amount가 최소 1000원 이상은 되어야 한다.
public class BrokenDiscountPolicy extends DiscountPolicy {
	...
    @Override
    public Money calculateDiscountAmount(Screening screening) {
    	checkPrecondition(screening); 		// 기존의 사전조건
        
        Money amount = screening.getMoiveFee();
        
        checkPostcondition(amount);			// 기존의 사후조건
        checkStrongPostcondition(amount);	// 더 강력한 사후조건
        return amount;
    }
    
    private void checkStrongerPostcondition(Money money) {
    	assert amount.isGreaterThanOrEqaul(Money.wons(1000));
    }
}

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

  1. MovieDiscountPolicy의 사후조건만 알고 있다.
    • Movie는 협력의 정상 작동 여부를 DiscountPolicy가 사후조건"만으로" 판단한다.
    • 따라서, BrokenDiscountPolicy의 더 강력한 사후조건(1000원 이상의 금액을 반환)은 상위 클래스 간의 계약 조건을 위반하지 않는다.

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

public class BrokenDiscountPolicy extends DiscountPolicy {
	...
    @Override
    public Money calculateDiscountAmount(Screening screening) {
    	checkPrecondition(screening);		// 기존의 사전조건
        
        Money amount = screening.getMovieFee();
        
        // checkPostcondition(amount);		// 기존의 사후조건 제거
        checkWeakerPostcondition(amount);	// 더 약한 사후조건
        return amount;
    }
    
    private void checkPostcondition(Money amount) {
    	assert amount != null;
    }
}
  • 변경된 코드에서는 요금 계산 결과가 마이너스라도 그대로 반환한다.
    • Movie는 협력하는 객체가 DiscountPolicy라고 믿기 때문에, 반환된 금액이 0원보다는 크다고 믿고, 예매 요금으로 사용하게 된다.

📌 정리

  • 계약에 의한 설계: 리스코프 치환 원칙 설명 가능
    • 사전조건
      • 슈퍼타입(더 강력) > 서버타입(더 약함) (적합) ✅
      • 슈퍼타입(더 약함) < 서브타입(더 강력) (서브타입 조건이 깨진다) ❌
    • 사후조건
      • 슈퍼타입(더 약함) < 서브타입(더 강력) (적합) ✅
      • 슈퍼타입(더 강력) > 서브타입(더 약함) (서브타입 조건이 깨진다) ❌

  • 계약에 의한 설계
    • 클라이언트 관점에서 대체 가능성을 계약으로 설명할 수 있다.
    • 서브타이핑을 위한 상속 사용 시, 부모 클래스와 클라이언트 간의 계약을 고려해야 한다.

Reference

  • 오브젝트 | 조영호
profile
Good Luck!

0개의 댓글

Powered by GraphCDN, the GraphQL CDN