반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 equals 메서드에 대해 설명한다.

 

Object.equals() 메서드

Object.equals() 메서드는 객체와 다른 객체가 동일한 지 여부를 반환한다. equals를 오버라이딩 하지 않았을 경우 최상위 객체인 Object의 메서드가 호출된다. 이 경우 오직 자기 자신하고만 같다. (메모리 주소가 동일)

 

아래는 Object.equals()의 코드이다.

public class Object {
	...
    
    public boolean equals(Object obj) {
        return (this == obj);
    }
}

 

Object.equals() 메서드를 오버라이딩하여 재정의할 때 준수해야 하는 일반 규약이 Object 클래스 명세서에 작성되어 있다.

 

  • It is reflexive: for any non-null reference value x, x.equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x.equals(null) should return false.

하나씩 알아보자.

 

Reflexive : 반사성

반사성이란 모든 객체는 자기 자신과 같아야 한다는 뜻이다. 이 규약을 의도적으로 깨트릴 수는 있으나, 그럴 이유도 없고 지키지 않기도 힘들다. 아래 코드는 의도적으로 깨트렸다.

public class ViolatingReflexiveTest {
	int i;

	public static void main(String[] args) {
		ViolatingReflexiveTest test = new ViolatingReflexiveTest();
		test.i = 1;

		System.out.println(test.equals(test)); // false
	}

	@Override
	public boolean equals(Object obj) {
		return ((ViolatingReflexiveTest) obj).i < this.i;
	}
}

 

Symmetry : 대칭성

대칭성이란 X와 Y가 같으면, Y도 X와 같아야 한다는 뜻이다. 이 규약은 쉽게 깨질 수 있다.

 

예를 들어 동일한(비슷한) 의미를 가진 서로 다른 클래스인 X와 Y가 존재한다고 하자. X는 Y와 의미가 비슷하기 때문에 자기 자신 클래스뿐만 아니라 Y 클래스와 호환되도록 equals 메서드에서 Y 클래스를 입력받아서 처리하도록 설계했다.

 

하지만, Y는 X 클래스가 구현되기 전에 구현된 클래스고 자기 자신인 Y만 입력받아서 equals 메서드를 처리하도록 하였다. 따라서 X.equals(Y)는 참일 수 있지만 Y.equals(X)는 X가 자기 자신 클래스가 아니기 때문에 거짓을 항상 반환할 것이다.

public class XClass {
	public int age;

	@Override
	public boolean equals(Object obj) {
		if (obj instanceof XClass) {
			return age == ((XClass) obj).age;
		}

		// X 클래스는 Y 클래스와도 비교를 한다.
		if (obj instanceof YClass) {
			return age == ((YClass) obj).years;
		}
		return false;
	}

}


public class YClass {
	public int years;

	@Override
	public boolean equals(Object obj) {
		if (obj instanceof YClass) {
			return years == ((YClass) obj).years;
		}
		return false;
	}

	public static void main(String[] args) {
		XClass xClass = new XClass();
		YClass yClass = new YClass();

		xClass.age = 10;
		yClass.years = 10;

		System.out.println(xClass.equals(yClass)); // true
		System.out.println(yClass.equals(xClass)); // false

	}
}

 

Transitivity : 추이성

추이성이란 수학에서 많이 봤던 “a=b이고 b=c이면 a=c이다.”과 동일한 의미이다.

 

먼저 이 예제를 보이기 위해 java.awt.Point 클래스를 상속하고, 색상을 추가로 가지는 ColorPoint를 구현한다. ColorPoint의 equals 메서드는 자신과 동일한 객체만 검사하며 부모 클래스인 Point의 equals 메서드와 색상을 비교하여 객체의 동일 여부를 판단하도록 구현하였다.

 

하지만 이는 이미 대칭성(symmetric)을 위반한다. Point를 ColorPoint와 비교하면 좌표 값(x, y)을 비교하지만, ColorPoint는 자신과 동일한 객체만 검사하므로 부모인 Point가 검사대상이 될 경우 false다.

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) {
		// ColorPoint 객체가 아닐 경우, 항상 false이다.
		if (!(obj instanceof ColorPoint)) {
			return false;
		}

		return super.equals(obj) && ((ColorPoint) obj).color == color;
	}

	public static void main(String[] args) {
		Point point = new Point(1, 2);
		ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);

		// Symmetry
		System.out.println(point.equals(colorPoint)); // true
		System.out.println(colorPoint.equals(point)); // false
	}
}

 

추이성을 테스트하기도 전에 대칭성을 위반해버린다. 대칭성을 지키기 위해 Point 객체가 아닐 경우 false를 리턴하도록 변경하고, Point 객체이면 색상은 제외한 좌표만 비교하는 로직을 넣게 되면 대칭성이 보존된다.

public class ColorPoint extends Point {
	private final Color color;

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

	public ColorPoint(Point point, Color color) {
		super(point);
		this.color = color;
	}

	@Override
	public boolean equals(Object obj) {
		// Point 객체가 아닐 경우, 항상 false를 리턴
		if (!(obj instanceof Point)) {
			return false;
		}

		// ColorPoint가 아닌 Point 객체일경우, 색상은 비교하지 않고 좌표만 비교
		if (!(obj instanceof ColorPoint)) {
			return obj.equals(this);
		}

		return super.equals(obj) && ((ColorPoint) obj).color == color;
	}

	public static void main(String[] args) {
		Point point = new Point(1, 2);
		ColorPoint redColorPoint = new ColorPoint(point, Color.RED);

		// Symmetry
		System.out.println(point.equals(redColorPoint)); // true
		System.out.println(redColorPoint.equals(point)); // true

		ColorPoint blueColorPoint = new ColorPoint(point, Color.BLUE);

		// Transitivity violation
		System.out.println(redColorPoint.equals(point)); // true
		System.out.println(point.equals(blueColorPoint)); // true
		System.out.println(redColorPoint.equals(blueColorPoint)); // false
	}

}

 

하지만 위 코드는 이제 추이성을 위반한다.

point, redColorPoint, 그리고 blueColorPoint의 좌표는 (1, 2)로 동일하다. 따라서 아래와 같이 된다.

  1. redColorPoint와 point를 비교하면 좌표만 비교하므로 true를 리턴한다.
  2. point와 blueColorPoint를 비교하면 좌표만 비교하므로 true를 리턴한다.
  3. redColorPoint == point이고, point == blueColorPoint이므로 redColorPoint == blueColorPoint여야 하지만 ColorPoint 객체는 색상까지 비교하므로 둘의 색상은 다르다. 따라서 false를 리턴하여 추이성을 위반한다.

그렇다면 위와 같이 상속을 하여 구현한 클래스의 equals는 어떻게 구현해야 할까? 상속을 받아 새로운 값을 추가하여 equals를 만들 때 추이성 규약을 위반하지 않을 방법이 없다. 부모 클래스가 존재하는 한 이는 해결할 수 없다.

 

따라서 위와 같이 상속받아 구현하였을 경우 불가능하지만, 피할 수 있는 방법은 있다. 규칙 16에서 나올 '계승하는 대신 구성하라' 규칙을 사용하는 것이다. 즉 Point를 상속하지 말고 하나의 필드로 만들어서 사용하는 방법이다. 코드는 아래와 같다.

public class CorrectColorPoint {
	// Point를 상속하지 않고 필드로 구성하였다.
	private final Point point;
	private final Color color;

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

	@Override
	public boolean equals(Object obj) {
		if (obj == this) {
			return true;
		}

		if (!(obj instanceof CorrectColorPoint)) {
			return false;
		}

		CorrectColorPoint cp = (CorrectColorPoint) obj;

		return cp.point.equals(point) && cp.color.equals(color);
	}
}

 

 

equals 메서드를 구현할 때 instanceof 대신 getClass 메서드를 사용하면 상속을 하여도 추이성을 지킬 수 있다는 소문이 있다. 하지만 이는 SOLID 원칙 중 하나인 리스코프 대체 원칙(Liskov substitution principle)을 위반한다. 리스코프 대체 원칙 참고.

 

리스코프 대체 원칙은 간단하게 말하면 자식의 인스턴스를 부모의 메서드에 대입하여도 부모 메서드의 결과는 동일하다는 의미이다. 말이 어려운데 코드를 보자. 아래는 Point.equlas의 코드이다.

public class Point extends Point2D implements java.io.Serializable {
	...
    
    public boolean equals(Object obj) {
        if (obj instanceof Point) {
            Point pt = (Point)obj;
            return (x == pt.x) && (y == pt.y);
        }
        return super.equals(obj);
    }
    
}

 

이제 이 Point.equals 코드의 instanceof를 getClass로 대체하면 아래와 같다.

    public boolean equals(Object obj) {
        if (obj == null || obj.getClass() != getClass()) {
            Point pt = (Point)obj;
            return (x == pt.x) && (y == pt.y);
        }
        return false;
    }

 

이렇게 변경되면 무엇이 문제일까? Point의 equals 메서드에 자식 ColorPoint를 넣게 되면 false가 된다. 왜냐하면 항상 자신의 class, 즉 Point가 아닐 경우에는 항상 false를 리턴하기 때문이다. 이는 리스코프 대체 원칙을 위반한다.

 

Consistent : 일관성

일관성이란 일단 같다고 판정된 객체들은 이후에 변화가 없으면 계속 같아야 한다는 것이다.

 

java.net.URL의 equals 메서드는 URL에 대응되는 IP 주소를 비교하여 동일 여부를 판단하였다. 하지만 IP주소는 네트워크상에서 언제든 변경될 수 있으므로 일관성을 보장하지 않는다. 아래 코드를 보자

public class UrlEqulasTest {
	public static void main(String[] args) throws MalformedURLException, UnknownHostException {
		URL firstUrl = new URL("https://www.google.co.kr/");
		URL secondUrl = new URL("https://142.250.199.67/"); // 구글의 접속 IP는 다양하므로 테스트때마다 다름

		InetAddress address = InetAddress.getByName(firstUrl.getHost());
		System.out.println(address.getHostAddress()); // 142.250.199.67

		InetAddress address2 = InetAddress.getByName(secondUrl.getHost());
		System.out.println(address2.getHostAddress()); // 142.250.199.67

		System.out.println(firstUrl.equals(secondUrl)); // true
	}
}

필자 네트워크상으로 'https://www.google.co.kr/'의 IP는 142.250.199.67이다. 따라서 'https://142.250.199.67/'과 equlas로 비교하면 현재는 true를 반환한다.

 

하지만 시간이 흐르면 'https://www.google.co.kr/'의 IP는 DNS에 따라서 계속 변경되기 때문에 어느 순간에는 '142.250.199.67'이 아닐 수 있다. 그럴 때는 위의 결과는 false를 리턴한다.

 

이렇게 코드는 동일하지만 equlas의 결과가 변화한다면 일관성이 없는 것이다. 따라서 equals를 정의할 때는 해당 객체의 고유한 값들만을 이용하여 작성해야 한다.

 

Non-nullity : 널(Null)에 대한 비 동치성

object.equals(null)는 항상 false를 반환해야 한다.

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

 

위와 같이 작성하여도 되지만 instanceof에 null을 체크할 경우, 항상 false를 반환한다. 따라서 아래와 같이 작성하여 한번에 해당 자료형인지 확인도 하면서 null인지를 확인하도록 작성하자.

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

 

equals 메서드 구현 순서

  1. == 연산자를 사용하여 인자가 자기 자신인지 제일 먼저 검사하여 같다면 바로 true를 리턴한다. 성능을 위함이다.
  2. instanceof 연산자를 사용하여 인자의 자료형이 정확한지 검사한다.
  3. 인자의 자료형을 캐스팅한다.
  4. 동일함을 검사하는 필드를 각각 비교한다.
    • float와 double은 각각 Float.compare와 Double.compare를 사용하여 비교한다.
    • 필드의 비교 순서는 다를 가능성이 가장 높거나 비교 비용이 낮은 필드부터 비교하는 게 좋다.
  5. 마지막으로 equals의 일반 규약을 만족하는지 검사한다.
	@Override
	public boolean equals(Object obj) {
		// 1. 자기 자신인지 검사한다.
		if (obj == this) {
			return true;
		}
        
		// 2. 자료형을 검사한다.
		if (!(obj instanceof CorrectColorPoint)) {
			return false;
		}
        
		// 3. 캐스팅한다.
		CorrectColorPoint cp = (CorrectColorPoint) obj;

		// 4. 다를 가능성이 높은 순서대로 필드를 비교한다.
		return cp.point.equals(point) && cp.color.equals(color);
	}

 

Object.equals()를 오버라이딩 하지 않아도 되는 경우

Object.equals()를 하위 클래스에서 재정의 하지 않아도 되는 경우는 아래와 같다.

 

  • 각각의 객체가 고유하다. 클래스 특성상 객체가 고유할 수밖에 없는 경우에는 오버 라이딩할 필요가 없다. 예를 들어 Thread 같은 클래스가 있다.
  • 클래스에 논리적 동일성 검사 방법이 있건 없건 상관없다. 클래스 특성상 equals 메서드가 있어봤자 사용할 일이 거의 없을 때 오버라이딩 하지 않는다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에서 사용하기에도 적당하다. 예를 들어 대부분의 Set, List, Map 클래스들은 각각 AbstractSet, AbstractList, AbstractMap의 equals를 사용한다.

 

 

반응형

+ Recent posts