[Java] Inheritance(상속) vs Composition(조합)
목차
- 상속; Inheritance
- 조합; Composition
- 질문 사항
- 상속보다는 컴포지션을 써야한다?
- 상속 설계 시 hook을 주의하자.
- 상속용 클래스의 생성자에서 재정의 가능한 메서드 호출 금지
- Cloneable과 Serializable 인터페이스를 조심해라?
- 상속을 금지하는 방법
- 도우미 메소드 활용하기
✨ 상속; Inheritance
먼저 상속에 대해서 알아보겠다.
상속은 코드 재사용을 쉽게 해주지만 잘못 사용하면 오류나기 쉽상이다.
또한 메소드 호출과 달리 캡슐화를 깨뜨린다.
상위 클래스의 변경이 하위 클래스까지 영향을 줄 수 있기 때문이다.
상속용 클래스는 재정의 가능한 메소드들을 내부적으로 어떻게 이용하는지, 어떠한 상황에서 호출할 수 있는지 등을 문서로 남겨야 한다.
🔻 API 문서에 대해
좋은 API 문서
- 본래 좋은 API 문서라면 '어떻게'가 아니라 '무엇'을 설명하는 것이 좋다.
하지만 상속은 캡슐화를 깨뜨리는 방식이라 어쩔 수 없다. (안전하게 상속하는 것이 우선!)
Implementation Requirements로 시작하는 절
- : 메소드 내부 동작 방식 설명
- 메소드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해줌
(Java 8에서 도입되고 9부터 본격적으로 사용됨)
예제를 살펴보자.
- 원소가 몇 번 더해졌는지 알기 위해 HashSet을 재정의
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet(){}
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;
}
}
- 요소 3개를 추가하는 코드
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("A", "B", "C"));
System.out.println(s.getAddCount());
결과가 어떻게 반환됐을까?
3을 기대했으나 실제로는 6이 출력된다.
HashSet의 addAll 메소드가 add 메소드를 사용해 구현하기 때문이다.
우리가 addAll()을 호출했을 때 요소의 size만큼 즉, 3개의 카운트가 추가되었다.
super.addAll()을 실행할 때 add 메소드를 이용해야 하는데 우리가 재정의한 add()를 사용하게 된다.
add() 메소드에서도 addCount를 1씩 카운트해주니 총 6이 출력되는 것이다.
우리는 HashSet가 addAll()의 구현 방법을 하나하나 살펴보기 까다롭고 만약 안다 해도 이를 회피하기 위해 새롭게 메소드를 구현하는 일은 어렵고 오류를 내거나 성능을 떨어뜨릴 수도 있다.
✨ 조합; Composition
기존 클래스의 내부 구현 방식의 영향에서 벗어나고
새로운 메소드가 추가되어도 영향받지 않는 방법이 있을까?
바로 컴포지션이다.
기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 만든다.
새 클래스의 인스턴스 메소드들은 요 인스턴스의 메소드들을 호출하여 결과를 반환한다. (= 전달; forwarding)
- ForwardingSet을 구현한 InstrumentedSet 클래스
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@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;
}
}
- Set를 implements하는 ForwardingSet
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s; // 기존 클래스인 Set를 private 필드로 생성
public ForwardingSet(Set<E> s) {
this.s = s;
}
// Set의 메소드를 호출하는 메소드들 재정의
@Override
public void clear() { s.clear(); }
@Override
public boolean contains(Object o) { return s.contains(o); }
// ...
}
🤔 질문 사항
❓ 상속보다는 컴포지션을 써야한다?
Effective Java의 item 18에서는 상속보다 컴포지션을 제안하고 있다.
- 상속
- 구체 클래스 각각을 따로 확장
- 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자 별도 생성 필요
- 컴포지션
- 한 번 구현해두면 어떤 구현체라도 계층 가능
- 기존 생성자와 함께 사용 가능
무조건 컴포지션을 쓰자는 말은 아니다.
상황에 따라 맞게 쓰도록 하자!!!
상속을 사용할 때는 아래 체크 리스트들을 체크해보도록 하자.
- 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황인가? (is-a 관계)
- 상속으로 인해 불필요하게 내부 구현을 노출되지 않는가?
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 결함이 있다면 하위 클래스까지 전파되어도 상관 없는가?
- 기존 행동을 부분적으로 보완하는 의미의 확장으로 사용하는가?
기능을 추가하는 확장보다는 불완전한 행동을 완전하게 바꾸는 정제에 가깝다.
❗ 상속 설계 시 hook을 주의하자.
훅; hook: 클래스의 내부 동작 과정 중간에 끼어들 수 있는 코드
상속을 설계할 때는 훅을 잘 선별해 protected 메소드 형태로 공개해야 한다.
이 부분에 대해선 정확한 기준은 없고 잘 예측해야 한다.
직접 하위 클래스를 만들고 검증하는 과정이 필수적이다.
example - AbstractList의 clear()
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
/**
* Removes all of the elements from this list (optional operation).
* The list will be empty after this call returns.
*
* @implSpec
* This implementation calls {@code removeRange(0, size())}.
*
* <p>Note that this implementation throws an
* {@code UnsupportedOperationException} unless {@code remove(int
* index)} or {@code removeRange(int fromIndex, int toIndex)} is
* overridden.
*
* @throws UnsupportedOperationException if the {@code clear} operation
* is not supported by this list
*/
public void clear() {
removeRange(0, size());
}
/**
* Removes from this list all of the elements whose index is between
* {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
* Shifts any succeeding elements to the left (reduces their index).
* This call shortens the list by {@code (toIndex - fromIndex)} elements.
* (If {@code toIndex==fromIndex}, this operation has no effect.)
*
* <p>This method is called by the {@code clear} operation on this list
* and its subLists. Overriding this method to take advantage of
* the internals of the list implementation can <i>substantially</i>
* improve the performance of the {@code clear} operation on this list
* and its subLists.
*
* @implSpec
* This implementation gets a list iterator positioned before
* {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
* followed by {@code ListIterator.remove} until the entire range has
* been removed. <b>Note: if {@code ListIterator.remove} requires linear
* time, this implementation requires quadratic time.</b>
*
* @param fromIndex index of first element to be removed
* @param toIndex index after last element to be removed
*/
protected void removeRange(int fromIndex, int toIndex) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}
// ...
}
- clear()
- removeRange()를 호출해 index 처음부터 끝까지 삭제
- removeRange()
- clear()를 고성능으로 만들기 쉽게 하기 위해 제공
- 해당 메소드가 없었다면 하위 클래스에서 clear 메소드 호출 시 성능이 느려지거나 새로 구현했어야 함
❗ 상속용 클래스의 생성자에서 재정의 가능한 메서드 호출 금지
상위 클래스의 생성자는 하위 클래스의 생성자보다 먼저 실행된다.
결론적으로 하위 클래스에서 재정의한 메소드가 하위 클래스의 생성자보다 먼저 호출되는 상황이 발생될 수 있다.
example - Class1을 상속하는 Class2
class Class1 {
public Class1() {
test();
}
public void test() {
System.out.println("test");
}
}
class Class2 extends Class1 {
private String string;
public Class2() {
string = "override!";
}
@Override
public void test() {
System.out.println(string);
}
}
위에서 Class2의 test() 메소드를 호출하면 결과가 뭐로 나올까?
일단 상위 클래스 생성자 선언 시 test()를 호출하니 System.out.println()은 2번 호출될 것이다.
string에 "override!" 값을 넣어줬으니 이 값이 2번 나오리라 생각했지만
결과는 null과 override!였다.
Class2의 생성자보다 Class2의 test()가 먼저 호출되었기 때문이다.
❓ Cloneable과 Serializable 인터페이스를 조심해라?
위 인터페이스를 구현한 클래스를 상속 가능하게 설계하는 것은 일반적으로 좋지 않다.
Cloneable의 clone()과 Serializable의 readObject()는 새로운 객체를 만들어내는, 생성자와 비슷한 기능을 가졌다.
클래스의 상태가 초기화되기 전에 메소드부터 호출되는 상황이 올 수 있다.
Serializable을 구현한 상속용 클래스가 readResolve(), writeReplace() 메소드를 가질 때 protected로 선언해야 한다.
private으로 선언 시 하위 클래스에서는 무시된다.
❗ 상속을 금지하는 방법
- 클래스를 final로 선언
- 모든 생성자를 private나 default로 선언 뒤 public 정적 팩토리 생성
일반적인 구체 클래스가 상속을 금지하는건 사용이 불편해질 수 있다.
이를 해결하기 위해서는 클래스 내부에서 재정의 가능 메소드를 사용하지 않게 만들고 이를 문서화하면 된다.
메소드를 재정의해도 다른 메소드의 동작에 아무런 영향을 주지 않게끔 개발하면 된다.
❓ 상속에서 도우미 메소드 활용하기
클래스 동작을 유지하며 재정의 가능 메소드를 사용하는 코드를 제거하고 싶다면?
재정의 가능 메소드를 private 형식의 도우미 메소드로 옮겨보자.
example - Class1을 상속하는 Class2 변형
class Class1 {
public Class1() {
helper();
}
public void test() {
helper();
}
private void helper() {
System.out.println("test");
}
}
class Class2 extends Class1 {
private String string;
public Class2() {
string = "override!";
}
@Override
public void test() {
System.out.println(string);
}
}
참고
- Effective Java / 조슈아 블로크 / item 18 ~ 19
- https://stackoverflow.com/questions/7285549/what-is-a-hook-and-how-can-i-write-one-in-java-and-how-to-communicate-with-ke