ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 10. equals는 일반 규약을 지켜 재정의하라
    백수의 개발/이펙티브 자바 2019. 8. 13. 17:51

     

    equals 메서드는 객체 내의 정보들에 대한 동등성을 비교하기 위한 메서드이다.

    비교 가능한 객체의 경우 equals 메서드를 잘못 작성하게 되면 의도하지 않은 결과가 초래되니, equals를 재정의하는 방법에 대해 알아보자.

     

    equals를 재정의하지 않아도 되는 경우

    1. 각 인스턴스가 본질적으로 고유하다.

    데이터나, 객체의 정보를 표현하는 것이 아닌, 동작하는 개체를 표현하는 클래스가 이에 속한다.ex) Thread

     

    2. 인스턴스의 '논리적 동치성'을 검사할 일이 없다.

    java.util.regex.Pattern은 Pattern의 인스턴스가 같은(동치성) 정규표현식을 나타내는지 검사할 필요가 없다.

     

    3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.

    대부분의 Set 구현체들은 AbstractSet, List 구현체들은 AbstractList, Map 구현체들은 AbstractMap 으로 부터 구현된 equals를 상속받아 사용된다.

     

    4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

     

    5. 싱글턴을 보장한다.

    인스턴스 통제 클래스나, 열거타입(Enum)인 경우 객체 간 동등성과 동일성이 보장된다.

     

    equals를 재정의해야 할 때

    객체 식별성이 아니라 논리적 동치성을 확인해야하는데. 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다.

    주로 값 클래스(Integer, String처럼 값을 표현하는 클래스)들이 이에 속하게 된다.

     

    equals 메서드 재정의 규약

    1. 반사성(reflexivity)

    객체는 자기 자신과 같아야 한다.

    null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.

     

    x.equals(x) == true

     

    2. 대칭성(symmetry)

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

    null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.

    (x.equals(y) == y.equals(x)) == true

     

    이는 자칫하면 어길 수 있다. 아래 코드를 보자

    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;
    	}
    }

    CaseInsensitiveString 클래스는 String의 존재를 알지만, 반대의 경우 String 클래스는 CaseInsensitiveString 클래스를 알지 못한다.

    따라서 아래와 같이 String.equals(CaseInsensitiveString)에 대해서는 항상 false가 나올 것이다.

    CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Test");
    String s = "test";
    caseInsensitiveString.equals(s); //true
    s.equals(caseInsensitiveString); //false

    따라서 CaseInsensitiveString은 String과의 비교를 포기 해야할 것이다. 그래서 아래와 같이 equals를 수정하면 문제 없이 사용할 수 있을 것이다.

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

     

    3. 추이성(transitivity)

    첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다.

    null이 아닌 모든 참조 값 x, y, z,에 대해, x.equals(y)가 true이고, y.equals(z)도 true이면, x.equals(z)도 true이다.

    ((x.equals(y) == y.equals(z)) == x.equals(z)) == true

     

    추이성 또한 자칫하면 어기기 쉽다. 간단한 예시를 통해 보자.

    아래는 x, y좌표를 갖는 Point객체이다.

    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;
    	}
    }

    이를 확장하여 색상이 있는 Point객체를 만들고 아래와 같이 equals를 재정의 했다고 생각해보자.

    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 o) {
    		if(!(o instanceof ColorPoint)) return false;
    		return super.equals(o) && this.color == ((ColorPoint) o).color;
    	}
    }

    이러한 경우 아래와 같이 대칭성에 문제가 생긴다.

    ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);
    Point point = new Point(1, 2);
    
    colorPoint.equals(point); //false
    point.equals(colorPoint); //true

    ColorPoint.equals에서는 ColorPoint만 비교하기 때문에 생긴 문제이다. 

    그렇다면 ColorPoint에서 Point 객체를 비교할 때 색상을 제외하고 비교를 하면 어떻게 될까?

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

    이러한 경우 아래와 같이 의도하지 않게 추이성이 깨지게 된다.

    ColorPoint colorPoint1 = new ColorPoint(1, 2, Color.RED);
    Point point = new Point(1, 2);
    ColorPoint colorPoint2 = new ColorPoint(1, 2, Color.BLUE);
    
    colorPoint1.equals(point); //true
    point.equals(colorPoint2); //true
    colorPoint1.equals(colorPoint2); //false

    위의 Point와 ColorPoint예시와 같이 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 없다.

    게다가 이렇게 구체 클래스를 확장하여 equals를 재정의하면 무한 재귀(Infinite Recursion)에 빠질 수 있다.

     

    아래의 코드를 보자.

    class SmellPoint extends Point {
    	private final Smell smell;
    
    	@Override
    	public boolean equals(Object o) {
    		if(!(o instanceof Point)) return false;
    
    		if(!(o instanceof SmellPoint)) return o.equals(this);
    
    		return super.equals(o) && this.smell == ((SmellPoint) o).smell;
    	}
    }

    위와 같은 SmellPoint객체와 ColorPoint객체를 비교하면 어떻게 될까?

    Point colorPoint = new ColorPoint(1, 2, Color.RED);
    Point smellPoint = new SmellPoint(1, 2, Smell.SWEET);
    
    colorPoint.equals(smellPoint); // ???

    위 같은 경우를 보면 colorPoint.equals내에서 smellPoint.equals를 부르고, 다시 smellPoint.equals에서 colorPoint.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);
    	}
    }

    이와 같이 컴포지션을 이용하면 상속에 때문에 발생하는 문제들을 막을 수 있다.

     

    4. 일관성(consistency)

    두 객체가 같다면(어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.

    null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

    while(x.equals(y) == x.equals(y))

     

    5. null-아님

    null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.

    x.equals(null) == false

     

    이를 위해 우리가 아래처럼 명시적으로 null검사를 해줄 필요는 없다.

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

    단순히 묵시적으로 아래와 같이 하면 null에 대한 비교를 false로 해준다.

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

     

    equals 메서드 구현 단계

    1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.

    • 성능 최적화
    • equals가 복잡할 때 같은 참조를 가진 객체에 대한 비교를 안하기 위함

    2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.

    • 묵시적 null체크 용도
    • equals중에서는 같은 interface를 구현한 클래스끼리도 비교하는 경우도 있음

    3. 입력을 올바른 타입으로 형변환한다.

    • 앞서 instanceof 연산을 수행했기 때문에 실패하지 않음

    4. 입력 객체와 자기 자신에게 대응되는 핵심 필드들을 비교한다.

    • float, double는 compare함수로, 이를 제외한 기본타입은 ==을 통해 비교
    • 참조(reference) 타입은 equals를 통해 비교
    • 배열의 모든 원소가 핵심 필드라면 Arrays.equals를 사용
    • null이 의심되는 필드라면, Objects.equals(obj, obj)를 이용해 NullPointerException을 예방
    • 다를 확률이 높은 필드부터 비교
    • 비교하는 비용(시간복잡도)이 적은 비교를 먼저 수행

     

    마무리

    꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이 비교하고, 반사성, 대칭성, 추이성, 일관성, null-아님 이 5가지 규약을 확실히 지키자.

    댓글

Designed by Tistory.