4-3. 데이터 클래스와 클래스 위임
1. 모든 클래스가 정의해야 하는 메소드
- 자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드 할 수 있다. 하지만 이 경우, hashCode 정의를 빠뜨리면 해당 메소드를 오버라이드한 클래스가 제대로 동작하지 않는 경우가 있다.
why? JVM 언어에서는 hashCode가 지켜야 하는 "equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다" 라는 제약이 있는데, 이를 어기면 제대로 된 결과값을 얻을 수 없다. HashSet을 이용할 경우 이는 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다. 따라서 원소 객체들이 해시 코드에 대한 규칙을 지키지 않는 경우 HashSet은 제대로 작동할 수 없다. 이 문제를 해결하기 위해서는 hashCode 역시 클래스 내에 구현을 해줘야 한다. → 직접 이 모든 메소드들을 구현하는 것은 너무 귀찮다. 코틀린은 이런 메소드들을 자동으로 생성해준다.
2. 데이터 클래스
- data 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.
data class Client(val name:String, val postalCode:Int)
다음과 같은 Client 클래스는 equals, hashCode, toString 메소드를 모두 포함한다. 이때, 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니다.
- copy() 메소드
코틀린에서는 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들라고 권장한다. 데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 copy 메소드를 제공해준다. 이는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 메소드이다.
3. 클래스 위임
- 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 새로운 메소드가 추가되면, 하위 클래스의 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다. 코틀린에서는 기본적으로 클래스를 final로 취급하여 open 변경자로 열어둔 클래스만 확장할 수 있게 하였다. 이는 open 변경자를 보고 해당 클래스가 다른 클래스에 의해 상속될 수 있음을 알려, 변경 시 주의를 요할 수 있다.
- 하지만 종종 상속을 허용하지 않는 클래스에 대해 새로운 동작을 추가해야 할 때가 있다. 이때 사용하는 방법이 데코레이터 패턴이다.
데코레이터 패턴의 핵심은 상속을 허용하지 않는 클래스 대신 사용할 수 있는 새로운 클래스를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것이다. 이때, 새로 정의해야 하는 기능은 데코레이터의 메소드에 새로 정의하고, 기존 기능이 그대로 필요한 부분은 데코레이터의 메소드가 기존 클래스의 메소드에게 요청을 전달한다.
이런 접근 방법은 준비 코드가 상당히 많이 필요하다는 단점이 있는데, 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.
class CountingSet<T> (
val innerSet : MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {
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)
}
>>> val cset = CountingSet<Int>()
>>> cset.addAll(listOf(1,1,2))
>>> println("${cset.objectsAdded} objects were added, ${cset.size} remain")
3 obecjects were added, 2 remain
위의 코드는 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현한 것이다. add와 addAll을 오버라이드해서 카운터를 증가시키고, MutableCollection 인터페이스의 나머지 메소드는 내부 컨테이너(innerSet)에게 위임한다.
이때, CountingSet에 MutableCollection의 구현 방식에 대한 의존관계가 생기지 않는다는 점이 중요하다. 내부 클래스의 문서화된 API가 변경되지 않는 한, CountingSet 코드가 계속 잘 작동할 것임을 확신할 수 있다.
4-4. object 키워드: 클래스 선언과 인스턴스 생성
1. 객체 선언
- 자바에서는 보통, 클래스의 생성자를 private으로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장하여 싱글턴 패턴을 구현한다. 코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원한다.
object Payroll {
val allEmployees = arrayListOf<Person> ()
fun calculateSalary() {
for(person in allEmployees) {
...
}
}
}
다음과 같이 객체 선언은 object 키워드로 시작한다. 객체 선언은 클래스를 정의하고, 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 한 문장으로 처리할 수 있게 한다.
- 클래스와 마찬가지로 객체 선언 안에서도 프로퍼티, 메소드, 초기화 블록 등이 들어갈 수 있지만, 생성자는 객체 선언에 쓸 수 없다.
- 객체 선언도 클래스나 인터페이스 상속이 가능하다. 특정 인터페이스를 구현해야 하는데, 그 구현 내부에 다른 상태가 필요하지 않은 경우에 유용하다.
- 클래스 안에 객체를 선언할 수도 있다.
- 중첩 객체를 사용해 Comparator 구현
data class Person(val name:String){
object NameComparator : Comparator<Person> {
override fun compare(p1:Person, p2:Person) : Int =
p1.name.compareTo(p2.name)
}
}
>>> val person = listOf(Person("Bob"), Person("Alice"))
>>> println(persons.sortedWith(Person.NameComparator))
[Person(name=Alice), Person(name=Bob)]
2. 동반 객체
- 코틀린에서는 자바 static 키워드를 대신할 방법으로 최상위 함수를 활용하는 편을 권장한다. 하지만 최상위 함수는 private으로 표시된 클래스 비공개 멤버에 접근할 수 없다. 그래서 클래스의 인스턴스와 관계없이 호출해야 하지만 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다. 그런 함수의 대표적인 예가 팩토리 메소드이다.
- 클래스 안에 정의된 객체 중 하나에 companion을 붙이면 그 클래스의 동반 객체로 만들 수 있다. 동반 객체의 이름을 따로 지정할 필요가 없이 정의되어있는 클래스의 이름을 사용하면 되므로, 자바의 정적 메소드 호출이나 정적 필드 사용 구문과 같아진다.
class A {
companion object {
fun bar(){
println("Companion object called")
}
}
}
- 동반 객체는 자신을 둘러싼 클래스의 모든 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")
>>> println(subscribingUser.nickname)
bob
- 팩토리 메소드는 그 팩토리 메소드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다.
- 생성할 필요가 없는 객체를 생성하지 않을 수 있다.(캐시에 있는 기존 인스턴스 반환 가능)
- 하지만 클래스를 확장해야만 하는 경우 동반 객체 멤버를 하위 클래스에서 오버라이드 할 수 없으므로 여러 생성자를 사용하는 편이 더 낫다.
3. 동반 객체를 일반 객체처럼 사용
- 동반 객체에 이름을 붙이거나, 동반 객체가 인터페이스를 상속하거나, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다.
- 동반 객체가 구현한 인터페이스의 인스턴스를 넘길 때 클래스 이름을 사용한다.
- 클래스에 동반 객체가 있으면 그 객체 안에 함수를 정의함으로써 클래스에 대해 호출할 수 있는 확장 함수를 만들 수 있다.
class Person(val firstName:String, val lastName:String) {
companion object {
}
}
fun Person.Companion.fromJSON(json:String) : Person {
...
}
val p = Person.fromJSON(json)
- fromJSON 함수는 클래스 밖에 정의한 확장함수지만 마치 동반 객체 안에서 정의한 것처럼 호출 가능하다. 이때, 동반 객체에 대한 확장 함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 빈 객체로라도 꼭 선언해야 한다.
4. 객체 식
- 무명 객체를 정의할 때도 object 키워드를 쓴다. 객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지는 않는다. 이런 경우 보통 함수를 호출하면서 인자로 무명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다. 이름을 붙여야 한다면, 변수에 무명 객체를 대입하면 된다.
※ 객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.
- 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있으며 객체 식 안에서 그 변수의 값을 변경할 수 있다.
'3-1기 스터디 > Kotlin' 카테고리의 다른 글
[8주차] 람다로 프로그래밍(2) (0) | 2022.01.03 |
---|---|
[7주차] 람다로 프로그래밍(1) (0) | 2021.12.26 |
[4주차] 클래스, 객체, 인터페이스 (0) | 2021.11.21 |
[3주차] 함수 정의와 호출 (0) | 2021.11.16 |
[2주차] 코틀린 기초 (0) | 2021.11.07 |
댓글