Kotlin, 어렵지 않게 사용하기 (4) - Object 2

2022. 9. 22. 23:41Spring/Kotlin

kotlin의 객체 사용 방식과 용례를 Java 코드와 비교하며 학습하는 것이 해당 포스팅의 목표입니다.

🔗 Kotlin 시리즈 모아보기

 

안녕하세요. 이번 포스팅에서는 Kotlin의 객체와 관련된 기본 문법을 학습합니다.

Java와의 호환성을 크게 갖기 때문에, Java와 호환하여 정의 및 호출하는 방식을 익히도록 합니다.

 

 


 

 

sealed class

: 상위 클래스를 상속한 하위 클래스 정의를 제한

 

Expr 인터페이스를 생성하고 이를 구현하는 두 클래스를 정의해보자.

 

 

Expr 는 숫자를 표현하는 Num과 덧셈 연산을 표현하는 Sum이라는 두 하위 클래스가 있다.

 

interface Expr

class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

fun eval(e: Expr): Int =
        when (e) {
            is Num -> e.value
            is Sum -> eval(e.right) + eval(e.left)
            else ->     // else 분기 필수
                throw IllegalArgumentException ("Unknown expression")

 

when 식에서 이 모든 하위 클래스를 처리하면 편리하지만,

Num과 Sum이 아닌 경우가 존재할 수 있기 때문에 코틀린 컴파일러가 else 분기를 강제한다.

인터페이스를 구현하는 방식은 아래와 같다.

 

 

위 코드의 단점은 세가지로 정리할 수 있다.

- 항상 디폴트 분기를 추가해야한다.

- 하위 클래스의 모든 경우를 처리하는지  확인할 수 없다.

- 새로운 클래스 처리를 잊으면 디폴트 분기가 선택되면서 심각한 버그가 발생한다.

 

 

상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다.

sealed 클래스의 하위 클래스를 정의할 때는 아래 코드와 같이 반드시 상위 클래스 안에 중첩시켜야 한다.

 

 

sealed class Expr {     // 기반 클래스를 sealed로 봉인한다.
    class Num(val value: Int) : Expr()
    class Sum(val left: Expr, val right: Expr) : Expr()
}

// 기반 클래스의 모든 하위 클래스를 중첩 클래스로 나열한다.
fun eval(e: Expr): Int =
        when (e) {
            is Num -> e.value
            is Sum -> eval(e.right) + eval(e.left)
        }

 

 

when 식에서 sealed 클래스의 모든 하위 클래스를 처리한다면 디폴트 분기(else 분기) 가 필요없다.

sealed로 표시된 클래스는 자동으로 open임을 기억하라.

따라서 별도로 open 변경자를 붙일 필요가 없다.

 

 

 

 

sealed class로 변경된 점을 구분해보면 아래의 표와 같다.

 

 

non-sealed class sealed class
첫 번째로 디폴트 분기 강제 스마트 캐스트: 디폴트 분기 제거 
새로운 하위 클래스의 모든 경우를 처리하는지  확인 불가
👉🏻 누락된 클래스는 디폴드 분기로 처리되며 버그 발생
새로운 하위 클래스를 추가하면 컴파일 불가
👉🏻 새로운 타입 처리를 바로 알 수 있음

 

 

위의 코드를 빌드한 후 Java로 디컴파일하면 아래와 같은 코드를 확인할 수 있다.

 

public abstract class Expr {
   private Expr() {
   }

   // $FF: synthetic method
   public Expr(DefaultConstructorMarker $constructor_marker) {
      this();
   }

   public static final class Num extends Expr {
      private final int value;

      public Num(int value) {
         super((DefaultConstructorMarker)null);
         this.value = value;
      }

      public final int getValue() {
         return this.value;
      }
   }
   
   public static final class Sum extends Expr {
      @NotNull
      private final Expr left;
      @NotNull
      private final Expr right;

      public Sum(@NotNull Expr left, @NotNull Expr right) {
         Intrinsics.checkNotNullParameter(left, "left");
         Intrinsics.checkNotNullParameter(right, "right");
         super((DefaultConstructorMarker)null);
         this.left = left;
         this.right = right;
      }

      @NotNull
      public final Expr getLeft() {
         return this.left;
      }

      @NotNull
      public final Expr getRight() {
         return this.right;
      }
   }
}

 

참고로, 내부적으로 Expr 클래스는 private 생성자를 가진다.

그 생성자는 클래스 내부에 서만 호출할 수 있다.

 

인터페이스는 sealed 를 적용할 수 없다. sealed 인터페이스를 만들 수 있다면

그 인터페이스를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에게 없기 때문이다.

 

 

 

Declaring a class

코틀린은 주 생성자 Primary constucture 와 부 생성자 secondary constucture 를 구분한다.

또한 코틀린에서는 초기화 블럭을 제공한다.

 

- 주 생성자: 클래스를 초기화할 때 주로 사용하는 간략한 생성자로, 클래스 본문 밖에서 정의한다.

- 부 생성자: 클래스 본문 안에서 정의한다.

- 초기화 블록initializer block : 초기화 로직을 추가할 수 있다.

 

class User(val nickname: String)

 

상위 클래스의 이름 뒤에는 반드시 중소괄호가 들어간다.

반면 인터페이스는 생성자가 없기 때문에 이름 뒤에는 아무 괄호도 없기 때문에,

괄호의 여부로 기반 클래스와 인터페이스를 구별할 수 있다.

 

 

 

primary constructor 

: 주 생성자와 초기화 블록

 

보통 클래스의 모든 선언은 중괄호({}) 사이에 들어간다.

하지만 이 클래스 선언에는 중괄호가 없고 괄호 사이에 val 선언만 존재한다.

 이렇게 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자Primary constructor라고 부른다.

 

주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼 티를 정의하는 두 가지 목적에 쓰인다. 이제 이 선언을 같은 목적을 달성할 수 있는 가장 명시적인 선언으로 풀어서 실제로는 어떤 일이 벌어지는지 살펴보자.

 

constructor 키워드는 주 생성자나 부 생성자 정의를 시작할 때 사용한다. 

 

// 초기화 방법 1: primary constructor
class User(_nickname: String) {
    val nickname = _nickname
}

// 초기화 방법 2: primary constructor + property val
class User(val nickname: String)

 

위의 방법 3가지는 정확히 동일한 로직을 가진다.

Java로 표현한 코드는 아래와 같다.

 

public final class User {
   @NotNull
   private final String nickname;

   public User(@NotNull String _nickname) {
      Intrinsics.checkNotNullParameter(_nickname, "_nickname");
      super();
      this.nickname = _nickname;
   }

   @NotNull
   public final String getNickname() {
      return this.nickname;
   }
}

 

 

 

 

initializer blocks

init 키워드는 초기화 블록을 시작한다.

초기화 블록에는 클래스의 객체가 만들어질 때인스턴스화될 때 실행될 초기화 코드가 들어간다.

 

/*
   초기화 방법 3: primary constructor + initial block
   - 방법 1,2와 정확히 동일
*/
class User constructor(_nickname: String) {

    val nickname: String

    init {
        nickname = _nickname
    }
}

 

이 코드도 위의 두 방법과 정확히 동일한 로직을 가진다.

 

초기화 블록은 주 생성자와 함께 사용된다.

주 생성자는 별도의 코드를 포함할 수 없어서 초기화 블록이 필요하다.

필요하다면 클래스 안에 여러 초기화 블록을 선언할 수 있다.


이 예제에서는 nickname 프로퍼티를 초기화하는 코드를 nickname 프로퍼티 선언에 포함시킬 수 있어서 초기화 코드를 초기화 블록에 넣을 필요가 없다.

또 주 생성자 앞에 별다른 애노테이션이나 가시성 변경자가 없다면 constructor를 생략해도 된다.

 

 

 

Default Value

일반 함수와 동일하게 생성자도 기본값을 넣을 수 있다.

자바에서 많은 생성자만 여럿 보이던 코드를 깔끔하게 정리할 수 있다.  

 

class User(val nickname: String, val isSubscribed: Boolean = true)

val hyun = User("현석")		// isSubscribed 파라미터에는 디폴트 값이 쓰인다.
println(hyun.isSubscribed)	 // true

val gye = User("계영", false)
println(gye.isSubscribed) 	// false

val hey = User("혜원", isSubscribed = false)
println(hey.isSubscribed) 	// false

 

어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 막고 싶다면 모든 생성자를 private 으로 만들면 된다. 

다음과 같이 주 생성자에 private 변경자를 붙일 수 있다.

 

class Secretive private constructor() {}


Secretive 클래스 안에는 주 생성자밖에 없는데 비공개이기 때문에,

외부에서는 Secretive를 인스턴스화할 수 없다. 

동반 객체companion object 안에서 비공개 생성자를 호출할 때 활용할 수 있는데, 추후에 살펴본다.

 

 

자바에서는 어쩔 수 없이 private 생성자를 정의해서 클래스를 다른 곳에서 인스턴스화하지 못하게 막는 경우가 생긴다.

가령 유틸리티 함수를 담는 의미없는 클래스가 있다.

 

코틀린은 그런 경우를 언어에 서 기본 지원한다.

정적 유틸리티 함수 대신 최상위 함수를 사용할 수 있고,

싱글턴을 사용하고 싶으면 객체를 선언하면 된다

 

 

secondary constructor

코틀린의 디폴트 파라미터 값과 named parameter를 사용해 여러 생성자를 하나로 해결할 수 있다.

 

그래도 생성자가 여럿 필요한 경우가 가끔 있다.

가장 일반적인 상황은 프레임워크 클래스를 확장해야 하는데

여러 가지 방법으로 인스턴스를 초기화할 수 있게 다양한 생성자를 지원해야 하는 경우다.

 


📌 인자에 대한 디폴트 값을 제공하기 위해 부 생성자를 여럿 만들지 말고,

파라미터의 디폴트 값을 생성자 시그니처에 직접 명시하라.

 

 

 

open class View {
    constructor(size: Int) {}
    constructor(size: Int, color: String) {}
}

class MyButton : View {
    constructor(size: Int) : super(size) {}
    constructor(size: Int, color: String) : super(size, color) {}
}

 

이 클래스는 주 생성자를 선언하지 않고, 부 생성자만 2가지 선언한다. 

부 생성자는 constructor 키워드로 시작한다. 

이 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.

 

class MyButton : View {
    constructor(size: Int) : this(size, "red") {}
    constructor(size: Int, color: String) : super(size, color) { }
}

 

 

 

Properties in Interfaces

코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다. 다음은 추상 프로퍼티 선언이 들어있는 인터페이스 선언의 예다.

 

interface User {
    val nickname: String
}

 

이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 방법을 제공해야 한다는 뜻이다. 

인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게 터 등의 정보가 들어있지 않다. 

사실 인터페이스는 아무 상태도 포함할 수 없으므로 상태를 저장할 필요가 있다면 인터페이스를 구현한 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 만들어야 한다.

 

 

 

Backing field

어떤 값을 저장하되, 그 값을 변경하거나 읽을 때마다 정해진 로직을 실행하는 유형의 프로퍼티를 만드는 방법을 살펴보자.

값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.


프로퍼티에 저장된 값의 변경 이력을 로그에 남기려는 경우를 생각해보자.

그런 경 우 변경 가능한 프로퍼티를 정의하되 세터에서 프로퍼티 값을 바꿀 때마다 약간의 코드 를 추가로 실행해야 한다.

 

class User(val name: String) {
    var address: String = "unspecified"
        set(value: String) {
            println("""
                Address was changed for $name: 
                "$field" -> "$value".""".trimIndent())
            field = value
        }
}

 

 

코틀린에서 프로퍼티의 값을 바꿀 때는 user.address = "new value" 처럼 필드 설정 구문을 사용한다. 

이 구문은 내부적으로는 address의 세터를 호출한다. 

이 예제에서는 커스텀 세터를 정의해서 추가 로직을 실행한다(여기서는 단순화를 위해 화면에 값의 변화를 출력하기만 한다).

접근자의 본문에서는 field라는 특별한 식별자를 통해 뒷받침하는 필드에 접근할 수 있다. 

게터에서는 field 값을 읽을 수만 있고, 세터에서는 field 값을 읽거나 쓸 수 있다.

변경 가능 프로퍼티의 게터와 세터 중 한쪽만 직접 정의해도 된다.

address의 게터는 필드 값을 그냥 반환해주는 뻔한 게터다. 

따라서 게터를 굳이 직접 정의할 필요가 없다.

 

컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이

게터나 세터에서 field 를 사용하는 프로퍼티에 대해 뒷받침하는 필드를 생성해준다.

 

다만 field를 사용하지 않는 커스텀 접근자 구현을 정의한다면 뒷받침하는 필드는 존재하지 않는다.

프로퍼티가 val인 경우에는 게터에 field가 없으면 되지만, var인 경우에는 게터나 세터 모두에 field가 없어야 한다.

 

 

 

Changing accessor visibility

접근자의 가시성은 기본적으로는 프로퍼티의 가시성과 같다. 

하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가해서 접근자의 가시성을 변경할 수 있다. 

 

 

class LengthCounter {
    var counter: Int = 0
        private set // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다.

    fun addWord(word: String) {
        counter += word.length
    }
}

 

기본 가시성을 가진 게터를 컴파일러가 생성하게 내버려 두는 대신 세터의 가시성을 private으로 지정한다.
이 클래스를 사용하는 방법은 아래와 같다.

 

val lengthCounter = LengthCounter()
lengthcounter.addWord("Hi !")
println(lengthCounter.counter)

 

 

Data classes

자바에서는 클래스가 equals, hashCode, toString 등의 메소드를 구현해야 하는데,

대부분 IDE에서 자동으로 만들어줄 수 있어서 직접 이런 메소드를 작성 할 일은 많지 않다.

 

코틀린 컴파일러는 더 나아가 이 메소드를 생성하는 작업을 보이지 않는 곳에서 해주기 때문에 소스코드를 깔끔하게 유지할 수 있다.

자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드할 수 있다.

 

toString()

: 문자열 표현

 

자바처럼 코틀린의 모든 클래스도 인스턴스의 문자열 표현을 얻을 방법을 제공한다.

주로 디버깅과 로깅 시 이 메소드를 사용한다.

기본 제공되는 객체의 문자열 표현은 Client@5e9f23b4 같은 방식인데, 이는 그다지 유용하지 않다.

이 기본 구현을 바꾸려면 toString 메소드를 오버라이드해야 한다.

 

class Client(val name: String, val postalCode: Int) {
    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

 

 

 

equals()

: 객체의 동등성

 

자바에서의  == 연산

✔️ 원시 타입 : 동등성 equality. 두 피연산 자의 이 같은지 비교

✔️ 참조 타입 : 참조 비교 reference comparision. 두 피연산자의 주소가 같은지 비교 

     👉🏻 두 객체의 동등성은 equals 사용

 

코틀린

== : 내부적으로 equals를 호출. 연산자가 두 객체를 비교하는 기본적인 방법

     👉🏻 따라서 클래스가 equals를 오버라이드하면 ==를 통해 안전하게 그 클래스의 인스턴스를 비교할 수 있음

=== 연산자: 참조 비교. 자바에서 객체의 참조를 비교할 때 사용하는 == 연산자와 동일

 

val client1 = Client("gngsn", 10580)
val client2 = Client("gngsn", 10580)
println(client1 == client2)  // false

 

코를린에서 == 연산자는 참조 동일성을 검사하지 않고 객체의 동등성을 검사한다. 

따라서 == 연산은 equals를 호출하는 식으로 컴파일된다.

 

class Client(val name: String, val postalCode: Int) {
    // ...
    override fun equals(other: Any?): Boolean { // Any는 java.lang.object에 대응하는 클래스로, 코롤린의 모든 클래스의 최상위 클래스다. "Any?"는 널이 될 수 있는 타입이므로 "other"는 null일 수 있다.
        if (other == null || other !is Interface.Client)  // "other"가 Client인지 검사한다.
            return false
        return name == other.name       // 두 객체의 프로퍼티 값이 서로 같은지 검사한다
                && postalCode == other.postalCode
    }
}

 

코틀린의 is 는 자바의 instanceof와 같으며, 어떤 값의 타입을 검사한다. 

in 연산자의 결과를 부정해주는 연산자가 !in 연산자인 것과 마찬가지로, !is 의 결과는 is 연산자의 결과를 부정한 값이다. 

이런 연산자를 사용하면 코드가 읽기 편해 진다. 

 

val client1 = Client("gngsn", 10580)
val client2 = Client("gngsn", 10580)
println(client1 == client2)  // true

 

그래서 equals를 오버라이드하고 나면 프로퍼티의 값이 모두 같은 두 고객 객체는 동등하리라 예상할 수 있다.

실제로 clientl = client2는 이제 true를 반환한다.

하지만 Client 클래스로 더 복잡한 작업을 수행해보면 제대로 작동하지 않는 경우가 있다.

이 경우에는 실제 hashCode가 없다는 점이 원인이다.

 

 

hashCode()

: 해시 컨테이너

 

자바에서는 equals를 오버라이드할 때 반드시 hashCode도 함께 오버라이드해야 한다.

아래의 코드에서 프로퍼티가 모두 일치하므로 두 인스턴스는 동등하다.

 

val processed = hashSetOf(Client("gngsn", 10580))
println(processed.contains(Client("gngsn", 10580))) // false

 

하지만, false가 나온다.

 

이는 Client 클래스가 hashCode 메소드를 정의하지 않았기 때문이다.

JVM 언어에서는 hashCode가 지켜야 하는 “equals ()가 true를 반환하는 두 객체는 반드시 같은 hashCode ()를 반환해야 한다”라는 제약이 있는데 Client는 이를 어기고 있다.

 

processed 집합은 HashSet이다.

HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다.

방금 본 예제의 두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.

해시 코드가 다를 때 equals가 반환하는 값은 판단 결과에 영향을 끼치지 못한다.

즉, 원소 객체들이 해시 코드에 대한 규칙을 지키지 않는 경우 HashSet은 제대로 작동할 수 없다.

 

이 문제를 고치려면 Client가 hashCode를 구현해야 한다.

 

class Client(val name: String, val postalCode: Int) {
	// ...
	override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}

 

이제 이 클래스는 예상대로 작동한다.

 

 

 

이렇게 어떤 클래스의 데이터를 저장 시 toString, equals, hashCode 를 반드시 오버라이드해야 한다. 

다행히 이런 메소드를 정의하기는 그리 어렵지 않으며, 

인텔리이 아이디어 등의 IDE는 자동으로 그런 메소드를 정의해주고, 작성된 메소드의 정 확성과 일관성을 검사해준다.

하지만 번거로울 수 있는데, 코틀린은 자동으로 생성해준다.

 

 

 

data class

코틀린 컴파일러는 위의 모든 메소드를 자동으로 생성해줄 수 있다.

데이터 클래스를 통해 모든 클래스가 정의해야 하는 메소드 자동 생성해준다.

 

data 라는 변경자를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어주며,

이를 데이터 클래스라고 부른다.

 

data class Client(val name: String, val postalCode: Int)

 

Client 클래스는 자바에서 요구하는 모든 메소드를 포함한다.
toString, equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다. 

 

-  toString : 문자열 표현 생성

-  equals    : 메소드는 모든 프로퍼티 값의 동등성을 확인

- hashCode : 메소드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환

 

이때 주 생성자 밖에 정의 된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니다.

코틀린 컴파일러는 data 클래스에게 방금 말한 세 메소드뿐 아니라 몇 가지 유용한 메소드를 더 생성해준다. 

 

자바로 디컴파일하면 아래와 같다.

 

public final class Client {
   @NotNull
   private final String name;
   private final int postalCode;

   public Client(@NotNull String name, int postalCode) {
      Intrinsics.checkNotNullParameter(name, "name");
      super();
      this.name = name;
      this.postalCode = postalCode;
   }

   @NotNull
   public final String getName() {
      return this.name;
   }

   public final int getPostalCode() {
      return this.postalCode;
   }

   @NotNull
   public final String component1() {
      return this.name;
   }

   public final int component2() {
      return this.postalCode;
   }

   @NotNull
   public final Client copy(@NotNull String name, int postalCode) {
      Intrinsics.checkNotNullParameter(name, "name");
      return new Client(name, postalCode);
   }

   // $FF: synthetic method
   public static Client copy$default(Client var0, String var1, int var2, int var3, Object var4) {
      if ((var3 & 1) != 0) {
         var1 = var0.name;
      }

      if ((var3 & 2) != 0) {
         var2 = var0.postalCode;
      }

      return var0.copy(var1, var2);
   }

   @NotNull
   public String toString() {
      return "Client(name=" + this.name + ", postalCode=" + this.postalCode + ")";
   }

   public int hashCode() {
      int result = this.name.hashCode();
      result = result * 31 + Integer.hashCode(this.postalCode);
      return result;
   }

   public boolean equals(@Nullable Object other) {
      if (this == other) {
         return true;
      } else if (!(other instanceof Client)) {
         return false;
      } else {
         Client var2 = (Client)other;
         if (!Intrinsics.areEqual(this.name, var2.name)) {
            return false;
         } else {
            return this.postalCode == var2.postalCode;
         }
      }
   }
}