Kotlin DSL:理論と実践

SQL、RegExp、Gradle-それらを結合するものは何ですか? これらはすべて、問題指向言語またはDSL(ドメイン固有言語)の使用例です。 このような各言語は、データベースからのデータのクエリ、テキスト内の一致の検索、アプリケーションの構築プロセスの記述など、その焦点が絞られたタスクを解決します。 Kotlin言語は、独自の問題指向言語を作成するための多数の可能性を提供します。 記事の過程で、プログラマーの武器にどのツールが含まれているかを把握し、提案された主題領域にDSLを実装します。







この記事で紹介する構文全体について、できるだけ簡単に説明しますが、この資料は、Kotlinを問題指向言語を構築するための言語と見なすエンジニアを練習するために設計されています。 記事の終わりに、準備する必要がある欠点が示されます。 この記事で使用されているコードは、Kotlinバージョン1.1.4-3に関連しており、GitHubで入手できます。













DSLとは何ですか?



プログラミング言語は、汎用プログラミング言語とドメイン固有言語の2種類に分類できます。 DSLの一般的な例は、SQL、正規表現、build.gradleです。 この言語は提供される機能の量を減らしますが、同時に特定の問題を効果的に解決することができます。 これは、プログラムを命令型スタイル(結果を取得する方法)ではなく、宣言型または宣言型に近い(現在のタスクを記述する)で記述する方法です。この場合、問題の解決策は与えられた情報に基づいて得られます。







標準の実行プロセスがあり、それを変更したり変更したりできる場合がありますが、一般的には異なるデータや結果の形式でそれを使用したいとします。 DSLを作成することにより、DSLのエンドユーザーはソリューションがどのように得られるかを考えずに、同じ主題分野からさまざまな問題を解決するための柔軟なツールを作成します。 これはいくつかのAPIであり、それらを巧みに使用して、人生と長期的なシステムサポートを大幅に簡素化できます。







この記事では、Kotlin言語で「内部」DSLを構築することを検討しました。 この種の問題指向言語は、汎用言語の構文に基づいて実装されています。 詳細については、 こちらをご覧ください







応用分野



私の意見では、Kotlin DSLを適用して実証する最良の方法の1つは、テストによるものです。







Javaの世界から来たとします。 かなり大規模なデータモデルのために、標準エンティティインスタンスを何度も何度も記述する必要がありましたか? このために、いくつかのビルダー、またはさらに悪いことに、内部のデフォルト値を埋める特別なユーティリティクラスを使用した可能性がありますか? オーバーロードされたメソッドはいくつありますか? どのくらいの頻度でデフォルト値から「やや」逸脱する必要がありますか。また、今どのくらいの作業をしなければなりませんか? これらの質問が否定性以外の何ものでもない場合は、正しい記事を読んでいます。







同様に、教育分野に特化した私たちのプロジェクトでは、ビルダーとユーティリティクラスの助けを借りて、システムの最も重要なモジュールの1つであるカリキュラムを構築するモジュールをテストでカバーしました。 このアプローチは、計画および検証システムのさまざまなアプリケーションの形成のために、Kotlin言語とDSLに置き換えられました。 以下に、この言語をどのように活用し、計画サブシステムのテストの開発を拷問から喜びに変えた例を示します。







この記事では、生徒と教師との間のクラス計画の小規模なデモンストレーションシステムをテストするためのDSLの設計について説明します。







主な機能



Kotlinの主な利点をリストしましょう。これにより、この言語でかなりきれいに記述でき、独自のDSLを構築することができます。 以下は、使用する価値のある主要な言語構文の改善点を示した表です。 このリストを注意深く確認してください。 ほとんどのデザインがよく知らない場合は、順番に読むことをお勧めします。 ただし、1つまたは2つのポイントに慣れていない場合は、それらに直接アクセスできます。 ここですべてがおなじみであれば、記事の最後でDSLを使用することの不利な点のレビューに行くことができます。 このリストを補足したい場合は、コメントにオプションを記入してください。







機能名 DSL構文 通常の構文
演算子のオーバーライド collection += element



collection.add(element)



タイプエイリアス typealias Point = Pair<Int, Int>



空の相続人クラスおよびその他の松葉杖を作成する
get / setメソッドの合意 map["key"] = "value"



map.put("key", "value")



複数の宣言 val (x, y) = Point(0, 0)



val p = Point(0, 0); val x = p.first; val y = p.second



カッコ外のラムダ list.forEach { ... }



list.forEach({...})



拡張機能 mylist.first(); // first() mylist



ユーティリティ関数
挿入機能 1 to "one"



1.to("one")



ハンドラー付きラムダ Person().apply { name = «John» }



いや
コンテキスト制御 @DslMarker



いや


何か新しいことを見つけましたか? それでは続けましょう。







この表では、委任されたプロパティは意図的に省略されています。これは、私の意見では、DSLが検討する形式でDSLを構築するのに役立たないからです。 これらの機能のおかげで、コードクリーナーを記述し、多くの「ノイズの多い」構文を取り除き、同時に開発をさらに楽しくすることができます(「どこがもっといい?」 Kotlin in Actionという本の自然言語での比較が気に入りました。たとえば、英語では、文は単語から構築され、文法規則は単語をどのように組み合わせるかを制御します。 DSLでも同様に、1つの操作を複数のメソッド呼び出しで構成でき、型チェックにより、構造が意味をなすようになります。 当然、呼び出しの順序は必ずしも明らかではないかもしれませんが、これはDSL設計者の良心に残ります。







この記事では、「内部DSL」、つまり 問題指向言語は、ユニバーサル言語-Kotlinに基づいています。







最終結果の例



問題指向言語の構築を始める前に、記事を読んだ後に構築できるものの結果を示したいと思います。 GitHubリポジトリにあるすべてのコードは、 こちらにあります 。 以下は、興味のある科目の生徒の教師検索をテストするためのDSLです。 この例では、固定のタイムグリッドがあり、クラスが教師と生徒の計画に同時に配置されていることを確認します。







 schedule { data { startFrom("08:00") subjects("Russian", "Literature", "Algebra", "Geometry") student { name = "Ivanov" subjectIndexes(0, 2) } student { name = "Petrov" subjectIndexes(1, 3) } teacher { subjectIndexes(0, 1) availability { monday("08:00") wednesday("09:00", "16:00") } } teacher { subjectIndexes(2, 3) availability { thursday("08:00") + sameDay("11:00") + sameDay("14:00") } } // data { } doesn't be compiled here because there is scope control with // @DataContextMarker } assertions { for ((day, lesson, student, teacher) in scheduledEvents) { val teacherSchedule: Schedule = teacher.schedule teacherSchedule[day, lesson] shouldNotEqual null teacherSchedule[day, lesson]!!.student shouldEqual student val studentSchedule = student.schedule studentSchedule[day, lesson] shouldNotEqual null studentSchedule[day, lesson]!!.teacher shouldEqual teacher } } }
      
      





ツール



DSLを構築するためのツールの完全なリストは上記にありました。 これらはそれぞれ例で使用されており、 参照によりコードを調べることにより、そのような構造の構築を調べることができます。 この例に何度も戻って、さまざまなツールのデモを行います。 DSLを構築する決定は本質的に実証的であることに注意することが重要です。自分のプロジェクトで見たことを繰り返すことができますが、これは提示されたオプションが唯一の真の選択肢であることを意味しません。 以下では、各ツールについて詳しく調べます。







この言語の一部の機能は他の機能との組み合わせで特に優れており、このリストの最初のツールは括弧の外側のラムダです。







カッコ外のラムダ



ドキュメント







ラムダ式またはラムダは、関数に渡したり、保存したり、呼び出したりできるコードのブロックです。 Kotlinでは、ラムダ型は次のように示されます( ) ->



。 この規則に従うと、ラムダの最も原始的な形式は() -> Unit



で、Unitは1つの例外を除いてVoidの類似物です。 ラムダまたは関数の終わりでは

「return ...」コンストラクトを記述する必要があります。 このため、常に戻り値の型があります。Kotlinではこれは暗黙的に行われます。







以下は、ラムダを変数に保存する最も簡単な例です。







 val helloPrint: (String) -> Unit = { println(it) }
      
      





パラメータのないラムダの場合、コンパイラは既知の型から独立して型を派生できます。 ただし、この場合、1つのパラメーターがあります。 このようなラムダの呼び出しは次のとおりです。







 helloPrint("Hello")
      
      





上記の例では、lambdaは1つのパラメーターを取ります。 ラムダ内では、このパラメーターはデフォルトで「it」という名前になっていますが、複数のパラメーターがある場合は、名前を明示的にリストするか、アンダースコア「_」を使用して無視する必要があります。 次の例は、この動作を示しています。







 val helloPrint: (String, Int) -> Unit = { _, _ -> println("Do nothing") } helloPrint("Does not matter", 42) //output: Do nothing
      
      





たとえば、Groovyですでに見た基本的なツールは、括弧の外側のラムダです。 記事の冒頭の例に注意してください。標準設計を除き、ほぼすべての中括弧の使用はラムダの使用です。 x { … }



形式を作成するには、少なくとも2つの方法があります。









オプションに関係なく、ラムダを使用します。 関数x()



ます。 Kotlin言語では、次のルールが適用されます。ラムダが関数の最後の引数である場合、角括弧から外すことができ、ラムダが唯一のパラメーターである場合、角括弧を書くことはできません。 その結果、構成x({…})



x() {}



に変換できます。その後、括弧を削除すると、 x {}



得られます。 このような関数の宣言は次のとおりです。







 fun x( lambda: () -> Unit ) { lambda() }
      
      





または単一行関数の省略形で、これを書くことができます:







 fun x( lambda: () -> Unit ) = lambda()
      
      





しかし、xがクラスではなく、オブジェクトであり、関数ではない場合はどうでしょうか? 別の興味深い解決策があります。これは、問題指向言語の構築、演算子の再定義で使用される基本概念の1つに基づいています。 このツールを見てみましょう。







演算子のオーバーライド



ドキュメント







Kotlinは、広くはあるが限られた範囲の演算子を提供します。 演算子修飾子を使用すると、特定の条件下で呼び出される規則によって関数を定義できます。 明らかな例は、2つのオブジェクト間で+演算子を使用するときに実行されるplus関数です。 上記のドキュメントのリンクで、演算子の完全なリストを見つけることができます。







少しささいな呼び出し演算子を考えてください。 この記事の主な例は、schedule {}構造から始まります。 設計の目的は、計画のテストを担当するコードブロックを分離することです。 このような構成を構築するには、上記で検討したものとは少し異なるメソッドが使用されます。invoke+ "lambda out of brackets"演算子です。 invokeオペレーターを定義すると、スケジュール(...)構造が使用可能になりますが、スケジュールはオブジェクトです。 実際、スケジュール(...)呼び出しは、コンパイラによってschedule.invoke(...)として解釈されます。 スケジュールの宣言を見てみましょう。







 object schedule { operator fun invoke(init: SchedulingContext.() -> Unit) { SchedulingContext().init() } }
      
      





スケジュール識別子は、特別なキーワードオブジェクトでマークされたスケジュールクラス(シングルトン)の唯一のインスタンスに送信されることを理解する必要があります(このようなオブジェクトの詳細については、 こちらを参照してください )。 したがって、スケジュールインスタンスでinvokeメソッドを呼び出します。この場合、メソッドへの唯一のパラメーターはラムダであり、これを角かっこで囲みます。 その結果、schedule {...}コンストラクトは次と同等です。







 schedule.invoke( {    } )
      
      





ただし、invokeメソッドをよく見ると、通常のラムダではなく、「ハンドラー付きラムダ」または「コンテキスト付きラムダ」が表示され、そのタイプは次のように記述されます: SchedulingContext.() -> Unit





それが何であるかを理解する時です。







ハンドラー付きラムダ



ドキュメント







Kotlinは、ラムダ式のコンテキストを設定する機会を与えてくれます。 コンテキストは通常​​のオブジェクトです。 コンテキストのタイプは、ラムダ式のタイプとともに決定されます。 このようなラムダは、コンテキストクラスの非静的メソッドのプロパティを取得しますが、このクラスのパブリックAPIのみにアクセスできます。

通常のラムダのタイプは次のように定義されます: () -> Unit



、タイプXのコンテキストを持つラムダのタイプは次のように定義されます: X.()-> Unit



、および最初のタイプのラムダを通常の方法で開始できる場合:







 val x : () -> Unit = {} x()
      
      





次に、コンテキストを持つラムダにはコンテキストが必要です:







 class MyContext val x : MyContext.() -> Unit = {} //x() // , ..    val c = MyContext() //  cx() //  x(c) //  
      
      





スケジュールオブジェクトでinvoke演算子を定義したことを思い出させてください(前の段落を参照)。これにより、構造を使用できます。







 schedule { }
      
      





使用するラムダには、SchedulingContextのようなコンテキストがあります。 データメソッドはこのクラスで定義されます。 その結果、次の構造が得られます。







 schedule { data { //... } }
      
      





ご想像のとおり、データメソッドはコンテキストを持つラムダを使用しますが、コンテキストは既に異なります。 したがって、複数のコンテキストが同時に使用できる入れ子構造を取得します。







この例がどのように機能するかを詳細に理解するために、すべての構文糖を削除しましょう。







 schedule.invoke({ this.data({ }) })
      
      





ご覧のとおり、すべてが非常に簡単です。

invokeステートメントの実装を見てみましょう。







 operator fun invoke(init: SchedulingContext.() -> Unit) { SchedulingContext().init() }
      
      





コンテキストのコンストラクターSchedulingContext()



)を呼び出し、作成されたオブジェクト(コンテキスト)で、パラメーターとして渡した識別子initでラムダを呼び出します。 これは、通常の関数の呼び出しに非常に似ています。 その結果、 SchedulingContext().init()



1行で、コンテキストを作成し、オペレーターに渡されたラムダを呼び出します。 他の例に興味がある場合は、適用に注意し、Kotlin標準ライブラリのメソッドを使用してください。







最後の例では、invoke演算子と、他のツールとの相互作用を調べました。 次に、正式には演算子であり、コードを簡潔にする別のツール、つまりget / setメソッドの規則に焦点を当てます。







get / setメソッドの合意



ドキュメント







DSLを開発するとき、1つ以上のキーを使用して連想配列にアクセスするための構文を実装できます。 以下の例を見てください。







 availabilityTable[DayOfWeek.MONDAY, 0] = true println(availabilityTable[DayOfWeek.MONDAY, 0]) //output: true
      
      





角括弧を使用するには、演算子修飾子を使用して、必要なもの(読み取りまたは書き込み)に応じてgetまたはsetメソッドを実装する必要があります。 このツールの実装例は、 リンクの GitHubのMatrixクラスにあります。 これは、マトリックスを操作するためのラッパーの最も単純な実装です。 以下は、興味のあるコードの一部です。







 class Matrix(...) { private val content: List<MutableList<T>> operator fun get(i: Int, j: Int) = content[i][j] operator fun set(i: Int, j: Int, value: T) { content[i][j] = value } }
      
      





getおよびset関数のパラメータータイプは、想像力によってのみ制限されます。 get / set関数に1つまたは複数のパラメーターを使用して、データにアクセスするための快適な構文を提供できます。 Kotlinのオペレーターは、ドキュメントに記載されている多くの興味深い機能を提供します。







驚いたことに、Kotlin標準ライブラリにはPairクラスがありますが、なぜですか? コミュニティの大部分は、Pairクラスが悪いと考えています。これにより、2つのオブジェクトの接続の意味がなくなり、それらがペアになっている理由が明らかになりません。 次の2つのツールは、カップルの意味を保存し、余分なクラスを作成しない方法を示しています。







タイプエイリアス



ドキュメント







整数座標を持つ平面上の点にラッパークラスが必要だと想像してください。 原則として、 Pair<Int, Int>



クラスは私たちに適していますが、このタイプの変数では、値をペアでバインドする理由の理解が一時的に失われる場合があります。 明らかな修正は、独自のクラスを作成するか、さらに悪いことです。 Kotlinでは、開発者の兵器庫には、次のように記述されたタイプエイリアスが補充されます。







 typealias Point = Pair<Int,Int>
      
      





実際、これはコンストラクトの通常の名前変更です。 このアプローチのおかげで、Pointクラスを作成する必要はありません。この場合、単純にカップルを複製します。 これで、次のようにポイントを作成できます。







 val point = Point(0, 0)
      
      





ただし、Pairクラスには2つのプロパティがあります。1つ目と2つ目です。これらのプロパティの名前を変更して、目的のPointクラスとPairクラスの違いを消去するにはどうすればよいでしょうか。 プロパティ自体の名前を変更することはできませんが、ツールキットには、職人が複数の宣言として指定した素晴らしい機会があります。







複数宣言(破壊宣言)



ドキュメント







例の理解を簡単にするために、状況を考えてみましょう。上記の例からわかるように、Point型のオブジェクトがあるため、これは名前が変更されたPair<Int, Int>



型にすぎません。 標準ライブラリのPairクラスの実装からわかるように、データ修飾子でマークされています。これは、とりわけ、このクラスで生成されたcomponentNメソッドを取得することを意味します。 それらについて話しましょう。







どのクラスに対しても、コンポーネントの1つのプロパティへのアクセスを提供するcomponentN演算子を定義できます。 これは、point.component1の呼び出しがpoint.firstの呼び出しと同等であることを意味します。 次に、この複製が必要な理由を見てみましょう。







複数宣言とは何ですか? これは、オブジェクトを変数に「分解」する方法です。 この機能のおかげで、次の構成を記述できます。







 val (x, y) = Point(0, 0)
      
      





一度に複数の変数を宣言する機会がありますが、値としてはどうなりますか? このため、シリアル番号に応じてcomponentN



の生成されたメソッドが必要になります。Nの代わりに、1から開始して、オブジェクトをそのプロパティのセットに分解できます。 したがって、たとえば、上記のエントリは次と同等です。







 val pair = Point(0, 0) val x = pair.component1() val y = pair.component2()
      
      





これは次と同等です:







 val pair = Point(0, 0) val x = pair.first val y = pair.second
      
      





ここで、firstとsecondはPointオブジェクトのプロパティです。







Kotlinのforコンストラクトの形式は次のとおりです。xは値1、2、および3を順番に受け取ります。







 for(x in listOf(1, 2, 3)) { … }
      
      





主な例のDSLのassertions



ブロックに注意してください。 便宜上、その一部を以下に示します。







 for ((day, lesson, student, teacher) in scheduledEvents) { … }
      
      





これですべてが明らかになります。 scheduleEventsコレクションを反復処理します。各要素は、現在のオブジェクトを記述する4つのプロパティに分解されます。







拡張機能



ドキュメント







サードパーティライブラリのオブジェクトに独自のメソッドを追加したり、Java Collection Frameworkにメソッドを追加したりすることは、多くの開発者にとって長年の夢です。 そして今、私たち全員にそのような機会があります。 拡張関数の宣言は次のとおりです。







 fun AvailabilityTable.monday(from: String, to: String? = null)
      
      





通常のメソッドとは異なり、メソッド名の前にクラス名を追加して、どのクラスを展開するかを示します。 この例では、 AvailabilityTable



はMatrixタイプのエイリアスであり、Kotlinのエイリアスは名前の変更にすぎないため、そのような宣言は以下の例の宣言と同等であり、必ずしも便利ではありません。





 fun Matrix<Boolean>.monday(from: String, to: String? = null)
      
      





しかし、残念ながら、ツールを使用しないか、特定のコンテキストクラスにのみメソッドを追加することを除いて、何もできません。 そして、魔法は必要な場所にのみ現れます。 さらに、これらの機能を使用してインターフェースを拡張することもできます。 良い例は、次のようにIterableオブジェクトを拡張する最初のメソッドです。







 fun <T> Iterable<T>.first(): T
      
      





その結果、要素のタイプに関係なく、Iterableインターフェイスに基づくコレクションは最初のメソッドを取得します。 興味深いのは、拡張メソッドをコンテキストクラスに配置できるため、特定のコンテキストでのみ拡張メソッドにアクセスできることです(上記のコンテキストのラムダを参照)。 さらに、Nullable型の拡張関数も作成できます(Nullable型の説明は記事の範囲外ですが、必要に応じてここで読むことができます)。 たとえば、CharSequence?タイプを拡張するKotlin標準ライブラリのisNullOrEmpty関数は、次のように使用できます。







 val s: String? = null s.isNullOrEmpty() //true
      
      





この関数のシグネチャは次のとおりです。







 fun CharSequence?.isNullOrEmpty(): Boolean
      
      





このようなKotlin関数を使用してJavaから作業する場合、拡張関数は静的として使用できます。







挿入機能



ドキュメント







構文を甘くする別の方法は、中置関数を使用することです。 簡単に言えば、このツールのおかげで、単純な状況で不必要なコードノイズを取り除くことができました。

メインサンプル記事のassertions



ブロックは、このツールの使用方法を示しています。







 teacherSchedule[day, lesson] shouldNotEqual null
      
      





この設計は次と同等です。







 teacherSchedule[day, lesson].shouldNotEqual(null)
      
      





ブラケットとドットが冗長な場合があります。 この場合、関数に中置修飾子が必要です。

上記のコードでは、 teacherSchedule[day, lesson]



コンストラクトはスケジュールアイテムを返し、 shouldNotEqual



関数はアイテムがnullでないことを確認します。







そのような関数を宣言するには、以下を行う必要があります。









以下のコードのように、最後の2つのツールを組み合わせることができます。







 infix fun <T : Any?> T.shouldNotEqual(expected: T)
      
      





デフォルトのジェネリック型は(Nullable型階層ではなく)Any子孫ですが、そのような場合はnullを使用できないため、Any?型を明示的に指定する必要があります。







コンテキスト制御



ドキュメント







多くのネストされたコンテキストを使用すると、非常に低いレベルで爆発的な混合物が発生するため、たとえば制御なしで、次の構成が意味をなさない場合があります。







 schedule { // SchedulingContext data { // DataContext +   SchedulingContext data { } // -    } }
      
      





Kotlin 1.1より前には、これを回避する方法がすでにありました。 DataContextのネストされたコンテキストで独自のデータメソッドを作成し、ERRORレベルのDeprecatedアノテーションでマークします。







 class DataContext { @Deprecated(level = DeprecationLevel.ERROR, message = "Incorrect context") fun data(init: DataContext.() -> Unit) {} }
      
      





このアプローチのおかげで、無効なDSLビルドの可能性を排除できました。 ただし、SchedulingContextの多数のメソッドでは、一定量のルーチン作業が行われたため、コンテキストを制御するすべての欲求が抑えられました。







Kotlin 1.1は、新しい制御ツール-@DslMarkerアノテーションを導入します。 これは、独自の注釈に適用されます。注釈は、コンテキストをマークするために必要です。 注釈を作成しましょう。注釈は、武器庫にある新しいツールを使用してマークします。







 @DslMarker annotation class MyCustomDslMarker
      
      





次に、コンテキストにラベルを付ける必要があります。 主な例では、これらはSchedulingContextとDataContextです。 各クラスを単一のDSLマーカーでマークするという事実により、次のことが起こります。







 @MyCustomDslMarker class SchedulingContext { ... } @MyCustomDslMarker class DataContext { ... } fun demo() { schedule { // SchedulingContext data { // DataContext +     SchedulingContext // data { } // , ..    DSL  } } }
      
      





多くの時間と労力を削減するこのようなアプローチのすべての喜びにもかかわらず、1つの問題が残っています。 メインの例に注目すると、次のコードが表示されます。







 schedule { data { student { name = "Petrov" } ... } }
      
      





Student, , , , , @MyCustomDslMarker , , , .







Student data {}



, .. DataContext , :







 schedule { data { student { student { } } } }
      
      





, , . :







  1. , , StudentContext. @DslMarker.
  2. , , IStudent ( ), -, , , .

     @MyCustomDslMarker class StudentContext(val owner: Student = Student()): IStudent by owner
          
          



  3. @Deprecated, . , , , .

    deprecated extension Identifiable .


 @Deprecated("Incorrect context", level = DeprecationLevel.ERROR) fun Identifiable.student(init: () -> Unit) {}
      
      





, , DSL .







DSL



DSL Kotlin , DSL .







DSL



, DSL, . DSL extension , .







, , : " callback'", DSL, . , , . , DSL , .







This, it!?



this it DSL. - it, , , , . , .







, . " " DSL. , , , val mainContext = this











. . , , , " ", . , DSL, , , DSL , - . DSL (, ), , .. .







, ?



- DSL, : " ?". . DSL, , . , . , .. - : " , ?" , , .







おわりに



, - . , .







, - , , . , DSL . , , .







" ", , DSL , , , , .

- !








All Articles