10. 상속과 코드 재사용
상속은 클래스를 재사용하기 위한 가장 대표적 기법 이다.
00. 선요약
상속으로 인한 클래스 사이의 결합을 피할수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스아 자식 클래스르 결합시킨다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 잇는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 사속 계층 전체에 걸쳐 부작용이 퍼지지 않게 하는 것이다.
상속을 사용할 때 주의해야할 사항은 다음과 같다.
- 자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
- 상속받은 부모 클래스의 메서드가 자식 메서드의 규칙을 깨뜨릴 수 있다.
- 자식 클래스가 부모 클래스를 오버라이딩 할 경우 부모 클래스가 자신의 메서드를 사용하는 방식에 자식 클래스가 결합될 수 있다.
- 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할수밖에 없다.
01. 상속과 중복 코드
중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다.
두 코드는 정말 동일한 것인가? 유사한 코드가 이미 존재하는데도 새로운 코드를 만든 이유가 무엇일까? 의도적인 것일까 아님 단순 실수인가? 중복을 없애도 문제가 없을 까? 양쪽 모두 수정하는 것 보다 한쪽만 수정하는 것이 더 안전한 방법 아닌가?(정말 공감!!!!)
중복 코드는 변경을 방해한다. 이것이 중복을 제거해야 하는 가장 큰 이유이다. 프로그램은 항상 변화하고 코드도 그에 맞춰 변경되어야 한다.
DRY 원칙
DRY는 '반복하지 마라'라는 뜻의 Don't Repeat Yourself의 첫 글자를 모아 만든 용어로 간단히 말해 동일한 지식을 중복하지 말란 뜻이다.
상속을 이용해서 중복을 제거하기
상속의 기본 아이디어는 매우 간단하다. 이미 존재하는 클래스와 유사한 클래스가 필요하면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하라는 것이다.
하지만 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용 하는 것은 생각보다 쉽지 않다. 상속을 이용해 코드를 재사용 하기 위해서는 부모클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 따라서 상속은 결합도를 높인다. 그리고 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.
02. 취약한 기반 클래스 문제
부모 클래스의 변경에 의해서 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제 라고 부른다. 취약한 기반 클래스 문제는 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어이다.
상속은 자식클래스를 점진적으로 추가해서 기능을 확장하는데는 용이하지만 높은 결합도로 인해 부모클래스를 점진적으로 개선하기는 어렵게 만든다. 또한 상속은 자식 클래스가 부모클래스의 구현에 의존하게 만들기 때문에 캡슐화를 약하게 만든다.
객체지향의 기반은 캡슐화를 통한 변경의 통제인데, 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향의 강력함을 반감시킨다. 몇가지 예를 통해 상속이 가지는 문제점을 알아보자.
불필요한 인터페이스 상속 문제
Vector와 Vector를 상속받은 Stack을 살펴보자.
위 그림의 퍼블릭 인터페이스를 보면 이 상속관계가 가지는 문제점을 잘 알 수 있다. Vector는 임의의 인덱스에서 여소를 조회하고, 추가하고, 삭제할 수 있는 get, add, remove 오퍼레이션을 제공한다. Stack은 push와 pop 오퍼레이션을 제공하는데 Vector의 오퍼레이션도 사용할 수 있으므로 임의의 위치에서 요소를 추가하거나 삭제할 수 있다. 따라서 맨 마지막 위치에서만 요소를 추가하거나 제거할 수 있도록 허용하는 Stack의 규칙을 위반해 버린다.
Stack<String> stack = new Stack();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.push(0, "4th");
assertEquals("4th", stack.pop()); //에러!!!
물론 Stack을 사용하는 개발자들이 Vector에서 상속받은 add메서드를 사용하지 않으면 된다고 생각할 수 있지만, 인터페이스는 제대로 쓰기는 쉽게 엉터리로 쓰기엔 어렵게 만들어야 한다.
메서드 오버라이딩의 오작용 문제
이펙티브 자바(Joshua Bloch)에 소개된 내용 이다.
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);
}
}
HashSet의 구현에 강하게 결합된 InstrumentedHashSet 예 이다. 언뜻 보기엔 별 문제 없어 보이지만 아래 코드를 실행해 보면 예상과 다른 결과가 나옴을 확인할 수 있다.
InstrumentedHashSet<String> languages = InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));
딱 보면 위 코드를 실행 후 addCount값이 3이 될거라고 예상하겠지만 실제로 실행한 후의 addCount 값은 6이 된다. InstrumentedHashSet의 addAll 메서드가 호출되면 addCount에 3이 더해지고 super.addAll 메서드를 호출한다. 그런데 super.addAll 메서드는 내부적으로 add 메서드를 호출하고 결과적으로 InstrumentedHashSet의 add 메서드가 호출되어 addCount에 3이 더 더해지게 되는 것이다.
당장엔 InstrumentedHashSet의 addAll 메서드에서 addCount += c.size()
부분을 제거하면 되지만 나중에 HashSet의 addAll 메서드가 변경되면 InstrumentedHashSet의 addAll 메서드에도 영향이 갈 수밖에 없다.
부모 클래스와 자식 클래스의 동시 수정 문제
상속은 기본적으로 부모 클래스의 내용을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알 것을 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할수밖에 없다.
03. 상속을 제대로 사용하기
중복 제거를 위하여 이 책의 저자는 아래 방법을 사용한다고 한다.
- 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식으로 내리는 것 보다 자식 클래스의 추상적인 메서드를 부모로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.
추상화에 의존하자
부모 자식 관계의 클래스가 강하게 결합되어 있으면 둘 다 추상화에 의존하도록 만들면 이 문제를 해결할 수 있다.
차이를 메서드로 추출하라
중복코드를 보고 둘 사이에 차이점을 별도의 메서드로 추출한다.
중복 코드를 부모 클래스로 올려라
부모클래스를 추가한다. 모든 클래스가 추상화에 의존하도록 만드는 것이 목표이기 때문에 새로운 클래스는 추상클래스로 만든다.
기존 클래스들의 공통부분을 부모클래스로 이동시킨다. 메서드의 시그니처는 같은데 구현만 다른 경우 메서드의 구현은 그대로 두고 시그니처만 이동시켜 추상메서드를 선언하고 자식클래스에서 오버라이딩 할 수 있도록 protected로 선언한다.
추상화가 핵심이다
공통 코드를 이동 시킨 후에 보면 각 클래스는 서로 다른 변경의 이유를 가지게 된다. 각 클래스는 각각 하나의 변경 이유만 가지게 되므로 응집도가 높아진다.
추상클래스의 추상메서드를 오버라이드 하는 경우에도 보면 추상메서드의 시그니처가 변경되지 않는 이상 부모클래스의 내부가 변경된다 해도 자식클래스는 영향을 받지 않는다.
하위 클래스가 Abstract 클래스의 추상 메서드를 구현하게 되므로(상위 정책에 의존을 하게 된다) 의존성 역전 원칙도 준수하게 된다.
새로운 기능이 필요한 경우도 추상 클래스를 상속받아 새로운 기능을 구현한 구체클래스를 만들면 되므로 확장에 대해 열려있고 수정에 대해 닫혀있는 설계를 만족한다. 개방-폐쇄 원칙 또한 준수한다고 할 수 있다.
의도를 드러내는 이름 선택하기
클래스, 메서드 모두 그 자신의 의도를 잘 드러내는 이름을 선택해야 한다. 추상클래스는 좀 더 포괄적인 이름을, 구체클래스는 특성을 잘 드러내는 이름을 선택해야 한다.
요구사항 추가하기
인스턴스 변수의 목록이 변하지 않은 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 잔화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 경우는 그렇지 못하다. 자식클래스는 자신의 인스턴스를 생성할 때 부모클래스에서 정의된 인스턴스 변수를 초기화 해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식클래스의 초기화로직에 영향을 미치게 된다. 책임을 아무리 잘 분리해도 인스턴스변수의 추가는 종종 상속 계층 전반에 걸친 변화를 유발한다.
상속으로 인한 클래스 사이의 결합을 피할수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스아 자식 클래스르 결합시킨다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 잇는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 사속 계층 전체에 걸쳐 부작용이 퍼지지 않게 하는 것이다.