[Java][Effective Java] item 10. equals는 일반 규약을 지켜 재정의하라 (대칭성, 추이성)

2022. 6. 7. 18:08JAVA/Effective Java

1. equals를 재정의하지 않아도 되는 상황

  • 각 인스턴스가 본질적으로 고유한 경우
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없는 경우
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는 경우
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없는 경우
    • equals를 호출하는 것을 막고 싶다면 다음과 같이 재정의할 수 있습니다.
@Override
public boolean equals(Object o){
	throw new AssertionError();	// 호출금지
}

 

2. equals를 재정의해야 하는 상황

equals를 재정의해야 할때는 객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때입니다.

 

논리적 동치성을 확인해야 하는 대표적인 사례가 값 클래스가 해당됩니다. 값 클래스란 IntegerString 클래스처럼 값을 표현하는 클래스를 의미합니다. 프로그래머는 String과 같은 객체가 물리적 주소가 같은지 확인하는 것이 아닌 String 객체가 가지고 있는 값이 동일한지 확인하는 경우가 많습니다. 따라서 String 클래스에서 재정의되어 있는 equals 메서드를 호출하여 객체의 속성이 갖고 있는 값이 같은지를 확인합니다.

 

3. equals 재정의시 일반 규약

equals 메서드는 동치관계(equivalence relation)를 구현하며, 다음은 만족해야 합니다.

  • 반사성(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다.

 

4. 대칭성(symmetry)이 위배되는 경우

// 코드 10-1 잘못된 코드 - 대칭성 위배 (54-55p)
public 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;
	}
	
	// 문제 시연 (55p)	
	public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        String s = "polish";
        
        System.out.println("cis.equals(s) : " + cis.equals(s));	// true
        System.out.println("s.equals(cis) : " + s.equals(cis)); // false

	}
}
  • cis.equals(s) : s가 String 클래스이므로 대소문자를 무시하고 같은지 확인합니다.
  • s.equals(cis) : String 클래스의 equals 메서드는 CaseInsensitiveString 클래스의 존재를 모르므로 false를 반환합니다. 이는 대칭성을 위배합니다.

위 대칭성 문제를 해결하기 위해서는 equals를 다음과 같이 변경합니다. 다음과 같이 변경하면 CaseInsensitiveString 클래스의 equals 메서드는 매개변수로 CaseInsensitiveString 클래스 타입 인스턴스만을 받아야지 비교할 수 있습니다.

@Override
public boolean equals(Object o) {
    if(o instanceof CaseInsensitiveString){
        CaseInsensitiveString cis = (CaseInsensitiveString) o;
        return o.s.equalsIgnoreCase(this.s);
    }
}

 

정리하면 equals를 재정의해야 할때 대칭성을 만족시키기 위해서는 매개변수로 받은 Object o 인스턴스가 호출한 인스턴스의 클래스 타입과 동일해야 합니다. 따라서 equals 메서드안에 조건문을 다음과 같이 추가해야 합니다.

@Override
public boolean equals(Object o){
	if(o instanceof 클래스타입){ // 인스턴스 o가 클래스타입인 경우에만 비교를 수행
    	클래스타입 that = (클래스타입) o; // downcasting
        return (this.속성1.equals(that.속성1) && this.속성2.equals(that.속성2) && ...);
    }
    return false;
}

 

 

5. 추이성(transitivity)이 위배되는 경우

추이성을 만족한다는 뜻은 첫번째와 두번째 객체가 같고, 두번째 객체와 세번째 객체가 같다면, 첫번째 객체와 세번째 객체도 같아야 한다는 뜻입니다. 

 

다음 예제는 Point 클래스와 ColorPoint 클래스를 구현한 것이고 Point 클래스가 부모 클래스, ColorPoint 클래스가 자식 클래스인 경우입니다.

public 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) {
			Point p = (Point) o;
			return this.x == p.x && this.y == p.y;
		}
		return false;
	}
}
// Point에 값 컴포넌트(color) 추가 (56p)
public class ColorPoint extends Point{
	private final Color color;

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

위와 같은 상태에서 ColorPoint 클래스가 equals를 호출한다면 ColorPoint 클래스 자신이 equals를 재정의하지 않았기 때문에 Point.equals가 호출될 것입니다. 이는 ColorPoint 클래스의 color 필드멤버를 무시한채 비교를 수행하는 것입니다.

		// 두 ColorPoint 인스턴스의 비교
		ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
		ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
		
		System.out.println(cp1.equals(cp2)); // true

위와 같이 두 ColorPoint 인스턴스는 x,y 좌표는 같지만 색상(color)이 다르기 때문에 논리적 동치성으로는 서로 다릅니다. 하지만 ColorPoint 클래스의 equals를 재정의하지 않는다면 Point.equals가 수행되기 때문에 color 필드멤버를 무시한채 비교하고 두 객체가 같다고 판단하는 것입니다.

 

따라서 ColorPoint의 color 필드멤버까지도 비교하기 위해서 ColorPoint에 equals를 재정의합니다.

	// 코드 10-2 잘못된 코드 - 대칭성 위배 (57p)
	@Override
	public boolean equals(Object o) {
		
		if(!(o instanceof ColorPoint)) {
			return false;
		}
		
		return super.equals(o) && ((ColorPoint) o).color == color;
	}
  • equals 메서드는 ColorPoint 인스턴스만을 매개변수로 받아서 비교를 수행합니다.
  • super.equals(o) : Point.equals 메서드를 호출합니다.
  • Point.equals 메서드와는 다르게 색상까지 비교합니다.

위와 같이 재정의를 하였지만 위 코드는 대칭성을 위배합니다. 대칭성 위배를 확인하기 위해서 다음과 같이 테스트합니다.

		Point p = new Point(1, 2);
		ColorPoint cp = new ColorPoint(1, 2, Color.RED);
		
		System.out.println("p.equals(cp) : " + p.equals(cp));	// true
		System.out.println("cp.equals(p) : " + cp.equals(p));	// false

위와 같이 일반 Point를 ColorPoint에 비교한 결과와 그 둘을 바꿔서 비교한 결과가 다를 수 있습니다. Point 클래스 타입의 인스턴스로 ColorPoint와 비교한 이유는 ColorPoint 인스턴스는 Point 클래스의 관계가 자식-부모 관계이기 때문입니다.

 

우선 대칭성을 만족시키기 위해서 ColorPoint.equals 메서드를 다음과 같이 개선합니다.

	public boolean equals(Object o) {
		if(!(o instanceof Point)) {
			return false;
		}
		
		// o가 일반 Point이면 색상을 무시하고 비교
		if(!(o instanceof ColorPoint)) {
			return o.equals(this);
		}
	
		// o가 ColorPoint이면 색상까지 비교한다
		return super.equals(o) && ((ColorPoint) o).color == color;
	}
		ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
		Point p2 = new Point(1, 2);
		ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
		
		System.out.println("p1.equals(p2) : " + p1.equals(p2));	// true
		System.out.println("p2.equals(p3) : " + p2.equals(p3));  // true
		System.out.println("p1.equals(p3) : " + p1.equals(p3));  // false
  • 추이성을 만족시키기 위해서는 p1과 p2가 같고 p2와 p3가 같다면 p1과 p3이 같아야만 합니다.
  • p1.equals(p2) : p2가 Point 인스턴스이므로 x,y만 비교하여 true를 반환합니다.
  • p2.equals(p3) : p2가 Point 인스턴스이므로 x,y만 비교하여 true를 반환합니다.
  • p1.equals(p3) : 둘다 ColorPoint이므로 색상까지 비교합니다. 그러나 색상이 달라서 false를 반환합니다. 이는 추이성을 위배합니다.

위와 같이 재정의시 대칭성을 지켜주지만 추이성을 위배합니다. 또한 위와 같은 방식은 무한 재귀에 빠질 위험이 있습니다. 예를 들어서 Point의 또다른 하위 클래스인 SmellPoint 클래스를 정의하고 ColorPoint 클래스와 동일한 방식으로 equals 메서드를 재정의합니다. 그리고 ColorPoint의 인스턴스인 myColorPoint와 SmellPoint의 인스턴스인 mySmellPoint 인스턴스를 생성하고 둘이 논리적 동치성이 같은지 비교한다고 가정한다면 다음과 같이 호출할 수 있습니다.

public class SmellPoint extends Point{
	private final String smell;

	public SmellPoint(int x, int y, String smell) {
		super(x, y);
		this.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) && ((SmellPoint) o).smell.equals(smell);
	}
	
	public static void main(String[] args) {
		ColorPoint myColorPoint = new ColorPoint(1, 2, Color.RED);
		SmellPoint mySmellPoint = new SmellPoint(1, 2, "a");
		
		// stackoverflowError 발생
		// ColorPoint <-> SmellPoint equals 메서드를 무한순환함
		System.out.println(myColorPoint.equals(mySmellPoint));
		
	}
}
  1. myColorPoint.equals(mySmellPoint) : mySmellPoint는 Point 클래스의 자식 클래스이여서 첫번째 조건문(!(o instanceof Point))를 통과하고 두번째 조건문에서 Point 클래스 취급을 받아서 SmellPoint.equals(ColorPoint)를 호출합니다.
  2. SmallPoint.equals에서도 위와 동일하게 Point 클래스 취급을 받아서 ColorPoint.equals(SmellPoint)를 호출합니다.
  3. 1번과 2번 과정을 계속 무한 반복하게 되어 StackOverflowError가 발생합니다.

instanceof 검사 대신 getClass 검사를 사용하여 해결을 시도 : 잘못된 방법

equals 메서드의 일반 규약에서 대칭성을 만족시키기 위해서 확장 클래스(ColorPoint)에서 확장 클래스의 부모 클래스(Point)와 비교하기 위해서 instanceof 검사를 통해서 비교하면 대칭성은 만족할 수는 있어도 추이성은 만족할 수 없습니다.

 

그렇다면 추이성을 만족시키기 위해서 instanceof 검사 대신 getClass 검사를 사용하여 equals 메서드로 비교시 매개변수로 받는 Object 인스턴스는 equals 메서드를 호출한 클래스의 타입과 동일한 클래스 타입이게 시도하겠습니다.

public 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) {
		// o가 null이거나 o가 Point 클래스 자체가 아닌 경우
		if(o == null || o.getClass() != getClass()) {
			return false;
		}
		Point p = (Point) o;
		return this.x == p.x && this.y == p.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 o) {
		if(o == null || o.getClass() != getClass()) {
			return false;
		}
		
		return super.equals(o) && ((ColorPoint) o).color == color;
	}
	
	public static void main(String[] args) {
		
		ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
		ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
		ColorPoint cp3 = new ColorPoint(1, 2, Color.RED);
		ColorPoint cp4 = new ColorPoint(1, 2, Color.RED);
		
		Point p1 = new Point(1, 2);
		Point p2 = new Point(1, 2);
		
		// 대칭성 만족 확인
		System.out.println(p1.equals(p2));	// true
		System.out.println(p2.equals(p1));	// true
		
		// 추이성 만족 확인
		System.out.println(cp1.equals(cp2)); // false
		System.out.println(cp2.equals(cp3)); // false
		System.out.println(cp1.equals(cp3)); // true
		
		System.out.println(cp1.equals(cp3)); // true
		System.out.println(cp3.equals(cp4)); // true
		System.out.println(cp1.equals(cp4)); // true		
		
				
	}
	
}

위와 같이 instanceof 검사 대신 getClass 검사로 개선한다면 Point 인스턴스와 ColorPoint 인스턴스간의 비교를 못하게 하고 Point 인스턴스 또는 ColorPoint 인스턴스끼리만 비교하게 하여 대칭성과 추이성을 만족시키는 것입니다. 그리고 ColorPoint 클래스 입장에서는 Point 클래스를 상속하면서 Color 필드 멤버 속성을 확장했기 때문에 확장에도 성공한 것 같습니다. 하지만 getClass 검사를 활용한 Point 클래스와 ColorPoint 클래스는 활용할 수 없습니다.

 

왜 getClass 검사를 활용하여 대칭성과 추이성을 만족하는 방법은 부적절한 방법인가?

Point의 하위 클래스 ColorPoint 클래스는 정의상 여전히 Point 클래스이므로 어디서든 Point로써 활용해야 하기 때문입니다. 그런데 getClass 검사를 활용한 방식은 비교하고자 하는 클래스가 부모, 자손을 가리지 않고 무조건 일치해야 하기 때문에 ColorPoint 클래스가 Point로써 활동하지 못합니다.

 

예를 들어 주어진 점이(반지름이 1인) 단위 원안에 있는지를 판별하는 메서드가 필요하다고 가정합니다. 다음은 이를 구현한 코드입니다. 여기서 Point 클래스의 equals는 getClass 검사를 사용하였습니다.

public 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) {
		// o가 null이거나 o가 Point 클래스 자체가 아닌 경우
		if(o == null || o.getClass() != getClass()) {
			return false;
		}
		Point p = (Point) o;
		return this.x == p.x && this.y == p.y;
	}
}
// Point의 평번한 하위 클래스 - 값 컴포넌트를 추가하지 않는다 (59p)
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();
	}
	
}
public class CounterPointTest {
	private static final Set<Point> unitCircle = Set.of(
			new Point(1, 0), new Point(0, 1),
			new Point(-1, 0), new Point(0, -1));
	
	public static boolean onUnitCircle(Point p) {
		return unitCircle.contains(p);
	}
	
	public static void main(String[] args) {
		Point p1 = new Point(1, 0);
		Point p2 = new CounterPoint(1, 0);	// CounterPoint 클래스 타입
		
		System.out.println(onUnitCircle(p1));	// true
		
		// true를 출력해야 하지만, Point의 equals가
		// getClass를 사용해 작성되었다면 그렇지 않다.
		System.out.println(onUnitCircle(p2));	// false
		
		
	}

}
  • onUnitCircle(p2) : p2의 참조변수 타입은 Point이지만 실제 가리키고 있는 타입은 CounterPoint 클래스 타입입니다. 해당 호출을 통해서 p2 인스턴스가 반지름이 1인 단위 원안에 있는지 검사합니다.

왜 onUnitCircle(p2)의 결과가 false가 나오는가?

CounterPoint 인스턴스의 x, y값이 단위 원안에 있는데도 불구하고 false를 반환하였습니다. 이러한 문제의 원인은 컬렉션 구현체(Set<Point> unitCircle)에서 주어진 원소를 담고 있는지를 확인하는 방법에 있습니다. onUnitCircle에서 사용한 Set을 포함하여 대부분의 컬렉션은 이 작업에 equals 메서드를 이용하는데, CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 때문(getClass 검사 방식을 사용했기 때문)입니다. 반면, Point의 equals를 instanceof 기반으로 올바로 구현했다면 CounterPoint 인스턴스를 건네줘도 onUnitCircle 메서드가 제대로 동작했을 것입니다.

 

위 예제와 같이 CounterPoint 인스턴스는 Point 클래스의 자식 클래스로써 기능함에도 불구하고 컬렉션이 true를 반환하지 못하는 것을 볼 수 있습니다. 이 예제를 통해서 getClass 검사 방식이 대칭성과 추이성은 만족시키지만 CounterPoint 인스턴스가 Point로써 작동하지는 않는 것을 볼 수 있습니다. 위와 같은 상황을 리스코프 치환 원칙을 위배했다고 말할 수 있습니다.

 

리스코프 치환 원칙이란 무엇인가?

리스코프 치환 원칙이란 어떤 타입(Point)에 있어 중요한 속성이라면 그 하위 타입(CounterPoint)에서도 마찬가지로 중요하기 때문에 그 타입의 모든 메서드(equals 등)가 하위 타입(CounterPoint)에서도 똑같이 잘 작동해야 한다는 원칙입니다.

이는 앞서의 "Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다"라고 다시 말할 수 있습니다.

 

6. 추이성(transitivity)을 회피하는 방법 : 상속 대신 컴포지션을 사용

5절에서와 같이 구체 클래스(Point)를 확장(ColorPoint)해 새로운 값(Color)을 추가하면서 equals 규약을 만족시킬 방법은 객체 지향적 추상화의 이점을 포기하지 않는 한 존재하지 않습니다.

 

하지만 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 우회하는 방법이 존재합니다. 그것은 상속 대신 컴포지션(Composition)을 사용하는 방법입니다.

 

Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드를 public으로 추가하는 식입니다.

// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60p)
public class ColorPoint {
	private final Point point;
	private final Color color;
	
	public ColorPoint(int x, int y, Color color) {
		point = new Point(x, y);
		this.color = Objects.requireNonNull(color);
	}
	
	// 이 ColorPoint의 Point 뷰를 반환한다.
	public Point asPoint() {
		return point;
	}

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

	@Test
	void test() {
		ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
		Point p2 = new Point(1, 2);
		ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
		
		System.out.println("p1.equals(p2) : " + p1.equals(p2));           // false
		System.out.println("p2.equals(p1) : " + p2.equals(p1));           // false
		
		System.out.println("p1.equals(p2) : " + p1.equals(p2));            // false
		System.out.println("p2.equals(p3) : " + p2.equals(p3));            // false
		System.out.println("p1.equals(p3) : " + p1.equals(p3));            // false
	}
}

위와 같이 ColorPoint 클래스를 정의시 ColorPoint 클래스는 Point 클래스의 속성(x, y)을 가지면서 Point 클래스와 비교시 동일하지 않을 수 있습니다. (Point.equals(ColorPoint) => false)

 

7. 대칭성(symmerty)과 추이성(transitivity)을 만족하는 예외적인 방법 : 추상 클래스 활용

5절에서 추이성을 만족하지 못한 원인은 부모 클래스인 Point 클래스가 일반 클래스이기 때문입니다. 하지만 만약 부모 클래스가 추상 클래스라면 하위 클래스가 equals 규약을 지키면서도 값을 추가할 수 있습니다. 예를 들어 아무런 값을 갖지 않는 추상 클래스인 Shape 클래스를 부모 클래스로 두고, 이를 확장하여 radius 필드를 추가한 Circle 클래스와, length와 width 필드를 추가한 Rectangle 클래스를 만들 수 있습니다. 즉, 상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 지금까지 이야기한 대칭성과 추이성의 문제들은 일어나지 않을 것입니다.

public abstract class Shape {
	public abstract double getArea();
}

public class Circle extends Shape{
	private static final double PI = 3.14;
	private final int r;
	
	public Circle(int r) {
		this.r = r;
	}
	
	@Override
	public double getArea() {
		return PI * r * r;
	}

	@Override
	public boolean equals(Object o) {
		if(o instanceof Circle) {
			Circle c = (Circle) o;
			return c.r == r;
		}
		return false;
	}
	
	public static void main(String[] args) {
		Shape s1 = new Circle(5);
		Shape s2 = new Circle(5);
		Shape s3 = new Circle(5);
		
		// 대칭성 만족
		System.out.println(s1.equals(s2));	// true
		System.out.println(s2.equals(s1));  // true
		
		// 추이성 만족
		System.out.println(s1.equals(s2));  // true
		System.out.println(s2.equals(s3));  // true
		System.out.println(s1.equals(s3));  // true
		
	}
}

위 예제와 같이 Shape 추상 클래스는 값을 갖지 않기 때문에 equals 메서드를 정의할 필요가 없고 자식 클래스인 Circle 클래스는 Circle 인스턴스 끼리만 비교하는 equals 메서드를 재정의하면 대칭성과 추이성을 만족할 수있게 비교할 수 있습니다.

 

References

source code : https://github.com/yonghwankim-dev/effective_java/tree/master/src/role10
effective java 3/E