객체지향 프로그래밍과 객체기반 프로그래밍
- 객체기반 프로그래밍(Object-Based Programming)
- 상태와 행동을 캡슐화한 객체를 좋바해서 프로그램을 구성하는 방식
- 객체지향 프로그래밍을 포함하는 개념
- ex) 초기버전의 비쥬얼 베이직(Visual Basic)
- 객체지향 프로그래밍(Object-Oriendted Programming)
- 객체기반의 개념을 가지며, 동시에 상속과 다형성을 지원한다는 점에서 차별화된다.
- ex) C++, 자바, 루비, C#
- cf. 객체기반 프로그래밍: 자바스크립트와 같이 클래스가 존재하지 않는 프로토타입 기반 언어(Prototype-Based Language)를 사용한 프로그래밍 방식을 지칭하기 위해 사용되기도 한다.
타입 계층이란 무엇인가? 상속을 이용해 타입 계층을 구현한다는 것은 무엇을 의미하는가?
프로그래밍 언어
: 타입의 심볼프로그래밍 언어
의 정의: 컴퓨터에 특정한 작업을 지시하기 위한 어휘와 문법적 규칙의 집합프로그래밍 언어
의 외연: 자바, 루비, 자바스크립트, C하드웨어는 데이터를 0과 1로 구성된 일련의 비트 조합으로 취급한다. 하지만 비트 자체에는 타입이라는 개념이 존재하지 않는다. 비트에 담긴 데이터를 문자열로 다룰지, 정수로 다룰지는 전적으로 데이터를 사용하는 애플리케이션에 의해 결정된다. 프로그래밍 언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다.
a+b
라는 연산은, a와 b의 타입이 int
라면 두수를 더하고, String
이면 두 문자열을 하나로 합친다.+
연산자의 문맥을 정의한다.두 정의를 객체지향 패러다임의 관점에서 조합해보자
일반화와 특수화 by [Martin98].
- 일반화: 다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의 결과
- 특수화: 다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과
슈퍼타입과 서브타입 by [Martin98].
- 슈퍼타입
- 집합이 다른 집합의 모든 멤버를 포함한다
- 타입 정의가 다른 타입보다 좀 더 일반적이다
- 서브타입
- 집합에 포함되는 인스턴스들이 더 큰 집합에 포함된다.
- 타입 정의가 다른 타입보다 좀 더 구체적이다.
내연의 관점에서 서브타입의 정의가 슈퍼타입의 정의보다 더 구체적이고,
외연의 관점에서 서브타입에 속하는 객체들의 집합이 슈퍼타입에 속하는 객체들의 집합에 포함된다.
서브 타입이 되기 위해서는 어떤 조건을 만족해야 할까?
서브 타입의 퍼블릭 인터페이스가 슈퍼 타입의 퍼블릭 인터페이스보다 특수하다는 것은 어떤 의미일까?
타입 계층 구현 시 지켜야 하는 제약사항을 클래스와 상속의 관점에서 살펴보자.
자식 클래스는 부모 클래스다
라는 명제가 성립하면, 상속을 사용할 수 있다.상속 사용시 2번 째 질문에 집중해야 한다.
클라이언트 관점에서 두 클래스에게 기대하는 행동이 다르다면(2번 충족❌), 어휘적으로 is-a 관계더라도(1번 충족⭕️) 상속을 사용해서는 안된다.
타입 T는 타입 S다(S is-a T)
라고 말할 수 있어야 한다.public class Bird {
public void fly() { ... }
...
}
public class Penguin extends Bird {
...
|
🤔 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...")
Penguin
이 Bird
의 서브타입이 아닌 이유:가정) 클라이언트가 날 수 있는 새만을 원한다.
public void filyBird(Bird bird) {
// 인자로 전달된 모든 bird는 날 수 있어야 한다.
bird.fly();
}
Penguin
은 Bird
의 자식이다.flyBird
메서드의 인자로 Penguin
의 인스턴스가 전달되는 것을 막을 수 있는 방법이 없다.Penguin
은 날 수 없고, 클라이언트는 모든 bird
가 날 수 있기를 기대하므로, flyBird
메서드로 전달되어서는 안된다.Penguin
은 클라이언트의 기대를 저버리기 때문에 Bird
의 서브타입이 아니다.펭귄은 새다
라는 이해때문에, 상속 계층을 유지하려고 노력해볼 수 있다.
상속 관계를 유지하면서 문제를 해결하기 위해서는, 3가지 방법을 시도해볼 수 있다.
Penguin
의 fly
를 오버라이딩해서 구현을 비워두기Penguin
에게 fly
를 전송하더라도 아무일도 발생하지 않는다.bird
가 날 수 있다는 클라이언트의 기대를 충족하지 못한다.public class Penguin extends Bird {
...
@Override
public void fly() {}
}
Penguin
의 fly
메서드를 오버라이딩한 후 예외 던지기flyBird
에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게 된다.flyBird
메서드가 모든 bird
에 대해 날 수 있다고 가정하는 사실을 충족하지 못한다.public class Penguin extends Bird {
...
@Override
public void fly() {
throw new UnsuppotedOperationException();
}
}
flyBird
를 수정해 인자로 전달된 bird
의 타입이 Penguin
이 아닐 경우에만 fly
메시지를 전송하도록 한다.flyBird
안에 instanceof
를 통한 타입 체크 코드가 추가되므로, 구체적인 클래스에 대한 결합도를 높인다.public void flyBird(Bird bird) {
// 인자로 전달된 모든 bird가 Pneguin의 인스턴스가 아닐 경우에만
// fly() 메서드를 전송한다
if (!(bird instanceof Penguin)) {
bird.fly();
}
}
flyBird()
: 해당 메서드와 관계하는 클라이언트는 날 수 있다고 가정할 것Penguin
: 날 수 없는 새와의 협력을 가정public class Bird {
...
}
public class FlyingBird extends Bird {
public void fly() { ... }
...
}
public class Penguin extends Bird {
...
}
Bird
의 클라이언트는 자신과 협력하는 객체들이 fly()
를 수행할 수 없음을 알고있다.Penguin
이 Bird
를 대체해도 놀라지 않는다.Bird
에게 fly
메서드를 전송할 수 없으므로, Bird
대신 FlyingBird
인스턴스를 전달하더라도 문제가 되지 않는다.Bird
가 날 수 있으면서 동시에 걸을 수 있어야 한다.Penguin
은 걸을 수만 있어야 한다.Bird
는 fly
와 walk
를, Penguin
은 walk
만 구현하면 된다.fly
만 전송할 수도, walk
만 전송할 수도 있다.Penguin
이 Bird
의 코드를 재사용해야 한다면?Bird
에서 끝난다. Client2
는 Flyer
나 Bird
에 대해 전혀 알지 못하므로 영향을 받지 않는다.FlyingBird
를 추가하는 것은 설계를 불필요하게 복잡하게 만든다.상속의 2가지 목적: 서브클래싱, 서브타이핑
by. [GOF 1994].
- 클래스 상속
- 이미 정의된 객체의 구현을 바탕으로 한다.
- 코드 공유의 방법
- 인터페이스 상속(서브타이핑)
- 다른 곳에서 사용 가능함을 의미한다.
- 프로그램에는 슈퍼타입으로 정의하지만, 러타임에 서브타입 객체로 대체 가능하다.
- 추상 클래스의 상속
코드 재사용을 위한 상속❌- 추상 클래스가 정의하고 있는 인터페이스의 상속 ⭕️
여기서 요구되는 것은 다음의 치환 속성과 같은 것이다.
S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고, T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때, P의 동작이 변하지 않으면 S는 T의 서브타입이다. [Liskov88]
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;
}
}
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);
}
}
Squre
는 Rectangle
의 자식 클래스이므로, 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
를 사용할 수 없으므로, Sqaure
는 Rectangle
이 아니다.Sqaure
는 Rectangle
의 구현을 재사용하고 있을 뿐이다.Rectangle
은 is-a라는 말이 얼마나 우리의 직관을 벗어날 수 있는지 보여준다.Square
(정사각형 추상화)는 Rectangle
(사각형 추상화)와 동일하지 않다.Rectangle
(부모) 클라이언트: 너비와 높이가 다를 것을 가정한다.Square
(자식) 클라이언트: 너비와 높이가 같을 것을 가정한다.Stack
에 포함되어서는 안되는 부모의 인터페이스가 포함하게 된다.Vector
(부모) 클라이언트: 임의의 위치에 요소를 추가하거나 제거할 것을 기대한다.Stack
(자식) 클라이언트: 임의의 위치에 요소 조회나 추가를 금지할 것을 기대한다.Stack
과 Vector
와 협력하는 클라이언트는, 각각에 대해 전송 가능한 메시지와 기대하는 행동이 다르다.Stack
과 Vector
가 서로 다른 클라이언트와 협력해야 한다는 것을 의미한다.is-a
관계는 클라이언트 관점에서 is-a
관계일 때만 참이다.(클라이언트 입장에서) 정사각형은 직사각형이다.
, (클라이언트 입장에서) 펭귄은 새다
리스코프 치환 원칙을 따르는 설계는 유연할 뿐만 아니라 확장성이 높다.
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;
}
}
위의 설계는 의존성 역전 원칙, 계방-폐쇄 원칙, 리스코프 치환 원칙이 한데 어우러져 설계를 확장 가능하게 만들었다.
Movie
)과 하위 수준의 모듈(OverlappedDisocuntPolicy
) 모두 추상 클래스인 DiscountPolicy
에 의존한다.DiscountPolicy
와 협력하는 Movie
의 입장에서 DiscountPolicy
(부모) 대신 OverlappedDiscountPolicy
(자식)와 협력해도 아무런 문제가 없다.중복 할인 정책
인 OverlappedDisocuntPolicy
를 추가해도, Movie
에 영향이 끼쳐지지 않아 수정이 필요없다.Movie
(클라이언트)에게 영향을 미치지 않을 수 있었던 것은, 리스코프 치환 원칙 덕분이다.타입 계층을 구현하는 방법은 클래스 상속 외에도 다양한 방법이 있다.
그러나, 반드시 리스코프 치환 원칙을 준수해야만 서브타이핑 관계라고 말할 수 있다.
핵심은 (구현 방법과 무관하게) 클라이언트 관점에서 슈퍼타입에 대해 기대하는 모든 것이 서브타입에게도 적용되어야 한다는 것이다.
클라이언트의 관점에서 서로 다른 구성요소를 동일하게 다뤄야 한다면 서브타이핑 관계의 제약을 고려해서 리스코프 원칙을 준수해야 한다.
클라이언트 관점에서 자식 클래스가 부모 클래스를 대체할 수 있다는 것은 무엇을 의미하는가?
클라이언트 관점에서 자식 클래스가 부모 클래스의 행동을 보존한다는 것은 무엇을 의미하는가?
서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 '계약'을 준수해야 한다.
자식 클래스는 부모 클래스의 계약을 반드시 존중하고 따라야 한다.
그렇지 않으면 프로그램은 예상치 못한 방식으로 동작하게 된다.
public class Movie {
...
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calcualteDiscountAmount(screening)); // **
}
}
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);
}
이 예시에는 암묵적으로 다음과 같은 조건들이 존재한다.
Screening
은 null
이 아니다.DiscountPolicy
의 calculateDiscountAmount
는 메서드 인자로 전달된 Screening
의 null
여부를 체크하지 않는다.
screening
에 null
이 전달되면 screening.getMovie()
가 실행될 때 NullPointException
이 발생할 것이다.
따라서, 단정문(assertion)을 이용해 사전조건을 다음과 같이 표현할 수 잇다.
assert screening != null && screening.getStartTime().isAfter(LocalDateTime.now());
null
이 아니어야 한다.assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);
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);
}
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;
}
}
Movie
는 BrokenDiscountPolicy
를 DiscountPolicy
로 간주한다.Movie
는 DiscountPolicy
의 사전조건만 알고 있다.Movie
는 DiscountPolicy
가 정의하는 사전조건을 만족시키기 위한 노력"만" 한다.BrokenDiscountPolicy
가 요구하는 협력을 위한 노력(자정 이후의 영화 불허
)은 하지 않는다.현재 설계에서, 클라이언트 관점에서 BrokenDiscountPolicy
가 DiscountPolicy
를 대체하는 경우 협력이 실패한다.
따라서 BrokenDiscountPolicy
는 DiscountPolicy
의 서브 타입이 아니다.
public class BrokenDiscountPolicy extends DiscountPolicy {
...
@Override
public Money calculateDiscountAmount(Screening screening) {
// checkPrecondition(screening); // 기존의 사전조건 제거
Money amount = screening.getMovieFee();
checkPostcondition(amount); // 기존의 사후조건
retrun amount;
}
...
}
기존의 사전조건을 제거했으나, Movie
는 DiscountPolicy
와의 협력을 준수하기 위한 노력을 유지하고 있다.
기존의 조건을 체크하지 않는 것이 협력에 영향을 미치지 않는다.
📌 결론
서브 타입에 슈퍼 타입과 같거나 더 약한 사전조건을 정의할 수 있다.
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));
}
}
Movie
는 DiscountPolicy
의 사후조건만 알고 있다.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원보다는 크다고 믿고, 예매 요금으로 사용하게 된다.>
서버타입(더 약함) (적합) ✅<
서브타입(더 강력)<
서브타입(더 강력) (적합) ✅>
서브타입(더 약함)