Kotlin interface에는 왜 상수를 정의할 수 없을까?



개인 광고 영역

Kotlin의 interface에서는 상수를 정의할 수 없다. 상수를 허용하지 않는 이유는 Java에서 이유를 찾는 게 빠르다.

이 글에서는 Java의 interface에서 왜 상수를 정의하지 않는 게 좋은지에 대해 알아본다.

수정. java 7이 아닌 java 8에서 interface의 default/static이 추가되어 수정합니다.


이 글에서 알아볼 내용

  • Java interface의 상수를 알아보고, 문제점을 알아본다.
  • kotlin interface의 상수 정의가 왜 없는지를 Java를 통해 알아본다.
  • Java 8의 interface에 추가된 default/static 사용에 대해 알아보고, 코틀린의 default 함수도 알아본다.


자바의 interface

Java에 익숙한 개발자들은 대부분 interface에 상수를 지정하고, 이를 활용한다. Effective Java를 읽은 분들은 사용하지 않을 수도 있다.

Effective Java에서 interface에 static final로 지정하는 상수에 대해 사용하지 말라고 이야기하고 있다.

그 이유는 요약하면 아래와 같다.

  • interface에 정의한 상수는 상속받는 각각의 클래스에서도 그대로 복사된다.
  • 상속받은 각각의 클래스에서도 interface의 상수가 아닌 각각의 클래스의 상수 접근이 가능하다.
  • interface에서는 정의를 넘어 상수를 사용하게 되면 사용자에게 혼란을 줄 수 있다.
  • 외부 구현이 아닌 내부 구현에 대한 상수들로 인해 혼란을 줄 수 있다.

마지막 부분은 java interface에서는 static final로 정의하기 때문에 항상 public으로 접근할 수 있다. 이를 해결하기 위해 상속받은 클래스에서 정의하는 게 좋다. 다만 이런 중복 값을 해결하기 위함이니 package level로 정의하고, 내부 정의해 사용하도록 작업하는 게 좋다.


Java interface의 상수는?

먼저 Java의 interface를 알아보자. 아래와 같은 상수를 중복으로 정의하고 사용했다면 어떤 값이 출력될까?

첫 번째 출력은 extra_name 두 번째는 extra_value이 출력된다.

public class JavaInterfaceTest {

    @Test
    public void test() {
        System.out.println(JavaInheritance.EXTRA_NAME); // extra_name
        System.out.println(JavaInheritance.EXTRA_VALUE); // extra_value
    }

    interface JavaTest {
        String EXTRA_NAME = "Name";
        String EXTRA_VALUE = "extra_value";
    }

    class JavaInheritance implements JavaTest {

        private static final String EXTRA_NAME = "extra_name";
    }
}

JavaInheritance 내부에 있는 EXTRA_NAME이 우선이기 때문에 이를 출력한다.

이 코드는 두 가지 설계 문제가 있다.

  • 외부에서 interface만 보는 사람은 EXTRA_NAME은 항상 Name 일 것으로 예측하고 사용한다.
  • EXTRA_VALUE는 상속받은 JavaInheritance에 정의하지 않았지만 접근 가능하고, 동일한 메모리 주소를 바라보겠지만 변수가 하나 더 추가되었다.

위 코드는 inner class로 정의해서 test 함수에서 접근이 가능했다. 하지만 아래와 같이 서로 다른 파일 3개로 존재해야 하니 수정하고, isSameExtraName과 isSameExtraValue를 추가했다.

public class JavaInterfaceTest {

    @Test
    public void test() {
        JavaInheritance inheritance = new JavaInheritance();
        System.out.println(inheritance.isSameExtraName(JavaTest.EXTRA_NAME)); // return false
        System.out.println(inheritance.isSameExtraValue(JavaTest.EXTRA_VALUE)); // return true
    }
}


interface JavaTest {
    String EXTRA_NAME = "Name";
    String EXTRA_VALUE = "extra_value";
}

class JavaInheritance implements JavaTest {

    private static final String EXTRA_NAME = "extra_name";

    public boolean isSameExtraName(String key) {
        return EXTRA_NAME.equals(key);
    }

    public boolean isSameExtraValue(String key) {
        return EXTRA_VALUE.equals(key);
    }
}

JavaTest interface를 사용하는 입장에서는 당연하게도 EXTRA_NAME, EXTRA_VALUE 모두가 true가 나와야 한다. 하지만 누군가 실수로 동일한 이름으로 JavaInheritance에 똑같은 이름의 EXTRA_NAME을 새로 정의하면서 false, true가 리턴되었다.


정리하면

결국 Java의 interface에 정의할 수 있는 상수는 2가지 문제점을 안고 있다.

  • interface에 상수를 정의하더라도, 상속받은 클래스에서 상수를 다시 한번 재정의 할 수 있다.
  • interface에 상수를 정의하고, 이를 상속받는 자식들 역시 클래스 내에 상수를 다 가지고 있다.

결국 재정의도 할 수 있고, 똑같은 상수를 1곳 이상에서 나눠가지고 있게 된다. 결국 인터페이스는 타입을 정의하는 용도로만 사용해야 함을 알 수 있다.


코틀린의 interface

다행히도 코틀린 interface는 자바에서 생길 수 있는 상수 문제를 사전에 방지해 제공하지 않는다.

인터페이스 본연의 작업을 가질 수 있도록 kotlin interface는 정의만을 허용한다.

kotlin interface에 아래와 같이 정의하면 즉시 오류가 발생한다.

interface KotlinTest {

    val EXTRA_NAME = ""
}

Property initializers are not allowed in interfaces

interface에 정의하는 Property에서는 초기화를 할 수 없다. 말 그대로 정의만 하라는 의미다.

그래도 상수는 필요하다. 그래서 아래처럼 사용하는 건 가능하다. 이러한 상수는 자바에서 보았던 재정의 문제도 없는데, Java에서는 KotlinTest.EXTRA_NAME, KotlinTestEXTRA_VALUE로만 접근할 수 있고, 상속을 받는다고 복사하지 않는다.

interface KotlinTest {

    companion object {
        const val EXTRA_NAME = "name"
        const val EXTRA_VALUE = "extra_value"
    }
}

참고로 디컴파일하면 다음과 같이 Companion이라는 새로운 static 클래스를 내부에 생성한다. 그렇기 때문에 상속을 받더라도, Companion 내부의 EXTRA_NAME과 EXTRA_VALUE로의 접근은 불가능하다.

public interface KotlinTest {

  public static final class Companion {
      @NotNull
      public static final String EXTRA_NAME = "name";
      @NotNull
      public static final String EXTRA_VALUE = "extra_value";

      // 생략
   }
}

그렇다고 하더라도 다음과 같이 동일한 이름으로 내부에 정의하는 건 가능하기 때문에 자바에서 생겼던 동일한 문제는 여전히 존재한다.

class KotlinInheritance : KotlinTest {

    companion object {
        private const val EXTRA_NAME = "extra_name"
    }

    fun isSameExtraName(key: String): Boolean {
        return EXTRA_NAME == key
    }
}


동일한 네이밍은…

동일한 네이밍은 자바든 코틀린이든 생길 순 있다. 코드 리뷰를 통해 사전에 확인하는 게 좋으나, 그전에 interface에서 상수 정의를 하지 않는 편이 가장 확실한 방법이다.

인터페이스에서 이런 값을 지정하지 않으면 이런 문제는 사전에 해결할 수 있기 때문이다.


Java 8 이상의 default/static은?

Java 8에서는 interface에 default와 static이라는 새로운 키워드를 통해 함수를 정의할 수 있다.

다만 Java 8에서는 default는 상속을 통해 재정의 할 수 있고, static은 재정의는 불가능하지만 동일한 이름으로 함수를 생성할 수 있다.

코드를 보기 전에 문제를 찾아보자.

  • default는 상속을 받으면 재정의할 수 있다.
    • override를 통해 재정의할 수 있고, 접근할 수 있다.
  • static은 상속은 불가능하지만 동일한 이름과 parameter를 만들 수 있다.
    • 모두 허용 가능하다.

문제의 소지는 여전히 있다. 다만 상속을 받더라도, 자바 특성상 override 키워드 누락은 문제가 없다.

이를 활용해 코드를 작성하면 아래와 같다.

interface JavaTest {
    String EXTRA_NAME = "Name";
    String EXTRA_VALUE = "extra_value";

    static boolean isSameExtraName(String key) {
        return EXTRA_NAME.equals(key);
    }

    default boolean isSameExtraValue(String key) {
        return EXTRA_VALUE.equals(key);
    }
}

class JavaInheritance implements JavaTest {

    public boolean isSameExtraName(String key) {
        return EXTRA_NAME.equals(key);
    }

    public boolean isSameExtraValue(String key) {
        return EXTRA_VALUE.equals(key);
    }
}

아래와 같이 접근해도 정상 동작한다.

public class JavaInterfaceTest {

    @Test
    public void test() {
        JavaInheritance inheritance = new JavaInheritance();
        System.out.println(inheritance.isSameExtraName(JavaTest.EXTRA_NAME));
        System.out.println(inheritance.isSameExtraValue(JavaTest.EXTRA_VALUE));
    }
}


코틀린에서도 default 함수를 만들 수 있다.

코틀린도 default 함수를 생성할 수 있는데 아래와 같지만 한 가지 다른 건 override를 강제한다는 점이다.

interface KotlinTest {

    companion object {
        const val EXTRA_NAME = "name"
        const val EXTRA_VALUE = "extra_value"
    }

    fun isSameExtraName(key: String): Boolean {
        return EXTRA_NAME == key
    }
}

class KotlinInheritance : KotlinTest {

    companion object {
        private const val EXTRA_NAME = "extra_name"
    }

    // 오류 발생
    fun isSameExtraName(key: String): Boolean {
        return EXTRA_NAME == key
    }

    fun isSameExtraValue(key: String): Boolean {
        return EXTRA_VALUE == key
    }
}

‘isSameExtraName’ hides member of supertype ‘KotlinTest’ and needs ‘override’ modifier

override 키워드는 항상 강제다 그래서 아래와 같이 override만 붙여주어도 문제가 없다.

override fun isSameExtraName(key: String): Boolean {
    return EXTRA_NAME == key
}

결국 코틀린은 명확한 override 임을 명시해 줘야 하는 차이가 있다.


Kotlin의 default를 디컴파일 하면

코틀린의 default 함수를 정의하고 디컴파일 해보면 조금 다른 결과를 볼 수 있다.

이유는 java 6를 타겟으로 컴파일 하기 때문인데, 안드로이드 스튜디오 Preferences에서 Other Settings > Kotlin Compiler > Kotlin to JVM의 타겟을 확인할 수 있다.

sample_01

1.6을 타겟으로 디컴파일하면 아래와 같다.

target을 1.8로 수정한다고 해서 크게 다르지 않다.

public interface KotlinTest {

   // 생략

   boolean isSameExtraName(@NotNull String var1);

   // 생략
   public static final class DefaultImpls {
      public static boolean isSameExtraName(@NotNull KotlinTest $this, @NotNull String key) {
         Intrinsics.checkNotNullParameter(key, "key");
         return Intrinsics.areEqual("name", key);
      }
   }


그럼 상위는 어떻게 접근할까?

java에서 이미 언급은 했지만 접근하는 방법은 간단하다.

먼저 자바는 아래와 같이 super에 접근할 수 있다.

interface JavaTest {
    String EXTRA_VALUE = "extra_value";

    static boolean isSameExtraName(String key) {
        return EXTRA_NAME.equals(key);
    }

    default boolean isSameExtraValue(String key) {
        return EXTRA_VALUE.equals(key);
    }
}

class JavaInheritance implements JavaTest {

    // static에 접근
    public boolean isSameExtraName(String key) {
        return JavaTest.isSameExtraName(key);
    }

    // default에 접근
    public boolean isSameExtraValue(String key) {
        return JavaTest.super.isSameExtraValue(key);
    }
}

kotlin 역시 자바처럼 명시해 주는 형태로 접근한다.

interface KotlinTest {

    companion object {
        const val EXTRA_NAME = "name"
    }

    fun isSameExtraName(key: String): Boolean {
        return EXTRA_NAME == key
    }
}

class KotlinInheritance : KotlinTest {

    // default에 접근
    override fun isSameExtraName(key: String): Boolean {
        return super<KotlinTest>.isSameExtraName(key)
    }
}


다중 상속 시 동일한 함수가 있다면

Java든 코틀린이든 다중 상속에서 동일한 함수가 있다면 override가 필수이다. Java는 @override 어노테이션 정의가 필수는 아니다.

kotlin 역시 다중 상속에서 동일한 함수가 있다면 override를 해야 하고, override역시 필수로 적어줘야 한다.

두 개 모두 접근은 아래와 같은데 먼저 자바는

클래스이름.super.함수명

코틀린도 아래와 같다.

super<클래스이름>.함수명


마무리

Kotlin interface에선 상수를 사용하지 못한다에서 시작한 글이다. 이유는 모두 Effective Java에 나오지만 문제도 역시 자바에 있다. 조금 더 잘 알고 사용하면 유용한 설계를 할 수 있으니 정리해보았다.

이번 글 요약

  • Java interface는 상수를 사용하지 않는 게 좋다.
  • Java interface에 상수를 정의하면 상속받는 모든 클래스에도 같은 상수를 새로 만든다.
  • Java interface에 상수를 정의하면 지속적인 관리가 필요하다.
  • kotlin은 interface에 상수 정의를 허용하지 않는 방법(Effective Java)에 따라 제공하지 않고 있다.
  • interface는 정의 용도로 만 활용하도록 좋다.
  • Java 8의 default/static을 통해 기본 함수를 제공한다.
  • Java7의 default는 override 할 때 주의해 사용할 것
  • Kotlin도 default를 제공하지만, 내부의 static class로 별도로 정의해 사용한다.
  • Java와 kotlin 모두 default에 접근 시 주의해 사용할 것


이 글만 보기 아쉽죠. 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