10. equals는 일반 규악을 지켜 재정의하라
안녕하세요, 이번 포스팅은 가장 애매한 equals 입니다.
저번 면접에서 관련 질문이 나왔는데, 애매하게 대답하여 혼났던 기억이 나네요.
요새 직장에서의 위치가 가중해져서인지, 일이 많고 주말 작업도 많았네요.
허리라인을 지탱하고 있다고 생각하고 열심히 달려야죠..
흔히들 죽음을 받아들이는 5단계에 대해서 이야기 합니다.
바로 부정 - 분노 - 우울 - 타협 - 수용 인데요.
이것은 죽음 뿐만아니라, 평소에도 느끼는 것 같습니다. 저번 아이템에서 여담으로 말씀 드린 것 처럼, 기분이 요동치고 있는데... 요즘은 잘 모르겠네요. 높은 파도가 오기 전에 잔잔하다는 바다라고 해야하나요.
각설하고, 요즘 코로나가 다시 기승인데 모두 건강 유의하시길 바라겠습니다.
객체 = 객체를 True로 반환하고 싶을 때, 흔히들 처음 자바를 접한다면 헷갈릴 수 있습니다.
예를 들어, 아래의 테스트 코드를 보겠습니다.
@Test
@DisplayName("equals 테스트 - 재정의 하지 않음")
void testEquals() throws Exception {
// given
Post post = new Post("제목입니다.", "내용입니다.");
String json = objectMapper.writeValueAsString(post);
//when
mockMvc.perform(post("/posts")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isOk())
.andDo(print());
//then
Post findPost = postRepository.findAll().get(0);
assertThat(post).isEqualTo(findPost);
}
토이 프로젝트를 진행하다 잠깐 좋은 예시가 떠올라 막 작성했는데요.
post를 만들어 Controller에 이것을 POST로 보낸 후, repository에서 다시 찾아와 비교를 하는 예시입니다.
결과는 아래와 같습니다.
org.opentest4j.AssertionFailedError:
expected: com.rineaubie.api.domain.Post@3d8b9dee
but was: com.rineaubie.api.domain.Post@6a4238ff
Expected :com.rineaubie.api.domain.Post@3d8b9dee
Actual :com.rineaubie.api.domain.Post@6a4238ff
객체 주소값이 다르므로, 다르다고 나옵니다.
물론 진짜 다르니까 다른것이죠, 그래서 우리들은 equals를 재정의하여 기준을 잡습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Getter
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Lob
private String content;
@Builder
public Post(String title, String content) {
this.title = title;
this.content = content;
}
@Override // Equals 재정의
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Post post = (Post) o;
return getTitle().equals(post.getTitle());
}
@Override
public int hashCode() {
return Objects.hash(getTitle());
}
}
제가 짠 예시는, 단순하게 title이 같으면 True를 반환합니다.
다시 테스트를 돌려본다면
성공하는 케이스를 볼 수 있습니다.
여기서 포스팅을 끝낸다면 정말 좋겠지만, 그럼 포스팅을 쓸 이유가 없겠지요..
equals 메서드는, 예시처럼 재정이하기 쉬워 보이지만 자칫하면 끔찍한 결과를 초래할 수 있습니다.
아예 재정의를 하지 않는것이 가장 좋습니다. (물론 재정의 해야 한다면 어쩔 수 없지만..)
다음 상황 중 하나에 해당한다면, 재정의하지 않는 것이 최선입니다.
각 인스턴스가 본질적으로 고유함
값을 표현하는게 아니라, 동작하는 개체를 표현하는 클래스가 해당합니다. Thread 정도가 해당되고, Object의 Equals 메서드는 이러한 클래스에 딱 맞게 구현되었습니다.
인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
논리적 동치성이란, 문장의 의미, 즉 진리가 같다고 하죠.
java.util.regex.Pattern 처럼 검사할 일이 없을 경우 (물론 euquals로 정의하여 사용할 수 있지만) 이는 기본 equals로 충분히 해결 가능합니다.
상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 맞는다.
대부분의 Set 구현체는, AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체는 AbstractList, Map 구현체는 AbstractMap으로부터 상속받아 사용합니다.
AbstractMap의 equals 재정의
/**
* Compares the specified object with this map for equality. Returns
* {@code true} if the given object is also a map and the two maps
* represent the same mappings. More formally, two maps {@code m1} and
* {@code m2} represent the same mappings if
* {@code m1.entrySet().equals(m2.entrySet())}. This ensures that the
* {@code equals} method works properly across different implementations
* of the {@code Map} interface.
*
* @implSpec
* This implementation first checks if the specified object is this map;
* if so it returns {@code true}. Then, it checks if the specified
* object is a map whose size is identical to the size of this map; if
* not, it returns {@code false}. If so, it iterates over this map's
* {@code entrySet} collection, and checks that the specified map
* contains each mapping that this map contains. If the specified map
* fails to contain such a mapping, {@code false} is returned. If the
* iteration completes, {@code true} is returned.
*
* @param o object to be compared for equality with this map
* @return {@code true} if the specified object is equal to this map
*/
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Map))
return false;
Map<?,?> m = (Map<?,?>) o;
if (m.size() != size())
return false;
try {
for (Entry<K, V> e : entrySet()) {
K key = e.getKey();
V value = e.getValue();
if (value == null) {
if (!(m.get(key) == null && m.containsKey(key)))
return false;
} else {
if (!value.equals(m.get(key)))
return false;
}
}
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}
return true;
}
만약 위험을 철저히 방지하고 싶다면, 다음처럼 구현할 수 있습니다.
@Overried public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지
}
그렇다면, equals를 재정이해야 할 때는 언제일까요? 바로 객체 식별성(물리적으로 같은가)이 아닌, 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의하지 않았을 경우입니다.
주로 값 클래스들이 여기에 해당하는데, Integer와 String이 해당합니다.
모든 대부분의 프로그래머는 이 두 값을 equals를 비교하는것이 객체 자체가 같은것이 아닌, 값이 같은지 의도하고 작성 할 것입니다. equals가 논리적 동시성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교하는 프로그래머들의 기대에 부응할 수 있습니다. 부가 효과로, Map의 키와 Set의 원소로 사용할 수 있습니다.
Integer equals 정의
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스(item. 1)와 Enum(item. 34)라면 equals를 재정의하지 않아도 됩니다. (당연한거죠)
따라서 이것들은 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있습니다.
equals 메서드를 재정의할 때, 반드시 아래의 일반 규약을 따라야 합니다.
다음은, Object 명세에 적힌 규약입니다.
equals 메서드는 동치관계를 구현하며, 다음을 만족한다.
반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.
일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환합니다.
null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
이것은 중요한 규악입니다. 어긴다면 프로그램이 이상하게 동작할 수 있고, 원이이 되는 코드를 찾기도 어려울 것입니다.
유명한 말이 있죠, "홀로 존재하는 클래스는 없다..."
한 클래스의 인스턴스는 다른 곳으로 빈번히 전달됩니다. 그리고 컬렉션 클래스들을 포함해 수많은 클래스는 전달받은 객체가 equals 규악을 지킨다고 가정하고 동작합니다.
위에 나온 Object 명세에서 말하는 동치관계란 무엇일까요?
명세에 규약들은 학창 시절 흔히 나온 집합의 정의와 많이 유사합니다.
말 그대로, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산입니다. 이 부분집합을 동치류라 합니다. equals 메서드가 쓸모 있으려면, 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 합니다.
이제 다섯가지의 요건을 하나씩 보겠습니다.
반사성은, 객체는 자기 자신과 같아야 한다는 뜻입니다. 오히려 어기기가 어려워 보이는 규악입니다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음, contanis 메서드를 호출하면 방금 넣은 인스턴스가 없다고 할 것입니다.
대칭성은, 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻입니다. 이것은 자칫하면 어길 수 있을 것 같아 보이는데, 아래의 대소문자를 구별하지 않는 문자열을 구현한 클래스로 예를 들겠습니다. 책에 나오는 클래스의 toString 메서드는 원본 문자열의 대소문자를 그대로 돌려주지만, equals에서는 대소문자를 무시합니다.
package item10;
import java.util.Objects;
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
);
if(o instanceof String)
return s.equalsIgnoreCase((String) o);
return false;
}
}
위 클래스의 equals는 일반 문자열과도 비교를 시도합니다. 다음 예시를 보겠습니다.
package item10;
public class Item10Main {
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Parrineau");
String s = "parrineau";
System.out.println("cis.equals(s) = " + cis.equals(s));
}
}
cis.equals(s) = true
Process finished with exit code 0
당연하게도, true를 반환합니다. 여기서 문제는 CaseInsensiriveString의 equals는 일반 String을 알고 있지만, String의 equals는 당연히 cis를 모릅니다. 따라서 대칭성을 위반합니다.
이번에는 cis을 컬렉션에 넣어보겠습니다.
List<CasInsensitiveString> list = new ArrayList<>();
list.add(cis);
이 다음에 list.contains(s)를 호출하면, 책과 다르게 true를 반환하는데요, 이는 equals 규악을 위반했기 때문입니다.
책에는 false는 반환하는데... 여기는 true를 반환하여 대충 ArrayList 클래스의 contains 메서드를 보았는데요.
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
/**
* Returns the index of the first occurrence of the specified element
* in this list, or -1 if this list does not contain the element.
* More formally, returns the lowest index {@code i} such that
* {@code Objects.equals(o, get(i))},
* or -1 if there is no such index.
*/
public int indexOf(Object o) {
return indexOfRange(o, 0, size);
}
int indexOfRange(Object o, int start, int end) {
Object[] es = elementData;
if (o == null) {
for (int i = start; i < end; i++) {
if (es[i] == null) {
return i;
}
}
} else {
for (int i = start; i < end; i++) {
if (o.equals(es[i])) {
return i;
}
}
}
return -1;
}
indexOfRange() -> indexOf() -> contains로 올라오는 순서인데, 여기서 indexOfRange의 o.equals()의 중점을 두어야합니다.
o라는 객체가 들어왔을 때, 이것의 equals를 사용하여 리턴하는 형식인데, 여기서 다시 cis의 equals를 보겠습니다.
@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;
}
cis를 인자로 받았으므로, 위의 if 문에 로직을 탈 것이고, 결국에 true를 반환합니다.
결과적으로, JDK 버전이나 다른 JDK 에서는 true나 런타임예외를 던질 수 있습니다. (당장 책과 저의 결과가 다른것만 봐도 그렇습니다.)
이 문제를 해결하려면, cis의 equals를 String과도 연동하겠다는 허황된 꿈을 버려야 한다고 합니다. 그 결과, equals는 다음처럼 간단한 모습으로 바뀝니다.
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
금일 포스팅 equals는, 양이 많은 관계로 1-2 나누어서 포스팅 하겠습니다.
다음 포스팅은 명세의 추이성 예시부터 소개하겠습니다.
Equals가 이렇게 복잡한...