본문 바로가기

Kotlin/Basic

[Kotlin] object & companion object

Object


Object 키워드를 다양한 상황에서 사용할 수 있다. 그 상황들의 공통점은, 클래스를 정의하면서 동시에 인스턴스(객체)를 생성한다는 점이다.

코틀린에서 object키워드는 다음 두가지 형태로 사용 가능하다.

  • object declarations(선언식)
  • object expressions(표현식)

1. Object declaration(선언식): Singleton 만들기


object를 선언식으로 사용하게 된다면, 무엇보다도 Singleton패턴 형태로 사용하는 것이다.

객체지향 프로그램을 설계하다 보면, 인스턴스가 하나만 필요한 클래스가 유용한 경우가 많아진다.

JAVA에서는 아래와 같이 보통 클래스의 생성자를 private로 선언하고, static 변수에 클래스 객체를 저장하는 방식으로 싱글턴 패턴을 구현한다.

public class Singleton {
    private static Singleton INSTANCE;

    private Singleton() { }

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}

반면, 코틀린은 객체 선언(object) 기능을 통해 싱글톤을 언어 자체에서 기본 지원해준다.

코틀린에서 object를 선언식으로 사용하는 문법은 아래와 같다.

object Singleton{
	...
}

// 사용 방법
val singleton = Singleton

선언된 싱글톤은 객체명만으로 사용이 가능하다.

💬
싱글톤과 의존관계 주입(Dependency Injection)

싱글톤 패턴과 마찬가지의 이유로, 대규모 프로젝트에서는 객체 선언이 항상 적합한 솔루션은 아니다. 의존관계가 많지 않은 소규모 프로젝트에서는 싱글톤이나 객체 선언이 유용하지만, 대규모 프로젝트에서는 객체 생성을 제어할 수 없고, 생성자 파라미터를 지정할 수 없으므로, 단위 테스트를 하거나 프로젝트의 설정이 달라질 때, 객체를 대체하거나 객체의 의존관계를 바꾸기가 쉽지 않다. 그렇기 때문에, 의존관계 주입을 사용해야한다.

차후, Hilt 혹은 의존성 주입에 관련된 글에서 자세히 다루겠다.

Object 내에 class와 같이 멤버 변수/ 메서드를 가질 수 있으며, class와 interface의 상속이 가능하다.

그렇기 때문에, object를 선언식으로 사용하게 되면, 객체를 생성하여 사용할 수 있다.

object Singleton: MyClass(), MyInterface { //⭐ class, interface 상속 가능
	
	private val name: String = "Molrhmbo" // ⭐ 멤버 변수를 가질 수 있다.
	
	override fun MyClassFunc(){
		...
	}
}

2. Object expressions(표현식): Non-Singleton


선언식과 달리 object를 표현식으로 사용하면 Singleton형태가 아닌, 아래와 같은 익명 객체(anonymous object)로서 사용가능하다.

흔히 접할 수 있는 익명 객체는 익명 클래스로 구현하는 이벤트 리스너들을 떠올리면 된다.

  • 익명 클래스의 객체를 바로 생성하고자 하는 경우
    // 📢 익명 클래스의 객체를 바로 생성하고자 하는 경우
    fun main() {
        val user = object{
            val name = "Molrhmbo"
            val age = 28
        }
        println(user.name)
        println(user.age)
    }
  • 추상 클래스, 인터페이스의 구현체를 익명 클래스의 객체로 구현하고자 하는 경우.
    // 📢 추상 클래스, 인터페이스의 구현체를 익명 클래스의 객체로 구현하고자 하는 경우.
    interface MyInterface{
        val name:String
        val age: Int
        fun greeting()
    }
    
    val myListener= object: MyInterface{
        override val name = "Molrhmbo"
        override val age = 28
       	override fun greeting(){
           	println("my name is ${name}")
           	println("and I'm ${age} years old!")
       	}
    }
    
    fun main() {
        myListener.greeting()
    }

아래는 익명 객체로 리스너를 구현하는 예시이다.

/*
* 익명 객체로 리스너 구현하기
*/
binding.countingButton.setOnClickListener(
	object: View.OnClickListener { // ⭐ 익명 객체로 클릭 리스너 선언
		override fun OnClick(v: View?) {
			...
		}
	}
)

위 예제에서 익명 클래스는 싱글톤이 아니다.

호출할 때 마라 새로운 인스턴스가 생성되고, 익명 클래스 안에서 자신이 포함된 함수의 로컬 변수에 접근할 수 있다.

자바에서는 무조건 final이어야만 접근이 가능한것에 비해, 코틀린에서는 final이 아닌 변수도 객체 식 안에서 사용할 수 있는 것이다.

fun countClick(){
	val counter = 0 // ⭐ 로컬 변수

	binding.countingButton.setOnClickListener( object: View.OnClickListener {
			override fun OnClick(v: View?) {
				counter ++ // ⭐ 로컬 변수의 값을 변경한다.
			}
		})
}
💬
여담

object 익명 객체의 사용은 여러 메소드를 오버라이드 해야하는 경우에 훨씬 더 유용하다.

위 예제와 같이 메소드가 하나뿐인 경우에는 람다식을 활용하는 편이 더 깔끔하다.

fun countClick(){
	val counter = 0
	binding.countingButton.setOnClickListener {counter++}
}

Static 멤버 선언


코틀린 언어는 자바의 static을 지원하지 않기 때문에, 클래스 안에는 static 멤버가 없다.

그 대신, 코틀린에서는 클래스 바깥에 선언하는 최상위 함수(==java의 static method역할)와 객체 선언을 활용한다. 대부분의 경우에는 최상위 함수를 활용하는 편을 더 권장한다. 하지만, 최상위 함수는 private 멤버에 접근을 할 수가 없다.

클래스 안에 “companion object”를 정의 후, 그 안에 클래스의 프로퍼티나 메소드를 선언한다면, 자바의 static 호출 사용 구문과 동일하게 외부에서 접근할 수 있다.

또, companion object 객체 선언은, private 생성자를 호출하기 좋은 위치이다. 이는 자신을 포함한 클래스의 모든 private 멤버에 접근할 수 있기 때문에, 팩토리 패턴을 구현하기에 가장 적합한 위치이다.

간단한 예제로, 부 생성자가 두 개 있는 클래스를, companion object를 사용해서 팩토리 클래스로 재정의 해보겠다.

class User{
    val name: String
    
    constructor(email: String){ // sub constructor 1
        name = email.substringBefore('@')
    }
    
    constructor(naverEmailId: String){ // sub construtor 2
        name = getNaverName(naverEmailId)
    }
}

위 코드를 더 유용한 방법으로 클래스의 인스턴스를 생성하는 팩토리 메소드로 바꿔보겠다.

class User private constructor(val name: String){ // declare main constructor in private
    companion object{
        fun newEmailUser(email: String) = User(email.substringBefor('@'))
        
        fun newNaverUser(naverEmail: String) = User(getNaverEmail(naverEmailId))
    }
}

val emailUser = User.newEmailUser("abcd@gmail.com") // invoke method in companion object
println(emailUser.name)
📢
companion object 내에 선언된 속성과 함수는, 클래스명.필드/함수 이름 형태로 바로 호출이 가능하다.

팩토리 패턴을 적용하니 훨씬 더 유용해 보인다.

이런식으로 정의된 팩토리 메소드는 생성할 필요가 없는 객체를 생성하지 않을 수도 있는데, 예를 들어 이메일 주소별로 유일한 User인스턴스를 만드는 경우, 팩토리 메소드가 이미 존재하는 인스턴스에 해당하는 이메일 주소를 전달받으면 새 인스턴스를 만들지 않고, 캐시에 있는 기존 인스턴스를 반활할 수 있다.

즉, 매우 유용하다.👍🏼

Companion object를 일반 객체처럼 사용하기


companion object는 클래스 안에 정의된 일반 객체이다.

그렇기 때문에 이름을 붙이거나, 인터페이스를 상속하거나, companion object안에 확장 함수와 프로퍼티를 정의할 수 있다.

class User private constructor(val name: String){ // declare main constructor in private
    companion object IdType{
        fun newEmailUser(email: String) = User(email.substringBefor('@'))
        
        fun newNaverUser(naverEmail: String) = User(getNaverEmail(naverEmailId))
    }
}

val emailUser = User.newEmailUser("abcd@gmail.com")
println(emailUser.name)
// >>> abcd

val emailUser2 = User.IdType.newEmailUser("hello@gmail.com") //⭐ 이름을 붙인 companion object 메소드 호출
println(emailUser2.name)
// >>> hello

아래는 companion object에서 인터페이스를 구현하는 예이다.

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

class User(val name: String){
	companion object: JsonFactory<User> { // User 클래스 사용 가능
		override fun fromJSON(jsonText: String): User = ...
	}
}


Uploaded by N2T