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

잘 정의된 객체는 언제나 코드를 간결하게 만들어 주지만, 언제나 그렇지 못한 상황이 찾아온다는 것을 경험에서 알고 있을 것입니다. 이를 해결하기 위해 우리는 클래스를 상속하여 기능을 확장하거나, 모듈 내의 코드나 패턴을 조합하거나 유틸리티로 새로 정의하여 사용하고 있습니다.

좀 더 쉬운 방법은 없을까요? 최소한 Kotlin의 경우, Extensions가 아마도 도움이 될 수 있을 것 같습니다.

Extensions 101

Kotlin은 확장(Extension)이라는 기능을 통해 간단하게 객체의 함수나 프로퍼티를 임의로 확장 정의하여 사용할 수 있습니다. Extension의 장점은 다음과 같습니다.

  • 유틸리티 클래스 등을 별도로 지정하지 않고, 직접적인 객체 확장의 방법을 제공합니다.
  • 함수와 프로퍼티 양측에 대한 확장을 지원합니다.
  • Generic을 통해 객체의 타입을 처리할 수 있습니다.
  • Extension이 적용될 범위(Scope)를 지정할 수 있습니다.

Extension function의 예

먼저 확장 함수(Extension functions)에 대해 알아보도록 하겠습니다. 만약 String 타입에 hello()라는 이름의 확장 함수를 구현하고자 한다면 다음과 같이 작성할 수 있을 것입니다.

fun String.hello() : String {
return "Hello, $this"
}
fun main(args: Array<String>) {
val whom = "cwdoh"
println(whom.hello())
}
// Result
Hello, cwdoh

보시다시피 마치 String의 함수인 것처럼 hello()를 정의하여 사용할 수 있습니다. 별도의 인터페이스를 선언할 필요도 유틸리티나 상속 역시 필요없는 아주 간단한 방식입니다. :)

몇가지 규칙들

편리함을 주는 모든 것은 반대로 각자의 그림자를 가지고 있습니다. Extension을 사용할 때 알아야 할 몇가지 규칙을 살펴보도록 합시다.

규칙.1 Extensions은 정적으로 처리된다.

앞에서 본 예제를 디컴파일한 코드는 다음과 같습니다.

public final class ExtensionsKt {
@NotNull
public static final String hello(@NotNull String $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
return "Hello, " + $receiver;
}
   public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
String whom = "cwdoh";
String var2 = hello(whom);
System.out.println(var2);
}
}

확장 대상(Receiver)인 String을 인자로 받는 static final로 메소드가 생성됩니다. 이는 클래스 자체가 확장된 것이 아니라, 정적인 메소드 형태로 코드가 생성되었으므로 ,객체 멤버 접근에 제한이 존재할 수 있다는 뜻으로 해석할 수 있습니다. 이 특성은 ”Extensions are resolved statically”에서 다음과 같이 설명하고 있습니다.

Extension은 실제로 클래스를 상속/수정하지 않습니다. 클래스에 새 멤버를 삽입하지 않고 단순히 해당 타입의 변수에 dot(.)을 기반으로 호출 가능한 함수를 생성합니다.
extension이 리시버의 타입에 의한 가상 함수가 아니라 정적으로 처리된다는 점을 강조하고 싶습니다. 이는 호출되는 확장 함수는 표현식의 타입에 따라 결정된다는 것을 의미합니다.

이를 이해하기 위해 공식 예제를 확인해보도록 하겠습니다.

open class C
class D: C()
fun C.foo() = "c"
fun D.foo() = "d"
fun printFoo(c: C) {
println(c.foo())
}
class Demo {
fun run() {
printFoo(D())
}
}

일반적인 객체의 멤버라면 실제로 참조하고 있는 자신의 타입을 기반으로 함수를 호출하겠지만, Extension의 경우 다음과 같이 코드가 생성되는 것을 확인할 수 있습니다.

public final class Demo {
public final void run() {
DemoKt.printFoo((C)(new D()));
}
}
// ...
public final class DemoKt {
@NotNull
public static final String foo(@NotNull C $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
return "c";
}
   @NotNull
public static final String foo(@NotNull D $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
return "d";
}
   public static final void printFoo(@NotNull C c) {
Intrinsics.checkParameterIsNotNull(c, "c");
String var1 = foo(c);
System.out.println(var1);
}
}

즉, printFoo()에서 D의 객체를 생성하여 전달하기 때문에 D.foo()를 호출 할 것 같지만 Extension은 정적으로 처리되는 이유로 printFoo()에서 사용하고 있는 타입인 C.foo()를 호출하게 됩니다.

규칙.2 Extension보다 멤버가 우선이다.

이미 클래스에 동일한 시그니처(Signature)를 가지는 멤버가 있을 때에도 Extension를 정의할 수 있습니다. 다음 예제를 봅시다.

class Person {
fun hello() { println("hello!") }
}
fun Person.hello() { println("HELLLLLLOOOOOOOOO!!!!") }
fun main(args: Array<String) {
Person().hello()
}
// Result
hello!

보다시피 동일한 시그니처를 가지는 멤버 함수가 존재한다면 언제나 멤버가 호출됩니다. 이는 멤버가 Extensions에 대해 항상 우선권을 가지기 때문입니다. 디컴파일된 코드를 통해 이를 확인해봅시다.

public final class Person {
public final void hello() {
String var1 = "hello!";
System.out.println(var1);
}
}
public final class ExtensionsKt {
public static final void hello(@NotNull Person $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
String var1 = "HELLLLLLOOOOOOOOO!!!!";
System.out.println(var1);
}

public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
(new Person()).hello();
}
}

보시다시피 동일한 시그니처의 멤버가 존재하기 때문에 이를 호출하도록 (new Person()).hello(); 로 코드가 생성된 것을 확인할 수 있습니다.

이렇게 멤버에 대한 우선 순위가 적용되는 것은 별도로 에러가 발생하지 않기 때문에 조심해야 하는 부분입니다. 물론 시그니처가 다를 경우는 해당되지 않습니다.

규칙 3. Extension 역시 범위(Scope)를 가진다.

Extensions의 선택적인 import가 가능합니다. 간단한 내용이므로, 별도로 설명은 하지 않도록 하겠습니다.

또한, 일반적인 멤버와 동일하게 Extensions을 클래스의 멤버로 선언하면 해당 클래스 내에서만 범위가 결정됩니다. 간단하게 말하자면, 클래스의 멤버로 선언된 Extension은 당연히 자신이 선언된 클래스의 멤버에 자유롭게 액세스할 수 있습니다.

class D {
fun bar() { println("D.bar()") }
}
class C {
fun baz() { println("C.bar()") }
    fun D.foo() {
bar() // calls D.bar
baz() // calls C.baz
}
    fun caller(d: D) {
d.foo() // call the extension function
}
}

위의 예제에서 C 클래스 내에 선언된 D.foo() extension의 경우 C 클래스 내에서만 유효합니다. 디컴파일된 코드를 살펴보면 다음과 같습니다.

public final class C {
public final void baz() {
String var1 = "C.bar()";
System.out.println(var1);
}
   public final void foo(@NotNull D $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
$receiver.bar();
this.baz();
}
   public final void caller(@NotNull D d) {
Intrinsics.checkParameterIsNotNull(d, "d");
this.foo(d);
}
}
public final class D {
public final void bar() {
String var1 = "D.bar()";
System.out.println(var1);
}
}

만약, scope로 인하여 this의 대상이 혼동될 경우는 @을 통해 이를 처리할 수 있습니다.

class D {
fun bar() { println("D.bar()") }
}
class C {
fun D.foo() {
toString() // calls D.toString()
this@C.toString() // calls C.toString()
}
}

디컴파일된 코드는 다음과 같습니다.

public final class C {
public final void foo(@NotNull D $receiver) {
Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
$receiver.toString();
this.toString();

}
}
public final class D {
public final void bar() {
String var1 = "D.bar()";
System.out.println(var1);
}
}

What are Today I learned:

Extension은 인스턴스의 멤버가 아닌 정적인 별도 형태로 코드가 생성됩니다. 이는 인스턴스에 따라 멤버가 호출되는 일반적인 객체 지향/기반 형태와는 달리 코드 내의 표현식이 가지는 타입에 따라 접근 대상이 결정됨을 의미합니다.

이 글에서는 위 특성으로 인해 발생하는 몇가지 규칙에 대해 알아보았습니다. 간략하게 오늘 내용을 정리하면 다음과 같습니다.

  • Extension은 정적인 함수로 코드가 생성되므로, 인스턴스가 아닌 현재 참조되는 타입에 따라 호출됩니다.
  • 멤버 우선, import 실수 등을 방지하기 위해 되도록 쉽게 구분이 가능하도록 함수의 이름이나 시그니처(Signature)를 잘 정의하도록 하여야 합니다.
  • 특정한 클래스 내에서 사용되는 경우 멤버로 선언하여 안전하게 사용하는 것이 좋습니다.

다시 한번 강조하지만, 범위(scope)를 주의하고, 멤버 등과의 혼용이 일어나지 않도록 네이밍 등에 신경을 쓰는 것이 좋은 방식이 될 수 있을 것 같습니다.

part 2에서 뵙겠습니다! :)