kotlin의 safe call operator과 run, let에 대해

effiRin·2023년 5월 27일
0

Kotlin

목록 보기
1/1
post-thumbnail

최근 코틀린을 너무 모르고 쓰고 있다고 느끼게 된 계기가 있었다.
바로 safe call operator ?. 때문

코틀린으로 코딩을 하다보면 정말 자주 쓰는 녀석인데,
코드에 대한 피드백을 받다가 내가 이 녀석을 제대로 모르고 쓰다보니 코드도 잘못 짜고 있다는 것을 이제야 깨달았다.
엥 물음표 이렇게 쓰는 게 아니었음?!

그래서 이번 포스팅을 통해 safe call operator ?. 에 대해 알게된 점을 정리하고자 한다.



safe call operator로 null check하기

출처 : https://kotlinlang.org/docs/null-safety.html#safe-calls

  • kotlin은 nullable한 프로퍼티에 접근하는 방법 중 하나로 safe call operator ?. 를 제공한다.

물론 if(a != null)과 같이 if 구문으로 단순하게 null 체크를 해주는 것도 있지만
safe call operator를 이용하면 다음과 같이 아주 간단하게 null 체크를 해줄 수 있다.

val a = "Kotlin"
val b: String? = null
println(b?.length)
println(a?.length) // Unnecessary safe call

프로퍼티 a 같은 경우엔 ? 없이 not null하게 값을 명시적으로 주고 있다.
따라서 a?.length <- 이런 식으로 굳이 null check를 해주지 않아도 된다. (not null이니까)

반면 b.length는 null이 아니면 b.length를 반환하고 그렇지 않으면 null을 반환한다.



safe calls는 chain으로 사용할 때 유용하다.

예를 들어 다음과 같은 코드가 있다고 해보자.

class SafeCallTests {

    private val me = Employee(
        name = "rin",
        department = Department(
            head = Employee(name = "tubi"),
            manager = null,
        ),
    )

    @Test
    fun safeCallChainTest() {
        val head = me.department?.head?.name
        val manager = me.department?.manager?.name

        println(head)
        println(manager)
    }

data class Employee(
    val name: String? = null,
    val department: Department? = null,
)

data class Department(
    val head: Employee? = null,
    val manager: Employee? = null,
)

여기서 sallCallChainTest에 작성된 부분을 살펴보자.


    val head = me.department?.head?.name
    val manager = me.department?.manager?.name

me(나)의 department(부서)의 head(상사)의 name(이름)를 접근하고 싶은데 null 체크를 하면서 접근하고 싶다면 어떻게 해야할까?
위처럼 safe call operator와 함께 chain 형태로 작성하면 된다.

그런데 중요한 것은 접근하는 프로퍼티들 중 (me, department, head, name) 중 null 값인 프로퍼티가 하나라도 있다면, null을 반환한다는 점이다.


그래서 테스트를 해보고자 me에 다음과 같이 값을 줬다.

    private val me = Employee(
        name = "rin",
        department = Department(
            head = Employee(name = "tubi"),
            manager = null,
        ),
    )

department에서 head엔 값을 줬지만 manager에는 null을 줬다.
그리고 바로 위에서 작성했던 코드를 println으로 찍어보자.

    @Test
    fun safeCallChainTest() {
        val head = me.department?.head?.name
        val manager = me.department?.manager?.name

        println(head) // tubi
        println(manager) // null
    }  

코드를 보면 manager?.name까지 작성해줘서 name까지 접근할 것처럼 보이지만
실제론 manager까지만 접근하고 null을 반환해버릴 것이다.
디컴파일을 해서 확인해보자.

   @Test
   public final void safeCallTest() {
      Department var10000;
      Employee var3;
      String var4;
      label21: {
         var10000 = this.me.getDepartment();
         if (var10000 != null) {
            var3 = var10000.getHead();
            if (var3 != null) {
               var4 = var3.getName();
               break label21;
            }
         }

         var4 = null;
      }

      String head;
      label16: {
         head = var4;
         var10000 = this.me.getDepartment();
         if (var10000 != null) {
            var3 = var10000.getManager();
            if (var3 != null) {
               var4 = var3.getName();
               break label16;
            }
         }

         var4 = null;
      }

      String manager = var4;
      System.out.println(head);
      System.out.println(manager);
   }

보면 해당 chain 부분은 if (varN != null) 구문을 중첩해서 이루어진 것으로 알 수 있다.
즉 me -> department -> head / manager -> name 순으로 순차적으로 접근하면서
null인 것이 발견되는 즉시 반환해버린다.

이처럼 safe call operator ? 와 함께 chaining 해서 작성해주면 저 로직이 알아서 한 줄로 짜여지는 것이다.

굉장히 효율적이지 않을 수 없다.



이외에 유용한 safe call operator

null이 아닌 값에 대해서만 특정 연산을 수행하고 싶을 경우 -> let

val listWithNulls: List<String?> = listOf("Kotlin", null)
for (item in listWithNulls) {
    item?.let { println(it) } // prints Kotlin and ignores null
}

null이 아닌 값에 대해서만 특정 연산을 수행하고 싶다면 위처럼 safe call operator let을 이 해주면 된다.

만약 safe calls chain의 receiver들 중 하나가 null이면 할당은 생략되고 오른쪽에 있는 표현식이 전혀 수행되지 않는다!



null일 경우에 특정 연산을 수행하고 싶을 경우 -> 엘비스 연산자 ?:

val l = b?.length ?: -1

만약 nullable한 값 b가 있다면, 위와 같이 작성해서
b가 null이면 b.length를 반환하고,
그렇지 않으면 non-null value, 즉 -1를 반환하라고 할 수 있다.

여기서 ?:를 엘비스 연산자 (Elvis operator)라고 한다.

val l: Int = if (b != null) b.length else -1

원래 위와 같이 작성해야하는 구문이지만 엘비스 연산자를 이용하면 더 간단하게 작성할 수 있다!

또한 throw exceptionreturn 도 아래와 같이 엘비스 연산자와 함께 써줄 수 있다!

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}


?.let?.run의 차이는?

디컴파일 결과

근데 생각해보면 ?.run?.let 둘다 실행하는게 비슷하지 않나?
무슨 차이가 있지?

일단 ?.run을 함께 사용할 때
safe call operator ?.가 알아서 null check를 해주기 때문에
null일 경우 run을 실행하지 않고
null이 아닐 경우 run을 실행하지 않도록 할 수 있다.



  • 그래서 ?.run 부분만 따로 빼서 디컴파일을 해보았다.
    fun safeCallRun(something: String?): String? {
        return something?.run {
            "Length of string: ${this.length}"
        }
    }
// 디컴파일
   @Nullable
   public final String safeCallRun(@Nullable String something) {
      String var10000;
      if (something != null) {
         int var4 = false;
         var10000 = "Length of string: " + something.length();
      } else {
         var10000 = null;
      }

      return var10000;
   }

위와 같이 테스트 해보니 if-else 구문으로 null 체크하듯이 실행된다는 것을 확인했다.
?.run 으로 썼을 때 null 여부에 따라 run의 표현식 부분의 실행 여부도 갈리는 것을 알 수 있었다.



이번엔 let으로 디컴파일 해보자.

    fun safeCallRun(something: String?): String? {
        return something?.let {
            "Length of string: ${it.length}"
        }
    }
   @Nullable
   public final String safeCallRun(@Nullable String something) {
      String var10000;
      if (something != null) {
         int var4 = false;
         var10000 = "Length of string: " + something.length();
      } else {
         var10000 = null;
      }

      return var10000;
   }

디컴파일하니까 동일하다.
물론 run은 this를 쓰고, let은 it을 쓴다는 차이는 있지만
디컴파일 코드를 보니 let이나 run이나 수행하는 역할은 동일한 셈인 것 같다.



둘 중 무엇을 써야하는가?

수행하는 바는 사실상 동일한 것 같지만
여기저기 조사한 바로는 다음과 같은 결론을 내려보겠다.

우선 null이 아닌 경우에 수행할 로직에서
(1) this를 쓰는 것이 좋은지 혹은 it(또는 명시적 파라미터)을 쓰는 것이 좋은지 판단해보자. (참고글 : https://relz.tistory.com/47)
(2) 둘다 상관없다면 let을 쓰는 편이 좋지 않을까 싶다. 왜냐하면 nullable한 객체에 대해서 어떤 코드를 실행해야 한다면 let을 쓰고, nullable하지 않은 객체에 대해서 코드를 실행하려면 run을 쓰는 것이 실무에서 보편적이라고 한다. (참고 : https://kotlinworld.com/255)

참고 자료

여기서 이런 자료를 올려줬는데 한번 참고해봐도 좋을 것 같다.

  • 또 다른 참고 자료

    run과 거의 동일하지만 다른 점은 run의 경우 프로퍼티를 바로 호출할 수 있지만 let의 경우 it을 거쳐서 프로퍼티를 호출할 수 있다. 마찬가지로 마지막 값이 반환값이 된다. run과 유사하게 it을 거치지 않고 바로 변수를 호출할 수 있다.
    출처 : https://growth-coder.tistory.com/44

  • 또또 다른 참고 자료

    Null safe operator는 참조연산자 (.) 앞에 물음표 (?) 가 들어간 형태로 사용할 수 있다 (?.) . Null safe 연산자는 참조연산자를 실행하기 전에 먼저 객체가 null인지 확인한 후, null 일 시에는 뒤에 오는 구문을 실행하지 않는 특징을 갖고 있다.
    이 방식은 run과 함께 사용하기 좋다. 먼저 null인지 확인한 후에 실행시키고 싶은 구문이 있다면 run에 넣어 실행시키면 되기 때문이다. 예를 들어 아래와 같이 사용할 수 있다.

    예시

    fun main() {
    var a: String? = null
        a?.run {
            println(uppercase())
            println(lowercase())
        }
    var b: String? = "Hello"
        b?.run {
            println(uppercase())
            println(lowercase())
        }
    }

    출력

    HELLO
    hello

    출처 : Kotlin - Null 처리와 동일성 확인 (Null Safety and Equality)



문제 상황 그리고 피드백

그렇다면... 내가 짰던 코드에서 문제가 되었던 상황과, 이에 받았던 피드백을 되짚어보자.



  • [test 1]
@Test
    @DisplayName("run 함수의 null 체크에 대해")
    fun checkRunNull() {
        var result: String?

        val something1: String? = "null"
        val something2: String? = null

        result = something1?.let {
            "Length of string: ${this.length}"
        }

        println(result)

        result = something2?.let {
            "Length of string: ${this.length}"
        }

        println(result)
    }
// 결과
Length of string: 4
null


  • [test 2]
		@Test
    @DisplayName("run 함수의 null 체크에 대해")
    fun checkRunNull() {
        var result: String? = null

        val something1: String? = "null"
        val something2: String? = null

        something1?.let {
            result = "Length of string: ${this.length}"
        }

        println(result)

        something2?.let {
            result = "Length of string: ${this.length}"
        }

        println(result)
    }
// 결과
Length of string: 4
Length of string: 4

test 1과 2의 차이는 변수 result를 밖에서 할당해주느냐, 아니면 안에서 할당해주느냐의 차이이다.

something 1이 값이 있을 경우만 해당 표현식만 실행해서 값을 할당하고
마찬가지로 something 2가 값이 있을 때만 해당 표현식만 실행해서 값을 할당해서 return 하고 싶었다.

하지만 test 1처럼 밖에서 할당해주는 식으로 짜니까 두 번째 result 프린트를 찍었을 때 null이 담겨버리는 문제가 발생했다.
(당연함... null이니까 safe call operator ? 에 의해서 let 안의 코드가 실행되지 않았고 그대로 something2 변수의 null이 할당됨)

이는 내가 원했던 의도와는 달랐고, 피드백을 통해서 이 사실을 깨달았다.
그래서 ?.let 형태에 안쪽에서 result를 할당하는 방식으로 바꾸었다.
원하는 흐름대로 실행할 수 있었고, 코드 가독성이나 실행 측면에서 효율적으로 개선할 수 있었다.

그리고 추가적으로 ?.run?.let, 둘 중에 어떤 걸 써야할지 고민했다.
하지만 앞서 조사한대로 (1) 'nullable'한 객체를 다루고 + (2) 해당 객체를 '이용'하여 특정 로직을 수행 것이니 ?.let을 택했다.




여튼...
safe call 연산자는 어떻게 보면 kotlin으로 코딩할 때 정말 필수적이고 기본적인 건데
잘 모르고 썼다는 사실에 조금 충격이었다.
앞으로 kotlin의 기초적인 것도 확실하게 알고 쓸 수 있도록 공부해야겠다!

그리고 디컴파일 기능을 잘 안썼는데 리팩토링할 때 한 번씩 쓰면서 체크해보는 것도 좋은 것 같다!


profile
모종삽에서 포크레인까지

0개의 댓글