programming/Effective Java

[Effective Java] Chapter 3

AGAPE1225 2025. 1. 25. 21:41
반응형

Chapter 3 모든 객체의 공통 메서드

 Object에서 final이 아닌 메서드는 모두 재정의를 염두에 두고 설계된 것이라 재정의 시 지켜야하는 일반 규약이 명확히 정의되어있다. 메서드를 잘못 구현하면 대상 클래스가 이 규약을 준수한다고 가정하는 클래스를 오동작하게 만들 수 있다.

Item 10 equals는 일반 규약을 지켜 재정의하라

 아래의 상황에서는 재정의를 최대한 피하도록하자!


  • 각 인스턴스가 본질적으로 고유하다.
    • 값을 표현하는 게 아니라 동적하는 개체를 표현하는 클래스가 여기 해당한다.
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
    • 논리적 동치성을 원하지 않다면 재정의하지 않아도 된다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
    • 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰는 것처럼 이런 경우에는 재정의하지 않아도 된다.
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

 equals는 논리적 동치성을 비교해야할 때 재정의 해야한다.

 

 아래의 내용은 Object 명세에 적힌 equals 규약이다.


  • 반사성
  • 대칭성
  • 추이성
  • 일관성
  • null-아님

- 대칭성

 

 대칭성은 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 아래는 잘못된 예시이다.

 

public class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s){
        this.s = Objects.requireNonNull(s);
    }
    
    @Override
    public boolean equals(Objects 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 객체를 전혀 모른다는 것이다.


- 추이성

 

 추이성은 첫 번째 개체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야한다는 것이다.

 

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(Objects 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;
    }
    
    @Override public boolean equals(Object o){
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

 

 이렇게 되면 Point에서는 ColorPoint를 생각하지 않고 equals 메서드를 시행하기에 문제가 발생한다.

 

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

 

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

    public ColorPoint(int x, int y, Color color){
        this.point = new Point(x, y);
        this.color = Objects.requireNonNull(color);
    }
    
    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);
    }
}

 

 자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종있다. java.sql.Timestamp는 java.util.Data 를 확장한 후 nanoseconds 필드를 추가했다. 그 결과로 Timestamp의 equals는 대칭성을 위해아며, Date객체와 한 컬랙션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있다.

 

 진짜인가 싶어서 찍어본 Timestamp 객체의 원형



- 일관성

 

 일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야한다.

 

 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다.

 

- equals 메서드 구현 방법 정리


1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.

2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.

3. 입력을 올바른 타입으로 형변환한다.

4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.


 다음은 equals 메서드의 예시이다.

 

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    public PhoneNumber(short areaCode, short prefix, short lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

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

 

- equals를 재정의할 때의 주의사항


  • equals를 재정의할 땐 hashCode도 반드시 재정의하다
  • 너무 복잡하게 해결하려 들지 말자.
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.

item 11 equals를 재정의하려거든 hashCode도 재정의하라

 equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet같은 컬랙션의 원소로 사용할 때 문제를 일으킬 것이다.

 

- Object 명세에서 발췌한 규약


  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
  • equals가 두 객체를 같다고 판단햇다면, 두 객체의 hashCode는 똑같은 값을 반환해야한다.
  • equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

 hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 두 번째다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

 

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

 

 위의 코드는 NULL을 반환한다. PhoneNumber 클래스는 hashCode를 재정의하지 않았기 때문에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환한다. 결과적으로 get 메서드는 엉뚱한 해시 버킷에 가서 객체를 찾으려 한 것이다. 두 인스턴스가 같은 버킷에 담겼더라도, HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어있기 때문에 여전히 NULL을 반환한다.

 

 좋은 hashCode를 작성하는 요령


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

 아래는 위의 요령으로 작성한 예시이다.

 

    @Override
    public int hasCode() {
        int result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        
        return result;
    }

 

 클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야한다. 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야한다.

item 12 toString을 항상 재정의하라.

 toString을 재정의 할 때는 포맷을 명시하든 아니든 의도를 명확하게 밝혀야한다. 다음은 의도의 예시이다.

 

    /*
    * 이 전화번호의 문자열 표현을 반환한다.
    * 이 분자열은 "XXX-YYY-ZZZZ" 형태의 12글자로 구성된다.
    * XXX는 지역 코드, YYY는 프리픽스, ZZZZ는 가입자 번호다.
    * 각각의 대문자는 10진수 숫자 하나를 나타낸다.
    * */
    @Override
    public String toString() {

    }

 

 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자. 위의 객체의 경우 지역 코드, 프리픽스, 가입자 번호용 접근자를 제공하는 제공자를 개발해야한다.

item 13 clone 재정의는 주의해서 진행하라.

 Clone 메서드는 Cloneable이 아닌 Object이고, 그마저도 protected라는 데 있다. 그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다. 이런 문제점에도 불구하고 Cloneable 방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다.

 

 Cloneable 인터페이스는 Object의 protected메서드인 clone의 동작 방식을 결정한다.

 

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

 

 위의 메서드가 동작하게 하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현한다고 추가해야한다. Object의 clone 메서드는 Object를 반환하지만 PhoneNumber의 clone메서드는 PhoneNumber를 반환하게 했다. 자바가 공변반환타이핑을 지원하니 가능한 문법이다.

 

 clone 메서드는 사실상 생성자와 같은 효과를 낸다. 즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야한다.

 

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

 

 Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다. 그렇지 않은 상황에서는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다. 다음 예를 보자.

 

public Yum(Yum yum){ ... };

 

 복사 팩터리는 복사 생성자를 모방한 정적 팩터리다.

 

public static Yum newInstance(Yum yum){ ... };

item 13 Comparable을 구현할지 고려하라.

 설명이 너무 장황해서 그냥 공식 레퍼런스를 먼저 읽어봤다.


https://docs.oracle.com/javase/8/docs/api/java/lang/Comparable.html

 

Comparable (Java Platform SE 8 )

This interface imposes a total ordering on the objects of each class that implements it. This ordering is referred to as the class's natural ordering, and the class's compareTo method is referred to as its natural comparison method. Lists (and arrays) of o

docs.oracle.com


compareTo 메서드에서 관계 연산자 < 와 >를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니, 이제는 추천하지 않는다.

 

    @Override
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }

 

 클래스에서 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요해진다.

 

private static final Comparator<PhoneNumber> COMPARATOR = 
            Comparator.comparingInt((PhoneNumber pn) -> pn.areaCode)
                    .thenComparingInt(pn -> pn.prefix)
                    .thenComparingInt(pn -> on.lineNum);
    
    public int compareTo(PhoneNumber pn){
        return COMPARATOR.compare(this, pn);
    }

 

위의 코드는 간편하지만, 10%정도의 성능저하가 있다.

 

해시코드는 값의 차를 기준으로 비교하면 안된다. 아래는 해시 코드의 compareTo를 구현한 예이다.

 

static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
반응형

'programming > Effective Java' 카테고리의 다른 글

[Effective Java] Chapter 4  (2) 2025.02.01
[Effective Java] Chapter 2  (3) 2025.01.18