Kotlin의 Extension은 어떻게 동작하는가 part 2

이전 글에서 Extensions에 대한 기본적인 규칙들과 주의해야 할 점들을 간략하게 살펴보았습니다. 이번 글에서는 좀 더 다양한 형태의 Extensions들을 살펴보도록 하겠습니다.

Extensions에 대한 기본적인 내용이 궁금하시다면 ‘Kotlin의 extensions는 어떻게 동작하는가 part 1’을 참조하시기 바랍니다.

Extension property 101

이전 글에서 함수 형태의 확장으로 예를 들었던 fun String.hello() : String { return “Hello, $this” }를 기억하실 것이라 생각합니다. 눈치가 조금 빠르신 분이라면 다음과 같은 궁금증을 떠올리셨을 것입니다.

‘그렇다면 프로퍼티에 대해서도 확장이 가능한가?’

물론 가능합니다. 다른 점이 있다면 Extensions는 정적인 접근 코드를 생성하는 방식이므로, 실제 저장할 필드를 해당 클래스에 생성할 수는 없고, 대신 getter/setter를 확장할 수 있습니다.

data class Length(var centimeters: Int = 0)

var Length.meters: Float
get() {
return centimeters / 100.0f
}
set(meters: Float) {
this.centimeters = (meters * 100.0f).toInt()
}

이를 통해 아래와 같이 Length 클래스에서 정의하고 있는 프로퍼티가 아님에도 meters를 통해 호출이 가능할 것입니다.

fun main(args: Array<String>) {
val length = Length(30)
println("centimeters = ${length.centimeters}")
println("meters = ${length.meters}")

println()

length.meters = 1.23f
println("centimeters = ${length.centimeters}")
println("meters = ${length.meters}")
}

이 코드의 실행 결과값은 아래와 같습니다.

centimeters = 30
meters = 0.3
centimeters = 123
meters = 1.23

디컴파일 코드를 보면 다음과 같이 getter/setter에 대응하는 정적 함수가 생성된 것을 확인할 수 있습니다.

public final class ExtensionsKt {
public static final float getMeters(
@NotNull Length $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
return (float)$receiver.getCentimeters() / 100.0F;
}
   public static final void setMeters(
@NotNull Length $receiver, float meters) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
$receiver.setCentimeters((int)(meters * 100.0F));
}
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
Length length = new Length(30);
String var2 = "centimeters = " + length.getCentimeters();
System.out.println(var2);
var2 = "meters = " + getMeters(length);
System.out.println(var2);
System.out.println();
setMeters(length, 1.23F);
var2 = "centimeters = " + length.getCentimeters();
System.out.println(var2);
var2 = "meters = " + getMeters(length);
System.out.println(var2);
}
}
public final class Length {
...
}

Getter/Setter에 한정된 확장임을 주의

Extension property는 getter/setter에 대응하는 정적 함수를 생성합니다. 따라서, 함수의 경우와 마찬가지로 클래스에 멤버로써 프로퍼티 필드를 추가하는 것이 아니라는 점을 주의해야 합니다.

보다시피 앞의 예제에 대한 생성 코드에 setter/getter에 대응하는 정적 메소드는 존재하지만, meters 필드는 존재하지 않습니다.

레퍼런스에서 이를 “Backing field가 없다.”라는 말로 표현하고 있는데, 이는 Extensions가 실제 클래스를 확장하는 것이 아닌 대상 클래스를 인자로 받는 정적 메소드를 생성하는 형태로 구현됨을 이해한다면 별로 복잡한 내용은 아닙니다.

backing field는 실제 데이터가 저장되는 저장소로써의 필드를 말합니다. 실제 필드가 존재하지 않으므로 Extension property에 대해서는 초기화를 허용하지 않습니다.

Nullable receiver

Nullable은 Kotlin만의 특징은 아니지만, 유용한 표현입니다. 특히 정적 함수를 생성하는 특성으로 인해 멤버 함수와는 달리 nullable에 대한 처리가 가능하므로, Extension을 생성할 때 역시 Nullable은 더욱 유용합니다.

멤버라면 당연히 멤버가 액세스할 인스턴스가 실제로 존재하여야 하기 때문에 오류가 나야할 대상입니다만 Extension에서 리시버는 정적함수의 매개 변수로써 호출되기 때문에 인스턴스의 존재 유무는 함수의 바디에서 처리할 수 있습니다.

Nullable을 리시버로 가지는 Extension을 보도록 하겠습니다.

fun Any?.toString(): String {
if (this == null) return "null"
    return toString()
}

디컴파일된 코드는 아래와 같습니다.

public final class DemoKt {
@NotNull
public static final String toString(@Nullable Object $receiver) {
return $receiver == null?"null":$receiver.toString();
}
}

보다시피 함수 내에서 Null 처리를 직접 해주어야 하지만, 어렵지 않을 것입니다. :)

Nullable receiver의 경우 멤버보다 extension이 우선됨에 주의

몇가지 케이스를 추가로 테스트해보도록 하겠습니다. 간단하게 위의 Nullable receiver extension을 호출해 보는 코드로 extension의 호출 여부를 파악하기 위한 로그 출력을 추가했습니다.

fun Any?.toString(): String {
println("Extension is called.")
if (this == null) return "null"
return toString()
}
fun main(args: Array<String>) {
val var1 : Any? = null
println(var1.toString())
val str1 : String? = null
println(str1.toString())
var str2 : String? = "hello"
println(str2.toString())
var str3 : String = "world"
println(str3.toString())
}

실행 결과는 다음과 같습니다.

Extension is called.
null
Extension is called.
null
Extension is called.
hello
world

뭔가 익숙하지 않은 동작을 느끼셨나요? 앞에서 항상 멤버가 우선한다고 얘기를 해왔는데, toString()의 실행 우선 순위가 멤버보다 높게 가지는 결과가 나왔습니다.

디컴파일된 코드를 통해 좀 더 자세히 알아보겠습니다.

public final class ExtensionsKt {
@NotNull
public static final String toString(@Nullable Object $receiver) {
String var1 = "Extension is called.";
System.out.println(var1);
return $receiver == null?"null":$receiver.toString();
}

public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
      Object var1 = null;
String str1 = toString(var1);
System.out.println(str1);
      str1 = (String)null;
String str2 = toString(str1);
System.out.println(str2);
      str2 = "hello";
String str3 = toString(str2);
System.out.println(str3);
      str3 = "world";
String var5 = str3.toString(); // NotNull은 여전히 멤버 함수 호출
System.out.println(var5);
}
}

str3에서 보다시피 NotNull 타입인 경우 멤버 함수인 toString()를 호출합니다만, 이전의 코드들은 모두 Extension을 호출하고 있습니다.

생각해보면 이유는 간단합니다. 동일한 시그니처를 가지는 멤버 함수에 대해 Nullable receiver로 정의된 extension을 통해 null 처리를 대신할 수 있도록 해주기 위함입니다.

그렇지 않다면 참조가 null인 상태에서 멤버가 먼저 호출되어 NPE를 발생하게 될 것입니다.

클래스 내에서 선언된 Extension

클래스 내에 Extension을 선언하면 어떻게 될까요? 다음 예제를 보겠습니다.

// 클래스 밖
fun Any?.toString(): String {
if (this == null) return "null"
return toString()
}

// 클래스 내
class test {
fun Any?.toString(): String {
if (this == null) return "null"
return toString()
}
}

위의 코드에 대한 디컴파일 결과는 다음과 같습니다.

public final class ExtensionsKt {
@NotNull
public static final String toString(@Nullable Object $receiver) {
return $receiver == null?"null":$receiver.toString();
}
}
public final class test {
@NotNull
public final String toString(@Nullable Object $receiver) {
return $receiver == null?"null":$receiver.toString();
}
}

위 코드를 보면 클래스 내에 선언된 Extension은 선언된 클래스 내에 생성된다는 것을 알 수 있습니다.

Extension은 선언된 범주(Scope)를 그대로 따라가는 특성으로 클래스 내에서 선언될 경우 해당 클래스의 멤버에 자유롭게 접근할 수 있으므로 다음과 같이 특정 클래스 내에서 한정적으로 사용할 유틸리티를 작성할 수 있습니다.

data class Url(val domain: String, val port: Short)

class Connection {
private lateinit var connectedUrl: Url
    constructor(domain: String, port: Short) {
this.connectedUrl = Url(domain, port)
}

fun Url.toURL(protocol: String) =
"$protocol://$domain:$port"

override fun toString() = connectedUrl.toURL("https")
}
fun main(args: Array<String>) {
val conn = Connection("cwdoh.com", 442)
print(conn)
}
아쉽게도 좋은 예제는 아닙니다. ;)

디컴파일된 소스는 다음과 같습니다.

public final class Connection {
private Url connectedUrl;
   @NotNull
public final String toURL(@NotNull Url $receiver,
@NotNull String protocol)
{
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
Intrinsics.checkParameterIsNotNull(protocol, "protocol");
      return "" + protocol + "://" + $receiver.getDomain()
+ ':' + $receiver.getPort();
}
   ...
}

What are Today I learned:

이 글에서는 프로퍼티를 대상으로 하는 Extension과 주의해야할 추가 규칙에 대해 알아보았습니다. 간략하게 오늘 내용을 정리하면 다음과 같습니다.

  • 기본적으로 Extension의 대상은 함수입니다만, setter/getter를 가지는 프로퍼티의 특징을 이용하여 프로퍼티의 확장이 가능합니다.
  • 단, getter/setter에 한정된 확장이므로 backing field가 존재하지 않는다는 점에 주의하여야 합니다.
  • Nullable receiver를 통해 리시버가 null인 경우에도 NPE가 없는 extension을 구현할 수 있습니다. 단, 멤버보다 extension이 우선됨에 주의하여야 합니다. (일반적인 경우 멤버가 우선입니다.)
  • 클래스 내에 선언된 extension은 클래스 내의 멤버에 자유롭게 접근할 수 있으므로, 특정 클래스에 한정적인 유틸리티가 필요할 경우 유용합니다.