[Kotlin] 11. object 키워드: 클래스 선언과 인스턴스 생성

2023. 3. 28. 22:58Programming Languages/Kotlin

Kotlin에서 object 키워드는 다양한 상황에서 사용하지만 모든 경우 클래스를 정의하면서 동시에 인스턴스를 생성한다는 공통점이 있다. object를 사용하는 여러 상황을 살펴보자.

  • 객체 선언(object declaration)
    • 싱글턴 객체를 정의하는 방법 중 하나이다.
  • 동반 객체(companion object)
    • 인스턴스 메서드는 아니지만 어떤 클래스와 관련 있는 메서드와 팩토리 메서드를 담을 때 쓰인다. 동반 객체 메서드에 접근할 때는 동반 객체가 포함된 클래스의 이름을 사용할 수 있다. Java의 정적 메서드 및 정적 필드를 대신한다.
  • 객체 식
    • Java의 익명 내부 클래스 대신 쓰인다.

1. 객체 선언: 싱글턴을 쉽게 만들기

Java에서 싱글턴을 만들 때는 보통 모든 생성자를 private으로 만들고 클래스에 만들어놓은 인스턴스 필드에 대한 게터를 사용하여 만들었다.

public class SingletonObject {

    private static final SingletonObject instance = new SingletonObject();

    private SingletonObject() {
    }

    public static SingletonObject getInstance() {
        return instance;
    }
}

Kotlin에서는 객체 선언 기능을 통해 언어 차원에서 지원한다. 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.

object SingletonObject {

    fun getInstance(): SingletonObject = this
}

변수와 마찬가지로 객체 선언에 사용한 이름 뒤에 마침표(.)를 붙이면 객체에 속한 메서드나 프로퍼티에 접근할 수 있다. 간단하게 예시를 보이면 다음과 같다.

object SingletonObj {
     var num = 0

     fun getInstance(): SingletonObj = this
 }
val sgt1 = SingletonObj.getInstance()
val sgt2 = SingletonObj.getInstance()
sgt1.num = 100
println("sgt1's num=${sgt1.num}, sgt2's num=${sgt2.num}")
// sgt1's num=100, sgt2's num=100

객체 선언도 클래스나 인터페이스를 상속할 수 있다. 특정 인터페이스를 구현해야 하는데 그 구현 내부에 다른 상태가 필요하지 않은 경우 유용하다. 한 가지 예로 Comparator 인터페이스를 들 수 있다. Comparator 구현은 두 개의 인자를 받아 더 큰 객체를 판별하는 정수를 반환한다. Comparator 안에는 데이터를 저장할 필요가 없으므로 Comparator 인스턴스를 만드는 방법으로 객체 선언은 적합하다.

object CaseInsensitiveFileComparator: Comparator<File> {

    override fun compare(file1: File, file2: File) {
        return file1.path.compareTo(file2.path, ignoreCase = true)
    }
println(CaseInsensitiveFileComparator.compare(File("/Users", File("/users"))
// 0

또는 함수의 인자로 넣어 사용할 수도 있다.

val files = listOf(File("/B"), File("/A"))
println(files.sortedWith(CaseInsensitiveFileComparator))
// [/A, /B]

클래스 안에서 객체 선언을 할 수도 있다. 이런 경우에도 싱글턴이 보장된다. 다시 말하면 인스턴스가 생성될 때마다 객체가 만들어지지 않는다.

data class Person(val name: String) {
    object NameComparator: Comparator<Person> {
        override fun compare(p1: Person, p2: Person): Int =
            return p1.name.compareTo(p2.name)
    }
}
val persons = listOf(Person("Bob"), Person("Alice"))
println(persons.sortedWith(Person.NameComparator))
// [Person(name=Alice), Person(name=Bob)]
💡 Kotlin의 객체 선언은 Java에서 유일한 인스턴스에 대한 정적인 필드가 있는 클래스로 컴파일된다. 이때 인스턴스 필드 이름은 항상 INSTANCE이다.
Java - CaseInsensitiveFileComparator.INSTANCE.compare(file1, file2)`

2. 동반 객체: 팩토리 메서드와 정적 멤버가 들어갈 장소

Kotlin 클래스에는 정적 멤버가 없다. 즉 static 키워드를 지원하지 않는다. 그 대신 Kotlin에서는 패키지 수준의 최상위 함수와 객체 선언으로 그 역할을 거의 대신한다. 대부분의 경우에는 최상위 함수를 쓰는 편이 권장된다. 하지만 최상위 함수는 private으로 표시된 클래스의 비공개 멤버에 접근할 수 없다. 그래서 클래스의 인스턴스와 관계없이 호출해야 하지만 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다.

fun bar() {
    //오류! private 멤버에 접근할 수 없음
    //A.foo = "bar"
}

class A {
    private foo = "foo"
}

클래스 안에 정의된 객체 중 하나에 companion이라는 특별한 키워드를 붙이면 그 클래스의 동반 객체로 만들 수 있다. 동반 객체의 프로퍼티나 메서드에 접근하려면 그 동반 객체가 정의된 클래스 이름을 사용한다. 이때 동반 객체의 이름은 따로 지정할 필요는 없다. 그 결과 동반 객체의 멤버를 사용하는 구문은 Java의 정적 메서드 호출이나 정적 필드 사용 구문과 같아진다.

class A {
    private foo = "foo"

    companion object {
        fun bar() {
            foo = "bar"
        }
    }
}
⚠️ 동반 객체는 한 클래스 당 하나로 제한된다.

 

동반 객체는 자신을 둘러싼 모든 private 멤버에 접근할 수 있다. 따라서 동반 객체는 외부 클래스의 private 생성자도 호출할 수 있다. 따라서 동반 객체는 팩토리 패턴을 구현하기 가장 적합한 위치다. 예전에 사용했던 User를 리팩토링해보자.

class User {

    val nickname: String

    constructor(email: String) {
        nickname = email.substringBefore('@')
    }

    constructor(facebookAccountId: Int) {
        nickname = getFacebookName(facebookAccountId)
    }
}
// 주 생성자를 private으로 만든다.
class User private constructor(val nickname: String) {
    companion object { // 동반 객체를 선언한다.
        fun newSubscribingUser(email: String) = 
            User(email.substringBefore('@'))
        fun newFacebookUser(accountId: Int) =
            User(getFacebookName(accountId))
    }
}
val subscribingUser = User.newSubscribingUser("bob@gmail.com")
val facebookUser = User.newFacebookUser(4)
println(subscribingUser.nickname)
// bob

3. 동반 객체를 일반 객체처럼 사용

동반 객체는 클래스 안에 정의된 일반 객체다. 따라서 동반 객체에 이름을 붙이거나 인터페이스를 상속하거나, 동반 객체에 확장 함수와 프로퍼티를 정의할 수도 있다.

class Person(val name: String) {
    companion object Loader {
        fun fromJSON(jsonText: String): Person = ...
    }
}
val person = Person.Loader.fromJSON("{name: 'jonny'}")
person.name
// jonny
val person2 = Person.fromJSON("{name: 'lojita'}")
person2.name
// lojita

동반 객체의 이름은 특별히 지정하지 않으면 기본적으로 Companion이다. 필요하다면 지정하자.

동반 객체에서 인터페이스 구현

동반 객체도 인터페이스를 구현할 수 있다. 구현 시 동반 객체의 외부 클래스의 이름을 바로 사용할 수 있다.

interface JSONFactory<T> {
    fun fromJSON(jsonText: String): T
}

class Person(val name: String) {
    companion object: JSONFactory<Person> { // 동반 객체에서 인터페이스 구현
        override fun fromJSON(jsonText: String): Person = ...
    }
}

fun loadFromJSON<T>(factory: JSONFactory<T>): T {
    ...
}

// 인자로 클래스의 이름이 넘어감
loadFromJSON(Person)

동반 객체 확장

동반 객체는 확장 함수 또한 적용할 수 있다. 이때 역시 멤버 함수가 아니라는 것에 유의하자.

// 비즈니스 로직 모듈
class Person(val firstName: String, val lastName: String) {
    companion object { // 비어 있는 동반 객체를 선언
    }
}

// 클라이언트/서버 통신 모듈
fun Person.Companion.fromJSON(json: String): Person { // 확장 함수 선언
    ...
}

val p = Person.fromJSON(json)
⚠️ 동반 객체에 대한 확장 함수를 작성하려면 원래 클래스에 동반 객체를 꼭 선언해야 한다. 빈 객체라도 동반 객체가 꼭 있어야 한다.

4. 객체 식: 익명 내부 클래스를 다른 방식으로 작성

익명 객체를 정의할 때도 object 키워드를 사용한다. 익명 객체는 Java의 익명 내부 클래스를 대신한다. 흔한 익명 내부 클래스인 이벤트 리스너를 Kotlin에서 구현해보자.

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

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

사용 방법은 객체 선언과 같지만 이름이 빠진 것이 차이점이다. 객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지는 않는다. 다음과 같이 변수에 익명 객체를 대입할 수도 있다.

val listener = object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { ... }
    override fun mouseEntered(e: MouseEvent) { ... }
}
💡 객체 선언과 달리 익명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.

 

Java의 익명 클래스와 같이 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다. 하지만 Java와 달리 final이 아닌 변수도 객체 식 안에서 사용할 수 있다. 즉, 객체 식 안에서 그 변수의 값을 변경할 수 있다.

fun countClicks(window: Window) {
    var clickCount = 0 // 로컬 변수

    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            clickCount++ // 로컬 변수 값 변경 가능
        }
    }
}