Programcılar sürekli olarak hangi dilin en iyi olduğu konusunda tartışıyorlar. Bir keresinde C ile Pascal'ı karşılaştırmıştık ama zaman geçti. Python / Ruby ve Java /C# savaşları artık geride kaldı. Her dilin artıları ve eksileri vardır, bu yüzden onları karşılaştırıyoruz. İdeal olarak, dilleri kendi ihtiyaçlarımıza uyacak şekilde genişletmek isteriz. Programcılar bu fırsata çok uzun zamandır sahip. Metaprogramlamanın, yani program oluşturmak için program oluşturmanın farklı yollarını biliyoruz. C'deki önemsiz makrolar bile küçük açıklamalardan büyük kod parçaları oluşturmanıza olanak tanır. Ancak bu makrolar güvenilmezdir, sınırlıdır ve pek anlamlı değildir. Modern diller çok daha anlamlı genişleme araçlarına sahiptir. Bu dillerden biri de Kotlin'dir.
Etki alanına özgü dil (DSL), Java, C#, C++ ve diğerleri gibi genel amaçlı dillerin aksine, belirli bir konu alanı için özel olarak geliştirilmiş bir dildir. Bu, konu alanındaki görevleri tanımlamanın daha kolay, daha kullanışlı ve daha anlamlı olduğu anlamına gelir, ancak aynı zamanda günlük görevleri çözmek için uygun değildir ve pratik değildir, yani evrensel bir dil değildir. DSL'de normal ifade dilini kullanabilirsiniz. Düzenli ifadelerin konu alanı dizelerin biçimidir.
Dizenin formata uygunluğunu kontrol etmek için, normal ifadeler için destek uygulayan bir kitaplığın kullanılması yeterlidir:
private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }
Dizenin evrensel bir dilde, örneğin Java'da belirtilen formata uygunluğunu kontrol ederseniz, aşağıdaki kodu alırsınız:
private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }
Yukarıdaki kodu okumak normal ifadelere göre daha zordur, hata yapmak daha kolaydır ve değişiklik yapmak daha zordur.
DSL'in diğer yaygın örnekleri HTML, CSS, SQL, UML ve BPMN'dir (son ikisi grafiksel gösterim kullanır). DSL'ler yalnızca geliştiriciler tarafından değil aynı zamanda test uzmanları ve BT dışı uzmanlar tarafından da kullanılır.
DSL'ler iki türe ayrılır: harici ve dahili. Harici DSL dillerinin kendi sözdizimleri vardır ve desteklerinin uygulandığı evrensel programlama diline bağlı değildirler.
Harici DSL'lerin artıları ve eksileri:
🟢 Farklı dillerde / hazır kütüphanelerde kod üretimi
🟢 Sözdiziminizi ayarlamak için daha fazla seçenek
🔴 Özel araçların kullanımı: ANTLR, yacc, lex
🔴 Bazen dil bilgisini anlatmak zordur
🔴IDE desteği yoktur eklentilerinizi yazmanız gerekmektedir
Dahili DSL'ler belirli bir evrensel programlama dilini (ana bilgisayar dili) temel alır. Yani, ana dilin standart araçlarının yardımıyla, daha derli toplu yazmanıza olanak tanıyan kütüphaneler oluşturulur. Örnek olarak Fluent API yaklaşımını düşünün.
Dahili DSL'lerin artıları ve eksileri:
🟢 Ana dilin ifadelerini temel alır
🟢 DSL'i ana dillerdeki koda yerleştirmek kolaydır ve bunun tersi de geçerlidir
🟢 Kod oluşturma gerektirmez
🟢 Ana dilde alt program olarak hata ayıklanabilir
🔴 Sözdizimini ayarlamada sınırlı olanaklar
Son zamanlarda şirket olarak DSL'imizi oluşturma ihtiyacıyla karşı karşıya kaldık. Ürünümüz satın alma kabulü işlevini hayata geçirmiştir. Bu modül BPM'nin (İş Süreçleri Yönetimi) mini motorudur. İş süreçleri genellikle grafiksel olarak temsil edilir. Örneğin, aşağıdaki BPMN gösterimi, Görev 1'in yürütülmesinden ve ardından Görev 2 ile Görev 3'ün paralel olarak yürütülmesinden oluşan bir süreci göstermektedir.
Dinamik olarak rota oluşturmak, onay aşamaları için uygulayıcıları ayarlamak, aşamanın yürütülmesi için son tarihi belirlemek vb. dahil olmak üzere iş süreçlerini programlı bir şekilde oluşturabilmek bizim için önemliydi. Bunu yapmak için öncelikle bu sorunu Fluent API kullanarak çözmeye çalıştık. yaklaşmak.
Daha sonra Fluent API'yi kullanarak kabul rotaları belirlemenin hala zahmetli olduğu sonucuna vardık ve ekibimiz kendi DSL'sini oluşturma seçeneğini değerlendirdi. Harici bir DSL ve Kotlin tabanlı dahili bir DSL'de kabul yolunun nasıl görüneceğini araştırdık (çünkü ürün kodumuz Java ve Kotlin ile yazılmıştır).
Harici DSL:
acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed
Dahili DSL:
acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }
Kıvrımlı parantezlerin dışında her iki seçenek de hemen hemen aynıdır. Bu nedenle harici bir DSL geliştirmek için zaman ve çaba harcamamaya, dahili bir DSL oluşturmaya karar verildi.
Bir nesne modeli geliştirmeye başlayalım
interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }
Öncelikle AcceptanceContext
sınıfına bakalım. Rota elemanlarının bir koleksiyonunu depolamak için tasarlanmıştır ve parallel
blokların yanı sıra tüm diyagramı temsil etmek için kullanılır.
addStep
ve parallel
yöntemler, parametre olarak alıcılı bir lambda alır.
Alıcılı bir lambda, belirli bir alıcı nesnesine erişimi olan bir lambda ifadesini tanımlamanın bir yoludur. İşlev değişmezinin gövdesi içinde, bir çağrıya iletilen alıcı nesnesi örtük bir this
haline gelir, böylece bu alıcı nesnesinin üyelerine herhangi bir ek niteleyici olmadan erişebilir veya alıcı nesnesine this
ifadesini kullanarak erişebilirsiniz.
Ayrıca, eğer bir yöntem çağrısının son argümanı lambda ise lambda parantezlerin dışına yerleştirilebilir. Bu nedenle DSL'imizde aşağıdaki gibi bir kod yazabiliriz:
parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }
Bu, sözdizimsel şeker içermeyen bir koda eşdeğerdir:
parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })
Alıcıları olan Lambdalar ve parantezlerin dışındaki Lambdalar, DSL'lerle çalışırken özellikle yararlı olan Kotlin özellikleridir.
Şimdi varlık acceptance
bakalım. acceptance
bir nesnedir. Kotlin'de nesne bildirimi, tek bir örneği olan bir sınıf olan singleton'u tanımlamanın bir yoludur. Yani nesne bildirimi hem sınıfı hem de onun tek örneğini aynı anda tanımlar.
Ayrıca, accreditation
nesnesi için invoke
operatörü aşırı yüklenmiştir. invoke
operatörü, sınıflarınızda tanımlayabileceğiniz özel bir fonksiyondur. Bir sınıfın örneğini bir işlevmiş gibi çağırdığınızda, invoke
operatörü işlevi çağrılır. Bu, nesneleri işlev olarak ele almanıza ve onları işlev benzeri bir şekilde çağırmanıza olanak tanır.
invoke
yönteminin parametresinin de alıcılı bir lambda olduğuna dikkat edin. Artık bir kabul rotası tanımlayabiliriz…
val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }
…ve onun üzerinden yürüyün
val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext
Bu koda bir göz atın
addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }
Bunu aşağıdaki yöntemlerle uygulayabiliriz:
enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)
ExecutorCondition
sınıfı birkaç olası görev yürütücüsü ayarlamamıza olanak tanır. ExecutorCondition
infix işlevini or
. Bir infix işlevi, onu daha doğal bir infix gösterimi kullanarak çağırmanıza olanak tanıyan özel bir işlev türüdür.
Dilin bu özelliğini kullanmasaydık şu şekilde yazmamız gerekirdi:
addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }
Infix işlevleri aynı zamanda protokolün gerekli durumunu ve saat dilimini ayarlamak için de kullanılır.
enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }
String.timezone
bir uzantı işlevidir. Kotlin'de eklenti işlevleri, mevcut sınıflara kaynak kodlarını değiştirmeden yeni işlevler eklemenizi sağlar. Bu özellik, standart veya harici kitaplıklardaki sınıflar gibi, üzerinde kontrol sahibi olmadığınız sınıfların işlevselliğini artırmak istediğinizde özellikle kullanışlıdır.
DSL'de kullanım:
addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }
Burada "2022-12-08 08:00"
, üzerinde genişletme işlevi timezone
çağrıldığı bir alıcı nesnesidir ve PST
parametredir. Alıcı nesnesine this
anahtar sözcük kullanılarak erişilir.
DSL'imizde kullandığımız bir sonraki Kotlin özelliği operatör aşırı yüklemesidir. invoke
operatörünün aşırı yükünü zaten değerlendirdik. Kotlin'de aritmetik olanlar da dahil olmak üzere diğer operatörlere aşırı yükleme yapabilirsiniz.
addStep { ... +canChange }
Burada tekli operatör +
aşırı yüklenmiştir. Bunu uygulayan kod aşağıdadır:
class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }
Artık DSL'imizde kabul yollarını tanımlayabiliriz. Ancak DSL kullanıcılarının olası hatalardan korunması gerekmektedir. Örneğin, mevcut sürümde aşağıdaki kod kabul edilebilir:
val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }
addStep
içindeki addStep
tuhaf görünüyor, değil mi? Bu kodun neden hatasız bir şekilde başarıyla derlendiğini bulalım. Yukarıda bahsedildiği gibi, acceptance#invoke
ve AcceptanceContext#addStep
yöntemleri parametre olarak alıcı içeren bir lambda alır ve alıcı nesnesine this
anahtar sözcüğüyle erişilebilir. Böylece önceki kodu şu şekilde yeniden yazabiliriz:
val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }
Artık [email protected]
iki seferde de çağrıldığını görebilirsiniz. Özellikle bu gibi durumlar için Kotlin'in DslMarker
açıklaması bulunmaktadır. Özel ek açıklamalar tanımlamak için @DslMarker
kullanabilirsiniz. Aynı açıklamayla işaretlenen alıcılara birbirlerinin içinden erişilemez.
@DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }
Şimdi kod
val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }
'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary
Aşağıda, bu makalede dikkate alınan dil özelliklerine ilişkin resmi Kotlin belgelerine bağlantılar bulunmaktadır:
Etki alanına özgü diller, belirli bir etki alanındaki sorunları modellemek ve çözmek için özelleştirilmiş ve etkileyici bir yol sağlayarak üretkenliği artırmak, hataları azaltmak ve işbirliğini geliştirmek için güçlü bir araç sunar. Kotlin birçok özelliği ve sözdizimsel yapısıyla modern bir programlama dilidir, dolayısıyla dahili DSL için ana dil olarak mükemmeldir.