ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 아이템 10. equals는 일반 규약을 지켜 재정의하라
    Study/Effective Java 2023. 5. 25. 04:12

    교재 : Effective java, 강의 : inflearn 백기선


    equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있어서 자칫하면 끔찍한 결과를 초래한다.

    다음에 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.

    1. 각 인스턴스가 본질적으로 고유하다.
      예) 싱글톤 Object, Enum
    2. 인스턴스의 ‘논리적 동치성(logical equality)’를 검사할 일이 없다.
    3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절하다.
    4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

     

    equals를 재정의해야할 때

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

    • 주로 값 클래스가 해당된다. (Integer, String)
    • 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은 지가 아니라 값이 같은 지를 알고 싶어 할 것이다.
    • 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다. (예; enum)

     

    equals 규약

    • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
      x.equals(x) = true
    • 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true다.
      x.equals(y) == y.equals(x)
    • 추이성(transitivity) : null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y)가 ture이고 y.equals(z)도 true면 x.equals(z)도 true다.
      x.equals(y) && y.equals(z), x.equals(z)
    • 일관성(consistency) : null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
      x.equals(y) == x.equals(y)
    • null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
      x.equals(null) == false

     

    반사성

    객체는 자기 자신과 같아야 한다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.

     

    대칭성

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

    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;
        }
    
        // 문제 시연 (55쪽)
        public static void main(String[] args) {
            CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
            String polish = "polish";
            System.out.println(cis.equals(polish)); // true
    //        System.out.println(polish.equals(cis)); // false 대칭성 위배. String은 CaseInsensitiveString의 존재를 모른다.
    
            List<CaseInsensitiveString> list = new ArrayList<>();
            list.add(cis);
    
            System.out.println(list.contains(polish)); // false
        }
    }

    이 문제를 해결하려면 CaseInsensitiveString의 equals를 String과도 연동하겠다는 허황된 꿈을 버려야 한다.

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

     

    추이성

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

    상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하여 equals 비교에 영향을 주는 정보를 추가해보자.

     

    2차원에서의 점을 표현하는 클래스 Point

    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 (this == o) {
                return true;
            }
    
            if (!(o instanceof Point)) {
                return false;
            }
    
            Point p = (Point) o;
            return p.x == x && p.y == y;
        }
        // ... 나머지 코드 생략
    }

     

    Point를 상속받아 Color 정보를 추가한 클래스 ColorPoint

    public class ColorPoint extends Point {
        private final Color color;
    
        public ColorPoint(int x, int y, Color color) {
            super(x, y);
            this.color = color;
        }
        // ... 나머지 코드 생략
    }

     

    Color 정보

    public enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET }

     

    ColorPoint의  equals 메서드를 재정의해보자.

     

    1. 대칭성 위배 예제

    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;
        }
    
        public static void main(String[] args) {
            Point p = new Point(1, 2);
            ColorPoint cp = new ColorPoint(1, 2, Color.RED);
            System.out.println(p.equals(cp) + " " + cp.equals(p)); // true false
        }
    }

    p.equals(cp)

    - Point의 equals는 색상을 무시하여 true를 반환한다.

    cp.equals(p)

    - ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 false를 반환한다.

     

     

    2. 추이성 위배 예제

    ColorPoint.equals가 Point와 비교할 때 색상을 무시하도록 해보자.

    public class ColorPoint extends Point {
        private final Color color;
    
        public ColorPoint(int x, int y, Color color) {
            super(x, y);
            this.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) {
            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));
        }
    }

    p1.equals(p2)

    - 입력 매개변수가 Point면 색상을 무시하고 비교하여 true를 반환한다.

    p2.equals(p3)

    - Point의 equals는 색상을 무시하여 true를 반환한다.

    p1.equals(p3)

    - p1, p3 비교에서는 색상까지 고려하므로 false를 반환한다.

     

    게다가 이 코드는 StackOverflowError 발생시킬 수 있는 위험한 코드다.

    public class SmellPoint extends Point {
        private 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;
    
            // o가 일반 Point면 색상을 무시하고 비교한다.
            if (!(o instanceof SmellPoint))
                return o.equals(this);
    
            // o가 ColorPoint면 색상까지 비교한다.
            return super.equals(o) && ((SmellPoint) o).smell.equals(smell);
        }
    }

    SmellPoint를 정의한 후 smellPoint.equals(colorPoint)가 호출이 되었다고 생각해보자.

    this가 SmellPoint, Object가 ColorPoint 이므로 return colorPoint.equals(smellPoint)가 호출되고

    ColorPoint에서는 같은 곳에서 return smellPoint.equals(colorPoint)가 호출되게 된다.

    서로의 equals 호출이 계속 일어나면서 StackOverflow가 발생하게 된다.

     

    ▼ StackOverflowError 발생하는 예제

    public class SmellPointTest {
        public static void main(String[] args) {
            SmellPoint p1 = new SmellPoint(1, 0, "sweet");
            ColorPoint p2 = new ColorPoint(1, 0, Color.RED);
            p1.equals(p2);
        }
    }

     

     

    3. 리스코프 치환 원칙 위배 예제

    equals 안의 instanceof 검사를 getClass 검사로 바꿔서, Point는 Point 끼리, 하위 클래스는 하위 클래스끼리 비교하도록 해보자.

    public class Point {
        private final int x;
        private final int y;
    
        public Point(int x, int y) {
            this.x = x;
            this.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;
        }
    }
    // Point의 평범한 하위 클래스 - 값 컴포넌트를 추가하지 않았다. (59쪽)
    // CounterPoint는 아무런 필드도 추가되지 않았기 때문에 상위 클래스의 equals를 그대로 사용하여도 되고, 상위클래스는 전혀 하위 클래스를 고려하지 않아도 된다.
    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(); }
    }

    CounterPointTest 코드

    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)); // Point가 네 개 들어있는 Set
    
        // 전달받은 Point가 들어있는지 확인하는 메서드
        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)); // false
        }
    }

     

    리스코프 치환 원칙이란 다음과 같다.

    ✔ 리스코프 치환 원칙(Liskov substitution principle)
    어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다.

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

    Set을 포함한 대부분의 컬렉션 작업은 equals 메서드를 이용하는데 CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없으므로 false가 반환되고, 리스코프 치환 원칙을 위배하게 된다.

     

    SOLID 원칙

    더보기
      약어 개념
    S SRP 단일 책임 원칙 (Single responsibility principle)
    : 한 클래스는 하나의 책임만 가져야 한다.
    O OCP 개방-폐쇄 원칙 (Open/closed principle)
    : 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
    L LSP 리스코프 치환 원칙 (Liskov substitution principle)
    : 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
    (= 하위 클래스의 객체가 상위 클래스 객체를 대체하더라도 소프트웨어의 기능을 깨트리지 않아야 한다.)
    I ISP 인터페이스 분리 원칙 (Interface segregation principle)
    : 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
    D DIP 의존 관계 역전 원칙 (Dependency inversion principle)
    : 프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다.

     

     

    구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

    자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다.

    public class EqualsInJava extends Object {
    
        public static void main(String[] args) {
            long time = System.currentTimeMillis();
            Timestamp timestamp = new Timestamp(time);
            // java.sql.Timestamp는 java.util.Date를 확장한 후 nanoseconds 필드를 추가했다.
            Date date = new Date(time);
    
            // 대칭성 위배! P60
            System.out.println(date.equals(timestamp)); // true
            System.out.println(timestamp.equals(date)); // false
        }
    }

    대칭성이 깨져있는 것을 볼 수 있다. Timestamp를 이렇게 설계한 것은 실수이니 절대 따라해서는 안된다.

    Timestamp API 설명에는 Date와 섞어 쓸 때의 주의사항을 언급하고 있다.

    https://docs.oracle.com/javase/8/docs/api/java/sql/Timestamp.html

     

    상속 대신 Composition을 사용하라 (아이템 18)

    괜찮은 우회 방법이 하나 있다.

    // 코드 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();
        }
    }
    public class ColorPointTest {
        // 단위 원 안의 모든 점을 포함하도록 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)); // Point가 네 개 들어있는 Set
    
        public static boolean onUnitCircle(Point p) {
            return unitCircle.contains(p);
        } // 전달받은 Point가 들어있는지 확인하는 메서드
    
        public static void main(String[] args) {
            Point p1 = new Point(1,  0);
            Point p2 = new ColorPoint(1, 0, Color.RED).asPoint(); // Point View
    
            System.out.println(onUnitCircle(p1)); // true
            System.out.println(onUnitCircle(p2)); // true
        }
    }

     

    일관성

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

    가변 객체는 비교 시점에 따라 달라질 수도 있지만 불변 객체는 한 번 다르면 끝까지 달라야 한다. equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.

    public class EqualsInJava extends Object {
        public static void main(String[] args) throws MalformedURLException {
            // 일관성 위배 가능성 있음. P61
            URL google1 = new URL("https", "about.google", "/products/");
            URL google2 = new URL("https", "about.google", "/products/");
            
            System.out.println(google1.equals(google2));
        }
    }

     

    java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데 그 결과가 항상 같다고 보장할 수 없다.

    이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야한다.

     

    null-아님

    실수로 NullPointException, ClassCastException, ... 을 던지는 경우도 허용하지 않는다.

     

     

    양질의 equals 메서드 구현 방법

    • == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
    • instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    • 입력을 올바른 타입으로 형변환한다.
    • 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
        @Override public boolean equals(Object o) {
    
            // 1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
            // 이는 단순한 성능 최적화용으로, 비교 작업이 복잡한 상황일 때 값어치를 할 것이다.
            if (this == o) {
                return true;
            }
    
            // 2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
            if (!(o instanceof Point)) {
                // 이 때의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만
                // 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다.
                // 어떤 인터페이스는 자신을 구현한 (서로 다른) 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다.
                // 이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야 한다.
                // Set, List, Map, Map.Entry 등의 컬렉션 인터페이스들이 여기 해당한다.
                return false;
            }
    
            // 3. 입력을 올바른 타입으로 형변환한다.
            // 앞서 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다.
            Point p = (Point) o;
    
            // 4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
            // - float, double을 제외한 (부동소수값 이슈) 기본 타입 필드는 == 연산자로 비교
            // - 참조 타입 필드는 각각의 equals 메서드로 비교
            // - float 필드 : Float.compare(float, float) 사용. Float.equals 사용 가능하나 오토박싱을 수반할 수 있어서 성능상 좋지 않다.
            // - double 필드 : Double.compare(double, double) 사용. Double.equals 사용 가능하나 오토박싱을 수반할 수 있어서 성능상 좋지 않다.
            // - 배열 필드 : 원소 각각을 앞서의 지침대로 비교. 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용
            // - null도 정상 값으로 취급하는 참조 필드일 경우 Objects.equals(Object, Object)로 비교하여 NPE 예방
            return p.x == x && p.y == y;
        }

     

    번거롭다!

    Tool을 사용하자.

    • 구글의 AutoValue 또는 Lombok을 사용한다.
      • AutoValue를 사용하려면 따라야하는 규약이 있어서 불편하다.
      • Lombok이 상대적으로 편하다.
        • @EqualsAndHashCode
    • IDE의 코드 생성 기능을 사용한다.
      • Field가 늘어날 때 다시 만들어야 해서 번거롭다.
    • 자바의 Record
      • 버전이 되고 적용 가능한 상황이라면 가장 추천하는 방법.

     

    equals 재정의 주의 사항

    • equals를 재정의할 때 hashCode도 반드시 재정의하자. (아이템 11)
    • 너무 복잡하게 해결하지 말자.
    • Object가 아닌 타입의 매개변수를 받는 equals 메서드는 선언하지 말자.

     

    댓글

Designed by Tistory.