관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 4장. 클래스, 객체, 인터페이스 본문

책/Kotlin in Action

[Kotlin in Action] 4장. 클래스, 객체, 인터페이스

참깨빵위에참깨빵 2023. 10. 3. 22:04
728x90
반응형
클래스 계층 정의

 

코틀린 인터페이스

 

코틀린 인터페이스는 자바 8 인터페이스와 비슷하다. 안에 추상 메서드와 구현이 있는 메서드도 정의할 수 있다. 다만 인터페이스에는 어떤 상태(필드)도 들어갈 수 없다.

 

interface Clickable {
    fun click()
}

class Button: Clickable {
    override fun click() {
        println("클릭됨")
    }
}

 

자바에선 extends, implements 키워드를 쓰지만 코틀린에선 클래스명 뒤에 콜론을 붙이고 인터페이스, 클래스명을 적는 걸로 상속, 인터페이스 구현을 모두 처리한다. 자바처럼 클래스는 인터페이스를 원하는 만큼 구현 가능하지만 클래스는 하나만 상속할 수 있다.

자바의 @Override와 비슷한 override 키워드는 상위 클래스, 인터페이스의 프로퍼티나 메서드를 재정의한다는 뜻이다. 하지만 자바와 달리 코틀린에선 override 키워드를 꼭 써야 한다. 

인터페이스 메서드도 디폴트 구현을 제공할 수 있다. 이 때 자바 8은 메서드 앞에 default 키워드를 붙여야 하지만 코틀린은 메서드에 특별한 키워드를 붙일 필요가 없이 메서드 본문을 시그니처 뒤에 추가하면 된다.

 

interface Clickable {
    fun click() // 인터페이스 안에서 일반 메서드 선언
    fun showOff() = println("난 클릭 가능해") // 디폴트 구현이 있는 메서드
}

 

이 인터페이스를 구현하는 클래스는 click()의 구현을 제공해야 한다. 반면 showOff()의 경우 새 동작을 정의할 수도 있고 정의를 생략하고 디폴트 구현을 사용할 수도 있다.

showOff()를 쓰는 다른 인터페이스가 아래 구현을 포함한다고 가정한다.

 

interface Focusable {
    fun setFocus(b: Boolean) = println("포커스를 가진 상태 : $b")
    fun showOff() = println("난 focusable해")
}

 

한 클래스에서 두 인터페이스를 같이 구현하면 어떻게 되는가? 두 인터페이스 모두 디폴트 구현이 있는 showOff()가 있지만 어느 쪽도 선택되지 않는다. 클래스가 구현하는 두 상위 인터페이스에 정의된 showOff() 구현을 대체할 재정의 메서드를 직접 제공하지 않으면 컴파일 에러가 발생한다.

 

 

코틀린 컴파일러는 두 메서드를 아우르는 구현을 하위 클래스에 직접 구현하도록 강제한다.

 

class Button: Clickable, Focusable {
    override fun click() {
        println("클릭됨")
    }

    override fun showOff() {
        // 상위 타입의 이름을 <> 안에 넣어서 super를 지정하면 어떤 상위 타입의 멤버 메서드를 호출할지 정할 수 있다
        super<Clickable>.showOff()
        super<Focusable>.showOff()
    }
}

 

이제 Button 클래스는 인터페이스 2개를 구현한다. Button은 상속한 두 상위 타입의 showOff()를 호출하는 식으로 showOff()를 구현한다. 상위 타입의 구현을 호출할 때는 자바처럼 super를 쓴다.

상속한 구현 중 하나만 호출해도 된다면 아래처럼 쓸 수 있다.

 

override fun showOff() = super<Clickable>.showOff()

 

이 클래스의 인스턴스를 만들어 showOff()가 구현대로 상속한 모든 메서드를 호출하는지 검증할 수 있다.

 

interface Clickable {
    fun click()
    fun showOff() = println("난 클릭 가능해")
}

interface Focusable {
    fun setFocus(b: Boolean) = println("포커스를 가진 상태 : $b")
    fun showOff() = println("난 focusable해")
}

class Button: Clickable, Focusable {
    override fun click() {
        println("클릭됨")
    }

    override fun showOff() {
        super<Clickable>.showOff()
        super<Focusable>.showOff()
    }
}

fun main(args: Array<String>) {
    val button = Button()
    button.showOff()
    button.setFocus(true)
    button.click()
}

// >> 난 클릭 가능해
// >> 난 focusable해
// >> 포커스를 가진 상태 : true
// >> 클릭됨

 

open, final, abstract 키워드 : 기본 final

 

자바에선 final로 명시적으로 상속을 금지하지 않는 모든 클래스를 다른 클래스가 상속 가능하다. 이것 때문에 문제가 생기는 경우도 많다.

취약한 기반 클래스라는 문제는 하위 클래스가 기반 클래스에 대해 가졌던 가정이 기반 클래스를 바꿈으로써 깨진 경우에 생긴다. 어떤 클래스가 자신을 상속하는 방법에 대한 정확한 규칙을 제공하지 않으면 그 클래스의 클라이언트는 기반 클래스를 작성한 사람의 의도와 다르게 메서드를 재정의할 위험이 있다.

이 문제를 해결하기 위해 이펙티브 자바에선 "상속을 위한 설계, 문서를 갖추거나 그럴 수 없다면 상속을 금지하라"라고 한다. 이는 특별히 하위 클래스에서 재정의하게 의도된 클래스, 메서드가 아니면 모두 final로 만들라는 뜻이다. 코틀린도 마찬가지로 코틀린 클래스, 메서드는 기본적으로 final이다.

어떤 클래스의 상속을 허용하려면 클래스 앞에 open을 붙여야 한다. 재정의를 허용하려는 메서드, 프로퍼티 앞에도 open을 붙여야 한다.

 

open class RichButton: Clickable {
    fun disable() {}
    open fun animate() {}
    override fun click() {}
}

 

기반 클래스, 인터페이스 멤버를 재정의할 경우 그 메서드는 기본적으로 정렬돼 있다. 재정의하는 메서드 구현을 하위 클래스에서 재정의하지 못하게 금지하려면 재정의하는 메서드 앞에 final을 명시해야 한다.

 

코틀린도 클래스를 abstract로 선언할 수 있다. 이 클래스는 인스턴스화할 수 없다. 추상 멤버는 항상 열려 있기 때문에 open 키워드를 붙일 필요가 없다.

abstract class Animated {
    // 하위 클래스에서 반드시 재정의해야 하는 추상 함수
    abstract fun animate()
    
    // 추상 클래스에 있어도 비추상 함수는 기본적으로 final이다
    // 원한다면 open으로 재정의를 허용할 수 있다
    open fun stopAnimating() {}
    
    fun animateTwice() {}
}

 

인터페이스 멤버는 final, open, abstract를 쓰지 않는다. 인터페이스 멤버는 항상 열려 있으며 final로 바꿀 수 없다.

인터페이스 멤버에 본문이 없으면 자동으로 추상 멤버가 되지만 멤버 선언 앞에 abstract 키워드를 쓸 필요가 없다.

 

가시성 변경자 : 기본적으로 공개

 

가시성 변경자는 선언에 대한 클래스 외부 접근을 허용한다. 어떤 클래스의 구현에 대한 접근을 제한해서 그 클래스에 의존하는 외부 코드를 깨지 않고도 클래스 내부 구현을 변경할 수 있다.

기본적으로 가시성 변경자는 자바와 비슷하다. 하지만 코틀린에서 아무 변경자도 없는 경우 기본 가시성은 모두 public이다. 자바의 기본 가시성인 패키지 전용은 코틀린에 없다. 코틀린은 패키지를 네임스페이스 관리를 위해서만 사용한다. 그래서 패키지를 가시성 제어에 쓰지 않는다.

패키지 전용 가시성의 대안으로 internal이란 새 가시성 변경자를 도입했다. internal은 "모듈 내부에서만 볼 수 있음"이란 뜻이다. 모듈은 한 번에 한꺼번에 컴파일되는 코틀린 파일들을 의미한다.

모듈 내부 가시성은 모듈 구현에 대해 캡슐화를 제공한다는 장점이 있지만 최상위 선언에 private 가시성을 허용한다는 점이다. 그런 최상위 선언에는 클래스, 함수, 프로퍼티 등이 있다. 비공개 가시성인 최상위 선언은 그 선언이 들어있는 파일 안에서만 쓸 수 있다.

 

// public 멤버가 자신의 internal 수신 타입인 TalkativeButton을 노출해서 컴파일 에러 발생
fun TalkativeButton.giveSpeech() {
    yell() // yell()은 TalkativeButton의 private 멤버라 접근 불가능
    whisper() // whisper()는 TalkativeButton의 protected 멤버라 접근 불가능
}

 

giveSpeech() 안의 각 줄은 가시성 규칙을 위반한다. 코틀린은 public 함수인 giveSpeech() 안에서 그보다 가시성이 더 낮은 타입인 TalkativeButton을 참조하지 못하게 한다. 이는 어떤 클래스의 기반 타입 목록에 들어있는 타입이나 제네릭 클래스의 타입 파라미터에 들어있는 타입의 가시성은 그 클래스 자신의 가시성과 같거나 더 높아야 하고 메서드 시그니처에 쓰인 모든 타입의 가시성은 그 메서드의 가시성과 같거나 더 높아야 한다는 일반적인 규칙에 해당한다.

컴파일 에러를 없애려면 giveSpeech()의 가시성을 internal로 바꾸거나, TalkativeButton 클래스의 가시성을 public으로 바꿔야 한다.

 

내부 클래스, 중첩 클래스 : 기본적으로 중첩 클래스

 

클래스 안에 다른 클래스를 선언하면 헬퍼 클래스를 캡슐화하거나 코드 정의를 그 코드를 쓰는 곳 가까이에 두고 싶을 때 유용하다. 자바와의 차이는 코틀린의 중첩 클래스는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다는 것이다.

코틀린 중첩 클래스에 어떤 변경자도 붙지 않으면 자바 static 중첩 클래스와 같다. 이걸 내부 클래스로 바꿔서 바깥쪽 클래스에 대한 참조를 포함하게 하려면 inner 키워드를 붙여야 한다.

내부 클래스 Inner에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.

 

class Outer {
    inner class Inner {
        fun getOuterReference(): Outer = this@Outer
    }
}

 

봉인된 클래스 : 클래스 계층 정의 시 계층 확장 제한

 

Expr, Sum, Num을 사용한 예제에서 상위 클래스인 Expr에는 숫자를 표현하는 Num, 덧셈 연산을 표현하는 Sum이란 하위 클래스 2개가 있다. when 식에서 이 모든 하위 클래스를 처리하면 편하다. 하지만 when 식에서 Num, Sum이 아닌 경우를 처리하는 else 분기를 반드시 넣어야 한다.

 

interface Expr
class Num(val value: Int): Expr
class Sum(val left: Expr, val right: Expr): Expr

fun eval(e: Expr): Int = when (e) {
    is Num -> e.value
    is Sum -> eval(e.right) + eval(e.left)
    else -> throw IllegalArgumentException("이건 무슨 식이죠?")
}

 

코틀린 컴파일러는 when으로 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 분기를 쓰게 강제한다. 위 예시에선 리턴할 만한 유의미한 값이 없어서 예외를 던진다.

항상 디폴트 분기를 추가하는 게 편하진 않다. 그리고 디폴트 분기가 있으면 이런 클래스 계층에 새 하위 클래스를 추가해도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다.

코틀린은 sealed class로 문제의 해법을 제공한다. 상위 클래스에 sealed 키워드를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다. sealed class의 하위 클래스 정의 시에는 반드시 상위 클래스 안에 중첩시켜야 한다.

 

sealed class Expr {
    class Num(val value: Int): Expr()
    class Sum(val left: Expr, val right: Expr): Expr()
}

fun eval(e: Expr): Int = when (e) {
    is Expr.Num -> e.value
    is Expr.Sum -> eval(e.right) + eval(e.left)
}

 

when 식에서 sealed class의 모든 하위 클래스를 처리한다면 디폴트 분기(else)가 필요없다. sealed class는 자동으로 open이라서 별도로 open 키워드를 붙이지 않아도 된다. 나중에 sealed class의 상속 계층에 새 하위 클래스를 추가해도 when 식이 컴파일되지 않는다. 따라서 when 식을 고쳐야 한다는 걸 쉽게 알 수 있다.

내부적으로 Expr 클래스는 private 생성자를 갖는다. 그 생성자는 클래스 내부에서만 호출 가능하고 sealed interface를 정의할 순 없다. sealed interface를 만들 수 있다면 그 인터페이스를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에 없기 때문이다.

 

뻔하지 않은 생성자, 프로퍼티를 갖는 클래스 선언

 

자바에서 생성자를 하나 이상 생성할 수 있는 것처럼 코틀린도 비슷하게 주 생성자와 부 생성자를 구분한다. 또한 코틀린은 초기화 블록을 통해 초기화 로직을 추가할 수 있다.

 

클래스 초기화 : 주 생성자와 초기화 블록

 

class User(val nickname: String)

 

보통 클래스의 모든 선언은 중괄호 사이에 들어간다. 하지만 이 클래스 선언에는 중괄호가 없고 val 선언만 소괄호 사이에 존재한다. 이렇게 클래스명 뒤에 소괄호로 둘러싸인 코드를 주 생성자라고 한다. 주 생성자는 생성자 파라미터를 지정하고, 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 2가지 목적에 쓰인다.

 

class User constructor(_nickname: String) {
    val nickname: String
    
    init {
        nickname = _nickname
    }
}

 

constructor 키워드는 주 생성자나 부 생성자 정의 시 사용한다. init 키워드는 초기화 블록을 시작한다. 초기화 블록에는 클래스의 객체가 만들어질 때 실행될 초기화 코드가 들어간다. 초기화 블록은 주 생성자와 같이 쓰이며 여러 개 선언할 수 있다. 주 생성자는 제한적이기 때문에 별도 코드를 포함할 수 없어서 초기화 블록이 필요하다.

주 생성자 앞에 어노테이션, 가시성 변경자가 없다면 constructor 키워드는 생략해도 된다.

 

class User(_nickname: String) {
    val nickname: String = _nickname
}

 

주 생성자의 파라미터로 프로퍼티를 초기화한다면 그 주 생성자 파라미터명 앞에 val을 추가해서 프로퍼티 정의, 초기화를 간략하게 할 수 있다. 그리고 함수 파라미터와 마찬가지로 생성자 파라미터에도 디폴트 값을 정의할 수 있다.

 

class User(val nickname: String, val isSubscribed: Boolean = true)

 

클래스에 기반 클래스가 있다면 주 생성자에서 기반 클래스의 생성자를 호출해야 할 필요가 있다.

기반 클래스를 초기화하려면 클래스명 뒤에 괄호를 치고 생성자 인자를 넘긴다.

 

open class User(val nickname: String) {}
class NaverUser(nickname: String): User(nickname) {}

 

클래스 정의 시 별도로 생성자를 정의하지 않으면 컴파일러가 자동으로 아무 일도 안 하는 인자가 없는 디폴트 생성자를 만든다. 그리고 어떤 클래스를 클래스 밖에서 인스턴스화하지 못하게 막으려면 모든 생성자를 private로 막으면 된다.

 

class Secretive private constructor() {}

 

부 생성자 : 상위 클래스를 다른 방식으로 초기화

 

일반적으로 코틀린에선 생성자가 여럿 있는 경우가 자바보다 훨씬 적다. 자바에서 오버로드한 생성자가 필요한 상황 중 대다수는 코틀린의 디폴트 파라미터 값과 이름 붙은 인자 문법을 써서 해결할 수 있다. 그래도 생성자가 여러 개 필요한 경우가 가끔 있는데, 프레임워크 클래스를 확장해야 하는데 여러 방법으로 인스턴스를 초기화할 수 있게 여러 생성자를 지원해야 하는 경우다. 자바에서 선언된 생성자가 2개인 View 클래스가 있을 경우, 그 클래스를 코틀린으론 아래처럼 정의할 수 있다.

 

open class View {
    constructor(ctx: Context) {
        // ...
    }
    
    constructor(ctx: Context, attr: AttributeSet) {
        //
    }
}

 

이 클래스는 주 생성자 없이 부 생성자만 2개 선언한다. 부 생성자는 constructor 키워드로 시작하며 얼마든 선언할 수 있다. 이 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.

 

open class View {
    constructor(ctx: Context) {
        // ...
    }

    constructor(ctx: Context, attr: AttributeSet) {
        //
    }
}

class MuButton: View {
    constructor(ctx: Context): super(ctx) {
        // ...
    }
    
    constructor(ctx: Context, attr: AttributeSet): super(ctx, attr) {
        // ...
    }
}

 

클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 생성을 위임해야 한다. 부 생성자가 필요한 주된 이유는 자바 상호운용성이다. 또는 클래스 인스턴스를 생성할 때 파라미터 목록이 다른 생성 방법이 여러 개 존재하면 부 생성자를 여러 개 둘 수 밖에 없다.

 

인터페이스에 선언된 프로퍼티 구현

 

코틀린에선 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다.

 

interface User {
    val nickname: String
}

 

이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 방법을 제공해야 한다는 뜻이다. 인터페이스의 프로퍼티 선언에는 backing field나 게터 등의 정보가 없다. 사실 인터페이스는 아무 상태도 포함할 수 없으므로 상태를 저장할 필요가 있다면 인터페이스를 구현한 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 만들어야 한다.

이 인터페이스를 구현하는 방법 몇 개를 확인한다.

 

class PrivateUser(override val nickname: String): User
class SubscribingUser(val email: String): User {
    override val nickname: String
        get() = email.substringBefore('@')  // 커스텀 게터
}
class FacebookUser(val accountId: Int): User {
    override val nickname: String = getFacebookName(accountId)
    private fun getFacebookName(accountId: Int): String { return "$accountId" } // // getFacebookName() 구현이 없어 임의로 추가
}

fun main(args: Array<String>) {
    println(PrivateUser("test@test.com").nickname)
    println(SubscribingUser("test@test.com").nickname)
}

// >> test@test.com
// >> test

 

PrivateUser는 주 생성자 안에 프로퍼티를 직접 선언한다. 이 프로퍼티는 User의 추상 프로퍼티를 구현하고 있어서 override를 표시해야 한다. SubscribingUser는 커스텀 게터로 nickname 프로퍼티를 설정한다. 이 프로퍼티는 backing field에 값을 저장하지 않고 매번 이메일 주소에서 닉네임을 계산해 반환한다.

FacebookUser에선 초기화 식으로 nickname 값을 초기화한다. 이 때 페이스북 유저 ID를 받아서 그 사용자의 getFacebookName()을 호출해서 nickname을 초기화한다.

 

인터페이스에는 추상 프로퍼티와 게터세터가 있는 프로퍼티 선언이 가능하다. 물론 그런 게터세터는 backing field를 참조할 수 없다. backing field가 있다면 인터페이스에 상태를 추가하는 것인데 인터페이스는 상태를 저장할 수 없다.

interface User {
    val email: String
    val nickname: String
        get() = email.substringBefore('@') // 프로퍼티에 backing field가 없는 대신 매번 결과를 계산해 돌려줌
}

 

이 인터페이스에는 추상 프로퍼티 email, 커스텀 게터가 있는 nickname 프로퍼티가 있다. 하위 클래스는 추상 프로퍼티인 email을 반드시 재정의해야 하지만, nickname은 재정의 없이 상속할 수 있다.

인터페이스에 선언된 프로퍼티와 달리 클래스에 구현된 프로퍼티는 backing field를 원하는 대로 쓸 수 있다.

 

게터, 세터에서 backing field에 접근

 

값을 저장하는 동시에 로직을 실행할 수 있게 하려면 접근자 안에서 프로퍼티를 backing field에 접근할 수 있어야 한다.

프로퍼티에 저장된 값의 변경 이력을 로그로 남기려는 경우를 가정한다. 이 경우 변경 가능한 프로퍼티를 정의하되 세터에서 프로퍼티 값을 바꿀 때마다 추가 코드를 실행해야 한다.

 

class User(val name: String) {
    var address: String = "unspecified"
        set(value: String) {
            println("""
                address was changed for $name : $field -> "$value".
            """.trimIndent())
            field = value
        }
}

fun main(args: Array<String>) {
    val user = User("Alice")
    user.address = "A시 A구"
}

// >> address was changed for Alice : unspecified -> "A시 A구".

 

코틀린에서 프로퍼티 값을 바꿀 때는 필드 설정 구문을 이용한다. 이 구문은 내부적으로 address의 세터를 호출한다. 여기선 커스텀 세터를 정의해서 추가 로직을 실행한다.

접근자 본문에선 field라는 식별자를 통해 backing field에 접근할 수 있다. 변경 가능한 프로퍼티의 게터, 세터 중 하나만 직접 정의해도 된다. 위 코드에서 address의 게터는 필드값을 리턴해주는 뻔한 게터라서 굳이 직접 정의할 필요가 없다.

 

접근자의 가시성 변경

 

접근자의 가시성은 기본적으로 프로퍼티의 가시성과 같다. 하지만 원한다면 get, set 앞에 가시성 변경자를 추가해 접근자의 가시성을 바꿀 수 있다.

 

class LengthCounter {
    var counter: Int = 0
        private set

    fun addWord(word: String) {
        counter += word.length
    }
}

fun main(args: Array<String>) {
    val lengthCounter = LengthCounter()
    lengthCounter.addWord("hi!")
    println(lengthCounter.counter)
}

// >> 3

 

이 클래스는 자신에게 추가된 모든 단어의 길이를 합산하고, 전체 길이를 저장하는 프로퍼티는 외부에 공개된다.

하지만 외부에서 단어 길이 합을 못 바꾸게 이 클래스 안에서만 길이를 변경하게 하려고 한다. 그래서 기본 가시성을 가진 게터를 컴파일러가 생성하게 두는 대신 세터의 가시성을 private로 지정한다.

 

컴파일러가 생성한 메서드 : 데이터 클래스와 클래스 위임

 

자바 플랫폼에선 클래스가 equals, hashCode, toString 등의 메서드를 구현해야 한다. 이런 메서드들은 보통 비슷하게 기계적으로 구현 가능하다. 자바 IDE들이 이런 메서드들을 자동 생성해주지만 코드베이스가 번잡해지는 건 동일하다.

코틀린 컴파일러는 이런 메서드를 기계적으로 만드는 작업을 안 보이는 곳에서 해 준다.

 

모든 클래스가 정의해야 하는 메서드

 

코틀린 클래스도 equals, hashCode, toString을 오버라이딩할 수 있다. 고객 이름, 우편번호를 저장하는 Client 클래스를 예시로 사용한다.

 

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

 

문자열 표현 : toString()

 

주로 디버깅, 로깅 시 이 메서드를 쓴다. 기본 제공되는 객체의 문자열 표현은 Client@5e9f23b4 같은 식인데 이건 유용하지 않다. 이 기본 구현을 바꾸려면 toString()을 오버라이드해야 한다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name = $name, postalCode = $postalCode)"
    }
}

 

객체의 동등성 : equals()

 

Client 클래스를 쓰는 모든 계산은 클래스 밖에서 이뤄진다. Client는 데이터를 저장할 뿐이고 그에 따라 구조도 단순하고 내부 정보를 외부에 노출한다. 하지만 클래스는 단순해도 동작에 대한 요구사항들이 있을 수 있다. 서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 둘을 동등한 객체로 간주해야 할 수 있다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name = $name, postalCode = $postalCode)"
    }
}

fun main(args: Array<String>) {
    val client1 = Client("김철수", 1234)
    val client2 = Client("김철수", 1234)
    println(client1 == client2)
}

// >> false

 

위에서 두 객체는 동일하지 않다. 요구사항을 만족시키려면 equals()를 오버라이드할 필요가 있다는 뜻이다.

참고로 코틀린에선 "==" 연산자가 두 객체를 비교하는 기본적인 방법이다. ==는 내부적으로 equals를 호출해서 객체를 비교한다. 따라서 클래스가 equals를 오버라이드하면 ==를 통해 안전하게 인스턴스를 비교할 수 있다. 참조 비교를 하려면 "==="를 쓸 수 있다.

아래는 equals를 추가한 Client다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name = $name, postalCode = $postalCode)"
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is Client) {
            return false
        }
        return name == other.name && postalCode == other.postalCode
    }
}

 

이제 client1 == client2는 true를 리턴한다. 하지만 더 복잡한 작업을 수행하면 제대로 안 되는 경우가 있다. 이와 관련해 흔히 면접에서 묻는 내용이 "Client가 제대로 작동하지 않는 경우를 말하고 문제가 뭔지 설명하라"다. hashCode 정의를 빠뜨려서 그렇다고 하는 개발자가 많을 것이다. 이 경우 실제로 hashCode가 없다는 게 원인이다.

 

해시 컨테이너 : hashCode()

 

자바에선 equals()를 오버라이딩할 때 반드시 hashCode()도 오버라이딩해야 한다. 원소가 "김철수"라는 고객 하나뿐인 집합을 만들고, 새로 원래의 "김철수"와 같은 프로퍼티를 포함하는 새 Client 인스턴스를 만들어 그 인스턴스가 집합 안에 들어있는지 검사한다. 프로퍼티가 모두 일치하니까 새 인스턴스와 집합 안의 기존 인스턴스는 동등하다. 따라서 새 인스턴스가 집합에 속했는지 여부를 검사하면 true가 나올 것이다.

 

fun main(args: Array<String>) {
    val processed = hashSetOf(Client("김철수", 1234))
    println(processed.contains(Client("김철수", 1234)))
}

// >> false

 

그러나 false가 리턴된다. hashCode()를 정의하지 않아서다.

JVM 언어에선 hashCode가 지켜야 하는 "equals()가 true를 리턴하는 두 객체는 반드시 같은 hashCode()를 리턴해야 한다"는 제약이 있는데 Client는 이를 어기고 있다.

processed 집합은 hashSet인데, 이는 원소를 비교하는 비용을 줄이기 위해 먼저 객체의 해시코드를 비교하고 해시코드가 같을 때만 실제 값을 비교한다. 두 Client 인스턴스는 해시코드가 달라서 2번째 해시코드가 집합 안에 없다고 판단한다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name = $name, postalCode = $postalCode)"
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is Client) {
            return false
        }
        return name == other.name && postalCode == other.postalCode
    }

    override fun hashCode(): Int  = name.hashCode() * 31 + postalCode
}

 

이제 이 클래스는 예상대로 작동하지만 지금까지 많은 코드를 작성해야 했다. 코틀린 컴파일러는 이 메서드들을 자동으로 생성해준다.

 

데이터 클래스 : 모든 클래스가 정의해야 하는 메서드 자동 생성

 

어떤 클래스가 데이터 저장만 수행한다면 위의 3가지 메서드를 반드시 재정의해야 한다. 코틀린을 쓰면 이 메서드들을 IDE로 생성할 필요도 없다. data 키워드를 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동 생성한다.

 

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

 

equals, hashCode는 주 생성자의 모든 프로퍼티를 고려해 생성된다. 생성된 equals()는 모든 프로퍼티 값의 동등성을 확인한다. hashCode()는 모든 프로퍼티의 해시값을 바탕으로 계산한 해시값을 반환한다. 이 때 주 생성자 밖에 정의된 프로퍼티는 두 메서드를 계산할 때 고려 대상이 아니다.

 

데이터 클래스와 불변성 : copy()

 

data class의 프로퍼티가 val일 필요는 없다. var 프로퍼티를 써도 된다. 하지만 data class의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들 걸 권장한다. hashMap 등의 컨테이너에 data class 객체를 담는 경우 불변성이 필수적이다.

data class 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy()를 지원한다. 객체를 메모리 상에서 직접 바꾸는 대신 복사본을 만드는 게 더 낫다. 복사본은 원본과 다른 생명주기를 가지며 복사하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 영향을 주지 않는다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString(): String {
        return "Client (name = $name, postalCode = $postalCode)"
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is Client) {
            return false
        }
        return name == other.name && postalCode == other.postalCode
    }

    override fun hashCode(): Int  = name.hashCode() * 31 + postalCode

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

fun main(args: Array<String>) {
    val client1 = Client("김철수", 1234)
    println(client1.copy(postalCode = 5678))
}

// >> Client (name = 김철수, postalCode = 5678)

 

클래스 위임 : by

 

종종 상속을 허용하지 않는 클래스에 새 동작을 추가해야 할 때가 있다. 이 때 쓰는 일반적인 방법이 데코레이터 패턴이다. 이런 접근법의 단점은 준비 코드가 많이 필요하다는 것이다. 이런 위임을 언어가 제공하는 일급 시민 기능으로 지원하는 게 코틀린의 장점이다. 인터페이스 구현 시 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 걸 명시할 수 있다.

메서드 일부 동작을 바꾸려는 경우 메서드를 재정의하면 컴파일러가 만든 메서드 대신 오버라이드한 메서드가 쓰인다. 기존 클래스의 메서드에 위임하는 기본 구현으로 충분하면 따로 오버라이드할 필요가 없다.

이 방법을 써서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현한다. 중복 제거 프로세스 설계 중이라면 원소 추가 횟수를 기록하는 컬렉션을 통해, 최종 컬렉션 크기와 원소 추가 시도 횟수 사이 비율을 봐서 중복 제거 프로세스의 효율성을 판단할 수 있다.

 

class CountingSet<T>(
    val innerSet: MutableCollection<T> = HashSet()
): 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)
    }
}

fun main(args: Array<String>) {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 1, 2))
    println("객체가 ${cset.objectsAdded}개 추가됨. ${cset.size}개 남음")
}

// >> 객체가 3개 추가됨. 2개 남음

 

 

object : 클래스 선언과 인스턴스 생성

 

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

 

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

 

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

 

객체 선언은 object 키워드로 시작한다. 객체 선언은 클래스 정의 후, 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 단 한 문장으로 처리한다.

클래스처럼 객체 선언 안에도 프로퍼티, 메서드, 초기화 블록 등이 들어간다. 하지만 생성자는 객체 선언에 쓸 수 없다. 변수처럼 객체 선언에 사용한 이름 뒤에 마침표를 붙이면 객체에 속한 메서드, 프로퍼티에 접근 가능하다.

객체 선언도 클래스, 인터페이스 상속이 가능하다. 프레임워크를 쓰기 위해 특정 인터페이스를 구현해야 하는데 그 구현 내부에 다른 상태가 필요없는 경우에 유용하다.

 

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

 

코틀린은 static 키워드를 지원하지 않는다. 대신 코틀린은 패키지 수준의 최상위 함수와 객체 선언을 활용한다. 대부분 최상위 함수를 활용하는 걸 더 권장하지만 최상위 함수는 private로 표시된 클래스의 비공개 멤버에 접근 불가능하다.

private 생성자를 호출하기 좋은 위치는 동반 객체다. 동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근 가능하다. 따라서 외부 클래스의 private 생성자도 호출 가능하다. 그래서 팩토리 패턴을 구현하기 가장 좋은 위치다.

부 생성자가 2개 있는 클래스를 동반 객체 안에서 팩토리 클래스를 정의하는 식으로 리팩토링하는 예제를 확인한다.

 

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))
    }
}

 

이제 클래스명을 통해 그 클래스에 속한 동반 객체의 메서드를 호출할 수 있다.

 

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

}

fun getFacebookName(accountId: Int): String = "$accountId"

fun main() {
    val subscribingUser = User.newSubscribingUser("aaa@aaa.com")
    val facebookUser = User.newFacebookUser(4)
    println(subscribingUser.nickname)
}

// >> aaa

 

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

 

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

예를 들어 직렬화 로직을 동반 객체 안에 넣을 수 있다.

 

class Person(val name: String) {
    companion object Loader {
        fun fromJson(jsonText: String): Person = ...
    }
}

 

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

 

시스템에 Person을 포함한 여러 타입의 객체가 있을 경우, 이 시스템에선 모든 객체를 역직렬화로 만들어야 하기 때문에 모든 타입의 객체를 생성하는 일반적인 방법이 필요하다.

 

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

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

 

동반 객체 확장

 

자바의 정적 메서드, 코틀린의 동반 객체 메서드처럼 기존 클래스에 대해 호출할 수 있는 새 함수를 정의하려면 어떻게 해야 하는가? 클래스에 동반 객체가 잇으면 그 객체 안에 함수를 정의해서 클래스에 대해 호출 가능한 확장 함수를 만들 수 있다.

C라는 클래스 안에 동반 객체가 있고 그 동반 객체(C.Companion) 안에 func를 정의하면 외부에선 이 함수를 C.func()로 호출 가능하다.

 

class Person(val name: String, val lastName: String) {
    companion object {
        // 빈 동반 객체 선언
    }
}

fun Person.Companion.fromJson(json: String): Person {
    // ...
}

fun main() {
    val p = Person.fromJson(JSON)
}

 

동반 객체 안에서 fromJson()을 정의한 것처럼 fromJson()을 호출할 수 있지만 실제로 fromJson()은 클래스 밖에서 정의한 확장 함수다. 동반 객체에 대한 확장 함수를 작성 가능하려면 원래 클래스에 동반 객체를 꼭 선언해야 한다. 비어 있더라도 반드시 있어야 한다.

 

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

 

무명 객체(anonymous object) 정의 시에도 object 키워드를 쓴다. 무명 객체는 자바의 무명 내부 클래스를 대신한다. 이벤트 리스너를 코틀린에서 구현한다면 아래처럼 할 수 있다.

 

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?) {
        // ...
    }
}

 

반응형
Comments