[Kotlin] 코틀린 데이터 클래스와 클래스 위임
코틀린 컴파일러는 equals, hashCode, toString 등의 메소드를 기계적으로 생성하는 작업을 보이지 않는 곳에서 해준다. 따라서 필수 메소드로 인한 잡음 없이 소스코드를 깔끔하게 유지할 수 있다.
이제 코틀린 컴파일러가 데이터 클래스에 유용한 메소드를 자동으로 만들어주는 예와 클래스 위임 패턴을 아주 간단하게 쓸 수 있게 해주는 예를 살펴보자.
모든 클래스가 정의해야 하는 메소드
자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드할 수 있다.
// 고객 이름과 우편번호를 저장하는 간단한 Client 클래스
class Client(val name: String, val postalCode: Int)
문자열 표현: toString()
class Client(val name: String, val postalCode: Int){
override fun toString() = "Client(name=$name, postalCode=$postalCode)";
}
객체의 동등성: equals()
코틀린에서는 == 연산자가 두 객체를 비교하는 기본적인 방법이다. ==는 내부적으로 equals를 호출해서 객체를 비교한다. 따라서 클래스가 eqauls를 오버라이드하면 ==를 통해 안전하게 그 클래스의 인스턴스를 비교할 수 있다. 참조 비교를 위해서는 === 연산자를 사용할 수 있다.(자바에서 객체의 참조를 비교하는 ==연산자와 같다.)
class Client(val name:String, val postalCode: Int){
override fun equals(other: Any?): Boolean{
if(other == null || other !is Client)
return false
return name == other.name && postalCode == other.postalCode
}
override fun toString() = "Client(name=$name, postalCode=$postalCode)";
}
>>> val client1 = Client("강성현", 4111)
>>> val client2 = Client("강성현", 4111)
>>> println(client1 == client2)
true // eqauls를 오버라이드 하지않으면 false가 반환된다.
하지만 Client 로 더 복잡한 작업을 수행하다보면 제대로 작동하지 않는 경우가 있는데, hashCode 정의를 빠드려서 그렇다. (자바를 할때도 많이 들어봤을 것이다. eqauls를 오버라이드할때는 hashCode도 오버라이드 해야한다.)
해시 컨테이너: hashCode()
원소가 "강성현"이라는 고객 하나뿐인 집합을 만든 후, 새로 원래의 "강성현"과 똑같은 프로퍼티를 포함하는 새로운 Client 인스턴스를 만들어서 그 인스턴스가 집합안에 들어있는지 검사해보자.
>>> val processed = hashSetOf(Client("강성현", 4111))
>>> println(processed.contains(Client("강성현", 4111)))
false // true가 나올것 같지만.. false가 나온다
이는 Client 클래스가 hashCode 메소드를 정의하지 않았기 때문인데 JVM 언어에서는 hashCode가 지켜야 하는 "equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다" 라는 제약이 있기 때문이다.
두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다. 따라서 Client에서 hashCode를 구현해야 한다.
class Client(val name:String, val postalCode: Int){
...
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
데이터 클래스: 모든 클래스가 정의해야 하는 메소드 자동 생성
코틀린에서 위의 메소드들을 자동으로 생성하게하는 방법이 있다. 바로 data라는 변경자를 클래스 앞에 붙여주면 컴파일러가 필요한 메소드들을 자동으로 만들어 준다.
data class Client(val name: String, val postalCode: Int)
데이터 클래스의 모든 프로퍼티는 읽기 전용으로 만들어서 데이터 클래스를 불변(immutable)클래스로 만드는 것을 권장한다.
클래스 위임: by 키워드 사용
대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속(implementation inheritance)에 의해 발생한다.
하위 클래스가 상위 클래스의 메소드를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메소드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다.
그래서 코틀린은 이런 문제를 인식하고 기본적으로 클래스를 final로 취급한다. (open 변경자로 열어둔 클래스만 확장 가능)
하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 데코레이터(Decorator)패턴이다.
⇒ 데코레이터 패턴 : https://github.com/ksh901016/design-pattern/blob/master/src/main/java/decorator/데코레이터패턴.md
이런 접근 방법의 단점은 준비 코드가 상당히 많이 필요하다는 점이다. 예를 들어 Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차도 복잡한 코드를 작성해야 한다.
class DelegatingCollection<T>: : Collection<T>{
private val innerList = arrayList<T>()
override val size: Int get() = innerList.size
override fun isEmpty(): Boolean = innerList.isEmpty()
override fun contains(element: T): Boolean = innerList.contains(element)
...
}
하지만 코틀린은 인터페이스를 구현할 때 by 키워드를 사용하면 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.
class DelegatingCollection<T>(
// 주 생성자
innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList ()
클래스 안에 있던 모든 메소드 정의가 없어졌는데, 컴파일러가 그런 전달 메소드를 자동으로 생성해주기 때문이다.
메소드 중 일부의 동작을 변경하고싶은 경우 메소드를 오버라이드하면 된다.
원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자.
// 클래스 위임 사용하기
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
): MutableCollection<T> by innerSet{
// MutableCollection의 구현을 innserSet에게 위임한다.
var objectsAdded = 0
override fun add(element: T): Boolean{
objectsAdded++
return innerSet.add(element)
}
override fun addAll(c: Collection<T>): Boolean{
objectsAdded += c.size
return innerSet.addAll(c)
}
}
fun main() {
val cset = CountingSet<Int>()
cset.addAll(listOf(1, 1, 2))
println("${cset.objectsAdded} objects were added, ${cset.size} remain")
}