관리 메뉴

루시와 프로그래밍 이야기

ITEM10. equals는 일반 규약을 지켜 재정의하라 본문

스터디/이펙티브 자바

ITEM10. equals는 일반 규약을 지켜 재정의하라

Lucy_Ko 2022. 8. 31. 01:02

재정의하지 않는 경우

  • 각 인스턴스가 본질적으로 고유하다.
    클래스는 값 표현x 동작하는 개체를 표현 (ex. Thread)
  • 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
    위험 회피 스타일 / 실수로라도 호출되는 것이 싫다면 다음과 같이 구현하자
@Override public boolean equals(Object o){
	throw new AsserionError(); //호출금지!
}

Q. 언제 equals를 재정의해야 할까?

A. 논리적 동치성을 확인해야할 때, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때

즉, 논리적으로 같은 값인지 확인하고 싶을 때!

 

[동일성 vs 동등성] (https://sedangdang.tistory.com/224)

동일성(identical): 두개의 오브젝트가 완전히 동일함. (== 비교)

동등성(equivalent): 동일한 정보를 담고 있음 (equals() 비교)

- 두 오브젝트가 서로 동일하다면 사실 오브젝트는 하나만 존재하고 있는것이다. 같은 주소값을 참조하고 있는 것.

- 동일한 오브젝트는 동등하다. 하지만 동등한 오브젝트가 동일하지는 않다.

 

String a = "1234";
System.out.println(a.equals("1234")); //true
System.out.println(a.equals(1234)); //false

equals 규약

  • equals 메서드는 동치관계를 구현한다.
  • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
  • 대칭성(symmetry) : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
  • 추이성(transivity) : 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다.

 


2. 대칭성 위배 : cis.equals(s) == true / s.equals(cis) == false

package effectivejava.chapter3.item10;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    // 대칭성 위배!
    // cf) equalsIgnoreCase = 대소문자 구분없이 문자열 자체만으로 비교
    @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;
    }

//    // 수정한 equals 메서드 (56쪽)
//    @Override public boolean equals(Object o) {
//        return o instanceof CaseInsensitiveString 
//					&& ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
//    }

    // 문제 시연 (55쪽)
    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        String s = "polish";

        List<CaseInsensitiveString> list = new ArrayList<>();
        list.add(cis);
        System.out.println(list.contains(s));
        //equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
    }

}
  • cis.equals(s); // true
    • cis가 CaseInsensitiveString클래스 이므로
    • CaseInsensitiveString클래스의 오버라이드된 equals 함수를 타서
    • if (o instanceof String) 를 통해 true 리턴
  • s.equals(cis); // false
    • s가 String클래스 이므로
    • String클래스의 기존 equals 함수를 타서 다음 코드 참고
    • CaseInsensitiveString instanceof String이 아니므로 false
public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

※ 해결방법 : CaseInsensitiveString와  String을 equals로 연동하겠다는 허황된 꿈을 버리면 된다

cis.equals(s) == false / s.equals(cis) == false

 

 


3. 추이성 위배

상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자.

package effectivejava.chapter3.item10;

// 단순한 불변 2차원 정수 점(point) 클래스 (56쪽)
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;
    }

//    // 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
//    @Override public boolean equals(Object o) {
//        if (o == null || o.getClass() != getClass())
//            return false;
//        Point p = (Point) o;
//        return p.x == x && p.y == y;
//    }

    // 아이템 11 참조
    @Override public int hashCode()  {
        return 31 * x + y;
    }
}
package effectivejava.chapter3.item10.inheritance;

import effectivejava.chapter3.item10.Color;
import effectivejava.chapter3.item10.Point;

// Point에 값 컴포넌트(color)를 추가 (56쪽)
public class ColorPoint extends Point {
    private final Color color;

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

    // 코드 10-2 잘못된 코드 - 대칭성 위배! (57쪽)
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
    }

//    // 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
//    @Override 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;
//    }

    public static void main(String[] args) {
        // 첫 번째 equals 메서드(코드 10-2)는 대칭성을 위배한다. (57쪽)
        Point p = new Point(1, 2);
        ColorPoint cp = new ColorPoint(1, 2, Color.RED);
        System.out.println(p.equals(cp) + " " + cp.equals(p));

        // 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)
        ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
        Point p2 = new Point(1, 2);
        ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
        System.out.printf("%s %s %s%n",
                          p1.equals(p2), p2.equals(p3), p1.equals(p3));
    }
}

1. 첫번째 대칭성 위배

  • p.equals(cp) // true
    • Point의 equals는 색상을 무시해서 같다고 나오고
    • 직관적으로 생각할때도 Point 기준으로 p와 cp의 위치가 같으므로 같은 값인 true 반환
  • cp.equals(p) //false
    • ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false 반환
    • 직관적으로 생각할때도 ColorPoint기준으로 (Ponint) p에는 컬러가 없으니까 다른값인 false 반환

※ 대칭성 위배 해결방법 : o가 일반 Point면 색상을 무시하고 비교한다.

이 방식은 대칭성은 지켜주지만, 추이성을 깨버린다.

 

 

2. 두번째 추이성 위배

  • p1.equals(p2)
    • p2가 Point이므로 색상 무시하고 비교하여 ture (o가 일반 Point면 색상을 무시하고 비교)
  • p2.equals(p3)
    • Point인 p2기준에서 p3는 위치가 같으므로 true
  • p1.equals(p3))
    • ColorPoint인 p1기준에서 ColorPoint p3과 색까지 같아야 하는데 달라서 false

※ 추이성 위배 해결방법 : 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 X

→ 다음 코드로는 p1.equals(p3)) 가 Point로서만 체크하므로 true 나옴. 

@Override public boolean equals(Object o){
	if(o==null || o.getClass() != getClass())
    	return false;
    Point p = (Point) o;
    return p.x == x && p.y == y;
}

리스코프 치환 원칙 : 어떤 타입에 있어 중요한 속성이라면, 그 하위 타입에서도 마찬가지로 중요하다

"Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다"

package effectivejava.chapter3.item10.inheritance;
import effectivejava.chapter3.item10.Point;

import java.util.concurrent.atomic.*;

// Point의 평범한 하위 클래스 - 값 컴포넌트를 추가하지 않았다. (59쪽)
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(); }
}
package effectivejava.chapter3.item10.inheritance;
import effectivejava.chapter3.item10.Point;

import java.util.*;

// CounterPoint를 Point로 사용하는 테스트 프로그램
public class CounterPointTest {
    // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
    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);

        // true를 출력한다.
        System.out.println(onUnitCircle(p1));

        // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
        System.out.println(onUnitCircle(p2));
    }
}

set을 포함한 대부분의 컬렉션은 contains에서 equals를 이용하는데

CounterPoint(p2)의 인스턴스는 어떤 Point와도 같을 수 없기 때문에 onUnitCircle(p2)  false 출력

 

※ 해결방법 : Point의 equals를 instanceof기반으로 올바로 구현했다면 제대로 동작할 것

→ 우회방법 : ITEM 18 "상속 대신 컴포지션을 사용하라"

Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고,

ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드(ITEM 6)를 public으로 추가하는 방식 ??

package effectivejava.chapter3.item10.composition;

import effectivejava.chapter3.item10.Color;
import effectivejava.chapter3.item10.Point;

import java.util.Objects;

// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)
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);
    }

    @Override public int hashCode() {
        return 31 * point.hashCode() + color.hashCode();
    }
}

 

 

 


4. 일관성 : 두 객체가 같다면 앞으로도 영원히 같아야한다 (가변이든 불변이든)

 


5. null-아님 : 모든 객체가 null과 같지 않아야 한다

NullPointException을 던지는 코드에서도 if(o==null) return false로 보호해야한다.

→ 그러나 instance검사를 하면 알아서 null검사가 진행된다.

 


※최종 정리 : equals 메서드 구현 방법

  1.  == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
  5. 자문하기
    1. 대칭적인가?
    2. 추이성이 있는가?
    3. 일관적인가?

package effectivejava.chapter3.item10;

// 코드 10-6 전형적인 equals 메서드의 예 (64쪽)
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  = rangeCheck(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;
    }

    // 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다(아이템 11)!
}

마지막 주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자(ITEM 11)
  • 너무 복잡하게 해결하려 들지 말지
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자
    입력타입이 달라지므로 재정의가 아니라 다중정의(ITEM 52)
  • 꼭 필요한 경우가 아니면 equals를 재정의하지 말자
Comments