객체를 생성하는 방법에 어떤 것들이 있는지, 각각의 방법에는 어떤 장점과 단점이 있는지 살펴보자.
[Effective Kotlin. 33] 생성자 대신 팩토리 함수를 사용하라.
객체 생성 방법
- 생성자
- 별도의 함수를 통해 생성
생성자의 역할을 대신 해주는 함수를 팩토리 함수
라 부르며, 아래와 같은 장점들이 생김
- 생성자와 다르게 함수에 이름을 붙일 수 있음 (가독성이 좋아짐)
- ArrayList(3) : ArrayList.withSize(3)
- 함수가 원하는 형태의 타입을 리턴할 수 있음
- 호출될 때마다 새 객체를 만들 필요가 없음
- 싱글턴 패턴처럼 객체를 하나만 생성하게 강제하거나, 최적화를 위해 캐싱 메커니즘을 사용할 수 있음
- 아직 존재하지 않는 객체를 리턴할 수 있음
- 가시성을 원하는 대로 제어할 수 있음
- 인라인으로 만들 수 있음
- 생성자로 만들기 복잡한 객체도 만들 수 있음
- 생성자는 즉시 슈퍼클래스 또는 기본 생성자를 호출해야 하지만 팩토리 함수는 원하는 때에 생성자를 호출할 수 있음
1. Companion 객체 팩토리 함수
companion 객체를 사용하여 팩토리 함수를 정의하는 가장 일반적인 방법 (정적 팩토리 메소드 함수)
companion object {
fun <T> of(vararg elements: T): MyLinkedList<T>? {
/*...*/
}
}
}
// 사용
val list = MyLinkedList.of(1, 2)
팩토리함수에는 아래와 같은 이름들이 많이 사용 되니 참고
- from : 파라미터를 하나 받고, 같은 타입의 인스턴스 하나를 리턴하는 타입 변환 함수
- val date: Date = Date.from(instant)
- of : 파라미터를 여러개 받고, 이를 통합해서 인스턴스를 만들어 주는 함수
- val faceCards: Set = EnumSet.of(JACK, QUEEN, KING)
- valueOf : form, of 와 비슷한 기능을 하면서도, 의미를 조금 더 쉽게 읽을 수 있게 이름을 붙인 함수
- val prime: BigInteger
- 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)
- newType : newInstance처럼 동작하지만, 팩토리 함수가 다른 클래스에 있을때 사용하는 이름
- val br: BufferedReader = Files.newBufferedReader(path)
2. 확장 팩토리 함수
이미 companion 객체가 존재할 때, 이 객체의 팩토리 함수를 만들어야할 때 사용
companion 객체를 직접 수정할 수는 없고, 다른 파일에 함수를 만들어야 한다면 확장 함수를 활용할 수 있음
// Tool 인터페이스는 교체할 수 없는 상황
interface Tool {
companion object { /*...*/ }
}
companion 객체를 활용하여 확장 함수를 정의할 수 있음
fun Tool.Companion.createBigTool( /*...*/ ): BigTool { // ... }
// 호출
Tool.createBigTool()
companion 객체를 활용하라면, 적어도 비어 있는 컴패니언 객체가 필요하다는 것을 기억해야 함
3. 톱레벨 팩토리 함수
대표적인 예로 listOf, setOf, mapOf가 있음
companion 객체로 팩토리 함수를 만들 수 있지만, 톱레벨 함수로 사용할 때가 읽기 쉬운 부분이 있을 수 있음
listOf(1, 2, 3)
ArrayList.of(1, 2, 3)
하지만 톱레벨 함수를 만들 때는 이름을 신중하게 생각해서 잘 지정해야 함
4. 가짜 생성자
List와 MutableList는 인터페이스이지만, List를 생성자처럼 사용하는 코드가 존재한다.
List(4) { "User$it" } [User0, User1, User2, User3]
다음과 같은 함수가 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 타입 아규먼트를 갖게 하고 싶을 때(?)
가짜 생성자를 선언하는 방법이 또 하나 있긴 한데, 추천하지는 않는 방식임
class Tree<T> {
companion object {
operator fun <T> invoke(size: Int, generator: (Int) -> T): Tree<T> {
// ...
}
}
}
// 사용
Tree(10) { "$it" }
- 연산자 오버라이드의 의미가 맞지 않음
- invoke는 호출한다는 의미며 객체 생성과 의미가 다름
- 이런식으로 연산자를 오버로드하면, 원래 의미와 차이가 발생
5. 팩토리 클래스의 메서드
점층적 생성자 패턴과 빌더 패턴은 코틀린에서 의미가 없음팩토리 클래스
는 클래스의 상태
를 가질 수 있다는 특징 때문에 팩토리 함수보다 다양한 기능을 갖음
data class Student(
val id: Int,
val name: String,
val surname: String
)
class StudentsFactory {
var nextId = 0
fun next(name: String, surename: String) = Student(nextId++, name, surname)
}
val factory = StudentFactory()
val s1 = factory.next("깡", "냉")
val s2 = factory.next("corn", "world")
팩토리 클래스는 프로퍼티를 가질 수 있기 때문에 이를 활용하여 다양한 종류로 최적화, 다양한 기능을 도입할 수 있음
예를 들어 캐싱을 활용하거나, 이전에 만든 객체를 복제해서 객체를 생성하는 방법으로 객체 생성 속도를 높일 수 있음
[Effective Kotlin. 34] 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라.
기본 생성자가 좋은 방식인 이유를 이해하려면, 아래 패턴들을 이해하는 것이 우선이다.
- 점층적 생성자 패턴(telescoping constructor pattern)
- 빌더 패턴(builder pattern)
점층적 생성자 패턴
여러 가지 종류의 생성자를 사용
하는 간단한 패턴
class Pizza{
val name : String
val cheese : Int
val olview : Int
val bacon : Int
}
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, 0)
constructor(size: String , cheese : Int): this (size, 0 )
위와 같은 코드는 좋지 않음.
코틀린에서는 일반적으로 디폴트 아규먼트(default argument)를 사용하여 이 문제를 해결함
class Pizza(
val size: String,
val chees : Int = 0
val olives : Int = 0
val bacon :Int = 0
)
코드를 단순하고 깔끔하게 만들어 줄 뿐만 아니라, 훨씬 다양한 기능을 제공함
- 파라미터들의 값을 원하는 대로 지정할 수 있음
- 아규먼트를 원하는 순서로 지정할 수 있음
- 명시적으로 이름을 붙여서 아규먼트를 지정하므로 의미가 훨씬 명확
val villagePizza = Pizza(
size = "L"
cheese = 1,
olives = 2,
bacon = 3
)
빌더 패턴
자바는 이름 있는 파라미터와 디폴트 아규먼트를 사용할 수 없어 빌더 패턴을 사용한다.
빌더 패턴을 사용하면 다음과 같은 장점이 있음
- 파라미터에 이름을 붙일 수 있음
- 파라미터를 원하는 순서로 지정할 수 있음
- 디폴트 값을 지정할 수 있음
이러한 장점은 코틀린의 디폴트 아규먼트와 이름 있는 파라미터가 갖고 있음
빌더 패턴을 사용하는 것보다 이름 있는 파라미터를 사용하는 것이 좋은 이유는 아래와 같음
- 더 짧음
- 구현하기 쉬울 뿐만 아니라 읽는 사람의 입장에서도 읽기 쉬움
- 코드를 수정하는 것도 어려움
- 더 명확함
- 객체가 어떻게 생성되는지 확인하고 싶을 때, 빌더 패턴은 여러 메서드들을 확인해야 함
- 디폴트 아규먼트가 있는 코드는 생성자 주변 부분만 확인하면 됨
- 더 사용하기 쉬움
- 기본 생성자는 기본적으로 언어에 내장된 개념
- 빌더 패턴은 언어 위에 추가로 구현한 개념이므로, 추가적은 knowledge가 필요
- 동시성과 관련된 문제가 없음
- 코틀린의 함수 파라미터는 항상 immutable 반면 빌더 패턴에서 프로퍼티는 mutable 임
[Effective Kotlin. 35] 복잡한 객체를 생성하기 위한 DSL을 정의하라
- DSL에 대한 지식이 부족하여 나중에 정리하도록 한다.
'프로그래밍 노트 > Effective 시리즈' 카테고리의 다른 글
[Effective Kotlin] 8장 효율적인 컬렉션 처리 (1) | 2023.12.18 |
---|---|
[Effective Kotlin] 3장. 재사용성(Reusability) (1) | 2023.11.23 |
[Effective Kotlin. 16] 프로퍼티는 동작이 아니라 상태를 나타내야 한다. (1) | 2023.11.01 |
[Effective Kotlin. 11] 가독성을 목표로 설계하라 (1) | 2023.11.01 |
[Effective Kotlin. 26] 함수 내부의 추상화 레벨을 통일하라 (0) | 2023.10.25 |