Study/kotlin

[코틀린 프로그래밍] Chapter.07 객체와 클래스

에디개발자 2021. 4. 26. 07:00
반응형

나를 닮았다고 한다...

객체와 싱글톤

코틀린은 싱글톤을 직접 지원함으로써 싱글톤을 구현할 때 발생할 수 있는 부담과 구현이 잘못될 수 있는 리스크를 제거하였다.

 

객체 표현식으로 사용하는 익명 객체

객체가 필요하면 객체를 가져야 한다. 코틀린의 객체 표현식은 Java의 익명 클래스(Anonymous Class)를 생성하는 데 유용하게 쓰인다. 선언 방식으로는 object 키워드 이후에 블록을 작성하여 사용할 수 있다.

fun drawCircle() {
    val circle = object {
        val x = 10
        val y = 20
        val radius = 30
    }
    println("Circle x : ${circle.x} y : ${circle.y} radius : ${circle.radius}")
}

drawCircle()

익명 객체의 내부 타입은 함수나 메소드의 리턴타입, 파라미터가 될 수 없다. 다시 말해서 객체 표현식은 지역변수들을 그룹핑할 때만 유용하게 쓰인다.

 

익명 객체는 인터페이스의 구현체로도 작성이 가능하다. Java의 익명 내부 객체와 동일하다, 익명 내부 객체는 전통적으로 즉석에서 인터페이스를 구현하는 데 사용된다. object 키워드와 블록 사이에 구현하길 원하는 인터페이스의 이름을 쉼표로 구분하여 적는다. 그리고 이 케이스에서는 클론을 작성해야한다. Runnable 예제로 살펴보자.

fun createRunnable(): Runnable {
    var runnable = object : Runnable {
        override fun run() {
            println("runnable run..")
        }
    }
    return runnable
}

val runnable = createRunnable()
runnable.run()

 

싱글 추상 메서드 인터페이스라면 아래와 같이 사용할 수도 있다.

fun createRunnable(): Runnable = Runnable { println("runnable run..") }
createRunnable().run()

 

둘 이상의 인터페이스를 구현해야 한다면 리턴이 필요한 경우에는 반드시 리턴할 인스턴스 타입을 명시해야한다.

fun createRunnable2(): Runnable = object : Runnable, AutoCloseable {
    override fun run() {
        println("run..")
    }

    override fun close() {
        println("close..")
    }
}

 

객체 선언을 이용한 싱글톤

코틀린은 object 키워드와 블록 사이에 이름을 넣으면 표현식이 아닌 명령문 또는 선언으로 인식한다. 익명 이너클래스의 인스턴스를 만들 땐 객체 표현식을 사용하도록 하고 싱글톤을 만들 땐 객체 선언을 사용하자. 코틀린의 대표적인 예는 Unit이다. 

// Kotlin
object Util {
    fun numberOfProcessors() = Runtime.getRuntime().availableProcessors()
}

// Java
public static final class Util {
    public final int numberOfProcessors() {
        return Runtime.getRuntime().availableProcessors()
    }
}

 

위 코드는 동일한 코드이다. 객체 선언을 이용해서 만들 Util 객체가 싱글톤이다. 위 코드에서 Util로는 객체 생성 불가하다. 코틀린 컴파일러는 Util을 클래스로 취급않고 Util은 이미 객체상태이다. Java의 private 생성자와 static 메소드만 가지고 있는 클래스와 동일하고 @JvmStatic 어노테이션을 사용하지 않았다면 해당 메소드는 바이트코드에서 static이 되지 않는다.

 

싱글톤은 메소드만 가질 수 있는게 아니다. val과 var로 선언된 속성 모두를 가질 수 있다. 객체 선언은 객체 표현식처럼 인터페이스를 구현가능하고 이미 존재하는 클래스를 확장할 수 있다.

// 싱글톤으로 val, var, mothod 구현
object Sun : Runnable {
    val radiusInKM = 615453
    var coreTemperatureInC = 1908527
    override fun run() {
        println("spin....")
    }
}

fun moveIt(runnable: Runnable) {
    runnable.run()
}

println(Sun.radiusInKM)
moveIt(Sun)

 

클래스 생성

읽기전용 && 읽기쓰기 속성

class를 선언하고 parameter에 val 속성으로 선언하여 읽기 전용  클래스를 생성할 수 있다.

// read only class
class Car(val yearOfMake: Int)

val car = Car(2019)
println(car.yearOfMake)

 

class 를 선언하고 parameter에 var 속성으로 선언하면 읽고 쓸수 있는 클래스를 생성할 수 있다.

// read && write class
class Car(val yearOfMake: Int, var color: String)

val car = Car(2019, "Red")
car.color = "Green"

 

속성 제어 변경

클래스에서 var 속성의 파라미터를 넘겨 쓸 수 있는 클래스를 생성할 수 있다. 하지만 변경 가능한 속성은 데이터의 일관성을 해친다. 이런  일을 방지 할 수 있도록 속성 제어 변경을 사용한다. 

// car3 클래스의 theColor field의 값을 바꾸기 위해 set, field를 사용
class Car(val yearOfMake: Int, theColor: String) {
    var fuelLevel = 100
    var color = theColor
        // private set(value) {  // private set으로 만들 경우
        set(value) {         // 속성 제어 변경
            if (value.isBlank()) {
                throw RuntimeException("no empty, please")
            }
            field = value
        }
}

코틀린은 백킹 필드를 제외하곤 필드를 만들 수 없습니다. 커스텀 getter와 setter를 가진 필드를 정의하고 랴딩 키워드를 사용한 백킹 필드를 사용하지 않는다면 백킹 필드는 생성되지 않는다. 

 

초기화 코드

생성자의 파라미터로 전달되지 않는 속성들은 클래스 내부에서 정의된다. 객체를 초기화하는 코드가 복잡하다면 생성자용 바디를 만들 필요가 있다. 클래스는 0개 이상의 init 블록을 가질 수 있다. 하지만 되도록이면 만들지 않고 만들어도 1개까지만 만들자.

class Car(val yearOfMake: Int, theColor: String) {
    var fuelLevel = 100
        private set
    var color = theColor
        set(value) {
            if (value.isBlank()) {
                throw RuntimeException("no empty, please")
            }
            field = value
        }

    init {  // 초기화 블록
        if (yearOfMake < 2020) {
            fuelLevel = 90
        }
    }
}

 

보조 생성자

주 생성자를 작성하지 않았다면 코틀린은 아규먼트가 없는 기본 생성자이다. 하지만 만약에 주 생성자가 모든 파라미터를 위한 기본 아규먼트를 가지고 있다면 코틀린은 주 생성자와 함께 아규먼트가 없는 생성자를 생성한다.

 

보조 생성자는 주 생성자를 호출하거나 다른 보조 생성자를 필수적으로 호출해야만 한다. 보조 생성자의 파라미터는 val이나 var을 사용할 수 없다.

class Person(val first: String, val last: String) {
    var fulltime = true
    var location: String = "-"

    constructor(first: String, last: String, fte: Boolean) : this(first, last) {
        fulltime = fte
    }

    constructor(first: String, last: String, loc: String) : this(first, last, false) {
        location = loc
    }

    override fun toString(): String {
        return "person(first='$first', last='$last', fulltime=$fulltime, location='$location')"
    }
}

println(Person("Jame", "Doe"))
println(Person("Jame", "Doe", false))
println(Person("Jame", "Doe", "home")

 

인스턴스 메소드 정의

클래스 안에 메소드를 정의할 때는 fun 키워드를 사용하고 기본적으로 public이다. 또한 private, protected, internal 키워드를 사용하여 메소드의 권한을 설정가능하다.

// Method 접근자 설정
class Person(val first: String, val last: String) {
    internal fun fullName() = "$last $first"
    private fun yearsOfService(): Int = throw RuntimeException("Not implemented yet")
}

val jane = Person("Jane", "Doe")
println(jane.fullName())
//jane.yearsOfService()  // 접근이 불가능하다.

 

인라인 클래스

인라인 클래스를 작성하면 명확성을 띌 수 있다는 장점이 있다. 또한 호출로 인한 오버헤드가 발생하지 않는다. 아래의 예제 코드를 살펴보자!

inline class SSN(val id: String)

fun receiveSSN(ssn: SSN) {
    println("Received $ssn")
}

receiveSSN(SSN("111-111-1111111"))

String 으로 만든 ssn 보다 SSN Inline Class를 활용하여 SSN의 고유한 값으로 만든다. 위 inline 클래스는 컴파일할 때 클래스는 사라진다.

 

컴패니언 객체와 클래스 맴버

속성이나 메소드가 클래스 레벨에서 필요하고, 클래스의 특정 인스턴스와는 관련이 없어야 한다면 클래스 안에 만들 수 없다. 대신 그런 속성이나 메소드를 컴패니언 객체로 만들어서 사용할 수 있다. 앞서 정리한 싱글톤과 연관짓자면 클래스 안에 선언되는 싱글톤인 셈이다. 컴패니언 객체는 인터페이스를 구현할 수도 있고 다른 클래스를 확장할 수도 있다.

 

클래스 레벨 멤버

// 컴패니언 선언
class MachineOperator(val name: String) {
    fun checkin() = checkedIn++
    fun checkout() = checkedIn--

    companion object {
        var checkedIn = 0
        fun minimumBreak() = "15 minutes every 2 hours"
    }
}

// 컴패니언 사용
MachineOperator("Mater").checkin()
println(MachineOperator.minimumBreak())
println(MachineOperator.checkedIn)

companion object 키워드를 이용하여 정의하였다. 컴패니언 객체 안에 있는 속성 checkedIn은 MachineOperator 클래스의 클래스 레벨 속성이 되었다. 비슷한 맥락으로 minimumBreak 메소드 역시 어떤 인스턴스에도 속하지 않는 클래스 레벨 메서드이다.

 

 

컴패니언 접근 방법

// 컴패니언 접근방법
val ref = MachineOperator.Companion

 

하지만 Companion이 아닌 컴패니언 객체에 이름을 만들 수도 있다.

// 컴패니언 명 지정
class MachineOperator(val name: String) {
    fun checkin() = checkedIn++
    fun checkout() = checkedIn--

    companion object MachineOperatorFactory {   // 선언
        var checkedIn = 0
        fun minimumBreak() = "15 minutes every 2 hours"
    }
}

// 사용
val ref = MachineOperator.MachineOperatorFactory

 

여객체를 사용 가능한 상태로 만들기까지 몇 가지 단계를 두고 진행을 해야하는 경우가 있다. 생성자의 작업이 완료되기 전에 객체가 사용가능해지는 상활이 발생할 수 있다.

 

컴패니언을 팩토리로 사용하기 위해서는 class에 private 생성자를 만들어야한다. 컴패니언 객체에서 생성된 인스턴스를 리턴하기 전에 인스턴스를 처리하는 메소드를 하나 이상 생성한다.

class MachineOperator private constructor(val name: String) {
    fun checkin() = checkedIn++
    fun checkout() = checkedIn--

    companion object MachineOperatorFactory {
        var checkedIn = 0
        fun create(name: String): MachineOperator {
            val instance = MachineOperator(name)
            instance.checkin()
            return instance
        }
    }
}

val operator = MachineOperator.create("Meter")
println(MachineOperator.checkedIn)

 

Static과는 다르다

컴패니언 객체를 살펴보면 Java의 static 구현과 비슷하다고 생각하지만 static 메소드라고 생각하면 안된다. 컴패니언 객체의 멤버에 접근하면 코틀린 컴파일러는 싱글톤 객체로 라우팅을 한다. 그런데 Java와의 상호 운용성 측명에서 봤을 때 이런 문제는 야기할 수 있다. static 처럼 사용하고 싶다면 @JvmStatic을 사용하도록 하자.

 

제네릭 클래스 생성

제네릭은 클래스의 멤버를 특정 타입으로만 한정짓지 않을 때 사용된다. 예를 들면 리스트를 선언할 때 List<String>, List<Integer>등으로 여러가지 클래스를 선언하여 사용할 수 있습니다. 이것을 제네릭이라고 합니다.  제네릭 클래스는타입 안정성을 지키면서 일반화를 할 때 사용됩니다.

 

한 가지 예로 코틀린에는 Pair로 두 가지 타입의 다른 객체를 소유하는 클래스가 있습니다. 하지만 PriorityPair라는 2개의 동일한 타입의 객체를 2개 소유하는 객체를 만들 것 입니다. 그리고 이 객체는 큰 객체가 첫번째, 작은 객체가 두 번째에 오도록 정렬할 것입니다. 객체의 순서를 정하기 위해서 Comparable<T> 인터페이스의 compareTo() 메소드를 사용해보도록 하겠습니다.

// 제네릭에 조건이 하나만 있으므로 where절을 사용하지 않고 :만 사용하자!
class PriorityPair<T : Comparable<T>>(member1: T, member2: T) {
    val first: T
    val second: T

    init {
        if (member1 >= member2) {
            first = member1
            second = member2
        } else {
            first = member2
            second = member1
        }
    }

    override fun toString(): String {
        return "$first $second"
    }
}

// check!!
println(Proritypair(2, 1))  // 2, 1
println(Proritypair("A", "B"))  // B, A

 

데이터 클래스

데이터 클래스는 특정한 행동, 동작보다는 데이터를 옮기는 데 특화된 클래스입니다. 데이터 클래스에선 val이나 var가 아닌 파라미터는 사용할 수 없고 필요하다면 블록 안에 속성이나 메소드를 추가할 수 있습니다. 

코틀린은 데이터 클래스에 자동으로 equals(), hashCode(), toString(). copy() 메소드를 만들어줍니다. 

// 선언 방법
data class Task(
    val id: Int, 
    val name: String, 
    val completed: Boolean, 
    val assigned: Boolean
)

data class로 작성하면 생성자에 의해 정의된 각각의 속성에 접근할 수 있는 component로 시작하는 메소드들이 생성됩니다. 

// data class byte code decomplie
public final int component1() {
    return this.id;
}

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

public final boolean component3() {
    return this.completed;
}

public final boolean component4() {
    return this.assigned;
}

 

위에 보이는 component 메소드들을 이용하여 구조분해도 가능합니다.

// 사용
val task = Task(
    1,
    "Create Project",
    false,
    assigned = true
)

// 구조분해를 하지 않는 경우
println(task.assigned)
println(task.id)
println(task.name)
println(task.completed)

// 구조분해를 하는 경우
val (id, _, _, isAssigned) = task

 

데이터 클래스를 사용해야 하는 경우

  • 행동, 동작보다는 데이터 자체에 집중된 모델링일 경우
  • equals(), hashcode(), toString(), copy()가 생성되길 원할 경우
  • 주 생성자에 적어도 하나 이상의 속성이 포함되어야 할 경우
  • 주 생성자를 속성만으로 구성해야 할 경우
  • 구조분해 기능을 이용해서 데이터를 쉽게 추출하고 싶을 경우
본인은 데이터 클래스를 선호하지는 않습니다. 바이트코드를 디컴파일하면 알겠지만 너무 많은 양의 코드가 있다고 생각합니다. 정말 구조분해 등이 필요할 경우가 아니면 사용하는 것을 선호하지 않습니다.

 

정리

- 코틀린은 필드를 생성하고, getter, setter를 해준다.
- 인스턴스 멤버는 클래스 안에 있고, 클래스 멤버는 컴패니언 객체에 들어있다.
- 싱글톤은 코틀린에서 최고의 객체이다.
- 데이터 클래스는 행동 < 데이터 일 때 유용하며 구조분해 기능으로 클래스에서 속성을 유연하게 추출 가능
- 제네릭 클래스는 가변성과 제약조건 기능 지원으로 향상된 타입 안정성 제공

반응형