- 1편과 2편은 이어지는 하나의 내용입니다.
equals()의 재정의가 필요한 상황은 언제일까요?
두 객체가 물리적으로 같은지가 아니라 논리적인 동치성을 확인해야 하는데,
상위 클래스의 equals가 논리적 동치성을 비교할 수 있도록 재정의되지 않았을 때입니다.
(동치란 “같다"는 개념을 추상화한 것입니다. : https://namu.wiki/w/동치관계 )
만약 equals()를 사용하려는 개발자가 있다면, 사용 의도는 객체의 주소가 같은 지를 알고 싶은 것이 아니라,
값이 같은 지를 알고 싶은 것일겁니다.
String name1 = new String("woowacourse");
String name2 = new String("woowacourse");
System.out.println(name1 == name2); //false
System.out.println(name1.equals(name2)); //true
위의 예시는 처음 자바를 시작하면, 한 번쯤 겪어 봤을 문제입니다.
해당 코드에서 == 로 비교하면, 객체의 주소를 비교하는 것이기 때문에 당연히 false가 뜨게 됩니다.
반면에 equals()를 사용하니 우리가 원하는 결과 값이 나오는 것(true)을 확인할 수 있습니다.
왜 true가 나오는지 String 클래스로 가봅시다.
간단히 코드를 보면 전달받은 객체의 주소가 같다면 true를 반환하고, 그렇지 않으면 값을 비교해서 true/false를 반환하는 로직으로 짜여있습니다.
이처럼 우리가 원하는 비교 방식으로 equals()를 재정의할 수 있습니다.
그전에 꼭 알아야 하는 것이 있는데 바로 일반 규약입니다.
equals는 일반 규약을 따르지 않으면, 의도치 않은 결과를 초래할 수 있습니다.
그럼 equals를 재정의할 때 지켜야 하는 규약들을 살펴봅시다.
일반 규약
1) 반사성(reflaxivity)
null이 아닌 모든 참조 값에 대해, x.equals(x)는 true
2) 대칭성(symmetry)
null이 아닌 모든 참조 값에 대해, 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
(위의 내용이 잘 와닿지 않으면 밑의 예시로 바로 넘어갑시다!)
위의 5가지 규약은 꼭 지켜야 합니다.
그렇지 않으면 이상한 결과를 불러올 수 있습니다.
그렇다면 위에서 부터 계속 언급하고 있는 논리적 동치란 무엇일까요?
그럼 위에서 언급한 equals 규약들의 예시를 보며 차근차근 살펴봅시다.
설명을 돕기 위해 임의의 객체1,객체2,객체3이 있다고 가정해 보았습니다.
equals의 일반 규약
1) 반사성 (객체 1 ~ 객체 1)
반사성은 객체는 자기 자신과 같아야 한다는 것이다.
2) 대칭성(객체1 ~ 객체2이면, 객체2 ~ 객체 1이다.)
대칭성은 두 객체는 서로의 동치 여부에 대해 같은 값을 반환해야 한다는 것이다.
대칭성이 지켜지지 않은 예시를 한번 살펴봅시다.
예시는 두 문자열의 대소문자를 무시하고, 내용이 같은지 비교하는 내용입니다.
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Woowa");
String string = "woowa";
System.out.println(caseInsensitiveString.equals(string)); //true
System.out.println(string.equals(caseInsensitiveString)); //false
테스트할 코드를 먼저 보여드리면, 위에는 true이지만 아래는 false를 리턴하는 것을 알 수 있습니다.
그렇다면 대칭성을 위반하는 상황입니다.
왜 이런 결과가 나왔는지 코드로 살펴보겠습니다.
public final class CaseInsensitiveString {
private final String string;
public CaseInsensitiveString(String s) {
this.string = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
**if (o instanceof CaseInsensitiveString)**
return string.equalsIgnoreCase(((CaseInsensitiveString) o).string);
if (o instanceof String) // 한방향으로만 작동한다.
return string.equalsIgnoreCase((String) o);
return false;
}
}
위의 코드를 보면 if(o instanceof String)라는 조건문이 있습니다.
따라서 CaseInsensitiveString는 String의 존재를 알고 있습니다.
하지만 String은 어떨까요?
저희가 Override해주지 않았기 때문에 CaseInsensitiveString를 모를 것입니다.
당연히 false를 반환하게됩니다.
따라서 둘은 대칭성이 성립되지 않는 것을 알 수 있습니다.
3) 추이성(객체1 ~ 객체2이고, 객체2 ~ 객체 3이면, 객체1 ~ 객체 3이다.)
추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 것입니다.
추이성을 위배한 경우를 예시로알아봅시다.
public class Square {
private final int width;
private final int height;
public Square(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Square))
return false;
Square c = (Square) o;
return ((Square) o).width == width && ((Square) o).height == height;
}
}
위와 같이 Square 객체가 존재하고, 사각형의 가로와 세로가 같은 경우 같은 사각형으로 오버라이드 했습니다.
Square square1 = new Square(1, 2);
Square square2 = new Square(1, 2);
System.out.println(square1.equals(square2)); //true
여기서 사각형에 색상을 더해봅시다.
public class ColorSquare extends Square {
private final Color color;
public ColorSquare(int width, int height, Color color) {
super(width, height);
this.color = color;
}
}
그 후, equals 메서드는 어떻게 해야할까요. 현재 상태에서는 색상 데이터를 제외하고 구현되어 있어, 해당 정보를 무시하게 됩니다.
그렇다면 ColorSqure에서 다시 equal 메서드를 오버라이드 해봅시다.
public class ColorSquare extends Square {
private final Color color;
public ColorSquare(int width, int height, Color color) {
super(width, height);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorSquare))
return false;
return super.equals(o) && ((ColorSquare) o).color == color;
}
}
이렇게 하면 문제가 해결되었을까요?
Square square = new Square(1, 2);
ColorSquare colorSquare = new ColorSquare(1, 2, Color.GREEN);
System.out.println(square.equals(colorSquare)); //true
System.out.println(colorSquare.equals(square)); //false
ColorSquare의 색상을 무시하게 되므로 false를 반환하게 됩니다.
즉, 위에서 설명한 대칭성을 위배하게 되네요.
그렇다면 ColorSqure의 equals를 다시 고쳐봅시다.
public class ColorSquare extends Square {
private final Color color;
public ColorSquare(int width, int height, Color color) {
super(width, height);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Square))
return false;
// o가 Squre인 경우.
if (!(o instanceof ColorSquare)) {
return o.equals(this);
}
// o가 ColorSqure인 경우.
return super.equals(o) && ((ColorSquare) o).color == color;
}
}
이렇게 수정하면 만약 Squre객체가 들어오면 색상을 무시하게 되므로 해결될까요?
ColorSquare square1 = new ColorSquare(1, 2, Color.GREEN);
Square square2 = new Square(1, 2);
ColorSquare square3 = new ColorSquare(1, 2, Color.BLUE);
System.out.println(square1.equals(square2)); //true
System.out.println(square2.equals(square3)); //true
System.out.println(square1.equals(square3)); //false
하지만 위의 코드를 실행하면 square1.equals(square3)는 false를 반환하게 됩니다.
square1.equals(square2)과 square2.equals(square3)는 색상을 무시했지만, square1.equals(square3)는 색상을 고려하기 때문입니다.
따라서 추이성을 위배하게 됩니다.
그럼 해결법은 무엇일까요?
사실 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 없습니다.
객체지향적 추상화 이점을 포기하지 않는 한은 말입니다.
이는 얼핏 equals 안의 instanceof 검사를, getClass로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 것으로 들리는데요.
예시로 직접 확인해볼까요?
@Override
public boolean equals(Object o) {
if(o == null || o.getClass() != getClass()) {
return false;
}
Square s = (Square) o;
return s.width == width && s.height == height;
}
위의 코드는 같은 구현 클래스의 객체와 비교하는 경우에만, true를 반환합니다.
하지만 이는 실제로 사용할 수 없습니다. 리스코프 치환 법칙(상위 타입의 객체를 하위 타입의 객체로 변환해도 동작에 문제가 없어야 한다.)을 위배하기 때문입니다.
Squre의 하위 클래스도 정의상 Squre이므로, 어디서든 Squre로 활용할 수 있어야 합니다.
결론적으로 구체 클래스의 하위 클래스에 새로운 값을 추가할 수 있는 방법은 없지만, 우회방법이 있습니다.
상속 대신 컴포지션을 사용하는 것입니다.
public class ColorSquare {
**private final Square square;**
private final Color color;
public ColorSquare(int width, int height, Color color) {
square = new Square(width, height);
this.color = color;
}
**public Square asSquare() {
return square;
}**
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorSquare))
return false;
ColorSquare cs = (ColorSquare) o;
return cs.square.equals(square) && cs.color.equals(color);
}
}
Squre을 상속하는 대신 private 필드로 두고, Point를 반환하는 public 메서드를 추가하는 방법입니다.
ColorSquare square1 = new ColorSquare(1, 2, Color.GREEN);
Square square2 = new Square(1, 2);
ColorSquare square3 = new ColorSquare(1, 2, Color.GREEN);
System.out.println(square1.asSquare().equals(square2)); //true
System.out.println(square2.equals(square3.asSquare())); //true
System.out.println(square1.equals(square3)); //true
앞에서 살펴본 코드의 결과 값이 대칭성을 만족하는 것을 확인해볼 수 있습니다.
4) 일관성
일관성은 두 객체가 같다면(수정되지 않는 한) 영원히 같아야 한다는 것입니다.
가변 객체는 비교 시점에 따라서 서로 같을 수도 다를 수도 있습니다.
불변 객체는 한번 다르면 끝까지 달라야 합니다.
결론적으로 클래스가 가변이든 불변이든 equals의 판단에 신뢰할 수 없는 자원이 포함돼서는 안됩니다.
5) null이 아니다.
이름에서 알 수 있듯이 모든 객체는 null과 같지 않아야 한다는 것입니다.
많은 클래스가 밑의 코드처럼 입력이 null 인지 확인합니다. (지양)
// 명시적 null 검사 - 불필요하다.
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
}
위의 코드 대신, 아래의 방식처럼 해주는 것이 올바른 방식입니다.
// 묵시적 null 검사 - 이 방법이 더 낫다.
@Override
public boolean equals(Object o) {
if (!(o instanceof Square)) {
return false;
}
Square square = (Square) o;
}
instanceof로 인해 null이면 false를 반환하므로, null을 명시적으로 검사하지 않아도 됩니다.
길고 길었던 일반 규약의 설명이 끝이 났습니다.
그럼 올바르게 equals 메서드를 구현하는 방법 에 대해 알아봅시다!
- 1) == 연산자를 사용하여 입력이 자기 자신의 참조인지 확인합니다.
- 2) instanceof 연산자로 입력이 올바른 타입인지 확인합니다.
- 3) 입력을 올바른 타입으로 형변환합니다. 앞의 2번에서 instanceof 검사를 했기 때문에 변환이 보장됩니다.
- 4)입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사합니다. (모든 필드가 일치해야 true 반환)
위의 방법들을 참고하여, ColorSquare 클래스의 equals를 작성해봅시다.
@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (!(o instanceof ColorSquare)) {
return false;
}
ColorSquare cs = (ColorSquare) o;
return cs.width == width && cs.height == height && cs.color == color;
}
- 주의사항
equals를 재정의할 때는 hashCode도 반드시 재정의한다. 이는 다음 포스트에서 알아보자!
Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자. 이는 재정의가 아니라, 다중정의가 된다.
저자의 핵심:
꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
많은 경우에 Object의 equals가 원하는 비교를 정확히 수행해준다.
재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.
Reference
조슈아 블로크, 이펙티브 자바 Effective Java 3/E, 2018
'Java' 카테고리의 다른 글
자바 성능 튜닝 끄적끄적 (0) | 2024.02.17 |
---|---|
[Java] Thread Pool (0) | 2022.09.12 |
[Java] 스레드 동기화 - Synchronized (0) | 2022.09.11 |
[Effective java] equals를 재정의하면 hashCode도 재정의해야 한다. (0) | 2022.02.18 |