코틀린의 프로퍼티는 자바의 필드와 getter, setter 메서드를 합친 개념입니다.
자바에서는 클래스의 멤버 필드와 접근 메서드(getter, setter)를 따로 만들어 줘야 했습니다.
자바 방식의 단점은 필드가 추가 될수록 코드가 너무 길어져서 가독성을 떨어뜨린다는 것입니다.
코틀린은 프로퍼티를 사용하여 자바와 같은 내용을 간결하게 표현할 수 있습니다.
[java]
public class Person{
private int age;
private String name;
public Person(int _age, String _name) { this.age = _age; this.name = _name; }
public String getName(void) { this.name; }
public int getAge(void) { this.age; }
public void setName(String _name) { this.name = _name; }
public void setAge(int _age) { this.age = _age; }
}
[Kotlin]
class Person(var age: Int, var name: String)
코틀린은 클래스에서 프로퍼티를 선언하면 내부적으로 getter와 setter를 만들어줍니다.
우리 눈에는 보이지 않지만 클래스의 프로퍼티로 접근하게 되면 그에 맞는 getter와 setter 메서드가 호출 됩니다.
var로 선언된 프로퍼티는 getter, setter 모두 생성되지만 val로 선언된 프로퍼티는 getter만 생성됩니다.
class Person(var age: Int, var name: String)
fun main() {
val person = Person(10, seongjki)
val age = person.age // == person.getAge()
person.age = 3 // == person.setAge(3)
}
[var 프로퍼티]
get() { 본문 } OR get() = 본문
set(value) { 본문 } OR set(value) = 본문
getter와 setter를 위의 형식에 맞게 직접 지정해줄수도 있다.
해당 프로퍼티로 접근할 때마다 각각 getter와 setter에 해당하는 본문이 실행된다.
이제 getter와 setter를 지정하지 않았을때, 기본으로 생성되는 본문을 만들어보자.
[var 프로퍼티]
get() = field
set(value) { field = value }
코드를 보면, getter와 setter 모두 field에 접근하여 값을 가져오고 변경하고 있다. 이 field는 무엇일까 ?
field는 프로퍼티를 참조하는 변수로 Backing field라고 합니다.
이는 getter와 setter 본문에서 자기 자신의 값에 접근할 때 사용됩니다. 왜 따로 field를 사용해서 접근해야 할까요?
[var 프로퍼티 이름]
get() = 프로퍼티 이름
set(value) { field = value }
위의 예제처럼 getter 본문 내에서 프로퍼티 이름으로 접근하면 또 getter를 호출하는 무한재귀에 빠지게 됩니다. 이런 문제가 발생하기 때문에 getter와 setter 본문 내에서는 field를 사용하여 자기 자신의 값에 접근합니다.
프로퍼티도 오버라이딩이 가능합니다. 가시성 변경자를 open으로 변경하고 파생 클래스에서 override 키워드를 사용하여 오버라이딩 할 수 있습니다.
프로퍼티를 오버라이딩하면 각 getter와 setter를 재정의하여 부모 프로퍼티의 getter, setter와 다르게 동작합니다.
open class First(open var value: Int)
class Second(value: Int): First(value) {
override var value= value
get() = field + 10
set(value) { }
}
fun main() {
val first = First(10)
val second = Second(10)
println(first.value) // 10
println(second.value) // 20
}
클래스에서 프로퍼티를 선언하면 반드시 초기화를 해줘야합니다. 그렇지 않으면 컴파일 에러가 발생할 것이라는 경고를 IDE에서 확인할 수 있습니다.
open class First(value: Int) {
open var value: Int // Property must be initialized or be abstract
}
하지만 프로퍼티가 다른 클래스와 의존관계에 있어서 당장 초기화 하지 못하는 경우가 생길수도 있습니다.
그럴때 지연 초기화를 사용할 수 있습니다.
lateinit은 var로 선언된 변수에만 사용할 수 있습니다.
lateinit 키워드를 사용하면 변수를 선언만 해놓고 초기화 하지 않아도 컴파일 에러를 발생시키지 않습니다. 하지만 적절한 시기에 초기화 해주지 않으면 런타임 에러가 발생 할 수 있습니다.
lateinit을 사용한 예제를 살펴보겠습니다.
class Car(var name: String) {
lateinit var owner: Person
}
class Person(var age: Int, var name: String) {
lateinit var car: Car
fun drive() {
if (!::car.isInitialized) {
println("i don't have a car")
return
}
println("drive ${car.name}")
}
}
fun contract(person: Person, car: Car) {
person.car = car
car.owner = person
}
fun main() {
val car = Car("붕붕")
val person = Person(28, "seongjki")
person.drive()
contract(person, car)
person.drive()
}
Person 클래스의 car 프로퍼티는 Car 객체가 생성되기 전까지는 초기화 될 수 없습니다. 그렇기 때문에 lateinit 키워드를 사용하여 Car 객체가 생길 때까지 초기화를 지연시켜줍니다.
drive 함수를 보면 isInitialized를 사용하고 있습니다. isInitialized를 사용하면 lateinit 키워드를 사용한 프로퍼티가 초기화 되었는지 확인할 수 있습니다.
isInitialized는 같은 클래스에 있을 때, inner class에서 outer class에 접근할 때, 같은 파일 내에 top-level에 선언됐을때 만 사용 가능합니다.
Delegation(위임)을 사용하면 상속이 불가능한 클래스에 추가 기능을 제공할 수 있다. 코틀린은 불필요한 코드 없이 언어 차원에서 위임을 지원합니다.
코틀린에서 위임은 클래스 위임과 프로퍼티 위임 두 가지가 있습니다.
위임은 by 키워드를 사용하여 이루어집니다.
클래스 위임의 기본 문법
class [클래스 이름]: [인터페이스 이름] by [위임할 클래스 이름]
클래스 위임을 사용하면 인터페이스의 구현을 위임 받은 클래스에서 가져와서 사용합니다.
그렇기 때문에 클래스 본문 내에서 따로 인터페이스를 구현할 필요가 없습니다. 하지만 위임 받은 구현과 다른 동작을 원할때는 그 메서드를 오버라이딩하면 위임 받은 구현이 가려지고 새로운 구현을 사용하게 됩니다.
interface Test {
fun test()
}
class TestImpl: Test {
override fun test() = println("test")
}
class TestImpl2: Test {
override fun test() = println("test2")
}
class DelegateTest(test: Test): Test by test {
// 동작을 바꾸고 싶다면...
// override fun test() = println("test3")
}
/*
위임은 아래와 같은 불필요한 코드를 없애준다.
class DelegateTest(test: Test) {
val delegate = test
fun test() = delegate.test()
}
*/
fun main() {
DelegateTest(TestImpl()).test() // test
DelegateTest(TestImpl2()).test() // test2
}
위임 프로퍼티의 기본 문법
val or var [프로퍼티 이름] by [위임 클래스]
위임 프로퍼티는 접근자(getter, setter)의 구현을 다른 클래스에 위임한다.
그렇기 때문에 by 뒤에 오는 위임 클래스는 getValue 메서드와 setValue 메서드가 반드시 있어야한다. (val일땐 getValue만 있어도 됨)
간단한 예제를 살펴보자.
class DelegateInt(var num: Int) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): Int {
println("getter called")
return num + 5
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
println("setter called")
num = value
}
}
class IntTest(num: Int) {
var num by DelegateInt(num)
}
val intTest = IntTest(3)
intTest.num.also { println("num: $it") } // getter called 8
intTest.num = 10 // setter called
intTest.num.also { println("num: $it") } // getter called 15
IntDelegate 클래스는 getValue와 setValue 메서드를 가지고 있기 때문에 위임 프로퍼티의 위임 객체가 될 수 있습니다.
예제에서 IntTest 클래스의 num 프로퍼티의 위임 객체로 IntDelegate 클래스가 사용되었습니다.
main문에서 num프로퍼티에 접근하면 각 getter와 setter가 위임 객체의 메서드로 호출 되는것을 확인할 수 있습니다.
lazy 함수는 지연 초기화를 지원하기 위한 코틀린 표준 함수입니다.
lazy 함수의 선언은 다음과 같습니다.
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
인자로 람다함수를 받고 람다 함수의 반환 타입에 대한 위임 객체를 반환합니다. 이제 코드를 자세히 살펴보겠습니다.
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this
override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
lazy 함수는 기본적으로 Lazy 인터페이스를 구현한 SynchronizedLazyImpl을 반환합니다. (인자에 따라 반환 객체는 달라집니다. 여기서는 기본값인 SynchronizedLazyImpl만 다룹니다.)
SynchronizedLazyImpl 클래스는 _value, initializer, lock, value 네 개의 프로퍼티를 가지고 있습니다.
각자의 역할은 다음과 같습니다.
SynchronizedLazyImpl의 작동 원리는 단순합니다. value 프로퍼티의 getter를 오버라이딩하여 getValue 메서드를 제공합니다. 즉, 지연 초기화 된 객체에 접근하면 value의 getter가 실행됩니다.
이제 get() 메서드를 살펴봅시다.
_value의 값이 초기화 되지 않은 초기값인지 아닌지를 확인합니다. 초기화가 되었다면 _value를 반환합니다.
그렇지 않다면 이제 객체를 초기화 해줘야 합니다. 하지만 SynchronizedLazyImpl은 스레드 세이프하기 때문에 한 번에 여러 스레드가 이 객체에 접근하면 안됩니다. 그래서 synchronized 블럭을 통해 동기화를 해주고 있습니다.
sychronized 블럭에서도 _value가 초기화 됐다면 _value를 리턴하는 로직이 있고 그 아래에서 초기화 되지 않으면 초기화를 진행한 후 리턴해주는 부분이 있습니다.
여기서 한번 더 초기화 여부를 확인해주는 이유는 이미 초기화가 되었는데 또 초기화하는 로직이 실행되지 않게 하기 위함입니다.
이제 lazy 함수를 사용하여 지연 초기화를 사용해보겠습니다.
class Person(var age: Int, var name: String)
fun main() {
val delegateObj = lazy { Person(1, "seong") }
val person by delegateObj
// val person by lazy { Person(1, "seong") }
println(delegateObj.isInitialized()) // false
println(person.name) // seong
println(person.age) // 1
println(delegateObj.isInitialized()) // true
}
person 변수를 lazy를 사용하여 지연초기화 했습니다.
현재 person은 초기화가 되지 않은 상태입니다. 그래서 isInitialzed 메서드를 사용해보면 false가 반환됨을 확인할 수 있습니다.
여기서 person에 접근하면 이 시점에 getter가 실행되며 초기화가 이루어집니다. 초기화가 잘 이루어졌기 때문에 각 프로퍼티에 해당하는 값이 출력됩니다.
또 isInitialized 메서드의 결과도 true로 출력되는 것을 확인할 수 있습니다.
정적 변수는 정적으로 할당되는 변수를 의미합니다. 이러한 정적변수는 프로그램의 시작부터 끝까지 유지된다는 특징이 있습니다.
일반적인 클래스의 객체 생성 없이 정적 변수나 메서드를 사용하면 프로그램 실행 시 메모리를 갖게 되며 모든 클래스들이 공유하여 사용할 수 있습니다.
코틀린에서는 정적 변수를 사용할 때, static 키워드 대신 companion object를 사용합니다.
class CompanionPrac {
val nonStatic = 0
companion object {
val staticInt = 1
val staticChar = 'a'
val staticStr = "abcd"
}
}
fun main() {
// println(CompanionPrac.nonStatic) // error
println(CompanionPrac.staticInt) // 1
println(CompanionPrac.staticChar) // a
println(CompanionPrac.staticStr) // abcd
}
companion object 내에 선언한 staticXXX 프로퍼티는 정적으로 할당되어 객체를 생성하지 않아도 접근할 수 있습니다. 하지만 nonStatic 프로퍼티는 객체를 생성해야만 접근이 가능합니다.
자바에서 코틀린 컴패니언 객체에 접근하기 위해서는 @JvmStatic 어노테이션을 사용하거나 Companion을 포함해야합니다.
public class Access {
public static void main(String[] args) {
System.out.println(CompanionPrac.getStaticInt());
System.out.println(CompanionPrac.getStaticChar());
System.out.println(CompanionPrac.getStaticStr());
// System.out.println(CompanionPrac.Companion.getStaticInt());
// System.out.println(CompanionPrac.Companion.getStaticChar());
// System.out.println(CompanionPrac.Companion.getStaticStr());
// System.out.println(CompanionPrac.Companion.getPerson()); // Error
System.out.println(CompanionPrac.staticPerson.getAge());
}
}
@JvmStatic 어노테이션을 사용하면 Companion 표기를 생략할 수 있습니다.
@JvmField 어노테이션을 사용하면 해당 프로퍼티에 대한 getter와 setter를 컴파일러가 생성하지 않고 필드로 노출시킵니다.
main 함수처럼 클래스 내부나 함수의 내부에 존재하지 않고 최상위에 존재하는 함수를 최상위 함수라고 합니다.
하지만 자바에서는 클래스 외부에 함수가 존재할 수 없습니다. 최상위 함수를 자바 바이트 코드로 디컴파일 해보면 함수가 존재하는 파일의 이름으로 클래스가 생기는 것을 확인할 수 있습니다.
그렇기 때문에 자바에서 코틀린의 최상위 함수를 호출할 때, 자동으로 생성되는 클래스 이름을 붙여서 호출해줘야 합니다.
// @file:JvmName("TopLevel")
fun topLevelFunc() {
println("this is Top Level")
}
위의 함수를 자바에서 실행해보겠습니다.
public class Access {
public static void main(String[] args) {
StaticMainKt.topLevelFunc(); // this is Top Level
// TopLevel.topLevelFunc();
}
}
자동으로 생성되는 클래스 이름을 변경하고 싶다면 @file:JvmName("클래스명")을 사용할 수 있습니다.
object Object {
val prop1 = 1
val prop2 = 't'
val prop3 = "Three"
fun print() = println("$prop1, $prop2, $prop3")
}
fun main() {
Object.print()
}
public class Access {
public static void main(String[] args) {
Object.INSTANCE.print();
}
}
object로 선언된 객체는 접근 시점에 객체가 생성 됩니다. 컴패니언 객체와 동일하게 객체 이름에 .을 붙여서 접근할 수 있습니다.
object 객체는 생성자를 호출하지 않으므로 생성자를 만들수 없습니다. 대신 init 블럭을 사용하면 초기화 시점에 특정 코드를 실행시킬 수 있습니다.
자바에서 object 객체에 접근할 때는 객체 이름에 INSTANCE를 붙여서 접근이 가능합니다.
object 표현식을 사용하면 익명 객체를 만들수 있습니다. 익명 객체는 생성될 때마다 메모리가 할당됩니다.
object 표현식을 사용하여 하위 클래스를 만들지 않고 멤버 메서드를 오버라이딩 해보겠습니다.
open class Person(var age: Int, var name: String) {
open fun introduce() = println("age: $age, name: $name")
}
fun main() {
val tempPerson = object : Person(10, "Seongjki") {
override fun introduce() {
super.introduce()
println("Override Func")
}
}
tempPerson.introduce()
// age: 10, name: Seongjki
// Override Func
}
tempPerson은 익명객체를 통해 Person을 상속받고 멤버 메서드인 introduce를 오버라이딩하고 있습니다.
object 표현식을 사용하여 하위 클래스를 생성하지 않고 간단하게 오버라이딩을 했습니다.
Do it 코틀린 프로그래밍(2021, 황영덕)
코틀린 인 액션(2017, 드미트리 제메로프, 스베트라나 이사코바, 오현석)
코틀린 공식 문서