Kotlin - 04. Part1. 클래스, 객체, 인터페이스

Kotlin 클래스에 대해서…

객체 지향 프로그래밍을 하면서 클래스와 인터페이스 개념을 빼놓을 수는 없을 것 같다. Kotlin 에서도 다양한 클래스를 지원하고 있고, 그와 상응하게 Java 코드로 변환 처리는 어떻게 되고 있는지 파악하여 정확하게 Kotlin 개발을 할 필요가 있다고 생각한다.

  • 클래스와 인터페이스
  • 생성자와 프로퍼티
  • 데이터 클래스
  • 클래스 위임
  • object 키워드

클래스와 인터페이스

인터페이스

Kotlin 의 인터페이스는 Java 8 의 인터페이스와 비슷하다. 추상 메소드뿐 아니라 구현이 있는 메소드도 정의할 수 있다. 하지만 인터페이스에는 아무런 상태(필드)도 저장될 수 없다.

interface Clickable {
    fun click()
    fun showOff() = println("Clickable ShowOff!")
}

class Button : Clickable {
    override fun click() = println("Button Clicked!")
    override fun showOff() = super<Clickable>.showOff()
}

println(Button().click())   // Button Clicked!
  • 콜론(:) : Java 에서 클래스나 인터페이스 상속은 extends 또는 implements 키워드를 사용하지만 Kotlin 에서는 콜론(:)을 붙이고, 클래스와 인터페이스 이름을 적는다.
  • override : Java@Override 와 비슷한 기능을 하는 override 변경자는 상위 클래스나 상위 인터페이스에 정의되어 있는 프로퍼티나 메소드를 오버라이드할 때 사용한다.
  • Kotlin 에서 인터페이스안에 default 메소드를 정의할 때는, Java 와 달리 default 변경자와 같은 키워드 없이 구현 가능하다.
    • 상위 객체의 default 메소드를 오버라이드할 때는, super 접근자를 통해 타입 지정하여 사용 가능하다.

open, final, abstract 변경자: 기본적으로 final (상속 제어 변경자)

객체 지향 프로그래밍을 하면서 상속을 활용한 클래스 설계 기법은 모두 익히 잘 알고 있을 것이고, 상속을 통해 많은 장점과 이점을 취할 수 있음도 잘 알고 있을 것이다. 결론부터 말하자면, Kotlin 에서는 상속을 기본적으로 금지하며 변경자 자체를 기본적으로 final 변경자로 선언되게끔 되어있다.

상속을 활용한 설계의 단점으로는, 취약한 기반 클래스 (fragile base class) 라는 문제가 있다. 이는 하위 클래스가 기반 클래스에 대해 가졌던 가정이 기반 클래스 즉 상위 클래스가 변경함으로써 깨져버릴 수 있는 경우를 말한다.

Java 프로그래밍 기법을 다룬 책 중 유명한 Effective Java 에서도 “상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라” 라는 조언도 있다고 한다. Kotlin 도 이와 같은 철학을 기반으로 모든 클래스와 메소드는 기본적으로 final 로 선언된다.

open 변경자

Kotlin 는 기본적으로 final 이기 때문에, 상속이나 override 가 필요한 경우에는, open 변경자를 사용한다.

open class RichButton : Clickable {   // (1)
    fun disable() {}                  // (2)
    open fun animate() {}             // (3)
    override fun click() {}           // (4)
}
  1. open class RichButton : 해당 클래스는 open 이고, 다른 클래스가 상속할 수 있다.
  2. fun disable() : 해당 함수는 final 이기 때문에, 하위 클래스가 이 함수를 오버라이드할 수 없다.
  3. open fun animate() : 해당 함수는 open 이기 때문에, 하위 클래스에서 이 함수를 오버라이드할 수 있다.
  4. override fun click() : 상위 클래스에서 선언된 함수를 오버라이드하여 구현한다.

abstract 변경자

abstract 변경자는 추상 클래스를 쿠현할 때 사용한다. 추상 클래스는 인스턴스화할 수 없으며, 구현이 없는 추상 멤버가 있기 때문에 하위 클래스에서 그 추상 멤버를 오버라이드해야만 한다.

추상 멤버는 항상 open 열려 있다. 따라서 별도 open 변경자를 명시할 필요 없다.

  • 인터페이스 멤버의 경우, 항상 열려있기 때문에 final 로 변경할 수 없다.
  • 인터페이스 멤버에게 본문이 없다면 자동으로 추상 멤버가 되며, 따로 멤버 선언 앞에 abstract 를 명시할 필요가 없다.

Kotlin 상속 제어 변경자

변경자 설명
final 오버라이드할 수 없음
open 오버라이드할 수 있음
abstract 반드시 오버라이드해야 함
override 상위 클래스의 멤버를 오버라이드 함

override 한 멤버는 항상 open 상태이다. 다시 하위 클래스에는 오버라이드를 금지하고자 한다면, final 변경자를 명시해줘야 한다.


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

Kotlin 에서 가시성 변경자(visibility modifier)는 Java 와 비슷하면서 다른 부분이 있다.

Java 에서 제공하는 패키지 전용 가시성 변경자는 Kotlin 에는 없다. 대신 같은 모듈에서만 볼 수 있는 internal 변경자가 있다. 이와 같이 비슷하면서도 다른 가시성 변경자를 가지고 있으며, 기본적으로는 public 변경자를 따른다.

Kotlin 가시성 변경자

변경자 클래스 멤버 최상위 선언
public 모든 곳에서 접근 가능 모든 곳에서 접근 가능
internal 같은 모듈 안에서만 접근 가능 같은 모듈 안에서만 접근 가능
protected 하위 클래스 안에서만 접근 가능 적용 불가
private 같은 클래스 안에서만 접근 가능 같은 파일 안에서만 접근 가능
가시성 변경자 유의 사항
  • 접근하고자 하는 함수나 객체에서 그보다 가시성이 낮은 경우 참조하지 못한다.
  • 외부 클래스가 내부 클래스 또는 중첩된 클래스의 private 멤버에 접근 불가하다.

내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스 nested class

Java 처럼 Kotlin 에서도 클래스 안에 다른 클래스를 선언하여 사용 가능하다. 이런 클래스는 내부 클래스(inner class) 라고 정의하는데 어떤 경우에 사용하면 좋을지 먼저 살펴보면, 2가지 정보가 있을 것 같다.

  • 클래스안에서 필요한 별도의 클래스를 캡슐화하거나
  • 코드 정의를 가까운 곳에 두고 싶을 때

Java 의 내부 클래스 예외 상황

Java 에서는 클래스안에 있는 내부 클래스를 그냥 선언하고 사용한다면, NotSerializableException 에러가 발생한다. 이 이유는 내부 클래스가 바깥쪽 클래스에 대한 묵시적 참조를 하고 있기 때문이다. 그래서 Java 에서는 이를 해결하기 위해 내부 클래스를 static 클래스로 변경해줘서 바깥 클래스와의 참조를 없애야 한다.

Kotlin 에서는 이러한 문제 때문에 클래스 안에 생성된 클래스는 중첩 클래스(nested class) 로 기본 생성하게 하였고, 이는 static 이 붙은 Java 중첩 클래스와 같다. 그리고 바깥쪽 클래스의 참조를 하고자 한다면, inner 변경자를 통해 생성 가능하다.

Java 와 Kotlin 의 중첩 클래스 & 내부 클래스 차이
구분 Java Kotlin
중첩 클래스 (바깥쪽 클래스에 대한 참조 X) static class A class A
내부 클래스 (바깥쪽 클래스에 대한 참조 O) class A inner class A
내부 클래스에서 바깥쪽 클래스의 참조
class Outer {
    inner class Inner {
        fun method(): Outer = this@Outer
    }
}
  • this@Outer : 내부 클래스 Inner 안에서 바깥쪽 클래스 Outer 접근하고자 할 때 사용

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

봉인된 클래스(sealed class) 는 말그대로 상위 클래스는 봉인할 때 사용한다. 클래스는 봉인한다라는 의미는 상위 클래스를 상속한 하위 클래스 정의를 제한한다 라고 할 수 있다.

상위 클래스를 봉인함으로써, 하위 클래스를 검증하는 로직에서 예외 처리를 하지 않아도 되는 이점이 생긴다.

sealed 변경자가 있는 상위 클래스와 상속한 하위 클래스 정의 예제를 살펴보자.

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)
    }
  • sealed 변경자를 명시하면서, Expr 클래스는 NumSum 클래스 외에는 정의할 수 없게 되었다.
  • eval() 함수에서 when 검사할 때, 더이상 else 문을 작성하지 않아도 된다.

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

Java 와 비슷하면서도 다른 Kotlin 의 클래스 선언 방식에 대해 알아보고자 한다.

Kotlin 은 주primary생성자와 부secondary생성자를 구분하고 있으며, 초기화 블록 initializer block 을 통해 클래스를 초기화할 수 있다.


primary 생성자

class User(val name: String)
  • 주 생성자는 생성자의 파라미터를 지정
  • 주 생성자는 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의

위 코드는 아래와 같다.

class User constructor(_name: String) {
    val name: String
    init {
        name = _name
    }
}
  • constructor : 주 생성자나 부 생성자를 정의할 때 사용하는 키워드
  • init { ... } : 클래스의 객체가 생성될 때(인스턴스화) 실행될 초기화 블록
  • 주 생성자는 제한적이기 때문에 별도의 로직이 필요한 경우 초기화 블록을 사용
  • 클래스안에는 여러 초기화 블록 정의 가능

생성자 파라미터 default 값 지정

class User(
    val name: String,
    val isSubscribed: Boolean = true
)
  • isSubscribed 프로퍼티는 Boolean 형이고, truedefault 값을 가진다.

모든 프로퍼티가 default 값을 가지는 경우, Kotlin 컴파일러는 자동으로 파라미터가 없는 생성자를 만들어 준다. 그런 생성자는 생성될 때 default 값을 활용해서 클래스를 인스턴스화한다.

비공개 생성자

class Secretive private constructor() {}
  • constructor 앞에 private 변경자를 붙이면, 해당 생성자는 비공개가 된다.
  • 클래스의 유일한 생성자를 비공개 처리하면, 클래스 외부에서 생성이 불가능하다.

비공개 생성자를 가진 클래스는 객체 인스턴스화가 필요없는 유틸리티 함수를 담아두는 클래스에 활용하기 용이하다.


secondary 생성사

class User {
    constructor(name: String) {
        ...
    }

    constructor(name: String, phoneNumber: String) {
        ...
    }
}
  • 부 생성자는 constructor 키워드로 시작하여 정의 가능하다.
  • super 키워드를 통해서 부 생성자도 부모 클래스의 생성자를 활용하여 인스턴스화 가능하다.
  • this 키워드를 활용하여 같은 클래스의 다른 생성자를 활용할 수 있다.

부 생성자의 필요성

  • 주된 이유는 Java 와의 상호운용성이다.
  • 인스턴스화에 필요한 특정 방법이 있는 경우 부 생성자를 활용할 수 있다.

인터페이스의 추상 프로퍼티

interface User {
    val nickName: String
}
  • 인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드 또는 게터 등의 정보가 없다.
    • 인터페이스는 아무 상태도 포함할 수 없기 때문에 상태 저장할 필요가 없다.
  • 인터페이스를 구현하는 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 구현할 필요가 있다.

인터페이스 프로퍼티 구현

class PrivateUser(
    override val nickName: String
) : User

PrivateUser 클래스처럼 상속받은 인터페이스의 프로퍼티를 오버라이드해서 주 생성자에 포함시켜주면 된다.

하지만 필요에 의해 인터페이스의 프로퍼티를 특별하게 상태 저장할 수 있는 방법이 있다.

class SubscribingUser(
    private val email: String
) : User {
    override val nickName: String
        get() = email.substringBefore("@")
}

SubscribingUser 클래스처럼 상위 인터페이스의 프로퍼티를 커스텀 게터를 사용하여 구현할 수 있다.

Kotlin 은 이처럼 인터페이스의 추상 프로퍼티뿐 아니라 게터와 세터가 있는 프로퍼티를 선언할 수 있다.

접근자 메소드: 게터 getter & 세터 setter

인터페이스의 추상 프로퍼티를 구현하는 커스텀 게터/세터를 선언할 수 있지만, 뒷받침하는 필드를 참조할 수 없기 때문에 매번 동일한 결과 처리를 할 수 밖에 없고, 그로인해 처리 비용이 높아질 수 밖에 없다.

Kotlin 에서는 위와 같은 상황을 방지하기 위해서 게터/세터에서 뒷받침하는 필드에 접근할 수 있는 방법을 제시한다.

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

val user = User("Jim")
user.address = "Seoul"  // Address was changed for JIM: "unknown" -> "Seoul"
println(user.address)   // Seoul
  • field 라는 특별한 식별자가 뒷받침하는 필드에 접근을 가능하게 한다.
    • 위 코드에서 세터에서의 fieldaddress 프로퍼티의 뒷받침하는 필드를 가리킨다.
  • 게터에서는 field 값을 읽을 수 있다.
  • 세터에서는 field 값을 읽거나 쓸 수 있다.
접근자의 가시성 변경

접근자의 가시성은 기본적으로 프로퍼티의 가시성을 따라가지만, 특별한 경우에 접근자의 가시성을 다르게 지정할 수 있다.

class LengthCounter {
    var counter: Int = 0
        private set
    fun addWord(word: String) {
        counter += word.length
    }
}
  • counter 프로퍼티의 setter 메소드를 private 으로 지정하여 해당 클래스에서만 접근 가능하도록 하였다.
  • addWord() 메소드를 통해서 counter 프로퍼티를 변경할 수 있다.
val lengthCounter = LengthCounter()
lengthCounter.addWord("Hello")
println(lengthCounter.counter)    // 5

지금까지 Kotlin 의 클래스의 전반적인 모습과 기능을 살펴보았다.

하지만 이외에도 Kotlin 에서는 Java 와 다른 클래스를 작성할 수 있는 방법들이 있는데, 분량이 많은 관계로 다음 포스트로 넘기도록 하겠다.


출처