Kotlin - 객체 지향 프로그래밍(5): object, inner class, 'this@클래스명', companion object


이 포스트에서는 object, inner class, 한정된 this (this@클래스명), companion object 에 대해 알아본다.

소스는 github 에 있습니다.


목차


개발 환경

  • 언어: kotlin 1.9.23
  • IDE: intelliJ
  • SDK: JDK 17
  • 의존성 관리툴: Gradle 8.5

1. object

1.1. object 기본

object 의 인스턴스는 오직 하나만 존재한다. 이것을 싱글턴 패턴이라고도 한다.

object 는 여러 인스턴스가 필요하지 않거나, 명시적으로 인스턴스를 여러 개 생성하는 것을 막고 싶은 경우 논리적으로 한 개체 안에 속한 함수와 프로퍼티를 함께 엮는 방법이다.

object 의 인스턴스를 직접 생성하는 경우는 절대 없다.
object 를 정의하면 그 object 의 인스턴스가 오직 하나만 생긴다.

object JustOne {
    val n = 2

    fun f() = n * 2

    // this 키워드는 유일한 객체 인스턴스를 가리킴
    fun g() = this.n * 20
}

fun main() {
    // 오류
    // JustOne() 을 이용하여 JustOne 의 새로운 인스턴스 생성 불가
    // val x = JustOne()

    val result1 = JustOne.n
    val result2 = JustOne.f()
    val result3 = JustOne.g()

    println(result1)    // 2
    println(result2)    // 4
    println(result3)    // 40
}

object 키워드가 객체의 구조를 정의하는 동시에 객체를 생성해버리기 때문에 JustOne() 으로 새로운 인스턴스를 생성할 수 없다.

object 키워드는 내부 원소들을 object 로 정의한 객체의 name space 안에 넣는다.
object 가 선언된 파일 안에서만 보이게 하려면 private 를 앞에 붙이면 된다.


1.2. object 의 상속

object 는 다른 클래스나 인터페이스를 상속할 수 있다.

특정 인터페이스를 구현해야 하는데 그 구현 내부에 다른 상태가 필요하지 않은 경우 이런 기능이 유용하다.

예) java.util.Comparator 인터페이스를 보면 Comparator 구현은 두 객체를 인자로 받아서 그 중 어느 객체가 더 큰지 알려주는 정수를 반환하며, Comparator 안에는 데이터를 저장할 필요가 없음

따라서 어떤 클래스에 속한 객체를 비교할 때 사용하는 Comparator 는 보통 클래스마다 단 하나씩만 있으면 되므로 Comparator 인스턴스를 만드는 방법으로는 object 선언이 가장 좋은 방법임

open class Paint(private val color: String) {
    open fun apply() = "apply $color~"
}

// 다른 클래스를 상속한 object
object Acrylic : Paint("Red") {
    override fun apply() = "Acrylic, ${super.apply()}"
}

interface PaintPreparation {
    fun prepare(): String
}

// 다른 인터페이스를 상속한 object
object Prepare : PaintPreparation {
    override fun prepare() = "prepare~"
}

fun main() {
    val result1 = Prepare.prepare()
    val result2 = Paint("Green").apply()
    val result3 = Acrylic.apply()

    println(result1) // prepare~
    println(result2) // apply Green~
    println(result3) // Acrylic, apply Red~
}

object 의 인스턴스는 단 하나이기 때문에 이 인스턴스가 object 를 사용하는 모든 코드에서 공유된다.

아래는 각각 다른 파일이다.

object Shared {
    var i: Int = 0
}
fun f() {
    Shared.i += 2
}
fun g() {
    Shared.i += 3
}

fun main() {
    f()
    g()
    println(Shared.i) // 5
}

object 는 인스턴스를 하나만 만들기 때문에 모든 파일에서 Shared 는 동일하다.

Shared 를 private 로 정의하면 다른 파일에서는 이 객체에 접근할 수 없다.


1.3. 다른 object 나 클래스 안에 object 내포

object 를 함수 안에 넣을 수는 없지만, 다른 object 나 클래스 안에 object 를 내포시킬 수는 있다.

이렇게 클래스 안에 선언된 object 도 인스턴스는 단 하나뿐이다.
바깥 클래스의 인스턴스마다 내포된 객체 선언에 해당하는 인스턴스가 하나씩 따로 생기는 것이 아니라는 의미이다.

클래스가 내포 클래스이어도 관계없지만, 내부 클래스(inner class) 의 경우엔 내부에 object 를 선언할 수 없음
이 내용은 바로 다음인 2. 내부 클래스 (inner class) 에 나옵니다.

object Outer {
    // object 안에 내포된 object
    object Nested {
        val a = "Outer.Nested.a"
    }
}

class HasObject {
    // 클래스 안에 내포된 object
    object Nested {
        val a = "HasObject.Nested.a"
    }
}

fun main() {
    println(Outer.Nested.a) // Outer.Nested.a
    println(HasObject.Nested.a) // HasObject.Nested.a
}

클래스 안에 object 를 넣는 또 다른 방법으로 companion object 가 있는데 이 내용은 4. 동반 객체 (companion object) 를 참고하세요.


2. 내부 클래스 (inner class)

inner 클래스는 내포된 클래스와 비슷하지만, inner 클래스의 객체는 자신을 둘러싼 클래스 인스턴스에 대한 참조(암시적 링크)를 유지한다.

아래 코드에서 Hotel2. 내포된 클래스 (nested class) 에 나온 Airport 와 비슷하지만 내포된 클래스가 아닌 inner 클래스가 포함되어 있다.

class Hotel(private val reception: String) {
    // inner class
    open inner class Room(val id: Int = 1) {
        // Room 을 둘러싼 클래스의 reception 사용
        fun callReception() = "Room $id calling $reception~"
    }

    // 내포된 inner class 이면서 private
    // inner class 인 Room 을 상속하므로 Closet 도 inner class 이어야 함
    // (내포된 클래스는 inner class 를 상속할 수 없음)
    private inner class Closet : Room()

    // 결과를 public 타입인 Room 으로 업캐스트하여 반환해야 함
    fun closet(): Room = Closet()
}

fun main() {
    val aaHotel = Hotel("AAA")

    // inner class 의 인스턴스를 생성하려면 그 inner class 를 둘러싼 클래스의 인스턴스가 필요
    val room = aaHotel.Room(111)
    val result1 = room.callReception()

    println(result1)    // Room 111 calling AAA~

    // 아래와 같은 오류가 뜨면서 컴파일되지 않음
    // Classifier 'Closet' does not have a companion object, and thus must be initialized here

    // val privateCloset = Hotel.Closet()

    val bbHotel = Hotel("BBB")
    val closet = bbHotel.closet()
    val result2 = closet.callReception()

    println(result2)    // Room 1 calling BBB~
}

Airport 에서 내포된 클래스인 Plane 객체를 생성할 때는 Airport 객체가 필요없었지만, inner 클래스의 인스턴스를 생성할 때는 그 inner 클래스를 둘러싼 클래스의 인스턴스가 필요하다.

코틀린은 inner data 클래스는 허용하지 않는다.


2.1. 한정된 this: this@클래스명

클래스의 장점 중 하나는 this 참조를 사용할 수 있다는 점이다.

간단한 클래스에서 this 의 의미는 분명해 보이지만 inner 클래스에서 this 는 inner 객체나 외부 객체를 가리킬 수 있다.

이러한 문제를 해결하기 위해 코틀린은 한정된 this 구문을 사용한다.
한정된 thisthis 뒤에 @ 를 붙이고 대상 클래스 이름을 붙이면 된다.

아래는 3가지 수준의 클래스 예시이다.
Fruit 안에 inner 클래스인 Seed 가 있고, Seed 클래스 안에 다시 inner 클래스인 DNA 가 있다.

val Any.name
    get() = this::class.simpleName

class Fruit { // @Fruit 라는 레이블이 암시적으로 붙음
    fun changeColor(color: String) = println("Fruit $color~")

    fun absorbWater(amount: Int) {}

    // Fruit 안에 있는 Seed inner class
    inner class Seed { // @Seed 라는 레이블이 암시적으로 붙음
        fun changeColor(color: String) = println("Seed $color~")

        fun germinate() {}

        fun whichThis() {
            // 디폴트로 (가장 안쪽의) 현재 클래스인 Seed 를 가리킴
            println(this.name) // Seed

            // 명확히 하기 위해 디폴트 this 를 한정시킴
            println(this@Seed.name) // Seed

            // name 이 Fruit 와 Seed 에 다 있으므로 Fruit 를 명시하여 접근
            println(this@Fruit.name) // Fruit

            // 현재 클래스의 inner class 에 @레이블 을 사용하여 접근 불가
            // println(this@DNA.name)
        }

        // Seed inner class 안에 있는 DNS inner class
        inner class DNA {
            fun changeColor(color: String) {
                // changeColor(color) // 재귀 호출이 됨

                this@Seed.changeColor(color)
                this@Fruit.changeColor(color)
            }

            fun plant() {
                // 한정시키지 않고 외부 클래스의 함수 호출 가능
                germinate()
                absorbWater(10)
            }

            // 확장 함수
            fun Int.grow() { // @grow 라는 레이블이 암시적으로 붙음
                // 디폴트는 Int.grow() 로, Int 를 수신 객체로 받음
                println(this.name) // Int

                // @grow 한정은 없어도 됨
                println(this@grow.name) // Int

                // 여기서도 여전히 모든 프로퍼티에 접근 가능
                println(this@DNA.name) // DNA
                println(this@Seed.name) // Seed
                println(this@Fruit.name) // Fruit
            }

            // 외부 클래스에 대한 확장 함수들
            fun Seed.plant() {}

            fun Fruit.plant() {}

            fun witchThis() {
                // 디폴트는 현재 클래스
                println(this.name) // DNA

                // @DNA 한정은 없어도 됨
                println(this@DNA.name) // DNA

                // 다른 클래스 한정은 꼭 명시 필요
                println(this@Seed.name) // Seed
                println(this@Fruit.name) // Fruit
            }
        }
    }
}

// 확장 함수
fun Fruit.grow(amount: Int) {
    absorbWater(amount)

    // Fruit 의 changeColor() 호출
    changeColor("Red") // Fruit Red~
}

// inner class 를 확장한 함수
fun Fruit.Seed.grow(amount: Int) {
    germinate()
    // Seed 의 changeColor() 호출
    changeColor("Red") // Seed Red~
}

// inner class 를 확장한 함수
fun Fruit.Seed.DNA.grow(amount: Int) = amount.grow()

fun main() {
    val fruit = Fruit()
    fruit.grow(3) // Fruit Red~

    val seed = fruit.Seed()
    seed.grow(4) // Seed Red~
    seed.whichThis() // Seed  Seed  Fruit

    val dna = seed.DNA()
    dna.plant()
    dna.grow(5) // Int  Int  DNA  Seed  Fruit
    dna.witchThis() // DNA  DNA  Seed  Fruit
    dna.changeColor("Red") // Seed Red~  Fruie Red~
}

Fruit, Seed, DNA 모두 changeColor() 함수를 제공하지만 세 클래스 사이에 아무런 상속관계가 없으므로 오버라이드 하지 않는다.

세 클래스에 정의된 changeColor() 의 시그니처가 같기 때문에 DNAchangeColor() 에서 보는 것처럼 한정된 this 를 사용하여 각 함수를 구별해야 한다.

Int.grow() 는 확장 함수임에도 불구하고 외부 객체에 접근이 가능하다.


2.2. inner 클래스 상속

inner 클래스는 다른 외부 클래스에 있는 inner 클래스를 상속할 수 있다.

아래에서 BigEggYorkEggYolk 를 상속한다.

open class Egg {
    private var yolk = Yolk()

    open inner class Yolk {
        // 주생성자
        init {
            println("Egg.Yolk()~")
        }

        open fun f() = println("Egg.Yolk.f()~")
    }

    // 주생성자
    init {
        println("New Egg~")
    }

    fun insertYolk(y: Yolk) {
        yolk = y
    }

    fun g() {
        yolk.f()
    }
}

// Egg 클래스 상속
class BigEgg : Egg() {
    // Egg 의 inner class 인 Yolk 상속
    inner class Yolk : Egg.Yolk() {
        init {
            println("BigEgg.Yolk()~")
        }

        override fun f() = println("BigEgg.Yolk.f()~")
    }

    // 주생성자
    init {
        insertYolk(Yolk())
    }
}

fun main() {
    // Egg.Yolk()~
    // New Egg~
    // Egg.Yolk()~
    // BigEgg.Yolk()~
    // BigEgg.Yolk.f()~
    BigEgg().g()
}

BigEgg.YolkEgg.Yolk 를 기반 클래스로 정의하고, Egg.Yolkf() 멤버 함수를 오버라이드한다.
insertYolk()BigEgg 가 자신의 Yolk 객체를 Egg 에 있는 yolk 참조로 업캐스트하게 허용한다.
따라서 g()yolk.f() 를 호출하면 오버라이드된 f() 가 호출된다.

Egg.Yolk() 에 대한 두 번째 호출은 BigEgg.Yolk 생성자에서 호출한 기반 클래스 생성자이다.


2.3. Local inner 클래스와 익명 inner 클래스: object

object 키워드를 싱글턴과 같은 객체를 정의하고 그 객체에 이름을 붙일 때만 사용하지는 않는다.
익명 object 를 정의할 때도 object 키워드를 사용한다.

멤버 함수 안에 정의된 클래스를 Local inner 클래스라고 한다.
이런 클래스는 객체 식(object expression) 이나 SAM 변환을 사용하여 익명으로 생성할 수 있다.

객체 식(익명 object) 은 자바의 익명 내부 클래스 대신 사용된다.

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

객체 식을 사용하는 방식은 object 선언과 동일하며, 유일한 차이는 object 이름이 빠진다는 점이다.

object 선언과 달리 익명 object 는 싱글턴이 아님
객체 식이 쓰일 때마다 새로운 인스턴스가 생성됨

SAM 변환에 대한 좀 더 상세한 내용은 1.3. 단일 추상 메서드 (Single Abstract Method, SAM): fun interface 를 참고하세요.

모든 경우에 inner 키워드를 사용하지는 않지만, Local inner 클래스는 암시적으로 inner 클래스가 된다.

fun interface Pet {
    fun speak(): String
}

object CreatePet {
    fun home() = " home~"

    // dog() 는 Pet 을 상속하면서 speak() 를 오버라이드하는 클래스 반환
    fun dog(): Pet {
        val say = "Bark~"

        // (1) Local inner 클래스
        class Dog : Pet {
            override fun speak() = say + home()
        }
        return Dog()
    }

    fun cat(): Pet {
        val emit = "Meow~"
        // (2) 익명 inner 클래스
        return object : Pet {
            override fun speak() = emit + home()
        }
    }

    fun hamster(): Pet {
        val squeak = "Squeak~"
        // (3) SAM 변환
        return Pet { squeak + home() }
    }
}

fun main() {
    val result1 = CreatePet.dog().speak()
    val result2 = CreatePet.cat().speak()
    val result3 = CreatePet.hamster().speak()

    println(result1)    // Bark~ home~
    println(result2)    // Meow~ home~
    println(result3)    // Squeak~ home~
}

Local inner 클래스는 함수에 정의된 원소와 함수 정의를 포함하는 외부 클래스 객체의 원소에 접근이 가능하다.
그래서 say, emit, squeakhome()speak() 안에서 사용할 수 있다.

위에서 cat()Pet 를 상속하면서 speak() 를 오버라이드하는 클래스의 object 를 반환한다.


inner 클래스는 외부 클래스 객체에 대한 참조를 저장하기 때문에 Local inner 클래스도 자신을 둘러싼 클래스에 속한 객체의 모든 멤버에 접근 가능하다.

// 단일 추상 메서드 (fun interface)
fun interface Counter {
    fun next(): Int
}

object CounterFactory {
    private var count = 0

    // Counter interface 구현
    // 이름이 붙은 inner 클래스의 인스턴스 반환
    fun new(name: String): Counter {
        // Local inner 클래스
        class Local : Counter {
            // 주생성자
            init {
                println("Local()~")
            }

            override fun next(): Int {
                // 함수의 지역 변수나 외부 객체 프로퍼티에 접근 가능
                println("$name, $count~")
                return count++
            }
        }
        return Local()
    }

    // 익명 inner 클래스 반환
    fun new2(name: String): Counter {
        // 익명 inner 클래스 인스턴스
        return object : Counter {
            init {
                println("Counter()~")
            }

            override fun next(): Int {
                println("$name, $count~~")
                return count++
            }
        }
    }

    // SAM 변환을 사용하여 익명 객체 반환
    fun new3(name: String): Counter {
        println("Counter()~~")
        // SAM 변환
        return Counter {
            println("$name, $count~~~")
            count++
        }
    }
}

fun main() {
    fun aaa(counter: Counter) {
        (0..3).forEach { _ -> counter.next() }
    }

    // Local()~
    // Local inner class, 0~
    // Local inner class, 1~
    // Local inner class, 2~
    // Local inner class, 3~
    val result1 = aaa(CounterFactory.new("Local inner class"))

    // Counter()~
    // Anonymous inner class, 4~~
    // Anonymous inner class, 5~~
    // Anonymous inner class, 6~~
    // Anonymous inner class, 7~~
    val result2 = aaa(CounterFactory.new2("Anonymous inner class"))

    // Counter()~~
    // SAM, 8~~~
    // SAM, 9~~~
    // SAM, 10~~~
    // SAM, 11~~~
    val result3 = aaa(CounterFactory.new3("SAM"))
}

SAM 변환 (fun interface)에 대한 좀 더 상세한 내용은 1.3.1. SAM 변환 을 참고하세요.

(0..3).forEach { -> counter.next() }_ 에서 밑줄은 1.4. 람다가 특정 인자를 사용하지 않는 경우: List.indices() 를 참고하세요.

위에서 new(), new2(), new3() 은 각각 Counter 인터페이스에 대한 다른 구현을 생성한다.

  • new() : 이름이 붙은 inner 클래스의 인스턴스 반환
  • new2() : 익명 inner 클래스의 인스턴스 반환
  • new2() : SAM 변환을 사용하여 익명 객체 반환

모든 Counter 객체는 외부 객체의 원소에 접근할 수 있으므로 이 클래스들은 내포된 클래스가 아니라 inner 클래스이다.
출력을 보면 모든 Counter 객체가 CounterFactorycount 를 공유한다는 것을 알 수 있다.

내포된 클래스에 대한 좀 더 상세한 내용은 2. 내포된 클래스 (nested class) 를 참고하세요.

SAM 변환에는 한계가 있는데 예를 들어 SAM 변환으로 선언하는 객체 내부에는 주 생성자인 init 블록이 들어갈 수 없다.


3. 내부 클래스 (inner class) 와 내포된 클래스 (nested class)

내부 클래스에 대한 내용은 2. 내부 클래스 (inner class) 를 참고하세요.

클래스 안에 다른 클래스를 선언할 수도 있는데 이렇게 클래스 안에 다른 클래스를 선언하면 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳에 가까이 두고 싶을 때 유용하다.

자바와의 차이는 내포된 클래스 (nested class) 는 명시적으로 요청을 하지 않는 한 외부 클래스 인스턴스에 대한 접근 권한이 없다는 점이다.

아래처럼 View 의 상태를 직렬화해야 할 경우 View 를 직렬화하기는 쉽지 않지만 필요한 모든 데이터를 다른 도우미 클래스로 복사할 수는 있다.
그러기 위해 State 인터페이스를 선언한 후 Serializable 을 구현한다.

View 인터페이스 안에는 뷰의 상태를 가져와서 저장할 때 사용할 메서드가 2개 선언되어 있다.

import java.io.Serializable

// View 를 직렬화하기 위해 선언한 인터페이스
interface State : Serializable

interface View {
    fun getCurrentState(): State

    fun restoreState(state: State) {}
}

Button1 클래스의 상태를 저장하는 클래스(ButtonState) 는 Button 클래스 내부에 선언하면 편하다.

아래는 자바에서의 예시이다.

public class Button1 implements View {
  @Override
  public State getCurrentState() {
    return new ButtonState();
  }

  @Override
  public void restoreState(State state) {
    // ...
  }

  public class ButtonState implements State {
    // ...
  }
}

자바는 다른 클래스 안에 정의한 클래스는 자동으로 내부 클래스 (inner class) 가 되기 때문의 위의 ButtonState 클래스는 바깥쪽 Button 클래스에 대한 참조를 묵시적으로 포함한다. 그 참조로 인해 ButtonState 를 직렬화할 수 없다.

이 문제를 해결하려면 ButtonState 를 클래스로 선언해야 한다.
자바에서 내포된 클래스 (nested class) 를 static 으로 선언하면 그 클래스를 둘러싼 외부 클래스에 대한 묵시적인 참조가 사라진다.

코틀린에서는 내포된 클래스 (nested class) 가 기본적으로 동작하는 방식이 자바와 정반대이다.

아래는 코틀린에서의 예시이다.

class Button2 : View {
    override fun getCurrentState(): State = ButtonState()

    override fun restoreState(state: State) {
        // ...
    }

    // 내포된 클래스 (nested class)
    class ButtonState : State {
        // ...
    }
}

코틀린의 내포된 클래스에 아무런 변경자가 붙지 않으면 자바의 static 중첩 클래스와 같다.

만일 이를 내부 클래스 (inner class) 로 변경해서 외부 클래스에 대한 참조를 포함하게 하고 싶다면 inner 변경자를 붙이면 된다.

자바와 코틀린의 내포된 클래스 (nested class) 와 내부 클래스 (inner class) 차이

클래스 B 안에 정의된 클래스 A자바코틀린
내포된 클래스 (바깥쪽 클래스에 대한 참조를 저장하지 않음)static class Aclass A
내부 클래스 (바깥쪽 클래스에 대한 참조를 저장함)class Ainner class A

4. 동반 객체 (companion object)

companion object 는 클래스 안에 내포된 객체 중 하나이다.

companion object 는 팩토리 메서드와 정적 멤버가 들어갈 장소에 사용된다.

코틀린 클래스 안에는 정적인 멤버가 없다. 코틀린은 자바의 static 키워드를 지원하지 않는다.
대신 코틀린에서는 패키지 수준의 최상위 함수와 object 선언을 활용한다.

  • 패키지 수준의 최상위 함수
    • 자바의 정적 메서드 역할을 거의 대신 할 수 있음
  • object 선언
    • 자바의 정적 메서드 역할 중 코틀린의 최상위 함수가 대신할 수 없는 역할이나 정적 필드를 대신함

최상위 함수에 대한 상세한 내용은 4.1. 최상위 함수: @JvmName 를 참고하세요.

대부분의 경우 최상위 함수를 활용하는 편을 더 권장하지만 최상위 함수는 private 로 정의된 클래스의 비공개 멤버에 접근할 수 없다.
따라서 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스의 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 내포된 object 선언의 멤버 함수로 정의해야 한다.


4.1. companion object 기본

동반 객체 (companion object) 안에 있는 함수와 필드는 클래스에 대한 함수와 필드이다.

일반 클래스의 원소는 companion object 의 원소에 접근할 수 있지만, companion object 의 원소는 일반 클래스의 원소에 접근할 수 없다.

companion object 안에 정의되어 있는 원소는 동반 클래스의 인스턴스나 함수를 마음대로 사용 가능함
다만, 동반 클래스의 멤버는 동반 클래스의 인스턴스에 대해 작용하므로 companion object 의 함수나 프로퍼티가 동반 클래스의 멤버에 접근하려면 반드시 동반 클래스의 인스턴스를 함수 파라메터로 받거나 해야 함

1.3. 다른 object 나 클래스 안에 object 내포 에서 본 것처럼 클래스 안에 일반 object 를 정의할 수 있다.
하지만 일반 내포 객체 정의는 내포 object 와 그 객체를 둘러싼 클래스 사이의 연관 관계를 제공하지 않는다.
내포된 object 의 멤버를 클래스 멤버에서 참조해야 할 때는 내포된 object 의 이름을 항상 명시해야 한다.
클래스 안에서 companion object 를 정의하면 클래스의 내부에서 companion object 원소를 투명하게 참조 가능하다.

companion object 의 프로퍼티나 메서드에 접근하려면 그 companion object 가 정의된 클래스 이름을 사용한다.
그 결과 companion object 의 멤버를 사용하는 구문은 자바의 정적 메서드 호출이나 정적 필드 사용 구문과 같아진다.
(인스턴스를 생성할 필요없음)

class WithCompanion {
    companion object {
        val i = 3

        fun f() = i * 3
    }

    // 클래스 멤버는 companion object 의 원소에 아무런 한정을 사용하지 않고 접근 가능
    // 만일 companion object 가 아니라 일반 object 였다면 Unresolved reference: i, f() 오류 발생
    fun g() = i + f()
}

// companion object 에 대한 확장 함수
fun WithCompanion.Companion.h() = f() * i

fun main() {
    val wc = WithCompanion()

    val result1 = wc.g()

    // 클래스 밖에서는 companion object 의 멤버를 클래스 이름을 사용하여 참조 가능
    // 만일 companion object 가 아니었다면 클래스 밖에서 object 의 원소 참조 불가
    val result2 = WithCompanion.i

    // 클래스 밖에서는 companion object 의 멤버를 클래스 이름을 사용하여 참조 가능
    // 만일 companion object 가 아니었다면 클래스 밖에서 object 의 원소 참조 불가
    val result3 = WithCompanion.f()
    val result4 = WithCompanion.h()

    println(result1) // 12
    println(result2) // 3
    println(result3) // 9
    println(result4) // 27
}

companion object 를 이용하며면서 핵심 로직과 도우미 로직을 분리하고 싶다면 아래와 같이 companion object 의 확장 함수를 이용하면 된다.

확장 함수를 사용하기 전의 예시

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

class Person(
    val name: String,
) {
    // 동반 객체가 인터페이스 구현
    companion object : JSONFactory<Person> {
        override fun fromJSON(jsonText: String): Person = Person("TEST")
    }
}

fun main() {
    val result = Person.fromJSON("aaa")
    println(result.name)    // TEST
}

확장 함수를 사용한 예시

// 비즈니스 로직
class Person2(
    val name: String,
) {
    // 비어있는 동반 객체 선언
    companion object
}

// 도우미 로직
fun Person2.Companion.fromJSON(jsonText: String): Person = Person("TEST")

fun main() {
    val result = Person2.fromJSON("aaa")
    println(result.name) // TEST
}

companion object 에 대한 확장 함수를 작성하려면 원래 클래스에 비어있는 object 라도 반드시 companion object 가 꼭 있어야 한다.


4.2. 함수를 companion object 대신 파일 영역에 배치

함수가 클래스의 private 멤버에 접근할 필요가 없다면 이 함수를 companion object 에 넣는 대신 파일 영역(최상위 수준)에 정의하면 된다.

companion object 는 클래스 당 하나만 허용 가능하며, 명확성을 위해 companion object 에 이름을 부여할 수도 있다.

class WithNamed {
    companion object Aaa {
        fun s() = "from Aaa~"
    }
}

class WithDefault {
    companion object {
        fun s() = "from Default~"
    }
}

fun main() {
    val result1 = WithNamed.s()
    val result2 = WithNamed.Aaa.s()
    val result3 = WithDefault.s()

    // 디폴트 이름은 Companion 임
    val result4 = WithDefault.Companion.s()

    println(result1) // from Aaa~
    println(result2) // from Aaa~
    println(result3) // from Default~
    println(result4) // from Default~
}

companion object 에 이름을 붙이지 않으면 기본으로 Companion 이라는 이름이 부여된다.


4.3. companion object 안에서의 프로퍼티

companion object 안에서 프로퍼티를 생성하면 이 필드는 메모리 상에 단 하나만 존재하게 되고, companion object 와 연관된 클래스의 모든 인스턴스가 이 필드를 공유한다.

class WithObjectProperty {
    companion object {
        private var n: Int = 0 // 메모리 상에 단 하나만 존재
    }

    // companion object 를 둘러싼 클래스에서 companion object 의 private 멤버에 접근 가능
    fun incr() = ++n
}

fun main() {
    val a = WithObjectProperty()
    val b = WithObjectProperty()

    println(a.incr()) // 1
    println(b.incr()) // 2
    println(a.incr()) // 3
}

위에서 WithObjectProperty 의 인스턴스가 몇 개가 생성되었든 n 은 모두 하나의 저장소임을 알 수 있다.
incr()companion object 를 둘러싼 클래스에서 companion object 의 private 멤버에 접근 가능하다는 것을 보여준다.


4.4. 함수를 companion object 영역에 배치

함수가 오직 companion object 의 프로퍼티만 사용한다면 해당 함수는 companion object 에 넣는 것이 합리적이다.

아래와 같이 하면 더 이상 incr() 을 호출할 때 CompanionObjectFunctions 의 인스턴스가 필요하지 않다.

class CompanionObjectFunctions {
    companion object {
        private var n: Int = 0

        fun incr() = ++n
    }
}

fun main() {
    println(CompanionObjectFunctions.incr())    // 1
    println(CompanionObjectFunctions.incr())    // 2
}

만일 생성하는 모든 객체에 대해 고유 식별자를 부여하면서 전체를 카운트하고 싶다면 아래와 같이 하면 된다.

class Counted {
    companion object {
        private var n = 0
    }

    private val id = n++

    override fun toString() = "$id"
}

fun main() {
    val result = List(4) { Counted() }

    println(result) // [0, 1, 2, 3]
}

4.5. companion object 를 만들면서 인터페이스 구현

다른 object 선언과 마찬가지로 companion object 도 인터페이스를 구현할 수 있다.
인터페이스를 구현하는 companion object 를 참조할 때 object 를 둘러싼 클래스의 이름을 바로 사용할 수 있다.

아래 코드에서 ZICompanionZIOpen 객체를 companion object 로 사용하고,
ZICompanionInheritanceZIOpen 클래스를 확장하고, 오버라이드 하면서 ZIOpen 객체를 생성한다.
ZIClass 는 companion object 를 만들면서 ZI 인터페이스 구현한다.

interface ZI {
    fun f(): String

    fun g(): String
}

// open 으로 되어있어야 다른 곳에서 상속 가능
open class ZIOpen : ZI {
    override fun f() = "ZIOpen.f()~"

    override fun g() = "ZIOpen.g()~"
}

class ZICompanion {
    // ZIOpen 객체를 companion object 로 사용
    companion object : ZIOpen()

    fun u() = println("ZICompanion: ${f()} ${g()}~")
}

// ZIOpen 클래스를 확장하고, 오버라이드 하면서 ZIOpen 객체 생성
class ZICompanionInheritance {
    companion object : ZIOpen() {
        override fun g() = "ZICompanionInheritance.g()~"

        fun h() = "ZICompanionInheritance.h()~"
    }

    fun u() = println("ZICompanionInheritance: ${f()} ${g()} ${h()}")
}

// companion object 를 만들면서 ZI 인터페이스 구현
class ZIClass {
    companion object : ZI {
        override fun f() = "ZIClass.f()~"

        override fun g() = "ZIClass.g()~"
    }

    fun u() = println("ZIClass: ${f()} ${g()}")
}

fun main() {
    ZIClass.f() //
    ZIClass.g() //
    ZIClass().u() // ZIClass: ZIClass.f()~ ZIClass.g()~

    ZICompanion.f() //
    ZICompanion.g() //
    ZICompanion().u() // ZICompanion: ZIOpen.f()~ ZIOpen.g()~~

    ZICompanionInheritance.f() //
    ZICompanionInheritance.g() //
    ZICompanionInheritance().u() // ZICompanionInheritance: ZIOpen.f()~ ZICompanionInheritance.g()~ ZICompanionInheritance.h()~
}

아래는 companion object 가 인터페이스를 구현하는 또 다른 예시이다.

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

class Person(
    val name: String,
) {
    // 동반 객체가 인터페이스 구현
    companion object : JSONFactory<Person> {
        override fun fromJSON(jsonText: String): Person = Person("TEST")
    }
}

fun <T> loadFromJSON(factory: JSONFactory<T>) = factory

fun main() {
    // 동반 객체의 인스턴스를 함수에 넘김
    // 동반 객체가 구현한 JSONFactory 의 인스턴스를 넘길 때 Person 의 인스턴스가 아닌 Person 클래스 이름을 넘김
    loadFromJSON(Person)
}

동반 객체가 구현한 JSONFactory 의 인스턴스를 넘길 때 Person 의 인스턴스가 아닌 Person 클래스 이름을 넘긴다는 부분을 유의해서 보자.


4.6. 클래스 위임을 사용하여 companion object 활용

클래스 위임에 대한 좀 더 상세한 내용은 1. 클래스 위임 (class delegation) 을 참고하세요.

바로 위 코드에서 companion object 로 사용하고 싶은 클래스가 open 이 아니라면 위처럼 companion object 가 클래스를 직접 확장할 수 없다.
대신 그 클래스가 어떤 인터페이스를 구현한다면 클래스 위임을 사용하여 companion object 가 해당 클래스를 활용할 수 있다.

interface ZI1 {
    fun f(): String

    fun g(): String
}

class ZIClosed : ZI1 {
    override fun f() = "ZIClosed.f()~"

    override fun g() = "ZIClosed.g()~"
}

class ZIDelegation {
    // companion object 는 ZI1 인터페이스를 ZIClosed 객체를 사용(by) 하여 구현함
    companion object : ZI1 by ZIClosed()

    fun u() = println("ZIDelegation: ${f()} ${g()}~")
}

// open 이 아닌 ZIClosed 클래스를 위임에 사용하고, 
// 이 위임을 오버라이드하고 확장함
class ZIDelegationInheritance {
    // companion object 는 ZI1 인터페이스를 ZIClosed 객체를 사용(by) 하여 구현함
    companion object : ZI1 by ZIClosed() {
        override fun g() = "ZIDelegationInheritance.g()~"

        fun h() = "ZIDelegationInheritance.h()~"
    }

    fun u() = println("ZIDelegationInheritance: ${f()} ${g()} ${h()}")
}

fun main() {
    ZIDelegation.f() //
    ZIDelegation.g() //
    ZIDelegation().u() // ZIDelegation: ZIClosed.f()~ ZIClosed.g()~~

    ZIDelegationInheritance.f() //
    ZIDelegationInheritance.g() //
    ZIDelegationInheritance().u() // ZIDelegationInheritance: ZIClosed.f()~ ZIDelegationInheritance.g()~ ZIDelegationInheritance.h()~
}

ZIDelegationInheritanceopen 이 아닌 ZIClosed 클래스를 위임에 사용하고, 이 위임을 오버라이드하고 확장한다.

위임은 인터페이스의 메서드와 메서드 구현을 제공하는 인스턴스에 제공한다.

구현을 제공하는 인트턴스가 속한 클래스가 final 이라고 해도 (코틀린은 open 을 지정하지 않으면 기본적으로 final 임) 여전히 위임을 사용하여 정의한 클래스에서 메서드를 추가하고 오버라이드 가능하다.


4.7. companion object 를 사용하여 인터페이스 구현

아래에서 Extend 는 companion object (디폴트 이름은 Companion) 를 사용하여 ZI2 인터페이스 구현하고, Extended 인터페이스도 구현한다.
ExtendedZI2 인터페이스에 u() 함수를 추가한 인터페이스이다.

Extended 에서 ZI2 에 해당하는 부분은 Companion 을 통해 이미 구현이 제공되므로, ExtendExtended 에 추가된 u() 함수만 오버라이드하여 모든 구현을 끝낼 수 있다.

interface ZI2 {
    fun f(): String

    fun g(): String
}

// ZI2 인터페이스에 u() 함수 추가
interface Extended : ZI2 {
    fun u(): String
}

// companion object (디폴트 이름은 Companion) 를 사용하여 ZI2 인터페이스 구현
class Extend : ZI2 by Companion, Extended {
    companion object : ZI2 {
        override fun f() = "Extend.f()~"

        override fun g() = "Extend.g()~"
    }

    override fun u() = "Extend: ${f()}, ${g()}"
}

// Extend 객체를 Extended 로 업캐스트 가능
private fun test(e: Extended): String {
    e.f()
    e.g()
    return e.u()
}

fun main() {
    println(test(Extend())) // Extend: Extend.f()~, Extend.g()~
}

4.8. companion object 로 객체 생성 제어: Factory Method 패턴

companion object 는 private 생성자를 호출하기 좋은 위치이다.
companion object 는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있기 때문에 바깥쪽 클래스의 private 생성자도 호출할 수 있다.

따라서 companion object 는 객체 생성을 제어하는 경우에 많이 사용하는데 이 방식은 팩토리 메서드 패턴에 해당한다.

아래는 Numbered2 객체로 이루어진 List 생성만 허용하고, 개별 Numbered2 의 생성을 불가하는 예시이다.

class Numbered2
    // Numbered2 의 비공개 생성자
    private constructor(private val id: Int) {
        override fun toString(): String = "$id~"

        companion object Factory1 {
            fun create(size: Int) = List(size) { Numbered2(it) }
        }
    }

fun main() {
    val result1 = Numbered2.create(0)
    val result2 = Numbered2.create(3)

    // Cannot access '<init>': it is private in 'Numbered2
    // val result3 = Numbered2(1)

    println(result1) // []
    println(result2) // [0~, 1~, 2~]
}

Numbered2 의 생성자가 private 이므로 Numbered2 의 인스턴스를 생성하는 방법은 create() 팩토리 함수를 통하는 방법 뿐이다.

이렇게 일반 생성자로 해결할 수 없는 문제는 팩토리 함수가 해결해줄 수 있다.

아래는 여러 개의 부생성자가 있는 경우를 팩토리 메서드로 변환하는 과정의 예시이다.

부생성자가 여러 개 있는 클래스

fun getFacebookName(id: Int) = "A::$id"

class User {
  val nickname: String

  // 부생성자
  constructor(email: String) {
    nickname = email.substringBefore('@')
  }

  // 부생성자
  constructor(facebookAccountId: Int) {
    nickname = getFacebookName(facebookAccountId)
  }
}

fun main() {
  val user1 = User("assu1@naver.com")
  val user2 = User(1)

  println(user1.nickname) // assu1
  println(user2.nickname) // A::1
}

이런 로직은 클래스의 인스턴스를 생성하는 팩토리 메서드로 구현하는 것이 더 좋다.
아래는 생성자를 통해 User 인스턴스를 만드는 것이 아닌 팩토리 메서드를 통해 인스턴스를 생성하는 예시이다.

fun getFacebookName1(id: Int) = "A::$id"

class User1 private constructor(
    val nickname: String,
) { // 주생성자를 private 로 만듬
    // 동반 객체 선언
    companion object {
        fun newEmailUser(email: String) = User1(email.substringBefore('@'))

        fun newFacebookUser(facebookAccountId: Int) = User1(getFacebookName1(1))
    }
}

fun main() {
    // 클래스 이름을 사용하여 그 클래스에 속한 companion object 의 메서드 호출
    val user1 = User1.newEmailUser("assu1@naver.com")
    val user2 = User1.newFacebookUser(1)

    println(user1.nickname) // assu1
    println(user2.nickname) // A::1
}

팩토리 메서드는 유용하지만 클래스를 확장해야만 하는 경우에는 companion object 멤버를 파생 클래스에서 오버라이드할 수 없으므로 여러 생성자를 사용하는 편이 더 낫다.


4.9. companion object 생성 시점

아래 코드를 보면 CompanionInit() 을 호출하여 CompanionInit 인스턴스가 최초로 생성되는 시점에 companion object 가 단 한번만 생성된 다는 것을 알 수 있다.
또한 동반 클래스 생성자 생성보다 companion object 생성이 먼저 일어난다는 것도 알 수 있다.

class CompanionInit {
    init {
        println("CompanionInit Constructor~")
    }

    companion object {
        init {
            println("Companion Constructor~")
        }
    }
}

fun main() {
    println("before")
    
    // Companion Constructor~
    // CompanionInit Constructor~
    
    CompanionInit()
    println("after 1")
    
    // CompanionInit Constructor~
    CompanionInit()
    println("after 2")
    
    // CompanionInit Constructor~
    CompanionInit()
    println("after 3")
}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 브루스 에켈, 스베트라아 이사코바 저자의 아토믹 코틀린 과 드리트리 제메로프, 스베트라나 이사코바 저자의 Kotlin In Action 을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10