Android 개발자라면, 혹은 개발자가 되고 싶다면 도구인 개발 언어를 기본부터 응용까지 잘 다룰 줄 알아야 합니다. 이 포스트는 Kotlin 언어와 관련해 아래 내용들에 대해 정리합니다.
⚠️ 개인 경험을 기반으로 적은 지식이므로 잘못된 내용이나 추가로 덧붙일만한 내용이 있다면 댓글이나 개인적으로 언제든 알려주세요!


Kotlin의 기본 특징 및 Java와의 비교

Q) null safety의 개념이란?

Q) mutableimmutable 컬렉션의 차이는 무엇이며 각각 언제 쓰는 게 좋은가?

Q) lateinit varlazy delegate의 차이와 각각의 장단점은 어떻게 되는가?

Q) extension 함수란? 어떤 기준으로 선언하는 것이 좋은가?

Q) data class가 일반 클래스와 비교했을때의 장점 및 주의할 점은?

Q) sealed class란? enum class와 비교해 언제 사용하기 좋은가?

Q) collectionsequence의 차이와 각각의 장단점 및 사용하기 좋은 경우는 언제인가?

Q) inline 키워드는 어떻게 동작하며 장단점은 어떻게 되는가?

Q) scope 함수의 종류와 각 특징들은 어떻게 되는가?

그 외 디테일한 Kotlin 개념 및 예시들


Kotlin의 기본 특징 및 Java와의 비교

  • IntelliJ IDEA의 개발사인 JetBrains에서 만든 언어입니다. JVM 기반으로 동작하고, Java와 100% 호환가능해서 2017년 5월 부터 안드로이드의 공식 지원언어로 채택되었고 2019년부터는 Java가 아닌 Kotlin을 첫 번째 언어로 선정했습니다. 이제 모든 예제 코드 작성은 코틀린으로 하고있죠.

  • Java와 비교해보면 여러가지 특징이 있는데, 그 중 눈에 띄는 몇가지를 적어보자면

    • new 키워드 없이 instance를 만들 수 있습니다. 그리고 생성된 instance가 할당되는 변수를 선언할 때 자료형을 명시적으로 지정하지 않아도 타입추론을 해줘 알아서 자료형이 결정됩니다. 물론 명시적으로 지정을 해도 문제는 없죠.
    • 제가 정말 좋아하는 코틀린 특징인데, 매 코드 라인마다 세미콜론을 적지않아도 됩니다.😆 물론 적어도 오류가 나지는 않지만 누가 적을까요..?ㅋㅋㅋㅋ
    val exampleClass = ExampleClass()
    ExampleClass exampleClass = new ExampleClass();
    • Property 개념 - 변수를 선언만 하면 Java로 컴파일 될 때 getter와 setter를 자동으로 만들어줍니다. value의 의미를 가진 val(저는 밸이라고 읽습니다)과 variable의 의미를 가진 var (저는 봐ㄹ 정도로 읽습니다)이 있는데요. val 는 선언 시점에 한 번 초기화되면 그 값을 새롭게 변경할 수 없습니다. 따라서 getter만 자동으로 생성되고요. 반면 var는 선언 후에 값을 계속 변경할 수 있습니다. 따라서 getter랑 setter 둘다 생성됩니다.
    • JavaInteger같은 Primitive Wrapper Class가 없이 기본형들을 바로 사용합니다: Primitive Types In Kotlin
  • 몇 개 적다보니 차이가 너무 많을 것 같아.. 기본을 넘어서는 진짜 세세한 내용들은 맨 아래에 추가 정리 해두겠습니다!


null safety의 개념이란?

  • null의 개념은 Tony Hoare가 처음 도입했지만, 당사자도 10억 달러의 실수라고 말할 정도로 정말 많은 프로그래밍적 오류를 야기해왔죠. Java가 아니더라도 NPE(=Null Pointer Exception)은 익숙할겁니다. Kotlin은 이러한 null 처리를 안정적으로 하는 방법으로 아예 자료형이 nullable인 경우와 non-null인 경우를 구분할 수 있게 했는데요. null은 곧 ?라고 생각하면 됩니다. ? 무슨말이냐고요? 코드 예시로 보죠.

    var nonNullIntValue: Int = 0
    var nullableIntValue: Int? = 0
    
    nonNullIntValue = null // 선언된 자료형에 ?가 붙어있지 않으므로, null이 불가능
    nullableIntValue = null // 가능!
  • 즉, ?가 붙어있지 않는 경우엔 NPE 걱정이 전혀 없이 사용할 수 있답니다. 하지만 대부분의 경우 Kotlin만 가지고 안드로이드 개발을 할 수 없고, 애초에 안드로이드 내부 코드들도 여전히 Java로 개발되어 있기 때문에 여전히 초기화되지 않은 값들 등을 다룰때는 null을 마주하게 됩니다.

  • 하지만 nullable인 변수라도 ?(safe-call operator라고 부릅니다)를 통해 NPE 걱정없이 사용할 수 있습니다. 그리고 !!라는 not-null assertion operator도 존재하는데 이건 변수가 null인지 아닌지 관계없이 무조건 참조해서 사용하겠다는 의미입니다.

    var exampleClass: ExampleClass? = ExampleClass()
    exampleClass.doSomething() // exampleClass는 null일 수 있기 때문에 컴파일 에러 발생
    exampleClass?.doSomething() // exampleClass가 null이 아닌 경우에만 doSomething 수행
    exampleClass = null
    exampleClass!!.doSomething() // NPE 발생
  • 이런 기본 사용법 말고도 다양한 활용이 가능합니다.

    val aInt: Int? = a as? Int // a라는 변수의 자료형이 Int였다면 그대로 aInt는 a값으로 초기화되고, a의 자료형이 다른것이라면 aInt는 null로 초기화됨
    
    val boilerPlateLength: Int = if (b != null) b.length else -1 // 아래와 동일
    val length = b?.length ?: -1 // Elvis operator. ?:의 의미는 앞에있는게 null이라면 뒤에있는것으로 결정된다는 의미입니다.
    
    val nullableList: List<Int?> = listOf(1, 2, null, 4) // Collection 내부 아이템 타입들에도 nullable 형태로 사용가능합니다.
    val intList: List<Int> = nullableList.filterNotNull() // non-null로 바꾸고 싶다면 이런 함수도 사용할 수 있죠.

mutable과 immutable 컬렉션의 차이는 무엇이며 각각 언제 쓰는 게 좋은가?

  • mutable: 말 그대로 수정가능하다는 의미입니다.

    val itemList = mutableListOf<ExampleItem>()
    itemList.add(ExampleItem()) // 이렇게 리스트 변수 내부에 아이템을 추가할 수 도 있고
    itemList.clear() // 내부 아이템을 모두 제거하는등의 작업이 가능하죠.
  • immutable: 말 그대로 수정가능하지 않다는 의미입니다.

    val itemList = listOf<Int>()
    itemListt.getOrNull(1) // 이렇게 초기화 된 후 내부에 접근만 가능하지, add나 remove등의 동작은 수행할 수 없습니다.
  • 그럼 그냥 내맘대로 수정할 수 있는 mutable을 늘 쓰면 좋지 않을까? 라고 생각할 수 있는데요, mutable을 사용하면 동시성 문제가 생길 수 있습니다. 하나의 변수를 서로 다른 쓰레드에서 접근해, 한 쪽은 수정하는 동시에 다른 한 쪽은 읽는 동작을 한다고 생각해보죠.

    val commonList = mutableListOf<CoreData>()
    
    // 사용자가 특정 아이템의 삭제 버튼을 누른 경우
    fun onRemoveData(position: Int) {
      commonList.remove(position)
    }
    
    // 사용자가 전체 아이템의 총합 버튼을 누른 경우
    fun calculateTotalAmount(): Int {
      var amount = 0
      for (i in 0 until commonList.count()) {
        amount += commonList[i].calculate()
      }
    }

    여기서 calculate 가 아주 오랜 시간이 걸리는 작업이라고 한다면(DB에 접근한다던지), for문 내부에서 하나씩 처리되는 와중에 onRemoveData 가 호출되는 상황이 발생할 수 있습니다. 그럼 마지막에서 유효하지 않는 index에 접근하게 될거고, IndexOutOfBoundsExeption이 발생하게 될 위험이 있습니다.

    물론 실제 코드를 짤 때는 이런 위험성을 배제하기 위해 아예 onRemoveData가 호출되지 않도록 로딩뷰를 띄운다던지 Mutex()를 사용한다던지, flag용 변수를 사용해서 각 함수의 동작중 여부를 확인한다던지의 방법을 적용해서 해결할 수도 있겠지만, 가장 좋은 건 변경이 가능한 변수와 변경이 되지 않을 변수를 구분해서 사용하는것이겠죠?

lateinit var와 lazy delegate의 차이와 각각의 장단점은 어떻게 되는가?

  • lateinit var는 말 그대로 나중에 초기화를 한다는 의미입니다. (선언하며 초기화를 하면 lateinit에 빨간밑줄이 쳐지며 사용할 수 없다고 뜹니다.) Java에서는 보통 이런 경우 null로 초기화를 해두죠. 그런데 이때 문제는 분명이 해당 변수를 접근할 시점엔 초기화가 이미 되어있으리란것을 프로그래머는 알고 있는데도 번거롭게 null 체크를 해줘야 하는 경우가 생깁니다.

    lateinit var exampleData: ExampleData

    그런데 위처럼 선언해서 사용해주면 실제 사용하는 시점에서 굳이 null 체크없이 바로 사용할 수도 있고, property가 var이기 때문에 할당했던 값을 바꿔줄수도 있습니다. 물론 실제 초기화되었는지 여부또한 확인할 수 있긴 한데, 이걸 사용할 일이 있다면 차라리 null로 초기화해두고 사용하는게 더 직관적이라고 생각합니다. 뒤에서 다루겠지만 코틀린은 null safety 개념이 아주 잘 적용되어있어서 nullable을 다루는게 그렇게까지 번거로운건 아니라서요.

    ::exampleData.isInitialized // isInitialzed를 사용하려면 변수값이 아닌 참조를 넘겨줘야 쓸 수 있어서, ::를 붙여줘야합니다.
  • lazy delegate: 앞서 lateinitvar과 하나로 쓰였다면, lazyval과 하나로 쓰입니다. 비슷하게 초기화를 나중에 한다는 의미인데요, property가 val인 만큼 초기화 된 이후 값을 변경할 수 없습니다. 그렇다면 언제 초기화가 될까요? 실제 사용되는 예시를 보면,

    val lazyValue: String by lazy {
        println("computed!")
        "Hello"
    }
    
    fun main() {
        println("main start")
        println(lazyValue)
        println(lazyValue)
    }
    
    /** 출력 결과
    main start
    computed!
    Hello
    Hello
    */

    위처럼 lazyValue를 선언할 때 초기화를 code block으로 미뤄두고, 실제 lazyValue를 접근할 때 초기화 블록이 수행되며 블록의 결과인 "Hello"lazyValue에 할당됩니다. 이후에는 lazyValue를 접근할 때 이미 초기화된 "Hello"만 가져다 쓰게되죠.

  • 이런 차이로 인해서 lateinit var는 초기화 로직이 실제 변수에 초기화를 하는곳에 들어가서 선언부는 깔끔해지지만 함수가 뚱뚱해질 수 있는 반면, lazy는 선언부에 초기화 로직이 들어가 뚱뚱해질 수 있지만 변수를 사용할 때는 함수 내부에서 그냥 사용하면 되는 장단점이 있네요.

extension 함수란? 어떤 기준으로 선언하는 것이 좋은가?

  • Kotlin의 강점 중 하나인데, 기존에 존재하는 클래스에 마치 새로운 변수나 함수가 존재하는것처럼 추가하여 사용할 수 있는 기법입니다. 바로 예시를 살펴보죠.

    fun View.fadeOut(duration: Long = 500L): ViewPropertyAnimator {
      animate().alpha(0.0f).setDuration(duration)
    }
    // 실제 사용하는 코드
    titleView.fadeOut(1000L)
  • 위에 보시면 View클래스에는 사실 fadeOut이라는 함수가 없습니다. 그런데 extesion 함수로 만들어두면, 실제로 사용하는곳에선 마치 fadeOut이라는 함수가 원래 있던것처럼 사용할 수 있습니다. 이렇게 등록되는 fadeOut 함수에서는 this를 통해 대상이 되는 클래스(예시에서는 View)를 접근할 수 있고, this 키워드는 생략이 가능하기 때문에 바로 View클래스에 원래 존재하는 animate함수를 fadeOut함수 내부에서 사용할 수 있는것이죠. 이런 확장성은 단순히 함수에만 국한된것이 아닙니다.

    val Fragment.viewLifecycleScope: CoroutineScope
        get() = viewLifecycleOwner.lifecycleScope
  • 함수에 parameter가 없다면 이렇게 extension property 형태로도 사용 가능합니다. property 내부에서 당연히 다른 함수 호출하는 형태도 사용가능하죠.

    val ViewGroup.layoutInflater: LayoutInflater
        get() = LayoutInflater.from(context)
  • 개인적으로는 이런 확장 함수들을 모아둔 .kt 파일을 별도로 관리하고 있습니다. Extensions_View.kt 라던지 Extensions_Fragment.kt 라던지요. 만약 확장 함수가 여기저기서 public으로 무분별하게 많이 사용되면 모아서 관리하기 어렵겠죠? 또한 진짜 필요한 함수가 아닌데 단순 편의성을 위해 마구잡이로 만든다면, 해당 클래스에서 .으로 참조했을때 IDE가 추천해주는 목록이 너무 많이 나오겠네요.

data class가 일반 클래스와 비교했을때의 장점 및 주의할 점은?

  • Kotlin의 기본 클래스는 Java의 final class와 같아, 기본적으로는 상속이 불가능 합니다. 상속을 시켜주려면 open 키워드를 앞에 붙여줘야 하죠. 그런데 data class는 말그대로 데이터를 다루기 위한 특수 클래스라서, 상속이 불가능합니다. open과 함께 쓰려고 하면 컴파일이 불가해요.

    open data class ExampleDataClass() // 불가
    data class ExampleDataClass() // 불가
  • open을 붙이지 않았는데 아랫줄은 왜 불가일까요? 그건 아무런 데이터가 생성자에 정의되지 않았기 때문입니다. 일반 클래스와는 다르게, 최소 하나의 property가 존재해야 합니다. 이걸 만족시키며 data class를 만들어보면,

    class ExampleClass()
    data class ExampleDataClass(
        val intData: Int,
        val stringData: String,
        val exampleData: ExampleClass,
    )

    실제 컴파일 될 때는 아래의 Java 코드와 같아집니다. 핵심만 살펴보자면 아래와 같네요.

    • 깊은 복사인 copy 함수 자동생성: 얕은 복사는 단순 참조를 복사하는 것이고, 깊은 복사는 실제 값들과 같은 값을 가진 새로운 instance를 만들어서 return 시켜주는 것입니다.
    • toString() 생성: 로그를 확인할 때 내부 변수들을 알아서 이쁘게 보여주도록 기본적으로 오버라이딩 해줍니다.
    • equals(), hashCode() 생성: 생성자에 선언된 변수들이 모두 같은 경우에만 같다고 판별해주도록 기본적으로 오버라이딩 해줍니다.
    Kotlin에서 생성된 Bytecode를 디컴파일한 Java code
    public final class ExampleDataClass {
       private final int intData;
       @NotNull
       private final String stringData;
       @NotNull
       private final ExampleClass exampleData;
    
       public final int getIntData() {
          return this.intData;
       }
    
       @NotNull
       public final String getStringData() {
          return this.stringData;
       }
    
       @NotNull
       public final ExampleClass getExampleData() {
          return this.exampleData;
       }
    
       public ExampleDataClass(int intData, @NotNull String stringData, @NotNull ExampleClass exampleData) {
          Intrinsics.checkNotNullParameter(stringData, "stringData");
          Intrinsics.checkNotNullParameter(exampleData, "exampleData");
          super();
          this.intData = intData;
          this.stringData = stringData;
          this.exampleData = exampleData;
       }
    
       public final int component1() {
          return this.intData;
       }
    
       @NotNull
       public final String component2() {
          return this.stringData;
       }
    
       @NotNull
       public final ExampleClass component3() {
          return this.exampleData;
       }
    
       @NotNull
       public final ExampleDataClass copy(int intData, @NotNull String stringData, @NotNull ExampleClass exampleData) {
          Intrinsics.checkNotNullParameter(stringData, "stringData");
          Intrinsics.checkNotNullParameter(exampleData, "exampleData");
          return new ExampleDataClass(intData, stringData, exampleData);
       }
    
       // $FF: synthetic method
       public static ExampleDataClass copy$default(ExampleDataClass var0, int var1, String var2, ExampleClass var3, int var4, Object var5) {
          if ((var4 & 1) != 0) {
             var1 = var0.intData;
          }
    
          if ((var4 & 2) != 0) {
             var2 = var0.stringData;
          }
    
          if ((var4 & 4) != 0) {
             var3 = var0.exampleData;
          }
    
          return var0.copy(var1, var2, var3);
       }
    
       @NotNull
       public String toString() {
          return "ExampleDataClass(intData=" + this.intData + ", stringData=" + this.stringData + ", exampleData=" + this.exampleData + ")";
       }
    
       public int hashCode() {
          int var10000 = Integer.hashCode(this.intData) * 31;
          String var10001 = this.stringData;
          var10000 = (var10000 + (var10001 != null ? var10001.hashCode() : 0)) * 31;
          ExampleClass var1 = this.exampleData;
          return var10000 + (var1 != null ? var1.hashCode() : 0);
       }
    
       public boolean equals(@Nullable Object var1) {
          if (this != var1) {
             if (var1 instanceof ExampleDataClass) {
                ExampleDataClass var2 = (ExampleDataClass)var1;
                if (this.intData == var2.intData && Intrinsics.areEqual(this.stringData, var2.stringData) && Intrinsics.areEqual(this.exampleData, var2.exampleData)) {
                   return true;
                }
             }
    
             return false;
          } else {
             return true;
          }
       }
    }
  • 이외에도 아래와 같은 특징이 있긴 한데, 저는 주의해볼 경험이 아직은 없어서 딱히 강조할 이유는 없어보이네요.
    • 각각의 property에 상응하는 componentN() 존재
    • open과 함께쓰지 못하는 것처럼 abstract, sealed, inner 키워드와 함께 사용 불가

sealed class란? enum class와 비교해 언제 사용하기 좋은가?

  • sealed는 봉인된이란 뜻을 지니는데요, 비슷한 사용성을 가진 클래스들을 하나로 묶어서 취급하고 싶을 때 사용할 수 있습니다. 말로 표현하기가 좀 어려워 예시부터 보자면,

    sealed class MainUiEvent {
        object ShowForceUpdateVersionPopup : MainUiEvent()
        object ShowNewFeatureVersionPopup : MainUiEvent()
        class ShowNewFeatureImagePopup(val newFeatureImageList: List<String>) : MainUiEvent()
    }
  • 이처럼 Main화면에서 다뤄지는 UiEvent는 여러개가 있을텐데요. 크게 보면 모두 메인화면 이벤트들이자면 세부적으로는 구분해야할 필요성이 있고 거기다가 개별 이벤트중 일부는 추가정보(parameter)가 필요할 수 있습니다. 이런 경우에 사용될 수 있고, sealed라는 이름의 특성상 sealed class를 상속받는 클래스들은 모두 해당 sealed class와 같은 패키지안에 있어야 합니다. 위의 예시 클래스가 실제 사용될 때는 아래처럼 when과 is의 조합으로 개별적 처리가 가능합니다.

    when (mainUiEvent) {
        is MainUiEvent.ShowForceUpdateVersionPopup -> showForceUpdateVersionPopup()
        is MainUiEvent.ShowNewFeatureVersionPopup -> showNewFeatureVersionPopup()
        is MainUiEvent.ShowNewFeatureImagePopup -> showNewFeatureImagePopup(it.newFeatureImageList)
    }
  • enum class 또한 목적은 비슷한데, 단어 그대로 열거 가능할 때 쓰이다보니 위처럼 개별적인 다양성이 필요하지 않은 단순한 경우에 쓰기 좋습니다.

    enum class TransitionMode {
        NONE, HORIZON, VERTICAL
    }

collection과 sequence의 차이와 각각의 장단점 및 사용하기 좋은 경우는 언제인가?

사실 안드로이드 개발 수준에서 sequence를 다룰 경우는 거의 없는 것 같긴 합니다. 그럼에도 개념적으로 비교해보자면,

  • Collection: 기본적으로 쓰이는 List, Set, Map 으로 구성됩니다. 데이터에 빠르게 접근해서 다양한 연산을 수행할 수 있는데, 모든 데이터가 메모리에 올라가기 때문에 대용량의 데이터를 처리해야 한다면 느리고 메모리 사용량이 많아지는 단점이 있습니다. 또한 여러 연산을 수행할때 중간 결과가 계속 생성됩니다.

  • Sequence: 데이터에 접근하는 속도는 느려서 적은양의 데이터를 처리할 때는 부적합하지만, Collection과 달리 모든 데이터를 메모리에 올리지 않고 필요할 때만 사용하기 때문에 대용량의 데이터를 처리할 때 사용하기 적합합니다. 중간 데이터를 생성하지도 않고, 연산의 결과를 실제로 사용할 시점에 각 데이터 요소들이 개별적으로 연산되기 때문에 각각의 데이터에 연산을 모두 적용하고 바로바로 그 결과를 받아 처리하고 싶다면 Sequence를 사용하기에 좋습니다.

  • 공식 도큐먼트의 예제코드를 수행시켜보면 바로 이해하기가 쉽습니다: https://kotlinlang.org/docs/sequences.html#sequence-processing-example

  • 또한 실제 메모리를 차지하는지도 아래의 코드로 Kotlin Playground에서 수행시켜보면 확인할 수 있습니다.

    import kotlin.random.Random
    import kotlin.system.*
    import kotlinx.coroutines.*
    
    fun main() {
        val largeNumberList = (1..10_00_000).toList() // 100만개의 데이터를 메모리에 올려두고
    
        val startTimeSequence = System.currentTimeMillis()
        val squaredSequence = largeNumberList.asSequnce().map { it * it } // Sequence를 사용하여 map 연산이 수행되도록 하면 squaredSequence는 중간결과로 만들어지지 않기때문에 수행이 됩니다. 그러나 중간의 asSequence()를 없애서 Collection으로 동작시키면, squaredSequence는 중간결과로 또 하나의 100만개의 데이터를 가진 새로운 메모리를 차지하게 되고 OutOfMemoryError가 출력됩니다.
        val endTimeSequence = System.currentTimeMillis()
        val sequenceTime = endTimeSequence - startTimeSequence
    
        println("Time taken using Sequence: $sequenceTime ms")
    }

inline 키워드는 어떻게 동작하며 장단점은 어떻게 되는가?

  • inline 키워드가 붙으면, 컴파일러는 실제 컴파일을 할 때 해당 함수나 property가 붙은 코드의 내용을 호출하는곳에 그대로 복사하여 붙여넣습니다. 그래서 함수를 호출하는 오버헤드를 줄여 성능 향상이 되기 때문에, 작은 크기의 함수들을 자주 호출하는 경우 효율성을 높일 수 있습니다. 또한 Kotlin에서는 모든 함수가 객체로 취급되기 때문에 closure를 형성합니다. 함수내부에 선언된 변수들이 접근될 수 있는 영역(scope)를 뜻하는데 이런 특징들로 인한 메모리 할당과 virtual call이 실행시점에서의 오버헤드를 야기할 수 있습니다. (잘 쓰지 않아서 풀어내기가 어렵네요😅) 공식 도큐에서 원문으로 참고하면 더 이해하기 쉬울 것 같습니다.
  • 반면 붙여넣어지다보니 컴파일된 코드의 전체양이 많아지거나 복잡해지고, 디버깅하기 어려워질 수 있습니다. 따라서 함수 코드가 크다면 inline을 붙이지 않는것이 좋습니다.

scope 함수의 종류와 각 특징들은 어떻게 되는가?

  • Kotlin에는 기본적으로 모든 객체에서 사용가능한 scope 함수들이 있습니다: run, with, apply, also, let, takeIf, takeUnless. Standard.kt를 들어가면 실제 내부 구현 코드를 볼 수 있습니다. 우선 모든 scope 함수에 적용되는 기본은 처리할 객체의 타입을 T로 취급하는 것입니다.(generic) 그럼 확장함수에서 다룬 것처럼, T.() 타입으로 전달받으면 내부에서 this로 접근가능할테고 (T) 타입으로 전달받으면 내부에서 it으로 접근가능하겠죠. 이걸 기본으로 생각하고 각 함수들을 살펴보면 이해가 쉽습니다.

  • run

    • 대상이 되는 객체를 this로 접근하는 code block을 수행한 뒤 그 수행 결과를 return 시켜줍니다.
  • with

    • 대상이 되는 객체를 receiver로 받은뒤 this 로 접근하는 code block을 수행한 뒤 그 수행 결과를 return 시켜줍니다.
  • apply

    • 대상이 되는 객체를 this로 접근하는 code block을 수행한 뒤 다시 해당 객체를 return 시켜줍니다.
  • also

    • 대상이 되는 객체를 it으로 전달받는 code block을 수행한 뒤 다시 해당 객체를 return 시켜줍니다.
  • let

    • 대상이 되는 객체를 it으로 전달받는 code block을 수행한 뒤 그 수행 결과를 return 시켜줍니다.
  • 나열하고 보면 비슷한 듯 살짝씩 다른데요. 5개가 주로 쓰이는 예시를 보자면(개인적으로 잘 안써서 적절하지 않아 보이는것도 있습니다.)

    // run: 어떤 객체에 일부 동작을 수행시키고, 최종 결과만 필요할 때 사용하기 좋습니다.
    val textView = findViewById(R.id.text_view)
    val textLength = if (textView != null) {
      textView.text = "New Text"
      textView.length
    } else {
      -1
    }
    
    // 위와 같은 코드지만, 결국 length 값만 필요하다면 중간에 textView 변수를 만들 필요없이 사용할 수 있습니다.
    // 또한 safe call과 함께 null이 아닌 경우에만 객체를 접근하여 필요한 code block이 수행되도록 할 수 있습니다.
    val textLength = findViewById(R.id.text_view)?.run {
      text = "New Text"
      length
    } ?: -1
    // with: 이미 객체는 존재하는 상황에서 계속 객체 변수를 코드에서 참조하지 않고 사용하려고 할 때 사용하기 좋습니다.
    val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    binding.textView.text = "New Text"
    binding.textView2.text = "New Text2"
    
    // 위와 같은 코드지만, 아래처럼 계속 binding 변수를 써주면서 코드가 길어지는것을 방지할 수 있습니다.
    with (binding) {
      textView.text = "New Text"
      textView2.text = "New Text2"
    }
    // apply: 객체를 추후에도 사용하고, 생성 시점에 초기화 동작들을 해줘야 할 경우 사용하기 좋습니다.
    val newTextView = TextView().apply {
      text = "New Text"
    }
    // also: 객체를 사용하는데, 생성 시점에 객체 내부의 함수 호출등으로 초기화 동작을 하는 것이 아닌 다른 요인으로 초기화 되는 경우 해당 객체를 argument로 넘겨줘야 하므로 이를 code block 내부에서 it으로 전달해 줄 때 사용하기 좋습니다.
    val display = getSystemService(WindowManager::class.java).defaultDisplay
    val metrics = if (display != null) {
        DisplayMetrics().also { display.getRealMetrics(it) }
    } else {
        Resources.getSystem().displayMetrics
    }
    // let: 거의 대부분 nullable 객체를 다룰때 safe call과 함께 적용해서 사용하기에 좋습니다.
    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        intent?.data?.let { uri ->
            handleUri(uri)
        }
    }
  • takeIf, takeUnless: 말그대로 해당 객체가 특정 조건을 만족할때 or 만족하지 않을때만 사용하고자 할때 safe call과 함께 사용됩니다. 개인적으로는 takeUnless는 한번 더 꼬아 생각하는 느낌이라, 가능한 takeIf만 사용하곤 합니다.

    imageList.takeIf { it.isNotEmpty() }?.let { 
      notifyDataSetChanged() 
    }
    imageList.takeUnless { it.isEmpty() }?.let {
      notifyDatasetChanged()
    }

그 외 디테일한 Kotlin 개념 및 예시들

//TODO 추후 시간 될 때 다룰 내용들..

  • crossinline
  • typealias
  • generic: in / out / where
  • nested / inner
  • SAM
  • Delegation
  • Java Annotations
  • label
profile
Best Ongoing Man, BOM. 최선의 자세로 살아 가고자 합니다. 모두의 마음에 봄의 씨앗이 자라길

1개의 댓글

comment-user-thumbnail
2023년 7월 22일

좋은 글 감사합니다.

답글 달기