아이템 33 - 생성자 대신 팩토리 함수를 사용하라
클래스의 인스턴스를 만드는 가장 일반적인 방법은 기본 생성자(primary constructor)를 사용하는 방법입니다.
class Shop(
val name: String,
val isOpen: Boolean,
)
val shop = Shop(name = "hello", isOpen = true)
생성자 외에도 객체를 만들 수 있도록 다양한 생성 패턴(creational pattern)이 존재합니다. 일반적으로 이런 생성 패턴은 객체를 생성자로 직접 생성하지 않고, 별도의 함수를 통해 생성합니다.
class Shop(
val name: String,
val isOpen: Boolean,
)
// 팩토리 함수
fun createOpenShop(val name: String) {
return Shop(name = name, isOpen = true)
}
위처럼 생성자의 역할을 대신 해주는 함수를 팩토리 함수라고 하며, 아래와 같은 장점이 있습니다.
- 생성자와 다르게, 함수에 이름을 붙일 수 있습니다.
- 이름이 있으면 어떠한 객체가 만들어지는지, 파라미터로는 어떤 것이 필요한지 등을 쉽게 볼 수 있게 해줍니다.
- ArrayList(3)보다는 ArrayList.withSize(3)이 더 이해하기 쉽습니다.
- 이름이 있으면 어떠한 객체가 만들어지는지, 파라미터로는 어떤 것이 필요한지 등을 쉽게 볼 수 있게 해줍니다.
- 생성자와 다르게, 함수가 원하는 형태의 타입을 리턴할 수 있습니다.
- 인터페이스 뒤에 실제 객체의 구현을 숨기고자 할 때 유용하게 사용할 수 있습니다.
- listOf()를 보면 실제 리턴하는 타입은 List이며, List는 인터페이스입니다.
- 플랫폼에 따라 리턴하는 객체를 다르게 하고자 인터페이스를 리턴합니다.
- 이로 인해, 코틀린/JVM, 코틀린/JS 등 어떤 환경인지 상관 없이 각 플랫폼의 빌트인 컬렉션을 반환할 수 있습니다.
- 인터페이스만 지켜진다면, 코틀린 제작자는 더 많은 자유를 얻게 구현할 수 있게 됩니다.
- 생성자와 다르게 호출될 때마다 새 객체를 만들 필요가 없습니다.
- 함수를 사용해 객체를 생성하면, 싱글턴 패턴처럼 객체를 하나만 생성하게 강제하는 등의 직업을 할 수 있습니다.
- 팩토리 함수는 아직 존재하지 않는 객체를 리턴할 수도 있습니다.
- 이를 활용하면 프로젝트를 빌드하지 않고도 앞으로 만들어질 객체를 사용하거나, 프록시를 통해 만들어지는 객체를 사용할 수 있습니다.
- 객체 외부에 팩토리 함수를 만든다면, 가시성을 원하는 대로 제어할 수 있습니다.
- 예를 들어, 특정 파일/모듈에서만 사용할 수 있게 할 수 있습니다.
- 팩토리 함수는 인라인으로 만들 수 있고, 파라미터들을 reified로 만들 수 있습니다.
- 생성자는 즉시 슈퍼클래스 또는 기본 생성자를 호출해야 하지만, 팩토리 함수를 사용하면 원하는 때에 생성자를 호출할 수 있습니다.
- 다만, 서브클래스 생성 시 슈퍼클래스 생성자가 필요하기 때문에, 팩토리 함수로 슈퍼클래스를 만들 경우 서브클래스를 기본 생성자로 만들 수 없으며, 서브클래스도 팩토리 함수로 구현이 필요합니다.
제네릭 타입 정보 지워짐
코틀린과 자바에서는 컨파일 시점에 제레릭 타입 정보가 지워집니다. 따라서, 런타임 시점에는 List<Int>나 List<String>이 모두 List처럼 여겨집니다. 이로 인해, 우리는 가끔 마땅찮은 방식으로 로직을 구현하도록 강제당합니다. 아래 예시는 JSON을 사용할 때 어떤 타입인지 런타임에는 알 수 없어 자바에서 JSON을 파싱 할 때 사용되는 코드입니다.
String json = objectMapper.readValue(data, String.class);
Inline 함수
코틀린의 함수는 일급시민이기 때문에 일반적인 타입처럼 함수를 파라미터로 사용하거나 반환값으로 사용할 수 있습니다. 하지만, 이로 인해 성능 차원에서 부가 비용이 발생할 수 있습니다.
- 익명 함수나 람다가 외부 영역의 변수를 참조하면 고차 함수에 함숫값을 넘길 때마다 외부 영역의 변수를 포획할 수 있는 구조도 만들어서 넘겨져야 합니다.
- 함숫값을 호출할 때는 컴파일러가 함숫값의 정적인 타입을 알 수 없기 때문에 동적으로 가상 호출을 사용해 어떤 함수 구현을 사용할지 디스패치해야 합니다.
코틀린은 함숫값을 사용할 때 발생하는 런타임 비용을 줄일 수 있도록, 함숫값을 사용하는 고차 함수를 호출하는 부분을 해당 함수의 본문으로 대체하는 인라인(inline) 기법을 제공합니다.
inline fun <T> Collection<T>.each(block: (T) -> Unit) {
for (e in this) block(e)
}
val numbers = listOf(1, 2, 3, 4, 5)
numbers.each { println(it) }
// 컴파일 시 아래와 같이 동작
val numbers = listOf(1, 2, 3, 4, 5)
for (number in numbers)
println(number)
인라인 함수를 사용하면, 컴파일된 코드의 크기가 커지지만, 지혜롭게 사용하면 성능을 크게 높일 수 있습니다.
참고로, 함수 인라인을 지원하는 C++ 등의 언어와 달리 코틀린의 inline 변경자는 컴파일러가 상황에 따라 무시해도 되는 힌트가 아닙니다. 따라서, inline이 붙은 코틀린 함수는 가능하면 항상 인라인이 되며, 인라인이 불가능한 경우 컴파일 오류로 간주됩니다.
inline fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int {
for (i in numbers.indices) {
if (condition(numbers[i])) return i
}
return -1
}
fun main() {
println(indexOf(intArrayOf(4, 3, 2, 1)) { it < 3 } // 2
}
// 컴파일 후
fun main() {
val numbers = intArrayOf(4, 3, 2, 1)
var index = -1
for (i in numbers.indices) {
if (numbers[i] < 3) { // inline 함수 파라미터로 전달된 함수값도 inline됨
index = i
break
}
}
println(index)
}
참고로, inline 변경자가 붙은 함수뿐 아니라, 해당 함수의 파라미터로 전달되는 함숫값도 인라인됩니다. 따라서, 만약 특정 람다를 인라인하고싶지 않다면 아래와 같이 noinline 변경자를 붙일 수 있습니다.
inline fun indexOf(numbers: IntArray, noinline condition: (Int) -> Boolean): Int {
for (i in numbers.indices) {
if (condition(numbers[i])) return i
}
return -1
}
Reified 함수
inline 기법은 제네릭 타입을 런타임 시점에 구체화할 수 있는 기회를 제공해줍니다. 이 의미는, 더 이상 T::class와 같이 타입 정보를 넘겨주지 않아도 된다는 의미이며, 이를 위한 작업은 reified 키워드만 추가하는 것 뿐입니다.
즉, 앞서서 봤던 JSON 파싱 메소드는 아래와 같이 명시적으로 타입 정보를 넘겨주지 않고도 사용 가능합니다.
// 명시적으로 형식 정보 전달하지 않아도 됨
val json = objectMapper.readValue<String>(data)
// 아래처럼도 사용 가능
val json: String = objectMapper.readValue(data)
팩토리 함수는 강력한 객체 생성 방법이며, 이 말의 뜻이 기본 생성자를 사용하지 말라는 의미는 아닙니다.
팩토리 함수 내부에서는 생성자를 호출하여 결국 객체를 생성해야 합니다.
팩토리 함수는 기본 생성자가 아닌 추가적인 생성자(secondary constructor)와 경쟁 관계입니다.
생성자
생성자는 클래스 인스턴스를 초기화해주고 인스턴스 생성 시 호출되는 특별한 함수입니다.
class Person(firstName: String, familyName: String) {
val fullName = "$firstName $familyName"
}
fun main() {
val person = Person("earth", "h") // 새 Person 인스턴스 생성
println(person.fullName) // earth h
}
class 키워드 뒤에 작성한 파라미터는 프로그램이 클래스의 인스턴스를 생성할 때 클래스에 전달됩니다. 이 파라미터를 사용해 프로퍼티를 초기화하고 다른 일을 수행할 수 있습니다.
주생성자
클래스 헤더의 파라미터 목록을 주생성자(primary constructor) 선언이라고 부릅니다. 주생성자는 함수와 달리 본문이 하나가 아닙니다. 대신, 주생성자는 클래스 정의 내에서 프로퍼티 초기화와 초기화 블록(init 키워드가 붙은 블록)이 등장하는 순서대로 구성됩니다.
class Person(firstName: String, familyName: String) {
val fullName = "$firstName $familyName"
init {
println("Created new Person instance: $fullName")
}
}
위 예시에서는 Person 클래스에서 주생성자가 호출될때마다 init 구문이 호출되어 메시지가 출력됨을 확인할 수 있습니다.
생성자 파라미터를 멤버 프로퍼티로 정의하기
주생성자 파라미터를 프로퍼티 초기화나 init 블록 밖에서 사용하려고 하면 아래와 같이 에러가 발생합니다.
주생성자 파라미터를 다른 곳에서 사용하고자 한다면, 생성자 파라미터의 값을 저장할 멤버 프로퍼티를 정의해야 합니다.
생성자 파라미터 값을 저장할 멤버 프로퍼티를 정의하는 방법으로는 위 예시처럼 2가지가 있으며, 두 번째 방법인 생성자 파라미터 앞에 val 또는 var 키워드를 붙여 자동으로 해당 생성자 파라미터로 초기화되는(생성자 파라미터와 이름이 같은) 프로퍼티 정의하는 방법을 권장합니다.
부생성자
경우에 따라 주생성자만으로 충분하지 않을 때, 부생성자(secondary constructor)를 사용할 수 있습니다. 부생성자 문법은 함수 이름 대신에 constructor 키워드를 사용한다는 점을 제외하면 함수 정의 문법과 유사합니다.
참고로, 부생성자의 파라미터 목록에는 val, var 키워드를 쓸 수 없습니다.
class Person {
val fullName: String
init {
println("Created new Person instance") // 어떤 생성자 호출하던 공통적인 초기화 코드 실행
}
constructor(firstName: String, familyName: String) {
fullName = "$firstName $familyName"
}
constructor(firstName: String): this(firstName, "h") // 부생성자 위임
}
위처럼 클래스에 주생성자를 선언하지 않은 경우, 모든 부생성자는 자신의 본문을 실행하기 전에 프로퍼티 초기화와 init 블록을 실행합니다. 이렇게 하면 어떤 부생성자를 호출하던 상관없이 공통적인 초기화 코드를 정확히 한 번 실행되도록 보장할 수 있습니다. 부생성자가 여러개 일경우, 생성자 위임을 통해 다른 부생성자를 호출할 수도 있습니다.
만약 클래스에 주생성자가 있다면, (부생성자가 있는 경우) 모든 부생성자는 주생성자나 다른 부생성자에게 위임을 해야 합니다.
팩토리 함수 종류
팩토리 함수에는 아래와 같은 것들이 있으며, 하나씩 살펴보겠습니다.
- companion 객체 팩토리 함수
- 확장 팩토리 함수
- 톱레벨 팩토리 함수
- 가짜 생성자
- 팩토리 클래스의 메서드
Companion 객체 팩토리 함수
팩토리 함수를 정의하는 가장 일반적인 방법은 companion 객체를 사용하는 것입니다.
class 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 객체를 사용하는 것은 자바의 정적 팩토리 함수(static factory function)와 같다는 것을 쉽게 알 수 있습니다.
참고로 코틀린에서는, companion object를 인터페이스에서도 구현할 수 있습니다.
class MyLinkedList<T>(
val head: T,
val tail: MyLinkedList<T>?
): MyList<T> {
...
}
interface MyList<T> {
companion object {
fun <T> of(varargs elements: T): MyList<T>? {
//...
}
}
}
팩토리 함수 이름으로 주로 쓰이는 함수명은 아래와 같습니다.
- from: 파라미터를 하나 받고, 같은 타입의 인스턴스 하나를 리턴하는 타입 변환 함수
- of: 파라미터를 여러 개 받고, 이를 통합해서 인스턴스 만들어주는 함수
- valueOf: from 또는 of와 유사한 역할을 하면서, 의미를 좀 더 쉽게 읽을 수 있게 이름 붙인 함수
- instance 또는 getInstance: 싱글턴으로 인스턴스 하나를 리턴하는 함수
- createInstance 또는 newInstance: getInstance처럼 동작하지만, 싱글턴이 적용되지 않아 함수를 호출할 때마다 새로운 인스턴스 만들어서 리턴하는 함수
- getType: getInstance처럼 동작하지만, 팩토리 함수가 다른 클래스에 있을 때 사용하는 함수
val fs: FileStore = Files.getFileStore(path)
와 같이 동작하며, get 뒤에 붙는 타입명은 팩토리 함수에서 리턴하는 타입입니다.
- newType: newInstance처럼 동작하지만, 팩토리 함수가 다른 클래스에 있을 때 사용하는 함수
val br: BufferedReader = Files.newBufferedReader(path)
와 같이 동작하며, getType처럼 get 뒤에 붙는 타입명은 팩토리 함수에서 리턴하는 타입입니다.
companion 객체 멤버는 단순한 정적 멤버 역할 말고도 더 많은 기능을 갖고 있습니다. 예를 들어, companion 객체는 인터페이스를 구현할 수 있으며, 클래스를 상속받을 수도 있습니다.
일반적으로 아래와 같은 형태로 companion 객체를 만드는 팩토리 함수를 만듭니다.
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 object(동반 객체)
생성자는 항상 자신이 정의한 클래스의 객체를 반환하거나 예외를 던질 수만 있습니다. 상황에 따라 생성자 생성이 어려울 때 널을 반환하거나 다른 타입의 객체를 반환하고자 할 때는 아래와 같이 생성자는 비공개로 지정하고 내포된 객체에 팩토리 메서드 역할을 하는 함수를 정의하고 사용할 수 있습니다.
class Application private constructor(val name: String) {
object Factory {
fun create(args: Array<String>): Application? {
val name = args.firstOrNull() ?: return null
return Application(name)
}
}
}
fun main(args: Array<String>) {
// 생성자는 private이라 직접 호출 불가
// val app = Application(args[0])
val app = Application.Factory.create(args) ?: return
}
위와 같이 사용할 경우, import Application.Factory.create로 팩토리 메서드를 임포트하지 않는 한 항상 내포된 객체의 이름을 함께 지정하여 메소드를 호출해야 하는 불편함이 있습니다.
코틀린에서는 위와 같은 내포된 객체를 동반 객체(companion object)로 정의함으로써 이러한 불편함을 해결할 수 있습니다. 동반 객체는 companion이라는 키워드를 덧붙인 내포된 객체로 아래와 같이 사용합니다.
class Application private constructor(val name: String) {
companion object Factory {
fun create(args: Array<String>): Application? {
val name = args.firstOrNull() ?: return null
return Application(name)
}
}
}
fun main(args: Array<String>) {
// 생성자는 private이라 직접 호출 불가
// val app = Application(args[0])
// 동반 객체 호출 시, 동반 객체 이름 없이 동반 객체가 들어있는 외부 클래스 이름으로 호출 가능
val app = Application.create(args) ?: return
}
위처럼 동반 객체를 사용하면 좀 더 심플하게 내포된 객체에 접근할 수 있습니다.
기본적으로 클래스에 동반 객체는 두 개 이상 있을 수 없습니다. 따라서, 아래와 같이 동반 객체 정의 시 동반 객체 이름을 생략할 수 있습니다.
class Application private constructor(val name: String) {
companion object {
fun create(args: Array<String>): Application? {
val name = args.firstOrNull() ?: return null
return Application(name)
}
}
}
코틀린의 동반 객체를 자바의 정적 문맥과 대응하는 것처럼 생각할 수도 있습니다. 자바와 정적 멤버와 코틀린의 동반 객체의 중요한 차이는 코틀린 동반 객체의 문맥은 객체 인스턴스라는 점입니다. 이로 인해 자바의 정적 멤버보다 코틀린 동반 객체가 더 유연합니다. 코틀린 동반 객체는 다른 상위 타입을 상속할 수도 있고, 일반 객체처럼 여기저기 전달될 수 있습니다.
상속과 동반 객체
동반 객체는 상속이 불가능합니다. 하지만, 다른 클래스나 인터페이스를 상속할 수 있습니다. 이로 인해 자바의 정적 선언과 유사한 것처럼 보여집니다.
interface Theme {
fun someFunction(): String
}
abstract class FactoryCreator {
abstract fun produce(): Theme
}
예를 들어, 위와 같이 인터페이스와 추상 클래스가 있을 때 아래와 같이 동반 객체에 상속시켜서 동작시킬 수 있습니다.
class FirstRelatedClass : Theme {
companion object Factory : FactoryCreator() {
override fun produce() = FirstRelatedClass()
}
override fun someFunction(): String {
return "I am from the first factory."
}
}
class SecondRelatedClass : Theme {
companion object Factory : FactoryCreator() {
override fun produce() = SecondRelatedClass()
}
override fun someFunction(): String {
return "I am from the second factory."
}
}
위처럼 동반 객체에 상속을 사용하면, 아래와 같이 추상 팩토리 패턴을 사용할 수 있습니다.
fun main() {
val factoryOne: FactoryCreator = FirstRelatedClass.Factory
println(factoryOne.produce().someFunction())
val factoryTwo: FactoryCreator = SecondRelatedClass.Factory
println(factoryTwo.produce().someFunction())
}
확장 팩토리 함수
이미 companion 객체가 존재할 때, 이 객체의 함수처럼 사용할 수 있는 팩토리 함수를 만들어야 할 때가 있습니다. 이 때, 해당 객체에 직접 수정을 할 수 없다면 다른 파일에 확장 함수 형태로 구현할 수 있습니다.
interface Tool {
companion object { /* ... */ }
}
fun Tool.Companion.createBigTool( /* ... */ ): BigTOol {
// ...
}
// 호출
Tool.createBigTool()
- 위처럼 companion 객체를 확장할 때 유의점은, (적어도 비어 있는) companion 객체가 필요하다는 점입니다.
톱레벨 팩토리 함수
객체를 만드는 흔한 방법 중 하나로 톱레벨 팩토리 함수가 있습니다. 대표적인 예로는 listOf, setOf, mapOf가 있습니다.
public 톱레벨 함수는 모든 곳에서 사용할 수 있으므로, IDE가 제공하는 팁을 복잡하게 만드는 단점이 있습니다. 톱레벨 함수의 이름을 클래스 메서드 이름처럼 만들면, 다양한 혼란을 일으킬 수 있습니다. 따라서, 톱레벨 함수를 만들 때는 꼭 이름을 신중하게 생각해서 지정해야 합니다.
가짜 생성자
보통 대문자로 시작 여부를 통해 생성자와 함수를 구분하게 됩니다. 물론, 함수도 대문자로 시작할 수 있지만 이는 특수한 용도로 사용됩니다. 예를 들어, List와 MutableList는 인터페이스라 생성자를 가질 수 없습니다. 하지만 아래와 같이 List를 생성자처럼 사용하는 코드는 kotlin에서 동작합니다.
함수명이 대문자로 시작해서 생성자처럼 보이지만, 이는 톱레벨 함수로, 생성자처럼 작동하며 팩토리 함수와 같은 모든 장점을 가지고 있습니다. 많은 개발자들이 이 함수가 톱레벨 함수인지 모르기 때문에, 가짜 생성자(fake constructor)라고 부릅니다.
개발자가 진짜 생성자 대신 가짜 생성자를 만드는 이유는 아래와 같습니다.
- 인터페이스를 위한 생성자를 만들고 싶을 때
- reified 타입 아규먼트를 갖게 하고 싶을 때
가짜 생성자는 진짜 생성자처럼 동작해야 하며, 캐싱, nullable 타입 리턴, 서브클래스 리턴 등의 기능까지 포함해서 객체를 만들고 싶다면 가짜 생성자 대신 companion 객체 팩토리 메서도처럼 다른 이름을 가진 팩토리 함수를 사용하는 것이 좋습니다.
가짜 생성자는 톱레벨 함수를 사용하는 것이 좋으며, 기본 생성자를 만들 수 없는 상황 또는 생성자가 제공하지 않는 기능(예: reified 타입 파라미터 등)으로 생성자를 만들어야 하는 상황에서만 활용하는 것이 좋습니다.
팩토리 클래스의 메서드
팩토리 클래스와 관련된 추상 팩토리, 프로토타입 등의 수많은 생성 패턴이 있습니다. 각각의 패턴은 장단점이 있으며, 일부는 코틀린에 적합하지 않을 수 있습니다.
팩토리 클래스는 클래스의 상태를 가질 수 있다는 특징 때문에 팩토리 함수보다 다양한 기능을 갖습니다. 팩토리 클래스는 프로퍼티를 가질 수 있어, 이를 활용해 다양한 기능을 도입할 수 있습니다. 예를 들어, 캐싱을 활용하거나, 이전에 만든 객체 복제하여 객체 생성이 가능합니다.
아이템 34 - 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라
기존에 자바를 사용할 때, 생성자와 관련된 몇 가지 패턴을 제공했습니다.
점층적 생성자 패턴(telescoping constructor pattern)
점층적 생성자 패턴은 아래와 같이 사용되며, 여러 종류의 생성자를 만드는 패턴입니다.
class Pizza {
val size: String
val cheese: Int
val olives: Int
val bacon: Int
constructor(size: String, cheese: Int, olives: Int, bacon: Int) {
this.size = size
this.cheese = chesse
this.olives = olives
this.bacon = bacon
}
constructor(size: String, cheese: Int, olives: Int): this(size, chesse, olives, 0)
constructor(size: String, cheese: Int): this(size, cheese, 0, 0)
constructor(size: String): this(size, 0, 0, 0)
}
이 코드는 작성해야 할 양이 많기 때문에, 편리한 코드는 아닙니다. 코틀린에서는 보통 아래와 같이 디폴트 아규먼트를 사용합니다.
class Pizza(
val size: String,
val chesse: Int = 0,
val olives: Int = 0,
val bacon: Int = 0
)
이렇게 디폴트 아규먼트를 사용하면, 코드를 한층 단순하고 깔끔하게 만들어줍니다. 또한 점층적 생성자보다 많은 기능을 제공합니다.
예를 들어, 이름 있는 파라미터와 함께 사용해서 객체를 생성할 때 아규먼트 순서를 원하는대로 지정할 수 있습니다.
빌더 패턴(builder pattern)
자바가 사용하는 또 다른 생성자 패턴인 빌더 패턴은 이름 있는 파라미터와 디폴트 아규먼트를 지정할 수 있습니다. 하지만, 빌더 패턴보다 코틀린의 기본 생성자를 사용하면서 이름 있는 파라미터를 사용하는 것이 좋은 이유는 아래와 같습니다.
- 더 짧습니다.
- 디폴트 아규먼트가 있는 생성자나 팩토리 메서드는 빌더 패턴보다 구현하기 훨씬 쉽습니다.
- 구현만 쉬운게 아닌, 읽기도 쉽습니다.
- 더 명확합니다.
- 객체가 어떻게 생성되는지 확인하고자 할 때, 빌더 패턴은 여러 메소드를 봐야 하지만, 생성자는 해당 생성자 근처만 확인하면 됩니다.
- 더 사용하기 쉽습니다.
- 기본 생성자는 기본적으로 언어에 내장된 개념인 반면, 빌더 패턴은 언어 위에 추가로 구현한 개념이라 추가적인 지식이 필요합니다.
- 동시성과 관련된 문제가 없습니다.
- 코틀린의 함수 파라미터는 항상 immutable인 반면, 대부분의 빌더 패턴의 프로퍼티는 mutable입니다. 따라서, 빌더 패턴의 빌더 함수를 쓰레드 안전하게 구현하기 어렵습니다.
물론, 빌더 패턴이 더 유용할 때도 있지만 그럴 경우에는 DSL 빌더를 활용하는 것이 더 유연하고 명확합니다. 빌더 패턴은 아래와 같은 경우 외에는 코틀린에서는 사용되지 않습니다.
- 빌더 패턴을 사용하는 다른 언어로 작성된 라이브러리를 그대로 옮길 때
- 디폴트 아규먼트와 DSL을 지원하지 않는 다른 언어에서 쉽게 사용할 수 있게 API를 설계할 때
아이템 35 - 복잡한 객체를 생성하기 위한 DSL을 정의하라
코틀린을 활용하면 DSL(Domain Specific Language)을 직접 만들 수 있습니다. DSL은 복잡한 객체, 계층 구조를 갖고 있는 객체를 정의할 때 굉장히 유용하며, 처음 만들기는 어렵지만 한 번 만들고 나면 보일러플레이트와 복잡성을 숨기면서 의도를 명확하게 표현할 수 있습니다.
DSL 사용 예시는 코틀린 개발할 때 많이 사용하는 Kotest나 Gradle 설정에서 볼 수 있습니다.
// kotest 예시
class MyFirstTestClass : FunSpec({
test("my first test") {
1 + 2 shouldBe 3
}
})
// gradle 예시
plugins {
// kotlin을 사용하기 위한 설정
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
application
}
DSL을 활용하면 복잡하고 계층적인 자료 구조를 쉽게 만들 수 있습니다. DSL 내부에서도 코틀린이 제공하는 모든 것을 활용할 수 있으며, 코틀린의 DSL은 type-safe이므로 여러 유용한 힌트를 활용할 수 있습니다.
사용자 정의 DSL 만들기
리시버를 가진 함수 타입은 코틀린 DSL을 구성하는 가장 기본적인 블록입니다.
리시버를 가진 함수 타입을 알아보기 전에 먼저 함수 타입에 대해 알아보겠습니다.
함수 타입이란, 함수로 사용할 수 있는 객체를 나타내는 타입으로 위 filter 함수의 predicate에 함수 타입이 활용됩니다.
함수 타입의 예시는 아래와 같습니다.
- () -> Unit: 아규먼트를 갖지 않고 Unit을 리턴하는 함수
- (Int) -> Unit: Int 아규먼트를 받고 Unit을 리턴하는 함수
- (Int) -> Int: Int 아규먼트를 받고 Int를 리턴하는 함수
- (Int, Int) -> Int: Int 아규먼트 2개를 받고, Int를 리턴하는 함수
- (Int) -> () -> Unit: Int 아규먼트를 받고, 다른 함수를 리턴하는 함수이며, 이 함수는 아규먼트를 받지 않은 채 Unit을 리턴함
- (() -> Unit) -> Unit: 다른 함수(아규먼트 없이 Unit 반환하는 함수)를 아규먼트로 받고, Unit을 리턴하는 함수
함수 타입을 만드는 기본적인 방법은 아래와 같습니다.
// 람다 표현식
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.myPlus(other: Int) = this + other
// 익명 확장 함수
val myPlus = fun Int.(other: Int) = this + other
// 위 익명 확장 함수의 타입을 표기하면 아래와 같습니다.
val myPlus: Int.(Int) -> Int = fun Int.(other: Int) = this + other
익명 확장 함수의 타입은 확장 함수를 나타내는 특별한 타입이 됩니다. 이를 리시버를 가진 함수 타입이라고 부릅니다.
- 일반적인 함수 타입과 비슷하지만, 파라미터 앞에 리시버 타입이 추가되어 있으며, 점(.) 기호로 구분되어 있습니다.
이렇게 파라미터 앞에 리시버 타입이 추가된 함수 타입을 리시버를 가진 함수 타입이라고 부릅니다.
리시버를 가진 익명 확장 함수와 람다 표현식은 아래와 같은 방법으로 호출할 수 있습니다.
// 일반적인 객체처럼 invoke 메서드 사용
myPlus.invoke(1, 2)
// 확장함수가 아닌 함수처럼 사용
myPlus(1, 2)
// 일반적인 확장 함수처럼 사용
1.myPlus(2)
- 일반적인 객체처럼 invoke 메서드를 사용
- 확장 함수가 아닌 함수처럼 사용
- 일반적인 확장 함수처럼 사용
리시버를 가진 함수 타입의 가장 중요한 특징은, this의 참조 대상을 변경할 수 있다는 것입니다.
DSL 알아보기
DSL을 언제 사용해야 할까?
DSL은 정보를 정의하는 방법을 제공합니다. DSL은 여러 종류의 정보를 표현할 수 있지만, 사용자 입장에서는 이 정보가 어떻게 활용되는지 명확하지 않습니다. DSL은 익숙하지 않은 사람에게 혼란을 줄 수 있어 유지보수가 어려울 수 있습니다.
따라서, 단순한 기능까지 DSL을 사용하는 것은 적합하지 않고 아래와 같은 것을 표현하는 경우 유용합니다.
- 복잡한 자료 구조
- 계층적인 구조
- 거대한 양의 데이터
DSL 없이 빌더 또는 단순하게 생성자만 활용해도 원하는 모든 것을 표현할 수 있습니다.
많이 사용되는 반복 코드가 있고, 이를 간단하게 만들 수 있는 별도의 코틀린 기능이 없다면, 그때 DSL 사용을 고려해보면 좋습니다.
스터디 준비
궁금한 점
- 아이템 33
- P207) 팩토리 함수는 아직 존재하지 않는 객체를 리턴할 수도 있다고 하는데, 어떨때를 의미하는지?
- P211) 팩토리 함수 만드실 때 from/of 등등 잘 쓰시는지? 팀에 룰이 있는지?
- P212) 추상 companion 객체 팩토리 ?
- 아이템 34
- P227) 커링?
고찰
참고자료
- 이펙티브 코틀린(마르친 모스칼라 저)
- baeldung - companion object
- baeldung - inline functions
- baeldung - reified functions
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Kotlin Coroutine] 코루틴 단위 테스트 (0) | 2024.07.27 |
---|---|
[Kotlin Coroutine] Asynchronous Flow (5) | 2024.07.20 |
[Kotlin Coroutine] 코루틴을 활용한 플로우와 상태 흐름 관리 (0) | 2024.07.13 |
[Effective Kotlin] 4장 추상화 설계 (0) | 2024.07.09 |
[Kotlin Coroutine] 코루틴과 멀티스레딩 환경에서의 최적화 (0) | 2024.07.04 |