[Kotlin] var / val / const val / companion object 특성

이 포스팅은 아래 Kotlin 공식문서의 일부를 번역(의역) + 의견을 추가한 것입니다.

var / val

변수의 정의가 가변(mutable)하는 경우 var 키워드(variable)를 사용하고, read-only일 경우 val 키워드(value)를 사용한다. 변수의 값이 가변하는 경우가 아니다. 예를 들어, area의 값은 width와 height에 따라 변하지만 정의는 변하지 않는다.

1
val area: Int = this.width * this.height

코틀린에서 backing field는 단지 메모리에 값을 유지하기 위해서만 사용되는 것에 불과하다. field는 직접 선언될 수 없으며 변수의 setter나 getter 내에서만 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
var counter = 0 // initializer가 field에 직접 값을 할당한다.
set(value) {
if (value >= 0) {
field = value
/* 'counter'라는 실제 변수명을 사용하는 건
setter를 다시 부르는 일이기 때문에 StackOverflow Error가 난다.
*/
// counter = value
}
}

이런 식의 암시적인 field 체계 대신 아래와 같이 backing 변수를 만들 수도 있다.

1
2
3
4
5
6
7
8
private var _table: Map<String, Int>? = null
public val table: Map<String, Int>
get() {
if (_table == null) {
_table = HashMap() // 여기서 파라미터의 타입이 추론된다.
}
return _table ?: throw AssertionError("Set to null by another thread")
}

const val

컴파일 타임 상수이다. 다음 조건을 모두 충족시키는 경우에 해당한다.

  • 최상위 레벨 속성이거나 object나 companion object의 멤버여야 한다.
  • String이나 원시(primitive) 타입으로 초기화되어야 한다.
  • 커스텀 getter를 사용할 수 없다.

컴파일 시 inline 형태로 이 변수를 변수 대신 실제 값으로 대치한다. 그러나 필드가 사라지는 건 아니라서 relection으로 상호작용은 여전히 가능하다.

1
const val SUBSYSTEM_DEPRECATED: String = "this subsystem is deprecated"

companion object(동반 객체)

class 내부에 object 선언은 “companion” 키워드를 써서 할 수 있다.

1
2
3
4
5
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}

companion object의 멤버는 단순하게 클래스명을 통해 호출할 수 있다.

1
val instance = MyClass.create()

companion object의 이름은 빼도 되고, 이때는 Companion으로 접근한다.

1
2
3
4
class MyClass {
companion object { }
}
val x = MyClass.Companion

클래스 멤버는 companion object 내의 private 멤버에 접근할 수 있다.

-

[Kotlin] Ticker(티커) 모드 차이(TickerMode.FIXED_PERIOD, FIXED_DELAY)

개발 환경: Kotlin 1.6

coroutines 라이브러리에는 **티커(ticker)**라고 하는 특별한 랑데부 채널이 있다. 이 채널은 Unit 값을 계속 발생시키되 한 원소와 다음 원소의 발생 시점이 주어진 지연 시간만큼 떨어져 있는 스트림을 만든다.

여기서 랑데부 채널이란 내부 버퍼가 없어 이 채널에서의 send() 호출은 다른 어떤 코루틴이 receive()를 호출할 때까지 항상 일시 중단된다. 마찬가지로 receive() 호출은 다른 어떤 코루틴이 send()를 호출할 때까지 일시 중단되는 특성을 가진다.
즉, 랑데부 채널은 생산자와 소비자 코루틴이 교대로 활성화되도록 보장한다.

티커 채널을 만들려면 ticker()라는 함수를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
val ticker = ticker(100) // TickerMode.FIXED_PERIOD
println(withTimeoutOrNull(50) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
delay(250)
println(withTimeoutOrNull(1) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
}
1
2
3
4
5
null
kotlin.Unit
kotlin.Unit
kotlin.Unit
null

TickerMode.FIXED_PERIOD

  1. 0ms ~ 50ms(타임아웃): 50ms(밀리초) 내에 티커 신호를 받으려고 시도하나, 티커 지연 시간이 100ms이므로 withTimeoutOtNull()은 신호를 받지 못하고 타임아웃이 걸려 널을 반환한다.

  2. 50ms ~ 100ms: 타임아웃이 1회 난 후 다음 60ms 안에 신호를 받으려고 시도한다. 그리고 이번에는 50 + 60ms가 100ms 보다 길기 때문에 결괏값을 얻는다. receive()가 호출되면 티커가 재개된다.

    2_1. 100ms ~ 350ms: 이때 소비자 코루틴이 약 250ms 동안 일시 중단된다. 일시 중단으로부터 100ms 후에 티커는 다른 신호를 보내고 신호가 수신될 때까지 일시 중단된다. 그리고 소비자 코루틴과 티커 코루틴 모두 150ms 동안 일시 중단 상태로 남는다.

  3. 350ms: 소비자 코루틴이 재개되고 신호를 요청하려고 시도한다. 신호가 이미 보내졌기 때문에 receive()는 즉시 결과를 반환한다.

    3_1. 이제 티커는 마지막 신호를 보내고 나서 얼마나 시간이 지났는지 검사하고(250ms), 지연 시간을 50ms로 줄인다.

  4. 350ms ~ 400ms: 소비자는 50ms 타임아웃 안에 신호를 받으려고 시도한다. 다음 신호가 50ms 이전에 보내졌기 때문에 이 시도는 거의 확실히 성공할 것이다.

  5. 400ms ~ 460ms(타임아웃): 마지막으로, 신호를 받으려는 receive() 호출이 거의 즉시 일어난다. 따라서 티커는 전체 지연 시간(100ms)를 다시 기다린다. 그 결과, 마지막 receive() 호출은 60ms 타임아웃 안에 티커로부터 신호를 받지 못하기 때문에 널을 받는다.

티커 모드를 FIXED_DELAY로 고정하면 결과가 다음과 같이 바뀐다.

1
2
3
4
5
null
kotlin.Unit
kotlin.Unit
null
kotlin.Unit

TickerMode.FIXED_DELAY

초반부는 앞의 예제와 비슷하게 진행된다. 하지만 250밀리초의 긴 지연 이후 소비자 코루틴이 재개될 때부터는 동작이 달라진다.

  1. 350ms: 소비자 코루틴이 재개되고 신호를 요청하려고 시도한다. 신호가 이미 보내졌기 때문에 receive()는 즉시 결과를 반환한다.

    3_1. receive()로 결과를 넘긴 시점에서 티커는 현재시간을 고려하지 않고 여기서부터 100ms를 다시 기다린다.

  2. 350ms ~ 410ms(타임아웃): 티커가 신호를 보내려면 40ms 남았으므로 널을 받는다.

  3. 410ms ~ 450ms: 3_1에서 티커가 재개된 시간으로부터 100ms가 지났으므로 결과를 무사히 반환받는다.

참고
알렉세이 세두노프 <코틀린 완벽 가이드>

자바와 코틀린에서의 동등성 비교(==, ===, equals())

  • 동일성(Identity): 참조 주소를 비교
  • 동등성(Equality): 내용 기반 비교

자바에서의 참조타입 동작

==와 != 연산자는 참조 동등성을 뜻하며, 내용을 기반으로 하는 동등성은 equals() 호출을 통해 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.*;

public class Main
{
public static void main(String[] args) {
String[] arr1 = {"1", "2"};
String[] arr2 = {"1", "2"};
System.out.println("arr1 address: " + arr1 + " / arr2 address: "+ arr2);

if (arr1 == arr1) {
System.out.println("array Identity");
}
if (arr1.equals(arr2)) {
System.out.println("array Equality");
}

Data d1 = new Data("x");
Data d2 = new Data("y");
ArrayList<Data> list1 = new ArrayList();
list1.add(d1);
list1.add(d2);
ArrayList<Data> list2 = new ArrayList();
list2.add(d1);
list2.add(d2);
System.out.println("list1 address: " + list1 + " / list2 address: "+ list2);

if (list1 == list2) {
System.out.println("list Identity");
}
if (list1.equals(list2)) {
System.out.println("list Equality");
}
}
}

class Data
{
Data(String a) {
this.a = a;
}
private String a = null;
}

/** 출력
array Identity
list Equality
*/

참고로 new ArrayList()로 리스트 객체를 생성하는 게 아니라 list를 대입하게 되면 주소값이 동일해진다.

1
2
3
4
5
6
7
8
9
10
11
list1 = list2;
if (list1 == list2) {
System.out.println("list Identity");
}
if (list1.equals(list2)) {
System.out.println("list Equality");
}
/** 출력
list Identity
list Equality
*/

반면, 코틀린에서의 참조타입 동작

연산자 형태(== 또는 !=)로 호출하는 경우 equals()를 호출하기 때문에 두 방식은 같은 결과가 나온다.
참조 동등성을 비교하려면 === 또는 !== 연산자를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
fun main() {
val dataclass1: DataClass = DataClass()
val dataclass2: DataClass = DataClass()
val list1: List<DataClass> = listOf(dataclass1, dataclass2)
val list2: List<DataClass> = listOf(dataclass1, dataclass2)

if (list1 == list2) println("list used data class == Equality")
if (list1.equals(list2)) println("list used data class equals() Equality")
if (list1 === list2) println("list used data class Identity")

val data1: Data = Data()
val data2: Data = Data()
val list3: List<Data> = listOf(data1, data2)
val list4: List<Data> = listOf(data1, data2)

if (list3 == list4) println("list used class == Equality")
if (list3.equals(list4)) println("list used class equals() Equality")
if (list3 === list4) println("list used class Identity")
}

data class DataClass(val x: String = "x")
class Data(val y: String = "y")

/** 출력
list used data class == Equality
list used data class equals() Equality
list used class == Equality
list used class equals() Equality
*/

String 변수의 동작

변수 타입은 기본형(Primitive type)과 참조형(Reference type)이 있다. 그 중 String은 참조형이지만 기본형처럼 쓰이는데 위의 예제와 동작이 약간 다르다. 자바에서는 String str1 = "a"와 같이 일반적인 대입 방식으로 값을 초기화하면 String str2 = "a"처럼 같은 값(“a”)을 가진 변수는 같은 주소를 참조하게 된다. 따라서 아래 예제에서 ==와 equals 동작이 동일하게 true로 출력된다.
그리고 같은 값이라도 이 참조 주소를 다르게 설정하고자 new String()으로 객체를 생성하게 되는 경우 참조 동등성(==)을 비교하면 false가 반환된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String str1 = "a";
String str2 = "a";

if (str1 == str2) System.out.println("string Identity");
if (str1.equals(str2)) System.out.println("string Equality");

String strNew1 = new String("b");
String strNew2 = new String("b");

if (strNew1 == strNew2) System.out.println("stringNew Identity");
if (strNew1.equals(strNew2)) System.out.println("stringNew Equality");

/** 출력
string Identity
string Equality
stringNew Equality
*/

코틀린에서는 new 키워드가 없어 다음 세 가지를 비교하면 이러하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() {
val str1: String = "a"
val str2: String = "a"

if (str1 == str2) println("string == Equality")
if (str1.equals(str2)) println("string equals() Equality")
if (str1 === str2) println("string Identity")
}
/** 출력
string == Equality
string equals() Equality
string Identity
*/

Nullable 값

자바에서는 NPE를 방지하기 위해 equals()에서도 수신 객체가 널인 경우 악명높은 NPE(Null Point Exception) 오류가 나기 때문에 null이 아님을 보장하기 위해 obj != null과 같이 조건문을 추가해 주어야 한다.

1
2
String obj = null;
if (obj.equals(something)) {} // NPE

반면, 코틀린에서는 ==와 != 두 연산자로 비교하는 값이 null이라도 오류가 나지 않는다. 연산자의 왼쪽 피연산자가 null인 경우에는 참조 동등성을 사용해 널과 비교하면 된다.

참고
알렉세이 세두노프 <코틀린 완벽 가이드>