코드스피츠 90-코틀린 언어 4회차

이누의 벨로그·2022년 6월 28일
0

90-4 코틀린언어 4회차

data 클래스는 값객체로 메모리 주소값을 통해 부여하는 해쉬코드값으로 비교하지 않고 ==(equals) 를 속성의 값에대한 비교로 바꿔준다.

값 객체의 선언은 두 가지의 의미를 가진다.

  1. 모든 속성이 불변이며
  2. 복사본을 할당해야 한다.

특히 2번은 값과 값 객체가 다른 부분으로 주의해야 한다. 값 객체는 값과 같은 의미를 가지므로 복사본을 할당하는 것이 의미상으로 타당하지만, 이를 신경쓰지 않고 그냥 할당하면 원본이 할당되게 된다. 따라 값 객체를 무언가에게 할당할 때는 항상 의도적으로 유의하여 복사본을 프로그래머가 직접 할당해야 한다.

kotlin의 Iterator는 상속을 통해 구현하는 다른 언어와는 달리 연산자 operator를 정의함으로써 구
현한다.


data clas Infinity<T>(
	private val value:T,
	private val NextValue:(T)->T
){
	class Iter<T>(private val item:Infinity<T>):Iterator<T>{
		
	}
	operator fun iterator() = Iter(this)
}

이때 이 iterator 메서드의 반환형은 반드시 Iterator<> 이다. Iterator<>를 구현하기 위해 중첩 클래스로 구현체를 만들어 제네릭하게 Infinity의 value 프로퍼티를 제네릭 T형으로 가지도록 했다.

다만 코틀린에서도 Iterator는 마찬가지로 시스템 객체로써, 연산자로 구현할 수 없으며 시스템에서 지정한 인터페이스를 구현해야 한다.(자바와의 상호호환 때문이라고 추측된다.) 바로 next와 hasNext이다.

우리가 구현할 Infinity 객체는 무한한 이터레이션이 가능하므로 hasNext()는 언제나 true가 될 것이다. next()는 Iterator의 제네릭인 T형을 반환하게 된다.

class Iter<T>(private val item:Infinity<T>):Iterator<T>{
	override fun hasNext()=true
	override fun next():T{
		val result by item
		
	}
}

val result 프로퍼티처럼 by 구문을 사용할 때 by 의 옆에 오는 item속성 델리게이터 라고 한다. 속성 델리게이터가 어떻게 작동하는지 알기 위해서는 프로퍼티를 알아야 한다. 객체 프로퍼티는 하나의 클래스를 상속받으며 getValue 연산자를 구현한다.

class Property{
	operator fun getValue(ref:Any?, prop:KProperty<*>) = 10
}
class Test{
	private val _prop = Property()
	val prop:Int get()=_prop.getValue(this, this::prop)
}

getValue는 객체의 this 참조와, 리플렉션상의 속성명을 인자로 받아 어떠한 값을 리턴해주는 getter 함수이다. prop은 게터가 _prop 이라는 Property 객체의 getValue를 호출하므로, Test().prop 은 언제나 10을 반환한다.

by 다음 getValue 오퍼레이터 메서드를 구현한 객체가 오게 되면, 위의 코드가 자동 생성된다. 즉, 위의 코드는 다음과 같다.

class Test{
	val prop by _prop
}

by로 위임할 객체는 getValue오퍼레이터를 구현한 객체여야 한다. getValue가 this ref와 KProperty를 인자로 받음으로써 this ref와 해당 프로퍼티에 따른 무수히 다른 구현이 가능해진다. 참고로 var 로 선언한 프로퍼티의 경우에는 속성 델리게이터가 setValue 오퍼레이터까지 구현해야 한다.


data clas Infinity<T>(
	private val value:T,
	private val NextValue:(T)->T
){
	class Iter<T>(private val item:Infinity<T>):Iterator<T>{
		override fun hasNext()=true
		override fun next():T{
			val result by item
			item = item.next()
			return result
		}
	}
	operator fun iterator()= Iter(this)
	operator fun getValue(ref:Any?, prop:KProperty<*>) = value
	fun next() = Infinity(nextValue(value), nextValue)
}

따라서 Infinity 값 객체는 하나의 iterator 이며 동시에 하나의 델리게이터 가 된다. 또한 스스로가 Iterator의 하나의 반환값이 되며, 이 때 불변객체를 매번 새롭게 생성한다. data class , 또는 값 객체 라면, 위의 코드처럼 객체의 프로퍼티는 항상 불변인 val 이다. 반면, Iter 클래스의 item 프로퍼티는 가변이 되는데, 바로 이 지점이 불변이 아닌 갱신 지점이 된다. Infinity 이터레이터의 전진은 다음과 같다.

val a  = Infinity(0){it+1}
var limit = 20
for (i in a){
	if(limit-->0)println("$i") else break
}

즉 limit과 같은 상태를 기반으로 전진하므로, 이러한 상태를 담는 홀더 의 역할을 어느 부분은 해야하여, 그것이 바로 가변 var item 이 된다. 이러한 격리를 통해 불변 data class 를 보호할 수 있게 된다. 이터레이터 객체인 Iter 클래스는 외부에 노출되지 않으므로 상태를 가지는 홀더 역할을 하기에 아주 적합한 객체가 된다. 보호되지 않고 외부에 노출되는 부분은 불변 객체로 대신하고, 보호할 수 있는 부분은 상태홀더를 삼음으로써 상태를 외부로부터 보호할 수 있다.

위 코드에서 마음에 안드는 점이 무엇이 있을까? 바로 limit이다. Infinity 이터레이터는 진행을 위해 limit를 필요로 하고 있는데, 이를 사용하는 쪽인 호스트 코드 쪽에서 이를 준비하고 있다. 따라서 이는 사용자에 따라 서로 다르게 구현될 가능성이 있으므로 이를 통일하기 위해서는, 호스트 코드로 부터 서비스 코드로 이를 밀어내야 한다.

따라서 다음과 같이 limit 라는 요소를 인자로 옮길 수 있다.

val a = Infinity(0,20){it+1}
for (i in a){
	println("$i")
}
data class Infininty<T>(
	private val value:T, 
	private val limit:Int = -1,
	private val nextValue:(T)->T){...}

기본값을 주었지만, 코틀린의 trailing lambda 특성 때문에 기본값을 생략하고 람다를 인자로 호출할 수 있다.

이제 Iter 클래스는 다음과 같이 변경된다.

class Itet<T>(private var item:Infinity<T>):Iterator<T>{
	override fun hasNext() = item.limit!=0
	override fun next():T{
		val result by item
		item = item.next()
		return result
	}
}

여기서 중요하게 생각해봐야할 점은, 호스트 코드와 서비스 코드는 두 개의 서로 다른 코드이며 호스트 코드에서 반복되는 사용 예시가 있다면 서비스 코드로 옮기는 것을 생각해봐야 하며, 그 이유는 서비스 코드는 한 번의 디버깅으로 사용자(프로그래머)의 사용 형태를 규정할 수 있기 때문이다. 다만, 서비스 코드의 복잡성이 증가하고 인자의 개수가 늘어날수록, 서비스 코드의 사용 범위가 축소되고 범용성이 감소한다. 즉, 보다 구체적인 경우에 한정된 코드가 된다. 설계/디자인은 호스트 코드와 서비스 코드의 균형을 맞추는 것이기도 하다.

지금까지 배운 operator 에 대해서 다시 정리해보자면, 다른 언어에서는 언어기능과 상호작용하기 위해서 특수한 시스템 라이브러리를 필요로 한다면, 코틀린은 연산자를 정의함으로써 타입계층에 영향을 주지 않고도 상호작용할 수 있다는 특징이 있다.

JSON Parser

Polymorphism parser 다형성 파서를 제외한 일반적인 경우에서 JSON 파서는 단방향으로 작동한다. 즉, 처음부터 마지막까지 전진하며 원하는 값(토큰)을 얻어내는 스캐너로써 작동하며, 이 때 어휘분석기 lexical analysis 라는 용어를 사용한다. lexical 은 직역하면 어휘이지만 실제로는 단어 에 가끼운 의미를 가지므로, 각각의 단어를 분석한다는 의미라고 생각하면 보다 쉽다. 이 때, 우리는 단어의 종류가 무엇이고 값이 무엇이냐 하는 여러가지 관심사를 가지게 되며 lexical 이라는 의미는 이러한 관심사를 포함한다.

따라서 이를 줄여서 일반적으로 Lexer 라는 단어로 부르며, Lexer 와 협력하여 원하는 토큰을 문법에 맞게 추출하여 유의미한 구조체를 생성해내는 것을 Parser 라고 한다.이러한 파서 는 범용문법정의에 의한 파싱, 혹은 전용 구조를 통한 파싱의 2가지 종류로 나뉘어진다. 이번 강의에서는 이러한 2가지 파서 중 전용 구조를 통한 파싱을 수행하는 파서를 만들어보려 한다.

우선 앞서 알아본 바와 같이, 프로그래머가 작성하는 서비스 코드와 이를 실제로 사용하는 호스트 코드 중에서, 호스트 코드 를 먼저 작성해보자.

fun <T:Any> parseJson(target:T, json:String):T?{}	

호스트 코드인 parseJson은 다음과 같은 시그니처를 가진다. 즉, 어떠한 제네릭 T형의 객체 target와 json 문자열을 인자로 받아, json을 파싱한 결과를 target에게 전달한다. 이 때 Target는 non-nullable 이여야 하므로 Any? 가 아닌 Any를 upper bound로 가진다. 따라서 이 경우 반환형 T의 null은 실패했을 경우의 신호로 사용된다. null 안정성이 확보된 코틀린에서는 null은 실패의 경우를 표시하기 위해 종종 사용되며, 다른 언어에서는 이러한 경우와 타입 자체의 null을 구분할 수 없지만 코틀린은 가능하다.

앞서 말했듯이 파서는 렉서 와 상호작용하여 렉서가 분석한 단어로부터 토큰을 도출하므로, Lexer 클래스를 만들어볼 것이다.

class Lexer(val json:String){
	val last = json.lastIndex
	var cursor = 0
		private set
	val curr get()=json[cursor]
	fun next(){
		if(cursor<last) ++cursor
	}
	fun skipWhite(){
		while("\t\n\r".indexOf(curr)!=-1 && cursor<last) next()
	}
}

긴 문자열에서 단어를 분석하는 데에는 문자열의 크기를 점점 줄여가면서 분석하는 전략과, 커서를 가지고 문자열을 보존하면서 커서만 이동시키는 전략이 있다. 문자열의 크기를 줄일 경우 되돌아가는 것이 불가능하므로, 커서를 사용해보도록 하자. 커서는 0부터 시작해서 지정한 위치까지 전진하면서, 해당 위치까지의 문자열을 얻게 한다.

우리는 커서를 내부에서만 사용되도록 가시성을 private으로 제한하고, curr 프로퍼티를 통해 해당위치의 문자를 얻게 했다. 가시성을 고려할 때, 불변값(val 프로퍼티)에 대해서는 엄밀하게 가시성을 적용하지 않아도 해당 프로퍼티의 불변성이 보호되므로 괜찮다.

fun <T:Any> parseJson(target:T, json:String):T?{
	val lexer = JsonLexer(json)
	lexer.skipWhite()
	return parseObject(lexer,target)
}	

그 다음, 공백 문자 행을 제거하는 skipWhite를 호출한다. skipWhite는 현재 커서가 공백문자가 아닐 때까지 전진한다. 마지막으로 타겟 객체에 따라 lexer와 협력하여 parseObject를 호출하여 나머지 작업을 넘겨주면 된다. 객체지향에서는 객체지향원칙에 따라 자신은 자신이 할 일만을 부분적으로 한 뒤 나머지는 다른 객체에 떠넘기는 것이 중요하며, 이 때 떠넘기기 위해 아무리 많은 함수호출을 하더라도 코틀린은 호출 부하를 처리할 수 있는 여러 방법이 존재하기 때문에 부담없이 호출할 수 있다.

fun <T:Any> T.fromJson(json:String):T?=	parseJson(this,json)

다음과 같이 확장함수로도 만들 수 있다. 전역에 함수를 만드는것에 비해 확장함수의 장점은 무엇일까? 바로 함수의 존재를 전역에서 프로그래머가 외워서 찾아내는 대신, 객체의 참조를 통해 IDE로 부터 힌트를 얻을 수 있다는 것이다.

parseObject라는 이름에 맞게 객체에 대해 렉서와 협력한 파싱을 수행할 것이다. 성공할 경우 파싱한 내용을 전달한 객체를 그대로 리턴하며, 실패할 경우 null을 리턴하게 된다. 객체를 파싱하기 위해 처음으로 수행할 작업은 당연히 객체 리터럴 { 의 여부를 확인하는 것이 될 것이다.

fun <T:Any>parseObject(lexer:JsonLexer,target:T):T?{
	if(!lexer.isOpenObject())return null
	...
	return target
}
class JsonLexer(val json:String){
	...
	var cursor=0
		private set
	val curr get()=json[cursor]
	fun isOpenObject():Boolean = '{'==curr
	...
}

현재 시작점이 객체리터럴이 아니라면 타겟은 객체가 아닐 것이다. 객체 리터럴을 확인했다면 커서를 한칸 전진시켜주면 드디어 객체의 키가 나온다. 우리가 파싱할 객체의 키는 반드시 우리가 전달할 target 객체의 속성으로 이미 존재하는 키여야 한다. 그렇지 않다면, 이 키를 객체와 어떻게 매칭할지 알 수 없기 때문이다. 그렇기 때문에 우리는 이미 파싱을 시작하기 전에 파싱을 통해 나올 수 있는 키들을 전부 리플렉션으로 알 수 있다는 얘기가 되며, 속성들 중에서도 값을 수정할 수 있는 var 프로퍼티, 즉 세터가 있는 <KMutableProperty<>> 의 키만을 변경할 수 있다.

fun <T:Any>parseObject(lexer:JsonLexer,target:T):T?{
	if(!lexer.isOpenObject())return null
	lexer.next()
	val props = target::class.members
		.filterIsInstance<KMutableProperty<*>>()
		.associate{
			(it.findAnnotation<Name>()?.name ?:it.name) to it
		}
	return target
}
	

이 때 이러한 var 프로퍼티들은 Name 어노테이션으로 부여된 이름/ 혹은 프로퍼티 명과 속성값이 Pair 로 맵핑된 props 맵에 속하게 된다. 즉 우리는 파싱한 결과가 맵에 담긴 키에 없다면 바로 null을 리턴하여 파싱이 실패했다고 알릴 것이다. associate 함수는 람다를 받아 원하는 형태의 map 을 생성해준다. 동일한 작업을 다른 내장함수를 사용하여 수행할 수도 있다.

val props = target::class.members
	.filterIsInstance<KMutableProperty<*>>()
	.map{
		(it.findAnnotation<Name>()?.name ?:it.name) to it
	}.toMap()

toMap 함수는 Pair 타입의 리스트를 맵으로 변환해준다.

객체 리터럴부터 다음 키까지의 공백을 건너뛰고, lexer로 부터 키를 얻는다.

while(!lexer.isCloseObject()){
	lexer.skipWhite()
	val key = lexer.key()?:return null
	...
}

코틀린의 return/throw는 Nothing을 리턴하므로 식 중간에 들어올 수 있다. 키를 얻지 못하면 그대로 함수를 종료하고 null을 리턴한다.

fun key():String?{
	val result = string()?:return null
	skipWhite()
	if(curr!=':')return null
	next()
	skipWhite()
	return result
}
fun string():String?{
	if(curr!='"') return null //커서의 현재지점이 쌍따옴표(문자열 리터럴)이 아니면 실패 
	next()
	var start = cursor
	var isSkip = false
	while(isSkip||curr!='"'){
		isSkip = if(isSkip) false else curr == '\\' //이스케이프 문자일 경우 커서가 문자열리터럴이더라도 건너뜀
		next()
	}
	val result = json.substring(start,cursor) //문자열 리터럴 (") 사이의 문자를 리턴함
	next()
	return result
} 

파서와 렉서는 객체를 serialize하고 deserialize 하는 데에 있어 기본이 된다. 현재는 문자열에 대한 렉서를 구현했지만, 앞에서 부터 순차적으로 해석하는 로직을 적용할 포맷만 변경하면 바이너리 포맷에도 적용가능한 파서를 구현할 수 있다.

while(!lexer.isCloseObject()){
	lexer.skipWhite()
	val key = lexer.key()?:return null
	val prop = props[key]?:return null
	val value = jsonValue(lexer, prop.returnType)?:return null
	prop.setter.call(target,value)
	lexer.skipWhite()
	if(!lexer.isComma())lexer.next()
}	
lexer.next()
return target

이렇게 렉스의 key() 함수를 통해 해당 json 커서의 key를 구했다면, 앞서 구한 props 맵으로부터 해당 속성하는지를 확인하고 없다면 실패한다. 다시 값을 구하는 작업은 jsonValue함수로 넘기며, 이 때 값의 타입에 따라 렉서가 값을 구하는 방식이 달라지기 때문에 렉서와 함께 리플렉션의 returnType을 같이 넘긴다. returnType은 <KType<>> 타입의 속성이다. 그 뒤, 공백과 쉼표처리를 하면 끝이다.

이 코드는 스스로 json 오브젝트만을 파싱하는 최소한의 기능만을 남겨두고 나머지는 모두 lexer 에게 위임하고, 여러가지 리턴타입에 따른 값 토큰을 추출하는 작업 등은 jsonValue 등의 다른 함수에게 떠넘기고 있다. 여기서 떠넘기는 것이 핵심이다.

우리는 보통 서비스 코드에 대한 계획을 세우고 그에 따라 호스트 코드를 작성한다. 이렇게 계획적으로 코드를 작성하는 것에는 장단점이 있지만, 보통은 계획한대로 코드가 작성되지 않기 마련이다. 알맞은 이름이나, 계획한 시그니처대로 함수의 역할이 분배되는 경우는 드물고, 그 때 그 때 발생하는 상황이나 필요에 의해 함수가 만들어지는 것이 대부분이다.

역할이란 곧 변화율에 따라 분리되는 것이며, 여기서 parseObject 함수는 json의 object 를 처리하기 위한 문법 구조만을 가지고 나머지는 전부 떠넘기고 있다. 이렇게 최소한의 책임만을 가지고 나머지는 다 미루는 것 만이 코드의 가독성을 높일 수 있는 방법이다. 코드를 사전에 계획한대로 역할을 전부 기술하게 하는 것은 어려운 일이며, 변화율에 따라 그 때 그 때 최소한의 역할 외에는 전부 나머지 함수들에게 전부 떠넘기는 것이다.

그럼 이제 값을 파싱하는 jsonValue 를 만들어보자.

fun jsonValue(lexer:JsonLexer, type:KType):Any?{
	return when(val cls=type.classifier as? KClass<*> ?:return null){
	String::class-> lexer.string()
	Int::class->lexer.int()
	Long::class->lexer.long()
	Float::class->lexer.float()
	Double::class->lexer.double()
	}
} 

KType의 classifier라는 프로퍼티를 이용하면 리플렉션의 KType으로부터 유사 클래스 를 얻을 수 있다. KType에 따라 KClass를 얻을 수도 있고, 아닐 수도 있기 때문에, safe casting 연산자인 as? 를 사용하여 안전하게 throw하지 않고 캐스팅되게 한다. 엘비스 연산자를 사용하면 클래스를 얻을 수 없는 타입의 경우 바로 리턴하여 실패한다. 이러한 안전한 타입 캐스팅은 자바에서는 불가능한 기능이다. 또한, when 내부에서 변수를 선언할 수 있으며 이 때 변수의 범위는 when의 중괄호 식 내부가 된다는 점에도 유의하자.

타입에 따라 렉서를 다르게 분기하게 된다. string()의 경우 앞서 살펴봤었다. int, long, float, double과 같은 숫자형에 따른 렉서의 구현은 다음과 같다.

class JsonLexer(json:String){
	...
	fun number():String?{
		val start=cursor
		while("-.0123456789".indexOf(curr)!=-1)next()
		return if(start==cursor) null else json.substring(start,cursor)
	}
	fun int() = number().toInt()
	fun double()= number().toDouble()
	fun double()= number().toDouble()
	fun float() = number().toFloat()
}	 

number의 렉서는 다른 렉서와 원리가 동일하다. 숫자를 표현하는 리터럴이 더 이상 나오지 않을 때까지 전진하고, 전진한 만큼의 문자열을 반환한다. 이렇게 얻은 숫자 리터럴은 nullable 하므로 null-safe 하게 형변환한 결과를 각각 리턴하면 된다.

boolean의 경우는 조금 특수하다.

fun boolean():Boolean?{
	return when{
		json.substring(cursor,cursor+4)=="true"->{
			cursor+=4
			true
		}	
		json.substring(cursor,cursotr+5)=="false"->{
			cursor+=5
			false
		}
		else->null
	}
}

우리의 lexer는 실제로 문자열을 줄여나가지 않고 커서만을 이동하기 때문에, 커서를 움직이면서 값을 찾아 비교해본 뒤 값이 일치하면 그 때 커서를 이동시키는 전략을 취할 수 있다.

마지막으로 값의 type 이 리스트일 경우를 알아보자.

fun jsonValue(lexer:JsonLexer, type:KType):Any{
	return when(val cls=type.classifier as? KClass<*>?:return null){
		...
		List::class->parseList(
			lexer, type.arguments[0].type?.classifier as? KClass<*> ?: return null
		)
	}
}

마찬가지로 리스트를 파싱할 책임은 JsonLexer의 parseList 메서드에 떠넘길 것이다. 그런데, 리스트는 제네릭 타입이 존재하고, 이 때 리스트의 제네릭 타입에 따라 값을 파싱하는 jsonValue와 마찬가지로 전부 다르게 파싱해야 한다. 따라서 jsonValue의 인자인 typeList 의 경우에는 제네릭 타입까지 확인해야 하며, 이를 확인하기 위해서 type.arguments[0].type?.classfier as? KClass<*>?: return null 과 같은 방법을 쓴다. 이는 리플렉션 KType의 스펙이며, 타입을 얻은 후 when의 변수블록에서와 마찬가지로 클래스형을 얻는 과정이다.

기본형과 List 타입을 처리했으니 나머지는 모두 parseObject 를 사용하여 객체 타입을 처리할 것이다.

else->parseObject(lexer,cls.createInstance())

parseObject는 lexer와 객체 인스턴스를 필요로 하므로, 클래스로 부터 인자 없는 생성자를 호출하는 createInstance 를 호출하면 끝이다. 다만 이로인해서, 파서에 제약사항이 생기는데 바로 json의 값으로 들어오는 객체들은 인자없는 생성자들을 지원해줘야 한다는 것이다.물론 가장 처음에 파싱한 json을 타겟 객체는 인자가 있어도 문제가 되지 않는다.

리스트를 파싱하는 메서드는 다음과 같다.

fun parseList(lexer:JsonLexer, cls:KClass<*>):List<*>?{
	if(!lexer.isOpenArray())return null
	lexer.next()
	val result = mutalbeListOf<Any>()
	val cls2 = cls.createType()
	while(!lexer.isCloseArray()){
		lexer.skipWhite()
		val v = jsonValue(lexer, cls2) ?:return null
		result+=v
		lexer.skipWhite()
		if(lexer.isComma()) lexer.next()
	}
	lexer.next()
	return result
}
class JsonLexer(val json:String){
	fun isOpenArray():Boolean = '['==curr
	fun isCloseArray():Boolean = ']'==curr
}

우선 현재 커서를 활용해 리스트 리터럴 [ 여부를 확인한 뒤, 맞다면 커서를 한칸 진행한다. 그다음으로는 모든 타입을 담을 수 있는 mutableListOf()를 생성한 뒤 인자로 들어온 클래스로부터 타입을 만드는데, 이 때 사용하는 것이 KClass 리플렉션의 createType 이다. 리스트의 타입은 제네릭하므로 한번 얻은 타입은 다른 모든 경우에 재활용할 수 있다.

리스트가 종료되는 리터럴 ] 이 나오기 전까지, 공백을 전진하면서 리스트 제네릭 타입에 대해 jsonValue 메서드로 실제 원소를 얻는다. 리스트가 지원하는 plusAssign 연산자를 사용해 리스트에 원소를 담고, 원소 사이의 공백과 comma를 전진한다. 대괄호 리터럴의 다음 문자까지 커서를 옮긴 후 원소들을 모은 리스트를 리턴하면 끝이다.

인라인 함수

우리는 지금까지 역할에 맞게 최소한의 작업만을 수행한 후 나머지는 모두 떠넘기는 과정을 통해 Lexer와 Parser를 구성해보았다. 그런데, 이렇게 여러 개의 함수와 책임을 주고받으면서 작업을 수행하게 되면 함수 호출 부하에 대해 생각해보지 않을 수 없다.

인라인 함수는 우리가 다른 함수를 분리하여 역할을 넘기도록 코드를 작성하더라도, 실제로 컴파일 시에는 하나의 코드에 그대로 내용을 복사하여 붙여넣어 준다. 따라서 함수 호출 부하가 없어지고 실제 컴파일 결과에서 성능이 그대로 보장된다. 따라서 성능을 중요시하는 엔진 코드 등의 작성에서 중요하게 사용되는 기능이다. 다만, 인라인 함수는 코드 본문의 크기가 늘어난다는 단점이 있다.

Json Parser 는 프로그램의 가장 말단에서 가장 자주 호출되는 코드로써 기능이 가장 중요한 코드 중 하나이다. 예를 들어, Jackson 등의 파서는 스프링 컨트롤러에서 모든 리퀘스트와 리스폰스를 전부 파싱하기 때문에, 어플리케이션의 모든 api 호출에 대해 전부 호출된다. 초당 수만 회가 호출이 될 수 있는 코드인 것이다.

여기서 한가지 주의할 점은, 인라인에서 inline 함수보다 가시성이 낮은 요소는 inline 함수 내부에 포함될 수 없다는 것이다. 인라인이 붙은 함수는 코드의 가시성을 항상 함수의 가시성 수준에서 그대로 노출한다. 인라인 함수가 public이면 코드가 그대로 public 한 수준에서 본문에 삽입되며, 함수가 private 이면 private 수준에서만 코드가 복사된다.

public 인라인 내부의 코드가 private 가시성을 가진다면 이 코드는 public 수준으로 복사될 수 없으므로 컴파일러가 에러를 낸다. 물론 inline 함수가 private이면 요소는 private일 수 있다. 따라서 다음과 같은 경우, next() 함수는 인라인화 되지 않는다.

val last = json.lastIndex
var cursor = 0
	private set
fun next(){
	if(cursor<last) ++cursor
}

하지만 next()함수를 호출하는 다른 함수는 얼마든지 인라인화할 수 있다. 또한 순환참조를 가지는 함수는 어느정도까지 코드가 인라인화될지 알 수 없기 때문에 인라인화가 불가능하다. IDE 상에서 1차적으로 순환참조가 없을 경우 inline을 붙이도록 허용해주기도 하지만, 이 경우에도 실제 컴파일 시에는 순환참조때문에 오류가 나게 된다.

코틀린에서는 이처럼 원하는 만큼 역할을 나누어서 함수 호출로 분리시키더라도, 함수 호출 부하 없이 인라인화하는 것이 가능하기 때문에 함수를 두려워하지 않는 코드 작성이 가능하다. 함수호출로 인한 스택오버플로우 가능성도 없이 분리한 함수가 제어문으로 대체된다. 콜 부하가 없이도 자유롭게 리팩토링할 수 있다.

profile
inudevlog.com으로 이전해용

0개의 댓글