[Java] 모든 객체의 공통 메서드 1
EFFECTIVE JAVA 3/E 책을 공부하고 정리한 글입니다.
📖 모든 객체의 공통 메서드
- Object : 객체를 만들 수 있는 구체 클래스지만, 기본적으로는 상속해서 사용하도록 설계되었다.
-- Object에서 final이 아닌 메서드 (equals, hashCode, toString, clone, finalize)는 모두 재정의(overriding)를 염두에 두고 설계된 것
--> 재정의 시 지켜야하는 일반 규약이 정의되어 있다.
--> Object를 상속하는 클래스, 즉 모든 클래스는 이 메서드들을 일반 규약에 맞게 재정의 해야 한다.
-- Object에서 final이 아닌 메서드 (equals, hashCode, toString, clone, finalize)는 모두 재정의(overriding)를 염두에 두고 설계된 것
--> 재정의 시 지켜야하는 일반 규약이 정의되어 있다.
--> Object를 상속하는 클래스, 즉 모든 클래스는 이 메서드들을 일반 규약에 맞게 재정의 해야 한다.
📖 equals는 일반 규약을 지켜 재정의하라
📌 equals 메서드를 재정의 하지 않는 상황
- equals 메서드는 재정의 하기 쉬워보이지만, 함정이 많아 조심해야 한다.
-- 문제를 회피하는 가장 쉬운 방법은 재정의하지 않는 것이다.
-- 어떤 상황에 재정의하지 않을 수 있을까?
1. 각 인스턴스가 본질적으로 고유한 상황
- 값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기에 속한다.
ex> Thread - Object의 equals 메서드는 이러한 클래스에 적합하게 구현되어 있다.
2. 인스턴스의 '논리적 동치성'을 검사할 일이 없는 상황
3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는 상황
- 대부분의 Set구현체는 AbstractSet이 구현한 equals를 상속받아 사용하고, List구현체들은 AbstractList로부터, Map구현체들은 AbstractMap으로부터 상속받아 그대로 사용한다.
4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없는 상황
- equals가 실수로라도 호출되는 것을 막고 싶다면 다음과 같이 구현해두자.
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
-- 문제를 회피하는 가장 쉬운 방법은 재정의하지 않는 것이다.
-- 어떤 상황에 재정의하지 않을 수 있을까?
ex> Thread - Object의 equals 메서드는 이러한 클래스에 적합하게 구현되어 있다.
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
📌 equals를 재정의해야 할 때
- 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때
-- 주로 값 클래스들이 여기에 해당
값 클래스 : Integer와 String 처럼 값을 표현하는 클래스
- 값 클래스라고 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다.
ex> Enum
-- 이런 클래스에서는 논리적으로 같은 인스턴스가 2개 이상 생성되지 않으므로 논리적 동치성과 객체 식별성이 같은 의미가 된다.
--> Object의 equals가 논리적 동치성까지 확인해준다.
객체 식별성(object identity) : 두 객체가 물리적으로 같은가
📌 equals 메서드 재정의 일반 규약
- 다음은 Object 명세에 적힌 규약이다.
equals 메서드는 동치관계(equivalence relation)을 구현하며, 다음을 만족한다.
1. 반사성 (reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true
2. 대칭성 (symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true
3. 추이성 (transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true
4. 일관성 (consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환
5. null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false
-
이 규약을 어기면 프로그램이 이상하게 동작하거나 종료될 것이고, 원인이 되는 코드를 찾기도 굉장히 어려울 것이다.
-
동치관계 : 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산
-- 이 부분집합을 동치류라고 함.
-- equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
-
반사성 : 객체는 자기 자신과 같아야 한다
-- 왠만하면 만족시키는 조건
-
대칭성 : 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻
-- 자칫 어길 수 있는 요건
// [코드 10-1] 잘못된 코드 - 대칭성 위배!
// toString 메서드는 원본 문자열의 대소문자를 그대로 돌려주지만,
// equals에서는 대소문자를 무시한다.
public final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Object.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는 일반 문자열과도 비교를 시도한다.
-- CaseInsensitiveString과 일반 String 객체가 하나씩 있다고 해보자.
CaseInsensitiveString cis = new CaseInsensitieString("Polish");
String s = "polish";
- cis.equals(s)는 true, s.equals(cis)는 false를 반환하고, 이는 대칭성을 명백히 위반한다.
-- CaseInsensitiveString의 equals는 일반 String을 알고 있지만, String의 equals는 CaseInsensitiveString의 존재를 모르기 때문에 발생한 문제다.
-- 이번에는 CaseInsensitiveString을 컬렉션에 넣어보자.
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
-
이 다음에 list.contains(s)를 호출하면 false를 반환한다. 하지만 이는 구현하기 나름이라, 다른 JDK나 다른 버전에서는 true를 반환하거나 런타임 예외를 출력할 수도 있다.
-
equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
-- 이 문제를 해결하기 위해 CaseInsensitiveString의 equals를 String과 연동하려 하면 안된다.
// 대칭성 문제를 해결
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
- 추이성 : 객체1과 객체2가 같고, 객체2와 객체3이 같다면, 객체1과 객체3도 같아야 한다는 뜻
-- 어기기 쉽다
-- 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자.
--> 이는, equals 비교에 영향을 주는 정보를 추가하는 행위이다.
-- 예시로 2차원에서의 점을 표현하는 클래스를 들어보자
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;
}
... // 나머지 코드는 생략
}
- 이 예제에서 equals 메서드를 그대로 둔다면, Point의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다.
-- 이는, equals 규약을 어긴것은 아니지만, 중요한 정보를 놓치게 된다.
-- 비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환하는 equals 예제를 보자.
// [코드 10-2] 잘못된 코드 - 대칭성 위배!
@Override public boolean equals(Object o) {
if(!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
- 이 메서드는 일반 Point를 ColorPoint에 비교한 결과와 그 둘을 바꿔 비교한 결과가 다를 수 있다.
-- Point의 equals는 색상을 무시하고, Colorpoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다.
-- 각각의 인스턴스를 만들어 실제 동작을 확인해보자
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
- p.equals(cp)는 true, cp.equals(p)는 false를 반환한다.
-- ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 코드를 수정하면 해결될까?
// [코드 10-3] 잘못된 코드 - 추이성 위배!
@Override public boolean equals(Object o) {
if(!(o instanceof Point))
return false;
// o가 일반 Point면 색상을 무시하고 비교
if(!(o instanceof ColorPoint))
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);
- p1.equals(p2)와 p2.equals(p3)는 true를 반환, p1.equals(p3)는 false 반환 : 추이성 위배
-- p1과 p2, p2와 p3 비교에는 색상을 무시했지만, p1과 p3 비교는 색상까지 고려했기 때문
-- 또한 이 방식은 무한 재귀에 빠질 위험도 존재.
- 해법?
-- 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
-- equals 내부에 instanceof 검사 대신 getClass 검사를 활용하면 규약을 지킬 수 있을까?
// [코드 10-4] 잘못된 코드 - 리스코프 치환 원칙 위배!
@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는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 실제로 활용할수 없다는 뜻이다.
-- Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 하는데 이 방식에서는 그렇지 못하다.
-- ex> 주어진 점이 단위 원 안에 있는지 판별하는 메서드
// 단위 원 안의 모든 점을 포함하도록 unitCircle 초기화
private static final Set<Point> unitCircle = Set.of(
new Point(1, 0), new Point(0, 1), new Point(-1, 0), new Point(0, -1));
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
- 여기서 값을 추가하지 않는 방식으로 Point를 확장해보자.
public class CounterPoint extends Point {
private static final AtomicInteger counter = new AtomicInteger();
public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}
public static int numberCreated() {
return counter.get();
}
}
-
리스코프 치환 원칙(Liskov substitution principle) : 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.
= Point의 하위 클래슨ㄴ 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다. -
CounterPoint의 인스턴스를 onUnitCircle 메서드에 넘기면 어떻게 될까?
-- Point 클래스의 equals를 getClass를 활용하여 작성했다면 onUnitCircle은 CounterPoint 인스턴스의 x, y 값과 관계없이 false를 반환할 것이다.
-- 왜? : CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 때문 -
반면, Point의 equals를 instanceof 기반으로 올바르게 구현했다면, CounterPoint 인스턴스를 건네줘도 onUnitCircle 메서드가 제대로 동작할 것
-
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없다.
-- 괜찮은 우회 방법이 하나 있다
-- 상속 대신 컴포지션을 사용하라
// [코드 10-5] equals 규약을 지키면서 값 추가하기
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);
}
... // 나머지 코드 생략
}
tmi) 추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다.
-
일관성 : 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻
-- 가변 객체는 비교 시점에 따라 서로 다를 수도, 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야 한다. -
클래스가 불변이든 가변이든, equals 의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.
-- 이것을 어기면 일관성 조건을 만족시키기 어렵다.
-- 이러한 문제를 피하기 위해 equals는 항시 메모리에 존재하는 객체만을 사용할 결정적 계산만 수행해야 한다.
- null-아님 : 모든 객체가 null과 같지 않아야 한다는 뜻
-- 수많은 클래스가 아래 코드처럼 입력이 null인지 확인하여 자신을 보호하는데, 이는 필요없다.
// 명시적 null 검사 - 필요 없다!
@Override public boolean equals(Object o) {
if(o == null)
return false;
...
}
- 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환 한 후 필수 필드들의 값을 알아내야 하는데, 그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.
// 묵시적 null 검사 - 이게 낫다
@Override public boolean equals(Object o) {
if(!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}
- instanceof는 첫 번째 피연산자가 null이면 false를 반환한다.
-- 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 된다.
📌 equals 메서드 단계별 구현 방법
1. ==연산자를 사용해 입력이 자기 자신의 참조인지 확인
- 자기 자신이면 true를 반환 - 단순 성능 최적화용
2. instanceof 연산자로 입력이 올바른 타입인지 확인
-
그렇지 않으면 false 반환
-
이 때 올바른타입은 보통 equals가 정의한 클래스지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다.
-
어떤 인터페이스는 자신을 구현한 서로 다른 클래스 끼리도 비교할 수 있도록 equals 규약을 수정하기도 하는데, 이런 경우에는 equals에서 클래스가 아닌 해당 인터페이스를 사용해야 한다.
ex> Set, List, Map, Map.Entry 등의 컬렉션 인터페이스
3. 입력을 올바른 타입으로 형변환
- 2번 단계에서 instanceof 검사를 했으므로 이 단계는 100% 성공
4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사
-
모든 필드가 일치하면 true, 하나라도 다르면 false
-
2단계에서 인터페이스를 사용했다면, 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다.
-
타입이 클래스라면 접근 권한에 따라 해당 필드에 직접 접근할 수도 있다.
📌 equals 메서드 구현 팁
-
float와 double을 제외한 기본 타입 필드는 == 연산자로 비교
-
참조 타입 필드은 각각의 equals 메서드로 비교
-
float, double은 각각 정적 메서드인 compare()로 비교
-- float, double은 부동 소수점 처리 때문에 특별 취급
-- float, double에 equals 메서드를 사용 할 수도 있긴 하지만, 이 경우에는 오토박싱을 수반할 수 있어 성능상 좋지 않다. -
배열 필드는 원소 각각을 위 지침대로 비교
-- 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용 -
가끔 null을 정상 취급하는 참조 타입 필드도 존재한다.
-- 이런 필드는 정적 메서드인 Object.equals(Object, Object)로 비교해 NullPointerException 발생을 예방 -
비교하기 복잡한 필드인 경우
-- 그 필드의 표준형(canonical form)울 저장해 둔 후 표준형끼리 비교하면 경제적
-- 이 기법은 특히 불변 클래스에 제격
-- 가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해야 함. -
어떤 필드를 먼저 비교하는지 순서가 equals의 성능을 좌우하기도 한다.
-- 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자
-- 동기화 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다. -
equals를 다 구현했다면 세 가지를 자문하자. 대칭적인가? 추이성이 있는가? 일관적인가?
-- 자문을 모두 통과했다면, 단위 테스트를 작성해보자.
-- 단, AutoValue를 이용하여 equals를 작성했다면 안심해도 된다.
-- 물론 반사성과 null-아님도 만족해야 하지만, 이 둘은 왠만하면 만족한다.
-- 아래 코드는 이상적인 비법에 따라 작성한 PhoneNumber 클래스용 equals 메서드이다.
// [코드 10-6] 전형적인 equals 메서드의 예시
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) {
if(val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val)
return (short)val;
}
@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를 재정의할 땐 hashCode도 반드시 재정의하자
-
너무 복잡하게 해결하려 하지 말자
-- 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다!
-- 일반적으로 별칭(alias)는 비교하지 않는게 좋다. -
Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자
// 잘못된 예 - 입력 타입은 반드시 Object이어야 한다!
public boolean equals(MyClass o) {
...
}
-
입력 타입이 Object가 아니므로 재정의가 아니라 다중정의한 것이다.
-- 이처럼 타입을 구체적으로 명시한 equals는 오히려 해가 된다. -
이 메서드는 하위 클래스에서의 @Override 애너테이션이 긍정 오류를 내게하고, 보안 측면에서도 잘못된 정보를 제공한다.
-- @Override 애너테이션을 일관되게 사용하면 이러한 실수를 예방할 수 있다.
// 여전히 잘못된 예 - 컴파일되지 않음
@Override public boolean equals(MyClass o) {
...
}
-
이 equals 메서드는 컴파일되지 않고, 무엇이 문제인지 정확히 알려주는 오류 메시지를 보여줄 것이다.
-
equals를 작성하고 테스트하는 일을 대신해주는 오픈소스가 존재한다.
-- AutoValue 프레임워크 (구글 제작)
-- 클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해준다.
-- 대다수의 IDE도 같은 기능을 제공하지만 AutoValue만큼 깔끔하지는 않다.
📌 핵심 정리
꼭 필요한 경우가 아니라면 equals를 재정의하지 말자.
대부분의 경우에 Object의 equals가 우리가 원하는 비교를 정확히 수행해준다.
재정의해야할 때는 그 클래스의 핵심 필드 모두를 빠짐없이 다섯가지 규약을 확실히 지켜가며 꼼꼼하게 비교해야 한다.
Author And Source
이 문제에 관하여([Java] 모든 객체의 공통 메서드 1), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@yun12343/Java-모든-객체의-공통-메서드-1저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)