도메인 특화언어Domain-specific language(DSL)는 특정 기능이나 영역을 위해 만들어진 언어이고 아래와 같은 다양한 작업을 처리하기 위해 소프트웨어 개발에 많이 사용된다.
- 소프트웨어 설정 설명
- 테스트 사양
- 작업 흐름 규칙 정의
- UI디자인
- 데이터 조작
반대 개념으로 General Purpose Language 가 있고 우리가 일반적으로 사용하는 C, C++, Kotlin, Swift 등의 언어가 해당된다. DSL 은 GPL 처럼 모든 문제를 풀기보단 domain-specific primitive 을 이용해 특정영역에 초점을 맞추고 효율적으로 목표를 달성하기 위한 언어이기때문에 이를 사용하면 깔끔한 코드작성이 가능하다.
하지만 언어가 다르기때문에 DSL코드를 내부에 내장시키기 어렵기 때문에 보통은 외부 호스트 코드에 저장하거나, 문자열리터럴(String Literal)에 포함시키는 방법이있지만 이는 compile time validation 과 IDE의 code assistance 가 복잡해진다는 단점이있다.
이러한 이유로 코틀린에서는 DSL을 설계하고 싶을때 도움이 되는 몇가지 기능을 제공한다. 이렇게 사용하게 되면, 작성된 코드는 다른 언어로 보이지만 코틀린언어에서 사용이 가능하기 때문에 DSL의 장점까지 모두 취할수있다.
Operator overloading
연산자 오버로딩을 이용하면 +, -, *, / 등 코틀린 내장 연산자에 대해 새로운 의미를 줄 수 있다. 예를 들면 + 는 수에서는 덧셈으로 사용되지만 문자열에선 연결연산이고, 컬렉션의 경우에는 원소를 맨 뒤에 붙이는 연산이 된다. 이러한 이유는 +가 오버로딩되어 다양한 구현을 제공하기 때문이다.
abc*3 //"abc.times(3)"
1+2 // 1.plus(2)
Unary operations 단항연산
오버로딩할수있는 단항 연산자는 +, -, ! 가있다. 아래의 코드는 !(not)연산자 오버로딩해 다른 구현을 하는 예시이다.
enum class Color {
BLACK, RED, GREEN, BLUE;
}
operator fun not() =
when(this) {
BLACK->WHITE
RED->CYAN
GREEN->MAGENTA
BLUE->YELLOW
}
fun main() {
println(!Color.RED) //CYAN
println(!COLOR.GREEN) //MAGENTA
}
Increments and decrements 증가와 감소
증가(++)와 감소(- -)연산자도 피연산자 타입에 대한 파라미터가 없는 inc()와 dec()함수로 오버로딩 할수있다. 반환타입은 증가/감소 전의 타입과 같아야한다. 아래의 코드는 정의된 색의 순서에 따라 inc() , dec()를 정의해본 코드이다.
enum class RainbowColor {
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
operator fun inc() = values[(ordinal + 1) % values.size]
operator fun dec() = values[(ordinal + values.size - 1) % values.size]
companion object {
private val values = enumValues<RainbowColor>()
}
}
val color = RainbbowColor.INDIGO
color++ //VIOLET
Binary operations 이항 연산
코틀린은 대부분의 이항 영산자를 오버로딩 할 수 있다.
Infix operations 중위연산
중위함수: 클래스의 멤버 호출 시 사용하는 점(.)을 생략하고 함수 이름뒤에 소괄호를 생략해 직관적인 이름을 사용할 수 있는 표현법
val multi = 3 multiply 10 // == val multi = 3.multiply(10)
중위 함수의 조건:
- 멤버 메서드 또는 확장 함수여야한다
- 하나의 매개변수를 가져야한다
- infix 키워드를 사용하여 정의한다
infix fun<A,B> A.to(that:B): Pair<A,B> = Pair(this,that)
infix fun Int.multiply(x: Int): Int { //infix 이기때문에 중위함수
return this * x
}
Assignment 대입
또 다른 이항 연산 그룹은 += 와 같은 복합대입연산(augmented assignments)을 처리한다.
1. immutable 타입이라면 새로운 컬렉션을 생성하고, 변수에 대입하므로 값이 바뀐다.
var numbers = listOf(1,2,3)
numbers += 4 // [1, 2, 3, 4]
2. mutable 타입이라면 원래의 객체의 정체성은 보존하되 내용만 바꾼다.
val numbers = mutableListOf(1,2,3)
numbers += 4 // [1, 2, 3, 4]
하지만 mutable collection 에 mutable 변수이게(var) 되면 에러를 발생한다.
어떤 관습을 따라야할지 결정 할 수 없기 때문이다.
3. var numbbers = mutableListOf(1,2,3)
numbers += 4 //ERROR
위의 예시는 임의 타입을 지원하고, 복합대입연산의 동작은 아래의 요소에 따라 달라질수있다.
- 이항 연산 함수(binary operator function)의 존재여부 ➡️ plus() 는 +=, miuns는-=
- 커스텀대입 함수(custom assignment function)의 존재여부 ➡️ plusAssign() 는 +=, minusAssign() 은 -= …
- 왼쪽 피연산자의 가변성(mutability)
만약, 왼쪽 피연산자는 이항연산자 (binary operator)이 있으면 복합대입연산은 단순한 연산식으로 변환된다(커스텀X). 이는 원시타입이나 불변(immutable) 컬렉션에서 발생되는 케이스다.
//기본 산술 연산을 지원하는 Rational numbers 프로토타입
class Rational private constructor(
val sign: Int,
val num: Int,
val den: Int
){
fun of(num: Int, den: Int = 1): Rational {
if (den == 0) throw ArithmeticException("Denominator is zero")
val sign = num.sign() * den.sign()
val numAbs = abs(num)
val denAbs = abs(num)
val gcd = gcd(numAbs, denAbs)
return Rational(sign, numAbs/gcd, denAbs/gcd)
}
}
//위의 코드를 이용하면 산술 연산을 편하게 사용할 수 있다. [Binary operators]
fun r(number: Int, den: Int = 1) = Rational.of(num, den)
위의 코드를 보면 Rational 객체는 이미 간단한 연산을 지원하기때문에 복합 대입문을 사용할 수 있다.
var r = r(1,2) // 1/2
r+r(1/3) // 1/2 + 1/3
r //5/6
이때, 복합대입 연산자 함수 정의가 없으므로 대입 연산자의 왼쪽 피연산자는 반드시 가변변수(mutable)이여야 컴파일이 가능하다.
Invocations and indexing 호출과 인덱스로 원소찾기
호출 관습을 사용하면 값을 함수처럼 호출식에서 사용이 가능하다. 사용하기 위해선 invoke()함수와 필요한 파라미터를 정의하면 된다. 함수 타입의 값은 자동으로 Invoke() 멤버가 생기고 만약 원한다면 임의의 타입에도 호출을 지원하게 할수 있다.
operator fun <K,V> Map<K,V>.invoke(key: Key) = get(key)
위와 같이 정의하면 map 인스턴스를 함수처럼 사용할수있다.
val map = mapOf("I" to 1, "V" to 5, "X" to 10)
map("V") //5
사용하기 좋은 예시로는 아래와 같이 invoke()함수를 companion object 에 추가해 팩토리로 만드는것이다. 아래의 코드는 Rational 클래스를 확장한 코드이다.
operator fun Rational.Companion.invoke(num: Int, den: Int = 1) = of(num,den)
val r = Rational(1,2)
그럼 위와같이 클래스 이름명으로 인스턴스를 생성할수있다. 위의 코드를 통해 invoke() -> of() -> private constructor of Rational 의 과정을 간단하게 한다.
Destructurning 구조분해
연산자 오버로딩을 사용하면 임의의 타입에 구조분해기능을 사용할수있다. 이를 사용하기 위해선 파라미터가없는 멤버/확장 함수 componentN(,) 을 선언한다. N은 based number가 1이라는 뜻이다.
class RationalRange(val from: Rational, val to: Rational) {
}
operator fun RationalRange.component1() = from
operator fun RationalRange.component2() = to
위와 같은 클래스가 있다면 이에 대해 컴포넌트 함수를 정의해 구조분해를 사용할 수 있다.
val (from, to) = r(1,3)..r(1,2)
from // 1/3
to //1/2
Iteration 이터레이션
문자열이나 범위, 컬렉션등의 객체에서 사용되는 for-loop 의 공통점은 iterator() 함수가 있다는 점이다. iterator()함수는 Iterator 타입의 인스턴스를 반환한다. 이는 원하는 타입에 대해 iterator()함수를 멤버나 확장으로 정의하면 for-loop를 사용할 수 있다.
operator fun <T>TreeNode<T>.iterator() = children.iterator()
여기까지가 연산자 오버로딩에 관한 설명이다.
Delegated properties 위임프로퍼티
위임프로퍼티는 커스텀 프로퍼티 접근 로직을 구현할수있는 방법을 뒷딴에서 제공한다. 예를들면 lazy delegate을 사용하면 처음 접근 전까지 아래의 코드가 계산이 안되는거와같은 문맥이다.
val result by lazy { 1 + 2}
이를 알아두면 사용하기 편한 API 와 DSL(domain specific language)를 만들수있다. 위에서 설명한 연산자 오버로딩과 마찬가지로 몇가지의 convention(규약)이 정해져있고, 이는 어떻게 읽고 쓰고 위임객체를 어떻게 관리하는지에 대해 알아보자.
Standard delegates 표준위임들
코틀린 표준 라이브러리엔 바로 이용할수있는 몇가지 위임구현이 정의되어 있다.
lazy()
val text by lazy {File("data.txt").readText()}
lazy() 함수는 멀티스레드 환경에서 각기다른 행동을 수행하기 위해 3가지의 모드를 제공한다. 디폴트(synchronized)로 지정된 lazy()함수는 항상 한가지 스레드에 의해서만 초기화되도록 보장되기때문에 안전하다. 이 경우엔 위임인스턴스가 동기화 객체 역활까지 수행한다. 하지만 synchronized는 모든 스레드에서 한개의 값만 보고있기때문에 이를 원치않으면 LazyThreadSagetyMode.PUBLICATION 을 파라미터로 넘겨 원하는 동기화 객체를 지정할수도있다.
3가지모드:
- SYNCHRONIZED(thread-safe): 한스레드에서만 사용되고 초기화된다. 다른 스레드에서는 저장된 값을 사용한다.
- PUBLICATION(thread-safe): 위와 다르게 제약이 없고, 다중 스레드에서 사용되고 초기화되지만 처음에 인스턴스를 만든 스레드가 우선이 되기 때문에 다른 스레드는 이를 따른다. 초기화 함수가 여러번 호출 될 수있다.
- NONE: 프로퍼티 접근을 동기화 하지 않는다. 이는 다중 스레드 환경에서 프로퍼티의 올바른 동작을 보장 할 수 없지만 가장 빠르고 한스레드에서만 불린다고 확신하는경우 유용하다.
SYNCHRONIZED 와 PUBLICATION은 둘다 thread-safe 이지만 다른점은 PUBLICATION은 값이 초기화 값을 받기 전까지 초기화 함수가 여러번 호출된다는 점이다.