코틀린 인라인 함수로 HTML 파서를 만들어보자

이누의 벨로그·2022년 5월 12일
0

이 글은 코드스피츠 유튜브 코드스피츠 82 코틀린 JS 3강 내용을 요약한 글입니다.
https://www.youtube.com/watch?v=ttIDj6wvjgI

이번 시간에는 코틀린 함수 생태계의 특성에 대해 알아볼 것이다.

null과 nullable

프로그램의 에러는 null 포인터 에러가 대다수를 차지한다. 모던 프로그래밍 언어는 모나드를 사용하지만 코틀린은 컴파일러 수준에서 null분기를 담당하여 프로그래머가 null을 확인할 책임을 가지도록 설계되었다.

val double:(Int)->Int = {it*2}
val v0:Int = 3
println("${double(v0)}")

아래와 같은 람다가 있다고 할 때, v0에는 null을 할당할 수 없다. 타입이 Int 이므로 null을 허용하지 않기때문이다. 코틀린의 기본타입들은 이처럼 null을 허용하지 않는다.

따라서 null을 할당하고 싶다면 nullable 타입으로 바꿔줘야 한다.

val v1:Int? = null

어떤 타입이 nullable을 하다는 것을 표현하기 위해서는 타입 뒤에 물음표를 붙여주기만 하면된다. null 타입은 존재하지 않는다. 단지 어떤 타입이 nullable 한지를 표현할 수 있을 뿐이다. 이러한 타입선언은 컴파일 타임에 확정되므로 null포인터 에러는 v1 변수에서만 발생할 수 있다.

이제 이 변수를 람다에 넘겨보면 컴파일 에러가 날 것이다.

println("${double(v1)}") //compile error

모든 형의 부모는 Any 이며, 모든 nullable의 부모는 Any?이다. Any의 부모가 Any? 이므로 사실 코틀린의 최상위 부모는 Any? 가 된다. 그렇다면 람다의 인자 타입을 Int? 로 바꾸면 해결될까?

val double:(Int?)->Int = {it*2} 

이렇게 바꾸면 인자로 nullable이 올 수 있게 되었지만 nullable인 it을 연산하는 부분에서 에러가 난다. 따라서 null이 아닌 경우를 분기해주지 않는 이상 컴파일 에러가 난다.

다음과 같이 분기해줘야 한다.

if(v1!=null) println("${double(v1)}")

이렇게 타입가드를 하면 double함수에 nullable을 넘길 수 있게 된다. 그런데 여전히 v1은 nullable 타입인데 이게 가능한 이유는 뭘까? 바로 코틀린 컴파일러가 스마트 캐스트 라는 기능으로 문맥을 인식해서 v1의 타입을 자동으로 Int?가 아닌 Int 로 변경해주기 때문이다. 사람이 인식할 수 있는 타입, 즉 추론 가능한 타입은 코틀린 컴파일러가 자동으로 판단하고 스마트 캐스트 를 해준다. 따라서 코틀린은 최대한 타입 추론은 활용하고 자바와 같이 강제적으로 타입을 캐스팅 하는 구문이 잘 나오지 않는다.

Inline function

내가 함수형태로 무언가를 만들어 함수를 호출하면 콜스택에 지역함수 메모리를 생성하고 실행된 결과를 주고받는, 콜스택 부하가 일어난다. 따라서 함수는 호출할 때마다 비용이 발생하다. 따라서 함수호출이 잦은 경우 비용이 커져서 문 statement 로 바꾸는게 더 나을 때도 있다.

inline 함수란 함수를 호출할 시 컴파일러가 함수를 그대로 문 statement 로 옮겨주는 것을 말한다. 따라서 함수 호출에 비용이 발생하지 않는다. 함수몸체를 그대로 문으로 옮겨주기 때문이다. inline 함수는 함수 호출의 비용이 줄어드는 대신 호출하는 곳의 전체적인 바이너리의 크기가 증가하는 단점이 있다. 결과적으로는 함수호출에 대한 메모리 비용과 연산비용을 교환한 것이다. 코틀린은 함수호출에 대한 비용이 없어지는 인라인 함수를 특성을 매우 적극적으로, 자주 사용한다. 다른 언어들에도 인라인 함수는 존재하지만 코틀린처럼 클래스, 함수에 모두 범용적으로 즐겨 활용하는 경우는 잘 없다.

인라인이 아닌 일반함수를 하나 예시를 들어보자. 인라인 함수를 만들기 위해서는 함수 인자에 람다가 와야 한다. 바로 람다 부분이 문 statement로 치환되기 때문이다.

fun pass(v:Int, block:(Int)->Int) = block(v)
println("${pass(3){it*2}}") //6

이는 자바스크립트로 정확하게 다음과 같이 컴파일된다.

function main$lambda(it){
	return it*2 ||0;
}
function pass(v,block){
	return block(v);
}
println(pass(3, main$lambda).toString()

함수 2개를 만들고, 2개의 함수를 호출해서 결과를 얻는 과정이 동일하게 컴파일 되었다. 그렇다면 인라인을 붙인 함수를 컴파일 하면 어떻게 될까?

inline fun pass(v:Int, block:(Int)->Int) = block(v)

다음과 같이 컴파일 된다.

println((3*2||0).toString());

main$lambda나 pass 함수가 모두 없어지고 , 인자가 그대로 값으로 계산되어서 함수 본체의 내용이 인라인되었다. 함수도 , 람다도 존재하지 않는다. 따라서 inline 함수를 사용하면 호출 부하를 전혀 고려하지 않고 얼마든지 함수를 호출해도 된다. 따라서 다른 언어에서 함수는 문의 덩어리 단위가 어느 범위를 넘기 시작할 때에서야 함수로 이를 분리하지만, 코틀린은 크기를 전혀 고려하지 않고 인라인 함수를 만들어서 마음껏 사용하는 경향이 있다. 다음과 같이 if분기를 함수로 만들 수도 있다.

inline fun ifTrue(v:Boolean, block:()->Unit){if(v) block()}
ifTrue(true){println("true")}
///컴파일
if(true){println("true")}

코틀린 코드와 컴파일 결과가 아예 동일한 것을 알 수 있다. 인라인 함수의 결과가 제어문 코드와 동일해지는 것이다.

다음과 루프를 수행하는 인라인 함수를 만들어보자.

inline fun <T>reverseFor(v:List<T>, block:(T)->Unit){
	var i=v.size
	while(i-- >0) block(v[i])
}
reverseFor(listOf("a","b","c"), ::println)

listOf는 내장함수로써 listOf<String> 과 같이 쓰지만, 코틀린 컴파일러가 타입을 추론해주므로 제네릭이 생략 가능하다. 람다 자리에 보낸 :: 표시는 Function Reference Operator 라는 것으로, 어떤 함수의 시그니처가 람다의 시그니처와 동일하다면 이 함수를 레퍼런스 삼아서 람다 대신에 넣을 수 있는 기능이다. 자바에 존재하는 문법이다.

이를 컴파일한 내용은 다음과 같다.

var v = listOf(['a','b','c']);
var tmp$;
var i = v.size;
while((tmp$=i, i=tmp$-1|0, tmp$)>0){ 
	println(v.get_za3lpa$(i));
}

함수 호출이라 함수에 관한 구문은 아예 사라지고 함수의 몸체가 그대로 문으로 들어왔다.

코틀린에서는 이렇게 추상화된 함수 호출을 호출부하를 전혀 신경쓰지 않고 마음껏 할 수 있다. 따라서 함수에 대한 추상화가 가장 작은 함수조각에도 일어난다.

Extention function

extention, 또는 수신함수라고 불리는 이 기능은 C#에서 가져왔다. 어떤 클래스의 인스턴스와 메소드의 관계를 생각해보면, 메소드는 클래스의 상태(멤버)를 전부 가지고 있어 메소드 본체에서 이를 자유롭게 사용할 수 있고 클래스의 인스턴스는 이러한 메소드들을 전부 안고 태어난다. 과거 C++을 C 매크로를 돌려 C 코드로 변환해 컴파일 하던 시절에는 클래스의 메소드를 모두 클래스 구조체를 인자로 받아들이는 외부함수로 변환했었다. C#은 어떤 클래스의 정적 메소드를 만들어서 첫번째 인자를 객체로 하는 규칙을 지키면 클래스 객체.메소드 처럼 호출할 수 있도록 컴파일러가 메소드를 변환해주었고, 이를 extention 이라고 한다. 즉 어떠한 객체의 메소드를 외부에서 정의할 수 있게 한 것을 말한다. 코틀린은 이러한 extention 을 매우 쉽게 사용할 수 있다. 코틀린 공식문서에서는 이를 어떤 객체를 꾸며주는 추가적인 decorator라고 설명하고 있다. 마치 디자인 패턴에서의 decorator 패턴 과도 같은 역할을 하는 것이다.

extention을 살펴보기 위해서 문자열의 내장 함수인 trim() 함수를 살펴보자. " aaa ".trim() 같은 형태로 사용하는 이 함수의 정의를 찾아가면 다음과 같은 함수가 나온다.

inline fun String.trim(): String = (this as CharSequence).trim().toString()

내가 원하는 타입.trim()이라는, 수신 타입 receiver가 지정된 형태의 함수 이름이 지정되어 있다. 원하는 객체 타입이 먼저 오고 그다음 함수 이름이 나오므로, 우리말로 이를 번역해서 수신 함수 라고 부르기도 한다. 이렇게 수신 타입을 지정해주면 어떤 일이 일어날까? 바로 함수 몸체 내에서 this 가 성립하게 된다. 위 함수에서 this 는 String 인스턴스가 될 것이다. 위 함수는 this를 CharSequence 로 캐스팅 한 뒤 CharSequence 클래스의 trim 내장함수를 호출하고 있다. 마치 클래스 내부에 정의되어 있는 메소드처럼 사용할 수가 있는 것이다. 참고로 this는 생략이 가능하다.

이를 컴파일해보면 앞서 말한 extention의 특성이 그대로 드러난다.

var trim  = Kotlin.kotlin.text.trim;
var tmp;
trim(Kotlin.isCharSequence(tmp='   aaa   ') ? tmp: throwCCE()).toString()

trim 함수가 인자를 받지 않았지만 이를 컴파일 해보면 첫번째 인자를 수신 타입으로 받는 함수로 정의되어 있다. 앞서 언급한 extention 함수의 모습과 동일한 모습을 하고 있다.

수신함수를 활용해 코틀린에 존재하지 않는 pop 함수를 만들어보자.

	inline fun <T> MutableList<T>.pop() = if(isEmpty()) null else removeAt(lastIndex)

MutableList에 대한 수신함수로 pop을 정의하여, MutableList 인스턴스의 메서드와 멤버인 isEmptylastIndex 를 사용하고 있다. this.isEmpty()this.lastIndex가 생략되어 있다.

val list = mutableListOf("a", "b", "c")
val last = list.pop()
println("$last, list=[${list.joinToString(",")}]") //"c", list = [a,b]

수신함수를 사용하면 자바나 ES6처럼 클래스 안에 메소드를 욱여넣을 필요가 없다. 클래스 안에 메소드를 넣는 방식은 인스턴스에 바인딩되는 메소드를 컴파일러에 알려주기 위한 것으로, 파일이 분산되어 링커가 분산되어 관리가 힘들어지는 점을 막기 위해 사용하는 방식이다. 이 방식은 메소드를 추가하기 위해서는 상속 확장을 해야 하는 단점이 있다. extention, 또는 수신함수를 사용하면 클래스 정의에 메소드가 정의되어 있지 않더라도 나중에 얼마든지 동일한 클래스 타입에 메소드를 추가할 수 있다.

코틀린 기본 패키지에 있는 인라인 함수는 코틀린의 기본 언어 스펙으로 공통된 표준이기 때문에, 이를 전부 외우는 것이 좋다. 표준 인라인 함수들은 아래와 같은 것들이 있다.(많다)

inline fun TODO():Nothing
inline fun TODO(reason:String):Nothing
inline fun <R> run(block:()->R):R
inline fun <T,R> T.run(block:T.()->R):R
inline fun <T,R> with(receiver:T, block: T.()->R):R
inline fun <T> T.apply(block:T.()->Unit):T
inline fun <T> T.also(block:(T)->Unit):T
inline fun <T, R> T.let(block: (T)-> R): R
inline fun <T> T.takeIf(predicate: (T)->boolean):T?
inline fun <T> T.takeUnless(predicate:(T)->boolean):T?
inline fun repeat(time:Int, action:(Int)->Unit)

차근차근 살펴보자.

위의 함수들은 T. 으로 시작하는 T형에 대한 수신함수와 일반함수로 나눌 수 있다. 이러한 수신함수들은 제네렉이기 때문에 어떠한 객체나 클래스 타입에도 사용할 수 있는 함수들이다. 게다가 코틀린의 제네릭은 nullable 을 포함하기 때문에, 그야말로 모든 곳에 쓰일 수 있다. 이러한 함수들은 어떤 클래스에도, 부모에도 없는 extention 이기 때문에, 보이지는 않지만 어떤 객체를 만들어도 쓸 가능성이 높다.

두번째로는 반환타입이 람다가 리턴하는 R 타입인 것과, 자기자신인 T 타입인 두가지가 있다. run, with , let 등은 람다의 리턴값이 반환값이 된다. 반면 apply, also, takeIf, takeUnless 등은 수신객체가 그대로 리턴되므로 람다의 리턴값은 중요하지 않다. 또한가지 눈에 띄는 것은 수신함수가 있는 T.run과, 수신함수가 없는 run 이 나뉘어 있고 [T.run](http://T.run) 의 람다는 T.() 를 인자로 받는 다는 것을 알 수 있다. 이 얘기는 T.run의 람다는 수신 객체 T를 this로 함수 객체 내부에서 컨텍스트로 들어간다는 뜻이다.

위의 인라인 함수들을 차례로 살펴보자.

inline fun TODO():Nothing
inline fun TODO(reason:String):Nothing

TODO 함수는 Nothing을 리턴한다. 즉 무조건 Throw를 한다. TODO를 내부에서 호출하는 함수는 실제 호출하면 Throw가 되서 종료되지만 호출하지만 않으면 컴파일이 된다. 즉 throw를 던지는 대신에 TODO라는 것으로 마킹해주는 용도이다. (사용 빈도가 높지는 않다)

run

run은 다음과 같이 사용한다.

val run0 = run{
	val a= 3
	val b= 5
	a+b
}
val run1 = 15.run{
	this+10
}

run은 인자로 람다만을 받으므로 따로 인자를 받지 않고 바로 람다 블록이 나오게 된다. 첫번째 run0에서, 람다가 리턴하는 값은 a+b로 8이며, 리턴값에서 타입 추론이 가능하므로 run0의 타입을 지정하거나 run 함수의 제네릭을 지정할 필요가 없다. 함수에서는 return이 명시적으로 있어야 리턴이 되지만 람다에서는 마지막에 오는 값이 리턴값이 되며, 리턴이라는 키워드를 쓰게되면 해당 람다가 리턴되는 것이 아니라 람다를 감싼 함수가 리턴된다. 감싸는 함수가 없다면 에러가 난다.

run1는 15가 수신 객체가 되어 this가 컨텍스트로 주어진다. 결과값은 25가 된다. 코틀린의 모든 타입은 객체형이며 primitive 형이 없으므로 무조건 메소드를 가질 수 있다. 따라서 run은 모든 타입에 쓸 수 있다.

With

inline fun <T,R> with(receiver:T, block: T.()->R):R

with는 수신람다를 사용하지만 스스로가 수신객체의 메서드가 되는 것이 아니라 인자로 수신객체를 받아서 이를 수신함수가 될 람다에게 전달한다.

val list1 = mutableListOf<String>()
val with1 = with(list1){
	this.addAll( "1,2,3,4,5,6,7".split(","))
	this[0]
}

apply

apply는 수신함수로써 람다에게 수신객체를 this 컨텍스트로 보내고 무언가 작업을 수행한 뒤 다시 수신객체 스스로를 반환한다.

inline fun <T> T.apply(block:T.()->Unit):T

따라서 apply로 함수 체이닝이 가능하다.

val apply1 = mutableListOf(1,2,3).apply{
	forEachIndexed{idx, v->
		this[idx] = v*2
	}
}

apply의 람다는 반환값이 없는 Unit 타입이어야 한다. forEachIndex는 mutableList 객체의 this가 생략된 메서드이다. apply의 실행결과인 apply1은 mutableList가 할당된다. 따라서 apply는 하나의 객체를 얻는 과정을 랩핑한 하나의 트랜잭션이 된다. 트랜잭션이라는 의미는, 과정에 다른 객체가 개입할 수 없다는 뜻이다. 트랜잭션은 apply의 주요 사용용도 이다. 함수호출 부하가 없이, 함수와 메소드의 구분이 없이 이러한 일들이 일어나게 된다.

also

also는 apply와 동일하지만 람다가 this 컨텍스트를 가지는 수신람다가 되는것이 아니라 also의 수신객체를 인자로 받는다.

inline fun <T> T.also(block:(T)->Unit):T

따라서 apply와 동일하게 사용하지만 this 대신 it 인자로 변경해주면 된다.

val apply1 = mutableListOf(1,2,3).also{
	it.forEachIndexed{idx, v->
		it[idx] = v*2
	}
}

it은 생략이 불가능하기 때문에 내부에서 호출하는 메서드에 전부 it을 붙여주면 된다.

let

let은 코틀린 코드에서 가장 많이 나오는 인라인 코드 중 하나로써 nullable과 밀접한 연관이 있다.

inline fun <T, R> T.let(block: (T)-> R): R

람다의 반환값이 let의 리턴값이 되며, 수신객체를 람다의 인자 it으로 전달한다. let은 safe call을 사용해서 수신객체 T가 null이면 람다를 실행하지 않고 곧바로 null을 리턴하는 기능을 수행할 수 있다. 따라서 코틀린에서 null 체크는 전부 let으로 변경하여 수행하는 것이 일반적이다. 앞서 봤던 nullable 예제를 let으로 변경해보면 다음과 같다.

val v1:Int? = null
if(v1 != null) println("${double(v1)}") //if로 스마트 캐스팅
v1?.let{
	println("${double(it)}")
}
val v2 = v1?.let{double(it)} ?:0

이렇게 ?.으로 safe call을 함으로써 null이 아닐 때만 let의 람다를 호출하게 되어, 람다 내에서 double함수에 인자인 it에 대해 스마트 캐스팅이 자동으로 된다. v1이 null이면 let 호출은 null을 리턴한다.

v2는 이전 값이 null일 경우에 실행되는 elvis operator를 사용해서 null일 경우 0을 할당할 수 있다. 혹은 엘비스 연산자를 활용해 null 일 경우 throw를 할 수도 있다. throw의 반환값은 항상 Nothing이므로 v2의 타입이 Int와 동시에 Nothing이 되야하는 건 아닌지 생각할 수 있지만 Nothing 타입은 그 즉시 코드 동작을 중지하는 타입으로 변수에 타입이 할당되지 않는다. 따라서 이 코드는 null을 처리하기 위해 매우 자주 사용하는 코드이다.

takeIf & takeUnless

takeIf / takeUnless는 어떠한 객체 T를 검증한 뒤 조건에 부합하면 T를 그대로 리턴하고, 아니라면 null을 리턴한다. 보통은 null일 경우 엘비스 연산자를 사용해 이를 처리한다.

inline fun <T> T.takeIf(predicate: (T)->boolean):T?
inline fun <T> T.takeUnless(predicate:(T)->boolean):T?

takeUnless은 takeIf의 반대이다.

val takeList = mutableListOf(1,2,3)
val takeIf0 = if(takeList.size>2) takeList else null
val takeIf1 = takeList.takeIf{it.size>2}

takeIf1은 takeIf0과 동일한 작용을 한다.

repeat

repeat은 반복문을 대체한다.

inline fun repeat(time:Int, action:(Int)->Unit)
var i =0
while(i<10){
	println(i)
	i++ //0~9
}
repeat(10){
	println(it)
}

repeat의 의미는 0부터 인자로 넣은 숫자 전까지라는 뜻으로 일반적인 반복문과 똑같이 작동한다고 생각하면 된다. 컴파일 결과가 반복문과 똑같기 때문에 아무 부담없이 repeat를 활용할 수 있다.

이처럼 코틀린은 제어문을 마치 함수형 언어처럼 모두 인라인 함수로 변경하여 사용하곤 한다. 다른 함수형 언어들의 경우 대신 호출하는 함수가 실제로 함수를 호출하는 콜스택 부하가 걸리기 때문에 성능상의 오버헤드가 발생하지만, 코틀린의 경우 아예 제어문과 동일하게 컴파일되어 콜스택 부하가 없다는 차이가 있다.

Request Builder

인라인 함수 사용을 훈련하기 위한 일환으로 빌더 패턴을 사용해서 리퀘스트를 보다 쉽게 만들어주는 리퀘스트 빌더객체를 만들어보자.

val request = RequestBuilder("https://apiServer")
    .method(Method.POST).form("name","hika")
    .form("email","hika@bsidesoft.com")
    .time(5000)
    .ok{}
    .fail{}
    .build()

우선 enum class 를 이용해서 POST, GET을 가지는 enum class를 만들어준다.

enum class Method{POST,GET}

내장 Request 클래스는 다음과 같다. 코틀린의 클래스 구문은 생성자 함수가 클래스 이름 바로 다음에 온다. 사실은 public constructor 키워드가 생략된 형태이다. 생성자 함수에서 val이나 var로 변수를 선언할 수 있으며, 변수로 선언한 인자는 클래스의 멤버 속성로 확정된다. (val / var 이 없는 인자는 속성이 되지 않는다). 클래스 생성자의 멤버 선언이 상당히 간략화되었다고 할 수 있다.

class Request( //constructor
    url: String,
    method: Method,
    form: MutableMap<String, String>?,
    timeout: Int,
    ok: ((String) -> Unit)?,
    fail: ((String) -> Unit)?
)

ok와 fail은 response body를 string 인자로 받아서 성공/ 실패 시 처리를 하는 Listener이다. nullable 타입들은 호출을 생략할 수 있다. 이제 이러한 Request를 빌더 패턴으로 호출할 수 있는 RequestBuilder 클래스를 만들어보자.

코틀린의 클래스 new가 없다.

class RequestBuilder(private val url: String) {
    private var method: Method = Method.GET
    private val form = mutableMapOf<String, String>()
    private var timeout = 0
    private var ok: ((String) -> Unit)? = null
    private var fail: ((String) -> Unit)? = null
    fun method(method: Method): RequestBuilder {
        this.method = method
        return this
    }

    fun form(key: String, value: String): RequestBuilder {
        this.form[key] = value
        return this
    }
    fun timeout(ms: Int): RequestBuilder {
        this.timeout = ms
        return this
    }
    fun ok(block: (String) -> Unit): RequestBuilder {
        this.ok = block
        return this
    }
    fun fail(block: (String) -> Unit): RequestBuilder {
        this.fail = block
        return this
    }
		fun build(): Request {
        
      return Request(url, method, if(form.isEmpty()) null else form, timeout, ok, fail)
    }

}

생성자에서 private val 로 접근자를 지정한 속성을 url로 받아들인다. ok와 fail 람다는 괄호가 없으면 (String)->Unit? 이 되므로 반환값이 nullable하다는 뜻이 되므로, 람다 자체가 nullable 하다는 것을 표현하기 위해서는 전체를 괄호로 묶어줘야 한다. ((String)->Unit)?

이렇게 우리가 알고 있는 일반적인 Builder 클래스를 만들어봤다. 이제 이것을 인라인 내장 함수를 이용해서 변경해보자.

우선 Build 함수의 form의 null 체크 부분은 takeIf로 해결할 수 있다.

fun build():Request{
	return Request(url,method, form.takeIf{it.isNotEmpty()}, timeout, ok,fail)
}

typealias

typealias는 특정 형에 대한 별명을 지정하는 문법이다. Builder 클래스에서 ok와 fail의 람다가 계속해서 중복되는 부분을 typealias로 줄여보자.

typealias listener = (String)->Unit
class Request( 
		...
    ok: listener?,
    fail: listener?
)
class RequestBuilder(...){
	...
	private var ok: listener? = null
  private var fail: listener? = null
}

켄트 벡 왈 : 프로그래밍은 자기만의 언어를 만들어과는 과정이다. 이름이 프로그래밍에서 중요한 부분이다.

Run 적용

현재 우리의 Builder 클래스 메소드 들은 빌더패턴의 체이닝을 위해 전부 클래스 타입을 리턴하고 있다. 그런데, 우리는 Single Expression Function으로 단일 표현 함수 사용하면 컴파일러가 타입추론을 통해 타입을 생략할 수 있게 해준다는 것을 알고 있다. 내부에서 return this 도 하면서 반환 타입도 RequestBuilder 라고 둘다 명시해 주지 않아도 추론이 가능하려면 단일 표현식으로 이를 바꿔줘야 한다. 앞서 배운 run 함수를 통해서 바꿔주자.

fun method(method:Method) = run{
	this.method = method
	this
}

람다의 반환값인 this가 자동으로 함수의 리턴값이 되므로 타입을 명시하지 않아도 된다. 모든 메소드를 이렇게 바꿔주자.

With 적용

build() 메서드는 RequestBuilder의 메서드지만 Request 객체를 리턴한다. 따라서 수신객체를 인자로 받아서 다른 타입을 리턴하는 With 함수에 사용하여 이를 표현할 수 있다.

val request = with(RequestBuilder("https://apiServer")){
    method(Method.POST).form("name","hika")
    form("email","hika@bsidesoft.com")
    time(5000)
    ok{}
    fail{}
    build()
}

RequestBuilder 가 수신객체가 되므로 람다 안의 메서드들은 this로 RequestBuilder를 가지게 됐다. 따라서 this 메서드들을 쭉 호출해주고 마지막에 build를 호출해주기만 하면 Request 타입을 반환하게 된다. 더 이상 메소드들이 RequestBuilder를 리턴해서 체이닝을 할 필요도 없다. 따라서 우리는 그냥 반환값을 생략하는 Unit를 리턴하는 함수들로 메서드를 전부 변경할 수 있다. 심지어 Run으로 단일 표현식을 사용할 필요도 없다.

class RequestBuilder(private val url: String) {
    private var method: Method = Method.GET
    private val form = mutableMapOf<String, String>()
    private var timeout = 0
    private var ok: listener? = null
    private var fail: listener? = null
    fun method(method: Method){ this.method = method }
    fun form(key: String, value: String) { this.form[key] = value }
    fun timeout(ms: Int){ this.timeout = ms }
    fun ok(block: (String) -> Unit){ this.ok = block }
    fun fail(block: (String) -> Unit){ this.fail = block }
    fun build(): Request {
        return Request(url, method, form.takeIf { it.isEmpty() }, timeout, ok, fail)
    }
}

왜 체이닝이 나빴을까? 모든 메소드들은 단지 클래스 멤버를 고치는 일만 하고 있을 뿐인데 체이닝이라는 인터페이스를 제공하기 위해서 쓸데없는 일을 전부 했기 때문이다. 한가지 책임을 가지는 SRP를 준수하기 위해서는 체이닝을 제거하는 것이 낫다.

extention 적용

그러나 with을 적용했어도 여전히 문제가 있다. 바로 build()를 호출하는 부분이다. 우리는 빌더 객체를 호출해서 Request를 실제로 할당하기 위해서는 마지막에 항상 build()를 호출해야만 한다. 이러한 부분을 build라는 메서드를 호출하는 것으로 분리했기 때문에, build 이후에 뭔가 다른 타입을 리턴한다거나 build를 잘못된 시점에 호출한다거나 하는 사용자에 의한 에러를 막을 수가 없다. 따라서 우리는 build를 호출하지 않아도 항상 Request가 반환되도록 해야 한다. Request를 반환하는 것만 보장하면, 람다 내부에서는 사용자가 원하는대로 멤버속성을 마음껏 변경할 수 있을 것이다.

val request = RequestBuilder("http://apiServer.com"){
	method = Method.POST
	form("name","hika")
	...
	ok={}
	fail={}
}

만약 RequestBuilder가 클래스라면, 호출의 결과는 무조건 RequestBuilder가 되야하므로 이는 불가능하다. 따라서 우리는 RequestBuilder를 함수로 만들것이다. 코틀린은 똑같은 객체에 대해 함수와 클래스의 중복 선언을 허용한다. 클래스 2개라던지, 함수 2개는 허용하지 않지만 함수와 클래스는 공용으로 중복선언하는 것이 허용된다.

fun RequestBuilder(url:String, block:RequestBuilder.()->Unit) = RequestBuilder(url).apply(block).build()

class RequestBuilder(...){
	...
	internal fun build() = Request(...)
}

물론 이것이 매우 권장한다거나 추천되는 패턴은 아니다. block은 수신람다로써 this에 접근할 수 있으므로, 내부에서 메서드를 마음대로 사용할 수 있게 된다. 또한, 이제 이 객체는 람다 공간 내부에서만 멤버 변수를 변경할 수 있기 때문에 멤버 변수를 private으로 만들고 이를 메서드로 감출 필요가 없다. 마지막으로 build 메서드의 접근 제한자를 internal 로 만들어서 RequestBuilder 객체만이 호출할 수 있게 변경하면 된다.

class RequestBuilder(private val url:String){
	var method:Method
	private val form=mutableMapOf<String,String>
	fun form(key:String,value:String){this.form[key] = value}
	var timeout = 0
	var ok:listener?=null
	var fail:listener?=null
	internal fun build()= Request(url,method,form,timeout,ok,fail)
}

따라서 이제 속성만 남기고 메소드는 노출할 필요가 없어졌다. RequestBuilder 클래스는 자신의 속성을RequestBuilder 함수의 람다에서 사용할 수 있는 어휘로써 제공해주며, 이를 Domain Specific Language, DSL이라고 부른다. 우리는 이를 수신함수를 통해서 어휘공간을 구축하여 사용하는 것이다.

HTML Parser

그러면, 코틀린으로 HTML 파서를 만들어보자

HTML 태그는 다음과 같은 3종류로 나눌 수 있다.

A.열린 태그와 닫는 태그 사이에 Body가 있는 태그.

B. 이미지 태그처럼 닫기 태그만 있는 단일 태그

C. 텍스트 노드로 변환되는 태그가 없는 텍스트.

그리고 태그 내부의 Body는 따라서 다음과 같이 정의된다.

Body = (A|B|C) N

즉, A,B,C 태그가 다수가 있는 것이 Body이다. 이 4가지 정의만 있으면 HTML 태그 전체를 정의할 수 있다.

구현을 위한 기본적인 자료구조를 만들어보자. w3c의 돔 구조를 그대로 가져올 것이다.

abstract class Node(val parent: Element?)
class Element(val tagName: String, parent: Element?) : Node(parent) {
    val attributes = mutableMapOf<String, String>()
    val children = mutableListOf<Node>()
}

class TextNode(val text: String, parent: Element?):Node(parent)

코틀린에서는 : 으로 부모 클래스를 지정할 수 있다. 이 때 부모의 생성자에 필요한 인자를 넘겨줘야 한다. 부모에게 넘겨줄 인자는 변수 선언으로 자기 속성으로 선언할 필요가 없이 인자만 받아서 넘겨주면 된다. 컴포지트 패턴처럼 Node 자식 컬렉션을 가질 수 있다. 사실 Node는 인자로 자식 객체인 Element를 알아서는 안되고, Node를 알아야 하지만 중간에 컨버팅 코드가 많아지고 이번 주제에서 다룰 수 있는 범위를 넘어가므로 어느정도 HTML Parser를 구현하기 위한 편의적 허용으로 봐주시면 감사하겠다.

연쇄적으로 작동하는 함수를 만들 때는 진입점 함수를 만들어야 하는 경우가 많다. 진입점 함수를 만드는 이유는 진입시에만 발생하는 특이사항이 있기 때문이며, 여기서의 특이사항은 파싱하려는 문자열에서 바로 노드를 만들 수 없고 부모가 존재하지 않는 노드의 최상위 루트를 만들어야 하는 것이다.

fun parseHTML(v:String) = parse(Element("root", null),v)

최상위 루트에 부모가 존재하지 않는 특이점 때문에 진입점 함수가 필요하게 되고, 이 진입점 함수는 최상위 노드와 파싱해야할 문자열을 받는다. 진입점 함수를 한 번만 설정해주면 나머지는 전진 파서로 재귀적으로 구현이 가능하다.

fun parse(parent:Element, v:String):Element{
	if(v[0] != "<"){
	 // 태그의 종류는 C
	}else{
		...
	}
}

우선 텍스트 노드일 경우를 살펴보자. 앞서 살펴본 세가지 종류 중에서 C에 해당되는 경우이다.

fun parse(...){
	if (v[0] != '<') {
        return if (v.isEmpty()) parent //빈 노드면 리턴
        else {
            val next = v.indexOf('<')
            parent.children += TextNode(v.substring(0, if (next == -1) v.length else next), parent)
            if (next == -1) parent else parse(parent, v.substring(next))
        }
    }else{
		...
		}
}

텍스트 노드는 < 꺽쇠로 시작하지 않을 경우에 해당된다.

만약 해당하는 C 노드가 빈문자열이면 바로 리턴을 하고, 아니라면 문자열 중에서 태그가 있는 노드를 찾는다.

부모 엘리먼트의 children 배열에 텍스트를 태그가 있는 노드까지 (없다면 전체 길이만큼)추가하고, 태그가 없다면 바로 리턴, 아니라면 꺽쇠가 있는 인덱스 부터 다시 parse 함수를 재귀호출한다.

그럼 이제 그 외의 경우를 알아보자

else {
        val next = v.indexOf('>')
        if (v[1] == '/') //닫는 태그일 때
            return if (parent.parent == null) parent
            else parse(parent.parent, v.substring(next + 1))
        }else{
           ...
        }
    }

우선 태그로 시작하는 노드이므로 next는 바로 다음 닫는 태그의 위치가 된다. v[1]이 / 라면 </a> 형태의, 닫는 태그가 될 것이므로 더 이상 파싱을 중지하고 리턴을 한다. 이 때, parent는 현재 닫는 태그 자체가 되므로, 닫는 태그가 부모가 없는 최상위 태그라면 그대로 parent를 리턴하고, 닫는 태그의 부모가 존재한다면, 닫는 태그의 부모, 즉 parent.parent 로 부모를 한단계 돌아간 뒤에, 닫는 태그 인덱스인 next +1인덱스 부터 다시 재귀적으로 문자열을 파싱하면 되는 것이다.

그럼 이제 정말 열린 태그 A와 단일 태그 B만 남았다. 이 두가지를 처리하기 전에, 태그의 속성처리 방법을 알아보자. 앞서 계산기를 구현할 때와 마찬가지로 정규식으로 구현해볼 것이다.

다음과 같은 태그가 있다고 해보자.

<div a="3" b="abc" disabled>

우선 태그명을 정규식으로 도출해보자.

val rex = """<([a-zA-Z]+)""".toRegex()

그 다음 나오는 data-index = "5" 와 같은 속성을 정규식으로 도출해보자. 어떤 것이 보이는가? 우선, 속성이 나오기 전에는 공백이 존재한다. 그 뒤, - 가 포함된 영어 대소문자 속성명이 온 뒤, 공백이 = 과 이름 사이에 나올 수도 있고 안나올 수도 있는 0개 이상이 되며, 마찬가지로 = 뒤 공백이 오거나/오지않은 뒤 " 로 감싸진 문자열 사이에 " 에 해당하지 않는 랜덤한 문자열이 0개 이상 올 수 있다. 이 말을 그대로 정규식으로 옮겨보자.

val rex = """\s+[a-zA-Z-]+\s*=\s*"[^"]*"""".toRegex()

그러면, disabled 같은 태그 속성은 어떻게 얻을 수 있을까?

우선 공백이 1개 이상 나오고, 문자가 1개이상 나오면 된다.

val rex = """\s+[a-zA-Z-]+""".toRegex()

그런데 앞서 만든 정규식과 중복된다. 즉, 앞서 정규식에서 = 과 그 뒤의 "" 가 나오는 내용을 옵셔널로만 만들어주면 두 정규식을 합칠 수 잇다.

val rex = """\s+[a-zA-Z-]+(?:\s*=\s*"[^"]*")?""".toRegex()

괄호로 묶고 ?: 로 사용해서 비캡쳐그룹으로 만들어줬다. 그리고 이 그룹을 ? 로 optional로 만들어줬다.

이 정규식의 의미는 속성 1개에 해당한다. 그렇다면 이 정규식 전체가 하나의 그룹이 되면, 속성이 없는 태그의 경우를 포함하여 태그에 정규식 그룹이 0개 이상 나오는 비캡쳐 그룹이 될 수 있을 것이다. 따라서 태그명을 합친 전체 태그를 하나의 정규식으로 표현하면 다음과 같다.

val rex = """<([a-zA-Z]+)((?:\s+[a-zA-Z-]+(?:\s*=\s*"[^"]*")?)*)""".toRegex()

태그 이름을 제외하고 나머지는 캡쳐그룹으로 만들면, 전체 속성이 하나의 그룹이 될 것이다. 이를 속성으로 변환해주면 된다.즉 개별 속성은 캡쳐하지 않고 전체 속성을 한번에 캡쳐하는 것이다. 우리는 전체 속성을 한번에 처리할 것이기 때문이다.

그럼 여기에, <div a="3" b="4" disabled /> 에서처럼, 태그가 끝날 때 닫는 태그 표시 / 가 오거나 0개 이상의 공백이 오는 경우까지 포함시켜보자.

val rex = """<([a-zA-Z]+)((?:\s+[a-zA-Z-]+(?:\s*=\s*"[^"]*")?)*)\s*/?""".toRegex()

공백이 있을 수도 있고 슬래시가 있을 수도 있는 경우를 추가했다. 우리는 닫는 태그를 / 로 인식할 것이므로 닫는 꺽쇠 > 는 포함하지 않아도 된다.

이렇게 정규식으로 태그를 추출해보았다. 처음 보는 사람에게는 조금 어려울 수 있지만, 사실은 매우 간단한 규칙을 가지고 있다. 이를 코드로 표현하려면 코드 몇줄로는 끝나지 않을 것이다.

그럼 이제 다시 코드로 돌아가 열린 태그를 처리해 보자.

fun parse(...){
	if(v[0]!='<'){ //태그가 아니면
		return ..
	}else{
		val next = v.indexOf('>')
		if(v[1] == '/'){ //닫는 태그일 때
			return ...
		}else{
			val isClose = v[next-1] =='/' // self-closing 태그인지 열린 태그인지를 나타냄
      val matches = rex.matchEntire(v.substring(0,next))?.groupValues!!
      val el = Element(matches[1], parent)
      if(matches[2].isNotBlank())matches[2].trim().split(' ').forEach {
        val kv = it.split('=').map { it.trim() }
        el.attributes[kv[0]] = kv[1].replace("\"","")
      }
      parent.children+=el
      return parse(if(isClose)parent else el, v.substring(next+1))
		}
}
        

isClose는 닫는 태그의 인덱스 바로 앞이므로, 이 문자가 / 면 A와 B 종류 중 self-closing 태그인 B라고 확정할 수 있다. 전체 태그와 앞서 만든 정규식이 일치하는지를 matchEntrie로 검증할 때, 꺽쇠 > 바로 앞까지 자른 부분 문자열과 이를 비교하면 된다.

그 다음 일치결과를 groupValues로 받기 위해 safe null로 ? 를 사용하면 matches의 결과는 groupValues혹은 null이 되는데 , 이 때 groupValues 뒤의 물음표 2개 연산자는 Not null Assertion Operator 로, 이 연산자가 수식하는 값이 null 이면 런타임에 NPE 예외를 throw하는 연산자이다. 따라서 원래는 이 구문을 try-catch로 감싸야 런타임에 프로그램이 멈추지 않는다.

groupValues는 정규식 매치 결과로써 0번 인덱스는 매치하는 전체 문자열을 , 1번부터는 차례대로 매치하는 캡쳐그룹의 캡쳐결과를 가지고 있다. 첫번째 캡쳐그룹은 태그 정규식에서 태그의 이름이 된다. 따라서 이 이름을 사용해서 Element 객체를 만든다.

두번째 캡쳐그룹은 속성부분이므로 이 부분이 빈문자열이 아니라면, trim으로 시작 공백을 제거하고, 속성과 속성 사이의 ` 공백으로 이를 split 해주면 속성의 리스트로 분리된다. 각 속성은attr="value"와 같은 형태이므로 이를 다시=로 split해주고, =사이의 공백을 trim 해준다. 마지막으로 value에서 쌍따옴표"를 제거하고, 최종 속성 key와 value을 앞서 만든Element` 객체의 attributes 객체의 key value로 설정해주면 끝이다

마지막 구문을 살펴보자

parent.children+=el
return parse(if(isClose)parent else el, v.substring(next+1))

parent의 children 배열에 Element 객체를 추가해주는 건 당연하다.

마지막에 리턴을 할 때, 재귀적으로 parse를 호출해주는데, 이 때 ifClose는 현재까지 파싱한 태그가 B종류의 self-closing 태그인지, 열린 태그인지를 나타낸다. 만약 self-closing 태그라면 그대로 parent 로 다시 한단계 돌아가서 파싱하면 되고, 열린 태그라면 현재 파싱한 Element 가 새로운 부모가 되어 그 자식 태그들을 계속해서 파싱하면 된다.

Tail recursion과 리턴 타입

앞서 만든 파서는 재귀 함수 이다. 재귀함수의 종류에는 꼬리 재귀가 있는데, 재귀의 최적화를 위해서 필요한 형태가 바로 꼬리 재귀이다.

꼬리 재귀의 조건을 우선 함수를 단일 함수식으로 변경해보자.

fun parse(parent: Element, v: String)=
    if (v[0] != '<') {
        return if (v.isEmpty()) parent //빈 노드면 리턴
        else {
            val next = v.indexOf('<')
            parent.children += TextNode(v.substring(0, if (next == -1) v.length else next), parent)
            if (next == -1) parent else parse(parent, v.substring(next))
        }
    } else {
        val next = v.indexOf('>')
        if (v[1] == '/') { //닫는 태그일 때
            if (parent.parent == null) parent
            else parse(parent.parent.v.substring(next + 1))
        } else {
            val isClose = v[next - 1] == '/'
            val matches = rex.matchEntire(v.substring(0, next))?.groupValues!!
            val el = Element(matches[1], parent)
            if (matches[2].isNotBlank()) matches[2].trim().split(' ').forEach {
                val kv = it.split('=').map { it.trim() }
                el.attributes[kv[0]] = kv[1].replace("\"", "")
            }
            parent.children += el
            parse(if (isClose) parent else el, v.substring(next + 1))
        }
    }

이것이 가능한 건 우리의 모든 조건분기가 동일한 타입을 리턴하고 있기 때문이다. 따라서 컴파일러가 Element 반환타입을 추론해준다.

코틀린 함수에는 아예 키워드로 꼬리재귀를 가능하게 하는 tailrec 라는 키워드가 있다. 이 키워드를 사용하면, 함수 내용이 꼬리물기 최적화 조건을 만족할 시 컴파일러가 재귀문을 for 반복문으로 변환시켜 버린다. 따라서 재귀함수가 스택오버플로우가 일어나는 일이 아예 방지된다. 아쉬운 점은, 컴파일 언어가 자바스크립트 인 경우에는 불가능하다는 것이다. (물론 추후에 기능이 추가될 수도 있다). tailRec의 조건만 만족한다면, 무한히 재귀가 일어나도 아무 문제 없이 for문으로 변환된다.

그런데 문제가 있다.

tail Recursion은 반복루프가 일어나면서 매번 리턴 타입을 추론하게 되어야 하는데, 다음 구문을 보자

//return 생략
parent else parse(parent,v.substring(next))

parent는 Element가 확정되어 있지만 parse는 현재 루프가 일어나는 중인, 타입이 추론되지 않은 함수 본체이다. 우리는 parse의 타입을 알 수 없기 때문에 마찬가지로 재귀가 일어나는 parse함수의 타입도 알 수가 없다. 추론이 불가능한 것이다. 따라서 이 함수는 단일 함수식이 오더라도 무조건 반환 타입을 확정해야 한다.

tailrec fun parse(parent: Element, v: String):Element=...

tailrec 키워드가 없으면 이 함수는 단일함수식만으로 타입추론이 가능하다. tailrec은 재귀함수임을 키워드로 알리기 때문에 타입이 확정되야 한다.

이 객체는 루트노드로 부터 재귀적으로 노드를 추가하기 때문에 이를 출력하는 함수도 컴포지트 패턴 을 이용해서 재귀적으로 출력해야 한다.

printElement

fun printElement(el:Element1, indent: Int = 0) {
    el.children.forEach {
        if(it is Element1){
            println("${"-".repeat(indent)}Element ${it.tagName}")
            if(it.attributes.isNotEmpty()) {
                println("${" ".repeat(indent+2)}Attribute ${
                    it.attributes.map{(k,v)->"$k = '$v'"}.joinToString {" "}
                }")
            }
					
        }
        else if(it is TextNode) {
            println("${"-".repeat(indent)}Text `${it.text}")
        }
    }
}

이함수는 인덴트 정수값을 받아서, 텍스트노드와 태그를 인덴트만큼 들여쓰기하여 출력하고 속성들은 한칸 더 들여쓴다. 코틀린의 map 객체에 대한 루프는 키와 값의 배열인 엔트리 가 들어오는데, 언어에서 이를 destructuring 인터페이스로 지원한다. 따라서 타입 선언 없이 디스트럭쳐링 문법을 사용할 수 있다.

이제 실제로 출력해보면 다음과 같이 출력된다.

profile
inudevlog.com으로 이전해용

0개의 댓글