-
3장 - 모든 객체의 공통 메서드📕 book/이펙티브 자바 2022. 10. 26. 21:19
💡 equals는 일반 규약을 지켜 재정의하라
꼭 필요한 경우가 아니면 equals는 재정의 하지 않은 것이 좋다.
equals를 재정의하지 않아도 되는 경우
각 인스턴스가 본질적으로 고유
- 값을 표현하는 것이 아닌 동작하는 객체를 표현하는 클래스 - ex) Thread
- 값 클래스라도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 재정의 하지 않아도 된다.
인스턴스 논리적 동치성을 검사할 일이 없을 때
상위 클래스에서 재정의한 equals가 하위 클래스에도 들어 맞을 때
- ex) 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 사용
- ex) List 구현체들은 AbstractList, Map 구현체들은 AbstractMap으로부터 상속받아 사용
클래스가 private 이거나 package-private 이고 equals 메서드를 호출할 일이 없을 때
- equals가 실수로라도 호출되는 것을 막고 싶다면 아래처럼 구현
@Override public boolean equals(Object o) { throw new AssertionError(); //호출 금지! }
equals를 재정의해야 할 때
객체 식별성 이 아닌 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때
객체 식별성
- 두 객체가 물리적으로 같은가
→ 주로 값 클래스들( 프로그래머는 객체가 같은지가 아닌 값이 같은지를 알고 싶어 한다 )
equals가 논리적 동치성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교하길 원하는 기대에 부응하는 것은 물론이고 Map의 키와 Set의 원소 로도 사용할 수 있다.
❗equals 메서드를 재정의할 때 따라야하는 일반 규약 - Object 명세에 적힌 규약
💡 반사성 : null이 아닌 모든 참조 값 x에 대해, x.equals(x) 는 true 이다.
- 객체는 자기 자신과 같아야 한다는 뜻
💡 대칭성 : null이 아닌 모든 참조값 x, y에 대해, x.equals(y)가 true이면 y.equals(x)도 true이다.
- 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
public final class CaseInsensitiveString { private final String s; public CaseInsensitiveString(String s) { this.s = Objects.requireNonNull(s); } @Override public boolean equals(Object o) { if (o instanceof CaseInsensitiveString) return s.equalsIgnoreCase( ((CaseInsensitiveString) o).s ); //대소문자 구분x 비교 if (o instanceof String) return s.equalsIgnoreCase( (String) o); } }
CaseInsensitiveString 클래스가 CaseInsensitiveString 뿐 아니라 String 과도 비교를 시도
CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); String s = "polish";
위 상황에서 cis.equals(s) 는 true
하지만, s.equals(cis)는 false를 반환한다.
(String 은 CaseInsensitiveString 의 존재를 모르기 때문)
⇒ 대칭성을 위반
💡 추이성 : null이 아닌 모든 참조값 x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)도 true이면 x.equals(z)도 true이다.
- 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면
- 첫 번째 객체와 세 번째 객체도 같아야 한다.
이 조건도 자칫하면 어기기 쉽다.
public class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } } public class ColorPoint extends Point { private final Color color; @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; } }
위 방식은 대칭성은 지켜주지만, 추이성을 깨버린다.
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
❗ 해법
- 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
getClass() 를 활용하는 방법은 리스코프 치환 원칙을 위배한다.
@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; }
리스코프 치환 원칙
- 어떤 타입에 있어 중요한 속성이면 그 하위 타입에서도 중요하다.
- 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다.
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 존재
“상속 대신 컴포지션을 사용”
- Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고
- ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public으로 추가
💡 일관성 : null이 아닌 모든 참조값 x, y에 대해, x.equals(y)를 반복 호출하면 항상 true 혹은 항상 false를 반환한다.
- 두 객체가 같다면 앞으로도 영원히 같아야 한다
- (하나 혹은 두 객체 모두가 수정되지 않는 한)
- 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해선 안된다.
💡 null 아님 : null이 아닌 모든 참조값 x에 대해, x.equals(null)은 false이다.
- 모든 객체가 null과 같지 않아야 한다
- instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.
- ⇒ 입력이 null이면 무조건 false 반환
- 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 된다.
equals 메서드 단계별 구현 방법
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인
- instanceof 연산자로 입력이 올바른 타입인지 확인
- 입력을 올바른 타입으로 형변환
- 입력 객체와 자기 자신의 대응되는 ‘핵심’ 필드들이 모두 일치하는지 하나씩 검사
🧐 주의 사항
- equals를 재정의할 땐 hashCode도 반드시 재정의 ⭐
- 너무 복잡하게 해결하려고 하지 말자
- Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자
- 이는 재정의가 아니라 다중정의를 한 것.
👀 TIP
- 어떤 필드를 먼저 비교하느냐에 따라 equals의 성능을 좌우한다
- 다를 가능성이 더 크거나
- 비교 비용이 싼 필드를 먼저 비교
- 구글의 AutoValue 프레임워크를 사용하면 깔끔한 equals가 생성
💡equals를 재정의하려거든 hashCode도 재정의하라
equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
Object 명세에 있는 규약
💡 1. equals 비교에 사용되는 정보가 변경되지 않았다면, 어플리케이션이 실행되는 동안 그 객체의 hashCode 메소드는 몇 번을 호출해도 항상 같은 값을 반환해야 한다. 단, 어플리케이션을 다시 실행한다면 달라져도 상관 없다.
💡 2. equals가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
💡 3. equals가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 2번째 이다.
→ 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
Map<PhoneNumber, String> m = new HashMap<>(); m.put(new PhoneNumber(707, 867, 5309), "제니"); m.get(new PhoneNumber(707, 867, 5309)) // -> null 반환
2개의 PhoneNumber 인스턴스가 사용되었는데 hashCode를 재정의하지 않았기 때문에
논리적 동치인 두 객체가 서로 다른 해시코드를 반환한다.
좋은 hashCode를 작성 하는 요령
- int 변수 result를 선언한 후 값 c로 초기화
- 해당 객체의 나머지 핵심 필드 각각에 대해 다음 작업을 수행
- 해당 필드의 해시코드 c를 계산
- 기본 타입필드라면, Type.hashCode(c)를 수행
- 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면 이 필드의 hashCode를 재귀적으로 호출
- 필드가 배열이라면 핵심 원소 각각을 별도 필드처럼 다룬다.
- 2.1 에서 계산한 해시코드 c로 result를 갱신
result = 31 * result + C;
- 해당 필드의 해시코드 c를 계산
- result 반환
전형적인 hashCode 메서드
@Override public int hashCode() { int result = Short.hashCode(areaCode); result = 31 * result + Short.hashCode(prefix); result = 31 * result + Short.hashCode(lineNum); return result; }
❓ 31 인 이유
- 31이 홀수면서 소수(prime)이기 때문
- 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게 된다.
- 2를 곱하는 것은 시프트 연산과 같은 결과를 내기 때문
- 결과적으로 31을 이용하면 shift 연산과 뺄셈으로 대체해 최적화할 수 있다.
Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공
→ hashCode 함수를 단 한줄로 작성할 수 있다.
→ 하지만 속도는 더 느리다
- 입력 타입중 기본타입이 있다면 박싱, 언박싱 과정을 거치기 때문
- 성능에 민감하지 않은 상황에서만 사용하자
💡toString 을 항상 재정의하라
toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.
toString은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다.
- 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면
- 요약 정보를 담아야 함
포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자
- 제공하지 않으면 이 정보가 필요한 프로그래머는 toString의 반환 값을 파싱할 수 밖에 없다.
💡clone 재정의는 주의해서 진행하라
Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 의도한 목적을 제대로 이루지 못했다.
문제 : clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected이다.
- 그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.
- Cloneable 인터페이스는 Object의 protected 메서드 clone의 동작 방식을 결정해주는 역할
- 객체를 복사할지, CloneNotSupportedException 을 던질지
사용하기 어렵다
Cloneable 인터페이스를 구현하고 clone 메서드를 오버라이드 했다면 끝일까? → ❌
Cloneable 인터페이스를 구현한 클래스가 불변 객체만을 참조하는 경우는 아무 문제가 없다.
하지만, 가변 객체를 참조하게 되면 원본 인스턴스와 복제 인스턴스 모두 동일한 가변 객체를 참조한다.
⇒ 복제 이후 문제가 발생할 수 있다. (불변식을 해친다)
가변 객체를 복제하는 방법
- 배열의 clone을 재귀적으로 호출 ⇒ 재귀 호출 대신 반복자를 써서 순회
- 고수준 API를 사용
Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다.
요약하자면, Cloneable을 구현하는 모든 클래스는 clone을 재정의 해야한다.
- 접근제한자는 public
- 반환 타입은 클래스 자신으로 변경
Cloneable을 이미 구현한 클래스를 확장한다면 clone을 잘 작동하도록 구현해야 한다.
그렇지 않은 상황이라면 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공
💡Comparable을 구현할지 고려하라
순서가 명확한 값 클래스 작성 시 반드시 Comparable 인터페이스를 구현하자
compareTo 메서드의 일반 규약
equals의 규약과 비슷
- 이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 더 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
- Comparable을 구현한 클래스는 모든 x, y에 대해 x.compare(y) == -(y.compare(x))여야 한다.
- Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, x.compareTo(y) > 0 && y.compareTo(z) > 0이면 x.compareTo(z) > 0이다.
- Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 x.compareTo(z) == y.compareTo(z)이다.
- 권고사항! x.compareTo(y) == 0 == x.equals(y) 여야 한다.
- 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.
- 첫 번째가 두 번째보다 크고 두 번째가 세 번째보다 크면, 첫 번째는 세 번째보다 커야 한다.
- 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.
⇒ 반사성, 대칭성, 추이성을 충족해야 한다!!
compareTo 메서드로 수행한 동치성 테스트의 결과는 equals와 같아야 한다.
Comparable vs Comparator
Comparable : 일반적인 비교 (오름차순, 내림차순…)
Comparator : 특별한 비교 (문자열 길이)
Comparator는 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는데 멋지게 활용할 수 있다.
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); }
참고 자료
'📕 book > 이펙티브 자바' 카테고리의 다른 글
7장 - 람다와 스트림 (0) 2022.11.07 6장 - 열거타입과 애너테이션 (0) 2022.11.04 5장 - 제네릭 (0) 2022.11.01 4장 - 클래스와 인터페이스 (0) 2022.11.01 2장 - 객체 생성과 파괴 (0) 2022.10.25