본문 바로가기

코틀린

코틀린(Kotlin) - 타입 시스템 as?, !!, let, lateinit

안전한 캐스트 "as?"

기본적으로 "as" 연산자는 대상 값을 as로 지정한 타입으로 캐스트 하며, 해당 타입으로 바꿀 수 없으면 ClassCastException이 발생하게 됩니다.

이럴 경우 is 연산자를 통해 대상 값이 해당 타입으로 변환 가능한 타입인지 체크해야 하지만 코틀린에서는 훨씬 더 간결한 기능으로 "as?" 연산자를 제공합니다.

 

"as?" 연산자는 어떤 값을 지정한 타입으로 캐스트를 하고, 만약 대상 타입으로 캐스트 할 수 없으면 null을 반환하게 됩니다. 

 

타입 캐스트 연산자는 주어진 타입과 맞지 않으면 null을 반환한다.

 

/* 안전한 캐스트를 사용해 equals 구현하는 예제 */
class User(val id: String) {
  override fun equals(o: Any?): Boolean {
    val otherUser = o as? User ?: return false //타입이 맞지 않으면 false 반환
    return otherUser.id == id //안전한 캐스트를 하고 나면 otherUser가 User로 스마트캐스트 된다
  }
}

>>> val user1 = User("Happy")
>>> val user2 = User("BTS")
>>> println(user1 == user2)
true

>>> println(user1 == 123) //User객체가 아닌 엉뚱한 타입과 비교
false

 

null이 아닌 값 "!!"

null이 아닌 값(not-null assertion)은 코틀린에서 느낌표를 이중(!!)으로 사용하면 어떤 값이든 null이 될 수 없는 타입으로 바꿀 수 있습니다.

그렇기 때문에 실제 null 값에 대해 !!를 적용하면 NullPointerException이 발생합니다.

 

null 아님(!!)을 사용하면 값이 null이 아닐 때 NPE를 던질 수 있다.

 

/* null 아님 !! 예제 */
fun ignoreNull(s: String?) {
  val stringNotNull: String = s!! //예외는 이 시점에서 발생됩니다.
  println(stringNotNull.length) //stringNotNull은 null이 아닌 값으로 인식됩니다.
}

>> ignoreNull(null)

Exception in thread "main" kotlin.KotlinNullPointerException...

 

근본적으로 !!는 컴파일러에게 개발자가 "이 값이 null이 아님을 확신한다! 만약 내 생각이 잘못됐다면 예외가 발생해도 감수하겠다" 라는 의미로 볼 수 있습니다.

!! 기호의 모습 자체를 마치 컴파일러에게 소리를 지르는 듯한 느낌으로 코틀린 설계자들이 일부러 의도한 것이라고 합니다.

컴파일러가 검증할 수 없는 null 아님(!!) 을 사용하기보다는 더 나은 방법을 찾아보라는 의도로 !! 라는 기호를 택했다고 합니다. 😮

 

Tip ! 
null 아님(!!) 을 사용하다가 발생하는 exception의 stackTrace에는 어떤 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 들어있지 않습니다.
그렇기 때문에 어떤 값이 null이었는지 확실히 파악하기 위해 !! 문을 한 줄에 연달아 나열해서 함께 쓰는 일은 반드시 피해주는 것이 좋습니다.

예시) user!!.address!!.country

이런 식으로 코드를 작성하면 어느 객체에서 NPE가 발생했는지 파악하기가 어렵습니다...! 😨

"let" 함수

let 함수를 사용하면 null이 될 수 있는 식을 쉽게 다룰 수 있습니다.

let 함수를 사용하는 가장 흔한 사례는 안전한 호출 연산자 (?.) 와 함께 사용하여 원하는 식의 결과가 null인지 검사한 다음 그 값을 전달받아 처리하는 경우입니다.

 

/* 안전한 호출 연산자 ?.와 let 함수의 사용 예제 */
carInfo?.let { //carInfo가 null이 아닌 경우에만 아래 로직 실행
   mTextCarName.setText(it.name)
   mTextCarPrice.setText(it.price)
}

 

안전한 호출 연산자 "?."를 사용하여 let을 호출하되 null이 아닌 값을 let 함수의 수신 객체로 람다에게 전달합니다.

 

?.let으로 안전하게 호출하면 수신 객체가 null이 아닌 경우 람다를 실행한다.

 

나중 초기화 "lateinit"

생성자에서 해당 변수를 초기화하는 경우와 같이 반드시 null이 될 일이 없는 변수를 선언하고 초기화하는 경우에는 "lateinit" 을 변수 앞에 붙이면 나중에 초기화할 수 있습니다.

"lateinit"이 붙는 변수는 반드시 var 로 선언되어야 합니다.

 

/* lateinit을 사용하지 않은 경우 */
class MainActivity : AppCompatActivity() {
   private var myService: MyService? = null //null이 가능한 변수로 선언
   
   override fun onCreate(savedInstanceState: Bundle?) {
      myService = MyService() //이렇게 생성된 myService 변수는 null 가능이기 때문에
                              //다른곳에서 사용할 때마다 null 체크를 해줘야 한다.
   }
}


/* lateinit을 사용한 경우 */
class MainActivity : AppCompatActivity() {
   private lateinit var myService: MyService //null이 될 수 없는 변수로 선언
   
   override fun onCreate(savedInstanceState: Bundle?) {
      myService = MyService() //null 체크 필요 없이 사용이 가능하다.
   }
}

 

null이 될 수 있는 타입에 대한 확장 함수

코틀린의 확장 함수 기능을 활용하면, null이 될 수 있는 변수에 대해 안전한 호출 연산자를 쓰지 않아도 확장 함수에서 null 체크를 해주는 기능을 구현할 수가 있습니다.

 

예를 들면, 코틀린 String의 확장 함수인 isNullOrEmpty, isNullOrBlank 메소드가 있습니다.

 

/* null이 될 수 있는 수신 객체에 대해 확장 함수 호출하기 */
fun sendEventLog(event: String?) {
   if(event.isNullOrBlank()) { //안전한 호출 연산자 ?.를 하지 않아도 된다.
      ...
   }
}

 

다음은 정의된 isNullOrBlank 확장 함수의 구현체입니다.

 

fun String?.isNullOrBlank(): Boolean = //null이 될 수 있는 String으로 확장
   this == null || this.isBlank()

 

null이 될 수 있는 타입에 대한 확장을 정의하였기 때문에 위의 예제에서 event 문자열이 null이 될 수도 있음에도 안전한 호출로 null 체크를 하지 않고 확장 함수를 호출할 수 있었던 이유입니다.

 

Tip !
확장 함수를 만드는 경우, 처음에는 null이 될 수 없는 타입에 대한 확장 함수로 먼저 정의를 해봅니다.
그러곤 나중에 이 확장 함수를 사용하게 되는 경우 null이 될 수 있는 타입에 대해 이 함수를 호출했다는 사실을 알게 되면 그때 확장 함수 안에서 null 체크를 제대로 처리해주게 되면 null에도 안전한 확장 함수가 작성될 수 있습니다. ^^