본문 바로가기
Language/Java

이펙티브 자바 - equals 재정의

by ocwokocw 2021. 9. 23.

- 이 글은 Effective Java 를 기반으로 작성되었습니다.

- equals 재정의

equals 메소드는 IDE 에서 자동으로 완성해주기 때문에 쉽다고 생각할 수 있지만 사실은 생각해야할 게 많다. equals 메소드를 정의하지 않아도 되는 경우가 있는데 이때 모든 객체는 자기 자신과만 동일하다.


- equals 메소드를 재정의 하지 않아도 되는 경우

  1. 각 객체가 고유한 경우: 주로 Value Object 가 아닌 활성 객체(Active Entity, ex - Thread) 와 같은 경우가 이에 해당한다.
  2. 클래스에 동치성 검사방법이 존재하지 않아도 상관 없을 때
  3. 상위 클래스의 equals 메소드를 하위 클래스에서 그대로 사용가능한 경우: 대부분의 Set, List, Map 클래스는 각각 AbstractSet, AbstractList, AbstractMap 의 equals 메소드를 그대로 사용한다.
  4. 클래스가 private, package-private(default) 로 선언되었고, equals 메소드를 호출하지 않는 경우: equals 를 구현하지 않아도 된다고 하지만 Effective Java 의 필자를 생각이 다른 것 같다. 실수로 equals 를 호출할 수 있기 때문에 아래와 같이 equals 를 재정의해야 한다고 주장한다.
@Override
public boolean equals(Object obj) {
    throw new AssertionError();

- 재정의 해야 하는 경우

Class 를 선언할 때 기본적으로 equals 메소드를 강제로 구현하지는 않는다. 이렇게 생각하면 equals 를 명시적으로 구현해야할 때를 잘 판단해야 되는데 어떤 경우가 재정의 해야하는지 알아보도록 하자.

  1. 동일성이 아닌 동등성을 지원해야할 경우: Date나 Integer 같이 Value Object 또는 Value Class 가 이런 경우에 해당한다.
  2. 상위 equals 가 하위의 equals 를 충족하지 못하는 경우
  3. Value Class 중 값 마다 최대 하나의 객체만 존재하는 경우, enum 도 이에 대한 경우에 해당한다.

- equals 메소드 정의시 준수해야할 일반 규약

어떤 객체에 대해서 동등하다고 판단할 Field 들을 비즈니스 요구사항에서 식별했다면 equals 메소드를 완전히 자유롭게 정의하면 되는걸까? 문제가 되지 않으면 상관없지만 일반 규약을 지키지 않으면 문제가 생길 확률이 크다. equals 메소드는 동치관계를 구현한다라고 표현하며 이를 만족하기 위한 몇 가지 속성이 있다.

 

반사성: 자기 자신과 같아야 한다. 이 속성은 불만족하려면 정성을 들여서 짜야 하는 수준이다. 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) {
		if(s == null) {
			throw new NullPointerException();
		}
		
		this.s = s;
	}

	@Override
	public boolean equals(Object obj) {
		if(obj instanceof CaseInsensitiveString) {
			return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
		}
		
		if(obj instanceof String) {
			return s.equalsIgnoreCase((String) obj);
		}
		
		return false;
	}
}

public class CaseInsensitiveStringTest {

	public static void main(String[] args) {
		
		CaseInsensitiveString a = new CaseInsensitiveString("test");
		String b = "Test";
		
		System.out.println("a.equals(b): " + a.equals(b));
		System.out.println("b.equals(a): " + b.equals(a));
	}
}

...........

a.equals(b): true
b.equals(a): false

 

CaseInsensitiveString 클래스의 equals 메소드에서는 자기 자신뿐만 아니라 String 에 대해서도 equals 비교를 지원하였다. 의도는 좋았지만 한 가지 문제가 있는데 CaseInsensitiveString 는 String 을 알지만, String은 CaseInsensitiveString 를 모른다는 것이다. 이 문제를 해결하려면 equals 메소드에서 String 과 상호작용 하지 않도록 변경해야 한다.

 

@Override
public boolean equals(Object obj) {
	if(obj instanceof CaseInsensitiveString) {
		return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
	}
		
	return false;
}

 

추이성: a=b 이고, b=c 일 때, a=c 임을 보장해야 한다는것이다. 좌표의 x,y 를 속성으로 갖는 Point 클래스가 있다고 가정해보자. 여기에 색상정보를 추가한 ColorPoint 클래스가 있다.

 

public class Point {

	private final int x;
	private final int y;
	
	public Point(int x, int y) {
		super();
		this.x = x;
		this.y = y;
	}

	@Override
	public boolean equals(Object obj) {
		
		if (!(obj instanceof Point)) {
			return false;
		}
		
		Point p = (Point) obj;
		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;
	}

	@Override
	public boolean equals(Object obj) {
		
		if(! (obj instanceof ColorPoint)) {
			return false;
		}
		
		return super.equals(obj) && ((ColorPoint) obj).color.equals(color);
	}
}

 

ColorPoint 의 equals 메소드를 생략하면 Point 의 equals 메소드를 상속받는다. 그런 상황에서 x와 y가 같은 Point 와 ColorPoint 를 비교하면 색상과 상관없이 true 를 반환한다. 색상이 있는 ColorPoint 와 색상이 없는 Point 를 다르게 반환하고 싶어서 위와 같이 구현하면 한 가지 문제가 있는데 대칭성이 깨진다는것이다.

 

public class PointTest {

	public static void main(String[] args) {

		Point p1 = new Point(1, 2);
		ColorPoint p2 = new ColorPoint(1, 2, Color.RED);
		
		System.out.println("p1.equals(p2): " + p1.equals(p2));
		System.out.println("p2.equals(p1): " + p2.equals(p1));
	}
}

....

p1.equals(p2): true
p2.equals(p1): false

 

Point 의 equals 메소드로 ColorPoint 와 비교하면 x, y 만 비교하기 때문에 true를 반환하지만, 색상정보까지 있는 ColorPoint의 equals 메소드 기준으로 색상이 없는 Point 와 비교하면 false 를 반환한다.

 

Point 일 경우 x, y만 비교하고, ColorPoint 일 경우 색상을 비교하면 되지 않을까?

 

.....
	@Override
	public boolean equals(Object obj) {
		
		if(! (obj instanceof Point)) {
			return false;
		}
		
		if(! (obj instanceof ColorPoint)) {
			return obj.equals(this);
		}
		
		return super.equals(obj) && ((ColorPoint) obj).color.equals(color);
	}
}

public class PointTest {

	public static void main(String[] args) {

		ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
		Point p1 = new Point(1, 2);
		ColorPoint p2 = new ColorPoint(1, 2, Color.RED);
		
		System.out.println("p3.equals(p1): " + p3.equals(p1));
		System.out.println("p1.equals(p2): " + p1.equals(p2));
		System.out.println("p3.equals(p2): " + p3.equals(p2));
	}
}

..........

p3.equals(p1): true
p1.equals(p2): true
p3.equals(p2): false

 

위의 코드에서 알 수 있듯이 p3 = p2 가 같아야 하는 추이성이 깨지게 된다. 객체 생성 가능 클래스를 계승하여 새로운 값 컴포넌트를 추가하면서 equals 규약을 어기지 않는 방법은 없다.

 

그렇다면 방법 자체가 아예 없는 걸까? 위의 문장을 유심히 읽어보자. '객체 생성 가능 클래스를 계승하여' 라고 하였는데, 만약 계승하지 않고 구성(Composition)을 하면 이런 문제를 피할 수 있다.

 

public class CompositionColorPoint{

	private final Point point;
	private final Color color;

	public CompositionColorPoint(int x, int y, Color color) {
		this.point = new Point(x, y);
		this.color = color;
	}

	public Point asPoint() {
		return point;
	}
	
	@Override
	public boolean equals(Object obj) {
		
		if(! (obj instanceof CompositionColorPoint)) {
			return false;
		}
		
		CompositionColorPoint cp = (CompositionColorPoint) obj;
		return point.equals(obj) && cp.color.equals(color);
	}
}

........

public class PointTest {

	public static void main(String[] args) {

		CompositionColorPoint p3 = new CompositionColorPoint(1, 2, Color.BLUE);
		Point p1 = new Point(1, 2);
		CompositionColorPoint p2 = new CompositionColorPoint(1, 2, Color.RED);
		
		System.out.println("대칭성===");
		System.out.println("p3.equals(p1): " + p3.equals(p1));
		System.out.println("p1.equals(p3): " + p1.equals(p3));
		
		System.out.println("추이성===");
		System.out.println("p3.equals(p1): " + p3.equals(p1));
		System.out.println("p1.equals(p2): " + p1.equals(p2));
		System.out.println("p3.equals(p2): " + p3.equals(p2));
	}
}

............

대칭성===
p3.equals(p1): false
p1.equals(p3): false
추이성===
p3.equals(p1): false
p1.equals(p2): false
p3.equals(p2): false

 

일관성: 같다고 판정된 객체들은 변경되지 않는 한 추후에도 계속 같아야 한다는것이다. 상태값을 가지는 변경가능(mutable) 의 경우 시간이 지남에 따라 달라질 수 있다. 하지만 변경불가능(immutable)의 경우는 추후에도 같다. 변경 불가능으로 구현해야 하는것은 아닌지 생각해볼 필요가 있다. 또한 변경 가능여부와 상관없이 신뢰성이 보장되지 않는 자원에 대한 equals 비교는 피하는것이 좋다.

 

Null 에 대한 비-동치성: o.equals(null) 을 호출하면 false 가 반환되어야 한다는것이다. equals 에서 null 을 명시적으로 체크하여 false 를 반환할 수도 있지만 instanceof 는 첫번째 피 연산자가 null 일 경우 false 를 반환해준다. null 을 전달하면 false 를 반환하므로 null 을 따로 검사하지 않아도 된다.

댓글