이번 게시글은 Effective Kotlin — Chapter 5. Object creation에 대한 내용입니다.

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

클라이언트가 클래스의 인스턴스를 만들게 하는 가장 일반적인 방법은 기본 생성자를 사용하는 방법입니다.

하지만 생성자가 객체를 만들 수 있는 유일한 방법은 아닙니다. 디자인 패턴으로 굉장히 다양한 생성 패턴들이 있습니다.

톱 레벨 팩토리 함수

생성자의 역할을 대신 해주는 함수를 팩토리 함수라고 합니다.

생성자 대신에 팩토리 함수를 사용하면 다양한 장점이 생깁니다.

  • 생성자와 다르게 별도의 이름을 붙일 수 있습니다.
  • 생성자와 다르게 별도의 원하는 타입을 리턴 할 수 있습니다. 따라서 인터페이스 뒤에 객체를 숨길 수 있습니다 (예시 : Kotlin의 listOf())
  • 생성자와 다르게, 객체를 매번 생성하지 않게 할 수 있습니다. (예시 : 싱글톤 getInstance())
  • 팩토리 함수는 inline 으로 만들 수 있으며, 타입 파라미터들을 reified로 만들어 런타임에 타입 파라미터에 접근 할 수 있습니다.
  • 팩토리 함수에 객체 생성에 대한 로직을 포함시켜 복잡한 객체도 쉽게 만들게 할 수 있습니다.
  • 객체 외부에 팩토리 함수를 정의하면, 가시성을 원하는데로 제어할 수 있습니다. 같은 모듈 안에서만 혹은 같은 파일 안에서만 생성하도록 제어 할 수 있습니다.

Companion 객체 팩토리 함수

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

static 팩토리 함수에 자주 사용되는 이름은 다음과 같습니다.

  • from() : 파라미터를 하나 받고, 같은 타입의 인스턴스를 리턴하는 타입
val data : Data = Data.from(instant)
  • of() : 파라미터를 여러 개 받고, 이를 통합해서 인스턴스를 만들어 주는 함수
val faceCards : Set<Rank> = EnumSet.of(JACK, QUEEN, KING)
  • valueOf() : from() 또는 of() 와 비슷한 기능을 하면서도, 좀 더 쉽게 읽을 수 있는 이름을 붙인 함수
val prime : BigInteger = BigInteger.valueOf(Integer.MAX_VALUE)
  • instance 또는 getInstance() : 싱글턴으로 인스턴스 하나를 리턴하는 함수
val luke : StackWalker = StackWalker.getInstance(options)
  • createInstance() 또는 newInstance() : getInstance() 처럼 동작하지만, 매번 새로운 객체를 생성해주는 함수
val newArray = Array.newInstance(classObject, arrayLen)
  • getType() : getInstance() 처럼 동작하지만 팩토리 함수가 다른 클래스에 있을 때 사용하는 이름
val fs : FileStore = Files.getFileStore(path)

Companion은 인터페이스, 클래스 상속도 가능하다.

companion 을 단지 상수, 함수 만을 위한 필드로 사용하는 경향이 많습니다. 하지만 companion 은 인터페이스, 클래스 상속이 가능합니다.

확장 팩토리 함수

만약 다른 라이브러리의 클래스의 companion에 팩토리 함수를 추가하고 싶다면 어떻게 해야할까요? 바로 해당 클래스의 companion에 확장함수로 할 수 있습니다.

가짜 생성자

객체 생성에 별다른 로직없이, 단순하게 람다가 필요할 때는 톱레벨 inline함수로 만들어 람다 생성에 오버헤드를 줄이고, 또한 네이밍도 생성자처럼 꾸며내어 마치 생성자처럼 활용할 수 있습니다.

코틀린 CollectionMutableList(size: Int, init: (index: Int) -> T): MutableList<T> 에도 동일하게 구현되어 있습니다.

가짜 생성자를 만드는 이유는 다음과 같습니다.

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

이를 제외하고 가짜 생성자는 진짜 생성자 처럼 동작해야합니다.

만약 객체 생성에 많은 로직이 들어간다면, companion inline함수로 만들어서 사용하는 것이 좋습니다.

팩토리 클래스

객체 생성에 상태가 별도로 필요할 경우엔 팩토리 클래스로 만드는 것이 좋습니다.

가령 ViewModel에서 Context가 필요한 객체 생성을 하고자 할 때, Factory 객체에 Context를 담아주고, 외부에서 주입해주는 방식으로 ViewModel이 Context를 가져야 되는 상황을 회피할 수 있습니다.

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

DSL은 복잡한 객체, 계층 구조를 갖고 있는 객체 들을 정의할 때 굉장히 유용합니다. DSL을 만드는 것은 약간 힘든 일이지만, 한번 만들고 나면 보일러 플레이트와 복잡성을 숨기면서 개발자의 의도를 명확하게 표현할 수 있습니다.

DSL의 대표적인 예시

Gradle 설정을 정의할 때도 Gradle DSL이 사용됩니다.

사용자 정의 DSL 만들기

사용자 정의 DSL을 이해하려면, 리시버를 사용하는 함수 타입에 대한 개념을 이해해야합니다.

함수 타입의 예

  • () -> Unit : Argument를 갖지 않고, Unit을 리턴하는 함수입니다.
  • (Int) -> Unit : Int를 Argument로 받고, Unit을 리턴하는 함수입니다.
  • (Int, Int) -> Int: Int 두개를 Argument로 받고, Int를 리턴하는 함수입니다.
  • (Int) -> () -> Unit : Int를 받고 다른 함수를 리턴하는 함수입니다. 리턴된 함수는 Argument를 갖지 않고, Unit을 리턴하는 함수입니다.
  • (() -> Unit)) -> Unit : 다른 함수를 Argument로 받고, Unit을 리턴하는 함수입니다. 이때 다른 함수는 Argument를 갖지 않고, Unit을 리턴하는 함수입니다.

일반 함수 타입을 만드는 방법

일반 함수 타입을 만드는 방법은 다음과 같습니다.

  • 람다 표현식
  • 익명 함수
  • 함수 레퍼런스

예를 들어 다음과 같은 함수가 있다고 해봅시다.

fun plus(a : Int, b : Int) = a + b

각각 함수 타입에 따라 다음과 같이 나타낼 수 있습니다.

람다 표현식

val plus1 : (Int, Int) -> Int = { a, b → a+b} 

익명함수

val plus2 : (Int, Int) -> Int = fun(a, b) = a+b

함수 레퍼런스

val plus3 : (Int, Int) -> Int = ::plus

또한 확장함수도 동일하게 만들 수 있습니다.

확장 함수 타입 만들기

가령 아래와 같이 확장함수가 있다고 했을 때,

fun Int.plus(other : Int) = a + b

람다 표현식

val plus1 : Int.(Int) -> Int = { other → this + other }

익명함수

val plus2 : Int.(Int) -> Int = fun Int.(other : Int) = this + other

함수 레퍼런스

불가..

일반 함수 람다와 확장 함수 람다의 차이

일반 함수의 람다와 확장 함수 람다의 가장 본질적인 차이는 람다 블럭 내부에서 리시버에 어떻게 접근 하냐의 차이가 존재합니다.

일반 함수 람다

val lambda : (Int) -> Unit = {
it으로 파라미터에 접근 할 수 있다.
}

확장 함수 람다

val extensionLambda : Int.() -> Unit = {
this로 리시버에 접근 할 수 있다.
}

이렇게 it으로 접근 하냐 this로 접근 하냐에 따라 용도가 나뉘게 됩니다.

it으로 접근하면 해당 파라미터를 이용해서 다른 함수의 인자로 활용하거나 다른 로직을 수행하고자 할 때,

this로 접근하면 해당 리시버의 상태를 변경하거나, 행동을 실행하고자 할 때 사용합니다.

그렇기에 그걸 바탕으로 DSL을 정의하겠습니다.

사용자 정의 DSL 만들기

다음과 같이 마치 html처럼 table을 만드는 DSL을 만들어 보도록 하겠습니다.

결과물

table 정의

가장 상위에 정의되는 table()은 탑레벨 함수로 정의하여 init() 에 실제 TableBuilder 인스턴스를 전달합니다.

그리고 tr() 이란 함수를 통해 init()TrBuilder 인스턴스를 전달합니다

tr, td 정의

최 상위인 table() 이후 부터는 람다 블럭에 전달되는 Builder 인스턴스를 통해 생성을 이어가기 때문에 그에 맞는 함수를 정의하여 Builder 인스턴스를 전달합니다.

언제 사용해야 할까?

사실 DSL이 사용하기엔 편하지만, 다른 개발자가 보기엔 오히려 혼동을 일으킬 수 있습니다. 그렇기에 간단한 로직에 DSL을 적용시키는 것은 닭 잡는 데 소 잡는 칼을 쓰는 꼴입니다.

DSL은 다음과 같은 것을 표현하는 경우에 유용합니다.

  • 복잡한 자료 구조
  • 계층적인 구조
  • 거대한 양의 데이터

DSL 없이 빌더 또는 단순하게 생성자만 활용해도 원하는 모든 것을 표현할 수 있습니다. DSL은 많이 사용되는 구조의 반복을 제거할 수 있게 해줍니다. 많이 사용되는 반복적인 코드가 있고, 이를 간단하게 만들 수 있는 별도의 코틀린 기능이 없다면, DSL을 고려 해보는 것이 좋습니다.

Effective Kotlin Chapter 5. Object creation에 대한 요약은 여기까지 입니다.

다음 게시글에서 Chapter 6. 클래스 설계에 대한 요약으로 작성하도록 하겠습니다.

--

--