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

flaxinger·2022년 2월 5일
0

이펙티브자바

목록 보기
3/3
post-thumbnail

본 글은 이펙티브 자바 3판을 기반으로 쓰여졌으며, 정보 공유보다는 개인적인 노트/생각 정리를 위해 작성되었습니다. 책이 워낙 어려워 종종 잘못 이해한 부분도 있을 터이니 독자의 이해를 부탁드립니다.

목차


  1. equals에 대하여
  2. hashCode에 대하여
  3. toString에 대하여
  4. clone에 대하여
  5. Comparable에 대하여



서론


해당 장에는 equals, hashCode, toString, clone, Comaparable 등이 다루어진다. 이 글을 작성하기 전에 equals, hashCode, toString에 대한 부연 설명을 잠깐 하고 싶다. 내가 equals, hashCode, toString을 무조건 직접 작성해야겠다라는 사람이면 편할대로 하면 되지만, 현실적으로 이에 대한 개념(왜 필요한지, 주의사항이 뭔지)만 잘 안다면 사실 직접 작성할 일은 거의 없다. 구글의 AutoValue이나 Lombok을 사용하면 어노테이션으로 이를 자동생성할 수 있기 때문이다. 따라서 구체적인 구현 방법은 간략하게만 설명하고 각 기능이 왜 필요한지에 대해 조금 더 자세히 다루겠다.
더불어 equals, hashcode는 이전 글에도 다룬적이 있어 기본 개념은 생략하겠다.

10. equals에 대하여


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

equals는 어디에 필요한걸까? equals는 두개 인스턴스의 논리적 동치성을 확인하기 위해 사용된다. 하지만 이 때 유의해야하는 것이 있으니 equals는 직접 사용하지 않아도 다른 클래스 함수에서도 사용한다는 것이다. 대표적으로 Collection의 contains 함수가 있다. contains 함수는 Collection이 인수 Object o에 대해 (o==null ? e==null : o.equals(e)) 조건이 참인 원소 e를 담고 있을 때 true를 반환한다고 한다. 요점은 클래스를 만들 때 당장은 equals를 사용하지 않아서 구현하지 않았지만 향후 누군가가 이를 Collection에 담아서 사용하게 되면 에러가 발생할 위험이 있다는 것이다. 위에 언급된 라이브러리를 사용하면 어노테이션 하나로 이를 추가할 수 있으므로 가급적 잊지 말자. 다만 직접 구현 시 잘못 구현하면 더 큰 문제가 발생하므로 아래 상황에서는 굳이 구현하지 않는다.

  • 각 인스턴스가 본질적으로 고유하다(eg. Thread)
  • 인스턴스의 논리적 동치성을 검사할 일이 없다.(개발자가 판단)
  • 상위 클래스의 equals가 하위 클래스와 동일하게 동작해도 된다.(eg. AbstractSet, Set)
  • private, 혹은 default class이다.

어노테이션으로 생성된 equals는 다음 조건을 만족한다. 만약 직접 equals 함수를 만들기로 했다면 마찬가지로 반드시 아래 조건을 만족시켜야 한다.

  • 반사성(Reflexivity) x!=null일 때 x.equals(x)는 true다.
  • 대칭성(Symmetry) x!=null, y!=null일 때 ((x.equals(y)==true) == (y.equals(x)==true))는 참이다.
  • 추이성(Transitivity) x!=null, y!=null, z!=null일 때 (x.equals(y)==true), (y.equals(z)==true)라면 x.equals(z)==true는 참이다.
  • 일관성(consistency) x!=null, y!=null일 때 x.equals(y)는 언제나 동일한 값을 반환한다.
  • Non-null x!=null일 때 x.equals(null)은 언제나 false다.

책에 나오는 위 조건에 대한 부연 설명은 대부분 상식 선에 있고 라이브러리로 자동 생성되기 때문에 특이한 점을 하나만 추가적으로 다루겠다. 바로 상속을 할 때 equals의 사용법이다. 언급된 조건을 언듯 보면 상당히 단순한듯 하나 상속을 하게 된다면 equals를 올바르게 구현할 방법이 없다. 정석 equals 로직은 다음과 같다.

  public boolean equals(Object o) {
  
  	// 동일 인스턴스라면 true 반환
    if (o == this) {
      return true;
    }
    
    // 동일 클래스라면 비교
    if (o instanceof Node) {
      // 상속 클래스라면 타입 변환
      Node that = (Node) o;
      //동치성 확인
      return (this.num == that.getNum());
    }
    
    // 동일 클래스가 아니므로 false 반환
    return false;
  }

이 로직은 상속 클래스일 때도 참을 반환할 수 있다. 여기서 대칭성, 추이성 문제가 발생한다. 이는 부모 클래스가 자식 클래스와 비교할 때는 참이 반환될 수 있으나, 자식 클래스가 부모와 비교할 때는 언제나 false이기 때문이다. 자식 클래스에서 부모를 비교할 때 다른 로직을 수행하도록 하면 대칭성이 해결되나, 이러면 추이성이 깨지게 된다. 그럼 비교를 안하면 되는거 아닌가라고 생각할 수 있지만 그렇게 되면 SOLID의 L(리스코프 치환 법칙)이 깨지게 된다. 예로 부모 클래스의 특정 메서드가 equals 혹은 contains를 활용한다면 자식 클래스에서 해당 함수를 쓰지 못할 것이다.
결론적으로 OOP와 equals는 자연스러운 융합이 불가하다. AutoValue 문서에도 이러한 한계로 상속을 지원/권장하지 않음을 명시한다(링크). 이를 해결하기 위해 한가지 우회 방법이 존재하는데, 직접적으로 상속을 하는 대신 자식 클래스에 부모 클래스를 멤버 변수로 저장하고 비교 시 이를 통해 비교하는 것이다. 예시는 아래 주소에 작성하였다.

코드 예시

상속 시 비교
AutoValue 예시

11. hashCode에 대하여


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

HashCode는 HashMap, HashSet 등을 사용할 때 필요한 함수이다. 주의할 점은 논리적 동치인 두 클래스에 대해 동일한 해시값이 계산되어야한다는 것이다(따라서 equals를 재정의한다면 무조건 hashCode를 재정의해야한다). 더불어 최대한 해시 충돌이 발생하지 않도록 해야 한다.
해시코드를 계산하는 방법은 책에 장황하게 나와 있으나, 결론적으로 인스턴스의 모든 값에 대해 hashCode() 함수를 재귀적으로 호출하는 것이다(기본 타입, 참조 타입의 해시코드 호출). 배열이 있는 경우에는 배열의 모든 원소에 대해 계산을 해준다. 단 파생 필드, 즉 다른 필드로 유추 가능하거나 equals에 사용되지 않은 필드는 생략이 가능하다.
만약 필드가 굉장히 많아 연산량이 많다면 성능을 위해 별도 필드에 캐싱하는 전략을 택해야한다.

롬복의 @EqualsAndHashCode를 애용하자.

12. toString에 대하여


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

해당 장은 길지 않으므로 요점만 간략히 적겠다. 디버깅, 로깅을 할 때 toString이 있으면 클래스를 분간하기 쉽다. 구현하지 않을 시 클래스명@16진수 HashCode가 반환되므로 큰 의미 없는 정보만 반환된다. Lombok @ToString, AutoValue의 @AutoValue가 자동 생성해주고, 필드 변경 시 따로 수정해주지 않아도 되므로 직접 작성보다는 이런 라이브러리를 애용하자.

13. cloneable/clone에 대하여


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

가끔 특정 객체를 복제해야하는 경우가 있는데, 자바 개발자라면 다들 Cloneable 인터페이스를 구현, 확장한 경험이 있을 것이다라고 생각한다. Cloneable은 Object 클래스의 protected clone() 함수를 사용하기 위해 사용되는데(clone()은 Object의 함수이지만 Cloneable을 구현하지 않으면 에러가 발생한다), 결론부터 말하자면 이 clone()을 상속하는 것보다 복사 생성자 혹은 복사 팩터리를 사용하는 것이 좋은 방식이다. Cloneable/clone()이 잘못 구현되었다기 보다는 clone() 함수의 상속 자체가 OOP와 잘 융합되지 않기 때문이다. 아주 간단한 일부 객체 혹은 기본형(primitive) 타입만 있는 클래스의 경우 무지성 clone()을 사용해도 되기는 하지만 굉장히 한정적이기에 clone()은 Cloneable을 이미 상속한 클래스를 상속하지 않은 이상 그냥 없다 치는게 편하다(확장했다면 필수적으로 clone()을 정의해야 한다).

우선 clone() 함수가 무엇인지 잠깐 보자. Object 클래스에 정의된 clone()은 클래스의 shallow copy를 리턴한다. Object 명세에 clone은 다음의 조건을 따르는 것이 좋다고 설명되어 있다(필수가 아니다).

  • x.clone() != x -> 새로운 인스턴스를 반환한다
  • x.clone().getClass() == x.getClass() -> 동일 클래스를 반환한다.
  • x.clone().equals(x) -> 논리적 동치성을 보장한다.

위의 첫 조건은 clone()이 하나의 생성자와 다름없다는 것을 의미한다. 더불어 clone()은 Object를 리턴하도록 되어 있는데, 자바가 공변 반환 타이핑(Covariant Return Typing), 즉 상속 클래스에서 메소드를 override할 때 하위 객체를 반환하는 기능을 제공하므로 2번 조건을 만족할 수 있다. 다만 상속하여 구현할 시 명시된 모든 조건은 필수가 아니라고 설명되어 있다. 비슷한 개념으로는 아 몰라 알아서 해가 있다
그럼 clone()함수에 어떤 문제가 있다는 것일까? clone()의 문제점을 하나씩 다루어보자.

  • clone은 애초에 망가진 기능이다
    적어도 조슈아 블로크형님은 이렇게 생각한다. 2002년의 인터뷰지만 책에 나오는 내용과 크게 다르지 않으므로 다음 링크를 참조할 수 있다. 블로크는 인터뷰 중 다음과 같은 말을 한다.

    If you've read the item about cloning in my book, especially if you read between the lines, you will know that I think clone is deeply broken.

    요점은 clone은 망가진 함수라는 것이다. 이에 대해 언급하는 이유는 다음과 같다.

        1. 대부분 경우 연쇄적인 clone()만으로 원하는 결과를 내지 못한다.
           clone()은 대부분 클래스에 대해 사용자가 원하는 새로운 인스턴스를 반환하지 못한다.
        2. 위의 이유로 문서가 모호하다.
           명백한 규약과 규칙이 없으므로 아무것도 보장되지 않는다.
        3. 내부적으로 아무 함수도 없는 Cloneable 인터페이스을 상속해야한다.
           명백히 틀린 구현 방식을 유지하고 있다.
        4. 전통적인 인스턴스 생성 방식을 사용하지 않는다.
           하지만 실질적인 기능은 하나의 생성자와 다름 없다.

    인터뷰 내용을 확인하면 쉽게 알 수 있지만 블로크는 clone에 대해 굉장히 부정적이다. 실제로 자바 커뮤니티에 이에 대한 찬반 논쟁이 있는지 모르지만 그의 책과 말을 보면 clone이 불안정한 디자인을 채택했다는 사실은 부정하기 힘들다. 다만 clone에 우호적인 사람도 존재하므로 해당 주장은 블로크의 권장 사항임을 알리고 싶다.

  • 가변 객체는 super.clone()으로 복제되지 않는다.
    List, Map, Set 등의 객체를 가변객체라고 하는데, super.clone() 함수는 가변 객체를 복제하지 못한다. 예로 배열 int[] array가 있는 클래스가 있다고 하자. 이 때 클래스를 clone() 함수로 복제하면 새로 만들어진 클래스는 새로운 배열 인스턴스 array를 가져야하지만 기존의 배열을 그대로 참조하고 있다. 해당 배열을 담고 있는 변수의 주소만 바뀌었을 뿐 둘 다 동일 배열을 참조하고 있는 것이다. 다행히 자바 배열의 경우 clone() 함수가 잘 정의 된 몇 안되는 클래스이기에 array.clone()을 하면 되지만, 그렇지 않은 경우는 직접 복사를 해주어야한다.
    까다로운 예로 linked list를 Node라는 inner class로 구현하여 사용하는 가상의 클래스를 생각해보자. 당연히 head만 clone()하는 것으로는 정상적인 clone이 힘들다. 근데 Node를 순회하면서 Node.clone()을 실행하는 것도 문제가 발생한다. 각 Node가 참조하는 next Node 참조값을 그대로 복사하면 두 객체가 요상하게 엮이기 때문이다. 따라서 각 Node를 순회하며 각 노드별로 새로운 객체를 생성해주고 각 노드의 next가 생성된 새로운 Node를 참조하도록 해야한다. 하지만 이쯤되면 clone()을 상속하는 이점이 완전히 사라지게 된다.

  • Final 변수가 있으면 에러가 발생한다.
    너무 깊게 다루지 않겠지만 clone()을 상속받아 override 하기 위해서는 super.clone()을 필수적으로 사용해야한다. 위에 설명한 배열 복사는 이로 생성된 객체 인스턴스의 멤버를 바꿔주는 형식이다. 하지만 final 가변변수가 있다면? clone() 함수는 생성자와 다름 없으므로 final 멤버 변수에 array.clone()을 할당하면 문제가 발생한다. final을 없애면 되는 것 아닌가라는 생각이 들 수 있지만 그리 단순하지는 않다. 가변 객체를 참조하는 필드는 final로 선언하는 것이 좋기 때문이다. 결론적으로 clone()은 final 멤버 변수와 공존하기 힘들고 따라서 final을 제거해야하는데, 이는 경우에 따라 치명적인 제약사항이 될 수 있다.

  • 스레드 안전성
    clone() 함수는 동기화를 신경쓰지 않는다. 따라서 clone()을 재정의할 때는 이를 신경써주어야 한다.

  • 하위 클래스가 모두 clone()을 사용해야만 한다
    Clonable을 구현한 하위 클래스는 거의 필수적으로 clone()을 구현해야한다. 위에서 설명하였듯 clone()을 재정의할 때는 부모의 clone()을 부르게 되는데, 이 때 상위 클래스의 clone()이 잘못 구현되어 있다면 에러가 발생하기 때문이다. 따라서 이는 하위 클래스에 때로는 불필요한 의무를 지워주게 된다.

이렇듯 clone() 함수를 사용하면 안되는 이유를 확인하였으니 복사 생성자 혹은 복사 팩터리를 사용하는 법을 간단히 살펴보자. 책의 예는 다음과 같다.

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

// OR

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

이렇듯 인스턴스를 받아서 복사하는 방법에는 아래의 장점이 있다.

  • 전통적인 생성자를 사용하여 인스턴스를 생성한다.
  • Cloneable/clone의 불완전한 규약과 디자인을 상속하지 않아도 된다.
  • final 필드 사용이 가능하다.
  • 인수를 받을 수 있다.

개인적인 생각이지만, 블로크가 권장한 방식의 가장 큰 장점은 final 필드를 사용할 수 있다는 점이라고 생각한다. 이 외 언급된 장점은 디자인 철학 측면의 장점일 뿐, 내부의 복잡한 객체( (eg. 내부 Linked List) 복사를 위한 로직이나 스레드 안전성을 보장하기 위해 필요한 비용은 사실 상 생략할 수 없다. 따라서 필요에 따라 Cloneable/clone을 사용하는 것도 나쁘지만은 않으리라 생각한다. 떽 조슈아 블로크 선생님이 그렇다면 그런거다

14. Comparable에 대하여


Item 14. Comparable을 구현할지 고려하라

equals와 마찬가지로 Compareable/CompareTo는 대부분 Collection 인터페이스를 사용기 위해 구현한다. 단순 sort() 함수에도 필요한 것이지만 TreeSet, TreeMap 등에도 기본적으로 사용된다.
구현 로직은 클래스마다 다르므로 개발자가 알아서 우선순위를 정해 비교해야하며 동일하다면 0, 아니면 1, -1을 반환한다. 단, equals와 마찬가지로 반사성, 대칭성, 추이성을 지켜야한다.
주의할 사항으로는 가끔 '<', '>', '-' 를 활용하여 비교를 하는 경우가 있는데 이 방식은 절대 사용하지 않는 것이 좋다. 기존에 primitive type에 대해서 compare 함수가 없어 <, >를 사용했으나, 이제는 박싱된 기본 타입 클래스에 compare로 이를 지원하기 때문이다. 더불어 compare는 int를 반환하는데, '-'를 사용하면 Integer Overflow가 발생할 수 있다.
추가로 if문을 여러개 사용하기 보다 Comparater 클래스의 정적 함수를 사용하여 정적 Comparator를 생성 후 이를 사용하는 것이 보다 깔끔하다. 아래는 책에 나온 예시다. 첫 비교에 참조 타입을 비교한다면 comparing() 함수를 사용하여 람다를 넘길 수 있다. 자세한 내용은 자바 문서를 확인하자.

private static final Comparator<PhonenUmber> COMPARATOR = 
		comparingInt((PhoneNumber pn) -> pn.areaCode)
        	.thenComparingInt(pn -> pn.prefix)
            .thenComparingInt(pn -> pn.lineNum);
            
private int compareTo(PhoneNumber pn) {
	return COMPARATOR.compare(this, pn);
}
profile
부족해도 부지런히

0개의 댓글