Note:이 연재는 'Do it! Kotlin Programming'에서 일부의 내용이 연재되고 있습니다. 완전한 내용은 책을 참고해 주세요!
코틀린은 변수를 사용할 때 반드시 값이 할당되어 있어야 한다는 원칙이 있습니다. 만약 값이 할당되지 않은 변수를 사용하면 코틀린에서 오류가 발생합니다. 한편 값이 없는 상태는 null이라고 부릅니다. 앞으로 값이 없는 상태라는 말 대신 null이라는 용어를 사용하겠습니다. 코틀린에서는 null 상태인 변수를 허용하려면 물음표(?) 기호를 사용해 선언 해야 합니다. 이때 null을 검사하고 처리하는 방법이 필요합니다. 또한 필요하면 자료형을 변환할 수도 있습니다.
프로그램이 실행되는 도중에 값이 null인 변수에 접근하려 하면 null 포인터 예외(NPE, NullPointerException) 오류가 발생합니다. 이 문제는 아주 오래 전부터 프로그래머의 머리를 아프게 만들었죠. 그런데 코틀린은 변수에 아예 null을 허용하지 않아 이 문제를 초기에 방지할 수 있습니다. 좀 더 안전한 프로그래밍을 할 수 있게 된 것이죠. 다음 실습을 통해 null과 자료형 검사에 대해 알아보겠습니다.
Tip:앞으로 null 포인터 예외를 NPE라고 줄여 부르겠습니다.
다음은 변수에 null을 할당한 것입니다. 코틀린은 변수에 null을 허용하지 않는다고 했죠? 다음과 같이 코드를 작성하면 IntelliJ IDEA에서 빨간 줄로 표시하여 프로그래머에게 알려줍니다. 다음과 같이 String형인 str1
변수에 null 값을 할당하는 코드를 직접 입력하여 정말 빨간 줄이 표시되는지 확인해 보세요.
null 처리하기 | NullTest.kt |
package chap02.section3
fun main() {
var str1 : String = "Hello Kotlin"
str1 = null // null을 허용하지 않음(오류 발생)
println("str1: $str1")
}
Result |
Error:(5, 12) Kotlin: Null can not be a value of a non-null type String
만약 위의 코드를 실행하면 null 값이 할당될 수 없다는 오류 메시지가 나타납니다. 변수에 null 할당을 허용하려면 자료형 뒤에 물음표 기호(?)를 명시해야 합니다. 위의 코드에서 자료형에 물음표 기호를 추가하고 다시 실행해보세요.
null 처리하기 | NullTest.kt |
package chap02.section3
fun main() {
var str1 : String? = "Hello Kotlin"
str1 = null
println("str1: $str1")
}
Result |
Run
이제 str1 변수에 null을 할당할 수 있게 되었습니다. null 허용 여부에 따라서 String
과 String?
는 서로 다른 자료형임을 확실히 이해하여야 합니다.
null을 허용하게 되면 null을 사용할 수 없는 함수에는 사용할 수 없게 됩니다. 예를 들어 프로그램 실행 도중 문자열의 길이를 구하기 위하여 str1.length를 사용하면 어떻게 될까요? str1 변수에 문자열이 할당되어 있는 상태라면 길이를 구할 수 있겠지만 null이 할당되어 있는 상태라면 문자열의 길이를 구할 때 NPE가 발생할 것입니다. 앞에서 작성한 코드에서 str1.length
를 출력하도록 수정해 보세요.
package chap02.section3
fun main() {
var str1 : String? = "Hello Kotlin"
str1 = null
println("str1: $str1 lenghth: ${str1.length}") // null을 허용하면 length가 실행될 수 없음
}
코드를 수정하면 str1.length
에 입력 커서를 두면 빨간 줄이 표시되고 String?
형에서는 세이프 콜(?.)이나 non-null 단정 기호(!!.)만 허용한다는 팁을 볼 수 있습니다. length에 접근해 사용하려면 이 두가지 기법 중 하나를 사용해야 합니다. 두 방법에 대해 하나씩 알아보겠습니다.
세이프 콜이란 null이 할당되어 있을 가능성이 있는 변수를 검사하여 안전하게 호출하도록 도와주는 기법을 말합니다. 세이프 콜의 사용 방법은 사용할 변수 이름 뒤에 ?.를 작성하면 됩니다. 위 프로그램에 세이프 콜 기호를 추가하여 수정하고 실행해 보세요.
println("str1: $str1 length: ${str1?.length}") // str1을 세이프 콜로 안전하게 호출
프로그램이 잘 실행되죠? 위의 코드는 변수 str1을 검사한 다음 null이 아니면 str1의 멤버 변수인 length에 접근해 값을 읽도록 만든 것입니다. str1을 검사해보니 아무것도 들어있지 않으므로 length
에 접근하지 않고 그대로 null을 출력합니다.
non-null 단정 기호를(!!.) 사용하면 어떻게 될까요? non-null 단정 기호는 null이 아님을 단정하므로 컴파일러가 null검사 없이 무시합니다. 따라서 null이 할당되어 있을지라도 컴파일은 잘 진행되지만 실행 중에는 NPE를 발생시킵니다. 위의 코드를 다음과 같이 수정하고 실행하여 결과를 확인해 보세요. 코딩을 할 때는 오류 메시지가 나타나지 않지만 프로그램을 실행하면 NPE가 발생합니다.
println("str1: $str1 length: ${str1!!.length}") // NPE 강제 발생
세이프 콜이나 non-null 단정 기호를 사용하는 것 대신 조건문으로 null을 허용한 변수를 검사해도 됩니다. 즉, null을 허용한 변수의 null 상태 가능성을 검사하기만 하면 코틀린 컴파일러는 오류를 발생시키지 않습니다. 코드를 다음과 같이 수정하여 실행해 보세요. 조건문에 대해서는 04장 프로그램의 흐름에서 좀 더 자세히 살펴봅니다.
fun main() {
var str1 : String? = "Hello Kotlin"
str1 = null
// 조건식을 통해 null 상태 검사
val len = if(str1 != null) str1.length else -1
println("str1: $str1 length: ${len}")
}
Result |
str1: null length: -1
위의 코드는 조건식(str1 != null)
을 통해 str1 변수에 할당된 값이 null이 아닌 경우에만 str1.length
를 사용하도록 만든 것입니다. 이 코드에서는 str1에 이미 null이 할당되어 있기 때문에 len의 값으로 -1이 호출되었습니다.
null을 허용한 변수를 조금 더 안전하고 간단하게 사용하려면 세이프 콜 ?.와 엘비스(Elvis) 연산자 ?:를 함께 사용하면 됩니다. 엘비스 연산자는 변수가 null인지 아닌지 검사하여 null이 아니라면 왼쪽의 식을 그대로 실행하고 null이라면 오른쪽의 식을 실행합니다. 일단 다음 코드를 입력하고 실행하여 세이프 콜과 엘비스 연산자의 사용 방법을 알아보겠습니다.
세이프 콜과 엘비스 연산자 | SafeCallandElvis.kt |
fun main() {
var str1 : String? = "Hello Kotlin"
str1 = null
println("str1: $str1 length: ${str1?.length ?: -1}") // 세이프 콜과 엘비스 연산자 활용
}
Result |
str1: null length: -1
위의 코드는 str1값이 null이면 -1을 출력합니다. str1값에 "Hi!"라는 문자열로 바꾸고 다시 실행해 보면 length의 길이인 3이 출력됩니다. 이 내용을 그림으로 보면 다음과 같습니다.
(1)번에서 먼저 str1이 null이 아닌 경우 length, null인 경우 null 반환, (2)번에서 다시 (1)번의 결과가 null인 경우 오른쪽 값(-1)을 반환 합니다. 즉, str1?.length ?: -1
이라는 표현은 다음 코드와 동일합니다.
if (str1 != null) str1.length else -1
세이프 콜과 엘비스 연산자를 사용하면 null인 경우 반환값을 -1과 같은 특정값으로 대체해 null 발생을 대비할 수 있으므로 안전하고, 또한 코드를 한 줄에 표현할 수 있어 가독성이 좋아집니다.
코틀린의 자료형은 모두 참조형으로 선언합니다. 하지만 컴파일을 거쳐서 최적화 될 때는 Int, Long, Short와 같은 참조형 자료형은 기본형 자료형으로 변환됩니다. 참조형과 기본형의 저장방식은 서로 다르기 때문에 자료형을 비교하거나 검사할 때는 이와 같은 특징을 이해하고 있어야 합니다.
코틀린에서 서로 다른 자료형을 비교하거나 연산하면 어떻게 될까요? 코틀린에서는 변환 없이 자료형이 서로 다른 변수를 비교하거나 연산할 수 없습니다. 예를 들어 Int로 선언한 변수와 Long으로 선언한 변수를 서로 더하거나 뺄 수 없죠. 코틀린에서는 자료형이 서로 다른 변수를 같은 자료형으로 만들어야 연산할 수 있습니다. 지금부터 자료형의 변환부터 서로 다른 자료형을 가진 변수의 비교까지 알아보겠습니다.
코틀린에서는 자료형이 다르면 변환 함수를 사용해야 하지만 자바에서 자료형이 서로 다를 때 자동적으로 변환됩니다. 다음 자바 코드를 먼저 살펴봅시다.
int a = 1; // 자바의 기본형 int형 변수인 a에 1을 할당
double b = a; // double형 변수인 b의 값으로 a를 할당
위와 같이 a 변수에는 int형을 지정하고, double형인 b 변수의 값으로 a를 할당하면, a가 int형에서 double형으로 자동 형 변환되어 b의 값으로 1이 아닌 1.0이 할당됩니다. 이처럼 자바에서는 서로 다른 자료형을 가진 변수를 할당할 때 작은 자료형에서 큰 자료형으로 변환됩니다. 이것을 자동 형 변환이라고 부릅니다.
코틀린에서는 자료형이 다른 변수에 재할당하면 자동 형 변환이 되지 않고 자료형 불일치 오류(type mismatch)가 발생합니다. 의도하지 않게 자료형이 변하는 것을 방지하기 위한 것이죠. 여기서도 마찬가지로 Double형 변수의 값으로 Int형 변수를 할당해 보겠습니다.
val a: Int = 1 // 정수형 변수 a 를 선언하고 1을 할당
val b: Double = a // 자료형 불일치 오류 발생
val c: Int = 1.1 // 자료형 불일치 오류 발생
변수 a는 정수형이므로 실수형 변수 b에 다시 할당할 수 없습니다. 물론 정수형 자료형인 c에 1.1을 대입하는 것도 안 됩니다. 만일 자료형을 변환해 할당하고 싶다면 코틀린에서는 자료형 변환 메서드를 이용해야 합니다. 위 코드에서는 정수형 변수 a에 명시적으로 실수형으로 변환할 수 있는 toDouble 변환 메서드를 점(.)과 함께 붙여 사용합니다.
val b: Double = a.toDouble // 변환 메서드 사용
만약 표현식에서 자료형이 서로 다른 값을 연산하면 어떻게 될까요? 이 경우에는 자료형이 표현할 수 있는 범위가 큰 자료형으로 자동 형 변환하여 연산합니다.
val result = 1L + 3 // Long + Int의 result는 Long
다음은 코틀린에서 사용할 수 있는 자료형 변환 메서드입니다. 위의 코드를 응용하여 한 번씩 사용해보고 넘어가는 것을 추천합니다.
자료형을 비교할 때는 단순히 값만 비교하는 방법과 참조 주소까지 비교하는 방법이 있습니다. 단순히 값만 비교할 때는 이중 등호(==)를 사용하고 참조 주소를 비교하려면 삼중 등호(===)를 사용합니다. 다음은 이중 등호를 이용하여 값만 비교한 것입니다. 이중 등호는 참조에 상관 없이 값이 동일하면 true를, 값이 다르면 false를 반환합니다. 삼중 등호는 값과 상관없이 참조가 동일하면 true를 반환 합니다. 값이 동일하더라도 참조가 틀리면 false를 반환합니다.
다음은 Int형으로 선언한 변수 a, b에 128을 대입하고 이중 등호와 삼중 등호로 비교한 것입니다. 비교 결과는 모두 true입니다. 이때 참조형으로 선언된 a와 b는 코틀린 컴파일러가 기본형으로 변환하여 저장한다는 점에 주의해야 합니다. 즉, 여기서는 삼중 등호가 비교하는 값도 저장된 값인 128입니다.
val a: Int = 128
val b: Int = 128
println(a == b) // true
println(a === b) // true
그러면 참조 주소가 달라지는 것은 무엇일까요? 예를 들어 null을 허용한 변수는 같은 값을 저장해도 이중 등호와 삼중 등호를 사용한 결괏값이 다릅니다.
val a: Int = 128
val b: Int? = 128
println(a == b) // true
println(a === b) // false
왜 그럴까요? 참조형으로 선언된 a는 기본형으로 변환되어 스택에 128이라는 값 자체를 저장하지만 Int?로 선언된 b는 참조형으로 저장됩니다. 그래서 b에는 128이 저장된 힙의 참조 주소가 저장되어 있습니다. 그래서 a와 b를 삼중 등호로 비교하면 false가 나옵니다.
다음 실습을 통해 이중 등호, 삼중 등호를 사용해 보고 기본형과 참조형 자료형이 실제 메모리에 어떻게 저장되는지 다시 한 번 자세히 설명해 보겠습니다.
이중 등호 비교와 삼중 등호 비교 사용하기 | ValueRefCompare.kt |
package chap02.section3
fun main() {
val a: Int = 128
val b = a
println(a === b) // 자료형이 기본형인 int가 되어 값이 동일 true
val c: Int? = a
val d: Int? = a
val e: Int? = c
println(c == d) // 값의 내용만 비교하는 경우 동일하므로 true
println(c === d) // 값의 내용은 같지만 참조를 비교해 다른 객체(주소 다름)이므로 false
println(c === e) // 값의 내용도 같고 참조된 객체도 동일(주소 동일)하므로 true
}
Result |
true true false true
다음은 위 코드의 메모리 상태를 그림으로 나타낸 것입니다.
a와 b는 참조형인 Int형으로 선언되었지만 코틀린 컴파일러에 의해 기본형으로 변환되어 저장됩니다. 그래서 프로그램을 실행하면 a와 b는 스택에 주소가 아닌 128이라는 값이 저장됩니다. 그래서 이중 등호로 비교해도 true가 나오고 삼중 등호로 비교해도 true가 나옵니다.
null을 허용한 변수 c, d는 참조형으로 저장됩니다. 그래서 c, d에는 a에 들어있는 값인 128이 저장되는 것이 아니라 서로 다른 128을 가리키고 있는 주소:A1, 주소:A2가 저장됩니다. 두 변수가 가리키는 값은 같으니 이중 등호로 비교하면 true입니다. 하지만 삼중 등호로 비교하면 두 변수의 참조 주소가 달라 false입니다.
null을 허용한 e도 조금 다릅니다. 참조형으로 만들어진 e에는 c의 참조 주소인 주소:A1이 저장됩니다. 그래서 c와 e의 이중 등호와 삼중 등호로 비교한 값은 모두 true입니다.
Note: 저장되는 값이 작으면 그 값은 캐시에 저장되어 참조
코틀린에서는 참조형으로 선언한 변수의 값이 -128~127 범위에 있으면 캐시에 그 값을 저장하고 변수가 캐시의 주소를 가리키도록 합니다. 이렇게 하면 더 좋은 성능의 프로그램을 만들 수 있기 때문이죠. 예를 들어 var a: Int = 28과 var b: Int = 28이라고 변수를 선언하면 28이라는 값은 스택이 아니라 캐시에 저장됩니다. 그리고 a와 b는 캐시의 주소를 가리키게 됩니다. 따라서 위 예제에서 a의 값을 128이 아니라 -128~127 사이의 값으로 변경하면 참조 주소의 값이 c, d와 같아집니다. 그래서 a, b, c, d를 삼중 등호로 비교한 값은 모두 true가 되므로 주의합니다.
만약 어떤 값이 정수일 수도 있고 실수일 수도 있다면 어떻게 해야 할까요? 그때마다 자료형을 변환해도 되지만 스마트 캐스트(smart cast)를 사용하는 것이 더 편리합니다. 대표적으로 스마트 캐스트가 적용되는 자료형은 Number형이 있습니다. Number형을 사용하면 숫자를 저장하기 위한 특수한 자료형 객체를 만듭니다. Number형으로 정의된 변수에는 저장되는 값에 따라 정수, 실수 등으로 자료형이 변환됩니다. 다음은 Number형과 함께 스마트 캐스트를 사용한 예제입니다. 지금은 Number형으로 정의된 변수에 12.2라는 값을 저장하고 Float형, Int형, Long형으로 스마트 캐스트하는 과정만 살펴보면 됩니다. Number형에 대해서는 나중에 더 자세히 설명하겠습니다.
스마트 캐스트 사용해 보기 | NumberTest.kt |
package chap02.section3
fun main() {
var test: Number = 12.2 // 12.2에 의해 test는 Float형으로 스마트 캐스트
println("$test")
test = 12 // Int형으로 스마트 캐스트
println("$test")
test = 120L // Long형으로 스마트 캐스트
println("$test")
test += 12.0f // Float형으로 스마트 캐스트
println("$test")
}
Result |
12.2 12 120 132.0
그러면 변수의 자료형을 알아내는 방법은 무엇일까요? 그럴 때는 is를 사용하면 됩니다. is는 왼쪽항의 변수가 오른쪽항의 자료형과 같으면 true를 아니면 false를 반환합니다. 다음은 is로 변수 num에 저장된 자료형이 무엇인지 검사하는 예제입니다. if, else if문으로 작성되어 있는 코드는 아직 이해하지 않아도 좋습니다. 지금은 is 키워드 설명에 집중하면 됩니다.
자료형 검사하기 | isCheck.kt |
package chap02.section3
fun main() {
val num = 256
if (num is Int) { // num이 Int형일 때
print(num)
} else if (num !is Int) { // num이 Int형이 아닐 때, !(num is Int) 와 동일
print("Not a Int")
}
}
Result |
256
위의 코드를 간단히 설명하면 num의 자료형이 Int라면(num is Int
) print(num)
이 실행되고 num의 자료형이 Int가 아니라면(num !is Int
) print("not a Int")
가 실행됩니다.
is는 변수의 자료형을 검사한 다음 그 변수를 해당 자료형으로 변환하는 기능도 있습니다. 이 내용도 조금 알아보겠습니다. 아직 공부하지는 않았지만 Any
형을 사용하면 자료형을 결정하지 않은 채로 변수를 선언할 수 있습니다. Any형은 코틀린의 최상위 기본 클래스로 어떤 자료형이라도 될 수 있는 특수한 자료형입니다. 이때 is를 사용하여 자료형을 검사하면 검사한 자료형으로 스마트 캐스트 됩니다. 다음 코드를 보며 자세히 설명해 보겠습니다.
val x: Any
x = "Hello"
if (x is String) {
print(x.length) // x는 자동적으로 String으로 캐스팅 된다.
}
변수 x는 Any형으로 선언되었습니다. 그런데 그 다음에 "Hello"라는 값을 대입합니다. 아직 x의 자료형은 Any형입니다. 이후 if문에서 is로 x의 자료형을 검사할 때 String으로 스마트 캐스트되어 조건문의 블록을 실행합니다.
as로 스마트 캐스트할 수도 있습니다. 이 경우에는 형 변환이 가능하지 않으면 예외를 발생하게 되죠. 사용 방법은 다음과 같습니다.
val x: String = y as String
이 경우 y가 null이 아닐 때 String으로 형 변환되어 x에 할당됩니다. y가 null인 경우 형 변환할 수 없어 예외가 발생합니다. null 가능성이 있다면 예외 발생을 피하기 위해서 다음과 같이 물음표 기호를 사용할 수 있습니다.
val x: String? = y as? String
Any형은 자료형이 특별히 정해지지 않은 경우에 사용합니다. 여기서는 간단히 개념만 살펴 보겠습니다. 코틀린의 Any형은 모든 클래스의 뿌리입니다. 우리가 자주 사용한 Int나 String 그리고 사용자가 직접 만든 클래스까지 모두 Any형의 자식 클래스입니다. 즉,코틀린의 모든 클래스는 바로 이 Any 형이라는 슈퍼 클래스(Superclass)를 가집니다.
Tip:Any는 자바의 최상위 클래스인 Object와 비슷하지만 서로 다른 유형입니다.
Any는 무엇이든 될 수 있기 때문에 언제든 필요한 자료형으로 자동 변환할 수 있습니다. 이것을 묵시적 변환이라고도 합니다. Long형으로 변환하는 다음 예제를 작성해 봅시다.
Any형 변수의 변환 | AnyCasting.kt |
package chap02.section3
fun main() {
var a: Any = 1 // Any형 a는 1로 초기화될 때 Int형이 됨
a = 20L // Int형이였던 a는 변경된 값 20L에의해 Long이 됨
println("a: $a type: ${a.javaClass}") // a의 자바 기본형을 출력하면 long이 나옴
}
Result |
a: 20 type: long
처음에는 값 1을 가진 a는 Int형이지만 이후 변경된 값 20L에 의해 Long형으로 형 변환됩니다. 여기에서 새롭게 등장한 a의 멤버 변수인 javaClass
는 a가 어떤 기본형을 가지고 있는지 출력합니다. 즉, 함수와 판단문을 사용해 언제든지 필요한 자료형에 따른 역할을 지정하여 처리할 수 있습니다. 함수는 다음 절에서 자세히 배울 것이지만 먼저 다음 예제를 작성해 미리 살펴봅시다.
Any형으로 인자를 받는 함수 만들기 | AnyArgTest.kt |
package chap02.section3
fun main() {
checkArg("Hello") // 문자열을 인자로 넣음
checkArg(5) // 숫자를 인자로 넣음
}
fun checkArg(x: Any) { // 인자를 Any형으로 받음
if (x is String) {
println("x is String: $x")
}
if (x is Int) {
println("x is Int: $x")
}
}
Result |
x is String: Hello x is Int: 5
main내부에는 checkArg
라는 사용자가 만든 함수를 사용하고 있으며, main 아래에 그 함수가 정의되어 있습니다. 눈치챘겠지만 main과 checkArg 앞에는 코틀린에서 함수를 선언하는 키워드인 fun을 사용하고 있습니다. 여기에서는 함수가 가지고 있는 인자에 집중해 봅시다. chechArg 함수의 인자 x가 Any형으로 선언되었습니다. 이 함수를 사용할 때 x에 들어오는 인자의 자료형에 따라 문자열 혹은 정수형 등으로 받아 처리할 수 있게 됩니다. 특히 함수 블록의 if 조건문과 is 연산자를 사용하면 해당 인자의 자료형에 대해 알아낼 수 있으며 곧바로 해당 자료형으로 변환되어 조건문 블록에서 사용할 수 있습니다.
"If you would thoroughly know anything, teach it to other."
- Tryon Edwards -