DSL для регулярних виразів на Kotlin



Всім привіт!
Ця стаття про реалізацію одного конкретного DSL (domain specific language, предметно-орієнтована мова) для регулярних виразів засобами Kotlin, але при цьому вона цілком може дати загальне уявлення, про те, як написати свій DSL Kotlin і що зазвичай робитиме "під капотом" будь-який інший DSL, використовує ті ж можливості мови.
Багато хто вже використовують Kotlin або хоча б пробували це робити, та й інші цілком могли чути про те, що Kotlin розташовує до написання витончених DSL, чому є блискучі приклади — Anko і kotlinx.html.
Звичайно ж, для регулярних виразів тощо вже робили (і ще: на Java, на Scala, на C# — реалізацій багато, схоже, це поширена розвага). Але якщо хочеться попрактикуватися або спробувати DSL-орієнтовані мовні можливості Kotlin, то ласкаво просимо під кат.
Як зазвичай виглядає DSL, написаний на Kotlin?

В гіршому випадку, напевно, так.
Більшість DSL на Java пропонують використовувати ланцюжка викликів для своїх конструкцій, як у цьому прикладі Java Regex DSL:
Regex regex = RegexBuilder.create()
.group("#timestamp")
.number("#hour").constant(":")
.number("#min).constant(":")
.number(#secs).constant(":")
.number("#ms")
.end()
.build();

Ми могли б використовувати цей підхід, але він має ряд незручностей, серед яких можна відзначити два:
  • незручна реалізація вкладених конструкцій (
    group
    та
    end
    ), з-за яких доведеться ще й воювати з форматтером, так і банальної перевірки відповідності відкривають і закривають елементів немає, можна написати зайвий
    .end()
    ;
  • погані можливості для динамічного формування вирази: якщо ми хочемо виконати довільний код перед додаванням чергової частини запит — наприклад, перевірити умова — нам потрібно буде розривати ланцюжок викликів і зберігати частково створене вираз змінної.
З цими недоліками в Kotlin можна впоратися, якщо реалізувати DSL в стилі Type-Safe Groovy-Style Builder (при поясненні технічних деталей ця стаття буде багато в чому повторювати сторінку документації по посиланню). Тоді виглядати код на ньому буде подібно цьому прикладу Anko:
Показати код
verticalLayout {
val name = editText()
button("Say Hello") {
onClick { toast("Hello, ${name.text}!") }
}
}

Або наприклад kotlinx.html:
Показати код
html {
body {
div {
a("http://kotlinlang.org") {
target = ATarget.blank
+"Main site"
}
}
}
}

Забігаючи вперед, скажу, що вийшов мова буде виглядати приблизно так:
код:
val r = regex {
group("prefix") {
literally("+")
oneOrMore { digit(); letter() }
}
3 times { digit() }
literally(";")
matchGroup("prefix")
}

Приступимо


class RegexContext { }

fun regex(block: RegexContext.() -> Unit): Regex { throw NotImplementedError() }

Що тут написано? Функція
regex
повертає побудований
об'єкт Regex
і приймає єдиний аргумент — іншу функцію типу
RegexContext.() -> Unit
. Якщо ви вже добре знайомі з Kotlin, сміливо пропустити пару абзаців, пояснюють, що це.
Типи функцій Kotlin записуються так:
(Int, String) -> Boolean
— це предикат двох аргументів — або так:
SomeType.(Int) -> Unit
— це функція, що повертає Unit (аналог void-функції), і крім аргументу
Int
приймаюча ще й receiver типу
SomeType
.
Функції, що приймають receiver, нам здорово допомагають будувати DSL завдяки тому, що можна передавати в якості аргументу такого типу лямбда-вираз, і у нього з'явиться неявний
this
, що має такий тип, як receiver. Простий приклад — бібліотечна функція
with
:
fun <T, R> with(t: T, block: T.() -> R): R = t.block() 
// викликає block зі своїм першим аргументом в якості receiver

// передаючи лямбду останнім аргументом, можна писати її за дужками
with(ArrayList<Int>()) {
for(i in 1..10) { add(i) } // виклик add з неявним використанням receiver
println(this) // явне звернення до this -- це ArrayList<Int>
}

Чудово, тепер ми можемо викликати
regex { ... }
і всередині фігурних дужок працювати з якимсь примірником
RegexContext
, як ніби це
this
. Залишилася сама малість — реалізувати члени
RegexContext
. :)
Навіщо потрібен RegexContext?
Давайте складати регулярний вираз по частинах — кожен statement нашого DSL просто буде дописувати в недобудоване вираз чергову частину. Ці частини і буде зберігати
RegexContext
.
class RegexContext {
internal val regexParts = mutableListOf<String>() // вже додані частини

private fun addPart(part: String) { // цю функцію будемо викликати в інших
regexParts.append(part) 
}
}

Відповідно, функція
regex {...}
тепер буде виглядати наступним чином:
fun regex(block: RegexContext.() -> Unit): Regex {
val context = RegexContext()
context.block() // викликаємо block, який щось зробить з context
val pattern = context.regexParts.toString()
return Regex(pattern) // компілюємо зібраний з частин патерн-рядок в Regex
}

Далі реалізуємо функції
RegexContext
, що додають різні частини в регулярний вираз.
Такі функції, якщо явно не сказано зворотного, теж розташовані в тілі класу.
Все дуже просто

Так?
fun anyChar(s: String) = addPart(".")

Цей виклик просто додає вираз точку, якою позначається підвираз, відповідне кожному окремому символу.
Аналогічно реалізуємо функції
digit()
,
letter()
,
alphaNumeric()
,
whitespace()
,
wordBoundary()
,
wordCharacter()
та навіть
startOfString()
та
endOfString()
— всі вони виглядають приблизно однаково.
А саме:
fun digit() = addPart("\\d")

fun letter() = addPart("[[:alpha:]]")

fun alphaNumeric() = addPart("[A-Za-z0-9]")

fun whitespace() = addPart("\\s")

fun wordBoundary() = addPart("\\b")

fun wordCharacter() = addPart("\\w")

fun startOfString() = addPart("^")

fun endOfString() = addPart("$")

А ось для додавання довільної рядка в регулярний вираз доведеться її спочатку перетворити, щоб присутні в рядку символи не інтерпретувалися як службові. Найпростіший спосіб це зробити — за допомогою функції Regex.escape(...):
fun literally(s: String) = addPart(Regex.escape(s))

Наприклад,
literally(".:[test]:.")
додасть у вираз частина
\Q.:[test]:.\E
.
Йдемо глибше
Що щодо квантификаторов? Очевидне спостереження: квантіфікатор навішується на підвираз, яке саме по собі теж валідний регекс. Давайте додамо трохи вкладеності!


Ми хочемо вкладеним блоком коду в фігурних дужках задавати підвираз квантификатора, приблизно так:
val r = regex {
oneOrMore {
optional { anyChar() }
literally("-")
}
literally(";")
}

Робити ми це будемо з допомогою функцій
RegexContext
, які поводяться майже так само, як
regex {...}
, але самі використовують побудоване підвираз. Додамо спочатку допоміжні функції:
private fun addWithModifier(s: String, modifier: String) {
addPart("(?:$s)$modifier") // додає non-capturing group з модифікатором
}

private fun pattern(block: RegexContext.() -> Unit): String { 
// насправді трохи інакше -- ми ще повернемося сюди
val innerContext = RegexContext()
innerContext.block() // block запускається на іншому RegexContext
return innerContext.regexParts.toString() // ми беремо створений ним патерн
} 

І потім використовуємо їх для реалізації наших "квантификаторов":
fun optional(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "?")

fun oneOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "+")

І так далі (плюс, функції, дозволяють не загортати literally в лямбду
fun oneOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "+")
fun oneOrMore(s: String) = oneOrMore { literally(s) }

fun optional(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "?")
fun optional(s: String) = optional { literally(s) }

fun zeroOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), ".*")
fun zeroOrMore(s: String) = zeroOrMore { literally(s) }

Ще в регексах є можливість задавати кількість очікуваних входжень точно або з допомогою діапазону. Ми собі таке теж хочемо, правда? А ще це хороший привід застосувати інфіксние функції — функцію двох аргументів, один з яких — receiver. Дзвінки таких функцій будуть виглядати наступним чином:
val r = regex {
3 times { anyChar() }
2 timesOrMore { whitespace() }
3..5 times { literally("x") } // 3..5 -- це IntRange
}

А самі функції оголосимо так:
infix fun Int.times(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "{$this}")

infix fun IntRange.times(block: RegexContext.() -> Unit) = 
addWithModifier(pattern(block), "{${first},${last}}")

І всі разом, знову з функціями для рядків-символів:
infix fun Int.times(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "{$this}")
infix fun Int.times(s: String) = this times { literally(s) }

infix fun IntRange.times(block: RegexContext.() -> Unit) = 
addWithModifier(pattern(block), "{${first},${last}}")
infix fun IntRange.times(s: String) = this times { literally(s) }

infix fun Int.timesOrMore(block: RegexContext.() -> Unit) = 
addWithModifier(pattern(block), "{$this,}")
infix fun Int.timesOrMore(s: String) = this timesOrMore { literally(s) }

infix fun Int.timesOrLess(block: RegexContext.() -> Unit) = 
addWithModifier(pattern(block), "{0,$this}")
infix fun Int.timesOrLess(s: String) = this timesOrLess { literally(s) }

Згрупуйтеся!
Інструмент для роботи з регексами не може таким називатися, якщо не підтримує групи, тому давайте їх підтримувати, наприклад, у такому вигляді:
val r = regex {
anyChar()
val separator = group { literally("+"); digit() } // повертає індекс групи
anyChar()
matchGroup(separator) // використовує індекс групи
anyChar()
}

Однак групи вносять нову складність в структуру регекса: вони нумеруються "наскрізь" зліва направо, ігноруючи вкладеність подвираженій. А значить, не можна вважати виклики
group {...}
незалежними один від одного, і навіть більше: всі наші вкладені подвираженія тепер теж один з одним пов'язані.
Щоб підтримувати нумерацію груп, злегка змінимо
RegexContext
: тепер він буде пам'ятати, скільки груп у ньому вже є:
class RegexContext(var lastGroup: Int = 0) { ... }

І, щоб наші вкладені контексти знали, скільки груп було до них і повідомляли, скільки додалося всередині них, змінимо функцію
pattern(...)
:
private fun pattern(block: RegexContext.() -> Unit): String {
val innerContext = RegexContext(lastGroup) // передаємо всередину
innerContext.block()
lastGroup = innerContext.lastGroup // оновлюємо кількість груп зовні
return innerContext.regexParts.toString()
}

Тепер нам нічого не заважає коректно реалізувати
group
:
fun group(block: RegexContext.() -> Unit): Int {
val result = ++lastGroup
addPart("(${pattern(block)})")
return result
}

Випадок іменованих груп:
fun group(name: String, block: RegexContext.() -> Unit): Int {
val result = ++lastGroup
addPart("(?<$name>${pattern(block)})")
return result
}

І матчинг груп, як індексованих, так і іменованих:
fun matchGroup(index: Int) = addPart("\\$index")
fun matchGroup(name: String) = addPart("\\k<$name>")

Що ще?


Так! Ми мало не забули важливу конструкцію регулярних виразів — альтернативи. Для літералів альтернативи реалізуються тривіально:
fun anyOf(vararg terms: String) = addPart(terms.joinToString("|", "(?:", ")") { Regex.escape(it) }) 
// збере з terms один рядок з префіксом, суфіксом і роздільником, 
// спочатку застосувавши до кожної Regex.escape(...)

Не складніше реалізація для вкладених виразів:
fun anyOf(vararg blocks: RegexContext.() -> Unit) = 
addPart(blocks.joinToString("|", "(?:", ")") { pattern(it) })

То ж і для наборів символів:
fun anyOf(vararg characters: Char) =
addPart(characters.joinToString("", "[", "]").replace("\\", "\\\\").replace("^", "\\^"))

fun anyOf(vararg ranges: CharRange) = 
addPart(ranges.joinToString("", "[", "]") { "${it.first}-${it.last}" })

Але стривайте, а що якщо ми хочемо в одному і тому ж
anyOf(...)
використовувати в якості альтернатив різні речі — наприклад, рядок, і блок з кодом для вкладеного подвираженія? Тут нас чекає невелике розчарування: у Kotlin немає union types (типи об'єднань), і написати тип аргументу
String | RegexContext.() -> Unit | Char
ми не можемо. Обійти це я зміг тільки страхітливого вигляду милицями, які все одно не роблять DSL краще, тому вирішив залишити все так, як написано вище — в кінці кінців, і
String
та
Char
можна написати у вкладених подвыражениях, використовуючи відповідну перевантаження
anyOf {...}
.
Страшні милиці
  • anyOf(vararg parts: Any)
    ,
    Any
    — тип, якому належить будь-який об'єкт. Перевіряти, який з типів, відповідно, всередині, а в необережного користувача, передав поганий аргумент, кидати
    IllegalArgumentException
    , чого він буде дуже радий.
  • Хардкор. У Kotlin клас може перевизначити оператор invoke(), і тоді об'єкти цього класу можна буде використовувати як функції:
    myObject(arg)
    , і якщо оператор має кілька перевантажень, і об'єкт буде вести себе як кілька перевантажень функції. Потім можна спробувати каррировать функцію
    anyOf(...)
    , але, раз вона має довільне число аргументів, то ми не знаємо, коли вони закінчаться — отже, кожне часткове застосування повинно скасовувати результат попереднього і після цього застосовуватися саме, як ніби його останній аргумент.
    Якщо це акуратно зробити, воно навіть запрацює, але ми несподівано упремося в неприємний момент в граматиці Kotlin: в ланцюжку викликів оператора
    invoke
    не можна використовувати поспіль виклики з фігурними дужками.
    object anyOf {
    operator fun invoke(s: String) = anyOf // даний тіло опущено для стислості
    operator fun invoke(r: RegexContext.() -> Unit) = anyOf
    }
    
    anyOf("a")("b")("с") // так можна
    anyOf("123") { anyChar() } { digit() } // а ось так не можна!
    anyOf("123")({ anyChar() })({ digit() }) // так можна
    ((anyOf("123")) { anyChar() }) { digit() } // або так

    Ну, і потрібно воно нам таке?

Крім цього, непогано було б переиспользовать регулярні вирази, як побудовані нашим DSL, так і ті, що прийшли до нас звідкись ще. Зробити це нескладно, головне — не забути про нумерацію груп. З регекса можна витягнути кількість груп:
Pattern.compile(pattern).matcher("").groupCount()
, і залишається тільки реалізувати відповідну функцію
RegexContext
:
fun include(regex: Regex) {
val pattern = regex.pattern
addPart(pattern)
lastGroup += Pattern.compile(pattern).matcher("").groupCount()
}

І на цьому, мабуть, обов'язкові фічі закінчуються.
Висновок
Дякую, що дочитали до кінця! Що у нас вийшло? Цілком життєздатний DSL для регексов, яким можна користуватися:
fun RegexContext.byte() = anyOf({ literally("25"); anyOf('0'..'5') },
{ literally("2"); anyOf('0'..'4'); digit() },
{ anyOf("0", "1", ""); digit(); optional { digit() } })
val r = regex {
3 times { byte(); literally(".") }
byte()
}

(Питання: для чого цей регекс? Правда ж, він простий?)
Ще переваги:
  • Складно поламати регекс: навіть дужки руками писати не треба, якщо код компілюється і групи правильні, то і регекс валідний.
  • Виходить наочно формувати регекс динамічно: це робить живий код з будь-якими валідними конструкціями на кшталт умов, циклів і викликів сторонніх функцій.
  • Якщо ви використовуєте індексовані групи, то індекс групі призначається динамічно, і навіть зміна великого регекса, написаного на DSL, не поламає індекси груп.
Що не вийшло:
  • Незграбно виглядає
    anyOf(...)
    , не вдалося домогтися кращого.
  • Щільність запису сильно поступається традиційній формі, регекс в полекрана довжиною перетворюється в блок полекрана заввишки. Зате, напевно, читаемо.
Исходники, тести і готова до додавання в проект залежність — репозиторії на Github.
Що ви думаєте з приводу предметно-орієнтованих мов для регулярних виразів? Користувалися хоч раз? А іншими DSL?
Буду радий обговорити все, що прозвучало.
Удачі!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.