본문 바로가기
Kotlin/Kotlin 프로그래밍

[Kotlin] 프로퍼티와 초기화

by 주 녕 2021. 4. 20.
728x90

모든 내용은 Do it! 코틀린 프로그래밍을 바탕으로 정리한 것입니다. 

프로퍼티의 접근

코틀린에서 Getter/Setter가 작동하는 방식

* 자바에서는 각 필드에 접근하기 위해서 접근 메서드 Getter/Setter(접근자)를 만들어야 함 → 무결성, 보안 문제

* 자바에서 필드가 늘어나면 접근 메서드도 많아지게 되기 때문에 코드를 읽기 어려워짐

 

코틀린에서 클래스의 변수를 필드가 아닌 프로퍼티라고 부르는 이유?

class User(_id: Int, _name: String, _age: Int) {
    // User(val id: Int, var name: String, var age: Int)
    val id: Int = _id
    var name: String = _name
    var age: Int = _age
}

fun main() {
    val user = User(1, "Jun", 30)
    val name = user.name
    user.age = 23
    println("name: $name, ${user.age}")
}
  • 객체를 생성하고 점(.) 표기법으로 프로퍼티에 접근함
    • user.name은 프로퍼티에 직접 접근하는 것처럼 보이지만 코틀린 내부적으로 접근 메서드가 내장되어 있음
    • val로 설정되어 있는 읽기 전용 프로퍼티는 setter로 값을 다시 할당할 수 있음
public final class User {
   private final int id;
   @NotNull
   private String name;
   private int age;

   public final int getId() {
      return this.id;
   }

   @NotNull
   public final String getName() {
      return this.name;
   }

   public final void setName(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.name = var1;
   }

   public final int getAge() {
      return this.age;
   }

   public final void setAge(int var1) {
      this.age = var1;
   }

   public User(int _id, @NotNull String _name, int _age) {
      Intrinsics.checkNotNullParameter(_name, "_name");
      super();
      this.id = _id;
      this.name = _name;
      this.age = _age;
   }
}

위의 코드를 Decompile한 코드임 (코틀린 코드도 실행하면 JVM에서 동작하기 때문에 변환된 코드는 자바 코드와 거의 동일)

  • var로 선언된 name, age는 getter/setter가 생성되었지만 val로 선언된 id는 getter만 존재함
  • 코틀린에서는 별도로 getter/setter를 지정하지 않았음 → 코드량 감소
  • 자바로 변환된 코드를 보면 프로퍼티에 대한 접근 메서드가 자동으로 만들어져 해당 프로퍼티에 접근할 때 이용되는 것을 알 수 있음

기본 getter/setter 직접 지정하기

    var 프로퍼티 이름[: 프로퍼티 자료형] [=프로퍼티 초기화]
        [get() { 게터 본문 }]
        [set(value) { 세터 본문 }]
    val 프로퍼티 이름[: 프로퍼티 자료형] [=프로퍼티 초기화]
        [get() { 게터 본문 }]
  • value : 세터의 매개변수로 외부로부터 값을 가져옴 (정해진 이름은 아니므로 다른 이름으로 변경하여 사용 가능)
  • field : 프로퍼티를 참조하는 변수 (정해진 이름이므로 변경할 수 없음) - 보조 필드(Backing Field)
    • 각 프로퍼티의 값을 읽는 특별한 식별자
    • get() = field 대신 get() = age → get() = age.get() 과 같으므로 무한 재귀 호출에 빠져 스택 오버플로 오류가 발생할 수 있음
class User(_id: Int, _name: String, _age: Int) {
    val id: Int = _id
        get() = field
    
    var name: String = _name
        get() = field
        set(value) {
            field = value
        }
    
    var age: Int = _age
        get() = field
        set(value) {
            field = value
        }
}

fun main() {
    val user1 = User(1, "Jun", 30)
    user1.age = 23
    println("user1.age = ${user1.age}")
}

커스텀 getter/setter 사용하기

class User(_id: Int, _name: String, _age: Int) {
    val id: Int = _id
    var name: String = _name
        private set(value) {
            println("The name was changed")
            field = value.toUpperCase()
        }
    var age: Int = _age
}

fun main() {
    val user3 = User(1, "Junyoung", 30)
    println("user3.name = ${user3.name}")  // user3.name = JUNYOUNG
}
  • 입력 문자를 대문자로 바꾸는 등의 특정 연산을 수행해야 한다면 getter/setter를 확장해 편리하게 코드를 구성할 수 있음
  • 보안 때문에 외부에서 접근해서 사용하지 못하게 하려면 접근 메서드에 가시성 지시자를 넣어줄 수 있음

보조 프로퍼티의 사용

→ 보조 필드를 사용하지 않는 경우, 임시적으로 사용할 보조 프로퍼티를 선언해 놓고 getter/setter에 사용할 수 있음

class User(_id: Int, _name: String, _age: Int) {
    val id: Int = _id
    private var tempName: String? = null  // 보조 프로퍼티
    var name: String = _name
        get() {
            if (tempName == null) tempName = "NONAME"
            return tempName ?: throw AssertionError("Asserted by others")
        }
    var age: Int = _age
}

fun main() {
    val user2 = User(1, "Junyoung", 23)
    user2.name = ""
    println("user2.name = ${user2.name}")
}

 

프로퍼티 오버라이딩

프로퍼티는 기본적으로 오버라이딩 할 수 없는 final 형태로 선언됨

프로퍼티 오버라이딩을 위해서는 open 키워드를 사용하여 프로퍼티를 선언해야 함

open class First {
    open val x: Int = 0
        get() {
            println("First x")
            return field
        }
    val y: Int = 0
}

class Second : First() {
    override val x: Int = 0
        get() {
            println("Second x")
            return field + 3
        }
}

 


지연 초기화와 위임

프로퍼티를 선언하면 기본적으로 모두 초기화해야 함.

객체의 정보가 나중에 나타나는 경우 객체 생성과 동시에 초기화를 하기 힘든 경우도 있음 → 지연 초기화 필요

lateinit을 사용한 지연 초기화

  • 특정 객체에 의존성이 있는 경우
  • 해당 자료형의 프로퍼티를 즉시 사용하지 않는 경우 (초기화 한다면 메모리 낭비)
  • 모듈별로 소스 코드를 테스트하는 유닛 테스트를 하기 위해 임시적으로 객체를 생성해야 하는 경우

프로퍼티 지연 초기화

클래스를 선언할 때 프로퍼티 선언은 null을 허용하지 않음

BUT 지연 초기화를 위한 lateinit 키워드를 사용하면 프로퍼티에 값이 바로 할당되지 않아도 컴파일러에서 허용O

 

< lateinit의 제한 >

  • var로 선언된 프로퍼티만 가능함
  • 프로퍼티에 대한 getter/setter를 사용할 수 없음
class Person {
    lateinit var name: String
    fun test() {
        if(!::name.isInitialized) {
            println("not initialized")
        } else {
            println("initialized")
        }
    }
}

fun main() {
    val jun = Person()
    jun.test()
    jun.name = "Junyoung"
    jun.test()
    println("name = ${jun.name}")
}
  • name을 lateinit으로 선언함 → 초기화하지 않은 채 선언할 수 있음
  • isInitialized : 프로퍼티가 초기화되었는지 검사하는 코틀린 표준 함수 API
    • 프로퍼티 참조를 위해 콜론 2개(::)를 사용하였음
    • Boolean이 반환값 → 초기화되었다면 true / 초기화되지 않았다면 false 반환
  • name을 초기화하지 않고 사용한다면, UninitializedPropertyAccessException 예외 발생

객체 지연 초기화

생성자를 통해 객체를 생성할 때도 lateinit 키워드를 사용해 필요한 시점에 객체를 지연 초기화 가능

data class Person(var name: String, var age: Int)

lateinit var person1: Person

fun main() {
    person1 = Person("jun", 23)
    print(person1.name + " is " + person1.age.toString())
}

 

lazy를 사용한 지연 초기화

  • 호출 시점(최초 접근 시점)에 by lazy{...} 정의에 의해 블록 부분의 초기화를 진행함
  • 불변의 변수 선언인 val에서만 사용 가능함 (읽기 전용)
  • val이므로 값을 다시 변경할 수 없음

프로퍼티 지연 초기화

val을 사용하는 읽기 전용 프로퍼티를 지연 초기화 할 때 매우 유용함

lazy는 람다식으로 구성되어 lazy 인스턴스 반환값을 가지는 함수!

class LazyTest {
    init {
        println("init block")
    }
    
    val subject by lazy {
        println("lazy initialized")  // 4번
        "Kotlin Programming"  // lazy 반환값
    }
    
    fun flow() {
        println("not initialized")  // 1번
        println("subject one: $subject")  // 2번 : 최초 초기화 시점
        println("subject two: $subject")  // 3번 : 이미 초기화된 값 사용
    }
}

fun main() {
    val test = LazyTest()
    test.flow()
}
  1. main()에서 test 객체 생성
  2. LazyTest의 init 초기화 블록이 실행됨 (아직 subject 프로퍼티는 초기화 되지 않음)
  3. main()으로 돌아와 test 객체의 flow() 메서드 실행
  4. flow() 메서드 안에서 1번 문장이 출력됨
  5. 2번 문장의 $subject에 의해 subject 프로퍼티가 최초로 접근되고, subject 값이 lazy 블록에 있는 4번 문장 출력 & 마지막 문장으로 초기화
  6. 3번 문장 출력

객체 지연 초기화

class Person(val name: String, val age: Int)

fun main() {
    var isPersonInstantiated: Boolean = false

    val person : Person by lazy {  // 1번 : lazy를 사용한 지연 초기화
        isPersonInstantiated = true
        Person("Lee", 23)  // 반환
    }

    val personDelegate = lazy {Person("Kim", 42)}  // 2번 : 위임 변수를 사용한 초기화

    println("person Init : $isPersonInstantiated")
    println("personDelegate Init : ${personDelegate.isInitialized()}")

    println("person.name = ${person.name}")  // 3번 : 초기화
    println("personDelegate.value.name = ${personDelegate.value.name}")  // 4번 : 초기화

    println("person Init: $isPersonInstantiated")
    println("personDelegate Init: ${personDelegate.isInitialized()}")
}
  • 1번에서 by lazy를 사용하여 person 객체를 지연 초기화함 → 객체의 위임
  • 2번에서 lazy만 사용하여 위임 변수를 받아 지연 초기화에 사용함 → 변수에 위임된 Lazy 객체 자체를 나타내므로 value를 거쳐 접근
  • 객체의 프로퍼티나 메서드가 접근되는 시점인 3, 4번에서 초기화 됨

lazy 모드

by lazy(모드) {...} 형태로 사용할 수 있음

  • SYNCHRONIZED : lock을 사용해 단일 스레드만이 사용하는 것을 보장함 (디폴트)
  • PUBLICATION : 여러 군데에서 호출될 수 있으나 처음 초기화된 후 반환값을 사용함
  • NONE : lock을 사용하지 않기 때문에 빠르지만 다중 스레드가 접근할 수 있음 (값의 일관성 보장X)

항상 단일 스레드에서 사용하고 있다는 것이 보장된다면 LazyThreadSaftyMode.NONE을 사용해도 좋지만

따로 동기화 기법을 사용하지 않는다면 다른 모드는 사용하는 것을 권장하지 않음

 

by를 이용한 위임

  • 위임 : 어떤 특정 일을 대신하는 중간자 역할
  • 위임자 : 프로퍼티나 클래스를 대신할 객체

클래스에서 by를 사용하면 위임된 클래스가 가지는 멤버를 참조 없이 호출할 수 있음

프로퍼티에서 by를 사용하면 getter/setter를 특정 객체에게 위임하고 그 객체가 값을 읽거나 쓸 때 수행하도록 만드는 것

    < val|var|calss > 프로퍼티명/클래스명: 자료형 by 위임자

 

클래스의 위임

위임을 사용하는 이유?

코틀린이 가지고 있는 표준 라이브러리는 final 형태의 클래스이므로 상속이나 직접 클래스의 기능 확장이 어려움

이렇게 어렵게 만들어 둠으로써 표준 라이브러리의 무분별한 상속에 따른 복잡한 문제를 방지함

∴ 필요한 경우에만 위임을 통해 상속과 비슷하게 해당 클래스의 모든 기능을 사용하면서 동시에 기능을 추가 확장 구현 할 수 있음

interface Animal {
    fun eat() { ... }
    ...
}
class Cat : Animal { }
val cat = Cat()
class Robot : Animal by cat
  • Animal 인터페이스를 구현하고 있는 Cat 클래스가 있음
  • Animal에서 정의하고 있는 Cat의 모든 멤버를 Robot 클래스로 위임할 수 있음
    • Robot은 Cat이 가지는 모든 Animal의 메소드를 갖게 됨 : 클래스 위임
    • Cat은 Robot 클래스 안에 저장된 Animal의 자료형의 private 멤버 → Cat에서 구현된 모든 Animal의 메서드는 정적 메서드로 생성
    • 따라서 Animal을 명시적으로 참조하지 않고도 eat()을 바로 호출할 수 있음

프로퍼티 위임과 by lazy

lazy도 by laby {...} 처럼 by가 사용되어 위임된 프로퍼티가 사용되었다는 것을 알 수 있음

lazy는 람다식 → 사용된 프로퍼티는 람다식에 전달되어 사용됨

< lazy의 동작 > 

  1. lazy 람다식은 람다식을 전달받아 저장한 Lazy<T> 인스턴스를 반환함
  2. 최초 프로퍼티의 getter 실행은 lazy에 넘겨진 람다식을 실행하고 결과를 기록함
  3. 이후 프로퍼티의 getter 실행은 이미 초기화되어 기록된 값을 반환함

by lazy에 의한 지연 초기화는 스레드에 좀 더 안정적으로 프로퍼티를 사용할 수 있음

ex) 프로그램 시작 시 큰 객체가 있다면 초기화할 때 모든 내용을 시작 시간에 할당해야 하므로 느려질 수 밖에 없음

→ 이것을 필요에 따라 해당 객체를 접근하는 시접에서 초기화하면 시작할 때마다 프로퍼티를 생성하느라 소비되는 시간을 줄일 수 있음

 

observable()와 vetoable()의 위임

observable()와 vetoable()를 사용하기 위해선 코틀린 패키지의 Delegates를 import 해야 함

public object Delegates {
    ...
    // initialValue : 프로퍼티의 초기값
    // onChange : 프로퍼티 변경된 후 호출되는 콜백
    public inline fun <T> observable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Unit):
            ReadWriteProperty<Any?, T> =
        object : ObservableProperty<T>(initialValue) {
            override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) = onChange(property, oldValue, newValue)
        }

    // initialValue : 프로퍼티의 초기값
    // onChange : 프로퍼티 값의 변경이 시도되기 전 호출
    // 따라서 이 콜백이 호출되었을 때 프로퍼티는 아직 변경되지 않음
    // 콜백이 true를 반환하면 프로퍼티는 새로운 값이 지정된 것이고
    // false를 반환하면 프로퍼티의 새 값은 취소되고 기존 old 값을 유지함
    public inline fun <T> vetoable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Boolean):
            ReadWriteProperty<Any?, T> =
        object : ObservableProperty<T>(initialValue) {
            override fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = onChange(property, oldValue, newValue)
        }

}
  • observable() : 프로퍼티를 감시하고 있다가 특정 코드의 로직에서 변경이 일어날 때 호출되어 처리됨
    • 특정 변경 이벤트에 따라 호출되므로 콜백이라고도 부름
    • 프로퍼티를 위임하는 object인 Delegates로부터 사용할 수 있는 위임자
    • 초기값을 위한 initialValue가 있으며, 프로퍼티 값이 변경될 때 호출하는 콜백인 onChange()가 있음
  • vetoable() : 반환값에 따라 프로퍼티 변경을 허용하거나 취소할 수 있음
    • 초기값을 위한 initalValue가 있으며, onChage()의 람다식에 Boolean을 사용하고 있음
    • true일 때 새로운 값이 지정되고 false이면 기존 oldValue를 유지함

observable()의 사용 방법

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("NONAME") {  // 프로퍼티 위임
        prop, old, new ->  // 람다식 매개변수로 프로퍼티, 기존 값, 새로운 값 지정
        println("$old -> $new")  // 3번 : 이벤트가 발생할 때만 실행
    }
}

fun main() {
    val user = User()
    user.name = "Jun"  // 1번 : 값이 변경되는 시점에서 첫 번째 이벤트 발생
    user.name = "Jen"  // 2번 : 값이 변경되는 시점에서 두 번째 이벤트 발생
}
  • User 클래스의 name 프로퍼티를 oberservable() 함수로 위임
    • 초기값 initialValue는 "NONAME"
  • 1번과 2번처럼 변경이 일어나면 3번의 문장을 실행함
  • 값의 변경이 일어나는 시점은 name에 새로운 값을 설정할 때임 → 이때 감시 역할을 하는 observable()의 코드가 실행

vetoable()의 사용 방법

import kotlin.properties.Delegates

fun main() {
    var max: Int by Delegates.vetoable(0) {
        prop, old, new ->
        new > old
    }
    
    println(max)  // 0
    max = 10
    println(max)  // 10
    
    max = 5
    println(max)  // 10
    
}
  • Delegates.vetoable(0)에 의해 초기값은 0
  • 기존 값보다 새 값이 커야 true가 되는 람다식을 가지고 있음
    • 따라서 max가 5일 때는 람다식이 false가 되면서 기존 값을 유지함

 


정적 변수와 컴패니언 객체

728x90

댓글