이펙티브 코틀린 Item 47: 인라인 클래스의 사용을 고려하라

woga·2023년 12월 16일
0

코틀린 공부

목록 보기
48/54
post-thumbnail

인라인으로 만들 수 있는 것은 하나의 값을 보유하는 객체도 만들 수 있다. 코틀린 1.3부터 도입됐는데 해당 객체를 사용하는 위치가 해당 프로퍼티로 교체된다.

이런 inline 클래스는 타입만 맞다면 값을 곧바로 집어 넣는 것도 허용된다. 그리고 inline 클래스의 메서드는 모두 정적 메서드로 만들어진다.

inline class Name(private val value: String) {
	// ...
    
    fun greet() {
    	print("Hello, I am $value")
    }
}

// code
val name: Name = Name("Marchi")
name.greet()

// when compile
val name: String = "Marchi"
Name.'greet-impl'(name)

인라인 클래스는 다른 자료형을 래핑해서 새로운 자료형을 만들 때 많이 사용된다. 이 때 어떠한 오버헤드도 발생하지 않는다.

하여 인라인 클래스는 다음과 같은 상황에서 많이 사용된다.

  • 측정 단위를 표현할 때

  • 타입 오용으로 발생하는 문제를 막을 때

측정 단위를 표현할 때

만약 아래와 같이 파라미터를 받는다면 이때 time은 어떤 단위일까?

interface Timer {
	fun callAfter(time: Int, callback: ()->Unit)
}

따라서 심각한 실수로 여러 문제가 발생할 수 있는 지점이다. 여기서 그럼 어떻게 해결해야하는걸까?
가장 쉬운 방법은 파라미터 이름에 측정단위를 붙여주는 것이다. fun callAfter(timeMillis: Int, callback: ()->Unit)

하지만 함수를 사용할 때 프로퍼티 이름이 표시되지 않아 실수가 가능하다. 또한, 파라미터는 이름을 붙여도 리턴 값은 이름을 붙일 수 없다.
물론 함수에 이름을 붙여서 어떤 단위로 리턴하는지 알려 줄 수 있다. ex) decideAboutTimeMillis
하지만 이또한 함수이름이 더 길어지고 필요없는 정보까지도 전달해 줄 가능성이 있으므로 실제로는 거의 사용되지 않는다.

그래서 인라인 클래스를 활용해서 타입 제한을 거는 것이다.

inline class Minutes(val minutes: Int) {
	fun toMillis(): Millis = Millis(minutes * 60 * 1000)
    //...
}

inline class Millis(val milliseconds: Int) {
	// ...
}

interface User {
	fun decideAboutTime(): Minutes
    fun wakeUp()
}

interface Timer {
	fun callAfter(timeMillis: Millis, callback: ()->Unit)
}

fun setUpUserWakeUpUser(user: User, timer: Timer) {
	val time: Minutes = user.decideAboutTime()
    timer.callAfter(time) { // error: Type mismatch
    	user.wakeUp()
    }
    // good
    timer.callAfter(time.toMillis()) {
    	user.wakeUp()
    }
}

타입 오용으로 발생하는 문제를 막을 때

SQL 데이터베이스는 일반적으로 ID를 사용해서 요소를 식별한다. 그런데 이런 코드는 모든 ID가 Int 자료형이므로 실수로 잘못된 값을 넣을 수 있다.
이런 문제를 미리 막으려면 다음과 같이 Int 자료형의 값을 inline 클래스를 활용해 래핑한다.

inline class StudentId(val studentId: Int)
inline class TeacherId(val teacherId: Int)
inline class SchoolId(val studentId: Int)

class Grades {
	@ColumnInfo(name = "studentId")
    val studentId: StudentId,
    @ColumnInfo(name = "teacherId")
    val teacherId: TeacherId,,
    @ColumnInfo(name = "schoolId")
    val schoolId: SchoolId,
    // ...
}

이렇게 하면 굉장히 안전해지고 컴파일할 때 타입이 Int로 대체되므로 코드를 바꾸어도 별도의 문제가 발생하지 않는다.
이처럼 인라인 클래스를 사용하면 안전을 위해 새로운 타입을 도입해도 추가적인 오버헤드가 발생하지 않는다.

인라인 클래스와 인터페이스

인라인 클래스도 다른 클래스와 마찬가지로 인터페이스를 구현할 수 있다.

interface TimeUnit {
	val millis: Long
}

inline class Minutes(val minutes: Long): TimeUnit {
	override val millis: Long get() = minutes * 60 * 1000
    // ...
}

inline class Millis(val milliseconds: Long): TimeUnit {
	override val millis: Long get() = milliseconds
}

fun setUpTimer(time: TimeUnit) {
	val millis = time.millis
    // ...
}

setUpTimer(Minutes(123))
setUpTimer(Millis(456789))

하지만 위의 예는 클래스를 inline으로 만들었을 때 얻을 수 있는 장점이 하나도 없다. 클래스가 inline으로 동작하지도 않고 인터페이스를 통해서 타입을 나타내려면 객체를 래핑해서 사용해야하기 때문이다.
인터페이스를 구현하는 인라인 클래스는 아무런 의미가 없다.

typealias

typealias를 사용하면 타입에 새로운 이름을 붙여 줄 수 있다.

이런 typealias의 특징은 길고 반복적으로 자주 사용되는 함수 타입을 이름을 붙여서 사용한다

typealias ClickListener = (view: View, event: Event) -> Unit

class View {
	fun addClickListener(listener: ClickListener) {}
    fun removeClickListener(listener: ClickListener) {}
    // ...
}

하지만 typealias는 안전하지 않다. 실수로 혼용해서 잘못 입력하더라도 어떤 오류도 발생하지 않는다.
그래서 오히려 문제가 발생했을 때 문제 찾는 것을 어렵게 만든다

typealias Seconds = Int
typealias Millis = Int

fun getTime(): Millis = 10
fun setUpTimer(time: Seconds) {}

fun main() {
	val secons: Second = 10
    val millis: Millis = seconds // not happen complie error
    
    setUpTimer(getTime())
 }

그래서 위 코드에서는 typealias를 사용하지 않는 것이 오류를 쉽게 찾을 수 있다. 이런 형태로는 사용하면 안된다.
단위 등을 표현하려면 파라미터 이름 또는 클래스를 사용하자. 이름은 비용이 적게 들고 클래스는 안전하다.

정리

인라인 클래스를 사용하면 성능적인 오버헤드 없이 타입을 래핑할 수 있다. 인라인 클래스는 타입 시스템을 통해 실수로 코드를 잘못 작성하는 것을 막아주므로, 코드의 안정성을 향상시켜 준다. 의미가 명확하지 않은 타입, 특히 여러 측정 단위들을 함께 사용하는 경우에는 인라인 클래스를 꼭 활용하자!

profile
와니와니와니와니 당근당근

0개의 댓글