객체 선언과 객체 표현식

객체 선언과 객체 표현식 (Object declarations and expressions)

Kotlin에서 object는 클래스를 정의하고 그 인스턴스를 만드는 작업을 한 번에 처리해 줘요. 재사용 가능한 싱글턴 인스턴스가 필요하거나, 딱 한 번 쓰고 말 객체가 필요할 때 특히 유용합니다. 이런 상황을 다루기 위해 Kotlin은 두 가지 핵심 방식을 제공해요. 싱글턴을 만드는 **객체 선언(object declaration)**과 익명의 일회용 객체를 만드는 **객체 표현식(object expression)**이 바로 그것이에요.

싱글턴(singleton)은 클래스의 인스턴스가 하나만 존재하도록 보장하고, 그 인스턴스에 접근할 수 있는 전역 지점을 제공해요.

객체 선언과 객체 표현식은 이런 상황에서 가장 잘 어울려요.

  • 공유 자원에 싱글턴 사용: 애플리케이션 전체에서 클래스의 인스턴스가 하나만 존재하도록 보장해야 할 때예요. 예를 들어 데이터베이스 커넥션 풀을 관리하는 경우가 대표적이죠.
  • 팩토리 메서드 만들기: 인스턴스를 효율적으로 만들 수 있는 편리한 방법이 필요할 때예요. 컴패니언 객체를 사용하면 클래스에 묶인 클래스 레벨 함수와 프로퍼티를 정의할 수 있어서, 인스턴스 생성과 관리를 간단하게 만들어 줘요.
  • 기존 클래스의 동작을 임시로 수정하기: 새로운 하위 클래스를 만들지 않고 기존 클래스의 동작을 바꾸고 싶을 때예요. 예를 들어 특정 연산을 위해 객체에 임시 기능을 추가하는 경우죠.
  • 타입 안전한 설계가 필요할 때: 객체 표현식으로 인터페이스나 추상 클래스의 일회용 구현이 필요할 때예요. 버튼 클릭 핸들러 같은 시나리오에서 유용하게 쓰입니다.

출처: Kotlin 공식 문서

본문

객체 선언

Kotlin에서 객체 선언(object declaration)을 사용하면 객체의 단일 인스턴스를 만들 수 있어요. object 키워드 뒤에는 항상 이름이 따라옵니다. 이 방식으로 클래스를 정의하면서 동시에 인스턴스를 만드는 작업을 한 번에 처리할 수 있는데, 싱글턴을 구현할 때 아주 유용해요.

//sampleStart
// Declares a Singleton object to manage data providers
object DataProviderManager {
    private val providers = mutableListOf<DataProvider>()

    // Registers a new data provider
    fun registerDataProvider(provider: DataProvider) {
        providers.add(provider)
    }

    // Retrieves all registered data providers
    val allDataProviders: Collection<DataProvider> 
        get() = providers
}
//sampleEnd

// Example data provider interface
interface DataProvider {
    fun provideData(): String
}

// Example data provider implementation
class ExampleDataProvider : DataProvider {
    override fun provideData(): String {
        return "Example data"
    }
}

fun main() {
    // Creates an instance of ExampleDataProvider
    val exampleProvider = ExampleDataProvider()

    // To refer to the object, use its name directly
    DataProviderManager.registerDataProvider(exampleProvider)

    // Retrieves and prints all data providers
    println(DataProviderManager.allDataProviders.map { it.provideData() })
    // [Example data]
}

객체 선언의 초기화는 스레드에 안전하며, 처음 접근할 때 이루어져요.

object를 참조할 때는 이름을 그대로 사용하면 돼요.

DataProviderManager.registerDataProvider(exampleProvider)

객체 선언도 익명 객체가 기존 클래스에서 상속받거나 인터페이스를 구현하는 것처럼 수퍼타입을 가질 수 있어요.

object DefaultListener : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { ... }

    override fun mouseEntered(e: MouseEvent) { ... }
}

객체 선언은 변수 선언처럼 표현식이 아니기 때문에, 할당문의 오른쪽에 사용할 수 없어요.

// Syntax error: An object expression cannot bind a name.
val myObject = object MySingleton {
    val name = "Singleton"
}

객체 선언은 지역(local)이 될 수 없어요. 즉 함수 안에 직접 중첩할 수 없다는 뜻입니다. 다만 다른 객체 선언이나 non-inner 클래스 안에는 중첩할 수 있어요.

데이터 객체 (Data objects)

Kotlin에서 평범한 객체 선언을 출력하면, 문자열 표현에 객체의 이름과 object의 해시가 함께 담겨요.

object MyObject

fun main() {
    println(MyObject) 
    // MyObject@hashcode
}

하지만 객체 선언에 data 수정자를 붙이면, toString()을 호출했을 때 실제 객체 이름을 반환하라고 컴파일러에 지시할 수 있어요. 데이터 클래스가 동작하는 것과 같은 방식이죠.

data object MyDataObject {
    val number: Int = 3
}

fun main() {
    println(MyDataObject) 
    // MyDataObject
}

추가로 컴파일러는 data object를 위해 몇 가지 함수를 생성해 줘요.

  • toString()은 데이터 객체의 이름을 반환해요
  • equals()/hashCode()은 동등성 검사와 해시 기반 컬렉션을 가능하게 해 줘요

data object에 커스텀 equalshashCode 구현을 제공할 수는 없어요.

data objectequals() 함수는 data object의 타입을 가진 모든 객체가 서로 동등하다고 간주되도록 보장해요. data object는 싱글턴을 선언하므로 대부분의 경우 런타임에 인스턴스가 하나뿐입니다. 하지만 같은 타입의 다른 객체가 런타임에 생성되는 엣지 케이스(예를 들어 java.lang.reflect로 플랫폼 리플렉션을 사용하거나, 그 내부적으로 이 API를 쓰는 JVM 직렬화 라이브러리를 사용하는 경우)에도 객체들이 서로 동등하게 취급되도록 보장해 주는 것이죠.

data object는 항상 구조적으로(== 연산자로)만 비교하고, 절대 참조로(=== 연산자로) 비교하지 않도록 주의하세요. 런타임에 데이터 객체의 인스턴스가 둘 이상 존재할 때 생길 수 있는 함정을 피하는 데 도움이 돼요.

import java.lang.reflect.Constructor

data object MySingleton

fun main() {
    val evilTwin = createInstanceViaReflection()

    println(MySingleton) 
    // MySingleton

    println(evilTwin) 
    // MySingleton

    // Even when a library forcefully creates a second instance of MySingleton, 
    // its equals() function returns true:
    println(MySingleton == evilTwin) 
    // true

    // Don't compare data objects using ===
    println(MySingleton === evilTwin) 
    // false
}

fun createInstanceViaReflection(): MySingleton {
    // Kotlin reflection does not permit the instantiation of data objects.
    // This creates a new MySingleton instance "by force" (using Java platform reflection)
    // Don't do this yourself!
    return (MySingleton.javaClass.declaredConstructors[0].apply { isAccessible = true } as Constructor<MySingleton>).newInstance()
}

생성된 hashCode() 함수는 equals() 함수와 일관된 동작을 해서, data object의 모든 런타임 인스턴스가 같은 해시 코드를 가지게 됩니다.

데이터 객체와 데이터 클래스의 차이점

data objectdata class 선언은 자주 함께 사용되고 비슷한 점도 있지만, data object에는 생성되지 않는 함수가 몇 가지 있어요.

  • copy() 함수가 없어요. data object 선언은 싱글턴으로 사용하기 위한 것이므로 copy() 함수가 생성되지 않습니다. 싱글턴은 클래스 인스턴스를 하나로 제한하는데, 인스턴스의 복사본을 만들 수 있으면 이 제약이 깨지니까요.
  • componentN() 함수가 없어요. data class와 달리 data object는 데이터 프로퍼티가 없습니다. 데이터 프로퍼티가 없는 객체를 구조 분해하는 건 말이 안 되니까, componentN() 함수도 생성되지 않아요.

sealed 계층과 함께 데이터 객체 사용하기

데이터 객체 선언은 sealed 클래스나 sealed 인터페이스 같은 sealed 계층에서 특히 유용해요. 객체 옆에 함께 정의한 데이터 클래스들과 대칭을 유지할 수 있게 해 주죠.

이 예시에서 EndOfFile을 평범한 object 대신 data object로 선언하면, 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()
    }
}

companion object의 멤버는 클래스 이름을 한정자로 사용해 간단히 호출할 수 있어요.

class User(val name: String) {
    // Defines a companion object that acts as a factory for creating User instances
    companion object Factory {
        fun create(name: String): User = User(name)
    }
}

fun main(){
    // Calls the companion object's factory method using the class name as the qualifier. 
    // Creates a new User instance
    val userInstance = User.create("John Doe")
    println(userInstance.name)
    // John Doe
}

companion object의 이름은 생략할 수 있는데, 이 경우 Companion이라는 이름이 사용돼요.

class User(val name: String) {
    // Defines a companion object without a name
    companion object { }
}

// Accesses the companion object
val companionUser = User.Companion

클래스 멤버는 해당 companion objectprivate 멤버에 접근할 수 있어요.

class User(val name: String) {
    companion object {
        private val defaultGreeting = "Hello"
    }

    fun sayHi() {
        println(defaultGreeting)
    }
}
User("Nick").sayHi()
// Hello

클래스 이름을 그대로 사용하면, 컴패니언 객체에 이름이 있든 없든 그 클래스의 컴패니언 객체를 가리키는 참조가 돼요.

//sampleStart
class User1 {
    // Defines a named companion object
    companion object Named {
        fun show(): String = "User1's Named Companion Object"
    }
}

// References the companion object of User1 using the class name
val reference1 = User1

class User2 {
    // Defines an unnamed companion object
    companion object {
        fun show(): String = "User2's Companion Object"
    }
}

// References the companion object of User2 using the class name
val reference2 = User2
//sampleEnd

fun main() {
    // Calls the show() function from the companion object of User1
    println(reference1.show()) 
    // User1's Named Companion Object

    // Calls the show() function from the companion object of User2
    println(reference2.show()) 
    // User2's Companion Object
}

Kotlin의 컴패니언 객체 멤버는 다른 언어의 정적(static) 멤버처럼 보이지만, 실제로는 컴패니언 객체의 인스턴스 멤버예요. 즉 객체 그 자체에 속합니다. 이 덕분에 컴패니언 객체는 인터페이스를 구현할 수 있어요.

interface Factory<T> {
    fun create(name: String): T
}

class User(val name: String) {
    // Defines a companion object that implements the Factory interface
    companion object : Factory<User> {
        override fun create(name: String): User = User(name)
    }
}

fun main() {
    // Uses the companion object as a Factory
    val userFactory: Factory<User> = User
    val newUser = userFactory.create("Example User")
    println(newUser.name)
    // Example User
}

다만 JVM에서는 @JvmStatic 애노테이션을 사용하면 컴패니언 객체의 멤버를 실제 정적 메서드와 필드로 생성할 수 있어요. 자세한 내용은 Java interoperability 섹션을 참고하세요.

객체 표현식

객체 표현식(object expression)은 클래스를 선언하면서 동시에 그 클래스의 인스턴스도 만들지만, 둘 중 어느 쪽에도 이름을 붙이지 않아요. 이런 클래스는 일회용으로 쓰기에 유용합니다. 처음부터 새로 만들 수도 있고, 기존 클래스에서 상속받거나 인터페이스를 구현할 수도 있어요. 이런 클래스의 인스턴스는 표현식으로 정의되고 이름으로 정의되지 않기 때문에 **익명 객체(anonymous object)**라고도 불러요.

처음부터 익명 객체 만들기

객체 표현식은 object 키워드로 시작해요.

객체가 어떤 클래스도 상속하지 않고 인터페이스도 구현하지 않는다면, object 키워드 뒤의 중괄호 안에 객체의 멤버를 바로 정의할 수 있어요.

fun main() {
//sampleStart
    val helloWorld = object {
        val hello = "Hello"
        val world = "World"
        // Object expressions extend the Any class, which already has a toString() function,
        // so it must be overridden
        override fun toString() = "$hello $world"
    }

    print(helloWorld)
    // Hello World
//sampleEnd
}

수퍼타입에서 익명 객체 상속받기

어떤 타입(들)에서 상속받는 익명 객체를 만들려면 object와 콜론 : 뒤에 그 타입을 지정해요. 그리고 상속할 때처럼 이 클래스의 멤버를 구현하거나 오버라이드하면 됩니다.

window.addMouseListener(object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { /*...*/ }

    override fun mouseEntered(e: MouseEvent) { /*...*/ }
})

수퍼타입에 생성자가 있다면 적절한 생성자 파라미터를 전달해요. 콜론 뒤에 여러 수퍼타입을 쉼표로 구분해 지정할 수도 있어요.

//sampleStart
// Creates an open class BankAccount with a balance property
open class BankAccount(initialBalance: Int) {
    open val balance: Int = initialBalance
}

// Defines an interface Transaction with an execute() function
interface Transaction {
    fun execute()
}

// A function to perform a special transaction on a BankAccount
fun specialTransaction(account: BankAccount) {
    // Creates an anonymous object that inherits from the BankAccount class and implements the Transaction interface
    // The balance of the provided account is passed to the BankAccount superclass constructor
    val temporaryAccount = object : BankAccount(account.balance), Transaction {

        override val balance = account.balance + 500  // Temporary bonus

        // Implements the execute() function from the Transaction interface
        override fun execute() {
            println("Executing special transaction. New balance is $balance.")
        }
    }
    // Executes the transaction
    temporaryAccount.execute()
}
//sampleEnd
fun main() {
    // Creates a BankAccount with an initial balance of 1000
    val myAccount = BankAccount(1000)
    // Performs a special transaction on the created account
    specialTransaction(myAccount)
    // Executing special transaction. New balance is 1500.
}

익명 객체를 반환 타입과 값 타입으로 사용하기

지역(local) 함수나 private 함수·프로퍼티에서 익명 객체를 반환하면, 그 익명 객체의 모든 멤버에 해당 함수나 프로퍼티를 통해 접근할 수 있어요.

//sampleStart
class UserPreferences {
    private fun getPreferences() = object {
        val theme: String = "Dark"
        val fontSize: Int = 14
    }

    fun printPreferences() {
        val preferences = getPreferences()
        println("Theme: ${preferences.theme}, Font Size: ${preferences.fontSize}")
    }
}
//sampleEnd

fun main() {
    val userPreferences = UserPreferences()
    userPreferences.printPreferences()
    // Theme: Dark, Font Size: 14
}

이렇게 하면 특정 프로퍼티를 가진 익명 객체를 반환할 수 있어서, 별도의 클래스를 만들지 않고 데이터나 동작을 캡슐화하는 간단한 방법이 돼요.

익명 객체를 반환하는 함수나 프로퍼티의 가시성이 public, protected, internal이라면, 그 실제 타입은 다음과 같아요.

  • 익명 객체에 선언된 수퍼타입이 없으면 Any
  • 선언된 수퍼타입이 정확히 하나라면 그 익명 객체의 선언된 수퍼타입
  • 선언된 수퍼타입이 둘 이상이라면 명시적으로 선언된 타입

이 모든 경우에 익명 객체에 추가된 멤버는 접근할 수 없어요. 오버라이드된 멤버는 함수나 프로퍼티의 실제 타입에 선언되어 있다면 접근할 수 있습니다. 예를 들어 볼게요.

//sampleStart
interface Notification {
    // Declares notifyUser() in the Notification interface
    fun notifyUser()
}

interface DetailedNotification

class NotificationManager {
    // The return type is Any. The message property is not accessible.
    // When the return type is Any, only members of the Any class are accessible.
    fun getNotification() = object {
        val message: String = "General notification"
    }

    // The return type is Notification because the anonymous object implements only one interface
    // The notifyUser() function is accessible because it is part of the Notification interface
    // The message property is not accessible because it is not declared in the Notification interface
    fun getEmailNotification() = object : Notification {
        override fun notifyUser() {
            println("Sending email notification")
        }
        val message: String = "You've got mail!"
    }

    // The return type is DetailedNotification. The notifyUser() function and the message property are not accessible
    // Only members declared in the DetailedNotification interface are accessible
    fun getDetailedNotification(): DetailedNotification = object : Notification, DetailedNotification {
        override fun notifyUser() {
            println("Sending detailed notification")
        }
        val message: String = "Detailed message content"
    }
}
//sampleEnd
fun main() {
    // This produces no output
    val notificationManager = NotificationManager()

    // The message property is not accessible here because the return type is Any
    // This produces no output
    val notification = notificationManager.getNotification()

    // The notifyUser() function is accessible
    // The message property is not accessible here because the return type is Notification
    val emailNotification = notificationManager.getEmailNotification()
    emailNotification.notifyUser()
    // Sending email notification

    // The notifyUser() function and message property are not accessible here because the return type is DetailedNotification
    // This produces no output
    val detailedNotification = notificationManager.getDetailedNotification()
}

익명 객체에서 변수 접근하기

객체 표현식 본문 안의 코드는 바깥 스코프의 변수에 접근할 수 있어요.

import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent

fun countClicks(window: JComponent) {
    var clickCount = 0
    var enterCount = 0

    // MouseAdapter provides default implementations for mouse event functions
    // Simulates MouseAdapter handling mouse events
    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            clickCount++
        }

        override fun mouseEntered(e: MouseEvent) {
            enterCount++
        }
    })
    // The clickCount and enterCount variables are accessible within the object expression
}

객체 선언과 표현식의 동작 차이

객체 선언과 객체 표현식 사이에는 초기화 동작에 차이가 있어요.

  • 객체 표현식은 사용되는 위치에서 즉시 실행(그리고 초기화)돼요.
  • 객체 선언은 처음 접근할 때 게으르게(lazily) 초기화돼요.
  • 컴패니언 객체는 해당 클래스가 로드(해석)될 때 초기화되는데, 이는 Java의 정적 초기화자(static initializer) 의미론과 일치해요.

더 알아보기 (Learn more)