코틀린 입문 스터디 (14) Inline functions

mook2_y2
17 min readMar 10, 2019

--

스터디파이 코틀린 입문 스터디 (https://studypie.co/ko/course/kotlin_beginner) 관련 자료입니다.

코틀린 입문반은 Kotlin을 직접 개발한 개발자가 진행하는 Coursera 강좌인 “Kotlin for Java Developers” (https://www.coursera.org/learn/kotlin-for-java-developers) 를 기반으로 진행되며 아래는 본 강좌 요약 및 관련 추가 자료 정리입니다.

목차

(1) Introduction

(2) From Java to Kotlin

(3) Basics

(4) Control Structures

(5) Extensions

(6) 실습 : Mastermind game

(7) Nullability

(8) Functional Programming

(9) 실습 : Mastermind in a functional style, Nice String, Taxi Park

(10) Properties

(11) Object-oriented Programming

(12) Conventions

(13) 실습 : Rationals, Board

(14) Inline functions

(15) Sequences

(16) Lambda with Receiver

(17) Types

(18) 실습 : Game 2048 & Game of Fifteen

1. Library functions looking like built-in constructs

  • 인라인 함수 (Inline function)를 다루기에 앞서, 내장된 언어 구성요소처럼 사용할 수 있는 라이브러리 함수 (run, let, takeif, takeUnless, repeat)들을 살펴봅니다.

run 함수

val foo = run {
println("Calculating foo...")
"foo"
}
  • 인자로 받는 코드 블록 (lambda)을 수행시키고, 마지막 expression에서 반환하는 값을 결과값으로 반환합니다.

let 함수

  • 기존 safe access(?)만 사용하는 방식은 멤버 프로퍼티/함수에 접근하기 위해 역참조되는 변수에 대한 null 체크 때만 사용할 수 있었습니다. (ex:str?.length, str?.get(0) )
  • 한편 ?.let { ... } 형태의 사용 방식은 함수 인자 (argument)로 사용되는 변수에 대한 null 체크 때에도 사용할 수 있습니다. (ex: str?.let { function(it) } )
// 명시적으로 null 체크하고 함수 인자로 사용하는 방식
val str = getStr()
if (str != null){
function1(str)
function2(str)
}
// ?.let{ ... }을 통해 null 체크하고 함수 인자로 사용하는 방식
getStr()?.let{
function1(it)
function2(it)
}
  • 이를 통해 위와 같이 명시적 null 체크 (ex: if(str != null) function(str))를 대체하고, 여러 함수의 인자로 쓰이는 값을 굳이 변수 (val str)에 할당하지 않고 it 으로 처리하여 코드 간결성을 개선할 수 있습니다.
interface User { val nickname: String }
interface Session { val user: User }
class FacebookUser(val accountId: Int) : User {
override val nickname = getFacebookName(accountId)
fun getFacebookName(accountId: Int):String = TODO()
}
class SubscribingUser(val email: String) : User {
override val nickname: String
get() = email.substringBefore('@')
}
// 1. as?를 통한 타입 체크
// 멤버 접근시에만 사용 가능
(session.user as? FacebookUser)?.accountId
// 2. 명시적 타입 체크 (is)
// 인터페이스 프로퍼티의 경우 open or custom getter 이슈
// 때문에 smart cast가 되지 않아서 3번 처럼 사용해야함.

if(session.user is FacebookUser){ println(session.user.accountId) }
// 3. 인터페이스 프로퍼티 타입 체크를 위한 변수 할당
val user = session.user
if(user is FacebookUser) { println(user.accounId) }
// 4. let을 사용한 변수 할당 없는 인터페이스 프로퍼티 타입 체크
(session.user as? FacebookUser)?.let{ println(it.accountId) }
  • 타입 체크의 경우 safe cast(as?)만 사용하는 방식은 역시 멤버 프로퍼티/함수에 접근하기 위해 역참조 되는 변수에 대한 safe cast시에만 사용할 수 있었습니다. (위 코드 블록 1. 참고) 한편(<변수> as? <클래스>)?let { ... } 형태의 사용 방식은 함수 인자로 사용되는 변수에 대한 safe cast시에도 사용할 수 있습니다. (코드 블록 4. 참고) 이는 명시적 타입 체크를 대체하며 코드 간결성 외에도 인터페이스 프로퍼티에 대해 smart cast가 안되는 이슈를 해결할 수 있습니다. (코드 블록 2. 3. 및 Week3 자료의 More about properties 파트 참고)

takeif 함수 / takeUnless 함수

  • takeif 함수는 lambda로 정의된 predicate를 만족하면 receiver object를 그대로 반환하고, 만족하지 않을 경우 null을 반환합니다. takeUnless 함수는 반대로 동작하여 lambda로 정의된 predicate를 만족하지 않을 경우 receiver object를 그대로 반환하고, 만족할 경우 null을 반환합니다. (predicate는 Week2 자료 참고)
class Content(val title:String, val readCount:Int, val isPromoted:Boolean)
lateinit var listOfContents: List<Content>
lateinit var searchKeyword: String
fun showPromotionPopup():Unit = TODO()
// 검색어에 맞는 결과를 조회순으로 정렬한 후
// 가장 조회수가 많은 콘텐츠가 프로모션 콘텐츠일 경우 팝업을 노출시키는 로직
listOfContents.filter {
it.title == searchKeyword
}.sortedByDescending {
it.readCount
}.first().takeIf {
it.isPromoted
}?
.let{
showPromotionPopup()
}
  • 위 코드와 같이 collection에 대한 chain of calls 후에 나온 결과값이 특정한 조건을 만족하는 경우에만 추후 작업을 수행하고자 할 때 takeIf/takeUnless? 를 사용할 수 있습니다. Predicate 조건을 통과하지 못할 경우 null이 반환되고 이에 대한 safe access로 인해 추후 작업이 수행되지 않습니다.

repeat 함수

repeat(10) {
println("Welcome!")
}
for (i in 0 until 10){
println("Welcome!")
}
  • 인자로 받는 코드 블록 (lambda)을 n회 만큼 반복하여 수행합니다. for loop를 대체하여 코드 간결성을 개선할 수 있습니다.

정리

  • run, let, takeif, takeUnless, repeat과 같은 라이브러리 함수들은 위에서 다룬 것 처럼 특정한 상황에서 유용하게 사용될 수 있습니다. 또한 앞서 2주차에서 배운 Groovy 프로그래밍 언어에서 차용한 구문적 관습 (ex: run({ ... }) -> run() { ... } -> run { ... })과 함께 사용하므로써, 마치 iffor과 같은 Kotlin의 내장된 언어 구성요소처럼 사용될 수 있습니다.
inline fun <R> run(block: () -> R): R = block()
inline fun <T, R> T.let(block: (T) -> R): R = block(this)
inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? = if (predicate(this)) this else null
inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? = if (!predicate(this)) this else null
inline fun repeat(times: Int, action: (Int) -> Unit) {
for (index in 0 until times){ action(index) }
}
  • 하지만 사실 이 함수들은 위와 같이 inline 키워드를 통해 인라인 함수로 정의된 라이브러리 함수들입니다. 그리고 인라인 함수로 정의되었기 때문에 퍼포먼스 오버헤드 없이 사용할 수 있습니다.

2. The power of inline

  • Inline이 작동하는 방식과, 이 방식이 non-inline 방식과 비교해 어떻게 다른지에 대해 배웁니다.

Regular non-inlined lambda

  • inline 키워드없이 정의된 일반적인 함수들은 lambda를 인자로 받고 이 lambda가 어떠한 변수라도 포함할 경우, lambda에 대응되는 익명 클래스 객체 (anonymous class object)를 생성하며 이는 퍼포먼스 오버헤드를 발생시킵니다.
fun runNonInline(f: () -> Unit) = f()val name = "Kotlin"runNonInline { println("Hi, $name!")}
println("Hi, $name!")
  • 즉 non-inline 형태로 run 함수를 정의한 경우, 그냥 코드 블록을 수행시키는 것에 비해, run함수를 통해 코드 블록 (lambda)을 수행시키는 것은 익명 클래스 생성에 따른 부가 비용으로 인해 퍼포먼스 측면에서 덜 효율적입니다.
  • 라이브러리 함수들이 non-inline 형태로 정의 되었다면, 앞서 배운 run, let, takeif, takeUnless, repeat 함수들이 유용하기는 하지만 퍼포먼스가 중요한 상황에서는 사용해서는 안된다는 스타일 가이드가 필요했을 것입니다. 하지만 inline 형태로 정의하므로써 이 문제를 해결할 수 있습니다.

Inline functions

  • 어떤 함수를 inline 으로 선언하는 경우 그 함수의 본문이 inline 됩니다. 이 말의 뜻은 해당 함수를 호출하는 코드를 컴파일 할 때, 해당 함수를 호출하는 바이트 코드로 컴파일하는 것이 아니라 해당 함수 본문에 대한 바이트 코드로 컴파일한다는 뜻입니다. (Compiler substitutes a body of the function instead of calling it)
inline fun <R> run(block: () -> R): R = block()val name = "Kotlin"// 아래 2줄은 바이트코드 레벨에서 동일하게 동작
run { println("Hi, $name!")}
println("Hi, $name!")
  • run 함수를 inline 형태로 정의한 경우, 그냥 코드 블록을 수행시키는 것과, run함수를 통해 코드 블록 (lambda)을 수행시키는 것은 바이트코드 레벨에서 동일하게 동작합니다.
inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? = if (!predicate(this)) this else nullval number = 42// 아래 2줄은 바이트코드 레벨에서 동일하게 동작
number.takeUnless { it > 10 }
if (!(number>10)) number else null
  • takeUnless 함수를 inline 형태로 정의한 경우, takeUnless 함수를 사용하는 것과 명시적으로 if / else expression을 통해 코드를 작성한 것은 바이트코드 레벨에서 동일하게 동작합니다. takeUnless 함수가 인자로 받는 predicate 역할을 하는 lambda가 컴파일시에 if 괄호 내부 코드로 대치되어 컴파일됩니다.
inline fun <T> synchronized(lock: Lock, action: () -> T): T {
lock.lock()
try {
return action()
} finally {
lock.unlock()
}
}
inline fun <T> Lock.withLock(action: () -> T): T {
lock()
try {
return action()
} finally {
unlock()
}
}
lateinit var lock: Lock// 1. 명시적으로 작성한 코드 블록
lock.lock()
try{
println("Action")
} finally {
lock.unlock()
}
// 2. synchronized 인라인 함수 사용
synchronized(lock){ println("Action") }
// 3. withLock 인라인 함수 사용
lock.withLock { println("Action") }
  • synchronized 의 경우 Java에서는 내장된 언어 구성요소로 제공되지만, Kotlin은 이를 라이브러리 함수 형태로 구현할 수 있으면서도 퍼포먼스 오버헤드 없이 사용할 수 있습니다. 한편 Kotlin에서는 withLock 함수를 통해 lock에 대한 확장함수 형태로도 synchronized 기능을 퍼포먼스 오버헤드 없이 사용할 수 있습니다. (위 코드 예시에서 1, 2, 3은 바이트코드 레벨에서 동일하게 동작합니다.)
// 1. try-with-resource에 대한 Java 스타일 코드
try{
val br = BufferedReader(FileReader(path))
return br.readLine()
} catch (e: IOException){
throw e
}
// 2. use 인라인함수 사용
BufferedReader(FileReader(path)).use { br ->
return br.readLine()
}
  • Java에서는 resource를 다룰 때 예외 발생에 대해 대응하기 위해 위 코드 예시의 1번과 같은 패턴으로 작성이 필요합니다. 이러한 경우에 대한 내장된 언어 구성요소가 없기 때문에 이러한 상황에 대해 일일이 코드를 작성해줘야 합니다.
  • 하지만 Kotlin은 이러한 동작을 하는 코드 블록을 use 라는 인라인 형태의 라이브러리 함수로 제공하여 퍼포먼스 오버헤드 없이 간결하게 작성할 수 있습니다.

Inline functions의 Java 상호운용성

  • 컴파일 과정에서 인라이닝은 Kotlin 컴파일러가 하는 것이기 때문에, Java 코드에서 Kotlin 의 인라인 함수를 사용할 경우 사용은 가능하지만 컴파일시 인라이닝 처리는 동작하지 않습니다.
  • 이에 따라 대부분의 인라인 라이브러리 함수는 @InlineOnly 어노테이션이 포함되어 있는데 이는 오직 인라인으로만 사용할 수 있다는 뜻으로, Java 코드에서 해당 인라인 함수에 접근할 수 없도록 (=Kotlin에서만 해당 함수를 사용할 수 있도록) 가시성을 제어하는 어노테이션입니다.
// Standard.kt에 정의된 run 라이브러리 함수  (@InlineOnly 있음)
@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
// Collections.kt에 정의된 filter 라이브러리 함수 (@InlineOnly 없음)
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}
  • 한편, 일부 라이브러리 함수 (ex: filter)의 경우 인라인 함수지만 @InlineOnly 어노테이션이 없는 경우도 존재합니다. 이 함수들은 Java 코드에서도 사용할 수 있습니다. 단, 컴파일시 인라이닝 처리는 되지 않습니다.

3. Inlining of the ‘filter’ function 실습 코드 작성시 고려할 점

  • 본 실습의 filterNonZeroGenerated(list: List<Int>) 함수는 filter predicate가 이미 결정되어 있어 (0이 아닌 경우) 인자로 lambda를 받지 않습니다. 이 함수에 대해 inline 키워드를 붙일 경우 오류는 뜨지 않지만 “Inlining works best for functions with parameters of functional types” 라는 메시지가 뜹니다.
  • 이는 앞서 배운 것 처럼 인라인 함수 (컴파일시에 함수를 호출하는 바이트 코드가 아니라 함수 본문 코드로 대체)가 퍼포먼스적인 이점을 주는 것은 lambda를 인자로 받는 경우일 때이기 때문입니다.

4. Inline or not inline?..

  • 코드 퍼포먼스 최적화를 위해 모든 함수에 inline 을 사용하면 좋을 것 같지만, 사실 대부분의 경우 inline 이 불필요하며 주의해서 사용해야 합니다.
  • 일반적인 non-inline 함수 호출 방식은 JVM이 바이트코드를 Just-in-time 컴파일하는 과정에서 최적화가 지원됩니다. 그에 비해 inline 함수는 바이트코드에 함수 본문이 그대로 복사되기 때문에 JVM 최적화가 불가능하며 코드 중복이 발생합니다.
  • 또한, 함수 본문의 코드 크기가 큰 경우에 inline 함수를 사용하면 많은 양의 코드에 해당하는 바이트코드가 각 함수 호출 지점에 그대로 복사되므로 바이트코드가 전체적으로 아주 커질 수 있는 문제가 있습니다.
  • 단, 위에서 언급한 것처럼 Lambda를 인자로 받는 경우에는 익명 클래스 객체가 생성되는 명백한 퍼포먼스 오버헤드가 있으므로 inline 함수를 사용하는 것이 이점을 줄 수 있습니다. (현재의 JVM은 함수 호출과 람다를 인라이닝해줄 정도로 똑똑하지는 못하기 때문)
  • 이에 따라, Lambda (ex: run의 경우 code block, filter의 경우 predicate, map의 경우 transform 등)를 인자로 받는 코드 사이즈가 작은 로직 중 높은 빈도로 재사용되는 경우 (ex: run, let, takeif, takeUnless, repeat, filter, map 등)만 inline 함수로 정의하는게 퍼포먼스 측면에서 유용하며, 그외의 경우는 대부분 그냥 일반 함수로 정의하여 재사용하는 것이 낫습니다.
// Collection.kt// 인자 없는 경우에 대한 first() 라이브러리 함수 (inline 아님)
public fun <T> List<T>.first(): T {
if (isEmpty())
throw NoSuchElementException("List is empty.")
return this[0]
}
// 인자 있는 경우에 대해 오버로딩된 first() 라이브러리 함수 (inline 으로 정의됨)
public inline fun <T> Iterable<T>.first(predicate: (T) -> Boolean): T {
for (element in this) if (predicate(element)) return element
throw NoSuchElementException("Collection contains no element matching the predicate.")
}
  • 실제로 위 Kotlin Collection 라이브러리 함수들을 보면 lambda를 인자로 받고 코드 크기가 작은 로직 (ex : predicate를 포함하는 first())은 inline 함수로 정의되어있지만, 그렇지 않은 로직 (ex : predicate를 포함하지 않는first())은 일반 함수로 정의되어 있습니다. 즉, Kotlin 라이브러리 함수들이 모두 inline 함수인 것은 아니며, 빈번히 재사용되는 코드 블록이라고 무조건 inline 함수로 정의해야하는 것은 아닙니다.

--

--