equals override 시 유의점들

수박참외메론·2022년 11월 1일
0

equals() 메서드는 Object 클래스에 정의되어 있는 메서드로 일반적으로는 두 객체가 동일한지 아닌지를 확인할때 사용하곤 한다. 이 equals() 메서드는 우리가 직접 사용하는 경우 말고도 예를 들어 set 이나 list 같은 자료구조에서도 contains() 등의 메서드 안에서 사용하는 경우가 많으므로, 해당 메서드를 잘못 구현할 경우, 이 equals() 함수가 일반 규약에 맞게 재정의되어있다고 가정하여 구현된 클래스들이 제대로 작동하지 않을 가능성이 높아진다.

그래서 이런 문제를 가장 현명하게 회피하는 길은 아예 재정의하지 않는 것이다.
그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.

equals 를 재정의하지 않아야 하는 상황

  • 각 인스턴스가 본질적으로 고유할 때
    Thread 가 이의 좋은 예로, Object 의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.
  • 인스턴스의 logical equality 를 검사할 일이 없을 때
    java.util.regex.Pattern은 equals 를 재정의해서 서로 다른 두 객체의 Pattern이 논리적으로 동치성을 가지는지 검사하는 로직을 따르게 한다. 이렇게 논리적 동치성을 판단하지 않는 이상, 보통은 가르키는 두 객체가 같은 객체를 바라보는지에 equals() 메서드가 사용된다.
  • 상위 클래스에서 재정의한 equals 매서드가 하위 클래스에서도 사용할 수 있는 상황
    예를들어 대부분의 Set 구현체는 AbstractSet이 구현한 equals 를 상속받아 쓰고, List, Map 들도 다 마찬가지이고, 이는 각 Abstract 클래스들이 의도한 대로이다.
  • 클래스가 private 이고 equals() 메서드를 호출할 일이 없을 때
    말그대로 불리지 않으니 할 필요가 없다.

그렇다면 언제 equals를 재정의 해야하나?

두 객체가 물리적으로 같은가를 판별할 때가 아닌, 논리적 동치성 을 확인해야 하는데, 해당 클래스의 상위 클래스가 논리적 동치성을 비교하도록 재정의되지 않았을 때라고 할 수 있다.

equals 메서드를 재정의할때 따라야 할 규약

equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.

  • 반사성(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)y.equals(z)가 true 이면 x.equals(z)도 true이다.
  • 일관성(consistency) : null 이 아닌 모든 참조값 x, y 에 대해 x.equals(y) 를 반복해서 호출하면 항상 true를 반환하거나 항상 false 를 반환한다.
  • null-아님 : null 이 아닌 모든 참조값 x에 대해, x.equals(null)은 false이다.

반사성(reflexivity)

객체는 자기자신과 같아야 한다는 뜻으로, 얼핏보면 만족하지 않기가 더 어려워 보이는 조건이다.
이 조건을 어긴 클래스의 인스턴스를 collection 에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.

대칭성(symmetry)

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻.

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는 일반 문자열과도 비교를 시도한다.
하지만 string 클래스가 제공하는 equals 를 사용했을 때와 CaseInsensitiveString이 제공하는 클래스의 equals를 사용했을 때의 작동이 다르기 때문에 대칭성이 깨지게 된다.

이를 해결하려면, CaseInsensitiveString 의 equals 를 String 과도 연동하겠다는 생각을 버려야 한다.

@Override
public boolean equals(Object o) {
	return o instance of CaseInsensitiveString &&
    	((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

추이성(transitivity)

추이성이란 첫번째 객체와 두번째 객체가 같고, 두번째 객체와 세번째 객체가 같다면, 첫번째 객체와 세번째 객체도 같아야 한다는 뜻이다.

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 this.x == p.x && this.y == p.y;
  }
}

위의 클래스에서 확장해 점에 색상을 더해본 ColorPoint 클래스를 선언해보자.

class ColorPoint extends Point {
  
  private final Color color;

  public ColorPoint(int x, int y, Color color) {
  	  super(x, y);
      this.color = color;
  }
}

여기서 equals 함수를 구성하려면, 추가된 속성인 color 까지 같아야 동치성이 만족되도록 짜야될 것이다.

class ColorPoint extends Point {
  
  private final Color color;

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

이런식으로 짜면 point 와 ColorPoint 의 비교에서 대칭성이 위배될 것이다.

Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp);
cp.equals(p);

그렇다면 색상을 무시하도록 하여 비교하게 되면 해결될까?

class ColorPoint extends Point {
  
  private final Color color;

  @Override
  public boolean equals(Object o) {
    if(!(o instanceof Point)) return false;

    //o가 일반 Point이면 색상을 무시햐고 x,y정보만 비교한다.
    if(!(o instanceof ColorPoint)) return o.equals(this);
    
    //o가 ColorPoint이면 색상까지 비교한다.
    return super.equals(o) && this.color == ((ColorPoint) o).color;
  }
}

이는 대칭성을 지키지만 추이성을 깨버린다.

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

그렇다면 근본적인 해결책은 무엇일까..?
사실 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 수 있는 방법은 존재하지 않는다.

아래코드처럼 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있을 것만 같다. 한번 살펴보자.


class Point {
	private final int x, y;
	private static final Set<Point> unitCircle = Set.of(
    	new Point(0, -1),
   		new Point(0, 1),
   		new Point(-1, 0),
   		new Point(1, 0)
  	);

  	public static boolean onUnitCircle(Point p) {
    	return unitCircle.contains(p);
  	}

    @Override
    public boolean equals(Object o) {
    	if(o == null || o.getClass() != this.getClass()) {
        	return false;
    	}

      	Point p = (Point) o;
      	return this.x == p.x && this.y = p.y;
    }
}

이제 값을 추가하지 않는 방식으로 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(); }
}

리스코프 치환 원칙에 따르면, 어떤 타입에 있어 중요한 속성은 그 하위 타입에서도 마찬가지로 중요하다.
이 예시에 따르자면 Point 의 하위 클래스는 정의상 여전히 Point 이므로, 어디서든 Point 로서 활용될 수 있어야 한다.
하지만 CounterPoint 의 인스턴스를 onUnitCircle 메서드에 넘기면 Point 클래스의 equals를 getClass 를 통해 작성한 만큼, 같은 점이더라도 equals에서 다르게 나오게 된다.

상속대신 컴포지션을 사용하여 코드 수정

public ColorPoint {
	private Point point;
	private Color color;

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

	public Point asPoint() {
    	return this.point;
  	}

  	@Override
  	public boolean equals(Object o) {
    	if(!(o instanceof ColorPoint)) {
      		return false;
    	}
    	ColorPoint cp = (ColorPoint) o;
    	return this.point.equals(cp) && this.color.equals(cp.color);
  	}
}

괜찮은 우회 방식으로는 Point를 상속하는 대신 Point 를 ColorPoint 의 private 필드로 두고, ColorPoint 와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public 으로 추가하면 equals 가 정상적으로 작동할 것이다.

일관성(consistency)

일관성이란 두 객체가 같다면 앞으로도 영원이 가아야 한다는 뜻이다.(두 객체 다 수정되지 않는다는 전제 하에)

null-아님

모든 객체가 null 과 같지 않아야 한다는 뜻이다.
equals 메서드는 입력이 null 인지를 확인하는 검사가 필요하지 않다. 아래 코드처럼 묵시적 null 검사로 instance 를 검사할 때 자체적으로 이루어지는게 낫다.

@Override
public boolean equals(Object o) {
	if(!(o instanceof MyType))
    	return false;
    MyType mt = (MyType) o;
}

양질의 equals 메서드 구현 방법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.

위의 주의사항들을 반영한 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();
        this.lineNum = rangeCheck();
    }
    
    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 도 재정의하자,
  • 너무 복잡하게 해결하려 들지 말자
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자

그리고 무엇보다 재정의할거면 AutoValue 프레임워크를 사용하여 클래스에 annotation 하나만 추가하여 직접 작성하지 말도록 하자.

profile
하루하루는 성실하게 인생전체는 되는대로

0개의 댓글