Kurangi Kode Boilerplate Dengan Scala Macros dan Quasiquotes
Diterbitkan: 2022-03-11Bahasa Scala menawarkan pengembang kesempatan untuk menulis kode berorientasi objek dan fungsional dalam sintaks yang bersih dan ringkas (dibandingkan dengan Java, misalnya). Kelas kasus, fungsi tingkat tinggi, dan inferensi tipe adalah beberapa fitur yang dapat dimanfaatkan oleh pengembang Scala untuk menulis kode yang lebih mudah dipelihara dan tidak rawan kesalahan.
Sayangnya, kode Scala tidak kebal terhadap boilerplate, dan pengembang mungkin kesulitan menemukan cara untuk memfaktorkan ulang dan menggunakan kembali kode tersebut. Misalnya, beberapa perpustakaan memaksa pengembang untuk mengulangi dirinya sendiri dengan memanggil API untuk setiap subkelas dari kelas yang disegel.
Tapi itu hanya benar sampai pengembang belajar bagaimana memanfaatkan makro dan quasiquotes untuk menghasilkan kode berulang pada waktu kompilasi.
Kasus Penggunaan: Mendaftarkan Handler yang Sama untuk Semua Subtipe Kelas Induk
Selama pengembangan sistem layanan mikro, saya ingin mendaftarkan penangan tunggal untuk semua peristiwa yang berasal dari kelas tertentu. Agar tidak mengganggu kami dengan spesifikasi kerangka kerja yang saya gunakan, berikut adalah definisi sederhana dari API-nya untuk mendaftarkan event handler:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } Memiliki pemroses peristiwa untuk semua jenis Event , kita dapat mendaftarkan penangan untuk subkelas Event dengan metode addHandler .
Melihat tanda tangan di atas, pengembang mungkin mengharapkan penangan yang terdaftar untuk tipe tertentu dipanggil untuk acara subtipenya. Misalnya, mari kita pertimbangkan hierarki kelas peristiwa berikut yang terlibat dalam siklus hidup entitas User :
Deklarasi Scala yang sesuai terlihat seperti ini:
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 Kami dapat mendaftarkan handler untuk setiap kelas acara tertentu. Tetapi bagaimana jika kita ingin mendaftarkan handler untuk semua kelas acara? Upaya pertama saya adalah mendaftarkan handler untuk kelas UserEvent . Saya berharap itu akan dipanggil untuk semua acara.
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)Saya perhatikan bahwa pawang tidak pernah dipanggil selama pengujian. Saya menggali kode Lagom, kerangka kerja yang saya gunakan.
Saya menemukan bahwa implementasi prosesor acara menyimpan penangan di peta dengan kelas terdaftar sebagai kuncinya. Ketika suatu peristiwa dipancarkan, ia mencari kelasnya di peta itu untuk membuat pawang memanggil. Pemroses acara diimplementasikan di sepanjang baris ini:
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))) } } Di atas, kami mendaftarkan penangan untuk kelas UserEvent , tetapi setiap kali peristiwa turunan seperti UserCreated dipancarkan, prosesor tidak akan menemukan kelasnya di registri.
Maka Dimulailah Kode Boilerplate
Solusinya adalah dengan mendaftarkan handler yang sama untuk setiap kelas acara konkret. Kita bisa melakukannya seperti ini:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)Sekarang kodenya berfungsi! Tapi itu berulang.
Ini juga sulit untuk dipertahankan, karena kita perlu memodifikasinya setiap kali kita memperkenalkan jenis acara baru. Kami mungkin juga memiliki tempat lain di basis kode kami di mana kami dipaksa untuk membuat daftar semua tipe konkret. Kami juga perlu memastikan untuk memodifikasi tempat-tempat itu.
Ini mengecewakan, karena UserEvent adalah kelas yang disegel, artinya semua subkelas langsungnya diketahui pada waktu kompilasi. Bagaimana jika kita dapat memanfaatkan informasi itu untuk menghindari boilerplate?

Makro untuk Menyelamatkan
Biasanya, fungsi Scala mengembalikan nilai berdasarkan parameter yang kami berikan kepada mereka saat dijalankan. Anda dapat menganggap makro Scala sebagai fungsi khusus yang menghasilkan beberapa kode pada waktu kompilasi untuk menggantikan pemanggilannya.
Sementara antarmuka macro mungkin tampak mengambil nilai sebagai parameter, implementasinya sebenarnya akan menangkap pohon sintaksis abstrak (AST)—representasi internal struktur kode sumber yang digunakan kompiler—dari parameter tersebut. Kemudian menggunakan AST untuk menghasilkan AST baru. Terakhir, AST baru menggantikan panggilan makro pada waktu kompilasi.
Mari kita lihat deklarasi macro yang akan menghasilkan pendaftaran event handler untuk semua subkelas yang diketahui dari kelas tertentu:
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 } Perhatikan bahwa untuk setiap parameter (termasuk parameter tipe dan tipe pengembalian), metode implementasi memiliki ekspresi AST yang sesuai sebagai parameter. Misalnya, c.Expr[EventProcessor[Event]] cocok dengan EventProcessor[Event] . Parameter c: Context membungkus konteks kompilasi. Kita dapat menggunakannya untuk mendapatkan semua informasi yang tersedia pada waktu kompilasi.
Dalam kasus kami, kami ingin mengambil anak-anak dari kelas tersegel kami:
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) Perhatikan panggilan rekursif ke metode subclasses untuk memastikan bahwa subclass tidak langsung juga diproses.
Sekarang setelah kita memiliki daftar kelas acara untuk didaftarkan, kita dapat membangun AST untuk kode yang akan dihasilkan makro Scala.
Membuat Kode Scala: AST atau Quasiquotes?
Untuk membangun AST kami, kami dapat memanipulasi kelas AST atau menggunakan quasiquotes Scala. Menggunakan kelas AST dapat menghasilkan kode yang sulit dibaca dan dipelihara. Sebaliknya, quasiquotes secara dramatis mengurangi kompleksitas kode dengan memungkinkan kita menggunakan sintaks yang sangat mirip dengan kode yang dihasilkan.
Untuk mengilustrasikan keuntungan kesederhanaan, mari kita ambil ekspresi sederhana a + 2 . Menghasilkan ini dengan kelas AST terlihat seperti ini:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))Kita dapat mencapai hal yang sama dengan quasiquotes dengan sintaks yang lebih ringkas dan mudah dibaca:
val exp = q"a + 2"Untuk menjaga agar makro kita tetap lurus, kita akan menggunakan quasiquotes.
Mari buat AST dan kembalikan sebagai hasil dari fungsi makro:
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) Kode di atas dimulai dengan ekspresi prosesor yang diterima sebagai parameter, dan untuk setiap subkelas Event , kode tersebut menghasilkan panggilan ke metode addHandler dengan fungsi subkelas dan handler sebagai parameter.
Sekarang kita dapat memanggil makro pada kelas UserEvent dan itu akan menghasilkan kode untuk mendaftarkan handler untuk semua subkelas:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)Itu akan menghasilkan kode ini:
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) Kode proyek lengkap dikompilasi dengan benar dan kasus uji menunjukkan bahwa pawang memang terdaftar untuk setiap subkelas UserEvent . Sekarang kita bisa lebih percaya diri dengan kapasitas kode kita untuk menangani jenis kejadian baru.
Kode berulang? Dapatkan Scala Macro untuk Menulisnya
Meskipun Scala memiliki sintaks ringkas yang biasanya membantu menghindari boilerplate, pengembang masih dapat menemukan situasi di mana kode menjadi berulang dan tidak dapat dengan mudah difaktorkan ulang untuk digunakan kembali. Makro Scala dapat digunakan dengan quasiquotes untuk mengatasi masalah tersebut, menjaga kode Scala tetap bersih dan dapat dipelihara.
Ada juga perpustakaan populer, seperti Macwire, yang memanfaatkan makro Scala untuk membantu pengembang menghasilkan kode. Saya sangat mendorong setiap pengembang Scala untuk mempelajari lebih lanjut tentang fitur bahasa ini, karena ini dapat menjadi aset berharga di set alat Anda.
