스물여섯 번째, 객체 표현식과 선언입니다.
때때로, 명시적인 새로운 하위 클래스 선언 없이, 특정 클래스를 살짝 변경한 객체 생성이 필요할 때 있습니다. 코틀린에서는 이런 경우 객체 표현식과 객체 선언을 사용할 수 있습니다.
객체 표현식
객체 표현식은 class 키워드를 사용하여 명시적으로 선언되 않은 익명 클래스의 객체를 만듭니다. 이러한 클래스는 일회용일때 유용합니다. 이러한 클래스는 맨 처음부터, 또는 다른 클래스를 상속 받거나 인터페이스를 구현하여 정의할 수 있습니다. 익명 클래스의 인스턴스는 이름이 아니라 표현식으로 정의되기 때문에, 익명 객체(anonymous objects)라고도 불립니다.
맨 처음부터(from scratch) 익명 객체 만들기
객체 표현식은 object 키워드로 시작합니다. 어떤 수퍼 타입도 갖지 않는 객체가 필요하다면, object 키워드 다음의 중괄호 안에 멤버들을 작성합니다.
fun main() {
val helloWorld = object {
val hello = "Hello"
val world = "World"
// object expressions extend Any, so `override` is required on `toString()`
// 객표 표현식도 (당연히) Any를 확장하기 때문에,
// toString()에는 override를 붙여야 합니다.
override fun toString() = "$hello $world"
}
print(helloWorld)
}
/* 결과
Hello World
*/
수퍼 타입을 상속 받는 익명 객체
특정 타입(또는 타입들)을 상속 받는 익명 클래스의 객체를 생성하기 위해서는 해당 타입을 object 키워드와 콜론(:) 뒤에 지정합니다. 그리고나서, 멤버들을 구현하거나, 해당 타입을 상속 받는 것처럼 오버라이딩 합니다.
// MouseAdapter 상속.
window.addMouseListener(object : MouseAdapter() {
// MouseAdapter의 멤버들을 오버라이딩
override fun mouseClicked(e: MouseEvent) { /*...*/ }
override fun mouseEntered(e: MouseEvent) { /*...*/ }
})
수퍼 타입이 생성자를 가지고 있는 경우에는 적절한 생성자 매개변수를 넘겨야 합니다. 복수의 수퍼타입은 콜론 뒤에 콤마로 구분하여 지정할 수 있습니다.
open class A(x: Int) {
public open val y: Int = x
}
interface B { /*...*/ }
val ab: A = object : A(1), B {
override val y = 15
}
익명 객체를 반환이나 값(value)의 타입으로 사용
익명 객체가 지역이나 private 함수나 프로퍼티의 타입(함수의 경우는 정확히는 반환 타입)이지만 인라인 선언이 아닌 경우로 사용될 때는 해당 함수나 프로퍼티로 익명 객체의 멤버들을 접근할 수 있습니다.
class C {
private fun getObject() = object {
val x: String = "x"
}
fun printX() {
println(getObject().x)
}
}
이 함수나 프로퍼티가 public 이거나 private 인라인인 경우에는 실제 타입은 다음과 같습니다.
- 익명 개체에 선언된 수퍼 타입이 없는 경우에는 Any
- 익명 객체에 선언된 수퍼 타입이 하나이 하나인 경우에는 해당 수퍼 타입
- 익명 객체에 선언된 수퍼 타입이 복수인 경우에는 명시적으로 선언한 타입
이 모든 경우에 익명 객체에 추가된 멤버들은 접근할 수 없습니다. 함수나 프로퍼티의 실제 타입에 선언돼 있는 것을 오버라이딩한 멤버는 접근 가능합니다.
interface A {
fun funFromA() {}
}
interface B
class C {
// 반환 타입은 Any. x는 접근할 수 없습니다.
fun getObject() = object {
val x: String = "x"
}
// 반환 타입은 A. x는 접근할 수 없습니다.
// 즉, C().getObjectA().funFromA() 가능. C().getObjectA().x 불가
fun getObjectA() = object: A {
override fun funFromA() {}
val x: String = "x"
}
// 복수의 수퍼타입인 경우 반환 타입을 명시한 예.
// 반환 타입은 B, funFromA()와 x 모두 접근 불가합니다.
fun getObjectB(): B = object: A, B { // explicit return type is required
override fun funFromA() {}
val x: String = "x"
}
}
익명 객체에서 변수 접근
객체 표현식 내의 코드는 객체 표현식을 감싸고 있는 범위(scope)의 변수들을 접근할 수 있습니다.
fun countClicks(window: JComponent) {
var clickCount = 0
var enterCount = 0
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
clickCount++
}
override fun mouseEntered(e: MouseEvent) {
enterCount++
}
})
// ...
}
객체 선언
싱글톤 패턴은 여러 경우에 유용합니다. Kotlin에서는 싱글톤을 쉽게 선언할 수 있습니다.
object DataProviderManager {
fun registerDataProvider(provider: DataProvider) {
// ...
}
val allDataProviders: Collection<DataProvider>
get() = // ...
}
이를 객체 선언(object declaration)이라고 부릅니다. 이 객체 선언은 object 키워드 다음에 항상 이름을 갖습니다. 객체 선언은 변수 선언과 같은 표현식이 아닙니다. 그래서, 할당문의 오른쪽 부분에 사용할 수 없습니다.
객체 선언 초기화는 쓰레드에 안전하면(thread-safe)하며 첫 접근시에 수행됩니다.
객체를 참조할 때는 이름을 바로 사용합니다.
DataProviderManager.registerDataProvider(...)
이런 객체 선언은 수퍼 타입을 가질 수 있습니다.
object DefaultListener : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { ... }
override fun mouseEntered(e: MouseEvent) { ... }
}
객체 선언은 지역적일 수 없습니다. 즉, 함수내에 중첩될 수 없습니다. 하지만, 다른 객체 선언이나 내부 클래스가 아닌 클래스에는 중첩될 수 있습니다.
데이터 객체
Kotlin에서 평범한 객체 선언을 출력하면, 문자열은 해당 객체 선언의 이름과 객체의 해시값를 나타냅니다.
object MyObject
fun main() {
println(MyObject) // MyObject@1f32e575
}
데이터 클래스처럼, 객체 선언에도 data 수정자를 표기할 수 있습니다. 이는 컴파일러에게 객체를 위해 다음과 같은 몇가지 함수를 생성하라고 지시하는 것입니다.
- toString() : 해시값이 포함되지 않은 이름을 반환합니다.
- equals() / hashCode() 쌍
※ 데이터 객체는 equals와 hashCode를 맞춤화 할 수 없습니다.
데이터 객체의 toString() 함수는 객체의 이름을 반환합니다.
data object MyDataObject {
val x: Int = 3
}
fun main() {
println(MyDataObject) // MyDataObject
}
데이터 객체의 equals()는 특정 데이터 객체 타입을 갖는 모든 객체는 같다는 것을 보장합니다. 대부분의 경우, 실행시간에 하나의 데이터 객체에 대해 하나의 인스턴스만 가질 것입니다(결국, 데이터 클래스는 싱글턴을 선언합니다). 하지만, 극단적인 경우(예를 들어, java.lang.reflect를 사용하는 플랫폼 리플레션이나이 API를 내부에서 사용하는 JVM 직렬화 라이브러리 같은 경우)로 실행 시간에 같은 타입에 대해서 또 다른 객체가 있는 경우도 해당 객체들은 같게 다뤄집니다.
데이터 객체는 단지 구조적으로 비교(==를 사용한 비교)해야지 참조 비교(===를 사용한 비교)를 해서는 안 됩니다. 이렇게 하는 것은 실행 시간에 하나 이상의 데이터 객체가 존재할 때의 잠재적인 위험을 피할 수 있습니다.
import java.lang.reflect.Constructor
data object MySingleton
fun main() {
val evilTwin = createInstanceViaReflection()
println(MySingleton) // MySingleton
println(evilTwin) // MySingleton
// 심지어 라이브러리가 강제적으로 MySingleton의 두 번째 인스턴스를 만들때도
// equals()는 true를 반환합니다.
println(MySingleton == evilTwin) // true
// 데이터 객체를 === 를 사용하여 비교하지 않습니다.
println(MySingleton === evilTwin) // false
}
fun createInstanceViaReflection(): MySingleton {
// Kotlin의 리플렉션은 데이터 객체의 인스턴스화를 허용하지 않습니다.
// Java 플랫폼 리프렉션을 사용하여 강제적으로 새로운 MySingleton 인스턴스를 생성합니다.
// 이렇게 사용하지 마세요.
return (MySingleton.javaClass.declaredConstructors[0].apply { isAccessible = true } as Constructor<MySingleton>).newInstance()
}
생성된 hashCode() 함수는 equals()와 일관된 행동을 합니다. 그래서, 실행 시간에 데이터 객체는 같은 해시 코드를 갖습니다.
데이터 객체와 데이터 클래스의 차이점
데이터 객체와 데이터 클래스 선언은 때때로 함께 사용되고 여러 유사성을 가지고 있지만, 데이터 객체에는 생성되지 않는 몇몇 함수가 있습니다.
- copy() 함수가 없습니다. 데이터 객체 선언은 싱글톤 객체로 사용하도록 의도된 것이기 때문에, copy() 함수는 생성되지 않습니다. 싱글톤 패턴은 클래스의 인스턴스를 하나로 제한합니다. 그러므로, 인스턴스의 복사본을 허용하는 것은 규칙 위반입니다.
- componentN() 함수가 없습니다. 데이터 클래스와 다르게 데이터 객체는 어떠한 데이터 프로퍼티도 갖지 않습니다. 데이터 프로퍼티가 없는 객체를 구조 분해하는 것은 의미가 없으므로, componentN() 함수는 생성되지 않습니다.
※ 데이터 프로퍼티는 데이터 클래스의 생성자에 선언되어 equals()에 사용(그리고, componentN() 생성의 대상)되는 프로퍼티라고 보시면 됩니다. 즉, 데이터로서 의미를 가져 다른 객체와 구별되게 해주는 프로퍼티라고 보면 됩니다. 실제로 공식 문서나 데이터 오브젝트 탑재를 위한 젯브레인의 유트랙에도 특별한 언급은 없는 걸 봤을 때, 뭔가 언어 차원에 구분되는 키워드라기 보다는 이런 의미를 전달하기 위해서 사용한 말 같습니다.
봉인된 계층 구조에서 데이터 객체 사용
데이터 객체는 봉인된 클래스나 인터페이스처럼 봉인된 계층구조에서 특별히 유용할 수 있습니다. 이는 해당 객체와 함께 정의한 데이터 클래스와 대칭성을 유지할 수 있도록 해줍니다. 다음의 예에서 EndOfFile을 일반적인 객체 대신에 데이터 개체로 선언하는 것은 toString()을 수작업을 오버라이딩 할 필요 없이 얻을 수 있다는데 의미가 있습니다.
sealed interface ReadResult
data class Number(val number: Int) : ReadResult
data class Text(val text: String) : ReadResult
data object EndOfFile : ReadResult
fun main() {
println(Number(7)) // Number(number=7)
println(EndOfFile) // EndOfFile
}
※ 별도의 소제목으로 나누어져 있기 때문에 뭔가 다른 의미가 더 있을거 같은 느낌이지만 그냥 단순히 여기에서 말하는 의미 정도만 있습니다.
동반 객체 (companion objects)
클래스 내에 선언되는 객체는 companion 키워드로 표기할 수 있습니다. 이를 동반 객체라고 합니다.
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}
(이렇게 키워드가 붙은) 동반 객체의 멤버는 단순히 클래스 이름을 한정자(qualifier)로 하여 사용할 수 있습니다.
val instance = MyClass.create()
동반 객체의 이름은 생략될 수 있고, 이런 경우에 동반 객체를 지칭할 때는 Companion을 사용할 수 있습니다.
class MyClass {
companion object {
fun create(): MyClass = MyClass()
}
}
val x = MyClass.Companion
// 동반 객체의 멤버는 동반 객체의 이름 유무와 상관 없이 클래스 이름을 한정자로 해서 접근할 수 있습니다.
val instance = MyClass.create()
// 물론 이렇게도 접근 가능하지만, 이렇게 사용할 필요는 없습니다. 이렇게 사용하면 IDE도 Companion이 불필요하다고 표시해 줍니다.
val instance2 = MyClass.Companion.create()
클래스의 멤버에서는 동반 객체의 private 멤버들도 접근할 수 있습니다.
다른 이름의 한정자로 쓰인게 아닌 그 자체로 쓰인 클래스 이름은 동반 객체가 이름이 있든지 없든 상관 없이 동반 객체의 참조처럼 동작합니다.
class MyClass1 {
companion object Named { }
}
val x = MyClass1
class MyClass2 {
companion object { }
}
val y = MyClass2
동반 객체의 멤버가 다른 언어에서의 정적 멤버처럼 보일지라도, 실행 시간에 이러한 것들은 여전히 실제 객체의 인스턴스 멤버입니다. 그래서, 예를 들어, 인터페이스를 구현할 수 있습니다.
interface Factory<T> {
fun create(): T
}
class MyClass {
companion object : Factory<MyClass> {
override fun create(): MyClass = MyClass()
}
}
val f: Factory<MyClass> = MyClass
하지만, JVM에서는 @JvmStatic 어노테이션을 사용하면 실제 정적인 메소드와 필드로 생성되는 동반 객체의 멤버를 가질 수 있습니다. 보다 자세한 내용은 자바와 상호 운용성 부분을 보시기 바랍니다.
객체 표현식과 객체 선언의 의미적(symantic) 차이점
객체 표현식과 선언 사이에는 의미적으로 중요한 하나의 차이점이 있습니다.
- 객체 표현식은 사용된 곳에서 즉시 실행(및 초기화)됩니다.
- 객체 선언은 게으르게(lazily) 초기화 됩니다. 즉, 처음 접근이 있을 때 초기화 됩니다.
- 동반 객체는 해당 클래스가 적재될 때 초기화됩니다. 이는 Java의 정적 초기화자(initializer)의 의미와 일치합니다.
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 28. Delegated properties (0) | 2024.03.12 |
---|---|
공식 문서로 배우는 코틀린 - 27. Delegation (0) | 2024.03.12 |
공식 문서로 배우는 코틀린 - 25. Inline value classes (0) | 2024.03.11 |
공식 문서로 배우는 코틀린 - 24. Enum classes (0) | 2024.03.11 |
공식 문서로 배우는 코틀린 - 23. Nested and inner classes (0) | 2024.03.11 |