클라이언트가 클래스의 인스턴스를 만들게 하는 가장 일반적인 방법은 기본 생성자를 사용하는 방법입니다.
코드로 보여드리면
class PersonClass(val name: String, val age: Int)
{
// TODO
}
val instance = Person("HEETAE",26)
다음과 같이 Person Class를 통해 name과 age라는 두 개의 속성을 가지고 있는 클래스를 생성하였습니다.
그리고 instance에 name을 HEETAE와 age는 26으로 초기화한 인스턴스를 생성하여 담고 있습니다.
하지만 생성자가 객체를 만들 수 있는 유일한 방법은 아닙니다. 디자인 패턴으로 굉장히 다양한 생성 패턴(creational pattern)들이 만들어져 있기 때문입니다.
일반적으로 이러한 생성 패턴은 객체를 생성자로 직접 생성하지 않고, 별도의 함수를 통해 생성합니다.
코르로 설명하겠습니다.
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)
위의 가장 상위 함수인 myLinkedListOf는 MyLinkedList 클래스의 인스턴스를 만들어서 제공해줍니다. 위의 클래스처럼 생성자의 역할을 대신 해주는 함수를 팩토리 함수라고 부릅니다.
팩토리 함수 : 객체 생성 로직을 캡슐화하는 방법으로 객체를 생성할 때 유연성과 확장성을 높일 수 있으며 코드를 더욱 간결하게 작성할 수 있습니다.
다른 예시도 하나 보겠습니다.
// Animal이라는 클래스를 정의해줍니다.
open class Animal(val name: String)
class AnimalFactory {
companion object {
fun create(name: String, type: String): Animal {
return when(type) {
"dog" -> Dog(name)
"cat" -> Cat(name)
else -> throw IllegalArgumentException("Unknown type")
}
}
}
}
// Dog 클래스 정의
class Dog(name: String): Animal(name) {
fun bark() {
println("$name is barking")
}
}
// Cat 클래스 정의
class Cat(name: String): Animal(name) {
fun meow() {
println("$name is meowing")
}
}
fun main() {
val dog = AnimalFactory.create("Buddy", "dog")
val cat = AnimalFactory.create("Luna", "cat")
println(dog.name)
dog.bark()
println(cat.name)
cat.meow()
위의 코드에서는 AnimalFactory 클래스의 create메서드를 팩토리 함수로 사용하여 Dog와 Cat 객체를 생성하는 것을 볼 수 있습니다.
생성자 대신 팩토리 함수를 사용하면 다양한 장점들이 생기게 됩니다.
예를 들어 ArrayList(3) 이라는 코드를 봤을 때 3이라는 것을 봤을 때 이 부분이 List의 사이즈가 3이라는 것인지 아니면 첫 번째 요소가 3이라는지 이 부분의 코드만 보고는 파악이 힘들 수 있습니다. 만약 ArrayList.withSize(3)라는 이름이 붙어 있다면? 그 전의 코드보다 훨씬 이해하기 쉬울 것입니다. 그렇기에 변수, 함수 명을 작성할 때 많이 고민을 하는 이유입니다.
예를 들어 listOf()함수를 생각해보면 listOf()는 List의 인터페이스를 리턴합니다. 그렇다면 실제로 어떤 객체를 리턴할까요? 이는 플랫폼에 따라 차이가 있습니다. 그렇기에 각 플랫폼의 빌트인 컬렉션으로 만들어지고 해당 컬렉션이 리턴되는 것입니다.
코드로 예를 들겠습니다.
object Counter {
var count = 0
private set
}
fun main() {
Counter.count++ // 1
Counter.count++ // 2
println(Counter.count) // 2
}
위의 코드는 Kotlin을 사용해서 싱글턴 패턴을 구현하는 방법입니다.
Counter라는 객체를 생성하고, 이 객체 내부에 count 변수를 만들어서 이를 공유합니다. count 변수의 접근제한자를 private으로 설정하여, 외부에서는 값을 수정할 수 없도록 합니다. 이렇게 하면 같은 객체를 사용하여 count값을 공유할 수 있습니다.
다른 예시도 보겠습니다.
fun fibonacci(n: Int): Int {
if (n == 0 || n == 1) {
return n
}
if (fibonacciCache.containsKey(n)) {
return fibonacciCache[n]!!
}
fibonacciCache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return fibonacciCache[n]!!
}
val fibonacciCache = mutableMapOf<Int, Int>()
fun main() {
println(fibonacci(10)) // 55
}
피보나치 수열을 계산하는 함수입니다. 이 함수 내부에 fibonacciCache라는 mutableMap을 만들어서 계산된 결과를 저장하고, 이전에 계산된 결과가 있다면 이를 반환하도록 합니다. fibonacciCache의 키는 n값이며 값은 피보나치 수열의 n번째 값을 나타냅니다. 이렇게 하면 같은 값을 계산할 때마다 캐시된 값을 사용하여 성능을 최적화 할 수 있습니다.
설명 중에 하나던 객체가 없을 경우 Null을 리턴하는 팩토리 함수도 구현해보겠습니다.
data class Person(val name: String, val age: Int)
fun createPerson(name: String, age: Int): Person? {
return if (name is String && age is Int) {
Person(name, age)
} else {
null
}
}
fun main() {
println(createPerson("HEETAE", 26)) // Person(name=HEETAE, age=26)
println(createPerson(123, "26")) // null
}
createPerson이라는 함수를 사용하여 객체를 생성하는데, 이 함수 내부에서는 name과 age가 각각 String과 Int 타입인지 체크하고, 그렇지 않은 경우에는 null을 반환 합니다. nullable이기 때문에 createPerson 합수의 타입을 Person?을 설정해주는 것을 확인할 수 있습니다.
이렇게 하면 함수를 사용하는 측에서는 객체가 생성되지 않았음을 쉽게 인지할 수 있습니다.
코드 예시를 작성하겠습니다.
interface Animal {
fun speak()
}
class Dog : Animal {
override fun speak() {
println("Woof!")
}
}
class Cat : Animal {
override fun speak() {
println("Meow!")
}
}
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class AnimalFactory(val value: KClass<out Animal>)
@AnimalFactory(Dog::class)
class DogFactory
@AnimalFactory(Cat::class)
class CatFactory
class AnimalService {
private val factories = mutableMapOf<Class<*>, () -> Animal>()
init {
val factoryClasses = this.javaClass.classLoader.getResources("")
.toList().flatMap { it.toURI().toFile().walkTopDown() }
.filter { it.name.endsWith("Factory.class") }
.map { Class.forName(it.name.replace('/', '.').dropLast(6)) }
for (factoryClass in factoryClasses) {
val annotation = factoryClass.getAnnotation(AnimalFactory::class.java)
val animalClass = annotation?.value?.java
val factory = factoryClass.kotlin.objectInstance ?: factoryClass.newInstance()
factories[animalClass!!] = { factory.create() }
}
}
fun getAnimal(animalClass: Class<out Animal>): Animal? {
val factory = factories[animalClass]
return factory?.invoke()
}
}
interface AnimalFactory {
fun create(): Animal
}
class DogFactory : AnimalFactory {
override fun create(): Animal {
return Dog()
}
}
class CatFactory : AnimalFactory {
override fun create(): Animal {
return Cat()
}
}
fun main() {
val animalService = AnimalService()
val dog = animalService.getAnimal(Dog::class.java)
val cat = animalService.getAnimal(Cat::class.java)
dog?.speak() // Woof!
cat?.speak() // Meow!
}
우선 전체적인 설명을 먼저 하고 세부적으로 코드 설명을 하겠습니다.
AnimalFactory라는 인터페이스를 정의하고 DogFactory와 CatFactory가 이를 각각 Dog와 Cat객체를 생성하도록 합니다. 이렇게 하면 Animal 객체를 생성하는데 필요한 팩토리 함수가 구현됩니다.
그 다음 AnimalService 클래스에서는 팩토리 클래스들을 스캔하여 factories 맵에 저장합니다. 각 팩토리 클래스는 AnimalFactory 어노테이션을 가지고 있으며, 어노테이션에는 해당 팩토리 클래스가 생성할 Animal의 타입이 지정되어 있습니다. factories 맵에는 Animal 타입을 키로, 팩토리 함수를 값으로 저장합니다.
마지막으로 main 함수에서는 AnimalService를 생성하고, getAnimal 함수를 사용하여 Dog와 Cat 객체를 생성하고 speak 메서드를 호출합니다. 이렇게 하면 어노테이션 기반으로 하는 팩토리 함수를 사용하여 다양한 Animal 객체를 할 수 있습니다.
모를 수 있는 코드부분에 대해서 설명하겠습니다.
class AnimalService {
private val factories = mutableMapOf<Class<*>, () -> Animal>()
// class 타입의 키와 () -> Animal 타입의 값을 가지는 가변적인 Map객체를 생성 해줍니다.
init {
val factoryClasses = this.javaClass.classLoader.getResources("")
.toList().flatMap { it.toURI().toFile().walkTopDown() }
.filter { it.name.endsWith("Factory.class") }
.map { Class.forName(it.name.replace('/', '.').dropLast(6)) }
// 현재 클래스의 클래스 로더에서 리소스를 가져오는 작업을 해주고 객체를 파일 객체로 변환해,
// 파일 객체에서 하위 디렉터리를 재귀적으로 탐색합니다.
// Factory.class 로 끝나는 클래스 파일만을 필터링합니다.
// 클래스 파일의 이름을 사용하여 클래스 객체를 생성합니다.
for (factoryClass in factoryClasses) {
val annotation = factoryClass.getAnnotation(AnimalFactory::class.java)
val animalClass = annotation?.value?.java
val factory = factoryClass.kotlin.objectInstance ?: factoryClass.newInstance()
factories[animalClass!!] = { factory.create() }
}
// factoryClasses에서 클래스 객체를 하나씩 가져와 팩토리 함수를 생성합니다.
// factoryClass에서 AnimalFactory 어노테이션을 가져옵니다.
// AnimalFactory 어노테이션의 vlaue 프로퍼티에서 Animal 클래스를 가져옵니다. nullable합니다.
// factoryClass의 Compoanion 객체가 있으면 그 객첼르 사용하고 없으면 새로운 인스턴스를 생성합니다.
// animalClass와 factory.create()를 factories에 등록합니다.
}
fun getAnimal(animalClass: Class<out Animal>): Animal? {
val factory = factories[animalClass]
return factory?.invoke()
}
// animalClass에 해당하는 팩토리 함수를 호출합니다.
}
위의 예시들에서는 AnimalFactory라는 인터페이스를 구현하여 팩토리 함수를 생성했지만 팩토리 함수를 생성하는 또 다른 방법으로 람다 함수를 사용하는 것이 있습니다. 예를 들어 위의 예시에서 사용된 AnimalFactory 인터페이스를 다음과 같이 람다 함수로 대체할 수 있습니다.
typealias AnimalFactory = () -> Animal
// typelias는 kotlin에서 타입에 대한 별칭을 지정할 때 사용하는 키워드입니다.
// 아래의 class에서 사용되는 것이 사실 타입이 () -> Animal이라고
선언하는 것을 AnimalFactory로 대신한 것 입니다.
class DogFactory : AnimalFactory {
override fun invoke(): Animal {
return Dog()
}
}
class CatFactory : AnimalFactory {
override fun invoke(): Animal {
return Cat()
}
}
위의 처럼 사용하면 훨씬 더 간결해진 모습을 볼 수 있습니다.
예시 ->
inline fun <reified T> createAnimal(): T? {
return when (T::class) {
Cat::class -> Cat() as T
Dog::class -> Dog() as T
else -> null
}
}
T라는 reified 타입 파라미터를 받아서, 해당 타입에 따라 적절한 Animal 객체를 생성하고 리턴합니다. reified 키워드를 사용하면 런타임에 타입 정보를 유지할 수 있어서, 해당 타입에 대한 정보를 사용해서 객체를 생성할 수 있습니다.
위의 같은 장점들이 있는 대신 약간의 제한이 발생합니다. 서브클래스 생성에는 슈퍼클래스의 생성자가 필요하기 때문에 서브 클래스를 만들어 낼 수 없다는 것입니다.
그렇다고 불가능한 것은 아닙니다.
팩토리 함수로 슈퍼클래스를 만들기로 했다면 그 서브클래스에도 똑같이 팩토리 함수를 만들면 되기 때문입니다.
앞의 생성자는 이전 생성자보다 길지만, 유연성, 클래스 독립성, nullable을 리턴하는 등의 다양한 특징을 갖습니다.
팩토리 함수는 굉장히 강력한 객체 생성 방법입니다. 참고로 이 말을 들으면 기본 생성자 또는 팩토리 함수 중에 하나를 사용해야 한다고 이해할 수 있습니다. 하지만 기본 생성자를 절대 사용하지 말라는 뜻은 아니니 상황에 맞게 사용하시면 될 것 같습니다.
대표적인 예시로는 다음과같은 상황들이 있습니다.
- 객체 생성이 복잡한 경우
- 객체를 캐시하고 재사용하는 경우
- 객체 생성에 필요한 정보를 검증하고 가공하는 경우
- 특정한 조건에 따라 객체를 생성하는 경우
- 객체 생성에 대한 유연성이 필요한 경우
팩토리 함수는 다른 종류의 팩토리 함수와의 경쟁 관계에 있다고 할 수 있습니다. 그렇다면 어떠한 종류의 팩토리 함수가 있을까요?
companion 객체 팩토리 함수
확장 팩토리 함수
Top-Level 팩토리 함수
가짜 생성자
팩토리 클래스의 메서드
다음 Kotlin 글에서는 위의 함수들의 차이점에 대해서 설명하는 시간을 가져보도록 하겠습니다. 읽어주셔서 감사합니다.