Kotlin, 어렵지 않게 사용하기 (5) - copy, by, companion

2022. 9. 27. 23:58Spring/Kotlin

반응형

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

🔗 Kotlin 시리즈 모아보기

 

 

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

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


Mutable Object

data class의 프로퍼티는 val 말고도 var를 써도 되지만,

코틀린에서는 불변 mutable 클래스를 권장하기 때문에

되도록 data class의 모든 프로퍼티를 읽기 전용인 val로 정의하는 것이 좋다.

 

불변 클래스를 권장하는 이유는 두 가지인데, 아래와 같다.

첫 번째는 프로퍼티를 HashMap의 key로 사용중 프로퍼티가 변경되면 HashMap이 예상대로 작동하지 않는다.

이 이유를 모르겠다면 equals()와 hashCode()를 다룬 Kotlin, 어렵지 않게 사용하기 (4) - Object 포스팅을 참고하세요.

두 번째는 불변 객체를 사용하면 프로그램을 훨씬 쉽게 추론 가능하기 때문이다.
특히 다중스레드 프로그램의 경우, 하나의 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.

 

만약, 변경이 필요한 경우에는 copy 메소드를 생각해볼 수 있다.

 

📌 Method - copy()

: 객체 복사copy

 

코틀린 컴파일러는 데이터 클래스를 불변 객체로 활용할 수 있도록 copy 메소드를 제공한다.
객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다.

 

복사본은 원본과 다른 생명주기를 가지기 때문에, 프로퍼티 값을 바꾸거나 복사 객체를 제거해도

원본에 전혀 영향을 끼치지 않기 때문에 불변성을 유지할 수 있다.

 

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

val park = Client("박경선", 10580)
val copy = park.copy()

Assertions.assertTrue(park == copy)  // true
Assertions.assertTrue(park !== copy) // true

 

위의 예제 코드에서 볼 수 있듯이, 두 인스턴스의 프로퍼티는 동일해도 참조는 다르다는 것을 알 수 있다.

 

 

 

📌 keyword - By

: 다른 객체에 위임 중임을 명시

 

대규모 객체지향 시스템의 취약점의 원인 중 하나는 구현 상속 implementation inheritance에 의해 발생한다.

코틀린은 기본적으로 클래스를 final로 취급하며, open 변경자로 열어둔 클래스만 확장할 수 있다.

open 변경자를 통해 해당 클래스를 다른 클래스가 상속하는 것을 쉽게 예상할 수 있다.

덕분에 상위 클래스 변경 시, 하위 클래스가 깨지지 않기 위해 좀 더 주의할 수 있다.

 

하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있는데,

이럴 때에는 데코레이터decorator 패턴을 사용하곤 한다.

 

 

 

데코레이터 패턴이란, 이 패턴의 핵심은 상속을 허용하지 않는 클래스concreteComponent 대신 사용할 수 있는 새로운 클래스Conponent를 만들되, 기존 클래스와 동일한 인터페이스Decorator데코레이터ConcreteDecorator가 제공하게 만들고 기존 클래스를 데코레이터 내부에 필드로 유지하는 것을 의미한다.

자세한 설명은 "Design Pattern, Decorator"을 참고하세요.

 

이때 새로 정의해야 하는 기능은 데코레이터의 메소드에 새로 정의하고,

기존 기능이 그대로 필요한 부분은 데코레이터의 메소드가 기존 클래스의 메소드에게 요청을 전달forwarding한다.

이 방식의 단점은 준비 코드가 상당히 많이 필요하는 것이다.

 

예를 들어 Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차도 다음과 같이 복잡한 코드를 작성해야 한다.

 

/* 무의미한 메서드를 반드시 생성해야하는 기존의 문제점 */
class DelegatingCollection<T> : Collection<T> {
    private val innerList = arrayListOf<T>()
    override val size: Int get() = innerList.size
    override fun isEmpty(): Boolean = innerList.isEmpty()
    override fun contains(element: T): Boolean = innerList.contains(element)
    override fun iterator(): Iterator<T> = innerList.iterator()
    override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}

 

by 키워드를 사용하면 위의 무의미한 오버라이드 메소드를 적지 않아도 된다.

 

class DelegatingCollection<T>(innerList: Collection<T> 
	= ArrayList<T>()) : Collection<T> by innerList {}

 

by 키워드로 클래스 안에 있던 모든 메소드 정의 제거한 코드이다.

컴파일러가 자동으로 생성하며 자동 생성한 코드의 구현은 DelegatingCollection에 있던 구현과 비슷하다.

메소드 중 일부의 동작을 변경하고 싶은 메소드만을 오버라이드하면,

컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 호출한다.

 

 

 

Example

Set에 데이터를 추가할 때, 중복된 개체들와 Set에 입력된 개체들의 크기가 얼마나 차이나는지 확인한다고 가정하자.

Set에 데이터를 입력하기 전, objectAdded라는 필드에 저장해보자.

 

class CountingSet<T>(val innerSet: MutableCollection<T> = HashSet<T>()) :
    MutableCollection<T> by innerSet { // MutableCollection의 구현을 innerSet에게 위임

    var objectsAdded = 0

    // 아래 두 메소드는 위임하지 않고 새로운 구현을 제공한다.
    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

    override fun addAll(c: Collection<T>): Boolean {
        objectsAdded += c.size
        return innerSet.addAll(c)
    }
}

 

중요한 점은 의존관계가 생기지 않는다는 점이다.

Mutable Collection에 문서화된 API를 활용하기 때문에,

내부 클래스 MutableCollection이 문서화된 API를 변경하지 않는 한 CountingSet 코드가 계속 잘 작동할 것임을 확신할 수 있다.
 

위의 코드를 아래의 동일한 로직의 Java 코드와 비교해봤을 때, 그 장점을 더욱 크게 느낄 수 있다.

 

public final class CountingSet implements Collection, KMutableCollection {
   @NotNull
   private final Collection innerSet;
   private int objectsAdded;

   // ... constructors
   
   public boolean add(Object element) { 
      int var2 = this.objectsAdded++;
      return this.innerSet.add(element); 
   }

   public boolean addAll(@NotNull Collection c) {
      this.objectsAdded += c.size();
      return this.innerSet.addAll(c); 
   }

   @NotNull
   public final Collection getInnerSet() { return this.innerSet; }

   public int getSize() { return this.innerSet.size(); }

   public void clear() { this.innerSet.clear(); }

   public boolean contains(Object element) { return this.innerSet.contains(element); }

   public boolean containsAll(@NotNull Collection elements) { return this.innerSet.containsAll(elements); }

   public boolean isEmpty() { return this.innerSet.isEmpty(); }

   @NotNull
   public Iterator iterator() { return this.innerSet.iterator(); }

   public boolean remove(Object element) { return this.innerSet.remove(element); }

   public boolean removeAll(@NotNull Collection elements) { return this.innerSet.removeAll(elements); }

   public boolean retainAll(@NotNull Collection elements) { return this.innerSet.retainAll(elements); }

   public final int getObjectsAdded() { return this.objectsAdded; }

   public final void setObjectsAdded(int <set-?>) { this.objectsAdded = <set-?>; }

   public CountingSet() { this((Collection)null, 1, (DefaultConstructorMarker)null); }

   public final int size() { return this.getSize(); }

   public Object[] toArray(Object[] array) { return CollectionToArray.toArray((Collection)this, array); }

   public Object[] toArray() { return CollectionToArray.toArray((Collection)this); }
}

 

 

 

📌 Keyword - object  

: 클래스 선언과 인스턴스 생성

 

object 키워드를 활용하는 방식은 크게 아래와 같이 세 개로 나눌 수 있다.

 

✔️ 객체 선언: object declaration. 싱글톤 정의 방법 중 하나이다.

✔️ 동반 객체companion object는 인스턴스 메소드는 아니지만 어떤 클래스와 관련 있는 메소드와 팩토리 메소드를 담을 때 쓰인다.

동반 객체 메소드에 접근할 때는 동반 객체가 포함된 클래스의 이름을 사용할 수 있다.

✔️ 객체 식은 자바의 무명 내부 클래스anonymous inner class 대신 쓰인다.

 

하나씩 살펴보자.

 

 

✔️ Object Declaration

싱글턴을 쉽게 만들기 객체지향 시스템에서 인스턴스를 하나만 두는 싱글톤을 많이 사용하곤한다.

Java에서는 보통 클래스의 생성자를 private으로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장해서 구현한다.

코틀린에서는 object 키워드를 통한 객체 선언으로 싱글턴을 기본 지원한다.

 

object class는 클래스 선언과 해당 클래스의 단일 인스턴스 선언을 동시에 한다.

 

object Payroll {
	val allEmployees = arrayListOf<Person>()
	fun calculateSalary() {
		for (person in allEmployees) {
			...
		}
	}
}

 

객체 선언의 특징을 정리해보면 아래와 같다.

 

- 객체 선언은 object 키워드로 시작한다.

- 클래스 정의와 해당 클래스 인스턴스 생성 후 변수에 저장 하는 작업을 한 문장으로 처리

- 클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메소드, 초기화 블록 등 정의 가능

- 객체 선언에서 생성자는 쓸 수 없음

일반 클래스 인스턴스와 달리 싱글턴 객체는 객체 선언문이 있는 위치에서

생성자 호출 없이 즉시 만들어진다는 점을 생각해보면 생성자 정의가 필요 없기 때문에 당연한 내용이다.

- 마침표(.)로 메소드나 프로퍼티에 접근

 

Payroll.allEmployees.add(Person(...))
Payroll.calculateSalary()

 

 

Inheritance

 

객체 선언도 클래스나 인터페이스를 상속할 수 있다.

프레임워크를 사용하기 위해 특정 인터페이스를 구현해야 하는데,

그 구현 내부에 다른 상태가 필요하지 않은 경우에 이런 기능이 유용하다.

 

예를 들어 java.util.Comparator 인터페이스를 보면

두 객체를 인자로 받아 어느 객체가 더 큰지 알려주는 정수를 반환한다.

Comparator 안에는 데이터를 저장할 필요가 없으며 보통 클래스마다 단 하나씩만 있으면 된다.

따라서 Comparator 인스턴스를 만드는 방법으로는 객체 선언이 가장 좋은 방법이다.

두 파일 경로를 대소문자 관계없이 비교해주는 Comparator를 구현해보자.

 

object CaselnsensitiveFileComparator : Comparator { 
	override fun compare(filel: File, file2: File): Int { 
		return filel.path.compareTo(file2.path, ignoreCase = true) 
	} 
}

 

일반 객체(클래스 인스턴스)를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 사용할 수 있다.

예를 들어 이 객체를 Comparator를 인자로 받는 함수에게 인자로 넘길 수 있다.

 

val files = listOf(File ("/Z"), File ("/a"))
println(files.sortedWith(CaselnsensitiveFileComparator))

 

이 예제는 전달받은 Comparator에 따라 리스트를 정렬하는 sortedWith 함수를 사용한다.

 

 

Decompile to Java

object 선언을 한 클래스는 아래와 같은 자바 코드로 치환될 수 있다.

 

public final class Payroll {
   @NotNull
   public static final Payroll INSTANCE = new Payroll();
   @NotNull
   private static final ArrayList allEmployees = new ArrayList();

   private Payroll() {
   }

   @NotNull
   public final ArrayList getAllEmployees() {
      return allEmployees;
   }

   public final void calculateSalary() {
      Person var2;
      for(Iterator var1 = allEmployees.iterator(); var1.hasNext(); var2 = (Person)var1.next()) {
         // ...
      }
   }
}

 

코틀린 객체 선언은 유일한 인스턴스를 참조하는 정적인 필드가 있는 자바 클래스로 컴파일되는데,

보다시피, INSTANCE라는 변수에 자신을 담고 있다.

 

자바 코드에서 코틀린 싱글턴 객체를 사용하려면 정적인 INSTANCE 필드를 통하면 된다.

/* 자바 */
CaselnsensitiveFileComparator.INSTANCE.compare(filel, file2);

 

 

 

✔️ Companion Class

코틀린 언어는 자바 static 키워드를 지원하지 않는다.

그 대신 코틀린에서는 패키지 수준의 최상위 함수와 객체 선언을 활용한다. 

최상위 함수으로 자바의 정적 메소드 역할을 거의 대신 할 수 있다

객체 선언으로 자바의 정적 메소드 역할 중 코틀린 최상위 함수가 대신할 수없는 역할이나 정적 필드를 대신할 수 있다

 

대부분의 경우 최상위 함수를 활용하는 편을 더 권장한다.

 

 

하지만 최상위 함수는 private으로 표시된 클래스 비공개 멤버에 접근할 수 없다. 

그래서 클래스의 인스턴스와 관계없이 호출해야 하지만, 

클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다. 

 

그런 함수의 대표적인 예로 팩토리 메소드를 들 수 있다.

 

 

Factory Method

팩토리 메소드는 매우 유용하다. 

목적에 따라 팩토리 메소드 이름을 정할 수 있고, 게다가 그 팩토리 메소드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다. 

예를 들어 SubscribingUser와 FacebookUser 클래스가 따로 존재한다면

그때그때 필요에 따라 적당한 클래스의 객체를 반환할 수 있다. 

 

class User private constructor(val nickname: String) {
    companion object {
        fun newSubscribingUser(email: String) = User(email.substringBefore("@"))
        fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId))
    }
}

val subscribingUser = User.newSubscribingUser("gngsn@gmail.com")
val facebookUser = User.newFacebookUser(4)

 

또 팩토리 메소드는 생성할 필요가 없는 객체를 생성하지 않을 수도 있다. 

예를 들어 이메일 주소별로 유일한 User 인스턴스를 만드는 경우,

팩토리 메소드가 이미 존재하는 인스턴스에 해당하는 이메일 주소를 전달받으면

새 인스턴스를 만들지 않고 캐시에 있는 기존 인스턴스를 반환할 수 있다. 

하지만 클래스를 확장해야만 하는 경우에는 동반 객체 멤버를 하위 클래스에서 오버라이드할 수 없으므로 여러 생성자를 사용하는 편이 더 낫다.

 

 

as Regular Object

동반 객체는 클래스 안에 정의된 일반 객체다.

따라서 동반 객체가 인터페이스를 상속하거나, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다. 

 

class Person(val name: String) {

    companion object Loader {
        fun fromJSON(jsonText: String): Person {
            val jsonObject = JSONTokener(jsonText).nextValue() as JSONObject
            return Person(jsonObject.get("name") as String)
        }
    }
}

val person = Person.Loader.fromJSON("{name: gngsn}") // gngsn
// or
val person = Person.fromJSON("{name: gngsn}")

 

동반 객체는 이름을 생략해도 되는데, 자동으로 Conpanion 가 된다.

하지만 필요하다면 위의 companion object Loader 같은 방식으로 동반 객체에도 이름을 붙일 수 있다.

 

 

 

Implementation Interface

다른 객체 선언과 마찬가지로 동반 객체도 인터페이스를 구현할 수 있다.

 

interface JSONFactory<T> {
    fun fromJSON(jsonText: String): T
}

class Person(val name: String) {

    companion object : JSONFactory<Person> {
        override fun fromJSON(jsonText: String): Person {
            val jsonObject = JSONTokener(jsonText).nextValue() as JSONObject
            return Person(jsonObject.get("name") as String)
        }
    }
}

 

이제 JSON으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다면 Person 객체를 그 팩토리에게 넘길 수 있다.

여기서 동반 객체가 구현한 JSONFactory의 인스턴스를 넘길 때 Person 클래스의 이름을 사용했다는 점에 유의하라.

 

fun loadFromJSON<T>(factory: JSONFactory<T>): T {
    ...
}

loadFromJSON(Person)

 

코틀린 동반 객체와 정적 멤버 클래스의 동반 객체는 일반 객체와 비슷한 방식으로, 

클래스에 정의된 인스턴스를 가리키는 정적 필드로 컴파일된다. 

 

동반 객체에 이름을 붙이지 않았다면 자바 쪽에서 Companion이라는 이름으로 그 참조에 접근할 수 있다.

 

/* 자바 */
Person.Companion.fromJSON("...");


동반 객체에게 이름을 붙였다면 Companion 대신 그 이름이 쓰인다.

 

 

 

Extension 

자바의 정적 메소드나 코틀린 의 동반 객체 메소드처럼,

기존 클래스에 대해 호출할 수 있는 새로운 함수를 정의하고 싶다면 어떻게 해야 할까?

클래스에 동반 객체가 있으면 그 객체 안에 함수를 정의함으로 써 확장 함수를 만들 수 있다.

가령, C라는 클래스 안에 동반 객체가 있고 그 동반 객체(C.Companion) 안에 func를 정의하면 외부에서는 func()를 C.func()로 호출할 수 있다.

 

예를 들어 앞에서 살펴본 Person의 관심사를 좀 더 명확히 분리하고 싶다고 하자.

Person 클래스는 핵심 비즈니스 로직 모듈의 일부다.

하지만 그 비즈니스 모듈이 특정 데이터 타입에 의존하기를 원치는 않는다.

따라서 역직렬화 함수를 비즈니스 모듈이 아니라 클라이언트/서버 통신을 담당하는 모듈 안에 포함시키고 싶다.

확장 함수를 사용 하면 이렇게 구조를 잡을 수 있다. 다음 예제에서는 이름 없이 정의된 동반 객체를 가리 키기 위해서 동반 객체의 기본 이름인 Companion을 사용했다.

 

class Person(val firstName: String, val lastName: String) {
    companion object {}		// 필수!
}

fun Person.Companion.fromJSON(json: String) {
	// ...
}

val p = Person.fromJSON(json)

 

마치 동반 객체 안에서 fromJSON 함수를 정의한 것처럼 fromJSON을 호출할 수 있다. 

하지만 실제로 fromJSON은 클래스 밖에서 정의한 확장 함수다. 

다른 보통 확장 함수처 럼 fromJSON도 클래스 멤버 함수처럼 보이지만, 실제로는 멤버 함수가 아니다. 

 

여기서 동반 객체에 대한 확장 함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 꼭 선언해야 한다는 점에 주의하라. 

빈 객체라도 동반 객체가 꼭 있어야 한다.

 

 

 

 

✔️ Anonymous object

무명 객체anonymous object를 정의할 때도 object 키워드를 사용한다.

 

무명 객체는 자바의 무명 내부 클래스를 대신한다.

예를 들어 자바에서 보통 무명 내부 클래스로 구현하는 이벤트 리스너를 코틀린에서 구현해보자.

 

window.addMouseListener(
    object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) { // 무명 객체를 선언한다.
    		// ...
        }
    
        override fun mouseEntered(e: MouseEvent) {
            // ...
        }
    }
)

 

사용한 구문은 객체 선언에서와 같으며, 단 한 가지 차이는 객체 이름이 빠졌다는 점이다. 

객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지는 않는다. 

이런 경우 보통 함수를 호출하면서 인자로 무명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다. 

하지만 객체 에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다.

 

val listener = object : MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { /*...*/ }
    override fun mouseEntered(e: MouseEvent) { /*...*/ }
}

 

 

한 인터페이스만 구현하거나 한 클래스만 확장할 수 있는 자바의 무명 내부 클래스와 달리 코틀린 무명 클래스는 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페 이스를 구현할 수 있다.

 

객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.

자바의 무명 클래스와 같이 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다. 

하지만 자바와 달리 final이 아닌 변수도 객체 식 안에서 사용할 수 있다. 

따라서 객체 식 안에서 그 변수의 값을 변경할 수 있다. 

 

 

fun countclicks(window: Window) {
    var clickCount = 0    	// 로컬 변수 정의
    
    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            clickCount++	 // 로컬 변수의 값을 변경한다.
        }
    })
    // ...
}

 

 

 

이것으로 Kotlin 에서의 Object와 관련된 내용을 모두 다뤘다.

다음에는 코틀린에서의 람다 표현식을 다루도록 한다.

 

 

 

반응형

Backend Software Engineer

Gyeongsun Park