기록하는 습관

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

스터디/Kotlin In Action

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

로그뉴 2023. 2. 20. 22:43

** 이 글은 Kotlin In Action을 읽고 정리한 글입니다. **

 

1. 클래스 계층 정의

1-1. 코틀린 인터페이스

interface Clickable {
    fun click()
}
class Button : Clickable {
    override fun click() = println("I was clicked")
}

>>> Button().click() // I was clicked
  • 코틀린에서는 클래스 이름 뒤에 콜론(:)을 붙이고 인터페이스와 클래스 이름을 적는 것으로 클래스 확장과 인터페이스 구현을 모두 처리한다.
  • override 변경자는 상위 클래스나 상위 인터페이스에 있는 프로퍼티나 메서드를 오버라이드 한다는 표시다.
  • 코틀린에서는 override 변경자를 꼭 사용해야 한다.
  • 인터페이스 메서드도 디폴트 구현을 제공할 수 있다. 자바와 달리 default 키워드로 꾸밀 필요가 없다.

 

class Button : Clickable, Focusable {
    override fun click() = println("I was clicked")

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

interface Clickable {
    fun click()
    fun showOff() = println("I'm clickable!")
}

interface Focusable {
    fun setFocus(b: Boolean) =
        println("I ${if (b) "got" else "lost"} focus.")

    fun showOff() = println("I'm focusable!")
}

fun main(args: Array<String>) {
    val button = Button()
    button.showOff()
    button.setFocus(true)
    button.click()
}
  • 코틀린 컴파일러는 이름과 시그니처가 같은 멤버 메소드에 대해 둘 이상의 디폴트 구현이 있는 경우 하위 클래스에서 직접 구현하게 강제한다.

 

1-2. open, final, abstract 변경자: 기본적으로 final

  • 취약한 기반 클래스: 하위 클래스가 기반 클래스에 대해 가졌던 가정이 기반 클래스를 변경함으로써 깨져버린 경우에 생긴다.
  • 코틀린 클래스와 메서드는 기본적으로 final이다.
    • 코틀린은 "상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라"는 조슈아 블로크의 철학을 따르기 때문.
  • 클래스의 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 한다.
  • 오버라이드를 허용하고 싶은 메서드나 프로퍼티 앞에도 open 변경자를 붙여야 한다.
  • 오버라이드하는 메서드의 구현을 하위 클래스에서 오버라이드하지 못하게 금지하려면 오버라이드하는 메서드 앞에 final을 명시해야 한다.
  • abstract로 선언한 추상 클래스는 인스턴스화할 수 없다.
open class RichButton : Clickable {
    fun disable() {}
    open fun animate() {}
    override fun click() {}
}

 

 

클래스 내의 변경자 정리

  • 인터페이스 멤버의 경우 final, open, abstract를 사용하지 않는다.
  • 인터페이스 멤버는 항상 열려 있으며 final로 변경할 수 없다.

 

1-3. 가시성 변경자: 기본적으로 공개

  • 가시성 변경자는 코드 기반에 있는 선언에 대한 클래스 외부 접근을 제어한다.
  • 코틀린의 기본 가시성은 자바와 다르게 public이다.
  • internal이라는 가시성 변경자는 모듈 내부에서만 볼 수 있다.
    • 모듈은 한 번에 한꺼번에 컴파일되는 코틀린 파일을 의미한다. 인텔리J나 이클립스, 메이븐, 그레들 등의 프로젝트가 모듈이 될 수 있다.
  • 자바와 차이
    • 코틀린에서는 최상위 선언에 대해 private 가시성을 허용한다는 점이다.
    • 코틀린에서는 외부 클래스가 내부 클래스나 중첩된 클래스의 private 멤버에 접근할 수 없다는 점이다.

 

1-4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩된 클래스

  • 중첩 클래스(inner class)는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다.
  • 클래스 계층을 만들되 그 계층에 속한 클래스의 수를 제한하고 싶은 경우 중첩 클래스를 쓰면 편리하다.

class Outer {
    inner class Inner {
        fun getOuterReference(): Outer = this@Outer
    }
}
  • 내부 클래스 Inner 안에서 바깥쪽 클래스의 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.

 

 

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

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을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 붙기를 덧붙이게 강제한다.
  • 상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다. sealed 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 한다.
  • sealed로 표시된 클래스는 자동으로 open된다.

 

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

2-1. 클래스 초기화: 주 생성자와 초기화 블록

  • 코틀린 클래스의 생성자는 크게 주 생성자와, 부 생성자로 구분할 수 있다.
    • 주 생성자는 클래스 본문이 아닌 괄호 안에서 정의
    • 부 생성자는 클래스 본문 안에서 정의
  • 생성자 파라미터에 디폴트 값을 정의할 수 있다.
class User(val nickname: String) // 주 생성자
class User(val nickname: String, val isSubscribed: Boolean = true)

 

  • constructor는 주 생성자나 부 생성자 정의를 시작할 때 사용한다.
  • init은 초기화 블록을 시작한다. 초기화 블록은 주 생성자와 함께 사용된다.
class User constructor(_nickname: String) {
    val nickname = _nickname;
}
class User constructor(_nickname: String) {
    val nickname: String

    init { 
        nickname = _nickname
    }
}

 

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

  • 부 생성자는 constructor 키워드로 시작한다.
  • 클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 생성을 위임해야 한다.
  • 부 생성자가 필요한 주된 이유는 자바 상호운용성이다. ?
class MyButton : View {
    constructor(ctx: Context) : super(ctx) {
        // ...
    }

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

 

2-3. 인터페이스에 선언된 프로퍼티 구현

interface User {
    val nickname: String
}
  • 인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게터 등의 정보가 들어있지 않다.

 

class SubscribingUser(val email: String) : User {
    override val nickname: String
        get() = email.subStringBefore('@') // 커스텀 게터
}

class FacebookUser(val accountId: Int) : User {
    override val nickname = getFacebookName(accountId) // 프로퍼티 초기화 식
}
  • SubscribingUser의 nickname은 매 번 호출될 때마다 substringBefore를 호출해 계산하는 커스텀 게터를 활용
  • FacebookUser의 nickname은 객체 초기화 시 계산한 데이터를 뒷받침하는 필드에 저장했다가 불러오는 방식을 활용

 

2-4. 게터와 세터에서 뒷받침하는 필드에 접근

  • get, set을 재정의할 때 실제 필드에 접근하고 싶다면 키워드 field를 사용할 수 있다. 이를 백킹 필드라고 한다.
  • 값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.
  • field 키워드를 사용하지 않고 setter를 정의하면 무한 재귀에 빠질 수 있다.
class User(val name: String){
    var address: String = "unspecified"
        set(value: String){
            field = value // 뒷받침하는 필드 값 변경하기
        }
}

 

2-5. 접근자의 가시성 변경

  • 접근자의 가시성은 기본적으로 프로퍼티의 가시성과 같다.
  • 원한다면 가시성 변경자를 추가해서 접근자의 가시성을 변경할 수 있다.

 

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

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

toString, equals, hashCode를 오버라이딩해야 할 때 IDE는 이런 메서드를 쉽게 생성할 수 있도록 도와준다. 하지만 코틀린은 IDE를 통해 생성할 필요도 없이 data라는 변경자를 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동으로 만들어 준다. 이런 클래스를 데이터 클래스라고 부른다.

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

 

  • data 클래스로 정의하면 equals, hashCode, toString, copy 메서드를 컴파일러가 자동으로 만들어준다.
  • equals: 모든 프로퍼티 값의 동등성 확인.
  • hashCode: 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시값 반환.
  • copy: 일부 프로퍼티를 변경하며 객체를 복사하는 메서드.
 

equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다.

  • equals 메서드는 모든 프로퍼티 값의 동등성을 확인한다.
  • hashCode 메서드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환한다.

 

데이터 클래스의 프로퍼티가 꼭 val일 필요는 없지만, 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들라고 권장한다.

  • HashMap 등 컨테이너에 데이터 클래스를 담는 경우 불변성 필수적
  • 다중스레드에서 스레드를 동기화해야 할 필요성이 줄어듬
  •  

 

3-3. 클래스 위임: 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)
    }
}
  • by 키워드를 통해 인터페이스에 대한 구현을 다른 객체에 위임할 수 있다.
  • 위임하지 않을 메서드는 직접 오버라이드하여 구현할 수 있다.

4. object 키워드 : 클래스 선언과 인스턴스 생성

  • 객체 선언(object declaration): 싱글턴을 정의하는 방법 중 하나
  • 동반 객체(companion object): 인스턴스 메소드는 아니지만 어떤 클래스와 관련 있는 메소드와 팩토리 메소드를 담을 때 쓰인다.

4-1. 객체 선언: 싱글턴을 쉽게 만들기

코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원한다.

object Payroll {
	val allEmployees = arrayListOf<Person>()

	fun calculateSalary() {
		fun (person in allEmployees) {
			...
		}
	}
}
Payroll.allEmployees.add(Person(...))
Payroll.calculateSalary()
  • 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어진다.
  • 변수와 마찬가지로 객체 선언에 사용한 이름 뒤에 .로 메서드나 프로퍼티에 접근할 수 있다.

 

일반 객체를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 쓸 수 있다. 아래 예시와 같이 Comparator를 구현하여 compare 메서드가 있는 CaseInsensitiveFileComparator 객체가 있을 때, sortedWith 메서드에서 해당 객체를 전달받아 사용하고 있다.

object CaseInsensitiveFileComparator : Comparator<File> {
    override fun compare(file1: File, file2: File): Int {
        return file1.path.compareTo(file2.path, ignoreCase = true)
    }
}

fun main(args: Array<String>) {
    println(CaseInsensitiveFileComparator.compare(
        File("/User"), File("/user")))
    val files = listOf(File("/Z"), File("/a"))
    println(files.sortedWith(CaseInsensitiveFileComparator))
}

 

중첩객체를 사용해서 Comparator 클래스를 내부에 정의하는 것이 더 좋다.

data class Person(val name: String) {
    object NameComparator : Comparator<Person> {
        override fun compare(p1: Person, p2: Person): Int =
            p1.name.compareTo(p2.name)
    }
}

 

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

  • 코틀린 언어는 자바 static 키워드를 지원하지 않는다. 대신 코틀린 패키지 수준의 최상위 함수와 객체 선언을 활용한다.
  • 동반객체를 사용하면 자바의 정적 메서드 호출이나 정적 필드 사용 구문과 같아진다.
class A {
    companion object {
        fun bar() {
            println("Companion object called")
        }
    }
}

fun main(args: Array<String>) {
    A.bar()
}

 

동반 객체는 바깥쪽 클래스의 private 생성자도 호출할 수 있다. 따라서 동반객체는 아래와 같이 팩토리 패턴을 구현하기에 가장 적합하다.

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

        fun newFacebookUser(accountId: Int) =
            User(getFacebookName(accountId))
    }
}

fun main(args: Array<String>) {
    val subscribingUser = User.newSubscribingUser("bob@gmail.com")
    val facebookUser = User.newFacebookUser(4)
    println(subscribingUser.nickname)
}
  • 팩토리 메소드 이름을 정할 수 있고, 팩토리 메소드는 팩토리 메서드가 선언된 클래스의 하위 클래스 객체를 반환할 수 있다.

 

4-3. 동반 객체를 일반 객체처럼 사용

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

  • 동반객체도 인터페이스를 구현할 수 있다.
  • 동반 객체에 이름을 붙일 수 있다.
interface JsonFactory<T> {
	fun fromJson(jsonText: String): T
}
class Person(val name: String){
	companion object: JSONFactory<Person> {
		override fun fromJson(jsonText: String): Person = ... // 동반객체가 인터페이스를 구현한다.
	}
}

 

동반 객체 확장

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

fun Person.Companion.fromJson(json:String): Person { // 확장 함수 선언
	...
}
  • Person 동반 객체에 fromJson 확장 함수를 정의할 수 있다.
  • 동반객체에 대한 확장함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 꼭 선언해야 한다. 설령 빈 객체일지라도 동반 객체가 꼭 있어야 한다.

 

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

  • 무명 객체를 정의할 때도 obejct 키워드를 쓴다.
  • 무명 객체는 자바에서 무명 내부 클래스이다.
  • 객체 선언과 달리 무명 객체는 싱글턴이 아니다(새로운 인스턴스 생성).
  • 코틀린의 무명 클래스는 여러 인터페이스를 구현할 수 있다.
window.addMouseListener(
	object : MouseAdapter() {
		override fun mouseClicked(e: MouseEvent) {
			// ...
		}
		override fun mouseEntered(e: MouseEvent) {
		}
	}
)

 

Comments