빨간색코딩

[이펙티브 자바3판] 3장 모든 객체의 공통 메서드 본문

Java

[이펙티브 자바3판] 3장 모든 객체의 공통 메서드

빨간색소년 2018. 11. 20. 14:02

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다.


책에 있는 내용을 기반으로 썼지만, 책에 없는 내용도 조금 적었다. (commons의 각종 빌더, lombok 등)


조슈아 블로크님이 구글에 다녀서 그러신가.. 구글 라이브러리(AutoValue 등)들을 책 전면에서 홍보하고 있는 느낌이 있다..ㅋㅋㅋㅋ 구글의 라이브러리들도 물론 좋지만, apache commons나 lombok 을 (국내에서는) 아마 더 많이 실무에 쓰고 계시지 않을까 추측해본다.


3장의 아이템 목록

  1. equals는 일반 규약을 지켜 재정의하라
  2. equals를 재정의하려거든 hashCode도 재정의하라
  3. toString을 항상 재정의하라
  4. clone 재정의는 주의해서 진행하라
  5. Comparable을 구현할지 고려하라

서문

Object에서 final이 아닌 메소드(equals, hashCode, toString, clone, finalize)는 모두 재정의를 염두에 두고 설계되었다. 이 메소드들을 잘못 구현하면 대상 클래스가 일반적인 규약을 준수한다고 가정하고 만들어진 클래스(HashMap, HashSet 등)에서 오동작이 일어날 수 있다.

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

equals 메소드를 재정의하지 않은 클래스의 인스턴스는 오직 자기 자신과만 같다. equals 메소드 재정의는 쉽지만 위험할 수 있어, 다음과 같은 상황이라면 재정의하지 않을 것을 권장한다.

10-1. 재정의하지 않을 상황

  1. 각 인스턴스가 본질적으로 고유하다. 주로 값(VO)을 표현하는게 아니라 동작하는 것을 표현하는 클래스가 여기에 해당된다.
  2. 인스턴스의 논리적 동치성을 검사할 일이 없다. 예를들어 regex.Pattern에서 eqauls을 재정의해서 정규표현식이 같은지 재정의하지는 않는다..
  3. 상위 클래스에서 재정의한 equals가 하위 클래스에서도 같은 상황이다.
  4. VO 여도 값이 같은 인스턴스가 2개이상 안 만들어진다는 보장이 있다면 재정의안해도 된다.
    • 예를들면 인스턴스 통제 클래스(싱글턴 등), Enum 등이 여기에 속한다. 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않는다.

10-2. 재정의해야 할 상황

equals 메소드를 재정의해야 할 때는 다음과 같다.

  1. equals 메소드는 '메모리주소를 기반으로 물리적으로 같은가?' 가 아니라 논리적 동치성(logical equality)를 비교해야할 때 재정의하면 좋다. 주로 값을 나타내는 Value Object 에 해당된다. 즉, 객체가 같은지가 중요한게 아니라, 객체 내 값이 같은지 비교해야할 때 재정의해야한다.
  2. Map의 키, Set의 원소 등으로 사용하려면 재정의해야한다.

equals 메소드를 재정의할 때는 동치관계를 구현해야 한다.

10-3. 동치관계란?

x, y, z는 null 이 아니라고 전제한다.

  • 반사성 : 객체는 자기 자신과 같다. x = x
  • 대칭성 : 두 객체는 서로에 대한 동치 여부에 똑같은 결과를 낸다. x = y, y = x
    public class CustomString {
        private String str;
        
        public CustomString(String str) {
            this.str = str;
        }
    
        public boolean equals(Object o) {
            if (o instanceof CustomString) {
                return str.equalsIgnoreCase(((CustomString) o).str); //equalsIgnoreCase은 대소문자 구별없이 문자열 equals 검사
            } else if (o instanceof String) {
                return str.equalsIgnoreCase((String) o);
            }
            return false;
        }
    }
    
    // 메인메소드
    CustomString REDBOY = new CustomString("REDBOY");
    String redboy = "redboy";
    
    System.out.println(REDBOY.equals(redboy)); // true
    System.out.println(redboy.equals(REDBOY)); // false
    
    • CustomString.equals(String)은 비교되도록 개발자가 짜두었지만, String.equals(CustomString)은 실패한다.
    • CustomString과 String을 연동하겠다는 것 자체가 잘못이다.
  • 추이성 : 첫번째 객체와 두번째 객체가 같고, 두번째 객체와 세번째 객체가 같으면, 첫번째 객체와 세번째 객체도 같다. x = y, y = z, x = z
    public class Point {
        private int x;
        private int y;
    
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    
        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 Color color;
    
        public ColorPoint(int x, int y, Color color) {
            super(x, y);
            this.color = color;
        }
    
        public boolean equals(Object o) {
            if (!(o instanceof Point)) {
                return false;
            } else if (!(o instanceof ColorPoint)) { // o가 일반 Point면 색상을 무시하고 비교한다.
                return o.equals(this);
            }
            // o가 ColorPoint면 색상까지 비교한다.
            return super.equals(o) && ((ColorPoint) o).color == color;
        }
    }
    
    // 메인메소드
    ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
    Point p2 = new Point(1, 2);
    ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
    System.out.println(p1.equals(p2)); // true
    System.out.println(p2.equals(p3)); // true
    System.out.println(p1.equals(p3)); // false
    
    • p1과 p2가 같고, p2와 p3가 같지만, p1과 p3는 다르므로 추이성을 위반했다.
    • 애초에 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약(동치 관계)을 만족시킬 방법은 존재하지 않는다. (상위클래스가 값을 갖지않는 추상클래스일 경우는 제외)
    • 방법은 없으나 상속대신 컴포지션으로 우회할 수는 있다.
      • ColorPoint가 Point를 상속받지 않고, private Point point;를 추가하는 것이다.
  • 일관성 : 두 객체가 같다면 앞으로도 영원히 같다. x = y 는 언제해도 x = y
  • null아님 : 모든 객체가 null과 같지 않아야한다. x.equals(null) 은 false
    • null 에 대한 명시적 검사보단 instanceof 가 묵시적으로 해주니까 이것만 쓰길 권장한다.

10-4. 좋은 equals 재정의

  1. == 연산자를 사용해 자기 자신의 참조인지 확인하라.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인하라. null검사도 해준다.
  3. 입력을 올바른 타입으로 형변환하라.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사하라.
  5. 성능을 위해 다를 가능성이 더 크거나, 비용이 싼 필드를 먼저 비교해라.

10-5. [실무권장] EqualsBuilder나 Lombok의 @EqualsAndHashCode 를 쓰자

  • EqualsBuilder Docs
  • EqualsBuilder를 사용해 속성들을 추가하여 append로 간편하게 만들 수 있다.
  • reflection을 사용하여 아주 예쁘게 만들수도 있다. 이 경우, 속성(멤버변수)가 추가되어도 equals를 수정할 일이 사라진다! 그러나 리플렉션을 사용해서 런타임이므로 성능적으로 떨어진다.
    public boolean equals(Object object) {
        return EqualsBuilder.reflectionEquals(this, object);
    }
    
  • @EqualsAndHashCode Docs
  • lombok 의 @EqualsAndHashCode를 이용해 equals 메소드를 재정의하는 방법이다. 어노테이션만 붙이면 되니 깔끔하다. 위에서 EqualsBuilder.reflectionEquals 에 비해, 컴파일타임에 코드가 생성되므로, 비교적 성능상 우위에 있다. (생성되는 코드는 문서의 Vanilla Java 참조)

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

아래는 Object 명세에서 발췌한 hashCode 관련 규약이다.

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

따라서 equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.

11-1. 좋은 hashCode 작성 방법

  • 이미 정의된 hashCode(Integer.hashCode, Arrays.hashCode 등)를 최대한 사용해라
  • 31을 곱해라. 홀수이면서 소수이기 때문이다. (구체적인 이야기는 책을..)
  • equals 비교에 사용되지 않은 필드는 hashCode 에서도 반드시 제외해야한다.
  • 해시 충돌이 더욱 적은 방법을 써야한다면 Guava Hashing을 참고하자
  • (주의) hashCode 반환 값의 생성 규칙을 사용자에게 알리지 말아라
    • 사용하는 개발자가 이 값에 의지하고 코드에 반영해버리면 문제가 생길 수 있다.

11-2. [실무권장] HashCodeBuilder나 @EqualsAndHashCode 를 쓰자

  • HashCodeBuilder Docs
  • 위의 EqualsBuilder와 같다.
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this);
    }
    
  • Lombok은 재정의할거면 같이하라는 뜻에서 @Equals와 @HashCode가 아닌 @EqualsAndHashCode로 정한 듯 하다.

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

toString은 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다. 이를 위해 모든 하위 클래스에서 toString을 재정의해야한다. toString을 잘 구현한 클래스는 디버깅이 쉬워지기도 한다.

12-1. 좋은 toString 작성 방법

  • toString은 그 객체가 가진 주요 정보 모두를 반환하는 게 좋다. (스스로를 완벽히 설명하는 문자열)
  • 주석으로 반환하는 포맷을 명시하든 아니든 의도를 명확히 밝혀야 한다.
  • toString 외에 객체의 값들을 얻어올 수 있는 API를 제공하자.
    • 만약 이게없다면, 사용하는 개발자쪽에서는 toString을 호출해서 파싱해서 필요한 정보를 얻을 것이다..

12-2. toString을 재정의하지 않아도 되는 경우

  1. 대부분의 유틸리티 클래스
  2. 대부분의 enum 타입
  3. 상위 클래스에서 이미 알맞게 재정의한 경우

12-3. [실무권장] ToStringBuilder나 Lombok의 @ToString을 쓰자

  • ToStringBuilder Docs
  • 위의 EqualsBuilder, HashCodeBuilder와 같다.
    public String toString() {
        return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
    }
    
    • 2번째 인자에서 ToStringStyle를 정할 수 있다.
      ToStringStyle.DEFAULT_STYLE
      ToStringStyle.MULTI_LINE_STYLE
      ToStringStyle.NO_FIELD_NAMES_STYLE
      ToStringStyle.SHORT_PREFIX_STYLE
      ToStringStyle.SIMPLE_STYLE
      ToStringStyle.NO_CLASS_NAME_STYLE
      ToStringStyle.JSON_STYLE
      
  • @ToString Docs
  • lombok 의 @ToString를 이용해 toString 메소드를 재정의하는 방법이다. 위의 @EqualsAndHashCode와 동일하다. 그러나 ToStringBuilder와 다르게 출력 포맷을 커스터마이징할 수 없다. 생성되는 toString 코드는 문서의 Vanilla Java 를 참고하자.

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

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 인터페이스이다. clone 메소드는 Cloneable에 정의된 것이 아니라 Object에 정의되었고 protected이다. Cloneable 인터페이스는 메소드 하나 없지만, clone의 동작 방식을 결정한다.

Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

13-1. clone의 명세는 허술하다.

clone의 명세는 허술하며 제대로 만드냐 안만드냐의 책임이 개발자에게 있다. (시스템적으로 구조화X)

예를들어 상위클래스에서 super.clone이 아니라 생성자를 호출해 반환해도 컴파일러는 정상으로 판단한다.

public class FirstRedboy implements Cloneable {
	@Override
	protected FirstRedboy clone() {
		return new FirstRedboy();
	}
}

public class SecondRedboy extends FirstRedboy {
	@Override
	protected FirstRedboy clone() {
		return super.clone();
	}
}

// 메인메소드
SecondRedboy second = new SecondRedboy();
System.out.println(second.clone() instanceof FirstRedboy); // true
System.out.println(second.clone() instanceof SecondRedboy); // false

super.clone 을 사용하면 아래와 같이 할 수 있다. 하위클래스에선 또 형변환이 필요하다.

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

super.clone()은 checked exception인 CloneNotSupportedException을 던지기 때문에 무조건 try-catch로 감싸야한다. 이 경우 100% 성공하지만 catch문이 필요한 것이다. (애초에 clone 설계가 잘못됨)

13-2. clone 재정의 시 참고사항

  1. clone 메소드는 사실상 생성자와 같은 효과를 낸다. 즉 clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.
  2. Cloneable을 구현하는 클래스는 clone을 재정의해준다. 이 때 접근제어자를 public으로 바꾸고, 반환형도 쓰기 편하게 자기 타입으로 바꾸고, throws 절도 없애는 것을 추천한다.
  3. 상속용 클래스는 implements Cloneable하면 안된다. 이것은 하위클래스에서 정하게 해야한다.

13-3. clone 방식보다 복사 생성자와 복사 팩토리를 사용해라

복사 생성자와 복사 팩토리에서는 자기 타입을 인수로 받아 복사하는 방식을 취한다.

public Redboy(Redboy redboy) { ... }
public static Redboy newInstance(Redboy redboy) { ... }

이것은 clone 방식의 단점이었던 언어모순적, 위험, 허술한 스펙, final용법과 충돌, 불필요한 checked exception, 형변환 등이 모두 없다.

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

Comparable 인터페이스의 메소드는 compareTo 뿐이다. 클래스가 Comparable을 구현한다면 클래스의 인스턴스 간에는 자연적인 순서가 있음을 뜻한다. equals와 비슷하지만 동치성 비교에 더하여 순서까지 비교할 수 있으며, 제네릭하다. 알파벳, 숫자, 연대 같이 순서가 명확한 VO 클래스를 작성한다면 반드시 Comparable을 구현하자.

14-1. compareTo의 규약

  1. 이 객체와 주어진 객체의 순서를 비교한다.
  2. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
  3. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
  4. 두 객체 참조의 순서를 바꿔서 비교해도 예상한 결과가 나와야 한다.
  5. 첫번째 객체가 두번째 객체보다 크고, 두번째 객체가 3번째 객체가 크면, 첫번째 객체는 세번째 객체보다 커야한다.
  6. 크기가 같은 객체들 끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.
  7. (권장) compareTo의 동치성 결과가 equals와 같아야 한다.

equals와 마찬가지로 반사성, 대칭성, 추이성을 충족해야하며, 주의사항도 같다. 기존 클래스를 확장한 구체 클래스에서 새로운 필드와 값이 추가되었다면 compareTo 규약을 지킬 방법이 없다. 우회 역시 '상속대신 컴포지션'으로 같다.

14-2. compareTo 작성요령

equals와 비슷하다. 다른점은 아래와 같다.

  1. 제네릭이므로 인자타입을 확인하거나 형변환할 필요가 없다.
  2. compareTo 메서드는 각 필드가 동치인지를 비교하는 게 아니라 순서를 비교한다.
  3. 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
  4. Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다.
    • Comparable은 기본 정렬기준을 제시해주어서 컬렉션의 sort() 등에 활용될 수 있게 한다.
    • Comparator 는 기본 정렬기준 외에 다른 정렬기준으로 정렬할 때 사용할 수 있다.
  5. 기본 정수타입을 비교할 때 관계연산자(>, < 등)보다 정적메소드 compare을 사용하라 (jdk7부터 추가)
  6. 핵심적인 필드부터 비교해라. 비교결과가 바로나온다면 곧장 return 하자.

14-3. Comparator

이전부터 Comparator는 있었으나, jdk8에 들어서 많은 비교자 생성 메소드들로 중무장하게 되었다. 대신 약간의 성능저하가 있다.

https://docs.oracle.com/javase/8/docs/api/java/util/Comparator.html

private static final Comparator<PhoneNumber> COMPARATOR =
        comparingInt((PhoneNumber pn) -> pn.areaCode)
                .thenComparingInt(pn -> pn.prefix)
                .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}

14-4. [실무권장] CompareToBuilder를 쓰자

  • CompareToBuilder Docs
  • 위의 Builder들과 같다. reflectionCompare 를 활용하면 우아하게 짤 수 있다. 다만 당연히 멤버변수들은 모두 Comparable 를 구현해야만 한다. (문서 중에 must either be an array or implement Comparable 부분)


Comments