본문 바로가기

Effective Java

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

Object는 구체 클래스지만 기본 적으로 상속해서 사용하도록 설계 되었다.

Object에서 final이 아닌 메서드(equals, hashcode, toString...)들은 모두 오버라이딩을 염두에 두고 설계 되었기 때문에 모든 클래스(Object를 상속하는)는 이 메서드를 규약에 맞게 재정의 해야한다.

equals는 재정의하기 쉬워보이지만 곳곳에 함정이 있어 아래 상황중 하나라도 해당하면 재정의하지 않는 것이 좋다.

  • 각 인스턴스가 본질적으로 고유하다.
    대부분 값 표현이 아닌 동작하는 개체의 클래스가 해당한다. ex)Thread
  • 인스턴스의 '논리적 동치성(equivalence)'을 검사할 일이 없다.
     Pattern 클래스의 equals가 두 인스턴스가 같은 정규 표현식을 나타내는지 검사하는 의도로  만들지 않을 것이라면 equals를 재정의 하지 않아도 된다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
     대부분의 SET,List 구현체는 AbstractSET, List 가 구현한 equals를 상속받아 쓴다.
  • 클래스가 private이거나 package-private이고 equals를 호출할 일이 없다.

그렇다면 equals는 언제 재정의 해야할까?

객체 식별성이 아니라 동치성을 확인해야 하는데 상위 클래스에서 그렇게 정의 되지 않았을 때 이다. 이 경우 대부분 값 클래스들이며 사용자들은 이 클래스의 객체가 똑같냐가 아니라 값이 똑같은지 궁금해 한다.

값 클래스더라도 Enum 같은 객체가 하나 뿐인 싱글턴 이라면 재정의 하지 않아도 된다.

 

equals 메서드 재정의시 지켜야할 규약

  • 반사성(reflexivity) X -> X | x.equals(x) => true
     즉, 객체는 자기 자신과 같아야 한다.

  • 대칭성(symmetry) X == Y 이면 Y == X  |  x.equals(y) == y.equals(x) => true
    package com.road3144.ehcahe;
    
    public final class CaseInsensitiveString {
    	private final String s;
    	
    	public CaseInsensitiveString(String s) {
    		this.s = 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;
    	}
    }​
     위 CaseInsentiveString(CIS)은 일반 문자열과도 비교를 하기 때문에 대칭성을 위반한다.
    CIS 객체 c 와 String 객체 s 가 있다 가정하면 c의 equals는 s를 알지만 s의 equals는 c를 모르기 때문에
    c.equals(s)는 true 지만 s.equals(c)는 false가 나오게 된다.
    CIS객체를 컬렉션에 넣어도 문제가 생긴다. equals 규약을 어기면 그 객체를 사용하는 다른 객체가 어떻게 동작할지 알수 없다.

  • 추이성(transitivity) X -> Y, Y -> Z 이면 X -> Z
    추이성 위반은 새로운 필드를 추가하며 하위클래스를 만드는 경우 쉽게 발생한다.
    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))
    			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 규약을 어기진 않았지만 색상 정보를 무시하고 비교했기 때문에 논리적 결함이다.
    그렇다고 아래 처럼 해도 해결 되진 않는다
    	// 대칭성 위배
    	@Override public boolean equals(Object o) {
    		if (!(o instanceof ColorPoint))
    			return false;
    		return super.equals(o) && ((ColorPoint) o).color == color;
    	}​
    p.equals(cp) != cp.equals(p)cp1.equals(p)는 참이고 p.equals(cp2)는 참이지만 cp1.equals(cp2)는 false이다

    모든 객체 지향 언어에서 구체를래스를 확장해 새로운 값을 추가하면서 equals 규약을 지킬 방법은 없다.
    instanceof 를 getClass로 바꾸면 같은 구현 클래스의 객체만 비교 할 수 있어 가능해 보이지만 이는 LIP를 위반한다.
    LIP에 따르려면 Point의 하위 클래스는 언제든 Point로 활용 가능해야 하지만 getClass로 하게 되면 CP의 x, y값과 상관 없이 컬렉션에서 문제가 된다.

    이는 컴포지션을 사용해 해결한다.
    Point를 상속하지 않고 ColorPoint의 private 필드로 두고 일반 Point를 반환하는 view메서드를 public으로 추가한다
    public class ColorPoint extends Point {
    	private final Color color;
    	private final Point point;
    
    	public ColorPoint(int x, int y, Color color) {
    		point, new Point(x, y);
    		this.color = color;
    	}
    
    	public Point asPoint() {
    		return Point;
    	}
    
    	@Override public boolean equals(Object o) {
    		if (!(o instanceof ColorPoint))
    			return o.equals(this);
    		ColorPoint cp = (ColorPoint) o;
    		return cp.point.equals(point) && cp.color.equals(color);
    	}
    }​
    ※ 지금 까지 말한 문제는 상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 일어나지 않는다.
    ex) 인터페이스 -Shape - Circle, Rectangle

  • 일관성(consistency) 
    일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 
    클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 껴있으면 안된다. java.net.URL의 equals는 매핑된 호스트의 IP를 이용하여 비교하는데 그 결과가 같다고 보장할 수 없다.  equals는 항시 메모리에 존재하는 객체만을 사용하여 deterministic한 계산만 해야한다.
  • null 아님
    모든 객체가 null이 아니여야 한다. 받은 인자를 instanceof로 검사하여 형태를 확인 후 형 변환 해야 한다.
    이는 ClassCastException을 막을 수 있고 null 검사를 명시적으로 하지 않아도 되는 장점이 있다.

 equals 구현 단계

  1.  == 연산자로 입력이 자기 자신 참조인지 확인.
    성능 최적화용, 비교작업이 복잡할 시 유용
  2. instanceof 연산자로 입력 타입 확인
    인터페이스 구현 클래스라면 해당 인터페이스를 피연산자로 설정 ex)Set, Map, List
  3. 입력을 올바른 타입으로 형 변환 한다.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는 지 하나씩 검사
    모든 필드가 일치하면 true. 인터페이스라면 필드 값을 가져올 때도 인터페이스의 메서드를 사용해야 한다
    == , equals, Type.compare 등으로 각 필드 확인
    null이 정상 값인 필드들은 Object.equals 로 NullPointException을 피한다.
    위의 CIS 처럼 비교가 복잡한 필드는 표준형을 만들어 저장해 둔 후 표준형 끼리 비교한다. 
    어떤 필드를 먼저 비교하냐에 따라 성능이 갈리기도 한다. 다를가능성이 크면서 비교하는 비용이 싼 필드를 먼저 비교하는 것이 좋다.
    파생 필드가 객체 상태 전체를 대표하는 경우 파생 필드를 먼저 비교하는데 더 빠를 때도 있다.
표준형 이란?
CIS 클래스의 equals가단순히 == 연산자로 비교할 수 있도록 형식을 지정해서 저장하는 것
ex) public CIS(String s ){ this.s = s.toLowerCase();  }

equals를 구현했다면 대칭적인지, 추이성이 있는지, 일관적인지 자문해 보고 단위 테스트를 돌려 보면 된다.

※주의사항

  • equals를 재정의할 때 hashcode도 반드시 재정의 해야한다.
  • 너무 복잡하게 해결하지 말자
    동치성만 검사해도 규약을 잘 지킬 수 있다. 파일의 이름(+경로)만 같다면 같은 것으로 취급하지 심벌릭 링크까지 비교하면 피곤하다
  • Object 외 타입을 매개변수로 받는 equals는 없다
    public boolean equals(MyClass o)는 Overriding이 아니라 Overroading이다.
    @Override 애너테이션을 습관화 하여 이런 실수를 예방하자