Kotlin extensions 정의를 알아보고, 활용 시 주의할 부분을 알아보자.



개인 광고 영역

Kotlin에서는 extensions의 사용은 매우 자유롭다. 기존 클래스에 포함되어 있지 않은 Member를 새롭게 구성해 원하는 형태를 사용할 수 있다.

이번 글은 extensions 정의 시 알아두면 좋을 부분을 소개한다.


이 글에서 알아볼 내용

  • Kotlin Extensions 정의를 알아본다.
  • Kotlin extensions 활용 시 알아야 할 점을 알아본다.
  • Kotlin extensions을 특정 클래스 내에 정의할 때 주의할 점을 알아본다.


Kotlin extensions 정의하기

Kotlin extensions 정의는 Top level에 아래와 같이 정의한다.

sample_01

fun 바로 뒤에는 Receiver type을 정의하고, Nullable이 필요하다면 ?을 추가한다. 그리고 Receiver type과 함수의 이름 사이에는 .으로 구분한다.

그리고 Receiver type으로 받은 object는 this로 접근해 사용할 수 있다.

이런 방법은 Higher-Order functions에서도 동일하게 사용하는데 아래와 같다.

sample_02

this는 필수는 아니다. 하지만 위 2 개의 샘플에서는 생략이 불가능하다. 상황에 따른 this 생략이 가능하니 참고하시길

fun String.isNotEmpty() = length > 0


Property에서 extensions 정의하기

Property에서도 extensions을 Top level에서 정의가 가능한데 아래와 같다. 이때도 function 정의와 동일하게 this를 생략해 사용할 수 있다.

sample_03

Property로 extensions을 정의할 때는 기존에 작성했던 글을 참고해 정의하는 게 좋으니 참고하시길

읽기 좋은 Kotlin 코드! Property와 Scope Functions!


정의하기는 쉽다.

사용하기 편한 만큼 정의하기도 쉽다. 하지만 Higher-Order functions과 함께 사용한다면 기존에 소개했던 inline 관련 글을 참고해 사용하길 바란다.

Kotlin inline class와 inline functions을 알아보고, 적절하게 사용하는 방법을 살펴보자.

inline을 제외한 extensions 정의할 때 주의해야 할 부분을 이 글에서 알아보도록 하겠다.


동일한 이름의 확장 함수를 생성해도 항상 Member를 우선 호출한다.

보통 이렇게 사용할 일은 없다. 이미 알고 있듯 동일한 이름의 extensions을 정의하더라도 호출하지 않는다.

당연하게도, Member에 포함되어 있는 함수를 우선 호출한다.

class Sample {
    fun name() = "Sample"
}

fun Sample.name() = "Extensions Sample"

@Test
fun test() {
    Sample().name()
}

이렇게 사용했다면 이미 워닝이 발생하고, 실제 호출하지 않음을 알 수 있다.

sample_04


동일한 이름의 return만 달라도 문제가 있다.

이번엔 동일한 이름에 return 결과만 다르게 구성했다.

class Sample {
    fun name() = "Sample"
}

fun Sample.name(): Boolean = false

@Test
fun test() {
    Sample().name()
}

이 역시 위에서 보았던 결과와 동일하다. 동일한 이름이기 때문에 Member가 우선해 호출된다.

결과는 위와 같은 결과가 나온다.

sample_05

결국 동일한 이름으로 코드를 작성했다면 아무 의미 없다는 것이다.


확장 함수는 확장 함수 일뿐

extensions을 정의했다고, class의 Member에 포함하지 않는다. 일반적인 유틸을 정의하는 것과 동일하게 만들어지고, 코틀린에선 이를 확장의 형태로 제공해 준다.

위에서 정의한 Sample class는 아래와 같이 디컴파일됨을 알 수 있다.

public final class Sample {
   @NotNull
   public final String name() {
      return "Sample";
   }
}

확장 함수는 Java 디컴파일의 결과와 같이 Sample classparameter로 전달받는 일반적인 자바 함수가 만들어진다. 그리고 Top level에 정의했기에 static final로 만들어짐을 알 수 있다. final 키워드는 override를 할 수 없음을 알고 있다.

public static final boolean name(@NotNull Sample $this$name) {
   Intrinsics.checkNotNullParameter($this$name, "$this$name");
   return false;
}


확장 함수는 상속과 동일한 결과를 가진다.

확장 함수도 상속 구조에서는 동일한 결과를 가진다.

open Sample class를 정의하고, SampleTwo는 open Sample class를 상속받도록 정의한다.

open class Sample
class SampleTwo : Sample()

아래와 같이 각각을 확장하는 함수 2개를 동일한 이름으로 확장한다.

fun Sample.name() = "Sample"
fun SampleTwo.name() = "SampleTwo"

이를 다음과 같이 테스트한다면 당연하게도 각각이 정의한 타입에 따라 확장 함수를 호출한다.

// 주석 부분 오류로 정정합니다.

-> SampleTwo()의 name은 SampleTwo를 출력하고, Sample로 형변환 후에는 SampleTwo가 아닌 Sample을 출력한다.

@Test
fun test() {
    val sampleTwo: SampleTwo = SampleTwo()
    println("sample two name : ${sampleTwo.name()}") // SampleTwo 출력
    val sample: Sample = sampleTwo
    println("sample name : ${sample.name()}") // Sample 출력
}

자바의 상속과 크게 다르지 않다. sampleTwo를 Sample로 변환했으니 Sample.name()을 호출한다. 만약 SampleTwo.name()을 호출할 것이라고 예측했다면 잘못된 결과이다.

먼저 정의한 2개의 확장 함수를 디컴파일 결과로 살펴보면 parameter로 Sample과 SampleTwo로 전달받음을 알 수 있다.

public final class ExtensionsSampleKt {
   @NotNull
   public static final String name(@NotNull Sample $this$name) {
      Intrinsics.checkNotNullParameter($this$name, "$this$name");
      return "Sample";
   }

   @NotNull
   public static final String name(@NotNull SampleTwo $this$name) {
      Intrinsics.checkNotNullParameter($this$name, "$this$name");
      return "SampleTwo";
   }
}

사용하는 쪽의 코드는 다음과 같은데, SampleTwo를 Sample로 형 변환했으니 name() 리턴 결과는 Sample을 결과로 리턴함을 알 수 있다. 결국 정적인 함수를 통해 접근함을 알 수 있다.

@Test
public final void test() {
   SampleTwo sampleTwo = new SampleTwo();
   String var2 = "sample two name : " + ExtensionsSampleKt.name(sampleTwo);
   boolean var3 = false;
   System.out.println(var2);
   Sample sample = (Sample)sampleTwo;
   String var6 = "sample name : " + ExtensionsSampleKt.name(sample);
   boolean var4 = false;
   System.out.println(var6);
}


class 내에 정의하는 extensions 사용 시 주의할 점

class 내에서도 확장 함수를 정의하는 건 가능하다.

아래와 같이 Use 클래스 내에 Sample을 확장하는 함수를 추가로 정의했다.

class Sample

class Use {
    fun Sample.age() = 100
}

그리고 Sample을 생성하고, Sample의 Sample.age에 접근을 시도했지만 접근할 수 없다.

@Test
fun test() {
    val sample: Sample = Sample()
    sample.age() // 찾을 수 없다.
}

이 확장 함수의 디컴파일 결과에서 볼 수 있듯 static이 포함되어 있지 않고, Use의 멤버 변수로 정의되어 있다. 이는 코틀린에서는 확장 함수 형태로 접근을 허용하지 않지만, Java에서는 접근할 수 있다.

public class Use {
   public final int age(@NotNull Sample $this$age) {
      Intrinsics.checkNotNullParameter($this$age, "$this$age");
      return 100;
   }
}

Effective kotlin을 참고하면 가급적 사용하지 않는 게 좋으나 필요하다면 사용할 수 있고, 대신 private으로 접근 제한을 하고 사용하라고 가이드하고 있다.


Effective kotlin을 일부 참고해 좀 더 알아보면

그럼 Effective Kotlin에서 어떤 이유로 필요하다면 private으로 정의하고, 코드에 유의해 사용하라고 할까?

먼저 Top level로 정의한 확장 함수는 레퍼런스 참조를 통한 접근이 가능한 반면, 특정 클래스에서 정의한 kotlin extensions인 Use의 age는 외부에서 레퍼런스 참조를 통한 접근이 불가능하다.

val ref = Sample::age
val str = Sample()
val boundedRef = str::age

가독성이 떨어질 수 있는데, 아래처럼 Sample과 Use class에 동일한 name 변수를 가지고 있고, Use class 내에 Sample에 대한 extensions을 정의했다.

이때 알 수 있는 부분은 extensions도 this를 생략할 수 있고, 클래스 내에서도 this를 생략할 수 있다. 결국 name이 어느 게 호출되었는지 알기가 어렵다.

이런 코드는 개발 중인 스튜디오에서는 잘 구분할 수 있다. 다만 코드 리뷰에서는 문제가 생길 수 있다.

class Sample {
    val name = "Sample"
}
class Use {
    val name = "Use"
    val nameTwo = "Use other"

    fun print() {
      // ...
    }

    fun Sample.name() = "$name $nameTwo" // name은 어느 클래스의 name 일까
    fun Sample.print() {
      print() // 이 print는 어느 print 일까
    }
}

그래도 이런 방식을 사용해야 한다면 다음의 규칙을 정하고 사용하길 바란다.

  • 필요하다면 이름을 명확하게 처리하거나, this를 명시해 준다.
  • 외부에서 접근을 차단하기 위한 private 정의한다.

결국 꼭 필요하다면 쓰고, 이런 문제가 있다는 걸 인지하고 있을 필요가 있다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments