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

2021. 11. 3. 12:57Kotlin

반응형

지난 글(Kotlin 함수 정의와 호출)에 이어 Kotlin in Action 이라는 책을 보면서 매주 공부한 내용을 블로그에 기록한다. 

이번 주는 4장 클래스/객체/인터페이스 에 대한 내용이다. 책 내용은 어마어마하게 많기 때문에.. 내 기준으로 새로 알게 된 것들, 평소에는 모르고 지나쳤던 부분 또는 한번 짚고 넘어가면 좋은 내용들 위주로 정리한다. 

 

목차


    Interface

    코틀린 인터페이스 특징

    • 자바와 다르게, 추상 메소드 뿐만 아니라 구현이 있는 메소드도 정의할 수 있다.
    • 단, 인터페이스에는 상태를 저장할 수 없다. 즉, 프로퍼티에 값이 저장될 수 없다. 
    • 그러나, 프로퍼티에 게터를 구현할 수는 있다. 이는 상태를 저장하는 것이 아닌, 해당 프로퍼티가 호출될 때마다 호출되는 게터 함수이기 때문.
    interface User {
    	val nickname: String
        
        // 인터페이스는 상태를 저장할 수 없다.
        val nicknameWithValue = "nickname" // compile error
        
        // 아래처럼 사용은 가능. 상태를 저장하는 것이 아닌, nicknameWithGetter 가 호출될 때 마다 get()이 호출되기 때문.
        val nicknameWithGetter: String
        	get() = "nickname"
    }

     

     

    💡 만약 클래스에서 여러 개의 인터페이스를 implement 할 경우, 동일한 메소드가 존재하면 어떻게 될까?

    interface Clickable {
        // 일반 메소드 선언
        fun click()
        // 디폴트 구현이 있는 메소드 
        fun showOff() = println("I'm clickable!")
    }
    
    interface Clickable {
        // 일반 메소드 선언
        fun click()
        // 디폴트 구현이 있는 메소드 
        fun showOff() = println("I'm clickable!")
    }

     

    코틀린 컴파일러는 두 메소드를 아우르는 구현을 하위 클래스에 직접 구현하게 강제한다. 즉, 아래와 같이 구현하면 된다.

    class Button : Clickable, Focusable {
        override fun click() = println("I was clicked!")
    
        /**
        * 이름과 시그니처가 같은 멤버 메소드에 대해 둘 이상의 디폴트 구현이 있는 경우,
        * 인터페이스를 구현하는 하위 클래스에 명시적으로 새로운 구현을 제공해야 한다. 
        */
        override fun showOff() {
            // 꼭 아래와 같이 2개 모두 호출할 필요는 없다. 
            super<Clickable>.showOff()
            super<Focusable>.showOff()
        }
    }

     


     

    open, final, abstract 변경자

    Effective Java 에서는 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라 라는 조언을 한다. 그 만큼 상속으로 인해 많은 문제가 생길 수 있다는 것.

    코틀린 클래스와 메소드는 기본적으로 final이다. 상속을 허용하려면 클래스 또는 메소드 앞에 open 변경자를 붙여야 한다.

    오버라이드한 메소드는 기본적으로 open 특징을 갖는다. 즉, 하위클래스에서 override 가능. 만약 오버라이드 한 메소드를 하위 클래스에서 오버라이드 하지 못하게 하려면, final 을 명시하면 된다.

     


     

    Inner class(내부 클래스) vs Nested class(중첩 클래스)

    자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있다. 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳 가까이에 두고 싶을 때 유용하다.

    내부에 추가한 클래스의 종류는 아래 2개로 나뉜다.

    • Inner class (내부 클래스)
    • Nested class (중첩 클래스)

    위 2개의 차이점은? 안에 선언된 클래스가 바깥 클래스를 참조하냐의 여부가 다르다!

     

    class B 안에 정의된 class A 특징 자바에서의 사용법 코틀린에서의 사용법
    Inner class (내부 클래스) - 바깥쪽 클래스에 대한 참조를 저장함
    - 즉, 내부 클래스에서 외부 클래스에 접근할 수 있다.
    class A inner class A
    Nested class (중첩 클래스) - 바깥쪽 클래스에 대한 참조를 저장하지 않음
    - 즉, 내부 클래스에서 외부 클래스에 접근할 수 없음.
    static class A class A

     

    class Outer {
        private val outer = "outer"
        
        // Nested class
        class NestedInner {
            // 바깥 클래스를 참조하기 "않기" 때문에, 바깥 함수의 [outer] 필드에 접근 불가! 즉, 아래 코드는 컴파일 오류 발생!
            val inner = "${outer}-inner" // compile error
        }
        
        // Inner class
        inner class Inner {
        	// 바깥 클래스를 참조하기 때문에, 바깥 함수의 [outer] 필드에 접근 가능
            val inner = "${outer}-inner"
        }
    }

     


     

    Class Property

    Backing field 

    Backing field 가 뭘까? 나는 실제로 코드에서 backing field 를 쓰긴 했지만, backing field 란 용어와 이것의 정확한 의미 등은 이번 공부를 통해 처음 알게 됐다. 

    In Kotlin, a field is only used as a part of a property to hold its value in memory. Fields cannot be declared directly. However, when a property needs a backing field, Kotlin provides it automatically. This backing field can be referenced in the accessors using the field identifier:

    A backing field will be generated for a property if it uses the default implementation of at least one of the accessors, or if a custom accessor references it through the field identifier.

    - 공식문서

     

    값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 backup 하는 필드에 접근할 수 있어야 한다.

    접근자의 본문에서는 field 라는 특별한 식별자를 통해 backing field 에 접근할 수 있다. 게터에서는 field 값을 읽을 수만 있고, 세터에서는 field 값을 읽거나 쓸 수 있다.

    class User(val name: String) {
        var address: String = "unspecified"
            set(value: String) {
                println("""Address was changed for $name: $field -> $value""")
    
                // 이 field 라는 식별자를 통해 backing field 에 접근.
                field = value // backing field 변경하기 
            }
    }

     

    💡단, 모든 프로퍼티에 backing field 가 존재하는게 아니다!! 그럼, 어느 경우에 backing field 가 존재할까?

    • 컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이 게터나 세터에서 field 를 사용하는 프로퍼티에 대해 backing field를 생성해준다.
    • 다만, field 를 사용하지 않는 커스텀 접근자 구현을 정의한다면 backing field는 존재하지 않는다.

    아래의 예시를 보자.

    /**
    * val 인 경우
    */
    // backing field 없음. field 를 사용하지 않는 커스텀 게터를 구현했기 때문에.
    val property1: Int
        get() {
            return 1
        }
    
    // compile error : Initializer is not allowed here because this property has no backing field
    val property1: Int = 0
        get() {
            return 1
        }
    
    // 이건 성공
    val property1: Int = 0
        get() {
            return field
        }
    
    
    /**
    * var 인 경우
    */
    // backing field 존재
    var property1: Int = 0
        get() {
            return field + 1
        }
    
    // backing field 존재
    var property1: Int = 3
        set(value) {
            field = value
        }
        get() {
            return 1
        }
    
    // compile error: Property must be initialized
    var property1: Int
        get() {
            return 1
        }
    
    // 이건 성공. backing field 없음.
    var property1: Int
        set(value) {
            
        }
        get() {
            return 1
        }

     

    접근자의 visibility 변경

    원한다면 get이나 set 앞에 visibility 변경자를 추가해서 접근자의 가시성을 변경할 수 있다.

    var counter: Int = 0
    	private set // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다!

     


     

    data class

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

    data class Client(val name: String, val postalCode: String)
    • equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다.
    • 생성된 equals 메소드는 모든 프로퍼티 값의 동등성을 확인한다.
    • hashCode 메소드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환한다.
      • JVM 언어에서는 equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode를 반환해야 한다 라는 제약이 있다.

     

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

    • 데이터 클래스의 프로퍼티를 읽기 전용(val 사용)으로 만들어서 immutable 클래스로 만들라고 권장
    • HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다.
    • 불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.

    코틀린 컴파일러가 제공하는 copy() 메소드를 이용하면 데이터 클래스를 불변 객체로 더 쉽게 활용할 수 있다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다고 한다.

    val lee = Client("이계영", 4122) println(lee.copy(postalCode = 4000)) // 출력: Client(name=이계영, postalCode=4000)

     


     

    Class Delegate (클래스 위임)

    대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드 하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메소드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다.
    코틀린을 설계하면서 우리는 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하기로 결정했다. 모든 클래스를 기본적으로 final로 취급하면 상속을 염두에 두고 open 변경자로 열어둔 클래스만 확장할 수 있다.
    하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 *데코레이터(Decorator) 패턴이다.

    그런데 이런 접근 방법의 단점은 준비 코드가 상당히 많이 필요하다는 점. 
    그러나, 코틀린은 이런 위임을 언어가 제공하는 일급 시민 기능으로 지원한다는 점이 장점이다. 코틀린에서는 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.
    - 책에서

     

    💡 Decorator Pattern
    데코레이터 패턴은 특정 클래스의 기본 기능에 추가 기능을 기존 클래스를 수정하지 않고 패턴을 통하여 덧 붙이고 싶을 때 사용한다.
    예시 :
    - 개발자는 개발이라는 업무만 할 수 있다.
    - 그런데 추가적으로 개발업무에 안드로이드개발 이라는 업무를 넣고 싶다.
    - 하지만 개발업무만 하는 개발자가 필요할 수도 있기 때문에 필요할 때 만 안드로이드개발 이라는 업무를 추가하고 싶다.

     

     


     

    companion object

    코틀린 클래스 안에는 static 멤버가 없다. 그 대신 최상위 함수(자바의 static 메소드 역할을 거의 대신 할 수 있다)와 object 선언(자바의 static 메소드 역할 중 코틀린 최상위 함수가 대신할 수 없는 역할이나 static 필드를 대신할 수 있다)을 활용한다. 대부분의 경우 최상위 함수를 활용하는 편을 더 권장한다. 하지만, 최상위 함수는 private 으로 표시된 클래스 멤버에 접근할 수 없다. 그래서 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다.

    companion object 키워드를 통해 만들 수 있다.

     

    companion object 는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 바깥쪽 클래스의 private 생성자도 호출 가능하다. 따라서, 이는 팩토리 패턴을 구현하기 가장 적합한 위치다.

     

    팩토리 메소드 구현 예시

    // primary constructor 를 비공개로 만든다
    class User private constructor(val nickname: String) {
    
        companion object {
    
            // 팩토리 메소드 
            fun newSubscribeUser(email: String) = 
            	User(email.substringBefore('@'))
            fun newFacebookUser(accountId: Int) = 
            	User(getFacebookName(accountId)) 
        }
    }

     

    팩토리 메소드는 매우 유용하다.

    • 위의 예제처럼 목적에 따라 팩토리 메소드 이름을 정할 수 있다.
    • 팩토리 메소드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다.
    • 생성할 필요가 없는 객체를 생성하지 않을 수도 있다.

     


     

    Summary

    • 코틀린 인터페이스는 디폴트 구현을 포함할 수 있고, 프로퍼티도 포함 가능
    • 모든 코틀린 선언은 기본적으로 final / public
    • 상속 및 오버라이딩이 가능하게 하려면 open 키워드 사용
    • internal 선언은 같은 모듈 안에서만 볼 수 있다
    • field 식별자를 통해 프로퍼티 접근자 안에서 데이터를 저장하는 데 쓰이는 backing field 를 참조할 수 있다
    • data class 는 컴파일러가 equals/hashCode/toString/copy 등의 메소드를 자동으로 생성해준다
    • class delegate 를 사용하면 위임 패턴을 구현할 때 필요한 수많은 준비 코드를 줄일 수 있다
    • object 선언을 통해 싱글톤 클래스를 정의할 수 있다
    • companion object 는 자바의 static 메소드와 필드를 대신한다
    • object, companion object 모두 인터페이스를 구현할 수 있다
    반응형