[아이템 10] equals는 일반 규약을 지켜 재정의하라

gang_shik·2022년 2월 22일
0

Effective Java 3장

목록 보기
1/5
  • Object에서 재정의를 염두에 두고 설계된 메서드에 대해서 일반 규약이 정의되어 있음, 그래서 그 중 먼저 equals를 알아볼 것임

  • equals의 경우 재정의가 쉬워보이지만 고려할 부분이 꽤 있음, 이 문제를 회피하는 것은 재정의를 하지 않는 것이긴 함, 그렇게 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같아짐

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

  • 인스턴스가 본질적으로 고유하다

    • 동작하는 개체표현하는 클래스가 여기에 해당함

    • Thread가 좋은 예로, 해당 경우에는 굳이 재정의를 하지 않아도 됨 equals 메서드는 이러한 클래스에 맞게 구현된 것임

  • 인스턴스논리적 동치성검사할 일이 없다

    • java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는 즉, 논리적 동치성을 검사하는 방법도 있음

    • 만약 클라이언트가 이 방식을 원하지 않거나 필요하지 않다고 판단할 수 있음, 필요하지 않다고 판단되면 그냥 기본 equals 만으로 해결됨

  • 상위 클래스에서 재정의equals하위 클래스에도 딱 들어맞는다

    • Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고, List도 그렇고 Map도 그렇게 함, 상속받아서 그대로 쓰기 때문에 굳이 재정의할 필요 없음
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다

    • 만약 equals를 실수로라도 호출되는 것이 싫다면 아래와 같이 쓰면 됨
      @Override public boolean equals(Object o) {
      		throw new AssertionError(); // 호출금지
      }
  • equals 사용은 객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야하는데, 상위 클래스의 equals논리적 동치성을 비교하도록 재정의되지 않았을 때가 주로 있음(값 클래스(Integer, String 등)들이 여기에 해당함)

  • 두 값 객체를 equals로 비교하는 것은 객체가 같은지가 아니라 이 같은지를 알고 싶어하는 것임

  • equals논리적 동치성확인하도록 재정의해두면, 그 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응함은 무론 Map의 키Set의 원소사용할 수 있게됨

  • 만약 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하면 재정의하지 않아도 됨, 이런 클래스는 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 됨

논리적 동치성?

논리적 동치성

앞서 물리적으로 같은지 이 물리적 동치성==비교하는 것으로 메모리에 저장된 변수가 가지는 서로 같은지 비교하는 것임

이와 비교해서 논리적 동치성 비교는 equals참조 타입 변수비교하는 것으로, 여기서 비교할 핵심 값을 정하고, 핵심 값비교하여 두 객체가 서로 동등하면 논리적으로 같다고 할 수 있음

String s1 = "ten";
String s2 = "ten";
String s3 = new String("ten");
StringBuilder sb1 = new StringBuilder("ten");
StringBuilder sb2 = new StringBuilder("ten");

여기서 s1.equals(s2), s1.equals(s3)의 경우 true를 반환함, s1,s2 모두 문자열 리터럴을 쓴 것이기 때문에 두 객체가 서로 동일하고 동등

s1,s3의 경우는 서로 다른 객체를 가지고 있기 때문에 동일하지 않지만 그 객체들이 가진 상태값논리적으로 서로 같기 때문에 동등하다고 볼 수 있음

여기서 sb1.equals(sb2)의 경우 ten이 들어와도 false를 반환함, 왜냐하면 equalsString에서는 동일성 체크동등성 체크를 하여서 s1,s3가 equals를 하면 true를 반환하지만, StringBuilder의 경우 equalsObject 기준이기 때문에 동일성만 체크함, 그래서 false가 나오는 것이고 이 경우 재정의를 해줘야함

앞서 Pattern에서도 이런 특징을 활용해서 각각의 패턴에 대해서 맞는 것인지 검사를 할 수 있게 내부적으로 정의가 되어서 논리적 동치성을 비교해서 처리할 수 있는 것임


equals의 일반규약

  • equals 메서드동치관계를 구현하면, 아래의 조건을 만족해야함

    • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)true

    • 대칭성(symmetry) : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)truey.equals(x)true

    • 추이성(transitivity) : null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y)true이고 y.equals(z)truex.equals(z)true

    • 일관성(consistency) : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환함

    • null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)false

  • 위 규약을 어기면 프로그램이 이상하게 동작하거나 종료됨

  • 한 클래스의 인스턴스는 다른 곳으로 빈번히 전달됨, 그리고 컬렉션 클래스들을 포함해 수많은 클래스는 전달받은 객첵 equals 규약을 지킨다고 가정하고 동작함

  • 동치관계란, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산임, 이 부분집합을 동치류(동치 클래스)라 함

  • equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 함

반사성

  • 객체는 자기 자신같아야 한다는 뜻

  • 일부러 어기는 경우가 아니면 만족시키지 못하기가 더 어려움

  • 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것임

대칭성

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

  • 자칫하면 어길 수 있음, 만약 아래 예시와 같이 대소문자를 구별하지 않는 문자열을 구현했을 때 equals는 대소문자를 무시함

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 cis = new CaseInsensitiveString("Polish");
String s = "polish";
  • cis.equals(s)true를 반환하지만 s.equals(cis)false를 반환함, 이는 대칭성위배하는 것임

  • 왜냐하면 이는 equals를 재정의하지 않았으므로 StringCaseInsensitiveString의 존재를 모름 그래서 equals를 써도 같은것인지 모름, 그래서 수정을 하려면 아래와 같이 해줘야함

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

추이성

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

  • 만약 여기서 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가한다면 equals비교에 영향을 주는 정보를 추가한 것임(아래와 같이)

public Class Point {
		private final x;
		private final 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 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;
		}
		...//나머지 생략
}
  • 여기서 equals를 그대로 둔다면 규약을 어긴 것은 아니지만 중요한 정보를 놓치게 됨

  • 만약 위의 예시에서 위치와 색상이 같은 경우만 true를 반환한다면 아래와 같이 쓰게 되는데

@Override public boolean equals(Object o) {
		if (!(o instanceof ColorPoint))
				return false;
		return super.equals(o) && ((ColorPoint) o).color == color;
}
  • 이는 Point를 ColorPoint에 비교한 결과와 그 둘을 바꿔 비교한 결과가 다르게 나옴

  • Point의 equals는 색상을 무시하고 ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르기 때문에

  • 이를 그러면 ColorPoint.equals가 Point와 비교할 때 색상을 무시하게 아래와 같이 쓰면, 이는 대칭성을 지켜도 추이성을 깨버림

@Override public boolean equals(Object o) {
		if (!(o instanceof ColorPoint))
				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);
  • 위에서 p1.equals(p2)p2.equals(p3)true를 반환하지만 p1.equals(p3)false를 반환함, 이는 추이성을 위배해, 무한 재귀에 빠질 수 있음

  • 이것은 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제임

  • 객체 지향적 추상화를 포기하지 않는 한, 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않음

  • 이를 instanceof 검사를 getClass로 바꾼다면 true를 반환해 괜찮아 보이지만 실제로 쓸 수 없음

// 리스코프 치환 원칙 위배
@Override public boolean equals(Object o) {
		if (o == null || o.getClass() != getClass())
				return false;
		Point p = (Point) p;
		return p.x == x && p.y == 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로써 활용될 수 있어야 한다는 것을 말하는 것임

// 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화함
private static final Set<Point> unitCircle = Set.of(
				new Point( 1, 0), new Point( 0, 1),
				new Poitn(-1, 0), new Point( 0,-1));

public static boolean onUnitCircle(Point p) {
		return unitCircle.contains(p);
}
  • 그래서 위와 같은 방식에서 CounterPoint의 인스턴스를 onUnitCircle 메서드에 넘기면 false를 반환함

  • 그리고 컬렉션 구현체에서 주어진 원소를 담고 있는지를 확인할 때 CounterPoint는 어떤 Point와도 같을 수 없음

  • 반면 Point의 equalsinstanceof기반으로 올바르게 구현했다면 제대로 작동할 것임

  • 그래서 위와 같이 getClass 이런 방식도 쓸 수가 없음

  • 결국 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 상속 대신 컴포지션을 이용하는 것을 우회할 수 있음

  • Point를 상속하는 대신 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 추가하는 식으로 가능함(추상 클래스인 경우 이런 문제가 발생하진 않음)

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))
						return false;
				ColorPoint cp = (ColorPoint) o;
				return cp.point.equals(point) && cp.color.equals(color);
		}
		... //나머지 생략
}

일관성

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

  • 가변 객체는 비교 시점에 따라 서로 다를 수도 같을 수도 있지만 불변 객체는 한 번 다르면 끝까지 달라야함

  • 불변 클래스로 만들지 고민을 해야하고 불변 클래스로 만들기로 했다면 equals가 한 번 같다고 한 객체와 영원히 같다고 답하고 다르다고 한 객체와는 영원히 다르다고 답하도록 만들어야 함

  • 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안됨

  • 그래서 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야함

null-아님

  • 모든 객체가 null과 같지 않아야 함

  • 여기서 명시적으로 o==null을 할 필요는 없음

  • 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아야함, instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야함

@Override public boolean equals(Object o) {
		if (!(o instanceof MyType))
				return false;
		MyType mt = (MyType) o;
		...
}
  • 위처럼하면 입력이 null이면 타입 확인 단계에서 false를 반환하여 굳이 명시적으로 하지 않아도 됨

리스코프 치환 원칙?

리스코프 치환 원칙

OOP 법칙 중에 하나로 리스코프 치환 원칙은 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다는 것을 의미함

특정 메소드가 상위 타입을 인자로 사용한다고 할 때, 그 타입의 하위 타입도 문제없이 정상적으로 작동을 해야 한다는 것임

그래서 위에서 getClass를 통한 비교가 리스코프 치환 원칙을 위배한다고 한 것임 아래 예시 코드에서 CounterPoint의 인스턴스를 컬렉션에서 Point와 같을 수 없게 되는 것임, 그래서 리스코프 치환 원칙을 위배했다고 한 것, 상위 타입인 Point로 사용하는데 하위 타입이 문제가 없어야 하는데 문제가 생기기 때문에


equals 메서드 구현 방법

  • ==연산자를 사용해 입력이 자기 자신의 참조인지 확인

    • 자기 자신이면 true를 반환함
  • instanceof연산자로 입력이 올바른 타입인지 확인

    • 그렇지 않으면 false를 반환함

    • 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있음

    • 어떤 인터페이스는 자신을 구현한 (서로 다른) 클래스끼리도 비교할 수 있도록 equals를 수정할 수 있음, 이런 인터페이스를 구현한 클래스라면 equals에서 해당 인터페이스를 사용해야함

  • 입력을 올바른 타입으로 형변환

  • 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사

    • 하나라도 다르면 false를 반환함, 인터페이스 사용시, 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야함, 타입이 클래스라면 해당 필드에 직접 접근할 수도 있음
  • 이 구현 방법을 다 담아서 예시를 보면 아래와 같이 짜이게 됨

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(prefix, 999, "프리픽스");
				this.lineNum = rangeChekc(lineNum, 9999, "가입자 번호");
		}
	
		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;
		}
		...// 나머지 코드 생략
}
  • floatdouble을 제외한 기본 타입 필드==연산자로 비교하고 참조 타입 필드는 각각의 equals메서드로 함

  • floatdouble필드는 각각 정적 메서드인 Float.compare(float, float), Double.compare(double, double)로 비교함(부동소수값을 다뤄야해서)

  • 배열 필드는 원소 각각을 앞서의 지침대로 비교함, 배열의 모든 원소가 핵심 필드라면 Arrays.equals메서드들 중 하나를 사용하면 됨

  • null도 정상값으로 취급하는 참조 타입 필드도 있음 Object.equals(Object, Object)로 비교해 NullPointerException방지할 수 있음

  • 아주 복잡한 필드를 가진 클래스의 경우 표준형을 저장하고 표준형끼리 비교하는게 나음, 이것은 불변 클래스일 경우 제격이고 가변 객체라면 바뀔 때마다 갱신해줘야함

  • 어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우함

  • 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하면 됨

  • 동기화용 락 필드 같이 객체의 논리적 상태와 관련없는 필드는 비교하면 안됨, 파생 필드를 비교하는게 더 나을때가 있음(파생 필드가 객체 전체의 상태를 대표하는 상황인 경우)

  • 만약 자신의 영역을 캐시해두는 클래스가 있다면 일일이 비교할 필요없이 캐시해둔 영역만 비교하면 됨

필드의 표준형?

표준형

표준형은 위와 같이 equals연산을 할 때 비용을 줄일 수 있음

이는 왜냐하면 만약 equals가 가능한 여러개의 객체가 있다면 이를 일일이 하나의 형태로써 바꾸어서 비교를 하게 되면 상당한 비용을 쓰게됨

그래서 표준형을 써서 이러한 비교를 줄여서 쓸 수 있음

그러면 String에서 볼 수 있듯이 굳이 upper, lower로 다 바꿔주지 않고 표준형을 만들어둬서 비교연산을 하면 이 바꾸는 비용을 쓰지 않고 표준형을 통해서 비교연산을 더 수월하게 할 수 있는것임


주의사항

  • equals를 구현했다면 3가지를 자문해볼 수 있음 대칭적인가? 추이성이 있는가? 일관적인가?, 이를 단위테스트를 통해 확인할수도 있음, 실패한다면 원인을 찾아 고치면 됨, 반사성null-아님이 문제가 되는 경우는 드뭄, 그리고 추가 주의사항은 아래와 같음

  • equals를 재정의할 땐 hashCode도 반드시 재정의해야함

  • 너무 복잡하게 해결하려 들지 말자

    • 필드들의 동치성검사해도 equals규약을 어렵지 않게 지킬 수 있음

    • 너무 공격적으로 파고들다가 문제를 일으킴, 별칭은 비교하지 않는게 좋음

  • Object외의 타입을 매개변수로 받는 equals메서드는 선언하지 말자

public boolean equals(MyClass o) // 입력 타입은 반드시 Object여야함
@Override public boolean equals(MyClass o) // 컴파일되지 않음
  • equals를 작성하고 테스트를 해야하는데 여기서 AutoValue를 사용하면 이런 작업을 줄일 수 있음

AutoValue?

AutoValue

AutoValue는 equals 재정의 등의 작업을 반복적으로 하는 것을 피하기 위해서 어노테이션을 통해서 자동으로 이 작업을 처리해주는 프레임워크를 말함

애너테이션 긍정 오류?

애너테이션 긍정 오류
public boolean equals(MyClass o) // 입력 타입은 반드시 Object여야함
@Override public boolean equals(MyClass o) // 컴파일되지 않음

위의 경우가 긍정 오류임 왜냐면 코드에는 문제가 보이는 것 같아 보이지 않지만 실제 진단시 문제가 있어 고쳐야 함을 의미함

즉, 형식 자체로는 Object대신해서 MyClass를 받은 것이기 때문에 틀리지 않음 하지만 애초에 equals 재정의에 있어서 Object타입을 매개변수로 받아야 하기 때문에 오류인 것임

그래서 코드 상으로 그리고 일반적인 통론상으론 문제가 없지만 equals 재정의에 맞지 않아서 잘못된 것이고 실제로 하더라도 컴파일이 안되고 오류가 나옴

profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글