Scala Makroları ve Quasiquotes ile Ortak Kod Kodunu Azaltın

Yayınlanan: 2022-03-11

Scala dili, geliştiricilere temiz ve özlü bir sözdiziminde (örneğin Java ile karşılaştırıldığında) nesne yönelimli ve işlevsel kod yazma fırsatı sunar. Vaka sınıfları, üst düzey işlevler ve tür çıkarımı, Scala geliştiricilerinin bakımı daha kolay ve daha az hataya açık kod yazmak için kullanabileceği özelliklerden bazılarıdır.

Ne yazık ki, Scala kodu genel kullanıma karşı bağışık değildir ve geliştiriciler bu kodu yeniden düzenlemenin ve yeniden kullanmanın bir yolunu bulmakta zorlanabilirler. Örneğin, bazı kitaplıklar, geliştiricileri, kapalı bir sınıfın her bir alt sınıfı için bir API çağırarak kendilerini tekrar etmeye zorlar.

Ancak bu yalnızca geliştiriciler, derleme zamanında tekrarlanan kodu oluşturmak için makrolardan ve yarı alıntılardan nasıl yararlanacaklarını öğrenene kadar geçerlidir.

Kullanım Örneği: Bir Üst Sınıfın Tüm Alt Türleri için Aynı İşleyiciyi Kaydetme

Bir mikro hizmet sisteminin geliştirilmesi sırasında, belirli bir sınıftan türetilen tüm olaylar için tek bir işleyici kaydetmek istedim. Kullanmakta olduğum çerçevenin özellikleriyle dikkatimizi dağıtmaktan kaçınmak için, olay işleyicileri kaydetmeye yönelik API'sinin basitleştirilmiş bir tanımı aşağıda verilmiştir:

 trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) }

Herhangi bir Event türü için bir Event işlemcisine sahip olarak, AddHandler yöntemiyle addHandler alt sınıfları için işleyicileri kaydedebiliriz.

Yukarıdaki imzaya bakıldığında, bir geliştirici, belirli bir tür için kayıtlı bir işleyicinin, alt türlerinin olayları için çağrılmasını bekleyebilir. Örneğin, User varlık yaşam döngüsünde yer alan olayların aşağıdaki sınıf hiyerarşisini ele alalım:

UserEvent'ten inen Scala olaylarının hiyerarşisi. Üç doğrudan torun vardır: UserCreated (her ikisi de Dize olan bir ada ve e-postaya sahip), UserChanged ve UserDeleted. Ayrıca, UserChanged'ın kendine ait iki torunu vardır: NameChanged (bir dize olan bir ada sahip olmak) ve EmailChanged (bir dize olan bir e-postaya sahip olmak).
Bir Scala olay sınıfı hiyerarşisi.

Karşılık gelen Scala bildirimleri şöyle görünür:

 sealed trait UserEvent final case class UserCreated(name: String, email: String) extends UserEvent sealed trait UserChanged extends UserEvent final case class NameChanged(name: String) extends UserChanged final case class EmailChanged(email: String) extends UserChanged case object UserDeleted extends UserEvent

Her belirli olay sınıfı için bir işleyici kaydedebiliriz. Peki ya tüm olay sınıfları için bir işleyici kaydetmek istiyorsak? İlk denemem, işleyiciyi UserEvent sınıfı için kaydettirmekti. Tüm olaylar için çağrılmasını bekledim.

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)

Testler sırasında işleyicinin hiçbir zaman çağrılmadığını fark ettim. Kullandığım çerçeve olan Lagom kodunu kazdım.

Olay işlemci uygulamasının işleyicileri anahtar olarak kayıtlı sınıfla bir haritada sakladığını buldum. Bir olay yayıldığında, işleyicinin aramasını sağlamak için o haritadaki sınıfını arar. Olay işlemcisi şu satırlar boyunca uygulanır:

 type Handler[Event] = (_ <: Event) => Unit private case class EventProcessorImpl[Event]( handlers: Map[Class[_ <: Event], List[Handler[Event]]] = Map[Class[_ <: Event], List[Handler[Event]]]() ) extends EventProcessor[Event] { override def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] = { val eventClass = implicitly[ClassTag[E]].runtimeClass.asInstanceOf[Class[_ <: Event]] val eventHandlers = handler .asInstanceOf[Handler[Event]] :: handlers.getOrElse(eventClass, List()) copy(handlers + (eventClass -> eventHandlers)) } override def process(event: Event): Unit = { handlers .get(event.getClass) .foreach(_.foreach(_.asInstanceOf[Event => Unit].apply(event))) } }

Yukarıda, UserEvent sınıfı için bir işleyici kaydettik, ancak UserCreated gibi türetilmiş bir olay yayıldığında, işlemci sınıfını kayıt defterinde bulamazdı.

Böylece Boyler Kodu Başlar

Çözüm, her somut olay sınıfı için aynı işleyiciyi kaydetmektir. Bunu şu şekilde yapabiliriz:

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)

Şimdi kod çalışıyor! Ama tekrar ediyor.

Her yeni etkinlik türü tanıttığımızda onu değiştirmemiz gerekeceğinden, bakımı da zordur. Kod tabanımızda tüm somut türleri listelemek zorunda kaldığımız başka yerlerimiz de olabilir. Ayrıca bu yerleri değiştirdiğimizden emin olmamız gerekir.

UserEvent mühürlü bir sınıf olduğu için bu hayal kırıklığı yaratıyor, yani tüm doğrudan alt sınıfları derleme zamanında biliniyor. Ya bu bilgiden, kalıptan kaçınmak için yararlanabilseydik?

Kurtarma Makroları

Normalde Scala işlevleri, çalışma zamanında onlara ilettiğimiz parametrelere göre bir değer döndürür. Scala makrolarını, derleme zamanında, çağrılarını değiştirmek için bazı kodlar üreten özel işlevler olarak düşünebilirsiniz.

macro arabirimi parametreleri parametre olarak alıyor gibi görünse de, uygulanması aslında bu parametrelerin soyut sözdizimi ağacını (AST) (derleyicinin kullandığı kaynak kod yapısının dahili temsili) yakalayacaktır. Daha sonra yeni bir AST oluşturmak için AST'yi kullanır. Son olarak, yeni AST, derleme zamanında makro çağrısını değiştirir.

Belirli bir sınıfın bilinen tüm alt sınıfları için olay işleyici kaydı oluşturacak bir macro bildirimine bakalım:

 def addHandlers[Event]( processor: EventProcessor[Event], handler: Event => Unit ): EventProcessor[Event] = macro setEventHandlers_impl[Event] def setEventHandlers_impl[Event: c.WeakTypeTag](c: Context)( processor: c.Expr[EventProcessor[Event]], handler: c.Expr[Event => Unit] ): c.Expr[EventProcessor[Event]] = { // implementation here }

Her parametre için (tür parametresi ve dönüş türü dahil), uygulama yönteminin parametre olarak karşılık gelen bir AST ifadesi olduğuna dikkat edin. Örneğin, c.Expr[EventProcessor[Event]] , EventProcessor[ EventProcessor[Event] ] ile eşleşir. c: Context parametresi, derleme bağlamını sarar. Derleme zamanında mevcut tüm bilgileri almak için kullanabiliriz.

Bizim durumumuzda, mühürlü sınıfımızın çocuklarını almak istiyoruz:

 import c.universe._ val symbol = weakTypeOf[Event].typeSymbol def subclasses(symbol: Symbol): List[Symbol] = { val children = symbol.asClass.knownDirectSubclasses.toList symbol :: children.flatMap(subclasses(_)) } val children = subclasses(symbol)

Dolaylı alt sınıfların da işlendiğinden emin olmak için subclasses yöntemine yapılan yinelemeli çağrıya dikkat edin.

Artık kaydedilecek olay sınıflarının listesine sahip olduğumuza göre, Scala makrosunun oluşturacağı kod için AST'yi oluşturabiliriz.

Scala Kodu Oluşturuluyor: AST'ler veya Quasiquotes?

AST'mizi oluşturmak için AST sınıflarını değiştirebilir veya Scala quasiquotes kullanabiliriz. AST sınıflarının kullanılması, okunması ve bakımı zor olan kodlar üretebilir. Buna karşılık, yarı alıntılar, oluşturulan koda çok benzeyen bir sözdizimi kullanmamıza izin vererek kodun karmaşıklığını önemli ölçüde azaltır.

Basitlik kazanımını göstermek için, a + 2 basit ifadesini alalım. Bunu AST sınıflarıyla oluşturmak şuna benzer:

 val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))

Aynısını, daha özlü ve okunabilir bir sözdizimi olan yarı alıntılarla da başarabiliriz:

 val exp = q"a + 2"

Makromuzu basit tutmak için quasiquotes kullanacağız.

AST'yi oluşturalım ve makro fonksiyonunun sonucu olarak geri döndürelim:

 val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls)

Yukarıdaki kod, parametre olarak alınan işlemci ifadesi ile başlar ve her Event alt sınıfı için, parametre olarak alt sınıf ve işleyici işleviyle addHandler yöntemine bir çağrı oluşturur.

Şimdi UserEvent sınıfındaki makroyu çağırabiliriz ve bu, tüm alt sınıflar için işleyiciyi kaydetmek için kodu üretecektir:

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)

Bu, bu kodu üretecektir:

 com.example.event.processor.EventProcessor .apply[com.example.event.handler.UserEvent]() .addHandler[UserEvent](handler) .addHandler[UserCreated](handler) .addHandler[UserChanged](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted](handler)

Tüm projenin kodu doğru bir şekilde derlenir ve test durumları, işleyicinin gerçekten UserEvent her alt sınıfı için kayıtlı olduğunu gösterir. Artık kodumuzun yeni olay türlerini işleme kapasitesinden daha emin olabiliriz.

Tekrarlanan Kod? Yazmak için Scala Makroları Alın

Scala'nın genellikle standart metinden kaçınmaya yardımcı olan kısa bir sözdizimine sahip olmasına rağmen, geliştiriciler yine de kodun tekrarlandığı ve yeniden kullanım için kolayca yeniden düzenlenemeyeceği durumları bulabilir. Scala makroları, bu tür sorunların üstesinden gelmek için yarı alıntılarla birlikte kullanılabilir ve Scala kodunu temiz ve bakımı yapılabilir tutar.

Ayrıca, geliştiricilerin kod oluşturmasına yardımcı olmak için Scala makrolarından yararlanan Macwire gibi popüler kitaplıklar da vardır. Araç setinizde değerli bir varlık olabileceğinden, her Scala geliştiricisinin bu dil özelliği hakkında daha fazla bilgi edinmesini şiddetle tavsiye ediyorum.