Object
1. 절차지향
- 프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부른다.(26p)
- 모든 처리가 하나의 클래스 안에 위치하고 나머지 클래스는 단지 데이터 역할만 수행한다.(26p)
1.1 절차지향의 문제점
- 절차적 프로그래밍의 세상에서 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다.(26p)
- 모든 처리가 하나의 클래스에 위치하기 때문에 여러 데이터 클래스 중 하나만 변경되더 라도 변경이 처리 클래스의 변경을 부른다.(26p)
- 변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계입니다.(27)
- 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수밖에 없습니다.
1.2 해결방안
- 자신의 데이터는 스스로 처리하도록 프로세스를 이동시켜야 합니다.
- 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부릅니다.(27)
- 비록 이 관점이 객체지향 구현 관점에서만 바로본 지극히 편현합 관점이지만 객체지향 프로그래밍의 본질을 실용적으로 설명하는 것은 사실입니다.
- 이 문서를 읽고 나면 객체지향이 단순히 데이터와 프로세스를 하나의 객체 안으로 모으는 것 이상의 것이라는 것을 알 수 있습니다.
2. 객체지향 프로그래밍
- 비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 시계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다.(33)
- 레베 카 워프스브록은 이처럼 능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 가리켜 의인화(anthropomorphism)라고 부른다.
- 훌륭한 객체지향 설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다.
- 그 대상이 비록 실세계에서는 생명이 없는 수동적인 존재라고 하더라도 객체지향의 시계에 들어오면 능동적이고 자율적인 존재로 바뀌게 된다.
- 보통 객체지향 프로그래밍을 작성할 때 가장 먼저 고려하는 것은 어떤 클래스가 필요한지 고민한다.(40p)
- 하지만 진정한 객체지향은 클래스가 아닌 객체에 초점을 맞출 때 얻을 수 있다.
- 즉 어떤 클래스가 필요한지가 아니라 어떤 객체가 필요한지 고민해야 합니다.
2.1 왜 객체지향인가?
- 좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계입니다.(35)
- 객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공하여 좋은 설계를 만들 수 있도록 돕습니다.
- 이로 인해 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 높여줍니다.
2.2 객체지향 설계
- 객체지향 프로그램을 작성 할 때 가장 먼저 고려하는 것은 어떤 클래스가 필요한지 고민할 것입니다.
- 대부분의 사람들이 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민합니다.
- 하지만 진정한 객체지향은 클래스가 아닌 객체에 초점을 맞출 때 얻을 수 있습니다.
- 이를 위해서는 두가지에 집중해야 합니다.
- 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하라
- 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것
- 클래스의 윤곽을 잡기 위해서는 객체들이 어떤 상태와 행동을 가져야 하는지 고민해야 합니다.
- 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야한다.
- 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하라
3. 컴파일 시간 의존성과 실행 시간 의존성(59p)
- 코드의 의존성과 실행 시점의 의존성이 다를 수 있습니다.
- 다시 말해 클래스 사이의 의존성과 객체 사이의 의존성이 다를 수 있습니다.
- 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워집니다.
- 코드를 이해하기 위해서는 코드뿐 아니라 객체를 생성하고 연결하는 부분을 이해해야 하기 때문입니다.
- 반면 코드는 더 유연해지고 확장이 가능합니다. 즉 의존성의 양면성은 설계가 트레이드오프의 산물이라 는 사실을 잘 보여줍니다.
- 설계가 유연해질수록 코드를 이해하고 디버깅하기 어려워지며 반면 유연성을 억제하면 코드를 이해하고 디버깅하기 쉬워지지만 확장 가능성이 낮아진다는 사실을 기억해야 합니다.
- 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아닙니다. 이것이 객체지향 설계가 어려운 이유입니다.
4. 다형성
- 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라지는 성질을
다형성
이라고 부른다.(63p)- 메시지: 객체의 오퍼레이션이 실행되도록 요청하는 것을
메시지 전송
이라고 합니다. - 메서드: 메시지에 응답하기 위해 실제로 실행되는 코드 블록을 의미합니다.
- 메시지: 객체의 오퍼레이션이 실행되도록 요청하는 것을
- 다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실에 기반합니다.
- 다형적 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 합니다.(63)
- 따라서 인터페이스가 동일해야 합니다.
- 다형성을 구현하는 방법은 다양합니다.
- 대표적으로 상속을 이용하면 다형성을 구현할 수 있습니다.
4.1 다형성의 종류
- 다형성은 크게 유니버셜 다형성과 임시 다형성으로 구분할 수 있습니다.(390)
- 유니버셜 다형성은 다시 매개변수 다형성과 포함 다형성으로 구분할 수 있습니다.(390)
- 임시 다형성은 다시 오버로딩 다형성과 강제 다형성으로 구분할 수 있습니다.(390)
- 매개변수 다형성(391)
- 제네릭 프로그래밍과 관련이 있습니다.
- 클래스의 인스턴스 변수다 메서드의 매개변수 타입을 임의로 선언한 후 사용 시점에 구체적인 타입으로 지정하는 방식입니다.
- 강제 다형성(391)
- 언어가 지원하는 자동적입 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 적용할 수 있는 방식입니다.
- 예를 들어
+
이항 연산자를 사용할 때 피연산자가 모두 정수면 정수 덧셈을 수행하고, 하나는 정수고 하나는 문자열인 경우 문자열 연결을 수행하는 방식입니다.
- 오버로딩 다형성(391)
- 메서드 오버로딩을 사용하여 유사한 작업을 수행하는 메서드의 이름을 통일하고 다른 매개변수 타입이나 개수를 사용하여 다형성을 구현하는 방식입니다.
- 따라서 기억해야하는 메서드 이름이 줄일 수 있습니다.
- 포함 다형성(391)
- 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행하는 행동이 달라지는 능력을 포함 다형성이라고 부릅니다.
- 포함 다형성은 서브 타입 다형성이라고도 부릅니다.
- 포함 다형성은 다형성 중 가장 일반적이고 널리 사용되는 형태입니다.
- 따라서 특별한 언급없이 다형성이라고 할 때는 포함 다형성을 의미합니다.
5. 상속
- 상속은 DRY 원칙을 지킬 수 있는 수단이다.
- 하지만 설계는 트레이드오프 활동이다. 상속은 코드의 재사용을 위해 캡슐화를 희생한다.
- 부모 클래스의 변경에 의해 자식 클래스가 영향 받는 현상을 취약한 기반 클래스 문제라고 부른다.(323)
- 상속은 자식 클래스르 점진적으로 추가해서 기능 확장하는데 용이하지만 높은 결합도로 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다.
- 최악의 경우 모든 자식 클래스를 수정하고 테스트해야 한다.
- 상속은 코드 재사용을 위해 캡슐화의 장점을을 희석시킨다.
5.1 상속의 단점
- 상속은 캡슐화를 위반합니다.(70)
- 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알아야 합니다.
- 다른 하나는 설계를 유연하지 못하게 만듭니다.(70)
- 상속은 부모 클래스와 지식 클래스 사이의 관계를 컴파일 시점에 결정합니다.
- 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능합니다.
- 반면 인스턴스 변수로 연결한 경우 실행 시점에 객체의 종류를 변경할 수 있습니다.
- 예를 들어, Movie에 DiscountPolicy를 변경할 수 있는 changeDiscountPolicy 메서드를 추가하면 실행 시점에 DiscountPolicy를 변경할 수 있습니다.
5.2 상속과 결합도
- 상속은 부모 클래스와 자식 클래스 사이에 강한 결합을 만들어낸다.
- 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다.
- 이것은 자식 클래스 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다.
- 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들어 캡슐화를 약화시킨다.
5.3 취약한 기반 클래스
- 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 의미한다.
- super
- 자식 클래스에서 super의 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 결합도가 강해진다.
- super 호출을 제거할 수 있는 방법을 찾아보자.
5.4 불필요한 인터페이스 상속 문제
- 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다
- 자바 초기에 상속 을 잘못 사용한 대표적인 사례 java.util.Stack을 보자
- Stack은 나중에 추가된 요소가 가장 늦게 추출되는 LIFO 자료 구조인 스택을 구현한 클래스다.
- Stack은 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현했다.
- 그러나 Vector에는 임의의 위치에서 요소를 조회하고, 추가하고, 삭제할 수 있는 퍼블릭 인터페이스가 존재한다.
- 따라서 Stack에게 상속된 Vector의 퍼블릭 인터페이스를 이용해 임의의 위치에서 요소를 추가하고 삭제해 Stack의 규칙을 쉽게 위반할 수 있다.
Stack<String> stack = new Stack<String>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th"); // 임의의 위치에 원소를 삽입하는 것이 가능하다.
5.5 메서드 오버라이딩 오작용 문제
- 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제
- 책에서는 HashSet은 상속받은 InstrumentedHashSet을 예로 들었다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
@Test
@DisplayName("InstrumentedHashSet 테스트")
void testAddAll() {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
Assertions.assertThat(s.getAddCount()).isEqualTo(6);
}
5.6 부모 클래스와 자식 클래스의 동시 수정 문제
- 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 한다.
5.7 업캐스팅
- 상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에 전송할 수 있는 메시지를 자식 클래스의 인스턴스에도 전송할 수 있다.(405)
- 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용합니다.
- 모든 객체지향 언어는 명시적으로 타입을 변환하지 않고도 부모 클래스의 참조 변수에 자식 클래스의 인스턴스를 할당할 수 있게 허용합니다. 이를
업캐스팅
이라고 부릅니다. - 업캐스팅으로 인해 미래의 자식 클래스들도 협력에 참여할 수 있게 됩니다.
- 반대로 부모 클래스의 인스턴스를 자식 클래 스 타입으로 변환하기 위해서는 명시적인 타입 캐스팅이 필요합니다. 이를
다운캐스팅
이라고 부릅니다.(405)
5.8 동적 바인딩
- 함수를 호출하는 전통적인 언어들은 호출될 함수는 컴파일 시점에 결정된다.(407)
- 이처럼 컴파일 타임에 호출한 함수를 결정하는 방식을
정적 바인딩
, 초기 바인딩, 또는 컴파일타임 바인딩이라고 부른다.
- 이처럼 컴파일 타임에 호출한 함수를 결정하는 방식을
- 객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 실행 시점에 결정된다.(407)
foo.bar()
라는 코드를 읽는 것만으로는 실행된느 bar가 어떤 클래스의 어떤 메서드인지를 판단하기 어렵다.- foo가 가리키는 객체자 실제로 어떤 클래스의 인스턴스인지 bar 메서드가 해당 클래스의 상속 계층의 어디에 위치하는지를 알아야 한다.
- 이처럼 실행될 메서드를 런타임에 결정하는 방식을
동적 바인딩
또는 지연 바인딩이라고 부른다.
- 동적 메서드 탐색 과정
- 객체지향 시스템은 아래 규칙에 따라 실행할 메서드를 결정한다.(408)
- 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 확인한다. 존재하면 해당 메서드를 실행하고 탐색을 종료한다.
- 메서드를 찾지 못했다면 부모 클래스에 적합한 메서드가 존재하는지 확인한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 계속해서 반복된다.
- 상속 계층의 가장 최상위 클래스에 이르렀지 만 메서드를 찾지 못했다면 해당 메시지를 처리할 수 없다는 예외를 발생시키며 탐색을 종료한다.
- 정적 타입 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단하여 컴파일 에러를 발생시킵니다.(419)
- 메서드 탐색은 자식 클래스에서 부모 클래스 방향으로 진행됩니다.(409)
- 따라서 항상 자식 클래스의 메서드가 부모 클래스의 메서드보다 더 높은 우선순위를 갖습니다.
- 메시지를 수신했을 때 실제 어떤 메서드를 실행할지 결정하는 것은 런타임에 결정되며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정합니다.
- self 참조가 Lecture 클래스의 인스턴스라면 Lecture 클래스에 정의된 메서드를 먼저 탐색을 시작해서 Object에서 종료됩니다.(416)
- 만약 self 참조가 GradeLecture 클래스의 인스턴스라면 GradeLecture 클래스에 정의된 메서드를 먼저 탐색을 시작해서 Lecture, Object 순으로 탐색을 진행합니다.
- 동일한 코드라 할지라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변합니다.
5.9 self 대 super
- 대부분의 객체지향 언어들은 자식 클래스에서 부모 클래스의 인스턴스 변수나 메서드에 접근하기 위해 사용할 수 있는 super 참조라는 내부 변수를 제공합니다.(422)
- 대부분의 사람들은 super.evaluate()라는 문장이 단순히 부모 클래스의 evaluate 메서드를 호출하는 것이라고 생각합니다.
- 하지만 super는 부모 클래스의 evaluate 메서드를 호출하는 것이 아니라 더 상위에 위치한 조상 클래스의 메서드일 수도 있습니다.
- super 전송은 부모 클래스에서부터 메서드 탐색을 시작하게 합니다.
- super의 용도는
지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요
입니다.
- self 전송은 메세지를 수신하는 객체의 클래스에 따라 탐색할 시작 위치를 동적으로 결정합니다.(424)
- 하지만 super 전송은 항상 메시지를 전송하는 클래스의 부모 클래스에서부터 시작됩니다.
- self 전송은 어떤 클래스에서 메시지가 탐색이 시작될지 알지 못합니다.
- super의 경우는 항상 해당 클래스의 부모 클래스에서부터 메서드 탐색을 시작합니다.
- 따라서 self의 경우 런타임에 동적으로 결정되고 super의 경우 컴파일 시점에 미리 결정됩니다.
5.10 상속을 위한 경고
- 자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스가 강하게 결합된다.(322)
- super 호출을 제거할 수 있는 방법을 찾아 결합도를 낮춰야 한다.
- 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨뜨릴 수 있다.(326)
- 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.(328)
- 클래스를 상속 하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.(332)
5.11 불필요한 인터페이스 상속 문제
- 자바 초기에 상속을 잘못 사용한 대표적인 예시로 java.util.Stack 클래스가 있다.(324)
- Stack 클래스는 Vector 클래스를 상속받아 구현되었다. 따라서 Vector의 퍼블릭 인터페이스를 그래도 상속받는다.
- Stack은 가장 나중에 추가된 요소가 가장 먼저 제거되는 LIFO(Last In First Out) 구조를 가지지만 Vector는 임의의 위치에 요소를 추가하거나 제거할 수 있는 기능을 제공한다.
- 따라서 Stack에 상속된 Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 제거할 수 있다.
- 이는 Stack의 규칙을 위반하는 것이다.
5.12 메서드 오버라이딩의 오작용 문제
- 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받을 수 있습니다.
- 이펙티브 자바 Item18 상속보다는 컴포지션을 사용하라 참고
- java.util.HashSet 클래스를 상속받은 InstrumentedHashSet 클래스를 예로 메서드 오버라이딩의 오작용 문제를 설명합니다.
5.13 부모 클래스와 자식 클래스의 동시 수정 문제
- 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스와 자식 클래스의 동시 수정이 필요할 수 있습니다.
- 조슈아 블로치는 이펙티브 자바에서 HashSet의 구현에 강하게 결합된 InstrumentedHashSet 클래스를 소개했습니다.
- InstrumentedHashSet은 HashSet을 상속받아 요소가 추가될 때마다 카운트를 증가시키는 기능을 추가했습니다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
InstrumentedHashSet<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Kotlin", "Scala"));
- 위 코드의 실행 결과는 3이 될거라 예상하지만 실행 결과는 6이 됩니다.
- 그 이유는 부모 클래스인 HashSet의 addAll 메서드가 메서드 안에서 add 메서드를 호출하기 때문입니다.
- 문제를 해결하기 위해 addAll 메서드를 오버라이딩을 제거해야 합니다.
- 하지만 이 방법도 완벽한 해결책이 아닙니다.
- 나중에 HashSet 클래스의 addAll 메서드가 add 메시지를 호출하지 않는 방식으로 변경된다면 InstrumentedHashSet 클래스는 더 이상 동작하지 않을 것입니다.
- 미래 수정을 감안한다면 addAll 메서드를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add 메서드를 호출하는 방법이 있습니다.
- 하지만 이 방법은 addAll 메서드의 구현이 HashSet 클래스의 addAll 메서드 구현가 동일하다는 문제가 존재합니다.
- 미래 발생할지 모르는 위험 방지를 위해 코드를 중복시킨 것입니다.
- 클래스가 상속되기 원하다면 상속을 위해 클래스를 설계하고 문서화 해야 하며, 그렇지 않으면 상속을 금지해야 합니다.(328)
- 내부 구현을 문서화 하면 객체지향의 핵심이 구현을 캡슐화하는 것인데 이를 위반하는게 아닌가 의문이 들 수 있습니다.
- 맞습니다. 상속은 결국 캡슐화를 위반함으로써 코드 재사용을 가능하게 하는 것입니다.
- 설계는 트레이드오프 활동입니다.
5.14 상속의 목적
- 상속을 이용해 자식 클래스를 추가하려 한다면 아래 질문을 스스로에게 던져보세요.(389)
- 상속을 사용하려는 목적이 단순히 코드를 재사용하기 위한 것인가?
- 클라이언트 관점에서 인스턴스들을 동리하게 행동하는 그룹으로 묶기 위한 것인가?
- 만약 첫 번째 질문에 대한 답이 '예'라면 상속을 사용하지 말아야 한다.
- 객체지향 패러다임 초기에는 상속은 타입 계층과 다형성을 구현할 수 있는 유일한 방법이였습니다.(389)
- 여기에 상속을 사용하면 코드를 재사용할 수 있다는 과대광고가 더해져 상속을 남용하게 되었습니다.
- 지금은 상속 이외에도 다형성을 구현할 수 있는 방법이 많아졌습니다.
- 따라서 상속에 중요성이 많이 낮아졌습니다.
- 상속을 사용하는 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이여야 합니다.(435)
- 상속 은 코드를 재사용하는 방법을 제공하지만 부모 클래스와 자식 클래스를 강하게 결합시켜 설계의 변경과 진화를 방해합니다.
5.15 언제 상속을 사용해야 하는가?
- 아래와 같은 질문을 해보고 모두 '예'라고 답할 수 있다면 상속을 사용하라고 조언합니다.(443)
- 상속 관계가 is-a 관계를 모델링 하는가?: 일반적으로 자식 클래스는 부모 클래스다라고 말해도 이상하지 않다면 상속을 사용할 예비 후보로 생각할 수 있습니다.
- 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?: 클라이언트는 입장에서 자식 클래스와 부모 클래스의 차이를 몰라야 합니다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 합니다.
6. 합성
- 347p
- 상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.
- 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.
- 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성은 두 객체 사이의 의존성은 런타임에 해결된다.
- 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다.
6.1 상속과 비교
- 합성은 구현에 의존하지 않는다는 점에서 상속과 다르다.
- 상속은 자식이 부모의 내부 구현에 대해 상세히 알아야하기 때문에 강한 결합이 생긴다.
- 상속은 내부에 포함된 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다.
- 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화 하는 것이 가능하다. (72)
- 따라서 객체의 내부 구현이 변경되어도 영향을 최소화할 수 있다.
- 따라서 코드 재사용을 위해 객체 합성이 클래스 상속보다 더 좋은 방법이다.
- 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경하여 결합도를 느슨하게 한다.
- 상속은 클래스를 통해서 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.(72)
- 코드 재사용 관점에서 상속을 화이트 박스 재사용이라고 하며 합성을 블랙박스 재사용이라 한다.
6.2 불필요한 인터페이스 상속 문제 해결
- 앞서 보았던 Stack을 합성을 이용해 다시 구현해 보자.
public class Stack<E> {
private Vector<E> elements = new Vector<>();
public E push(E item) {
elements.addElement(item);
return item;
}
public E pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
}
- 상속을 사용한 기존 Stack 대신 Vector를 포함한다.
- 상속에서는 불필요한 인터페이스 상속 문제 때문에 임의 위치의 원소를 삭제하거나 삽입하는게 가능했지만 합성을 이용하면 그런 불필요한 인터페이스를 가지지 않는다.
6.3 메서드 오버라이딩 오작용 문제 해결
- 예제의 InstrumentedHashSet을 상속에서 합성으로 변경하면 메서드 오버라이딩 오작용 문제를 해결할 수 있다.
- InstrumentedHashSet이 내부에 HashSet을 인스턴스로 가지고 HashSet의 퍼블릭 인터페이스만 사용해서 InstrumentedHashSet을 구현하면 된다.
해결 코드
public class InstrumentedHashSet<E> {
private int addCount = 0;
private Set<E> set;
public InstrumentedHashSet(Set<E> set) {
this.set = set;
}
public boolean add(E e) {
addCount++;
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
- 하지만 InstrumentedHashSet은 HashSet의 모든 퍼블릭 인터페이스를 그대로 제공해야 한다.
- 따라서 아래와 같은 코드를 작성한다.
public class InstrumentedHashSet<E> implements Set<E> {
private int addCount = 0;
private Set<E> set;
public InstrumentedHashSet(Set<E> set) {
this.set = set;
}
@Override
public boolean add(E e) {
addCount++;
return set.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}
@Override
public Spliterator<E> spliterator() {
return Set.super.spliterator();
}
@Override
public int size() {
return set.size();
}
@Override
public boolean isEmpty() {
return set.isEmpty();
}
@Override
public boolean contains(Object o) {
return set.contains(o);
}
@Override
public Iterator<E> iterator() {
return set.iterator();
}
@Override
public Object[] toArray() {
return set.toArray();
}
@Override
public <T> T[] toArray(T[] a) {
return set.toArray(a);
}
@Override
public boolean remove(Object o) {
return set.remove(o);
}
@Override
public boolean containsAll(Collection<?> c) {
return set.containsAll(c);
}
@Override
public boolean retainAll(Collection<?> c) {
return set.retainAll(c);
}
@Override
public boolean removeAll(Collection<?> c) {
return set.removeAll(c);
}
@Override
public void clear() {
set.clear();
}
}
- Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드 안에서 set 인스턴스에게 동일한 메서드를 그대로 전달한다.
- 이를 포워딩이라고 부른다.
- 포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경할 때 유용한 기법이다.
6.4 상속과 합성의 차이점
- 상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.(346)
- 상속은 부모 클래스와 자식 클래스 사이의 의존성이 컴파일 타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.(346)
- 이 차이점은 생각보다 중요하다. 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하다. 합성 관계는 실행 시점에 동적으로 변경이 가능해 변경하기 쉽고 유연한 설계를 얻을 수 있다.(347)
- 상속은
is-a
관계를 표현하는 반면 합성은has-a
관계를 표현한다.(346) - 코드 재사용을 위해서는 객체 합성이 클래스 상속보다 더 좋은 방법이다.(347)
- 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경할 수 있다.
- 다시 말해 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체할 수 있다.
- 상속을 화이트박스 재사용이라 부르고 합성 은 블랙박스 재사용이라고 부른다.(347)
- 화이트박스 재사용은 부모 클래스의 내부 구조를 알고 있어야 한다. 따라서 부모 클래스의 변경이 자식 클래스에 영향을 미칠 수 있다.
- 블랙박스 재사용은 부모 클래스의 내부 구조를 알 필요가 없고 인터페이스를 통해 부모 클래스와 협력한다. 따라서 부모 클래스의 변경이 자식 클래스에 영향을 미치지 않는다.
- 화이트박스 재사용은 부모 클래스의 내부 구조를 알고 있어야 한다. 따라서 부모 클래스의 변경이 자식 클래스에 영향을 미칠 수 있다.
6.5 합성으로 변경하기
- Vector를 상속받는 Stack을 합성으로 변경하여 불필요한 인터페이스 상속문제를 해결할 수 있습니다.(348)
public class Stack<E> {
private final Vector<E> elements = new Vector<>();
public E push(E item) {
elements.add(item);
return item;
}
public E pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
- 이제 Stack 불필요한 Vector의 오퍼레이션들이 포함되지 않습니다.
- 클라이언트 이제 임의의 위치에 요소를 추가하거나 제거할 수 없습니다.
- 합성 관계로 변경함으로써 클라이언트가 Stack을 사용할 때 Stack의 규칙을 위반하는 행동을 할 수 없게 되었습니다.
6.6 클래스 폭발
- 상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에 변경할 수 없다. (368)
- 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우별로 클래스를 추가해야한다.
- 이것이 바로 핸드폰 과금 시스템의 설계 과정에서 직면했던 클래스 폭발 문제입니다.
- 컴파일타임 의존성과 런타임 의존성의 거리가 멀수록 설계가 유연해집니다.(368)
- 상속은 컴파일타임 의존성과 런타임 의존성을 동일하게 만들겠다고 선언하는 것입니다.
- 따라서 클래스 폭발 문제를 해결하려면 상속을 피하고 합성을 이용해야 합니다.
7. 역할, 책임, 협력
- 객체지향 패러다임의 관점에서 핵심은 역할, 책임, 협력입니다.(73)
- 클래스와 상속은 객체들의 책임과 협력이 어느 정도 자리를 잡은 후에 사용할 수 있는 구현 메커니즘입니다.(73)
- 다분히 구현 측면에 치우져 있기 때문에 객체지향 패러다임의 본질과는 거리가 멀다.(73)
8. 협력
- 객체지향 시스템은 자율적인 객체들의 공동체다.(75)
- 협력은 애플리케이션의 기능을 구현하기 위해 메시지를 주고받는 객체들 사이의 상호작용이다.(97)
- 협력이란 어떤 객체가 다른 객체에게 무엇인가를 요청하는 것
메시지 전송
은 객체 사이의 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단이다.(49p)- 메시지를 수신한 객체는 스스로의 결정에 따라
메서드
를 실행해 요청에 응답한다. - 수신된 메시지를 처리하기 위한 자신만의 방법을
메서드
라고 부른다.
- 메시지를 수신한 객체는 스스로의 결정에 따라
- 객체를 자율적으로 만드는 가장 기본적인 방법은 내부 구현을
캡슐화
하는 것이다.(76)- 캡슐화를 통해 변경에 대한 파급효과를 제한할 수 있습니다.
- 자율적인 객체는 자신에게 할당된 책임을 수행하는 중에 필요한 정보를 알지 못하거나 외부의 도움이 필요한 경우 적절한 객체에게 메시지를 전송해서 협력을 요청한다.
8.1 협력이 설계를 위한 문맥을 결정한다
- Move 객체는 어떤 행동을 수행해야 될까? 영화라는 단어만 들었을 때 대부분 사람들은 극장에서 영화를 상영하는 장면을 상상하고 자연스럽게 play라는 행동을 수행할 것이라고 생각한다. (77)
- 그러나 예매 시스템에서 영화를 상영하기 위한 어떤 코드도 포함돼있지 않다.
- 그 이유는 영화 예매를 위한 협력에 참여하고 있기 때문이다.
- 협력이라는 문맥을 고려하지 않고 Movie의 행동을 결정하는 것은 의미가 없다.
7 책임
- 책임이란 객체에 의해 정의되는 응집도 있는 행위의 집합이다.
- 즉 객체의 책임은
무엇을 알고 있는가
와무엇을 할 수 있는가
로 구성된다. - 무엇을 알고 있는가
- 사적인 정보에 관해 아는 것
- 관련된 객체에 관해 아는 것
- 자신이 유도하거나 계산할 수 있는 것에 관해 아는 것
- 무엇을 할 수 있는가
- 객체를 생성하거나 계산을 수행하는 등의 스스로 하는 것
- 다른 객체의 행동을 시작시키는 것
- 다른 객체의 활동을 제어하고 조절하는 것
7.1 책임 할당: Information Expert
- 자율적인 객체를 만드는 가장 기본적인 방법은 책임을 수행하는데 필요한 정보를 가장 잘 알고있는 전문가에게 그 책임을 할당하는 것이다.(81)
- 이를 Information Expert(정보 전문가) 패턴이라고 부른다.
8. 역할
- 객체가 어떤 특정한 협력 안에서 수행하는
책임의 집합을 역할
이라고 부른다.(86) - 역할을 구현하는 가장 일반적인 방법 은
추상 클래스
와인터페이스
를 사용하는 것이다. - 협력 관점에서 추상 클래스와 인터페이스는 구체 클래스들이 따라야하는 책임의 집합을 서술한 것이다.
8.1 역할과 협력
- 영화 예매 협력에서
예매하라
라는 메시지를 처리하기에 적합한 객체로Screening
을 선택했다.(86) - 하나의 단계처럼 보이지만 실제로는 두 개의 독립적인 단계가 결합된 것이다.
- 첫 번째 단계는 영화를 예매할 수 있는 적절한 역할이 무엇인가 찾는다.
- 두 번째 단계는 역할을 수행할 객체로
Screening
을 선택하는 것이다.
- 왜 역할이라는 개념을 이용해서 설계 과정을 더 번거롭게 만드는 것일까?
- 아래에서 그 답을 알 수 있다.
8.2 유연하고 재사용 가능한 협력
- 역할이 중요한 이유는 역할을 통해 유연하고 재사용 가능한 협력을 얻을 수 있기 때문이다.(87)
8.3 역할의 구현
- 역할을 구현하는 가장 일 반적인 방법은
추상 클래스
와인터페이스
를 사용하는 것이다.(89) - 협력 관점에서 추상 클래스와 인터페이스는 구체 클래스들이 따라야하는 책임의 집합을 서술한 것이다.
- 추상 클래스는 책임의 일부를 구현해 놓은 것이고 인터페이스는 책임의 집합만을 나열해 놓았다는 차이가 있다.
- 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상 클래스를 사용하고 없다면 인터페이스를 사용하면 된다.(156)
8.4 객체 대 역할
- 역할은 객체가 참여할 수 있는 일종의 슬롯이다.(90)
- 그러나 오직 한 종류의 객체만 협력에 참여한는 상황에서 역할이라는 개념을 고려하는 것이 유용할까?
- 만약 협력에 적합한 책임을 수행하는 대상이 한 종류라면 간단하게 객체로 간주한다. 만약 여러 종류의 객체들이 참여할수 있다면 역할이라고 부르면 된다.
- 대부분의 경우 어떤 것이 역할이고 어떤 것이 객체인지가 또렷하게 드러나지 않을 것이다.(91)
- 특히 설계 초반에는 결정을 내리기 더욱 어렵다.
- 애매하다면 단순하게 객체로 시작하고 반복적으로 책임과 협력을 정제해가면서 필요한 순간에 객체로부터 역할을 분리하는 것이 가장 좋은 방법이다.