오브젝트: 코드로 이해하는 객체지향 설계 13장을 읽으며
오브젝트: 코드로 이해하는 객체지향 설계 13장을 읽으며
느낀점
상속과 관련된 설계에서 부모 클래스와 자식 클래스 간의 계약이 어떻게 정의되고 지켜져야 하는지 알게 되었음. 부모 클래스에서 정의된 규칙과 제약을 자식 클래스가 확장하거나 변경할 때, 이를 위반하지 않고 일관성을 유지하는 것이 객체지향 설계의 핵심
상속이 단순히 코드 재사용을 위한 도구가 아니라, 명확하고 견고한 계약을 기반으로 하는 책임의 연속성을 의미.
계약에 의한 설계에서 계약 위반이 발생했을 때 시스템이 얼마나 취약해질 수 있는지에 설득력 있었고, 상속보다는 합성(composition)을 활용해 계약을 명시적으로 정의하고 관리하는 것이 때로는 더 효과적일 것 같음.
서브클래싱과 서브타이핑
상속의 첫번째 용도는 타입 계층을 구현하는 것
- 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)
- 타입 계층의 관점에서 자식 클래스는 부모 클래스의 특수화(specialization)
상속의 목표는 타입 계층을 구현하는 것으로 부모와 자식을 강하게 결합시킴
- 타입 계층을 목표로 상속을 사용할 경우 다형적으로 동작하는 객체들의 관계에 기반에 확장 가능하고 유연한 설계를 얻을 수 있음.
- 동일한 메시지에 대해 서로 다르게 행동할 수 있음.
객체지향 프로그래밍과 객체기반 프로그래밍
객체기반 프로그래밍(Object-Based Programming)
- 상태와 행동을 캡슐화한 객체를 조합해서 프로그램을 구성하는 방식
객체지향 프로그래밍(Object-Oriented Programming)
- 객체기반 프로그래밍의 한 종류
- 상속과 다형성을 지원한다는 점에서 객체기반 프로그래밍과 차별점
프로토타입 프로그래밍(Prototype-Oriented Programming)
- 자바스크립트와 같이 클래스가 존재하지 않는 프로그래밍 방식
타입
개념 관점의 타입
- 어떤 대상의 타입으로 분류될 때 그 대상을 타입의 인스턴스라고함
- 일반적으로 타입의 인스턴스를 객체라고 부름
- 타입은 세가지 요소로 구성
- 심볼(symbol): 타입에 이름을 붙인 것.
- 내연(intension): 타입의 정의로써 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동을 가리킴
- 외연(extension): 타입에 속하는 객체들의 집합
프로그래밍 언어 관점의 타입
- 타입에 수행할 수 있는 유효한 오퍼레이션의 집합을 정의
- 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한
- 예시: 클래스 객체가 '+'가 언어에 따라서 제한
- 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공
- 이처럼 객체를 생성하는 방법에 대한 문맥을 결정하는 것
- 예시: Int타입의 '+'라면 숫자의 덧셈, String 타입이라면 글자를 하나의 문자열로 합칩
객체지향 패러다임 관점의 타입
객체가 수신할 수 있는 메시지의 집합을 퍼블릭 인터페이스라고 부름
- 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일
타입 사이의 포함관계
- 타입 계층을 구성하는 두 타입간의 관계에서 더 일반적인 타입을 슈퍼타입이라 부르고 특수한 타입을 서브타입이라고 부름
객체지향 프로그래밍과 타입 계층
객체지향 프로그래밍 관점에서 타읩 정의가 더 일반적이고 더 특수하다는 사실이 어던 의미를 가지는지 생각해보기
- 일반적으로 퍼블릭 인터페이스를 가지는 객체들은 더 특수한 퍼블릭 인터페이슬를 가지는 객체들의 슈퍼 타입
- 서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주될 수 있음.
언제 상속을 사용해야 하는가?
반복해서 강조하지만 상속의 올바른 용도는 타입 계층을 구현하는 것
- 클라이언트 입장에서 부모 클래스의 타입으로 자식클래스를 사용해도 무방.
- 즉, 상속 계층을 사용하는 클라이언트 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 함.
- 클라이언트 관점에서 두 클래스에 대해 기대하는 행동이 다르다면, 그것이 어휘적으로 is-a 관계로 표현할 수 있어도 상속을 사용해서는 안됨.
행동 호환성
즉, 상속 계층을 사용하는 클라이언트 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 함.
- 클라이언트 입장에서 부모 클래스의 타입으로 자식클래스를 사용해도 무방.
클라이언트의 기대에 따라 계층 분리하기
지금까지 살펴본 것처럼 행동 호환성을 만족시키지 않는 상속 계층을 그대로 유지한 채 클라이언트 기대를 충족시킬 수 있는 방법을 찾기가 쉽지 않음
- 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 부름
protocol Flyer {
func fly()
}
protocol Walker {
func walk()
}
class Bird: Flyer, Walker {
func fly() {}
func walk() {}
}
class Penguin: Walker {
func walk() {}
}
서브클래싱과 서브타이핑
상속은 기본적으로 코드 재사용과 타입 계층화의 목적을 가지고 있음. 사람들은 사용을 사용하는 두 가지 목적에 특별한 이름을 붙였음
- 서브클래싱: 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 가리킴. 자식과 부모의 행동이 호환되지 않기 떄문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없음. 구현상속, 클래스 상속이라고 부름
- 서브타이핑: 타입 계층을 구성하기 위해 상속을 사용하는 경우를 가리킴. 부모 클래스는 자식 클래스의 슈퍼타입이 되고 자식 클래스는 부모 클래스의 서브 타입이 됨.
슈퍼타입과 서브타입 사이의 관계에서 가장 중요한 것은 퍼블릭 인터페이스임
- 서브클래싱은 클래스의 내부 구현 자체를 상속받는 것에 초점
- 서브타이핑은 서브타입이 슈퍼타입이 하는 모든 행동을 수행할 수 있어야 하기에 행동 호환성을 만족해야 함.
- 자식 클래스가 부모클래스를 대체하기 위해서 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제 없이 동작할 것이라는 것을 보장해야 함.
- 행동 호환성은 대체 가능성을 포함
리스코프 치환 원칙
올바른 상속 관계의 특징을 정의하기 위해 리스코프 치환 원칙을 발표
- 한마디로 정의하면, 서브타입은 그것의 기반 타입에 대해 대체 가능해야 함.
- 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야한다는 것.
is-a 관계 다시 살펴보기
행동을 고려하지 않은 두 타입의 이름이 단순히 is-a
- 한마디로 정의하면, 서브타입은 그것의 기반 타입에 대해 대체 가능해야 함.
- 상속이 서브타이핑을 위해 사용될 경우에만 is-a 관계
- 서브클래싱을 구현하기 위해 상속을 사용했다면 is-a 관계라고 말할 수 없음.
리스코프 치환 원칙은 유연한 설계의 기반
어떤 자식클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현할 수 있는 가이드라인 제공
- 클라이언트 입장에서 동일하게 행동하기만 한다면 클라이언트를 수정하지 않고도 상속 계층을 확장할 수 있음.
계약에 의한 설계
클라이언트와 서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계(Design By Contract, DBC)라고 하며, 아래 3가지 요소로 구성
- 클라이언트가 만족시켜야 메서드를 실행하기 위해 만족시켜야 하는 사전조건(precondition)
- 메서드가 실행된 후에 서버가 클라이언에게 보장해야 하는 사후조건(postcondition)
- 메서드 실행 전 후 인스턴스가 만족시켜야하는 클래스 불변식(class invariant)
서브타입과 계약
계약의 관점에서 상속이 초래하는 가장 큰 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩 할 수 있다는 것
- 서브타입에 더 강력한 사전조건을 정의할 수 없음
- 서브타입에 슈퍼타입과 같거나 약한 사전조건을 정의할 수 없음
- 서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있음
- 서브타입에 더 약한 사후 조건을 정의할 수 없음
부모 클래스가 클라이언트와 맺고 있는 계약에 관해 깊이 있게 고민할 필요가 있음.
- 상속은 타입 계층을 구현할 수 있는 전통적인 방법이지만 유일한 방법은 아님
// ✅ 기존 정의된 코드
func calculateDiscount() {
// 사전조건
checkPrecondition()
getFee()
// 사후 조건
checkPostcondition()
}
// ✅ 서브타입에 더 강력한 사전조건을 정의할 수 없음
func calculateDiscount() {
checkPrecondition() // 기존의 사전 조건
checkStrongPreContdition() // 더 강력한 사전 조건
getFee()
checkPostcondition()
}
// ✅ 서브타입에 슈퍼타입과 같거나 약한 사전조건을 정의할 수 없음
func calculateDiscount() {
// checkPrecondition() // 기존의 사전 조건을 제거
getFee()
checkPostcondition()
}
// ✅ 서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있음
func calculateDiscount() {
checkPrecondition()
getFee()
checkPostcondition()
checkStrongPostcondition() // 더 강력한 사후조건
}
// ✅ 서브타입에 더 약한 사후 조건을 정의할 수 없음
func calculateDiscount() {
checkPrecondition()
getFee()
checkPostcondition()
checkWeakerPostCondition() // 더 약한 사후조건
}