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

2023. 3. 26. 20:51Programming Languages/Kotlin

Java에서는 하나 이상의 생성자를 선언할 수 있다. Kotlin은 약간 다른 점이 있다. Kotlin은 주(primary) 생성자부(secondary) 생성자를 구분한다. 또한 초기화 블록을 통해 초기화 로직을 추가할 수 있다.

주 생성자는 보통 클래스를 초기화할 때 주로 사용하는 간략한 생성자로 클래스 본문 밖에서 정의한다. 부 생성자는 클래스 본문 안에서 정의한다. 주 생성자부터 살펴보자.

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

Java에서 클래스를 선언하면 다음과 같을 것이다.

class User {

    private final String nickname;

    public User(String nickname) {
        this.nickname = nickname;
    }
}

하지만 위 코드를 Kotlin에서 간단히 작성하면 다음과 같다.

class User(val nickname: String)

이렇게 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자라고 부른다. 주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 두 가지 목적에 쓰인다. 이를 명시적으로 표현하면 다음과 같다.

class User constructor(_nickname: String) {
    val nickname: String
    init { // 초기화 블록
        nickname = _nickname
    }
}

위 예제에서 constructorinit이라는 새로운 키워드를 볼 수 있다. init 키워드는 초기화 블록을 시작한다. 초기화 블록은 Java와 마찬가지로 클래스의 인스턴스가 만들어질 때 실행될 초기화 코드가 들어간다. 초기화 블록은 주 생성자와 함께 사용된다. 주 생성자는 제한적이기 때문에 별도의 코드를 포함할 수 없으므로 초기화 블록이 필요하다. 필요하면 클래스 안에 여러 초기화 블록을 사용할 수 있다.

위 예제에서는 밑줄(_)을 통해 파라미터와 프로퍼티를 구분했다. Java처럼 this 키워드로 구분해도 된다.

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

또한 nickname 프로퍼티를 초기화하는 코드를 nickname 프로퍼티 선언에 포함시킬 수 있다면 초기화 코드를 초기화 블럭에 넣을 필요가 없다. 그리고 주 생성자에 별다른 어노테이션이나 가시성 변경자가 없다면 constructor를 생략해도 된다. 따라서 위 예제는 다음과 같이 축약된다.

// val은 파라미터에 상응하는 프로퍼티가 생성된다는 뜻이다.
class User(val nickname: String)

생성자 파라미터에도 디폴트 파라미터를 넣을 수 있다.

class User(val nickname: String = "anonymous")
💡모든 생성자 파라미터에 디폴트 파라미터 값을 지정하면 컴파일러는 자동으로 파라미터가 없는 생성자를 만들어준다. 그렇게 자동으로 만들어진 파라미터 없는 생성자는 디폴트 값을 사용해 클래스를 초기화한다.

 

만약 클래스에 기반 클래스가 있다면 주 생성자에서 기반 클래스의 생성자를 호출해야 할 필요가 있다. 기반 클래스를 초기화하려면 기반 클래스 이름 뒤에 괄호를 치고 생성자 파라미터를 넘긴다.

open class User(val nickname: String) {...}
class TwitterUser(nickname: String): User(nickname) {...}

클래스를 정의할 때 별도로 생성자를 정의하지 않으면 컴파일러가 자동으로 아무 일도 하지 않는 파라미터가 없는 디폴트 생성자를 만들어준다.

open class Button

Button의 생성자는 아무 파라미터도 받지 않지만 이 클래스를 상속하는 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.

이 규칙으로 인해 기반 클래스의 이름 뒤에는 반드시 괄호가 들어간다. 반면 인터페이스는 생성자가 없기 때문에 어떤 클래스가 인터페이스를 구현하는 경우에는 괄호가 없다. 이를 통해 클래스 상속과 인터페이스 구현을 구별할 수 있다.

어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 하려면 모든 생성자를 private으로 만들면 된다.

class Secretive private constructor() {}

대부분의 경우 주 생성자를 사용하면 되지만 복잡한 문제도 있기 마련이다. 이를 위해 부 생성자가 존재한다.

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

일반적으로 코틀린에서는 생성자가 많이 있는 경우가 자바보다 적다. 자바에서 오버로드한 생성자가 필요한 경우 중 대부분은 코틀린의 디폴트 파라미터 값과 이름 붙인 파라미터 문법으로 해결할 수 있다.

💡파라미터에 대한 디폴트 값을 제공하기 위해 부 생성자를 여러 개 만들지 마라. 대신 파라미터의 디폴트 값을 생성자 시그니처에 직접 명시해라.

 

그래도 생성자가 여러 개 필요한 경우가 있다. 일반적인 상황은 프레임워크 클래스를 확장해야 하는데 여러 가지 방법으로 인스턴스를 초기화할 수 있게 다양한 생성자를 지원해야 하는 경우이다.

open class View {
    // 부 생성자들
    constructor(ctx: Context) {...}
    constructor(ctx: Context, attr: AttributeSet) {...}
}

위 클래스는 주 생성자를 선언하지 않고 부 생성자만 2개를 선언했다. 부 생성자는 constructor 키워드로 시작한다. 위 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.

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

이 2가지 부 생성자는 상위 클래스에 생성을 위임한다.

Java와 마찬가지로 생성자에서 this()를 사용해 다른 생성자에게 생성을 위임할 수 있다.

class MyButton: View {
    constructor(ctx: Context): this(ctx, MY_STYLE) {...}
    constructor(ctx: Context, attr: AttributeSet): super(ctx, attr) {...}
}

클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 위임해야 한다. 이처럼 부 생성자가 필요한 주요 이유는 Java와의 상호운용성이다.

하지만 부 생성자가 필요한 다른 경우도 있다. 클래스 인스턴스를 생성할 때 파라미터 목록이 다른 생성 방법이 여러 개 존재하는 경우 부 생성자를 여러 개를 둘 수 밖에 없다.

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

Kotlin에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다.

interface User {
    val nickname: String
}

이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 수단을 제공해야 한다는 뜻이다. 인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게터 등의 정보가 들어있지 않다. 사실 인터페이스는 아무 상태도 포함할 수 없다.

이 인터페이스를 구현하는 방법을 몇 가지 살펴보자.

// 주 생성자에서 구현
class PrivateUser(override val nickname: String): User

// 커스텀 게터를 구현. 필드에 접근할 때마다 게터가 실행됨.
class SubscribingUser(val email: String): User {
    override val nickname: String
        get() = email.substringBefore('@')
}

// 프로퍼티 초기화 식으로 구현.
// Facebook에 접속해 정보를 가져오는 비용은 비싸기 때문에
// 객체를 초기화할 때 한 번만 호출하도록 설계함.
class FacebookUser(val accountId: Int): User {
    override val nickname = getFacebookName(accountId)
}

인터페이스에는 추상 프로퍼티뿐 아니라 getter와 setter가 있는 프로퍼티를 선언할 수도 있다. 물론 그런 getter와 setter는 뒷받침하는 필드를 참조할 수 없다(뒷받침하는 필드가 있다면 인터페이스에 상태가 추가되는 꼴이므로).

interface User {
    val email: String
    val nickname: String
        // nickname의 게터. 매번 결과를 계산해 리턴한다.
        get() = email.substringBefore('@')
}

위 인터페이스를 구현하는 클래스는 email 프로퍼티를 반드시 오버라이드해야 한다. 하지만 nickname은 오버라이드하지 않고 상속할 수 있다.

4. getter와 setter에서 뒷받침하는 필드에 접근

지금까지 프로퍼티의 두 가지 유형(값을 저장하는 프로퍼티와 커스텀 접근자에서 매번 값을 계산하는 프로퍼티)에 대해 살펴봤다. 이제 이 두 유형을 조합해서 어떤 값을 변경하거나 읽을 때마다 정해진 로직을 실행하는 유형의 프로퍼티를 만들어보자. 값을 저장하는 동시에 로직을 실행하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.

class User(val name: String) {
    var address: String = "unspecified"
        set(value: String) {
            println("""
                Address was changed for $name:
                "$field" -> "$value".""".trimIndent()) // 뒷받침하는 필드값 읽기
            field = value // 뒷받침하는 필드값 변경
        }
}

접근자의 본문에서는 field라는 특별한 식별자로 뒷받침하는 필드에 접근할 수 있다. getter에서는 field에 접근할 수만 있고 setter에서는 field의 값을 읽거나 변경할 수 있다.

val user = User("Bob")
user.address = "Seoul, South Korea"
// Address was changed for Bob:
// "unspecified" -> "Seoul, South Korea".

5. 접근자의 가시성 변경

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

// set의 가시성을 private으로 제한하여 해당 클래스에서만 사용하도록 한다.
class LengthCounter {
    var counter: Int = 0
         private set
    fun addWord(word: String) {
        counter += word.length
    }
}