Study(종료)/Kotlin 22.09.13 ~ 12.18

4-3. 클래스와 객체 다루기 : 단순한 변수 이상의 프로퍼티

Ski_ 2022. 10. 1. 00:18

4장

4-1. 클래스와 객체 다루기 : 클래스 정의하기

4-2. 클래스와 객체 다루기 : 널 가능성

4-3. 클래스와 객체 다루기 : 단순한 변수 이상의 프로퍼티

4-4. 클래스와 객체 다루기 : 객체


4-3. 단순한 변수 이상의 프로퍼티

1) 최상위 프로퍼티

클래스나 함수와 마찬가지로 최상위 수준에 프로퍼티를 정의할 수 있다.

이 경우 프로퍼티는 전역 변수나 상수와 비슷한 역할을 한다.

val prefix = "hello"

fun main() {
    val name = readLine() ?: return
    println("$prefix $name")
}

2) 늦은 초기화

클래스를 인스턴스화할 때 프로퍼티를 초기화해야 한다는 요구 사항이 불필요하게 엄격할 때가 있다.

어떤 프로퍼티는 클래스 인스턴스가 생성된 뒤이지만 해당 프로퍼티가 사용되는 시점보다는

이전에 초기화돼야 할 수도 있다.

예를 들면 단위 테스트를 준비하는 코드, 의존 관계 주입에 의해 대입되는 프로퍼티가 있다.

 

이 경우 생성자에서는 초기화되지 않은 상태를 의미하는 디폴트 값을 대입하고 실제 필요할 때 값을 대입할 수 있다.

import java.io.File

fun main() {
    var text: String? = null
    
    fun loadFile(file: File){
        text = file.readText()
    }
}

fun getContentSize(content: Content) = content.text?.length ?: 0

여기서 loadFile()은 다른 곳에서 호출되며 어떤 파일의 내용을 모두 문자열로 가져온다고 가정하자.

위 코드의 단점은 실제 값이 항상 사용 전에 초기화되므로 절대 널이 될수 없는 값이지만,

늘 널 가능성을 처리해야 한다는 점 이다.

코틀린은 이런 문제를 해결하기 위해 lateinit 키워드를 제공한다.

import java.io.File

fun main() {
    lateinit var text: String

    fun loadFile(file: File){
        text = file.readText()
    }
}

fun getContentSize(content: Content) = content.text.length()

lateinit 표시가 붙은 프로퍼티는 값을 읽으려고 시도할 때 프로그램이 프로퍼티가 초기화 여부를 검사해

초기화되지 않은 경우 UninitializedPropertyAccessException을 던진다는 차이를 제외하면 일반 프로퍼티와 같다.

 

프로퍼티를 lateinit으로 만들기 위해서는 만족해야 하는 조건이 이다.

1. 프로퍼티가 코드에서 변경될 수 있는 지점이 여러 곳일 수 있으므로 가변 프로퍼티(var)로 정의해야 한다.

2. 프로퍼티의 타입은 not-null 타입이어야 하고, Int나 Boolean 같은 원시 값을 표현하는 타입이 아니어야 한다.

3. lateinit 프로퍼티를 정의하면서 초기화 식을 지정해 바로 대입할 수 없다(이 경우 애초에 lateinit 사용 의미가 없으므로)

lateinit var text: String

fun readText(){
    text = readLine()!!
}
fun main() {
    readText()
    println(text)
}

3) 커스텀 접근자 사용하기

커스텀 접근자(custom accessor)를 이용하면 변수와 함수의 동작을 한 선언 안에 조합할 수 있다.

커스텀 접근자는 프로퍼티 값을 읽거나 쓸 때 호출되는 특별한 함수다.

class Person(val firstName: String, val lastName: String){
    val fullName: String
        get(): String{
            return "$firstName $lastName"
        }
}

게터는 프로퍼티 정의(위 코드에서 fullName) 끝에 붙으며

기본적으로 이름 대신 get 이라는 키워드가 붙은 함수처럼 보인다.

하지만 이런 프로퍼티를 읽으면 프로그램이 자동으로 게터를 호출한다.

fun main() {
    val person = Person("김", "자반")
    println(person.fullName) //김 자반
}

식이 본문인 형태를 사용할 수 있으며

val fullName: String
    get()= "$firstName $lastName"

프로퍼티 타입을 생략하고 타입 추론에 의존해 사용할 수 있다.

val fullName
    get()= "$firstName $lastName"

게터에는 파라미터가 없다. 게터의 반환 타입은(만약 반환 타입을 지정한다면) 프로퍼티의 타입과 같아야 한다.

val fullName
    //get():Int = "$firstName $lastName"
    //error : Type mismatch Required:Int Found:String
    get():String = "$firstName $lastName"

위 코드처럼 사용하는 프로퍼티는 fullName 프로퍼티를 읽을 때 마다 다시 계산된다.

 

이후에 뒷받침하는 필드(backing field)에 대한 내용이 있는데, 책 내용만 봐서는 이해가 안가서 공식 문서를 참고했다https://kotlinlang.org/docs/properties.html#backing-fields

 

코틀린에서 필드는 프로퍼티의 일부로, 메모리에 프로퍼티에 값을 유지시키는 용도로만 사용된다.

필드는 직접 선언될 수 없지만, 프로퍼티가 backing field가 필요한 경우 코틀린이 자동으로 제공한다.

이 backing field는 식별자 field를 이용해 참조할 수 있다

class Person(val firstName: String, val lastName: String, age: Int){
    var counter = 0 // the initializer assigns the backing field directly
        set(value) {
            if (value >= 0)
             field = value
             // counter = value 
             // ERROR StackOverflow: Using actual name 'counter' would make setter recursive
        }
}

fun main() {
    val person = Person("김", "자반", 20)
    person.counter = 20
}

위 코드에서 counter = value를 사용하면 무한 루프에 빠지고,

field = value를 사용하면 정상적으로 작동한다.

 

field 식별자는 프로퍼티의 접근자(accesser)로써만 사용될 수 있다.

만약 하나 이상의 접근자를 기본 구현하거나 field 식별자를 이용한

커스텀 접근자로 참조할 때 backing-field는 프로퍼티가 생성된다 

예를 들어 밑에 코드는 backing-field가 없다.

val isEmpty: Boolean
    get() = this.size == 0

var로 정의하는 가변 프로퍼티에는 값을 읽기 위한 getter, 값을 설정하기 위한 setter 두 가지 접근자가 있다.

class Person(val firstName: String, val lastName: String, age: Int){
   var age: Int? = null
    set(value){
        if(value != null && value <= 0)
            throw IllegalArgumentException("Invalid age: $value")
        field = value
    }

}

fun main() {
    val person = Person("김", "자반", 20)
    person.age = 20 // call custom setter
    println(person.age) // call custom getter
}

위 코드는 자동적으로 backing-field를 생성한다.

 

프로퍼티 세터의 파라미터는 단 하나이며 타입은 프로퍼티 자체의 타입과 같아야 한다.

관습적으로 파라미터 이름을 value로 정하는 경우가 많지만, 다른 이름도 사용 가능하다.

프로퍼티를 초기화하면 값을 바로 backing-field에 쓰기 때문에 프로퍼티 초기화는 setter를 호출하지 않는다.

 

가변 프로퍼티에는 두 가지 접근자가 있으므로 두 접근자를 모두 커스텀화하고,

두 접근자가 모두 field 키워드를 통해 backing-field를 사용하지 않는 경우를 제외하면 항상 backing-field가 생긴다.

아래 코드는 backing-field가 생기지 않는다.

class Person(var firstName: String, var lastName: String, age: Int){
   var fullName: String 
        get(): String = "$firstName $lastName"
        set(value){
            val names = value.split(" ")
            if(names.size != 2)
                throw IllegalArgumentException("invalid full name : $value")
            firstName = names[0]
            lastName = names[1]
        }
}

 프로퍼티 접근자에 가시성 변경자(visibility modifiers)를 붙일 수 있다.

프로퍼티가 포함된 클래스 외부에서는  프로퍼티의 값을 변경 못하게 해서

외부에서 볼 때 객체가 불변인 것 처럼 하고싶을 떄 이 방식을 사용할 수 있다.

 

단순한 접근자 구현(backing-field를 바로 돌려주는 getter와 field에 값을 바로 대입하는 setter)만 필요한 경우

그냥 get이나 set 키워드만 사용해 getter와 setter를 정의할 수 있다.

import java.util.Date

class Person(name: String){
   var lastChanged: Date? = null
    private set // Person 클래스 밖에서 변경 x

    var name: String = name
     set(value){
         lastChanged = Date()
         field = value
     }

}

fun main() {
    val person = Person("김자반")
    println(person.lastChanged) // null

    person.name = "유퀴즈"
    println(person.lastChanged) // Fri Sep 30 23:39:42 KST 2022
    // person.lastChanged = "10:11:12"
    // Error : Cannot assign to 'lastChanged': the setter is private in 'Person'
}

4) 지연 계산 프로퍼티와 위임

어떤 프로퍼티를 처음 읽을 때까지 그 값에 대한 계산을 미뤄두고 싶을 때 lazy 프로퍼티를 이용하면 된다.

import java.io.File

val text by lazy {
    File("data.txt").readText()
}

fun main() {
    while(true){
        when (val command = readLine() ?: return){
            "print data" -> println(text)
            "exit" -> return
        }
    }
}

위 코드에서 text 프로퍼티를 lazy로 정의했다. lazy 다음에 오는 블록 안에는 프로퍼티를 초기화 하는 코드를 지정한다.

main()함수에서 사용자가 적절한 명령으로 프로퍼티 값을 읽기 전 까지 프로그램은 lazy 프로퍼티의 값을 계산하지 않는다.

초기화가 된 이후 프로퍼티의 값은 필드에 저장되고, 그 이후에는 값을 읽을 때 마다 저장된 값을 읽게 된다.

 

일반적인 경우 프로그램이 시작될 떄 바로 파일을 읽는다.

val text = File("data.txt").readText()

앞에서 배운 getter를 사용하면, 프로퍼티 값을 읽을 때 마다 파일을 매번 다시 읽어온다.

val text get() = File("data.txt").readText()

필요하면 프로퍼티 타입을 명시할 수 있다.

val text: String by lazy { File("data.txt").readText() }

lateinit 프로퍼티와 달리 lazy 프로퍼티는 가변 프로퍼티가 아니다.

lazy 프로퍼티는 초기화된 다음에는 변경되지 않는다.

var text1 by lazy {"hello"}
// error: Type 'Lazy<String>' has no method 
// 'setValue(Nothing?, KProperty<*>, String)' and 
// thus it cannot serve as a delegate for var (read-write property)

val text2 by lazy {"hello"} // OK

lazy 프로퍼티는 스레드 안전(thread-safe)하다.

다중 스레드 환경에서도 값을 한 스레드 안에서만 계산하기 때문에 lazy 프로퍼티에 접근하려는

모든 스레드는 궁극적으로 같은 값을 얻게 된다.

 

함수 본문에서도 지연 변수를 정의할 수 있다.

fun getName() = readLine()!!

fun main() {
    val name by lazy { getName() }
    println(name)
}

다음글

4-4. 클래스와 객체 다루기 : 객체

반응형