이번 글은 대부분의 개발자들을 고통스럽게 했던 null 값에 대한 코틀린의 대응 문법에 대한 내용입니다.
참조변수가 객체를 참조하지 않은 상태에서 객체의 기능을 사용하려 했을 때 많이 만나는 NullPointerException[NPE]은 코드를 작성할 때 발견되지 않고 앱이 실행중에 발생하기에 코딩 중 문제를 발견하기 어렵고 초보자들의 경우 예외처리에 대한 부분에 미흡하여 앱이 다운되는 주요 원인 중 하나가 되어 개발자들 사이에 아주 짜증나는 문제 중 하나입니다.
NPE는 이 문제의 원인을 제공한 null 이라는 값을 만든 토니호어(Tony Hoare) 조차도 2009년 강연에서 이 문제에 대해 본인을 자책하는 말을 남겨 더욱 유명해 졌습니다.
null값 : 토니 호어'(Tony Hoare)가 1965년에 처음 발명, -- 퀵정렬 이론을 만든 사람
2009년 강연 중..
(널 포인터는) 내 10억 달러짜리 실수였다. 1965년 당시, 나는 ALGOL W라는 객체 지향 언어에 쓰기 위해 포괄적인 타입 시스템을 설계하고 있었다.
내 원래 목표는 어떤 데이터를 읽든 항상 안전하도록 컴파일러가 자동으로 확인해 주는 것이었다.
그러나 나는 널 포인터를 집어넣으려는 유혹을 이길 수가 없었다.
그렇게 하는 게 훨씬 쉬웠기 때문이다. 이 결정은 셀 수도 없는 오류와 보안 버그, 시스템 다운을 낳았다.
지난 40년 동안 이러한 문제들 때문에 입은 고통과 손해는 10억 달러는 될 것이다.
코틀린은 NullPointerException[NPE]에 대한 앱의 버그를 문법에서부터 막아주기 위한 Null safety 관련 문법들을 제공합니다.
새로운 파일 Test09NullSafe.kt 을 만들고 문법들에 대해 알아봅시다.
프로그램의 시작함수 main() 함수를 만들고 문법들에 대해 알아보겠습니다.
1. null 값을 저장할 수 있는 nullable 변수
코틀린은 null값을 저장할 수 있는 타입을 명시적으로 구분하여 사용하도록 하고 있습니다. 이를 통해 코드를 작성하는 시점에 이 변수가 널값을 가지고 있을 수 있다는 것을 개발자에게 인식하게 하며 문법적으로 강제로 null 값에 대한 처리를 하도록 하였습니다.
1.1) non nullable 변수 - null 값을 저장할 수 없는 변수
var s1:String = null //ERROR - non nullable 변수
1.2) nullable 변수 - null 값을 저장할 수 있는 변수 [ 자료형(type)뒤에 ? 추가]
var s2:String? = null //OK - nullable 변수
두 타입의 변수가 어떻게 다른지 알아보면서 nullable 변수를 안전하게 사용하기 위한 몇가지 특별한 연산자 들을 소개하도록 하겠습니다.
fun main(){
//null 값을 저장할 수 없는 변수와 있는 변수.
var str1:String = "Hello" // non nullable type
var str2:String? = "Nice" // nullable type
}
두 참조변수 모두 실제 문자열객체를 참조하고 있습니다. 이제 각각의 참조변수를 이용하여 문자열 객체의 글자수(길이)를 화면에 표시해보겠습니다. 이미 잘 알고 있듯이 String 객체는 본인이 가진 문자열의 길이값을 length 라는 프로퍼티(멤버변수)에 저장하고 있습니다.
먼저 null 값을 저장할 수 없는 non nullable variable 인 str1 변수의 length를 사용해 보겠습니다. 자바와 특별히 다른 점이 없습니다.
fun main(){
//null 값을 저장할 수 없는 변수와 있는 변수.
var str1:String = "Hello" // non nullable type
var str2:String? = "Nice" // nullable type
//non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
println("글자수 : " + str1.length) // OK : . 연산자를 통해 String객체의 멤버 접근하여 사용
}
//**출력**
글자수 : 5
이번에는 null 값을 저장할 수 있는 nullable variable 인 str2 변수의 length를 사용해 보겠습니다. ERROR가 발생하는 것을 확인하세요.
fun main(){
//null 값을 저장할 수 없는 변수와 있는 변수.
var str1:String = "Hello" // non nullable type
var str2:String? = "Nice" // nullable type
//non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
println("글자수 : " + str1.length) // OK : . 연산자를 통해 String객체의 멤버 접근하여 사용
//nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력시도하기
println("글자수 : " + str2.length) // ERROR : String? 타입은 null 일 수도 있다는 것이기에 . 연산자가 언제나 안전하게 멤버를 가져올 수 있다고 확신할 수 없음 - 실제 null 이 아니어도 사용불가 [ 객체가 없을때 NullPointException 발생 ]
}
str1 과 다르게 문법적 에러를 발생합니다. str2 참조변수가 실제 문자열 객체를 참조하는 것과는 상관없이 nullable 타입은 언제든 객체를 참조하지 않을 수도 있기에 컴파일 단계에서부터 개발자에게 null 값인지를 체크하는 코드를 추가하여 런타임(실행 중) 예외가 발생하지 않도록 코드를 작성하도록 강제하는 것입니다.
그럼 해결해볼까요? 일단, 자바에서 처럼 if 조건문을 통해 null 체크 코드로 해결해 보겠습니다.
fun main(){
//null 값을 저장할 수 없는 변수와 있는 변수.
var str1:String = "Hello" // non nullable type
var str2:String? = "Nice" // nullable type
//non nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력하기
println("글자수 : " + str1.length) // OK : . 연산자를 통해 String객체의 멤버 접근하여 사용
//nullable 변수가 참조하는 문자열 객체의 글자수(길이) 출력시도하기
//println("글자수 : " + str2.length) // ERROR : String? 타입은 null 일 수도 있다는 것이기에 . 연산자가 언제나 안전하게 멤버를 가져올 수 있다고 확신할 수 없음 - 실제 null 이 아니어도 사용불가 [ 객체가 없을때 NullPointException 발생 ]
//해결방법 - null인지 확인하는 if문 코드 필요
if(str2!=null) println("글자수 : " + str2.length) //if문이 참일때만 실행되는 영역이어서 String?도 사용가능함.
}
//**출력**
글자수 : 5
글자수 : 4
if() 조건문이 참(true)인 영역에 작성하기에 str2는 무조건 객체를 참조하고 있다는 것이 확실하기에 컴파일 단계에서 에러로 표시하지 않도록 합니다. 자바에서 많이 사용하던 null check 방법입니다.
근데 위 해결방법이 좀 코드가 번거로운 것 같습니다. 그래서 등장한 코틀린의 null 안정성 문법을 소개하고자 합니다.
♣ null을 안전하게 사용하기 위한 몇가지 연산자
1) ?. 연산자 : null safe 연산자
코틀린은 if 처리 코드를 조금 더 간결하게 표시하기 위해 null 이 아닐때만 . 연산자로 멤버를 접근하는 널 안전 연산자를 제공합니다.
println("글자수 : " + str2?.length) // null이 아니면 멤버에 접근, null이면 그냥 null 을 결과로 줌
//**출력**
글자수 : 4
만약 str2 가 정말로 null 이라면 null 값이 출력됩니다.
str2= null;
println("글자수 : " + str2?.length) // null 출력
//**출력**
글자수 : null
근데 객체가 null일때 그냥 null로 값이 전달되는 것이 싫고 내가 원하는 값으로 나왔으면 할 수 도 있습니다.
만약 객체가 null이면 길이값을 -1 로 주고 싶다면 아래처럼 if - else 를 사용해야 할겁니다.
val len= if(str2!=null) str2.length else -1
println(len)
//**출력**
-1
근데 if 표현식이 조금 지저분해 보이네요. 이를 간결하게 하기 위한 2번째 연산자를 소개하겠습니다.
2) ?: 연산자 - 엘비스[Elvis] 연산자
연산자에 사용된 ? 의 모양이 엘비스 프레슬리의 머리부터 구렛나루 모양과 흡사하다고 해서 엘비스 연산자 라고 부릅니다. 좀 어이없지만요. ?: 연산자의 앞에 작성한 참조변수가 null 이면 ?: 연산자 뒤에 작성된 값이 결과값이 되는 연산자 입니다.
val len2= str2?.length ?: -1 // ?: 연산자 뒤에 null일때 원하는 값을 지정
println(len2)
//**출력**
-1
위 if - else 코드에 비해 훨씬 간결해 진 코드를 볼 수 있습니다. 아주 맘에 드는 연산자 입니다. 연산자 이름도 맘에 듭니다.^^
위 2가지 null 안전 연산자를 이용하여 코드를 작성하기 하지만 ? 를 표시하는 것이 귀찮거나 굳이 nullable variable 로 결과값을 받아 사용하는 것이 필요치 않을수도 있습니다.
이런 NPE에 안전한 연산자를 쓰지 않고 그냥 자바에서처럼 실수로 null 참조변수를 사용하면 Exception이 발생하여 앱이 종료되로록 하고싶다면?
즉, 개발자가 의도하지 않게 null이면 앱이 꺼지도록 하여 앱을 더이상 실행되게 하고 싶지 않다면 이 참조변수가 null 이 아니라고 주장하면 됩니다.
3) non-null asserted(주장된,단언된) call !! 연산자
이 참조변수는 null 이 아니니 컴파일단계에서 에러로 표시하지 말라고 주장하는 연산자 입니다.
var ss:String?= "Hello"
//println( ss.length ) //ERROR - String? 타입은 NPE 발생이 안되도록 문법적으로 못쓰도록 하고 있음
println( "글자수 : " + ss!!.length )
//**출력**
글자수 : 5
만약 실제 참조변수가 null 을 참조하고 있었다면 컴파일 단계에서는 에러는 아니지만 런타임(실행 중)에 예외가 발생하여 앱이 다운됩니다.
//null참조객체
val sss:String?= null
println( "글자수 : " + sss!!.length ) //Exception -- 실행 중 예외발생
//**출력**
Exception in thread "main" java.lang.NullPointerException
마지막으로 null에 대한 대응은 아니지만 안전하고 간결한 예외처리를 제공하는 연산자를 소개하고 마무리 하겠습니다.
4) 안전한 casting 연산자 as? - 자료형이 맞지 않는 타입을 억지로 형변환 하는 경우
코틀린은 참조형 타입의 형변환을 위한 연산자로 as( ~로서) 를 사용합니다. 그러다 만약, 형변환이 불가능한 타입으로 변환을 시도하면 런타임(실행 중) 예외가 발생하여 앱이 다운됩니다. 컴파일 단계에서 에러를 표시하지 않기에 개발자가 발견하기 어려운 예외입니다.
이를 위해 형변환 중 예외가 발생하면 그냥 null 값으로 결과를 주는 안전한 형변환(type casting) 연산자가 as? 입니다.
참조형 타입의 형변환을 실습해 보기 위해 main()함수 아래에 MMM 과 ZZZ 라는 이름의 클래스를 설계하겠습니다.
멤버변수는 둘다 a 변수 1개씩 가진 비슷한 모양이지만 둘은 전혀 상관없는 class 입니다. 서로 연관이 없는 클래스이기에 형변환이 불가능한 클래스입니다.
//시작 함수..
fun main(){
}// main() 함수 종료..
//4) 안전한 캐스팅 실습용 클래스들
class MMM{
var a=10
}
class ZZZ{
val a=20
}
main 함수에 MMM 객체를 생성한 후 zzz 참조변수에 대입해 보겠습니다.
fun main(){
val mmm:MMM= MMM()
//전혀 다른 타입 참조변수에 대입
val zzz:ZZZ= mmm //문법적으로 error - 타입이 전혀 다르므로.
}
//4) 안전한 캐스팅 실습용 클래스들
class MMM{
var a=10
}
class ZZZ{
val a=20
}
당연히 컴파일 단계에서 문법적으로 에러를 발생합니다. 전혀 다른 타입을 대입하려 했으니 당연합니다.
이때, 억지로 MMM객체를 ZZZ 타입으로 형변환 하여 대입해보겠습니다.
fun main(){
val mmm:MMM= MMM()
//전혀 다른 타입 참조변수에 대입
//val zzz:ZZZ= mmm //문법적으로 error - 타입이 전혀 다르므로.
//억지로 as 연산자를 통해 형변환
val zzz:ZZZ = mmm as ZZZ //문법적으로는 error가 없지만 Class cast Exception 발생함!
}
//4) 안전한 캐스팅 실습용 클래스들
class MMM{
var a=10
}
class ZZZ{
val a=20
}
명시적인 형변환 연산자 as 를 사용함으로서 컴파일 단계에서는 에러로 인지하지 않습니다. 하지만 실제 런타임(실행 중)에 형변환이 불가능하여 예외가 발생하며 앱이 다운됩니다.
이때 혹시 잘못된 casting을 해도 예외로 인해 앱이 다운되지 않고 그냥 null을 리턴하도록 하고 싶다면 안전한 캐스팅 연산자 as? 를 사용합니다.
fun main(){
val mmm:MMM= MMM()
//전혀 다른 타입 참조변수에 대입
//val zzz:ZZZ= mmm //문법적으로 error - 타입이 전혀 다르므로.
//억지로 as 연산자를 통해 형변환
//val zzz:ZZZ = mmm as ZZZ //문법적으로는 error가 없지만 Class cast Exception 발생함!
//이때 혹시 잘못된 casting을 해도 예외로 인해 앱이 다운되지 않고 그냥 null을 리턴하는 안전한 캐스팅 연산자 as?
val zzz:ZZZ? = mmm as? ZZZ?
println(zzz) // null출력 - 객체가 실제로 캐스팅이 되었다면 [클래스명@해시코드] 가 출력됨
}
//4) 안전한 캐스팅 실습용 클래스들
class MMM{
var a=10
}
class ZZZ{
val a=20
}