계승하는 대신 구성하라

이펙티브 자바!

계승하는 대신 구성하자!
계승(상속)은 코드 재사용을 돕는 강력한 도구지만, 항상 최선이라고는 할 수 없다.
계승(상속)을 적절히 사용하지 못한 소프트웨어는 깨지기 쉽다.
한 클래스가 다른 클래스를 상속(extends) 한다는 소리이다. 인터페이스의 상속을 말하는 것은 아니다. 또한 인터페이스가 인터페이스를 상속하는 것도 포함되지 않는다.

메서드 호출과 달리 계승(상속)은 캡슐화 원칙을 위반한다. 하위 클래스가 정상 동작하기 위해서는 상위 클래스의 구현에 의존할 수 밖에 없다.
상위 클래스의 구현이 릴리즈가 거듭되면서 자주 바뀌는데 그러다 보면 하위 클래스 코드는 수정된 적이 없어도 망가질 수 있다.
따라서 상위 클래스 작성자가 계승(상속)을 고려해 클래스를 설계하고 문서가지 만드렁 놓지 않았다면 하위 클래스는 상위 클래스의 변화에 발맞춰 진화해야 한다.
예로 HashSet을 보자

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;
  }
}

위와 같이 우리는 HashSet을 계승(상속) 받아서 구현을 했다.
딱히 이상한점은 찾을 수 없다.
하지만 이 클래스는 제대로 동작하지 않는다.
다음과 같이 addAll을 호출해서 실제 요소를 삽입한 횟수를 출력해보자

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(s.getAddCount());

우리는 3개는 넣었으니 3개를 기대할 것이다. 하지만 기대 했던거와 달리 6이 나온다.
사실 HashSet의 addAll 메서드는 add메서드를 통해 구현되어 있다. 그리고 HashSet 문서에는 그런 사실이 명시 되어 있지 않다.
InstrumentedHashSet에 정의된 addAll 메서드는 addCount에 3을 더하고 상위 클래스인 HashSet의 addAll 메서드를 호출 하는데 이 메서드는 InstrumentedHashSet에 재정의한 add 메서드를 삽입할 원소마다 호출 하게 된다. 각각의 add 메서드가 호출될 때마다 addCount는 1씩 증가함에 따라 총 6이 나오게 된다.
사실 하위 클래스에서 재정의한 addAll 메서드를 삭제하면 이 문제를 해결 할 수 있는데 이 클래스가 정상 작동한다는 것은 HashSet의 addAll 메서드가 add 위에서 구현되었다는 사실에 의존한다.

우리는 다음과 같이 해결 할 수 있다. 재사용이 가능한 전달 클래스?(위임을 말하는 듯?) 의 두 부분으로 나뉜다. 전달 클래스는 모든 전달 메서드를 포함하고 다른 것은 없다.
다음과 같이 계승 대신 구성을 사용하는 포장 클래스를 살펴보자


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; } } class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s){ this.s = s; } @Override public int size() { return s.size(); } @Override public boolean isEmpty() { return s.isEmpty(); } @Override public boolean contains(Object o) { return s.contains(o); } @Override public Iterator<E> iterator() { return s.iterator(); } @Override public Object[] toArray() { return s.toArray(); } @Override public <T> T[] toArray(T[] a) { return s.toArray(a); } @Override public boolean add(E e) { return s.add(e); } @Override public boolean remove(Object o) { return s.remove(o); } @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); } @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); } @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); } @Override public boolean removeAll(Collection<?> c) { return s.retainAll(c); } @Override public void clear() { s.clear(); } }

InstrumentedSet과 같은 클래스를 포장 클래스라고 부르는데 다른 Set 객체를 포장하고 있기 때문이다. 또한 이런 구현 기법은 장식자(데코래이터) 패턴이라고 부르는데 기존 Set 객체에 기능을 덧붙여 장식하는 구실을 하기 때문이다. 그런데 기술적으로 보자면 포장 객체가 자기 자신을 포장된 객체에 전달하지 않으면 위임이 라고 부를 수 없다.
이 포장 클래스에는 단점이 별로 없으나 역호출(콜백) 프레임워크와 함께 사용하기에는 적합하지 않다. 객체는 자기 자신에 대한 참조를 다른 객체에 넘겨, 나중에 필요할 때 콜백 하도록 요청한다. 포장된 객체는 포장 객체에 대해서는 모르기 때문에 자기 자신에 대한 참조를 전달할 것이다. 따라서 그 과장에서 포장 객체는 제외된다.

요약하자면 계승은 강력한 도구 이지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다는 것이다.
상위 클래스와 하위 클래스 사이에 IS-A? 관계가 있을 때만 사용하는 것이 좋다.
그것이 아니라면 구성과 전달 기법을 사용하는 것이 좋다. 포장 클래스 구현에 적당한 인터페이스가 있다면 더더욱 그렇다.
포장 클래스는 하위 클래스보다 견고할 뿐 아니라 더 강력하다.

이것이 답은 아니지만 되도록이면 포장 클래스를 쓰라는 말 같다.

equals를 재정의할 때는 반드시 hashCode도 재정의하라

equals를 재정의할 때는 반드시 hashCode도 재정의하라

예전에 동등성과 동일성에 대해 설명한 적이 있었다.
아마도 비슷한 내용이지 않나 싶다.
그래도 책에 나와있으니 다시 한번 포스팅해보자

Object 클래스 명세에서 복사해 온 일반 규약이다.

프로그램 실행 중에 같은 객체의 hashCode를 여러 번 호출하는 경우, eqauls가 사용하는 정보들이 변경되지 않는다면, 언제나 동일한 정수가 반환되어야 하지만 프로그램이 종료 되어 다시 실행 할 경우에는 그럴필요 없다.

equals 메서드가 같다고 판정 되면 hashCode도 같아야 한다.

equals 메서드가 다르다면 hashCode는 같을 수도 다를 수도 있지만 hashtable의 성능 향상을 위해 다른게 낫다.

만약 hashcode를 재정의하지 않으면 같은 객체는 같은 해시코드 값을 가져야 한다는 규약에 위반 된다.
다음 코드를 보자

final class PhoneNumber {
  private final short areaCode;
  private final short prefix;
  private final short lineNumber;

  public PhoneNumber(int areaCode, int prefix, int lineNumber) {
    rangeCheck(areaCode, 999, "area code");
    rangeCheck(prefix, 999, "prefix");
    rangeCheck(lineNumber, 9999, "line number");
    this.areaCode = (short) areaCode;
    this.prefix = (short) prefix;
    this.lineNumber = (short) lineNumber;
  }

  private static void rangeCheck(int arg, int max, String name) {
    if (arg < 0 || arg > max) {
      throw new IllegalArgumentException(name + ":" + arg);
    }
  }

  @Override
  public boolean equals(Object o) {
    if (o == this) {
      return true;
    }
    if (!(o instanceof PhoneNumber)) {
      return false;
    }
    PhoneNumber pn = (PhoneNumber) o;
    return pn.lineNumber == lineNumber && pn.prefix == prefix && pn.areaCode == areaCode;
  }
}

hashCode를 구현하지 않은 객체이다.

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

String s = m.get(new PhoneNumber(707, 867, 5309));
System.out.println(s);

그리고 다음과 같이 해보자
우리는 Jenny를 기대 할 것이다. 하지만 기대와 달리 null이 반환된다.
두개의 PhoneNumber 객체가 사용되었음에 유의하자 하나는 삽입할때 다른 하난(논리적으로 동일한) 꺼낼 때 사용한 객체다.
hashCode를 재정의 하지 않았기에 다른 해시코드를 갖는다. hashCode 규약에 위반 된다.
설사 운이 좋아서 같은 해시 버깃을 뒤지게 되더라고 거의 항상 null을 반환할 것이다. HashMap은 성능 최적화를 위해 내부에 보관된 항목의 해시 코드를 캐시해 두고 캐시된 해시 코드가 없으면 객체는 동일성 검사조차 하지 않는다.
이 문제는 hashCode를 구현하면 간단하게 해결 된다.

@Override
public int hashCode() {
  return 42;
}

위 메서드는 어떻게 보면 문제는 없다. 그러나 모든 객체가 같은 해시 코드를 가지게 되니 끔찍하다. 전부 같은 버킷에 해시되므로 해시 테이블은 결국 연결 리스트가 되어버린다.
복잡도가 제곱에 비례하게 증가 하면서 끔찍하게 느려진다. 해시 테이블에 저장되는 자료가 많을 경우에는 프로그램이 동작하지 않는 것처럼 되어버린다.
(아무도 느려지는 이유는 해시코드가 다르면 equals를 비교 조차 하지 않는데 매번 비교해야 하므로?)

지침의 설명은 책 혹은 인터넷에 찾아 보도록 하고 구현을 해보자

@Override
public int hashCode() {
  int result = 17;
  result = 31 * result + areaCode;
  result = 31 * result + prefix;
  result = 31 * result + lineNumber;
  return result;
}

위와 같은 메서드에서 PhoneNumber 객체의 중요한 필드만 입력으로 사용하여 결정적 계산을 수행하므로 동치 관계의 PhoneNumber 객체들에는 동일한 해시 코드를 반환한다.
result 는 0이 아닌 수면 된다. (여기서 17인 이유는 소수라서?, 31도 마찬가지?)
이렇게 구현하면 된다.
주의할 것은 성능을 개선하려고 객체의 중요 부분을 해시 코드 계산 과정에 생략 하면 절대 안된다.

요즘은 툴 혹은 lombok 에서 지원을 잘해주기 때문에 이런 구현은 극히 드물다. 물론 구현 할때도 있지 않나 싶기도 하다.
필자도 거의 구현해 본적이 없다.
툴에서 혹은 lombok에서 구현해주는 그대로라도 쓰자!

equals를 재정의할 때는 일반 규약을 따르자

이펙티브 자바!

equals를 재정의할 때는 일반 규약을 따르자

equals 메서드는 재정의하기 쉬워 보이지만 실수할 여지가 많다.
만약 재정의 하지 않는다면 그 경우에는 자기 자신하고만 같다. 만약 아래 조건이 부합한다면 그래도 된다.
1. 각각의 객체가 고유하다.
2. 클래스에 논리적 동일성 검사 방법이 있건 없건 상관 없다.
3. 상위 클래스에서 재정의한 equals가 하위 클래스에서 사용하기에도 적합하다.
4. 클래스가 private 또는 packing-private선언 되고 equals메서드를 호출할 일이 없다.

equals 메서드는 동치 관계를 구현한다.
1. 반사성 : null이 아닌 참조 x가 있을때 x.equals(x)는 true를 반환한다.
2. 대칭성 : null이 아닌 참조 x와 y가 있을 때 x.equals(y)는 y.equals(x) 가 true일때만 true를 반환한다.
3. 추이성 : null이 아닌 참조 x,y,z가 있을 때 x.equals(y)가 true이고 x.equals(z)가 true면 x.equals(z)도 true이다.
4. 일관성 : null이 아닌 참조 x,y가 있을 때 equals 통해 비교되는 정보에 아무 변화가 없다면 호출 결과는 횟수에 상관 없이 항상 같아야 된다.
5. null 아닌 참조 x에 대하여 x.equals(null)은 항상 false이다.

대칭성에 대해 보자

final class CaseInsensitiveString{
  private final String s;

  public CaseInsensitiveString(String s){
    if(s == null){
      throw new NullPointerException();
    }
    this.s = s;
  }

  @Override
  public boolean equals(Object o){
    if(o instanceof CaseInsensitiveString){
      return s.equalsIgnoreCase(((CaseInsensitiveString)o).s);
    }
    if(o instanceof String){
      return s.equalsIgnoreCase((String) o);
    }
    return false;
  }
}

의도는 좋으나 일반 문자열과도 호환되도록 하려는 시도를 하고 있다.
예를들어보자

CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Polish");
String s = "polish";

caseInsensitiveString.equals(s)는 true로 반환하지만 s.equals(caseInsensitiveString)는 false로 반환할 것이다.
CaseInsensitiveString는 equals에 String을 알지만 String의 equals는 CaseInsensitiveString을 모른다.
그러므로 CaseInsensitiveString의 equals는 String과 상호작용을 하지 않도록 해야된다.

추이성에 대하 알아보자

class Point {
  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  @Override
  public boolean equals(Object o) {
    if (!(o instanceof Point)) {
      return false;
    }
    Point that = (Point) o;
    return that.x == this.x && that.y == y;
  }
}

위와 같이 불변의 객체의 있다고 가정하자.
이 클래스를 상속 받아 색상 정보를 추가해 보자.

class ColorPoint extends Point {
  private final Color color;

  public ColorPoint(int x, int y, Color color) {
    super(x, y);
    this.color = color;
  }

  @Override
  public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) {
      return false;
    }
    return super.equals(o) && ((ColorPoint) o).color == color;
  }
}

이 메소드의 문제점은 Point객체와 ColorPoint 객체를 비교하는 순서를 바꾸면 다른 결과를 반환한다. 대칭성이 깨진다.

Point point = new Point(1, 2);
ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);
System.out.println(point.equals(colorPoint));
System.out.println(colorPoint.equals(point));

그럼 대칭성을 갖기 위해 색상정보를 무시하면 어떨까

@Override
public boolean equals(Object o) {
  if (!(o instanceof Point)) {
    return false;
  }
  if (!(o instanceof ColorPoint)) {
    return o.equals(this);
  }
  return super.equals(o) && ((ColorPoint) o).color == color;
}

그럼 대칭성은 되지만 추이성이 깨진다.

Point point = new Point(1, 2);
ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);
ColorPoint colorPoint1 = new ColorPoint(1, 2, Color.black);
System.out.println(point.equals(colorPoint));
System.out.println(colorPoint.equals(point));
System.out.println(colorPoint.equals(colorPoint1));

나머지는 다 true가 나오지만 마지막은 false가 나온다.

이번엔 일관성이다.
일관성은 객체가 변경되지 않는한 계속 같아야 한다는 것이다. 변경 가능한 객체들간의 동치 관계는 시간에 따라 달라질 수 있지만 변경 불가능한 객체 사이의 동치 관계는 달라질 수 없다.
신뢰성이 보장 되지 않는 자원들은 equals 구현을 삼가하는 편이 낫다.

null에 대한 비 동치성
모든 객체는 null과 동치 관계에 있지 아니 한다. e.equals(null)을 호출해서 우연하게도 true가 나온다면 생각하기 조차도 어려운 것이긴 하나, nullpointexception예외가 발생하는 상황은 그렇지 않다.
상당수 클래스는 equals안에서 null조건을 명시적으로 검사해서 이런 예외가 발생하지 않도록 한다.

if(o == null){
  return false;
}

그런데 굳이 이렇게 할 필요 없이 instanceof연산자를 사용해 자료형이 정확한지 검사한다.

if (!(o instanceof ColorPoint)) {
  return false;
}

첫번째 피연산자가 null이면 두번째 피연산자의 자료형에 상관 없이 무조건 false 이다.

지금까지 설명한 내용을 종합해보면 아래와 같이 지침들을 따라줘라

  1. == 연산자를 사용하여 equals의 인자가 자기 자신인지 검사하라.
  2. instanceof 연산자를 사용하여 인자의 자료형이 정확하지 검사하라.
  3. equals의 인자를 정확한 자료형으로 변환하라.
  4. 중요 필드 각각이 인자로 주어진 객체의 해당 필드와 일치하는지 검사하라.
  5. equals 메서드 구현이 끝냈다면, 대칭성, 추이성, 일관성의 세 속성이 만족되는지 검토하라,
  6. equals를 구현할 때는 hashcode도 재정의하라.
  7. 너무 머리 쓰지 마라.
  8. equals 메서드의 인자형을 Object 에서 다른것으로 바꾸지 마라.

물론 다 설명 하지는 않았지만, 위와 같이 구현을 권장한다.
일이 많다.