Search
Duplicate

아이템 18 - 상속보다는 컴포지션을 사용하라

작성자
챕터
4장 - 클래스와 인터페이스
최종 편집
2023/07/16 05:54
생성 시각
2023/07/13 09:03
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 안전하다
하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체클래스를 상속하는 일은 위험하다
클래스가 다른 클래스를 extends 하는 상속을 말한다
클래스가 인터페이스를 구현(Implements) 하는 경우를 얘기하는게 아니다

상속은 캡슐화를 깨뜨린다

상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 이러면 하위 클래스가 박살날 수 있다.
전체

예시코드

addAll 메소드는 HashSet은 재정의 되어있지 않았고, HashSet의 super class인 AbstaractSet 에서 확인 할 수있다
public abstract class AbstractCollection<E> implements Collection<E> { public boolean addAll(Collection<? extends E> c) { boolean modified = false; for (E e : c) // 의도 : HashSet.add() // 실제 : InstrumentHashSetExample.add() if (add(e)) modified = true; return modified; } // 얘는 abstract 라서 자식에서 구현이 안되어있어야 불림 public boolean add(E e) { throw new UnsupportedOperationException(); } }
Java
복사
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { @java.io.Serial static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map; // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object(); // addAll() 미구현 되어있다. public boolean add(E e) { return map.put(e, PRESENT)==null; } }
Java
복사
public class InstrumentHashSetExample { public class InstrumentedHashSet<E> extends HashSet<E> { @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(java.util.Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); } }
Java
복사
public static void main(String[] args) { InstrumentedHashSet<String> myHashSet = new InstrumentedHashSet<String>(); myHashSet.addAll(List.of("Snap", "Crackle", "Pop")); System.out.println(myHashSet.getAddCount()); //결과는 6 }
Java
복사
의도
1.
InstrumentHashSet.addAll()
a.
addCount++
2.
super.addAll()
a.
구현체의 add 호출
b.
AbstractCollection.add()
c.
HashSet.add()
실제 실행
1.
InstrumentHashSet.addAll()
a.
addCount++
2.
super.addAll()
a.
add( ) add 호출 ⇒ AbstractCollection.add()
b.
InstrumentHashSet.add()
i.
addCount++;
ii.
super.add() ⇒ HashSet.add() 호출
InstrumentHashSet 에서 addAll()을 구현하지 않으면 된다.
하지만 이런 해법은 HashSet 의 addAll 은 자기자신의 add 메소드를 이용해서 구현했음을 가정해야한다
자기사용 여부(본인클래스의 메소드 사용)는 해당 클래스 내부 구현 방식에 해당하며, 이런 세부 구현은 나중에 바뀔수도있다.
부모의 세부구현이 변경될경우 하위 클래스는 같이 깨진다
다른 해결방법으로는 원소당 add 하나씩 호출 하는 것이다
public class InstrumentHashSetExample { public class InstrumentedHashSet<E> extends HashSet<E> { @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(java.util.Collection<? extends E> c) { for (E element : c){ add(e) } }
Java
복사
재정의를 피해가기 위해서 부모의 메소드 와 다른 이름의 메소드를 만든다?
⇒나중에 릴리스되면서 부모에 똑같은 함수가 생긴다면? 의도치않은 미래 재정의

해결책

래퍼클래스

기존 클래스를 확장(extend) 하지 않는다
새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조 ⇒ 컴포지션(구성) (포함관계)
새 클래스의 인스턴스 메서드는 private 참조된 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환 ⇒ 전달 forwarding
새 클래스의 메소드들 → 전달 메서드 ( forwarding method )
class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } public void clear() { s.clear(); } public boolean contains(Object o) { return s.contains(o); } public boolean isEmpty() { return s.isEmpty(); } public int size() { return s.size(); } public Iterator<E> iterator() { return s.iterator(); } public boolean add(E e) { return s.add(e); } public boolean remove(Object o) { return s.remove(o); } public boolean containsAll(Collection<?> c) { return s.containsAll(c); } public boolean addAll(Collection<? extends E> c) { return s.addAll(c); } public boolean removeAll(Collection<?> c) { return s.removeAll(c); } public boolean retainAll(Collection<?> c) { return s.retainAll(c); } public Object[] toArray() { return s.toArray(); } public <T> T[] toArray(T[] a) { return s.toArray(a); } public boolean equals(Object o) { return s.equals(o); } public int hashCode() { return s.hashCode(); } public String toString() { return s.toString(); } }
Java
복사
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; } }
Java
복사
Instrumented 클래스를 WrapperClass 라고도 한다.
다른 Set 에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴 ( Decorator pattern ) 이라 한다.
컴포지션과 전달의 조합은 넓은 의미로 위임(delegation) 이라고 부른다. 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만

단점

콜백 프레임워크와는 어울리지 않는다.

콜백 프레임워크?

자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다.
내부 객체는 자신을 감싸는 래퍼의 존재를 모르니, 래퍼클래스 대신 자신(this)의 참조를 넘기고,
콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. ⇒ SELF 문제
예시
전달 메서드를 작성하는게 지루하다.
재사용 가능한 Forwarding Class 를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다.
forwarding class : 만약 래핑 클래스를 상속한다면, 구현해야하는 필수 메서드들
상속은 반드시 하위 클래스가 상위 클래스의 “진짜” 하위 타입인 상황에서만 쓰여야한다 ⇒ Class B is-a ClassA
B 는 정말 A 인가? 생각해볼것
자바 플랫폼 라이브러리에도 안좋은 예시가 있다.
Stack extends Vector
Properties extends HashTable
⇒ 개념적으로 봤을때 B is A 가 아니므로 컴포지션을 써야한다.
1.
내부구현을 불필요하게 노출하는 꼴이다.
2.
API가 내부 구현에 묶이고, 클래스의 성능도 영원히 제한된다.
3.
클라이언트가 노출된 내부에 직접 접근 가능
사용자의 혼란 야기
class Person { int age; String name; public Person(int age, String name) { this.age = age; this.name = name; } public int getAge() { return age; } public String getName() { return name; } } public class PropertiesExample { public static void main(String[] args) { Properties properties = new Properties(); Person person = new Person(1234, "wchae"); properties.setProperty("name",person.getName()); ///? properties.put("name", person); // hashtable 동작 System.out.println("properties.get(\"name\") = " + properties.get("name")); // Properties 동작 System.out.println("properties.getProperty() = " + properties.getProperty("name")); //결과 // properties.get("name") = test.item18.whynotcomposition.Person@a09ee92 // properties.getProperty() = null } }
Java
복사
properties 는 키밸류를 String 으로 관리하려고 하는데, 상위클래스의 .put 을 이용하면 Object가 들어간다
getProperty() 와 상위 클래스의 get을 사용한 동작이 다르다.

상속을 쓰기 전에…

컴포지션 대신 상속을 사용하기로 결정하기 전, 질문해봐야한다.
상위 클래스의 API 에 아무런 결함이 없는가?
자식까지 결함이 있을때 전파되도 되는가?