관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 33. 생성자 대신 팩토리 함수를 사용하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 33. 생성자 대신 팩토리 함수를 사용하라

참깨빵위에참깨빵_ 2023. 1. 29. 19:46
728x90
반응형

클라이언트가 클래스의 인스턴스를 만들게 하는 가장 일반적인 방법은 기본 생성자(primary constructor)를 쓰는 것이다.

 

fun main() {
    val list = MyLinkedList(1, MyLinkedList(2, null))
}

class MyLinkedList<T>(
    val head: T,
    val tail: MyLinkedList<T>?
)

 

하지만 생성자가 객체를 만들 수 있는 유일한 방법은 아니다. 디자인 패턴으로 다양한 생성 패턴들이 만들어져 있다. 일반적으로 이런 생성 패턴은 생성자를 객체로 직접 생성하지 않고 별도의 함수를 통해 생성한다.

예를 들어 아래 코드의 톱레벨 함수는 MyLinkedList 클래스의 인스턴스를 만들어서 제공한다.

 

fun main() {
    val list = myLinkedListOf(1, 2)
}

class MyLinkedList<T>(
    val head: T,
    val tail: MyLinkedList<T>?
)

fun <T> myLinkedListOf(
    vararg elements: T
): MyLinkedList<T>? {
    if (elements.isEmpty()) return null
    val head = elements.first()
    val elementsTail = elements.copyOfRange(1, elements.size)
    val tail = myLinkedListOf(*elementsTail)

    return MyLinkedList(head, tail)
}

 

생성자의 역할을 대신 하는 함수를 팩토리 함수라고 한다. 생성자 대신 팩토리 함수를 쓰면 다양한 장점들이 생긴다

 

  • 함수에 이름을 붙일 수 있다. 이름은 객체가 생성되는 방법과 아규먼트로 뭐가 필요한지 설명할 수 있다. ArrayList(3)이란 코드가 있을 때 3이 뭘 의미하는지 알 수 있는가? 새로 생성하는 리스트의 1번째 요소인가? 아니면 리스트의 크기인가? ArrayList(3)이란 코드만 보고는 알기 어렵다. 만약 ArrayList.withSize(3)이란 이름이 있다면 훨씬 이해하기 쉬울 것이다. 이름은 굉장히 유용하다. 동일한 파라미터 타입을 갖는 생성자와 충돌을 줄일 수 있다는 장점도 있다
  • 함수가 원하는 형태의 타입을 리턴할 수 있다. 따라서 다른 객체를 숨길 때 사용할 수 있다. 인터페이스 뒤에 실제 객체의 구현을 숨길 때 유용하게 사용할 수 있다. 예를 들어 stdlib(표준 라이브러리)의 listOf()는 List 인터페이스를 리턴한다. 실제로 어떤 객체를 리턴하는지는 플랫폼에 따라 다르다. 또한 인터페이스를 리턴한 것이므로 인터페이스만 지켜서 만들어진다면 어떤 클래스라도 잘 동작할 것이다. 따라서 코틀린 제작자가 더 많은 자유를 가질 수 있다
  • 호출될 때마다 새 객체를 만들 필요가 없다. 함수를 써서 객체를 만들면 싱글턴 패턴처럼 객체를 하나만 생성하게 강제하거나, 최적화를 위해 캐싱 매커니즘을 사용할 수도 있다. 또한 객체를 만들 수 없을 경우 null을 리턴하게 만들 수도 있다
  • 팩토리 함수는 아직 존재하지 않는 객체를 리턴할 수도 있다. 이 특징 때문에 어노테이션 처리를 기반으로 하는 라이브러리에선 팩토리 함수를 많이 사용한다. 이를 활용하면 프로젝트를 빌드하지 않고도 앞으로 만들어질 객체를 사용하거나, 프록시를 통해 만들어지는 객체를 사용할 수 있다
  • 객체 외부에 팩토리 함수를 만들면 그 가시성을 원하는 대로 제어할 수 있다. 예를 들어 톱레벨 팩토리 함수를 같은 파일 or 모듈에서만 접근하게 만들 수 있다
  • 팩토리 함수는 인라인으로 만들 수 있으며 그 파라미터들을 reified로 만들 수 있다
  • 팩토리 함수는 생성자로 만들기 복잡한 객체도 만들 수 있다
  • 생성자는 즉시 슈퍼클래스 or 기본 생성자를 호출해야 한다. 하지만 팩토리 함수를 호출하면 원하는 때 생성자를 호출할 수 있다

 

fun makeListView(config: Config): ListView {
    val items = _           // config로부터 요소 읽어들임
    return ListView(items)  // 진짜 생성자 호출
}

 

다만 팩토리 함수로 클래스를 만들 때는 약간의 제한이 발생한다. 서브클래스 생성에는 슈퍼클래스 생성자가 필요하기 때문에 서브클래스를 만들 수 없다.

하지만 아무 문제가 되지 않는다. 팩토리 함수로 슈퍼클래스를 만들기로 했다면 그 서브클래스에도 팩토리 함수를 만들면 된다.

 

fun main() {
    val list = myLinkedListOf(1, 2)
}

open class MyLinkedList<T>(
    val head: T,
    val tail: MyLinkedList<T>?
)

fun <T> myLinkedListOf(
    vararg elements: T
): MyLinkedList<T>? {
    if (elements.isEmpty()) return null
    val head = elements.first()
    val elementsTail = elements.copyOfRange(1, elements.size)
    val tail = myLinkedListOf(*elementsTail)
    
    return MyLinkedList(head, tail)
}

// 아래를 보면 된다
class MyLinkedIntList(head: Int, tail: MyLinkedIntList?): MyLinkedList<Int>(head, tail)

fun myLinkedIntListOf(vararg elements: Int): MyLinkedIntList? {
    if (elements.isEmpty()) return null
    val head = elements.first()
    val elementsTail = elements.copyOfRange(1, elements.size)
    val tail = myLinkedIntListOf(*elementsTail)
    
    return MyLinkedIntList(head, tail)
}

 

앞의 생성자는 이전 생성자보다 길지만 유연성, 클래스 독립성, nullable을 리턴하는 등 다양한 특징을 갖는다.

팩토리 함수는 굉장히 강력한 객체 생성 방법이다. 이 말을 들으면 기본 생성자 또는 팩토리 함수 중 하나를 써야 한다고 이해할 수 있는데 기본 생성자를 쓰지 말라는 말이 아니다. 팩토리 함수 안에선 생성자를 써야 한다.

일반적인 자바로 팩토리 패턴을 구현할 땐 생성자를 private로 만들지만, 코틀린에선 그렇게 하는 경우가 거의 없다. 팩토리 함수는 기본 생성자가 아닌 추가적인 생성자(secondary constructor)와 경쟁 관계다. 여러 코틀린 프로젝트를 보면 알겠지만 추가적인 생성자보단 팩토리 함수를 많이 사용한다. 또한 팩토리 함수는 다른 종류의 팩토리 함수와 경쟁 관계에 있다고 할 수 있다. 아래는 팩토리 함수의 종류다.

 

  • companion 객체 팩토리 함수
  • 확장 팩토리 함수
  • 톱레벨 팩토리 함수
  • 가짜 생성자
  • 팩토리 클래스의 메서드

 

Companion 객체 팩토리 함수

 

팩토리 함수를 정의하는 가장 일반적인 방법은 companion 객체를 쓰는 것이다.

 

fun main() {
    val list = MyLinkedList.of(1, 2)
}

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

 

기존 자바 개발자라면 이 코드가 static factory function과 같다는 걸 알 수 있을 것이다. C++같은 프로그래밍 언어에선 이를 이름을 가진 생성자(Named Constructor Idiom)라고 부른다. 이름 그대로 생성자 같은 역할을 하면서도 다른 이름이 있기 때문이다. 코틀린에선 이런 접근 방법을 인터페이스에도 구현할 수 있다.

 

fun main() {
    val list = MyList.of(1, 2)
}

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

interface MyList<T> {
    // ...
    
    companion object {
        fun <T> of(vararg elements: T): MyList<T>? {
            // ...
        }
    }
}

 

함수명만 보면 뭐 하는 함수인지 모를 수도 있지만 대부분 개발자는 자바에서 온 규칙 덕분에 이미 이 이름에 익숙할 것이므로 큰 문제 없이 이해할 수 있을 것이다. 이외에도 다양한 이름들이 많이 사용된다.

 

  • from : 파라미터를 하나 받고 같은 타입의 인스턴스 하나를 리턴하는 타입 변환 함수를 나타냄
  • of : 파라미터를 여럿 받고 이를 통합해 인스턴스를 만들어주는 함수를 나타냄
  • valueOf : from 또는 of와 비슷한 기능을 하면서도 의미를 좀 더 쉽게 읽을 수 있게 이름을 붙인 함수
  • instance 또는 getInstance : 싱글턴으로 인스턴스 하나를 리턴하는 함수. 파라미터가 있을 경우 아규먼트를 기반으로 하는 인스턴스를 리턴한다. 일반적으로 같은 아규먼트를 넣으면 같은 인스턴스를 리턴하는 형태로 작동한다
  • createInstance 또는 newInstance : getInstance처럼 동작하지만 싱글턴이 적용되지 않아서 함수 호출 시마다 새 인스턴스를 만들어 리턴한다
  • getType : getInstance처럼 동작하지만 팩토리 함수가 다른 클래스에 있을 때 쓰는 이름이다. 타입은 팩토리 함수에서 리턴하는 타입이다
  • newType : newInstance처럼 동작하지만 팩토리 함수가 다른 클래스에 있을 때 쓰는 이름이다. 타입은 팩토리 함수에서 리턴하는 타입이다

 

경험 없는 코틀린 개발자들은 companion 객체 멤버를 단순한 정적 멤버처럼 다루는 경우가 많다. 하지만 companion 객체는 더 많은 기능을 갖고 있다. companion 객체는 인터페이스를 구현할 수 있고 클래스를 상속받을 수도 있다.

일반적으로 아래와 같이 companion 객체를 만드는 팩토리 함수를 만든다.

 

fun main() {
    val intent = MainActivity.getIntent(context)
    MainActivity.start()
    MainActivity.startForResult(activity, requestCode)
}

abstract class ActivityFactory {
    abstract fun getIntent(context: Context): Intent

    fun start(context: Context) {
        val intent = getIntent(context)
        context.startActivity(intent)
    }

    fun startForResult(activity: Activity, requestCode: Int) {
        val intent = getIntent(activity)
        activity.startActivityForResult(intent, requestCode)
    }
}

class MainActivity : AppCompatActivity() {
    // ...

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

 

추상 companion 객체 팩토리는 값을 가질 수 있다. 따라서 캐싱을 구현하거나 테스트를 위한 가짜 객체 생성(fake creation)을 할 수 있다.

코틀린 팀 제품의 구현을 확인하면 companion 객체를 어떻게 쓰고 있는지 명확하게 확인할 수 있다. 코루틴 라이브러리를 살펴보면 거의 모든 코루틴 컨텍스트의 companion 객체가 컨텍스트 구별 목적으로 CoroutineContext.Key 인터페이스를 구현하고 있다.

 

확장 팩토리 함수

 

이미 companion 객체가 존재할 때 이 객체의 함수처럼 사용할 수 있는 팩토리 함수를 만들어야 할 때가 있다. 이 때 companion 객체를 직접 수정할 수는 없고 다른 파일에 함수를 만들어야 한다면 어떻게 하나? 이런 경우 확장 함수를 활용하면 된다.

아래와 같은 Tool 인터페이스를 교체할 수는 없다고 가정한다.

 

interface Tool {
    companion object {/**/}
}

 

그래도 companion 객체를 써서 확장 함수를 정의할 수 있다.

 

fun Tool.Companion.createBigTool(/*...*/): BigTool {
    // ...
}

fun main() {
    Tool.createBigTool()
}

 

이런 코드를 활용하면 팩토리 메서드를 만들어서 외부 라이브러리를 확장할 수 있다. 다만 companion 객체를 확장하려면 적어도 비어 있는 컴패니언 객체가 필요하다.

 

interface Tool {
    companion object {}
}

 

톱레벨 팩토리 함수

 

대표적인 예로 listOf, setOf, mapOf가 있다. 여러 라이브러리에서도 이런 방법을 사용한다.

톱레벨 팩토리 함수는 광범위하게 쓰인다. 안드로이드에선 액티비티를 시작하기 위해 인텐트를 만드는 함수를 정의해서 사용한다. 이를 코틀린으로 옮기면 getIntent()를 companion 객체로 아래처럼 만들 수 있다.

 

class MainActivity : AppCompatActivity() {
    
    companion object {
        fun getIntent(context: Context) =
            Intent(context, MainActivity::class.java)
    }
}

 

코틀린 Anko 라이브러리를 쓰면 reified 타입을 활용해 intentFor라는 톱레벨 함수를 쓰는 코드를 작성할 수 있다.

 

intentFor<MainActivity>()

 

List, Map 등을 생각해 보라. listOf(1, 2, 3)이 List.of(1, 2, 3)보다 훨씬 읽기 쉬워서 객체 생성에 톱레베 함수를 사용한 것이다. 하지만 톱레벨 함수는 신중하게 써야 한다.

public 톱레벨 함수는 모든 곳에서 쓸 수 있으므로 IDE가 제공하는 팁을 복잡하게 만드는 단점이 있다. 톱레벨 함수의 이름을 클래스 메서드 이름처럼 만들면 다양한 혼란을 야기할 수 있다. 따라서 톱레벨 함수를 만들 땐 꼭 이름을 신중하게 생각해서 잘 지정해야 한다.

 

가짜 생성자

 

코틀린의 생성자는 톱레벨 함수와 같은 형태로 사용된다.

 

class A
val a = A()

 

따라서 아래처럼 톱레벨 함수처럼 참조될 수 있다. 생성자 레퍼런스는 함수 인터페이스로 구현한다.

 

val reference: () -> A = ::A

 

일반적으로 대문자로 시작하는지 아닌지는 생성자, 함수를 구분하는 기준이다. 함수도 대문자로 시작할 수 있지만 이는 특수한 다른 용도로 쓰인다. 예를 들어 List, MutableList는 인터페이스다. 따라서 생성자를 가질 수 없다. 하지만 List를 생성자처럼 쓰는 코드를 봤을 것이다.

 

fun main() {
    List(4) { "User$it" }   // [User0, User1, User2, User3]
}

 

이는 아래와 같은 함수가 코틀린 1.1부터 stdlib에 포함됐기 때문이다.

 

public inline fun <T> List(
    size: Int,
    init: (index: Int) -> T
): List<T> = MutableList(size, init)

public inline fun <T> MutableList(size: Int, init: (index: Int) -> T): MutableList<T> {
    val list = ArrayList<T>(size)
    repeat(size) { index -> list.add(init(index)) }
    return list
}

 

이런 톱레벨 함수는 생성자처럼 보이고 생성자처럼 작동한다. 하지만 팩토리 함수와 같은 모든 장점을 갖는다. 많은 개발자가 이 함수가 톱레벨 함수인지 잘 모른다. 그래서 이걸 가짜 생성자(fake constructor)라고 부른다.

개발자가 진짜 생성자 대신 가짜 생성자를 만드는 이유는 아래와 같다.

 

  • 인터페이스를 위한 생성자를 만들고 싶을 때
  • reified 타입 아규먼트를 갖게 하고 싶을 때

 

이를 제외하면 가짜 생성자는 진짜 생성자처럼 동작해야 한다. 생성자처럼 보여야 하고 생성자와 같은 동작을 해야 한다. 캐싱, nullable 타입 리턴, 서브클래스 리턴 등의 기능까지 포함해 객체를 만들고 싶다면 companion 객체 팩토리 메서드처럼 다른 이름을 가진 팩토리 함수를 쓰는 게 좋다.

가짜 생성자를 선언하는 또 다른 방법이 있다. invoke 연산자를 갖는 companion 객체를 쓰면 비슷한 결과를 얻을 수 있다.

 

fun main() {
    Tree(10) { "$it" }
}

class Tree<T> {
    companion object {
        operator fun <T> invoke(
            size: Int,
            generator: (Int) -> T
        ): Tree<T> {
            // ...
        }
    }
}

 

다만 이런 방식은 거의 쓰이지 않고 필자도 추천하지 않는다. 아이템 12에 위배되기 때문이다.

companion 객체가 invoke를 가지면 아래와 같은 코드를 사용할 수 있다. 함수명을 활용해서도 연산자 기능을 활용할 수 있다는 걸 기억하라.

 

fun main() {
    Tree.invoke(10) { "$it" }
}

 

invoke는 호출한다는 의미다. 따라서 객체 생성과 의미가 다르다. 이런 식으로 연산자를 오버로드하면 원래 의미와 차이가 발생한다.

또한 이런 방식은 톱레벨 함수로 만드는 코드보다 훨씬 복잡하다. 리플렉션을 보면 지금까지 봤던 생성자, 가짜 생성자, invoke()의 복잡성을 확인할 수 있다.

 

fun main() {
    // 생성자
    val f: () -> Tree = ::Tree
    
    // 가짜 생성자
    val f2: () -> Tree = ::Tree
    
    // invoke()를 갖는 companion 객체
    val f3: () -> Tree = Tree.Companion::invoke
}

 

따라서 가짜 생성자는 톱레벨 함수를 쓰는 게 좋다. 기본 생성자를 만들 수 없는 상황 또는 생성자가 제공하지 않는 기능(reified 타입 파라미터 등)으로 생성자를 만들어야 하는 상황에만 가짜 생성자를 쓰는 게 좋다.

 

팩토리 클래스의 메서드

 

팩토리 클래스와 관련된 추상 팩토리, 프로토타입 등의 수많은 생성 패턴이 있다. 이런 패턴들은 각각 다양한 장점이 있다.

이런 패턴 중 일부는 코틀린에선 적합하지 않다. 예를 들어 점층적 생성자 패턴, 빌더 패턴은 코틀린에선 무의미하다.

팩토리 클래스는 클래스의 상태를 가질 수 있다는 특징 때문에 팩토리 함수보다 다양한 기능을 가진다. 아래 코드는 다음 ID(nextId)를 갖는 학생을 생성하는 팩토리 클래스다.

 

fun main() {
    val factory = StudentFactory()
    val s1 = factory.next(name = "김", surName = "철수")
    println(s1)
    val s2 = factory.next(name = "박", surName = "미희")
    println(s2)
}
// Student(id=0, name=김, surName=철수)
// Student(id=1, name=박, surName=미희)

data class Student(
    val id: Int,
    val name: String,
    val surName: String
)

class StudentFactory {
    var nextId = 0
    fun next(name: String, surName: String) =
        Student(nextId++, name, surName)
}

 

팩토리 클래스는 프로퍼티를 가질 수 있다. 이를 활용하면 다양한 종류로 최적화하고, 다양한 기능을 도입할 수 있다.

예를 들어 캐싱을 활용하거나, 이전에 만든 객체를 복제해서 객체를 생성하는 방법으로 객체 생성 속도를 높일 수 있다.

반응형
Comments