데이터 클래스

어떤 클래스가 데이터를 저장하는 역할을 수행한다면 toString, equals, hashCode를 반드시 오버라이드해야 한다. 코틀린은 클래스 앞에 data 변경자를 붙이는 걸로 필요한 메소드를 컴파일러가 자동으로 만든다.

data class Client(val name: String, val postalCode: Int)

데이터 클래스는 위에 언급된 메소드 뿐만 아니라 몇 가지 유용한 메소드를 더 생성한다.

copy() 메소드

데이터 클래스의 프로퍼티는 꼭 val일 필요는 없지만 모든 프로퍼티를 val로 만들어 불변(immutable) 클래스로 만드는 것을 권장한다(HashMap 등의 컨테이너에 데이터 클래스 객체를 담을 때는 불변성이 필수). 특히 다중 스레드 환경에서 스레드 동기화를 할 필요를 줄일 수 있다.

copy() 메소드는 불변 객체를 더 쉽게 활용할 수 있게 해주는데, 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 메소드다.

fun copy(
    name: String = this.name,
    postalCode: Int = this.postalCode
) = Client(name, postalCode)

클래스 위임

보통 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 데코레이터(Decorator) 패턴이다. 데코레이터 패턴은 상속을 허용하지 않는 클래스(기존 클래스) 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들고 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고 데코레이터 내부에 기존 클래스를 필드로 유지하는 것이다. 이 패턴의 문제점은 준비 코드가 상당히 많아진다는 것이다.

class DelegatingCollection<E> : Collection<E> {
    private val innerList = arrayListOf<E>()
    override val size: Int get() = innerList.size
    override fun contains(element: E) = innerList.contains(element)
    override fun containsAll(elements: Collection<E>) = innerList.containsAll(elements)
    override fun isEmpty() = innerList.isEmpty()
    override fun iterator() = innerList.iterator()
}

이런 위임(delegate)를 코틀린에서는 언어 차원에서 제공한다. by 키워드를 사용하면 인터페이스의 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.

class DelegatingCollection<E>(
    innerList: Collection<E> = arrayListOf()
) : Collection<E> by innerList

메소드 중 일부의 동작을 변경하고 싶은 경우 메소드를 오버라이드하면 컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 쓰인다.

원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자.

class CountingSet<E>(
    val innerSet: MutableCollection<E> = hashSetOf()
) : MutableCollection<E> by innerSet { //MutableSet의 구현을 innerSet에 위임한다.
    var objectsAdded = 0

    //아래 두 메소드는 위임하지 않고 오버라이드한다.
    override fun add(element: E): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<E>): Boolean {
        objectsAdded += elements.size
        return innerSet.addAll(elements)
    }
}
>>> val countingSet = CountingSet<Int>()
>>> countingSet.addAll(listOf(1, 1, 2, 3, 4, 4))
>>> println("${countingSet.objectsAdded} objects were added, ${countingSet.size} remain")

6 objects were added, 4 remain

이 코드에서 중요한 점은 CountingSet에 MutableCollection 구현 방식에 대한 의존관계가 생기지 않는다는 것이다. 예를 들면 addAll을 처리할 때 루프를 돌면서 add를 호출할 수도 있지만 다른 방식을 구현할 수도 있다. 클라이언트에서 CountingSet의 코드를 호출할 때 발생하는 일은 CountingSet 안에서 마음대로 처리할 수 있지만 결국 이 코드는 MutableCollection 인터페이스를 갖추고 있다. 따라서 MutableCollection이 변경되지 않는 한 이 코드는 계속 잘 작동할 것임을 확신할 수 있다.

object

코틀린에서는 다양한 상황에서 object 키워드를 사용하는 데, 여기에는 클래스를 정의하면서 동시에 인스턴스를 생성하는 공통점이 있다.

object 키워드는 다음과 같은 상황에 사용된다.

  • 객체 선언(object declaration) : 싱글턴을 정의하는 방법 중 하나
  • 동반 객체(companion object) : 인스턴스의 메소드는 아니지만 클래스와 관련 있는 메소드와 팩토리 메소드를 담을 때 쓰인다.
  • 객체 식 : 자바의 무명 내부 클래스(anonymous inner class) 대신 쓰인다.

객체 선언

인스턴스가 하나만 필요한 클래스가 유용한 경우가 많다. 자바에서는 보통 클래스의 생성자를 private로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장하는 싱글턴 패턴(singleton pattern)을 사용하여 구현한다.

코틀린은 객체 선언 기능을 통해 싱글턴 패턴을 언어에서 기본 지원한다. 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.

object Payroll {
    val allEmployees = arrayListOf<Person>()
    fun calculateSalary() {
        for(person in allEmployees) {
            //...
        }
    }
}

객체 선언 안에도 프로퍼티, 메소드, 초기화 블록이 들어갈 수 있지만 생성자(주 생성자와 부 생성자 모두)는 객체 선언에 쓸 수 없다. 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어지기 때문이다.

객체 선언에 사용한 이름 뒤에 마침표를 붙이면 객체에 속한 메소드나 프로퍼티에 접근할 수 있다.

Payroll.allEmployees.add(Person())
Payroll.calculateSalary()

객체 선언도 클래스나 인터페이스를 상속할 수 있다. 특정 인터페이스를 구현해야 하는데, 구현 내부에 다른 상태가 필요하지 않은 경우 유용하게 쓸 수 있다.

object CaseInsensitiveFileComparator : Comparator<File> {
    override fun compare(file1: File, file2: File): Int {
        return file1.path.compareTo(file2.path, ignoreCase = true)
    }
}

일반 객체를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 사용할 수 있다.

>>> val files = listOf(File("/Z"), File("/a"))
>>> println(files.sortedWith(CaseInsensitiveFileComparator))

[\a, \Z]

클래스 안에서도 객체를 선언할 수 있다.

data class Person(val name: String) {
    object NameComparator : Comparator<Person> {
        override fun compare(o1: Person, o2: Person): Int {
            return o1.name.compareTo(o2.name)
        }
    }
}
코틀린 객체를 자바에서 사용하기
코틀린 싱글턴 객체를 사용하려면 정적 INSTANCE 필드를 사용한다.

CaseInsensitiveFileComparator.INSTANCE.compare(file1, file2);

동반 객체

코틀린 클래스 안에는 정적인 멤버가 없으며 static 키워드를 지원하지 않는다. 코틀린에서는 패키지 수준의 최상위 선언(자바의 정적 메소드 역할을 거의 대신할 수 있다)과 객체 선언(최상위 함수가 대신하지 못하는 역할이나 정적 필드를 대신)을 활용한다.

클래스 안에 정의된 객체 중 하나에 companion이라는 표시를 붙이면 동반 객체로 만들 수 있다. 이렇게 하면 동반 객체의 멤버를 사용하는 구문은 자바의 정적 메소드나 정적 필드와 같아진다.

class A {
    companion object {
        fun bar() {
            println("Companion object called")
        }
    }
}

동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 바깥쪽 클래스의 private 생성자도 호출할 수 있다.

이런 특징으로 팩토리 패턴 구현에 적합하다.

class User {
    val nickname: String
    constructor(email: String) {
        nickname = email.substringBefore('@')
    }
    constructor(facebookAccountId: Int) {
        nickname = getFacebookName(facebookAccountId)
    }
}

이를 팩토리 메소드를 이용하면

class User private constructor(val nickname: String) {
    companion object {
        fun newSubscribingUser(email: String) = User(email.substringBefore('@'))
        fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId))
    }
}

동반 객체를 일반 객체처럼 사용하기

동반 객체에 이름을 붙이거나 인터페이스를 상속하거나 확장 함수와 프로퍼티 또한 정의할 수 있다.

이름 붙이기
data class Person(val name: String) {
    companion object Loader {
        fun fromJSON(jsonText: String): Person = //...
    }
}

대부분은 이름을 짓지 않아도 참조할 수 있기 때문에 굳이 이름을 짓지 않지만 필요하다면 companion object 이름 과 같은 형식으로 이름을 지정할 수 있다.

자바에서 동반 객체에 접근하기
동반 객체에 이름을 붙이지 않았다면 자바에서는 Companion으로 접근할 수 있다.
인터페이스 구현
interface JSONFactory<T> {
    fun fromJSON(jsonText: String): T
}

data class Person(val name: String) {
    companion object : JSONFactory<Person> {
        fun fromJSON(jsonText: String): Person = //...
    }
}

일반 객체의 인터페이스 상속과 동일한 방법으로 인터페이스를 상속할 수 있다.

확장
data class Person(val name: String) {
    companion object
}

fun Person.Companion.fromJSON(jsonText: String): Person = //...

동반 객체 안에 확장 함수 또는 확장 프로퍼티를 정의할 수 있다.

동반 객체를 확장하려면 빈 동반 객체이더라도 꼭 원래 클래스에 동반 객체를 선언해야 한다.

객체 식

무명 객체를 정의할 때도 object 키워드를 사용한다. 무명 객체는 자바의 무명 내부 클래스를 대체한다. 자바에서 흔히 무명 내부 클래스로 구현하는 event listener를 코틀린에서 구현해보면 다음과 같다.

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

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

구문은 객체 선언과 같지만 이름이 빠져있다. 객체 식은 클래스를 정의하고 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지 않는다. 객체에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다.

val listener = object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent?) {}
    override fun mouseEntered(e: MouseEvent?) {}
}

코틀린의 무명 클래스는 한 클래스만 확장할 수 있는 자바의 무명 내부 클래스와는 다르게 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페이스를 구현할 수 있다.

자바와 다르게 final이 아닌 변수도 객체 식 안에서 사용할 수 있다.

fun main() {
    var clickCount = 0

    val listener = object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent?) {
            clickCount++
        }
        override fun mouseEntered(e: MouseEvent?) {}
    }
}

© 2021. All rights reserved.

Powered by Hydejack v9.1.6