접근자의 가시성은 기본적으로 프로퍼티의 가시성과 같다. 하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가해서 접근자의 가시성을 변경할 수 있다.
class LengthCounter {
var counter: Int = 0
private set // 이 클래스 밖에서 property값을 바꿀 수는 없다.
fun addWord(word: String) {
counter += word.length
}
}
LengthCounter라는 class는 자신에게 추가된 모든 단어의 길이를 합산한다. 전체 길이를 저장하는 property(여기서는 counter)는 client에게 제공하는 api의 일부분이므로 public으로 외부에 공개된다. 즉 counter라는property는 addWord라는 함수 이외에 값을 변경할 수 있는 수단은 LengthCounter class에 따로 만들지 않는 이상 다른 것은 없다. 따라서 해당 함수를 호출하여 counter의 값을 변경시킬 수 있다. 다음과 같이 말이다.
class LengthCounter {
var counter: Int = 0
private set
fun addWord(word: String) {
counter += word.length
}
}
fun main() {
val lengthCounter = LengthCounter()
lengthCounter.addWord("hello!")
println(lengthCounter.counter) // 6
}
즉 setter의 가시성을 private으로 지정함으로서 counter의 값을 바꿀 수 있는 방법을 LengthCounter class 내부로 한정시킨 것이다.
java에서는 클래스에서 equals, hashcode, toString 등의 메소드를 구현해야 한다. 그리고 이런 메소드들은 보통 비슷한 방식으로 기계적으로 구현할 수 있다. 물론 이런 메소드는 IDE에 자동으로 만들어 준다. 하지만, 자동을 만들어 준다고 해도 코드가 번잡해지는 것은 달라지지 않는다. kotlin compiler는 여기서 한 걸음 더 나아갔다. 즉 이런 메소드를 기계적으로 생성하는 작업을 보이지 않는 곳에서 처리해준다. 그러니 java보다 코드를 좀 더 깔끔하게 유지할 수 있다.
앞서도 이런 코틀린의 원칙을 잘 보여주는 경우를 살펴보았다. 클래스 생성자나 프로퍼티 접근자를 compiler가 자동으로 만들어주는 경우가 그러하다.
java와 마찬가지로 kotlin 메소드도 toString, equals, hashcode 등을 override할 수 있다. 각각이 어떤 메소드이고 어떻게 정의해야 하는지 알아보자.
class Clinet(val name: String, val postalCode: Int)
위 예제 class의 instance를 어떻게 문자열로 표현할지 생각해보자.
java처럼 kotlin의 모든 class도 instance의 문자열 표현을 얻을 방법을 제공한다. 주고 debuging 시 이 메소드를 사용한다. 근데 여기서 그냥 객체의 문자열 표현을 사용하면 그리 유용한 정보를 얻을 수는 없다. 따라서 toString()을 사용하여 메소드를 override하고 class의 상태를 문자열로 표현하도록 toString을 사용한다.
class Clinet(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalCode: $postalCode)"
}
fun main() {
val clinet = Clinet("dev-junku", 1111)
print(clinet) // Client(name=dev-junku, postalCode: 1111)
}
즉 기존의 문자열 표현보다 override하여 class에 대한 더 많은 정보를 얻을 수 있다.
위 client class를 보면 모든 class의 연산은 class 밖에서 이루어진다. client class는 단지 데이터를 저장할 뿐이다. 다시 한 번 말하지만 client class의 경우 주 생성자를 포함하고 있으며, 이를 통해 default parameter가 존재한다는 것을 알 수 있다. 따라서 데이터의 상태를 저장하는 class로 존재한다.
따라서 구조도 단순하고 내부 정보를 투명하게 외부에 노출하게 설계했다. 하지만 이 class는 동일한 데이터를 포함하는 서로 다른 instance에 대해 어떤 판단을 내릴 것인가?
fun main() {
val clinet1 = Clinet("dev-junku", 1111)
val client2 = Clinet("dev-junku", 1111)
print(clinet1 == client2) // false
}
class Clinet(val name: String, val postalCode: Int) {
override fun toString() = "Client(name=$name, postalCode: $postalCode)"
}
false가 뜬다. 즉 객체는 동일하지 않다는 결과를 얻게 된다. 하지만 실제로 비즈니스 상황에서는 동일한 고객을 의미하고 있기 때문에 어떤 특정 연산을 수행해야 할 수도 있다. 이렇듯 false라는 결과를 다시 생각해봐야 한다. 이를 위해서 equals 함수를 override한다.
fun main() {
val clinet1 = Client("dev-junku", 1111)
val client2 = Client("dev-junku", 1111)
print(clinet1 == client2) // true
}
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)"
}
이때 굳이 if 문법을 왜 사용했는가?
Any? 라는 코드가 담겨있기 때문에 이미 equals 함수의 인자를 null로 받을 수 있기 때문에 예외를 처리해야 정확한 코드가 된다.
이 경우는 이제 true값을 반환한다. 하지만 class로 더 복잡한 작업을 수행해보면 제대로 작동하지 않는 경우가 있다. 이때는 hashcode 함수를 빠트려서 그렇다.
왜 hashcode() 함수가 중요할까?
java에서는 equals를 override할 때 반드시 hashCode도 함께 override해야 한다. 왜 그럴까?
아래 코드를 보면 바로 이해가 될 것이다.
fun main() {
val processed = hashSetOf(Client("Dev-junku", 1111))
println(processed.contains(Client("Dev-junku", 1111))) // false
}
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)"
}
processed
에 Client("Dev-junku", 1111)
가 있는지 궁금하여 contains를 활용했지만, 포함되어있지 않다는 결과를 내뱉었다. 이러한 결과가 나온 이유는 hashCode 메소드를 정의하지 않아서이다. 즉 JVM언어에서 hashCode는 equals()가 true인 두 객체는 반드시 같은 hashCode()를 반환해야 한다는 제약
이 있으며, 위 Client class는 이를 어기고 있다.
위 예제에서 hashSetOf()를 사용하여 hash값을 통해 true와 false를 반환하게 되는데, 결국에는 hash값이 다르기 때문에 false라는 값을 반환한 것이다.
결국에는 하나를 얻었다.
equals함수는 hash값까지 같다고 해줄 수 없다.
따라서 위 문제를 고치려면 hashCode함수를 구현해야 한다.
fun main() {
val processed = hashSetOf(Client("Dev-junku", 1111))
println(processed.contains(Client("Dev-junku", 1111))) // true
}
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)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}
이제 toString, equals, hashCode 등 여러 개의 함수를 실제로 override하면서 무엇을 느꼈는가? 데이터 값이 같다는 것을 확인하기 위해서 작성해야 하는 코드가 너무 많다는 것을 느끼지 않았는가? 사실 java로 코드를 짜면 이를 모두 구현해야 한다. 앞서 이야기 했듯이 IDE에서 이를 대신 자동으로 작성해주지만, 저 코드가 모두 보이게 된다.
여기서 kotlin을 사랑할 수 밖에 없는 이유가 나온다. 바로 data class이다.
앞서 데이터의 값이 같은지 확인하기 위해서 작성했던 코드는 kotlin에서는 다음과 같이 축약시킬 수 있다.
data class Client(val name: String, val postalCode: Int)
위 data class에서 하는 일은 다음과 같다.
equals
hashCode
toString
즉 우리가 앞서 했던 작업을 data라는 keyword를 통해서 모두 해결한 것이다.
물론 data class의 경우 위 3가지 method말고도 유용한 다른 메소드를 더 생성해준다.
data class의 property가 꼭 val일 필요는 없다. 원한다면 var를 사용해도 된다.
하지만 data class의 모든 property를 read only으로 만들어서 data class를 immutable class로 만들 것은 권장한다. HashMap 등의 container에 데이터 클래스 객체를 담는 경우엔 immutable이 필수적이다.
왜냐하면 data class 객체를 키로 하는 값을 컨테이너에 담은 다음에 key로 쓰인 데이터 객체의 property를 변경하면 container의 상태가 잘못될 수 있다.
또한 immutable 객체를 사용하면 프로그램에 대해 훨씬 쉽게 추론할 수 있다. 특히 multi-Thread 프로그램의 경우 이런 성질은 더 중요하다. 불변 객체를 주로 사용하는 프로그램에서는 Thread가 사용 중인 data를 다른 Thread가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.
이때 data class instance를 immutable 객체로 더 쉽게 활용할 수 있게 kotlin complier에서 한 가지의 method를 제공한다. 이 method는 객체를 copy하면서 일부 property를 바꿀 수 있게 해주는 copy method이다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 좋다. var로 만들어 객체값이 변동되어 오류를 찾는 것보다는 이를 사전에 방지하면서 새로 객체를 만들어내는 것이 더 나을 수 있다는 이야기이다.
따라서 class인 Client에서 copy를 직접 구현한다면 다음과 같이 코드를 작성하는 것이다.
fun main() {
val dev = Client("dev-junku", 1111)
println(dev.copy(postalCode = 2222)) // Client(name=dev-junku, postalCode: 2222)
}
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)"
override fun hashCode(): Int = name.hashCode() * 31 + postalCode
fun copy(name: String = this.name,
postalCode: Int = this.postalCode) = Client(name, postalCode)
}
물론 개인적으로는 data class를 활용하자.. 그게 훨씬 좋은거 같다.