KotlinInAction/DSL 만들기

중위 호출 연쇄: 테스트 프레임워크의 should

webmaster 2022. 8. 21. 11:20
728x90

중위 호출 연쇄: 테스트 프레임워크의 should

코틀린 테스트 DSL에서 중위 호출을 어떻게 활용하는지 살펴보자

s should startwith("kot") //DSL 단언문 표현

infix fun <T> T.should(matcher: Matcher<T>) = matcher.test(this) //should 함수 구현
  • s에 들어있는 값이"kot"로 시작하지 않으면 단언문 실패
  • should 함수 앞에 infix 변경자를 붙여 중위 호출 함수를 정의한다.
    • should 함수는 Matcher 인스턴스를 요구하며, Matcher는 값에 대한 단언문을 표현하는 제네릭 인터페이스다.
    • startwith는 Matcher를 구현하며, 문자열 시작을 검사한다.
interface Matcher<T>{
	fun test(value:T)
}
class startWith(val prefix: String): Matcher<String>{
	override fun test(value:String){
		if(!value.startWith(prefix)){
			throw AssertionError("String $value does not start with $prefix")
		}
	}
}
  • DSL에서는 일반적인 명명 규칙을 위배할 때가 있다(startWith의 첫 글자가 대문자가 아닌 소문자)
  • 쉽게 중위 호출을 적용할 수 있음을 보여준다.

더 잡음을 줄여보자!

"kotlin" should start with "kot"
//"kotlin".should(start).with("kot") 와 같다
  • 중위 호출은 일반 메서드 호출로 바꿔봐야 이해가 가능하다.
  • should, with 메서드를 연쇄적으로 중위 호출하는 사실과, start가 should의 인자라는 사실을 알 수 있다.
    • start는 객체 선언을 참조, should와 with는 중위 호출 구문으로 쓰인 함수

should 함수 중 start 객체를 파라미터 타입으로 사용하는 오버로딩 버전을 살펴보자!

object start //싱글톤 구현

infix fun String.should(x: start): StartWrapper = StartWrapper(this) //싱글톤 객체를 파라미터 타입으로 넘긴다.

class StartWrapper(val value: String){
	infix fun with(prefix: String) = 
		if(!value.startsWith(prefix))
			throw AssertionError("String does not start with $prefix: $value
		else
			Unit
}
  • 오버로딩한 should 함수는 중간 래퍼 객체를 반환하고, 래퍼 객체 안에는 중위 호출이 가능한 with 메서드가 있다.
  • DSL 맥락 밖에서 Object로 선언한 타입을 파라미터 타입으로 사용할 이유가 없지만(싱글톤 객체는 인스턴스가 하나라 파라미터 타입으로 넘길 필요 X) 넘긴 이유는 DSL문법을 정의하기 위해 사용한다.
    • Object로 선언한 타입을 파라미터 타입으로 넘김으로서 should를 오버로딩한 함수중 적절한 함수를 선택할 수 있고, 결과롤 StartWrapper 인스턴스를 받을 수 있다.

코틀린 테스트 라이브러리는 다른 Matcher도 지원하며, Matcher들은 모두 일반 영어 문장처럼 보이는 단언문을 구성한다.

Ex) "kotlin" should end with "in" , "kotlin" should have substring "otl"

중위 호출과 object로 정의한 싱글턴 인스턴스를 조합해 복잡한 문법을 도입하거나, DSL을 깔끔하게 만들수 있다 → 이런 장점에도 DSL은 여전히 정적 타입 지정 언어로 남으며, 함수와 객체를 잘못 조합하면 컴파일에 실패한다. 

원시 타입에 대한 확장 함수 정의: 날짜 처리

import java.time.LocalDate
import java.time.Period

val Int.days: Period
    get() = Period.ofDays(this) //this는 상수의 값을 가리킨다.

val Period.ago: LocalDate
    get() = LocalDate.now() - this //연산자 관례를 사용해 LocalDate.minus 호출

val Period.fromNow: LocalDate
    get() = LocalDate.now() + this //연산자 관례를 사용해 LocalDate.plus 호출

fun main() {
    println(1.days.ago)
    println(1.days.fromNow)
}
  • days는 Int 타입의 확장 프로퍼티다.
    • 코틀린에서는 아무 타입이나 확장 함수의 수신 객체 타입이 될 수 있어, 원시타입에 대한 확장 함수를 정의하고, 원시 타입 상수에 대해 확장 함수를 호출할 수 있다.
  • days 프로퍼티는 Period 타입의 값을 반환하며, 두 날짜 사이의 시간간격을 나타내는 JDK8 타입이다.
  • ago를 지원하기 위해 Period 클래스에 대한 확장함수가 필요하다
    • 프로퍼티 타입은 LocalDate로 날짜를 표현하며, -,+ 는 LocalDate클래스에 존재하는 minus, plus 관례를 사용하여 컴파일러가 변환하여 호출한다.

Github의 kxdate 라이브러리에서는 하루 단위뿐 아니라 모든 시간 단위를 지원하는 완전 구현을 볼 수 있다

멤버 확장 함수: SQL을 위한 내부 DSL

맴버 확장 : 클래스 안에서 확장 함수와, 확장 프로퍼티를 선언한 것(클래스의 멤버인 동시에 확장하는 다른 타입의 멤버이기도 하다)

object Country: Table(){
    val id = integer("id").autoIncrement().primaryKey()
    val name = varchar("name", 50)
}

class Table {
    fun integer(name: String) = Column<Int>()
    fun varchar(name: String, length: Int) = Column<String>()

    fun <T> Column<T>.primaryKey(): Column<T> = TODO()
    // 자동 증가는 int만 되도록 제한을 건다
    fun Column<Int>.autoIncrement(): Column<Int> = TODO()
}
  • 위 코드는 DB 테이블에 대응 된다.
  • Country객체에 속한 프로퍼티들의 타입은 각 컬럼에 맞는 타입 인자가 지정된 Column 타입이다(id = Column<id>, name = Column<String>)
  • exposed 프레임워크의 Table 클래스는 DB 테이블에 대해 정의할 수 있는 모든 타입을 정의한다.
  • autoIncrement, primaryKey 같은 메서드로 각 컬럼의 속성을 지정하는데, Column에 대해 이런 메서드를 호출할 수 있다.
    • 메서드는 자신의 수신 객체를 다시 반환하기 때문에 메서드 연쇄 호출이 가능하다.
    • 두 메서드는 Table 클래스의 멤버이다(Table 클래스 밖에서 이 함수들을 호출할 수 없다) → 제약을 건다
    • autoIncrement 같은 경우 정수 타입에만 제한을 걸어야 하므로 Column<Int>의 확장 함수로 정의하였다.
  • 어떤 컬럼을 primaryKey로 지정하면, 그 컬럼을 포함하는 테이블 안에 그 정보가 저장되며, primaryKey는 Table에 확장 멤버이기 때문에, 정보를 테이블 인스턴스에 바로 저장할 수 있다.

TIP : 멤버 확장도 멤버다

멤버 확장에는 확장성이 떨어진다는 단점 존재(어떤 클래스 내부에 속해 있기 때문에 기존 클래스 소스코드를 손대지 않고 새로운 멤버 확장을 추가 X) EX) Table에 새 컬럼 속성을 추가하기 위해서는 Table 클래스의 정의를 수정해서 새로운 멤버 확장을 추가해야 한다.(Table의 원 소스코드를 수정하지 않고는 Table에 필요한 확장 함수, 프로퍼티를 추가할 방법이 없다)

val result = (Country join Customer)
        .select { Country.name eq "USA"} //Where Country.name = "USA" 
    result.forEach{ println(it[Customer.name]) }  

fun Table.select(where: SqlExpressionBuilder.() -> Op<Boolean>) : Query{
	... 
}

object SqlExpressionBuilder{  
  infix fun<T> Column<T>.eq(t: T) : Op<Boolean>    
	...
}
  • eq 메서드는 "USA"를 인자로 받는 함수를 중위 표기법 식으로 적었고, 다른 클래스에 속한 멤버 확장이다(확장 함수)
    • SqlExpressionBuilder 객체는 조건을 표현할 수 있는 여러 방식을 정의
    • select 함수가 받는 파라미터 타입이 SqlExpressionBuilder를 수신 객체로 하는 수신 객체 지정 람다이다
    • → select에 전달되는 람다 본문에서는 SqlExpressionBuilder에 정의가 들어있는 모든 확장 함수 사용 가능

결론 : 멤버 확장을 사용하면 각 함수를 사용할 수 있는 맥락을 제어할 수 있다

안코: 안드로이드 UI를 동적으로 생성하기

안코 라이브러리 : 안드로이드 애플리케이션의 UI 구성에 도움을 준다

fun Activity.showAreYouSureAlert(process:() -> Unit){
    alert(title = "Are you sure?", message = "Are you really sure?") {
        positiveButton("Yes") { process() }
        negativeButton("No") { cancel() }
    }
}
  • alert 함수의 세 번째 인자, positiveButton,negativeButton의 인자로 전달된 것들 → 총 3가지 람다 존재
  • alert 함수의 세번째 인자의 람다의 수신 객체는 AlertDialogBuilder 타입이다
    • 람다 안에서 AlertDialogBuilder의 멤버에 접근해 경고 창에 여러 요소를 추가, 변경하는 패턴이 반복된다
fun Context.alert(
	message:String,
	title:String,
	init:AlertDialogBuilder.() -> Unit 
)
class AlertDialogBuilder{
    fun positiveButton(text: String, callback: DialogInterface.() -> Unit) 
    fun negativeButton(text: String, callback: DialogInterface.() -> Unit) 
    //...
}
verticalLayout{
    val email = editText { //EditText 뷰 요소를 선언하고 그에 대한 참조를 저장
        hint = "Email"//이 람다의 수신 객체는 안드로이드 API가 제공하는 일반 클래스 android,.widget.EditText
    }
    val password = editText{
        hint = "Password"//EditText.setHint("Password")를 호출하는 간단한 방법
        transformationMethod =//EditText.transformationMethod(...)를 호출
            PasswordTransformationMethod.getInstance()
    }
    button("Log In"){ //새 버튼을 선언한다
        onClick{ //버튼 클릭시 하는 작업을 정의
            login(email.text, password.text) //UI요소 안에 정의한 참조를 사용해 각각의 요소에 들어있는 데이터에 접근근        }
    }
}

결론 : 내부 DSL을 사용하면 UI와 비즈니스 로직을 다른 컴포넌트로 분리할 수 있지만 모든 컴포넌트를 여전히 코틀린 코드로 작성할 수 있다

728x90