최근 NEXTSTEP 에서 주관하는 TDD, 클린코드 with Kotlin 5기 과정을 들으면서 어떻게 인스 턴스를 생성할지의 대한 고민을 많이 했다.
리뷰어들 마다의 생각도 다르고 각각의 상황도 달라 최대한 다양한 방법을 사용해보려고 노력했던 것 같다 😊
생성자 대신 팩토리 함수를 사용하라
생성자의 주된 임무는 제공된 인자를 사용해서 캡슐화된 프로퍼티를 초기화하는 것이다.
메서드의 수가 많을 수록 단일 책임 원칙 (SRP) 을 위반하지만 생성자의 수가 많아질 수록 클라이언트가 클래스를 더 유연하게 사용할 수 있다.
인스턴스를 만드는 방법중에는 팩토리 함수를 사용하는 방법도 존재한다.
이펙티브 코틀린, 이펙티브 자바에서는 생성자 대신 팩토리 함수를 사용하라
라고 말하고 있다. 관련해서 책에서 말하고 있는 내용을 설명해보겠다.
위에서 말했듯 클라이언트가 인스턴스를 생성하는 가장 일반적인 방법은 주 생성자를 제공하는 것이다.
class MyLinkedList<T> (val head: T, val tail: MyLinkedList<T>?)
val list = MyLinkedList(1, MyLinkedList(2, null)
하지만 생성자가 객체를 생성하는 유일한 방법은 아니다.
객체 인스턴스화를 위한 많은 창조적인 디자인 패턴이 있다.
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)
}
val list = myLinkedListOf(1, 2)
위 예시처럼 생성자의 대안으로 사용되는 함수는 객체를 생성하기 때문에 팩터리 메서드
라고 한다.
그렇다면 생성자 대신 팩터리 메서드를 사용하면 어떤 이점이? 🤔
- 생성자와 달리 함수에 이름이 있다.
- 이름은 개체가 생성되는 방법과 인수가 무엇인지 설명한다. 객체 생성의 특징적인 방법을 설명하기 때문에 이름은 정말 유용하다. 또 다른 이유는 매개 변수 유형이 동일한 생성자 간의 충돌을 해결할 수 있기 때문이다.
- 생성자와 달리 함수는 반환 형식의 하위 유형의 개체를 반환할 수 있다.
listOf
로 리스트를 생성했을 때List
인터페이스가 반환되는 것을 예로 들 수 있음!
- 생성자와 달리 메서드는 호출될 때마다 새 Object 를 만들 필요가 없다.
- 메서드를 사용하여 Object 를 생성할 때 Object 생성을 최적화 하거나 일부의 경우 Object 재사용성을 모장하기위해 캐싱 매커니즘을 포함할 수 있기 때문에 유용할 수 있다. Object 를 만들 수 없을 경우 null 을 반환하는 정적 팩터리 메서드를 정의할 수도 있다.
- e.g)
Connections.createOrNull()
null 을 반환하는 패턴은 코틀린에서는 권장하고 있는 방식이다.
- 팩터리 메서드는 아직 존재하지 않는 Object 를 제공할 수 있다.
- 이러한 방식으로 개발자는 프로젝트를 빌드하지 않고 프록시를 통해 생성되거나 사용될 객체에 대해 작업할 수 있다.
- 객체 외부에 팩터리 메서드를 정의할 때 가시성을 제어할 수 있다.
- 동일한 파일 또는 동일한 모듈에서만 액세스할 수 있는 최상위 팩터리 기능을 만들 수 있다.
- 팩터리 메서드는 인라인 방식으로 작동할 수 있으므로 해당 유형의 매게변수를 구체화할 수 있다.
- 생성자는 즉시 수퍼 클래스 혹은 기본 생성자의 생성자를 호출해야하지만 팩터리 메서드를 사용하면 생성자 사용을 연기할 수 있다.
이러한 이점이 있는 팩터리 메서드는 기능에 제한이 있다.
하위 클래스에서는 사용할 수 없다. 서브 클래스에서는 슈퍼 클래스의 생성자를 불러야하기 때문이다.
class IntLinkedList: MyLinkedList<Int>() {
// Supposing that MyLinkedList is open
constructor(vararg ints: Int): myLinkedListOf(*ints) // Error
}
하지만 이러한 제한은 그리 문제가 되지 않는다. 팩터리 메서드를 사용하여 슈퍼 클래스를 만들기로 했다면 우린 하위 크래스의 생성자를 사용할 필요가 없기 때문이다.
위에서 계속 보여줬던 예제를 다시 한 번 보자!
class MyLinkedIntList(
head: Int,
tail: MyLinkedIntList?
): MyLinkedList<Int>(head, tail)
fun myLinkedListOf(vararg elemnets: 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 반환 유형을 선언하는 기능
과 같은 더 나은 특성을 가지고 있다.
팩터리 메서드는 주로 부 생성자와 비교대상이며 코틀린 프로젝트를 보면 부 생성자가 거의 사용되지 않는다...!
코틀린에서 다양한 팩터리 메서드를 정의하는 방식을 알아보자!
companion object
팩터리 메서드- 확장 팩터리 메서드
- Top level 팩터리 메서드
- Fake constructors 가짜 생성자
- factory class 에서의 메서드
1. Companion object
Factory Function
동반객체는 가장 널리 쓰이는 팩터리 메서드를 정의하는 방식이다.
class MyLinkedList<T>(val head: T, val tail: MyLinkedList<T>? {
companion object {
fun <T> of(vararg elements: T): MyLinkedList<T>? { /*...*/ }
}
}
//Usage
val list = MyLinkedList.of(1,2)
요러한 접근 방식은 정적 팩토리 방식과 거의 동일해서 Java 개발자에게는 매우 친숙하다.
코틀린에서 이러한 접근 방식은 인터페이스에서도 작동한다.
class MyLinkedList<T> {
val head: T,
val tail: MyLinkedList<T>?
): MyList<T> {
//...
}
interface MyList<T> {
// ...
companion object {
fun <T> of(vararg elements: T): MyList<T>? {
//...
}
}
}
// Usage
val list = MyList.of(1,2)
다음은 통상적으로 사용되는 팩토리 메서드 네이밍 규칙이다.
from
: 단일 매개 변수를 사용하여 동일한 유형의 해당 인스턴스를 반환하는 형식 변환 함수이다.val date: Date = Date.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
와 비슷하지만 팩토리 함수가 다른 클래스에 있을 경우 사용된다. type은 팩터리 메서드에서 반환되는 객체의 유형이다.val fs: FileStore = Files.getFileStore(path)
newType
:newInstance
와 비슷하지만 팩토리 메서드에서 반환되는 객체의 유형이다.val br: BufferedReader = Files.newBufferedReader(path)
2. 확장 팩터리 메서드
1번에서 언급했던 companion object 를 수정할 수 없거나 다른 파일에 만들고 싶을 때 확장함수를 이용한다.
아래 인터페이스를 변경할 수 없다고 가졍했을 때,
interface Tool {
companion object { /*...*/ }
}
우리는 companion object 에 확장함수를 정의할 수 있다.
fun Tool.Companion.createBigTool(/*...*/): BigTool {/*..*/}
이렇게 정의하면 아래와 같이 사용할 수 있다.
Tool.createBigTool()
이는 자체 팩터리 방식으로 외부 라이브러리를 확장할 수 있는 강력한 기능이다. 한 가지 단점은 companion object 를 확장하기위해서는 companion object 객체가 있어야 한다.
3. Top-level 함수
최상위 팩터리 함수의 일반적인 예로는 우리가 흔히 사용하고있는 listOf
, SetOf
, mapOf
가 있다.
유틸성으로 사용되는 함수들이 그 예이다.
top-level 함수를 사용하는 객체 생성은 ListOf(1,2,3)
이 List.of(1,2,4)
보다 간단하고 읽기 쉽기 때문에 top-level 함수로 구현해두고 사용하는 것이 좋은 선택이라고 한다. 그러나 공용되는 top-level 기능은 신중하게 사용해야한다. 어디서나 사용되기때문에 특히 이름을 현명하게 지어야한다!
(코틀린은 알면 알수록 가독성을 정말 많이 따지는 언어인 것 같다)
4. Fake Constructors 가짜 생성자
코틀린의 생성자는 top-level 함수와 동일한 방식으로 사용된다.
class A
val a = A()
또한 top-level 함수와 동일하게 참조된다.
val reference: () -> A = ::A
사용관점에서 첫 글자의 대문자는 생성자와 함수의 유일한 구별이다. 일반적으로 클래스는 대문자로 시작하여 소문자로 명명한다.
코틀린의 인터페이스인 List
, MutableList
를 보면 생성자를 가지고 있지 않은 인터페이스이다.
하지만 코틀린에서는 다음과 같은 구현을 허용한다.
List(4) { "User$it" } // [User0, User1, USer2, USer3]
실제로 해당 코드를 들어가보면...아래와 같이 구현되어있는데...
이러한 top-level 함수는 생성자 처럼 보이고 작동하기 때문에 많은 개발자들이 이것이 top-level 함수인지 모른다.
나도 처음에 생성자처럼 기능하니 당연히 생성자일 거라고 생각했었다. 이것이 바로 가짜 생성자이다.
실제로 인텔리제이에서 해당 함수를 사용하면
뭔가 킹받게(?) 살짝 기울어져 있는 것을 볼 수 있다. 가짜임을 알려주나보다 ㅎ..
가짜 생성자는 실제 일반 생성자처럼 행동해야한다. 캐싱이나 null 반환 또는 생성할 수 있는 클래스의 하위 클래스를 반환하는 기능을 포함하려면 companion object 와 같이 이름있는 팩토리 함수를 사용하는 것이 좋다.
가짜 생성자를 선언할 수 있는 방법은 하나 더 있다. invoke 연산자
와 함께 companion object 를 사용하는 것이다.
class Tree<T> {
companion object {
operator fun <T> invoke(size: Int, generator: (Int) -> T): Tree<T> {
//...
}
}
}
// Usage
Tree(10) { "$it" }
그러나 가짜 생성자를 만들기 위해 동반 객체에서 호출을 구현하는 것은 권장하지 않는다.12. 이름에 따른 연산자 메서드를 사용한다.
라는 룰을 깬다.
Tree.invoke(10) { "$it" }
invoke
는 객체 구성과는 다른 작업이다. 연산자를 이런식으로 사용하는 것은 연산자 이름과 일치하지 않는다. 더 중요한 것은 이 접근법은 top-level 기능보다 더 복잡하다는 것이다.
생성자, 가짜 생성자를 참조하고 동반객체에서 함수를 호출할 때 어떻게 보이는지 비교해보자!
생성자:
val f: () -> Tree = ::Tree
가짜 생성자:
val f: () -> Tree = ::Tree
동반 객체 invoke:
val f: () -> Tree = Tree.Companion::invoke
가짜 생성자가 필요하다면 top-level 함수를 사용하는 것을 추천한다.
클래스 자체에서 생성자를 정의할 수 없거나 생성자가 제공하지 않는 기능이 필요할 때 일반적인 생성자와 같은 효과를 얻기 위해서 사용해야한다.
5. factory 클래스에서의 메서드
팩토리 클래스
와 관련된 많은 생성 패턴들이 있다. 예를 들면 추상 팩토리 또는 프로토 타입이다. 이것들의 장점은 무엇일까?
클래스가 상태를 가질 수 있기 때문에 팩토리 클래스는 팩토리 함수보다 유리하다.
(다음 항목에서 우리는 코틀린에서 Telescoping 생성자와 Builder 패턴이 거의 의미가 없다는 것을 알게된다...)
다음은 id 번호를 가진 학생을 생성하는 매우 간단한 팩토리 클래스 예제이다.
data class Student(
val id: Int,
val name: String,
val surname: String
)
class StudentsFactory {
var nextId = 0
fun next(name: String, surname: String) =
Student(nextId++, name, surname)
}
val factory = StudentsFactory()
val sl = factory.next("Marcin", "Moskala")
println(s1) // Student(id=0, name=Marcin, Surname=Moskala)
val s2 = factory.next("Maja", "Markiewicz")
println(s2) // Student(id=1, name=Maja, Surname=Markiewicz)
팩토리 클래스는 속성을 가질 수 있으며 이러한 속성을 사용하여 개체 생성을 최적화 할 수 있다. 상태를 유지할 수 있을 때 다양한 종류의 최적화 또는 기능을 도입할 수 있다. 예를 들면 캐싱을 사용하거나 이전에 생성된 객체를 복제하여 개체 생성 속도를 높힐 수 있다.
🤔 그래서... 생성자보다 팩토리 메서드를 써야해?
사실 이 질문의 결정을 내리기 위해서 자료들을 찾아본 것인데 사실 더 혼란스러워졌다..! 코드 바이 코드, 상황 바이 상황(회바회 ㅎ)인 것 같다. 쭉 살펴보았듯이 코틀린은 팩터리 함수를 지정하는 다양한 방법들을 제공하고 모두 고유한 용도로 사용하고있다. 따라서 각각 때에 맞춰 합리적으로 사용되어야하거나 일부는 조심스럽게 규약을 지키면서 사용해야한다. 팩터리 함수를 정의하는 가장 보편적인 방법은 companion object 를 정의하는 것인 것 같다. Java 의 Static Factory methods 형태와 매우 유사하기 때문에 대부분의 개발자에게 익숙하고 직관적인 것 같다.
'Kotlin & Java' 카테고리의 다른 글
매개변수를 통해 JVM 메모리 할당과 회수 전략에 대해서 알아보자! (1) | 2024.11.24 |
---|---|
# c++과 Java의 닮은 점과 차이점은 무엇일까? (0) | 2020.02.02 |
Kotlin 설치 및 실습 환경 구축하기 (0) | 2019.12.24 |
안드로이드 apk 파일 만들기 (0) | 2019.06.20 |