Hyeyeon blog

이펙티브 코틀린 - 5장 객체생성, 6장 클래스 설계 본문

개발/Android

이펙티브 코틀린 - 5장 객체생성, 6장 클래스 설계

Hyeyeon.P 2024. 4. 9. 05:00
반응형

5장 객체 생성

33. 생성자 대신 팩토리 함수를 사용하라
34. 기본 생성자에 이름있는 옵션 아규먼트를 사용하라
35. 복잡한 객체를 생성하기 위한 DSL을 정의하라 

6장 클래스 설계

36. 상속보다는 컴포지션을 사용하라
37. 데이터 집합 표현에 data 한정자를 사용하라
38. 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라 
39. 태그 클래스보다는 클래스 계층을 사용하라 
40. equals의 규약을 지켜라 
41. haseCode의 규약을 지켜라 
42. compareTo의 규약을 지켜라 
43. API의 필수적이지 않은 부분을 확장함수로 추출하라
44. 멤버 확장 함수의 사용을 피하라


33. 생성자 대신 팩토리 함수를 사용하라

* 팩토리 함수: 생성자 역할을 대신 해주는 함수 
* 생성자와의 차이점:
- 함수에 이름을 붙일 수 있다.
- 함수가 원하는 형태의 타입을 리턴할 수 있다.
- 호출될 때마다 새 객체를 만들 필요가 없다. 객체를 만들 수 없는 경우, null을 리턴할 수도 있다.
- 아직 존재하지 않는 객체를 리턴할 수 있다 --> 프로젝트 빌드하지 않고도 앞으로 만들어질 객체를 사용할 수 있다. 
- 생성자는 즉시 슈퍼클래스 또는 기본 생성자를 호출해야한다. 팩토리 함수는 원하는 때에 생성자를 호출할 수 있다. 

   1) Companion 객체 팩토리 함수 ⭐️

자바에서 온 규칙으로 익숙한 아래의 이름으로 인터페이스를 직접 구현한다.
- from, of, valueOf, instance/getInstance, createInstance/newInstance, getType, newType 

fun MyLinkedList<T>(val head:T, val tail: MyLinkedList<T>?) {
    companion object {
        fun <T> of (vararg elements: T): MyLinkedList<T>? {
           /* .. */
        }
    }
}

// 사용
val list = MyLinkedList.of(1, 2)

companion 객체 멤버는 단순한 정적 멤버 이상의 다양한 기능을 갖고있다. 
인터페이스 구현, 클래스 상속 등이 가능하다. 

abstract class ActivityFactory {
    abstract fun getIntent(context: Context): Intent
    
    fun start(con: Context) {
        val intent = getIntent(con)
        con.startActivity(intent)
    }
}

class MainActivity: AppCompatActivity() {
    // ...
    companion objecgt: ActivityFactory(){
        override fun getIntent(context: Context): Intent = Intent(context, MainActivity::class.java)
    }
}

// 사용
val intent = MainACtivity.getIntent(context)
MainActivity.start(context)

   2) 확장 팩토리 함수 

이미 존재하는 companion 객체를 활용해서 확장함수를 정의할 수 있다. 
이로 외부 라이브러리를 확장할 수 있으며, 최소한 비어있는 companion 객체가 필요하다.

// 최소한 비어있는 객체 필요 
interface Tool {
    companion object {}
}

// 확장 함수
fun Tool.Companion.createBigTool(..) : BigTool { .. }
Tool.createBigTool(..)

   3) 톱레벨 팩토리 함수

객체를 만드는 흔한 방법 중 하나로, listOf, setOf, mapOf가 있다. 

   4) 가짜 생성자

인터페이스를 위한 생성자를 만들거나, reified 타입 아규먼트를 갖게하고 싶을 때 가짜 생성자를 만든다. 
invoke 연산자를 갖는 companion 객체를 사용하면 비슷한 결과를 얻을 수 있다.
그러나 invoke는 '호출'한다는 의미지, '객체 생성' 의미가 아니기 때문에 원래 의미와 차이가 발생하여 비추천한다. 

   5) 팩토리 클래스와 메서드 

팩토리 클래스는 다양한 생성 패턴을 갖으나, 점층적 생성자 패턴과 빌더 패턴은 코틀린에서 무의미하다.

34. 기본 생성자에 이름있는 옵션 아규먼트를 사용하라 

  1) 점층적 생성자 패턴

점층적 생성자 패턴이란, 여러가지 종류의 생성자를 사용하는 패턴이다. 
코틀린에서는 디폴트 아규먼트를 사용하여 코드를 더욱 깔끔하게 만들어주고 아규먼트를 원하는 순서로 지정할 수 있다. 
또한 이름있는 아규먼트를 활용하여 의미를 명확하게 표현할 수 있다. 

// 점층적 생성자
class Pizza { 
   // ...
    constructor(size: String, cheese: Int, olives: Int, bacon: Int){
       this.size = size
       this.cheese = cheese  
       this.olives = olives
       this.bacon = bacon
    }
    constructor(size: String, cheese: Int, olives: Int): this(size, cheese, olives, 0)
    constructor(size: String, cheese: Int): this(size, cheese, 0)
    constructor(size: String): this(size, 0)
}

// 디폴트 아규먼트
class Pizze(val size:String, val cheese: Int = 0, val olives: Int = 0, val bacon: Int = 0)

  2) 빌더 패턴 

자바에서는 이름있는 파라미터와 디폴트 아규먼트를 사용할 수 없기에, 빌더 패턴을 사용한다. 
코틀린에서는 빌더 패턴의 장점이 없어서 빌더 패턴을 거의 사용하지 않고, 디폴트 아규먼트를 갖는 기본 생성자 또는 DSL을 사용한다. 
빌더패턴을 사용하는 다른 언어로 작성된 라이브러리를 그대로 옮길 때, 디폴트 아규먼트와 DSL을 미지원하는 다른 언어에서 사용할수 있는 API를 설계할ㄷ 때에만 사용한다.  

class Pizza private constructor(val size: String, val cheese: Int, ..) {
    class Builder(private val size: String) {
       private var cheese: Int = 0
       .. 

      fun setCheese(value: Int): Builder = apply {
         this.cheese = value
      }
      ..
      fun build() = Pizza(size, cheese, olives, bacon)
    }
}

// 사용
val pizza = Pizza.Builder("L").setOlives(3).build()
// 전통 빌더 패턴
val dialog = AlertDialog.Builder(context)
   .setMessasge(R.string.msg)
   .setPositiveButton(R.string.fire, {d, id -> }) 
   .. 

// 코틀린 DSL 빌더 패턴
val dialog = context.alert(R.string.msg) {
   positiveButton(R.string.fire) { .. }
   negativeButton { .. }
}

35. 복잡한 객체를 생성하기 위한 DSL을 정의하라 

* DSL 사용 시 특징 
 - 보일러플레이트와 복잡성을 숨기고 개발자 의도를 명확하게 표현할 수 있다. 
 - 많이 사용되는 구조의 반복을 제거할 수 있다.
 - 주로 리시버를 사용하는 함수로 구현된다. 
 - 복잡한 자료구조, 계층적인 구조, 거대한 양의 데이터를 표현하는 데에 유용하다. 

// 리시버를 사용하는 확장함수
fun String.printWithPrefix(prefix: String) {
    println("$prefix$this")
}

36. 상속보다는 컴포지션을 사용하라

아래의 단점을 고려하여 단순 코드 추출, 재사용을 위해서라면 상속보다 컴포지션을 사용하는 것이 좋다.
컴포지션이란, 객체를 프로퍼티로 갖고 함수를 호출하는 형태로 재사용하는 것을 의미한다. 

* 컴포지션 특징
- 다른 클래스의 내부 구현에 의존하지 않아 안전하다.
- 여러 클래스를 대상할 수 있기 때문에 유연하고 필요한 것만 받을 수 있다. 
- 객체를 명시적으로 사용해야해서 번거롭다. 

// 컴포지션 활용 예제
class Progress {
  fun showProgress() { .. }
}

class ProfileLoader {
  val progress = Progress()
  fun load() {
     progress.showProgress()
  }
}

class ImageLoader {
  val progress = Progress()
  fun load() {
     progress.showProgress()
  }
}

* 상속의 특징 
- 상속으로 행위를 추출하다보면 많은 함수를 갖는 거대한 BaseXXX 클래스가 만들어진다.
- 클래스의 모든 것을 가져오기 때문에 불필요한 함수도 갖게된다. 일부 재사용을 위한 목적의 상속은 부적합하다.  
- 상속용으로 설계된 메서드에 open을 붙여 상속을 허용한다.
- 상속을 제한할 메서드에 final을 붙여 하위 클래스가 상속하지 못하도록 오버라이딩을 제한한다. 

abstract lass InternetLoader(val show: Boolean){
   fun load() { 
     // 상속받는 일부 클래스에서 경고창 출력이 불필요하여
     // 파라미터가 있는 생성자를 사용하는 경우 (안티패턴) 
     if(show) {
       // 경고창 출력
     }
   }
}

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

- 함수의 리턴 타입이 명확해지며, 리턴 타입의 전달이 쉬워진다. 
- toString, equals, hashCode, copy, componentN 함수가 자동으로 생성된다. 
- 튜플(Pair, Triple) 대신 데이터 클래스를 사용한다. (튜플은 값에 간단하게 이름을 붙일 때 사용) 

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

- 대부분 언어에서는 연산, 액션을 전달할 때 하나의 메서드로 이루어진 인터페이스를 활용한다. (SAM: Single-Abstract Method)
- SAM은 코틀린이 아닌 다른 언어(Java 등)에서 사용할 클래스를 사용할 때만 사용하는 것이 좋다. 
- 함수 타입을 사용하여 다양한 방법으로 파라미터를 전달할 수 있다.
  1) 람다, 익명 함수로 전달 -> setOnClickListener { /* ..*/ }
  2) 함수 레퍼런스로 전달 -> setOnClickListener(::println) 
  3) 선언된 함수 타입을 구현된 객체로 전달 

39. 태그 클래스보다는 클래스 계층을 사용하라 

- 상수(constant) '모드'를 가진 클래스를 태그(tag)라고 부르며, 태그를 포함한 클래스를 태그 클래스라고 부른다. 
- 서로 다른 목적을 한 클래스에 태그로 구분해서 넣기 때문에, 상태의 일관성과 정확성을 지키기 어렵다.
- 코틀린에서는 타입 계층인 sealed 클래스를 주로 사용한다. 
   한 클래스에 여러 모드 생성이 아닌, 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용한다.  
- sealed 클래스는 여러 개의 상태로 구분할 수 있는 뷰를 가질 때 주로 활용된다. 

// 태그 클래스
fun area(shape: String, vararg dimens: Double): Double){
  return when(shape) {
    CIRCLE -> { .. }
    RECTANGLE -> { .. }
    else -> throw Exception()
  }
}

val circleArea = area(CIRCLE, 5.0)

// selaed 클래스
sealed class Shape {
  abstract fun area(): Double
}

class Circle(val radius: Double): Shape(){
  fun area(): Double { .. }
}

class Rectangle(val width: Double, val height: Double): Shape(){
  fun area(): Double { .. }
}

val circle = Circle(5.0)

40. equals의 규약을 지켜라 

  1) 동등성

- 구조적 동등성: == 연산자로 확인하는 동등성
- 레퍼런스적 동등성: === 연산자로 확인하는 동등성 (같은 객체를 가리키면 true) 

  2) equals의 규약 

- 반사적 동작: x가 null이 아니라면, x.equlas(x)는 true를 리턴해야 한다. 
- 대칭적 동작: x와 y가 null이 아니라면, x.equals(y)와 y.equals(x)는 같은 결과를 출력해야 한다. 
- 연속적 동작: x, y, z가 null이 아니고,  x.equals(y)와 y.equals(z)가 true라면 x.equals(z)도 true여야 한다. 
- 일관적 동작: x와 y가 null이 아니라면, x.equals(y)는 여러 번 실행하더라도 항상 같은 결과를 리턴해야 한다. 
- null 관련 동작: x가 null이 아니라면, x.equals(null)은 항상 false를 리턴해야 한다. 

  3) URL과 관련된 equals 문제

네트워크 상태에 따라 결과가 달라져서 일관성 동작에 위배된다. 그리고 URL 처리는 굉장히 느리다.

val eWiki = URL("https://..")
val wiki = URL("https://..")
println(eWiki == wiki)

  4) equals 구현하기

- 특별한 이유가 없는 이상, 직접 equals을 구현하지 않고 기본 제공되는 것을 그대로 사용하거나 데이터 클래스로 만들어서 사용하는 것이 좋다. 
- 직접 구현해야 한다면 equlas 6가지 규약을 충족하는 지 꼭 확인해야 한다.
- 직접 구현한 클래스는 final로 만드는 것이 좋다. 상속을 한다면 서브클래스에서 equals가 작동하는 방식을 변경하면 안된다. 
- 상속을 지원하는 완벽한 사용자 정의 equals 함수를 만드는 것을 불가능에 가깝다. 
- 데이터 클래스는 항상 final이다. 

41. haseCode의 규약을 지켜라 

hashCode 함수는 컬렉션, 알고리즘에 사용되는 자료구조인 해시 테이블을 구축할 때 사용된다. 
데이터 클래스에서 equals와 함께 자동 생성되는 메서드이나, equals를 따로 정의했다면 반드시 hashCode도 함께 정의해야 한다. 

* hashCode의 규약

- 일관성 유지: 어떤 객체를 변경하지 않았다면, hashCode는 여러 번 호출해도 결과가 항상 같아야 한다.
- equals 메서드의 실행 결과로 두 객체가 같다고 나온다면, hashCode 메서드의 호출 결과도 같다고 나와야 한다. 

42. compareTo의 규약을 지켜라 

- compareTo는 수학적인 부등식으로 변환되는 연산자로, compareTo를 따로 정의해야하는 상황은 거의 없다.
- 두 값을 단순하게 비교하기만 한다면 compareValues 함수를 활용한다.
- 더 많은 값을 비교하거나 선택기(selector)로 비교하고 싶다면 compareValuesBy를 사용한다.  
- 특별한 논리를 구현할 경우, compareValues와 compareValuesBy 함수는 다음 값을 리턴해야 한다.
  1) 0: 리시버가 other과 같은 경우 
  2) 양수: 리시버가 other보다 큰 경우 
  3) 음수: 리시버가 other보다 작은 경우 

// compareValues
override fun compareTo(other: User): Int = compareValues(surname, other.surname)

// compareValuesBy
override fun compareTo(other: User): Int = compareValuesBy(this, other, {it.surname}, {it.name})

* compareTo의 규약

- 비대칭적 동작: a >= b 이고 b >= a 라면, a == b 여야 한다.
- 연속적 동작: a >= b 이고 b >= c 라면, a >= c 여야 한다.
- 코넥스적(connex) 동작: 두 요소는 확실한 관계를 갖고있어야 한다. 즉, a>=b 또는 b>=a 중 적어도 하나는 항상 true 여야 한다.

43. API의 필수적이지 않은 부분을 확장함수로 추출하라

- 클래스의 메서드 정의 시, 메서드를 멤버로 정의할 지, 확장함수로 정의할 지 결정해야 한다. 
- 상속을 목적으로 설계된 요소는 확장함수로 만들면 안된다. 
- API의 필수적인 부분은 멤버로 두고, 필수적이지 않은 부분은 확장 함수로 만드는 것이 좋다. 

// 멤버로 정의
class Workshop(){
  fun makeEvent(date: DateTime): Event = .. 
}
val e = workshop.makeEvent(date)

// 확장함수로 정의 
class Workshop(){
  fun Workshop.makeEvent(date: DateTime): Event = .. 
}
val e = Workshop::makeEvent

44. 멤버 확장 함수의 사용을 피하라

가시성 제한을 위해 멤버 확장 함수를 만드는 것은 좋지 않다. 
가시성 제한을 위해서라면 가기성 한정자를 붙여라.

728x90
Comments