이펙티브 자바 - 3장

JeongJun Min·2024년 10월 4일

JAVA

목록 보기
2/7
post-thumbnail

모든 객체의 공통 메서드

Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다. Object에서 final이 아닌 메서드는 모두 재정의를 염두에 두고 설계되었다.

final이 아닌 메서드
equals
hashCode
toString
clone
fainalize

하지만 메서드를 일반 규약에 맞지 않게 재정의하면 클래스를 오작동하게 만들 수 있다.

아이템 10. equals는 일반 규약을 지켜 재정의하라

문제를 회피하는 가장 쉬운 길은 재정의를 하지 않는 것 이다. 그러면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.

다음은 재정의를 하지 않는 것이 최선인 상황들이다.

  • 각 인스턴스가 본질적으로 고유하다.

값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다.

  • 인스턴스의 ‘논리적 동치성(logical equality)’을 검사할 일이 없다.

정규표현식을 검사하는 Pattern의 인스턴스는 논리적으로 동치성을 검사한다. 하지만 설계자는 클라이언트가 이 방식을 원하지 않거나 애초에 필요하지 않다고 판단할 수도 있다. 필요하지 않을 경우 Object의 기본 equals만으로 해결된다.

  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.

Set과 Map 구현체들은 각각 AbstractSet, AbstractList이 구현한 equals를 상속받아 그대로 사용한다.

  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
@Override public boolean equals(Object o){
	throw new AssertionError(); // 호출 금지!
}

🤔그렇다면 equals를 재정의 할 때는 언제일까?

두 객체가 물리적으로 같은지를 비교하는 객체 식별성이 아닌 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다

값 클래스란(Integer, String)처럼 값을 표현하는 클래스들이 위에 해당된다.

두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어 할 것이다.

값 클래스야도 같은 값 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스(Enum)라면 equals를 재정의하지 않아도 된다.

equals 메서드를 재정의할 때 반드시 따라야할 일반 규약 : 동치관계

  • 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.
  • 대칭성(symmetry): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)는 true면 y.equals(x)도 true이다.
  • 추이성(transitivity): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)는 true이고, y.equals(x)도 true면, x.equals(z)는 true이다.
  • 일관성(consistency): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
  • null-아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
  • 대칭성(symmetry)
public final class CaseInsensitiveString{
  private final String s;

  public CaseInsensitiveString(String s){
    this.s = Obejcts.requireNonNull(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의 equals는 String을 알지만, String의 equals는 CaseInsensitiveString의 존재를 몰라 대칭성을 위반하게 된다.

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

CaseInsensitiveString의 equals를 String 과 연동하겠다는 것을 버려야한다.

  • 추이성(transitivity)

첫 번째 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체가 같다야 한다는 뜻이다.

public 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 p = (Point) o;
		return p.x == x && p.y == y;
	}
	...
}
public class ColorPoint extends Point {
	private final Color color;
	
	public ColorPoint(int x, int y, Color color) {
		super(x, y);
		this.color = color;
	}

	...
}
  1. 잘못된 코드 - 대칭성 위배
@Override public boolean equals(Object o) {
	if(!o instanceof ColorPoint)
		return false;
	return super.equals(o) && ((ColorPoint) o).color == color;
}

Point의 equals는 색상을 무시하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 달라 두 객체를 비교하면 매번 false만 반환할 것이다.

  1. 잘못된 코드 - 추이성 위배
@Override public boolean equals(Obejct o){
  if(!(o instanceof Point))
    return false;
  if(!(o instanceof ColorPoint))
    return o.equals(this);
  return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(){
  ColorPoint p1 = new ColorPoint(1,2, Color.RED);
  Point p2 = new Point(1,2);
  ColorPoint p3 = new ColorPoint(1,2, Color.BLUE);
 
  p1.equals(p2);    // true 
  p2.equals(p3);    // true 
  p1.equals(p3);    // false
}

위 코드는 대칭성은 지켜주지만, 추이성을 깨버린다. p1과 p2, p2와 p3는 색상을 무시했지만, p1과 p3비교에서는 색상까지 고려했기 때문이다. 또한, 이 방식은 무한 재귀에 빠질 위험도 있다.

구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

  1. 잘못된 코드 - 리스코프 치환 원칙 위배
@Override public boolean equals(Object o){
  if(o == null || o.getClass() != getClass())
    return false;
  Point p = (Point) o;
  return p.x == x && p.y == y;
}

얼핏 equals 안의 instanceof 검살르 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다고 생각할 수 있지만, Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다.

❗구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다.

  1. 상속 대신 컴포지션을 사용하라

컴포지션:

클래스 필드 내에 private or public 필드로 클래스의 인스턴스를 참조하게 하고

해당 클래스를 구성하는 부분의 합

public class ColorPoint{
  private final Point point;
  private final Color color;

	public ColorPoint(int x, int y, Color color) {
		point = new Point(x, y);
		this.color = Objects.requireNonNull(color);
	}

	/* 이 ColorPoint의 Point 뷰를 반환한다. */
  public Point asPoint(){
    return point;
  }

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

Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드를 public으로 추가하는 식이다.

java.sql.Timestamp도 java.util.Date를 확장한 후 nanoseconds 필드를 추가했다. 그 결과 Timestamp의 equals는 대칭성을 위배하여 Date객체와 한 컬렉션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있다.

  • 일관성(consistency) - 두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 영원히 같다.

    • 불변 객체일 경우 eqauls가 한번 같다고 한 객체와는 영원히 같다고 답하고, 다르다고 한 객체와는 영원히 다르다고 답하도록 만들어야 한다.
    • 불변 클래스를 만드는 게 나을지를 심사숙고하자
  • null-아님 - 모든 객체가 null과 같지 않아야 한다.

@Override
public boolean equals(Object o) {
  if(!(o instanceof MyType)) { 
      return false;
  }
  MyType myType = (MyType) o;
}

위처럼 묵시적 null 검사를 사용하면 첫 번째 피연산자가 null일 경우 false를 반환한다.

양질의 equals 메서드를 구현하는 방법

  1. ==연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 자기 자신이면 true를 반환한다. 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 어떤 인터페이스는 자신을 구현한 클래스 끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다. 이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야 한다. ex) SetListMapMap.Entry
  3. 입력을 올바른 타입으로 형변환 한다. 앞서 2번에서 instanceof 연산자로 입력이 올바른 타입인지 검사 했기 때문에 이 단계는 100% 성공한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다. 모두 일치해야 true를 반환하고, 하나라도 다르면 false를 반환한다.

float와 double(부동 소수를 다뤄야 하기 때문)을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입 필드는 각각의 equals 메서드로, float와 double 필드는 각각 정적 메서드인 Float.compare(), Double.compare()로 비교한다.

배열 필드는 원소 각각을 앞서의 지침대로 비교한다. 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용하자.

null도 정상 값으로 취급하는 참조 타입 필드도 있다. 이런 필드는 정적 메서드인 Objects.equals()로 비교해 NullPointerException 발생을 예방하자.

비교하기가 아주 복잡한 필드일 경우 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 훨씬 경제적이다. (불변 클래스에 제격)

필드 비교 순서에 따라 equals의 성능을 좌우한다.

equals를 다 구현했다면 대칭적인지, 추이성이 있는지, 일관적인지 자문을 해보자.

equals를 재정의할 땐 hashCode도 반드시 재정의하자.

너무 복잡하게 해결하려 들지 말자.

Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.

아이템 11. equals를 재정의하려거든 hashCode도 재정의하라

해쉬코드필요한 이유?

Object 명세에서 발췌한 규약

equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. (애플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없음)

equals가 두 객체가 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.

equals가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode는 서로 다른 값을 반환할 필요는 없다.단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

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

이 코드에 map.get(new PhoneNumber(707,867, 5309))를 실행하면 "제니"가 아닌 null을 반환한다.

여기에는 2개의 PhoneNumber 인스턴스가 사용되었는데, 해시코드를 재정의하지 않아 논리적 동치인 두 객체(넣을 때 사용, 가져올 때 사용)가 서로 다른 해시코드를 반환하여 두 번째 규약을 지키지 못한다.

HashMap은 해시코드가 서로 다른 엔트리끼리는 동치성 비교를 시도조차 않도록 최적화 되어있다.

좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다.

  1. int 변수인 result를 선언한 후 값을 c로 초기화한다.
    • 이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
    • 여기서 핵심 필드는 equals 비교에 사용되는 필드를 말한다.
  2. 해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
    1. 해당 필드의 해시코드 c 를 계산한다.
      • 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본타입의 박싱 클래스다.
      • 참조 타입 필드면서, 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출한다. 계산이 복잡해질 것 같으면, 이 필드의 표준형을 만들어 그 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.
      • 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다.
      • 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    2. 단계 2.1에서 계산한 해시코드 c로 result를 갱신한다.
      • result = 31 * result + c;
  3. result를 반환한다.

파생 필드는 해시코드 계산에서 제외해도 된다. 또한 equals 비교에 사용되지 않은 필드는 반드시 제외해야 한다. (두 번째 규악을 어기게될 위험 있음)

2.b의 곱셈을 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다. 31로 정한 이유는 홀수이면서, 소수 이기 때문이다. 만약 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게 된다.

클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다 캐싱을 고려해야 한다.

성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.

아이템 12. toString을 항상 재정의하라

toString은 클래스이름@16진수로표시한_해시코드를 반환할 뿐이다. 일반 규약에 따라 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다.

toString의 규악은 모든 하위 클래스에서 이 메서드를 재정의하라 고 한다.

실전에서 toString은 그 객체가 가진 주요 정보를 모두 반환하는 게 좋다.

toString을 구현할 때면 반환값의 포멧을 문서화할 지 정해야 한다.

포멧 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.

정적 유틸리티 클래스는 toString을 제공할 필요가 없으며, 대부분의 열거타입도 자바가 이미 완벽한 toString을 제공하므로 따로 제정의하지 않아도 된다.

아이템 13. clone 재정의는 주의해서 진행하라

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스이다. 하지만 clone메서드는 Object에 선언이 되어 있고 심지어 protected 이다.

Cloneable 인터페이스는 Object의 clone의 동작 방식을 결정한다. Cloneable 을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

clone메서드가 super.clone이 아닌 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않을 것이다. 하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져 결국 하위 클래스의 clone메서드가 제대로 동작하지 않게 된다.

결국 clone 메서드를 내부에서 super.clone 메서드를 통해 재정의 해야한다.

가변 상태를 참조하지 않는 클래스용 clone 메서드

@Override
public PhoneNumber clone(){
    try{
        return (PhoneNumber) super.clone();
    }catch (CloneNotSupportedException e){
        throw new AssertionError();
    }
}

쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone 메서드를 제공하지 않는 것이 좋다.

super.clone 메서드를 사용하면 위와 같이 구현을 할 수 있다. PhoneNumber의 clone 메서드는 Object와 다르게 PhoneNumber를 반환하게 했다. 공변 반환 타이핑을 권장한다.

공변 변환 타이핑(Convariant Return Type)
재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입이 될 수 있다.

try-catch 블록으로 감싼 이유는 clone 메서드가 검사 예외인 CloneNotSupportedException을 던지도록 선언되었기 때문이다. super.clone은 무조건 성공하여 비검사 예외를 했어야 했다.

가변 상태를 참조하는 클래스용 clone 메서드

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_CAPACITY];
    }

    public void push(Object e){
        ensureCapacity();
        elements[size++] = 0;
    }

    public Object pop(){
        if(size == 0){
            throw new EmptyStackException();
        }
        return elements[--size];
    }

    private void ensureCapacity(){
        if(elements.length == size){
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }

}

clone 메서드는 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야한다.
가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다.

@Override
public Stack clone() {
    try{
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    }catch (CloneNotSupportedException e){
        throw new AssertionError();
    }
}

배열의 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다. 따라서 배열을 복제할 때는 배열의 clone메서드를 사용하라고 권장한다. 하지만 elements 필드가 final이었다면 final 필드는 새로운 값을 할당할 수 없기때문에 위 clone 메서드 방식은 작동하지 않는다.

Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다. 복사 생성자와 복사 팩터리를 위해 더 나은 객체 복사 방식을 제공할 수 있다.

복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.

복사 생성자와 그 변형인 복사 팩터리는 Cloneable/clone 방식보다 나은 면이 많다.

언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지도 않고, 형변환이 필요하지 않다.

아이템 14. Comparable을 구현할지 고려하라

Comparable 인터페이스의 유일무이한 메서드인 compareTo가 있다. compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다. Comparable을 구현한 클래스의 인스턴스에는 자연적인 순서가 있음을 뜻하며, Arrays.sort 메서드를 통해 손쉽게 정렬할 수 있다.

자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입이 Comparable을 구현하여 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

compareTo 규약

  • 첫 번째 객체가 두 번째 객체보다 작으면, 두 번째가 첫 번째보다 커야 한다.
  • 첫 번째가 두 번째보다 크고 두 번째가 세 번째보다 크면, 첫 번째는 세 번째보다 커야 한다.
  • 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.

equals와 마찬가지로 반사성, 대칭성, 추이성을 충족해야하며, 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가 했다면 compareTo 규약을 지킬 방법이 없다. 우회 방법으로 컴포지션을 사용한다.

compareTo 메서드 작성 요령

  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인스 타입은 컴파일타임에 정해진다.
  • null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다.
  • 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교한다.
  • 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Compator)를 대신 사용한다.
  • 자바 7부터는 기본 정수 타입을 비교할 때 관계 연산자 <와> 을 사용하지 않고 박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 사용하라.
  • 가장 핵심적인 필드부터 비교한다.
profile
개발계발

0개의 댓글