Lambdaカクテル

京都在住Webエンジニアの日記です

Case classを同じメンバのcase classに変換する

Case Classを変換したいことがある。具体的には今作ってる年表作成ツールで,ドメインモデルであるdomain.Timelineをテンプレート用のDTOであるapplication.dto.Timelineに変換したいのだが,domain.timelineはそのフィールドにSeq[domain.Event]を保持していて,これもSeq[application.dto.Event]に変換したい,というもの。

最も素朴にやるならば,普通に変換するメソッドを書くのだろうが,こういうボイラープレートがたくさんできてしまう。

def toDTO(
    timeline: Timeline
): windymelt.timeline.application.dto.Timeline = {
  DTOTimeline(
    timeline.id,
    timeline.title,
    toDTO(timeline.editor),
    timeline.events.map(toDTO)
  )
}
def toDTO(event: Event): windymelt.timeline.application.dto.Event = {
  DTOEvent(
    event.id,
    event.name,
    event.description.getOrElse(""),
    event.occurredAt
  )
}
def toDTO(user: User): windymelt.timeline.application.dto.User = {
  DTOUser(user.id, user.name)
}

もう完全に型を合わせるためのボイラープレートだ。型を合わせる…………?型変換…………?一般的なケースクラスを別のケースクラスに変換するような,汎用的なメソッドが書けたら…………!?

JSONライブラリ

そういえばJSONライブラリもcase classのserialization時に似たようなことをしている。Case classを受け取り,JSONのASTを生成し,最終的に文字列に変換したりする。この時,具体的なケースクラスの型はなんでもよく,case classでさえあれば大抵の場合にうまく動作する。

これを支えているのはScalaの強力な型機能なのだが,実際はどうなっているのか知らないので見ておく。

手頃なJSONライブラリとしてcirceというのがあるのでひとまずこれの実装を見てみる。*1

circeでは,case classのserializeはio.circe.generic.autoが担っているそうなので確認する。

takkkun.hatenablog.com

io.circe.generic.autoAutoDerivationを継承している。

github.com

コードを読むとそこには………!

  
package io.circe.generic

import io.circe.{ Decoder, Encoder }
import io.circe.export.Exported
import io.circe.generic.decoding.DerivedDecoder
import io.circe.generic.encoding.DerivedAsObjectEncoder
import io.circe.generic.util.macros.ExportMacros
import scala.language.experimental.macros

/**
 * Fully automatic codec derivation.
 *
 * Extending this trait provides [[io.circe.Decoder]] and [[io.circe.Encoder]]
 * instances for case classes (if all members have instances), "incomplete" case classes, sealed
 * trait hierarchies, etc.
 */
trait AutoDerivation {
  implicit def exportDecoder[A]: Exported[Decoder[A]] = macro ExportMacros.exportDecoder[DerivedDecoder, A]
  implicit def exportEncoder[A]: Exported[Encoder.AsObject[A]] =
    macro ExportMacros.exportEncoder[DerivedAsObjectEncoder, A]
}

exportEncoderに注目。すべてのAについてExported[Endocer.AsObject[A]]を提供するという型変換的なことをやっていますね。

github.com

なにやらExportMacrosってやつを使ってますが,マクロを読んだ(理解したとは言ってない)最終的にnew _root_.io.circe.export.Exported($t: _root_.io.circe.Encoder.AsObject[$A])というのを生成するようです。

Exportedってなんやねんという感じですが実装はcase class Exported[+T](instance: T) extends AnyValだったのでただのコンテナっぽい。おそらくマクロ中でいい感じのJSONオブジェクトに変換するする君を探してそれを継承させる,といったことをやっているのだと思います。

具体的処理

ところで具体的な処理はio.circe.Encoderにズラリと書かれています。

github.com

具体的には以下のようなもの。

implicit final val encodeByte: Encoder[Byte] = new Encoder[Byte] {
  final def apply(a: Byte): Json = Json.fromInt(a.toInt)
}

EncoderがJSONオブジェクトへの変換をやるわけですが,具体的処理であるapplyはインスタンス化するときに実装してもらうようになっています。implicit valなので自動変換されて便利ですね。

ところでProductはどうなってんの?と思うわけですが,それはProductEncodersに実装されています。といっても具体的に実装されているのではなく,自動生成されていそうです。

github.com

Productは最大22-arityまで作成できるので,人間にはメンテ不可能というのが事情のようです。

自分でも実装してみようと思ったけどパワーが足りなかった。

まとめ

ライブラリは型パワーとマクロでJSONのエンコードをやっている。serializeしなくていいなら型パワーだけで型変換とかできそう。ライブラリはshapelessとかcatsを使っている。

*1:ところでこれどう読むの?キルケー?カーク?サーク?