Hanbit the Developer

Effective Kotlin | 6장. 클래스 설계 본문

Mobile/Kotlin

Effective Kotlin | 6장. 클래스 설계

hanbikan 2023. 8. 12. 21:35

아이템 36: 상속보다는 컴포지션을 사용하라

상속의 장점

  • 다형성을 활용할 수 있다.

상속의 단점

  • 복잡한 계층구조가 만들어지기 쉽다.
  • 인터페이스 분리 원칙 위배: 불필요한 것까지 가져온다.
  • 코드를 읽기 어렵다.

컴포지션 장점

  • 다른 클래스의 public한 것들에만 의존하므로 안전하다.
  • 유연하다.

컴포지션 단점

  • 상속 사용 시 보다 코드를 수정해야 하는 경우가 더 많다.

→ 일반적으로 컴포지션을 사용하는 것이 좋다.

→ 상속을 사용하는 경우: 명확한 ‘is-a’ 관계

아이템 37: 데이터 집합 표현에 data 한정자를 사용하라

data 한정자를 붙이면 다음과 같은 함수가 자동으로 생성된다:

  • equals()
  • hashCode()
  • toString()
  • copy()
  • componentN()

장점:

  • 가독성: Pair<String, String> vs FullName(val firstName: String, val lastName: String)
  • 함수의 리턴 타입이 더 명확해진다.
  • 구조 분해 시, 다른 이름을 사용하면 경고 메시지가 뜬다.

튜플 사용을 지양할 것

*사용하는 경우:

  • 값에 간단하게 이름을 붙일 때
  • 미리 알 수 없는 aggregate를 표현할 때

아이템 38: 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라

함수 타입이 다양한 지원을 받을 수 있고 널리 사용되기 때문에, SAM 대신 함수 타입을 써야 한다.

typealias로 타입에 이름을 붙일 수도 있다:

typealias OnClick = (View) -> Unit

단, 코틀린이 아니라 자바 등 다른 언어에서 사용할 클래스를 설계할 때는 SAM을 사용하는 것이 좋다. 함수 타입으로 만들면 타입 별칭이나 IDE 지원 등을 제대로 못 받기 때문이다.

아이템 39: 태그 클래스보다는 클래스 계층을 사용하라

태그 클래스: 상수 ‘모드’를 가진 클래스

이를 사용할 시의 단점:

  • 여러 모드를 처리하기 위한 boilerplate 발생
  • 일관성, 정확성이 떨어지기 쉽다.
  • 팩토리 메서드를 사용해야 하는 경우가 많다. 그렇지 않으면 객체가 제대로 생성되었는지 확인하는 것이 어렵다.

→ sealed를 사용하라.

단, 태그 클래스와 상태 패턴(예시: 여러 상태로 구분할 수 있는 뷰)을 구분할 필요가 있다.

아이템 40: equals의 규약을 지켜라

동등성

  • ==, equals(): 값이 완전히 같은가?
  • ===: 같은 주소를 가리키는가?

*data class의 경우, equals()는 기본 생성자의 값들을 비교한다.(즉, 내부 프로퍼티는 무시한다.)

equals의 규약

  • 반사적(reflexive) 동작: x.equals(x)는 true를 반환해야 한다.
  • 대칭적(symmetric) 동작: x.equals(y)는 y.equals(x)와 같다.
  • 연속적(transitive) 동작: x, y가 같고 y, z가 같다면 x, z도 같다.
  • 일관적(consistent) 동작: 비교에 사용되는 프로퍼티가 달라진 게 아니라면, x.equals(y)는 언제나 같은 결과를 반환해야 한다.
  • 널과 관련된 동작: x.equals(null)은 항상 false를 리턴한다.

*전제: 위에서 x, y, z는 null이 아니다.(null이라면 메소드 호출이 안 되므로)

equals 직접 구현하기

특별한 이유가 없는 이상 직접 구현하는 것은 좋지 않다.

직접 구현해야 한다면, 규약을 잘 지키며 final로 만드는 것이 좋다. 상속을 지원하면서도 완벽하게 작동하는 equals를 자체 정의하는 것은 거의 불가능에 가깝다.(추상화의 이점을 포기하지 않는 한)

아이템 41: hashCode의 규약을 지켜라

가변성과 관련된 문제

LinkedHashSet에 들어가는 item은 불변이어야 한다.

val person = FullName("A", "B")
val s = mutableSetOf<FullName>()

s.add(person)
person.firstName = "C"

print(person in s) // false

hashCode의 규약

  • equals에서 비교에 사용되는 정보가 수정되지 않는 이상, 여러 번 호출해도 같은 결과가 나와야 한다.
  • equals의 결과가 같다고 나온다면, hashCode의 호출 결과도 같다고 나와야 한다.

+추가로 hashCode의 결과값은 최대한 범위 내에서 ‘넓게’ 나와야 성능이 좋다.

구현 예시

override fun hashCode(): Int {
	return millis.hashCode() * 31 + timeZone.hashCode()
}

아이템 42: compareTo의 규약을 지켜라

compareTo의 규약

  • 비대칭적 동작
  • 연속적 동작
  • 코넥스적 동작

*너무 당연한 내용이어서 설명 생략

사실 따로 정의할 일이 거의 없으며, 다음과 같은 함수들을 사용하곤 한다:

  • sortedBy: names.sortedBy { it.surname }
  • sortedWith: names.sortedWith(compareBy({ it.surname }, { it.name })), 자주 쓰는 경우 특정 클래스의 companion 객체로 만들어도 좋다.
  • compareValues(surname, other.surname)
  • compareValuesBy(this, other, { it.surname }, { it.name })

아이템 43: API의 필수적이지 않는 부분을 확장 함수로 추출하라

확장 함수 vs 멤버

  • 확장 함수는 import 해야한다.
  • 확장 함수는 virtual이 아니다. 컴파일 시점에 정적으로 동작하며 오버라이드 불가능하다.
  • 멤버가 높은 우선 순위를 갖는다.(같은 시그니처의 확장 함수와 멤버가 있다면 멤버가 호출된다.)
  • 확장 함수는 클래스 위가 아니라 타입 위에 만들어진다. 따라서 nullable이나 구체적인 제네릭 타입에도 정의가 가능하다.(예시: CharSequence?.isNullOrBlank(), Iterable<Int>.sum())
  • 확장 함수는 클래스 레퍼런스에 나오지 않는다.

아이템 44: 멤버 확장 함수의 사용을 피하라

이유:

  • 레퍼런스 미지원(e.g. book::isPhoneNumber)
  • 암묵적 접근 시 어떤 리시버가 선택되는지 혼동된다.
  • class A { val a = 10 } class B { fun A.test() = a + b }
  • 해당 함수가 어떤 동작을 하는지 명확하지 않다.
  • 직관적이지 않다.

*가시성을 제한한다고 클래스 내부에 배치한다고 해서 제한되지 않는다. top level에 정의한 뒤 한정자를 사용하라.