본문 바로가기
책/이펙티브 코틀린

2부 코드설계 > 6장 클래스 설계 > equals, hashCode, compareTo / 확장함수

by 정선한 2022. 9. 27.
728x90
반응형
2부 코드설계 > 6장 클래스 설계 > equals의 규약을 지켜라.
2부 코드설계 > 6장 클래스 설계 > hashCode의 규약을 지켜라.
2부 코드설계 > 6장 클래스 설계 > compareTo의 규약을 지켜라.
2부 코드설계 > 6장 클래스 설계 > API의 필수적이지 않는 부분을 확장 함수로 추출하라.
2부 코드설계 > 6장 클래스 설계 > 멤버 확장 함수의 사용을 피하라.

오늘의 TIL 3줄 요약

  • equals, hashCode, compareTo 메서드의 기본 사용법
  • 확장 함수를 사용하는 방법과 활용하는 방법

1. 책에서 기억하고 싶은 내용

equals

  • 구조적 동등성 (structural equality) : ==, != 으로 확인하는 동등성.
  • 레퍼런스적 동등성 (referential equality) : ===, !== 으로 확인하는 동등성, true를 반환.

equals는 모든 클래스의 슈퍼클래스인 Any에 구현되어 있으므로 모든 객체에서 사용할 수 있다. 일반적으로 코틀린에서는 equals를 직접 구현할 필요가 없지만 특정 상황에 따라 equals를 직접 구현해야 하는 경우가 존재한다.

  • 기본적으로 제공되는 동작과 다른 동작을 해야 하는 경우
  • 일부 프로퍼티만으로 비교해야 하는 경우
  • data 한정자를 붙이는 것을 원하지 않거나, 비교해야 하는 프로퍼티가 기본 생성자에 없는 경우

equals의 규약

  • 반사적 동작 (reflexive)
    • if (x != null)
      => x.equals(x) => return true
    • equals 규약이 잘못되면, 컬렉션 내부에 해당 객체가 포함되어 있어도, contains메서드 등으로 포함되어 있는지 확인할 수 없다.
  • 대칭적 동작 (symmetric)
    • if (x,y != null)
      => x.equals(y) == y.equals(x)
    • 동등성 비교가 대칭적으로 동작하지 못하면 결과를 신뢰할 수 없다. 
      또한 예상하지 못한 오류가 발생할 수 있으며 이것을 디버깅 중에 찾기 어렵다. 그러므로 동등성을 구현할 때  항상 대칭성을 고려해야 하며 결론적으로 다른 클래스는 동등하지 않게 만들어 버리는 것이 좋다.
  • 연속적 동작 (transitive)
    • if (x,y,z != null)
      => x.equals(y) y.equals(z) => return true
      => x.equals(z) => return true
  • 일관적 동작 (consistent)
    • if (x,y != null)
      => x.equals(y) => return true (always)
    • equals는 반드시 비교 대상이 되는 두 객체에만 의존하는 순수함수여야 한다.
  • 널과 관련된 동작
    • if (x != null)
      => x.equals(null) => return false
    • null과는 같을 수 없다.

hashCode

hashCode함수는 수많은 컬렉션과 알고리즘에 사용되는 자료구조인 해시 테이블을 구축할 때 사용된다.

  • hashTable은 알고리즘들의 성능 개선을 위한 해결방법으로 사용된다.
  • 해시 테이블은 각 요소에 숫자를 할당하는 함수가 필요하며 이 함수를 해시 함수라고 부른다.

해시함수는 빠르고, 충돌이 적은 특성을 갖고 있으면 좋다.

  • “같은 요소라면 항상 같은 숫자를 리턴한다.”
  • 해시 함수는 각각의 요소에 특정한 숫자를 할당하고, 이를 기반으로 요소를 다른 버킷에 넣는다.
  • 해시 함수의 기본적인 조건에 의해서 같은 요소는 항상 같은 버킷에 넣는다.
  • 버킷은 버킷 수와 같은 크기의 배열인 해시 테이블에 보관된다.

해시 코드는 요소가 추가될 때만 계산한다. 따라서 요소가 변경되어도 해시 코드는 계산되지 않으며, 버킷 재배치도 이루어지지 않는다.
그렇기 때문에 LinkedHashSet, LinkedHashMap의 키는 한 번 추가한 요소를 변경할 수 없다.

  • mutable 객체가 사용 되지 않는다. 따라서 set, map 의 키로 mutable 요소를 사용해서는 안되며,
    사용하더라도 요소를 변경 해서는 안된다.
  • 이러한 이유로 immutable 객체를 많이 사용한다.

hashCode를 구현할 때에는 언제나 equals와 일관된 결과가 나오도록 구현해야 한다.

  • equals를 따로 정의했다면, 반드시 hashCode도 함께 정의해 주어야 한다.

compareTo

compareTo 메서드는 Any 클래스에 있는 메서드가 아니라 수학적인 부등식으로 변환되는 연산자이다. Comparable <T> 인터페이스에도 들어있다.

  • 어떤 객체가 이 인터페이스를 구현하고 있거나 compareTo 라는 연산자 메서드를 가지고 있다는 의미는 해당 객체가 어떤 순서를 가지고 있으므로 비교할 수 있다는 의미를 가진다.

compareTo의 동작

  • 비대칭적 동작 : 비교와 동등성 비교에 어떠한 관계가 있어야 하며, 서로 일관성이 있어야 한다.
  • 연속적 동작 
    • if (a >= b && b >= c)
      => if (a >= c) return true
    • 이러한 동작을 수행하지 못하면 요소 정렬이 무한 반복에 빠진다.
  • 코넥스적 동작 (connex relation)
    • 두 요소는 확실한 관계를 가지고 있어야 한다.
    • 두 요소 사이에 관계가 없으면, 퀵 정렬과 삽입 정렬과 같은 고전적인 정렬 알고리즘을 사용할 수 없다. 대신 위상 정렬(topological sort)와 같은 정렬 알고리즘은 사용 가능.

객체가 자연스러운 순서인지 확실하지 않다면,
비교기(comparator)를 사용하는 것이 좋으며 이를 companion객체를 만들어 두면 유용하다. 일반적으로 측정 단위, 날짜, 시간 등은 모두 자연스러운 순서를 가진다.

확장 함수

클래스의 메서드를 정의할 때, 메서드를 멤버로 정의할 것인지 확장 함수로 정의할 것인지를 결정해야 한다.

  • 이 두가지의 방법은 거의 비슷하다.
  • 따라서 두 방식 중에 어떤 방식이 우월하다고 할 수 없다. 장단점을 모두 갖고 있으므로 상황에 맞게 사용해야 한다.

멤버와 확장의 가장 큰 차이점은 확장은 따로 가져와서 사용해야 한다. 따라서 일반적으로 확장은 다른 패키지에 위치한다.

확장

  • 확장은 직접 멤버를 추가할 수 없는 경우, 데이터와 behavior를 분리하도록 설계된 프로젝트에서 사용된다.
  • 필드가 있는 프로퍼티는 클래스에 있어야 하지만, 메서드는 클래스의 public API만 활용한다면 어디에 위치해도 상관이 없다.
  • import 하여 사용한다는 특징 때문에 확장은 같은 타입에 같은 이름으로 여러개를 만들어 낼 수 있다. 여러 라이브러리에서 여러 메서드를 받을 수 있으며 충돌이 발생하지 않는다.
  • 단, 같은 이름으로 다른 동작을 하는 확장이 있는 것은 위험하므로 이럴땐 멤버 함수로 만들어서 사용하는 것이 옳다. 컴파일러는 항상 확장 대신 멤버 함수를 호출한다. (멤버가 우선순위가 더 높다.)
  • 확장은 virtual이 아니기 때문에 파생 클래스에서 오버라이드 할 수 없다. 확장 함수는 컴파일 시점에 정적으로 선택되며, 가상 멤버 함수와 다르게 동작한다. 따라서 상속을 목적으로 설계된 요소 에서는 확장 함수로 만들면 안된다. (확장 함수는 ‘첫 번째 아규먼트로 리시버가 들어가는 일반 함수’ 로 컴파일 되기 때문)
  • 확장 함수는 클래스가 아닌 타입에 정의한다. nullable 또는 구체적인 제네릭 타입에도 확장 함수를 정의할 수 있다.
  • 확장은 클래스 레퍼런스에서 멤버로 표시되지 않는다. 따라서 annotation processor가 따로 처리하지 않는다. (확장함수가 클래스 내부에 있는 것이 아님)

확장함수는 개발자에게 더 많은 자유와 유연성을 부여한다. 확장 함수는 상속, 어노테이션 처리 등을 지원하지 않고, 클래스 내부에 없기 때문에 혼동을 줄 수 있다.  API의 필수적인 부분은 멤버로 두는 것이 좋으나 필수적이지 않은 부분은 확장 함수로 만드는 것이 여러모로 좋다.

확장을 피해야 할 때

어떤 클래스에서 확장 함수를 정의할 때, 이를 멤버로 추가하는 것은 좋지 않다. 특히 가시성 제한을 위해 확장 함수를 멤버로 정의하는 것은 굉장히 좋지 않다.

  • 가시성을 제한하지 못한다. 단순하게 확장 함수를 사용하는 형태를 어렵게 만들뿐.
  • 좋지 않은 습관이다.
  • 확장 함수의 가시성을 제한하고 싶다면, 멤버로 만들지 말고, 가시성 한정자를 붙여주면 된다.

멤버 확장을 피해야 하는 이유

  • 레퍼런스를 지원하지 않음.
  • 암묵적 접근을 할 때, 두 리시버 중에 어떤 리시버가 선택될지 혼동.
  • 확장 함수가 외부에 있는 다른 클래스를 리시버로 받을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않음.
  • 경험이 적은 개발자는 확장함수를 보면 직관적으로 보지 못함.
728x90
반응형