객체지향 프로그래밍과 객체기반 프로그래밍
- 객체기반 프로그래밍(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));
}
}
BrokenDiscountPolicyDiscountPolicy 상속 -> 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와의 협력을 준수하기 위한 노력을 유지하고 있다.
기존의 조건을 체크하지 않는 것이 협력에 영향을 미치지 않는다.
📌 결론
서브 타입에 슈퍼 타입과 같거나 더 약한 사전조건을 정의할 수 있다.
BrokenDiscountPolicyDiscountPolicy 상속 -> 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원보다는 크다고 믿고, 예매 요금으로 사용하게 된다.> 서버타입(더 약함) (적합) ✅< 서브타입(더 강력)< 서브타입(더 강력) (적합) ✅> 서브타입(더 약함)
