Search
Duplicate

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

작성자
챕터
3장 - 모든 객체의 공통 메서드
최종 편집
2023/07/16 05:54
생성 시각
2023/07/13 17:28

Equals 메서드

Object class의 equals 메서드는 == 연산자와 동일하게 주소값 비교를 통해서 비교하는 객체가 같은지를 비교하는 메서드이다. 하지만, Object 메서드를 상속하는 (Java에서 모든 클래스는 기본적으로 Object 클래스를 상속한다.) 하위 클래스에서 equals 메서드를 재정의 함으로써 주소값이 다른 객체지만 논리적 동치성을 판단해서 비교 동작을 수행하도록 할 수 있다.

모든 클래스에서 equals를 재정의 할 필요는 없다!

equals 메서드를 자칫 잘못 재정의 했다가는 예상치 못한 결과가 초래될 수 있기 때문에 다음과 같은 경우에는, 재정의하지 않는 것이 오히려 좋을 수 있다.
각 인스턴스가 본질적으로 고유하다. 즉, Thread 클래스와 같이 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 해당된다.
인스턴스의 논리적 동치성을 검사할 일이 없다. 이런 경우라면, Object 클래스의 기본 equals 만으로 해결이된다.
상위 클래스에서 재정의한 equals가 하위 클래스에도 들어맞는다.
클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

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

비교하고자 하는 객체가 물리적으로 같은지가 아니라 논리적 동치성을 확인해야 하는 경우
Enum과 같은 인스턴스 통제 클래스는 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않기 때문에, 사실상 논리적 동치성이 객체 식별성(물리적으로 동일함)과 똑같은 의미를 가진다. → 재정의할 필요가 없다.
일반적으로 Integer와 같이 값을 표현하는 클래스

equals 메서드를 재정의하기 위해서는 Object 명세에 적힌 일반 규약을 따라야 한다.

equals 메서드는 동치관계를 구현하며, 다음의 5가지 사항을 만족해야 한다.
반사성 : null이 아닌 모든 참조값 x에 대해, x.equals(x)는 true다. → 자신은 자기 자신과 같다.
대칭성 : x.equals(y) == true → y.equals(x) == true
추이성 : x.equals(y) == true && y.equals(z) == true → x.equals(z) == true
일관성 : x.equals(y) == true면 몇 번을 호출해도 항상 true이다. (false의 경우도 마찬가지)
null-아님 : null이 아닌 모든 참조값 x에 대해, x.equals(null) == false

동치관계란 무엇일까?

집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산
이 부분집합을 동치류(equivalence class)라 한다.
equals 메서드가 쓸모 있으려면, 모든 원소가 같은 동치류에 속한 어떤 원소와도 교환 가능해야 한다.
→ “같은 부분집합으로 묶인 원소들은 서로 같은 혹은 교환이 가능한 존재다” 라는 의미로 보임

대칭성 위배

public class Job { private final String name; private final int salary; private final int workingHours; private final int vacationDays; public Job(String name, int salary, int workingHours, int vacationDays) { this.name = name; this.salary = salary; this.workingHours = workingHours; this.vacationDays = vacationDays; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Job)) return false; Job job = (Job) o; return name.equals(job.name) && salary == job.salary && workingHours == job.workingHours && vacationDays == job.vacationDays; } }
Java
복사
public class Developer extends Job { private final String mainLanguage; public Developer(String name, int salary, int workingHours, int vacationDays, String mainLanguage) { super(name, salary, workingHours, vacationDays); this.mainLanguage = mainLanguage; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Developer)) return false; Developer developer = (Developer) o; return super.equals(o) && mainLanguage.equals(developer.mainLanguage); } }
Java
복사
public class item10 { public static void main(String[] args) { Job job = new Job("Developer", 1000, 8, 20); Developer developer = new Developer("Developer", 1000, 8, 20, "Java"); System.out.println("Job equals Developer = " + job.equals(developer)); System.out.println("Developer equals Job = " + developer.equals(job)); } } 결과 Job equals Developer = true Developer equals Job = false
Java
복사
Job 클래스와 이를 확장한 Developer 클래스가 있을 때, Job이 가지고 있는 4개의 필드가 모두 동일한 값을 갖도록 job과 developer객체를 생성한 후 재정의한 job의 equals 메서드와 developer의 equals메서드를 사용해서 둘을 각각 비교하면, 다른 결과값이 나온다. → 대칭성 위배
즉, job.equals(developer)의 경우, job이 가진 4개의 필드만을 이용해서 비교하기 때문에 논리적으로 동치인 결과가 나오지만, developer.equals(job)의 경우 Developer 클래스가 확장한 mainlanguage라는 필드를 추가로 비교하기 때문에 동치가 아니라는 결과가 나오게 된다.

추이성 위배

public class Developer extends Job { private final String mainLanguage; public Developer(String name, int salary, int workingHours, int vacationDays, String mainLanguage) { super(name, salary, workingHours, vacationDays); this.mainLanguage = mainLanguage; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Developer)) { return o.equals(this); // 입력 매개변수가 Developer 클래스의 인스턴스가 아닐 경우, 해당 매개변수의 equals로 비교(이 경우 Job 클래스의 equals 메서드) } Developer developer = (Developer) o; return super.equals(o) && mainLanguage.equals(developer.mainLanguage); } }
Java
복사
public static void main(String[] args) { Job job = new Job("Developer", 1000, 8, 20); Developer javaDeveloper = new Developer("Developer", 1000, 8, 20, "Java"); Developer cppDeveloper = new Developer("Developer", 1000, 8, 20, "C++"); System.out.println("cppDeveloper equals job = " + cppDeveloper.equals(job)); System.out.println("job equals javaDeveloper = " + job.equals(javaDeveloper)); System.out.println("cppDeveloper equals javaDeveloper = " + cppDeveloper.equals(javaDeveloper)); } 결과 cppDeveloper equals job = true job equals javaDeveloper = true cppDeveloper equals javaDeveloper = false
Java
복사
Developer 클래스의 equals메서드에서 다른 클래스의 매개 변수에 대해서는, 해당 클래스(Job 클래스)의 equals를 이용하여 두 객체를 비교하는 방식을 사용하여 대칭성 위배의 문제를 해결할 수 있다.
하지만, 위의 방법은 추이성을 위배하는 결과를 보여준다.
이러한 현상은, 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제이기에, 객체 지향적 추상화의 이점을 포기하지 않는다면, 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
public boolean equals(Object o) { if (this == o) return true; if (o == null || o.getClass() != getClass()) // instanceof 대신 getClass를 이용하여 같은 구현 클래스의 객체와 비교할때만 true를 반환하도록 한다. return false; Job job = (Job) o; return name.equals(job.name) && salary == job.salary && workingHours == job.workingHours && vacationDays == job.vacationDays; }
Java
복사
instanceof 대신 getClass 메서드로 객체를 검사하면 equals 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다.
하지만, Job의 하위 클래스(Developer)는 정의상 Job에도 해당하기 때문에 실제로 Job으로써 활용될 수 있어야 하지만, 위와 같은 방식을 활용하면 그렇지 못하기 때문에 실제로 활용할 수 있는 방법은 아니다.
→ 리스코프 치환 원칙에 따르면 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하기 때문이다. 즉, 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다는 뜻이다.

Note. 추상 클래스의 하위 클래스에서는 equals 규약을 지키면서도 값을 추가할 수 있다.

상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 위와 같은 문제들은 일어나지 않는다.

일관성

equals의 판단에 신뢰할 수 없는 자원이 끼어들면 안된다. 즉, equals는 항상 메모리에 존재하는 객체만을 사용한 결정적 계산만을 수행해야 한다.
→ 예를 들면, 네트워크를 통해 가져온 값을 이용한 계산을 수행한다면, 매번 결과가 같다고 보장할 수 없다.

null-아님

모든 객체가 null과 같지 않아야 한다.
명시적인 null 검사보다는 아래와 같이 묵시적인 null 검사를 하는 것이 낫다.
public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Job)) return false; Job job = (Job) o; return name.equals(job.name) && salary == job.salary && workingHours == job.workingHours && vacationDays == job.vacationDays; }
Java
복사
equals 메서드에서는 규약을 지키기 위해서 타입을 확인해야 한다. 타입을 확인하는 instanceof 메서드는 첫 번째 피연산자가 null이면 false를 반환하기 때문에 명시적인 null 검사가 필요없다.

equals 메서드 구현 방법 정리

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

주의사항

때로는 null도 정상 값으로 취급하는 참조 타입 필드도 있다. 이런 경우는 정적 메서드인 Object.equals()로 비교해서 NPE를 예방해야 한다.
복잡한 필드를 가진 클래스의 경우에는 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 경제적이다. → 보충
어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우할 수 있다. 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하는 것이 좋다. 객체의 논리적 상태와 관련없는 필드는 비교하면 안된다. (ex. 동기화용 lock 필드)
equals를 재정의할 땐 hashCode도 반드시 재정의해야 한다. (item11에서 후술)
Object외의 타입을 매개변수로 받는 equals 메서드를 선언하면 안된다. 이의 경우는 Object.equals를 재정의 한 것이 아니라 다중 정의 한 경우에 해당하기 때문이다.

AutoValue를 활용하자! →보충

equals 를 작성하고 테스트하는 일을 하기 위한 프레임워크 (by Google)
클래스에 annotation을 추가하면 알아서 해당 메서드들(equals, hashCode…)을 작성해준다.

질문 및 추가로 알아볼 만한 내용

집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산이란?
반사성 요건을 어기는 경우는 어떤 경우가 있을까? → 자신이 자기 자신과 다를 수가 있을까?
AutoValue 사용법