🥰

Scala の Shapeless を使ってジェネリックなベクトル を Tuple で表現する.

2021/12/23に公開

Scala でベクトルを表現するにはどうすればいいだろうか. 様々な表現方法があるが shapeless の学習のため Tuple と shapeless を使ってみましょう. Travis Brown さんの Stackoverflow の Answer がとても参考になりました.

コードは gist に置いてあります. 気が向いたらスターしてね❤
https://gist.github.com/i10416/46e41cea6331e65fa66ef8df1c0abf1b

まずは 2次元ベクトルから.

2次元ベクトルは次のように書けます.

まず基本的な演算を trait を使って定義します.

trait VecLike[T]{
  def dim:Int
  def add(a:T,b:T):T
  def sub(a:T,b:T):T
}

次に T に対応する型クラスのインスタンスを作ります.

implicit def doubleVec2D:VecLike[(Double,Double)] =
  new VecLike[(Double,Double)] {
    def dim:Int = 2
    def add(a:(Double,Double),b:(Double,Double)):(Double,Double) = (a._1+b._1,a._2+b._2)
    def sub(a:(Double,Double),b:(Double,Double)):(Double,Double) = (a._1 - b._1, a._2 - b._2)
  }

これで次のようにベクトルの加算・減算ができます.

implicitly[VecLike[(Double,Double)]].add((1,1),(1,1))

これはあまり使い勝手が良くなさそうなので次のような改善策が考えられます.

  1. 演算子の追加. つまり add(a,b) ではなく a |+| b と表記できるようにしたい.
  2. Double 以外の型についても同様の処理を定義したい.

まずは 1 について. これは定番の enrich my library パターンを使って書けます.

implicit class VecLikeOps[T:VecLike](underlying:T) {
  def dim:Int = implicitly[VecLike[T]].dim
  def |+|(b:T) :T = implicitly[VecLike[T]].add(underlying,b)
  def |-|(b:T):T = implicitly[VecLike[T]].sub(underlying,b)
}
// これで次のように書ける
(1.0,1.0) |+| (1.0,1.0)

2 については Double ではなく Numeric 型クラスを使って型の制約を表現しましょう.
doubleVec2D を 型パラメタ T を使って numericVec2D に書き直します.

implicit def numericVec2D[T:Numeric]:VecLike[(T,T)] =
  new VecLike[(T,T)] {
    def dim:Int = 2
    def add(a:(T,T),b:(T,T)):(T,T) = (Numeric[T].plus(a._1,b._1),Numeric[T].plus(a._2,b._2))
    def sub(a:(T,T),b:(T,T)):(T,T) = (Numeric[T].minus(a._1,b._1),Numeric[T].minus(a._2,b._2))
  }

あとは 2次元以外のベクトルにも同様の処理を定義すればいいですね. 手間暇かけて Tuple2 ~ Tuple22 まで VecLike[(T,.....)] のインスタンスを定義しても良いですが、加算・減算が Tuple のサイズによらず同じ index の要素への処理であることに注目すれば shapeless を使ってもっとスマートに書けます.

まず一般のベクトルをあらわす trait を定義します.

import shapeless._
trait GenericVecLike[L<: HList] extends VecLike[L] {type Elm}

HList は異なる型からなる List です. ["str",1,1.0f]: List<Object> ではなく ["str",1,1.0f]: String :: Int :: Float :: Nil というふうに、型の情報を失わずに複数の型の要素を持つリストを構成できます. Scala 3 では Tuple が同じような性質を持っています.

次に、ジェネリックな行列を定義します. のちのち再帰的にすべての要素に処理を実行するので、標準ライブラリの List のように 空の要素・連結要素を定義しましょう.

Aux は型の情報を保ちながら処理するためのおまじない,あるいはそれを書きやすくするためのヘルパーと考えるといいかもしれません.

object GenericVecLike {
  type Aux[L<:HList,A] = GenericVecLike[L]{ type Elm = A}

  implicit def empty[A]:Aux[HNil,A] = new GenericVecLike[HNil] {
    type  Elm = A
    def dim = 0
    def add(a:HNil,b:HNil):HNil = HNil
    def sub(a:HNil,b:HNil):HNil = HNil
  }
  implicit def cons[T<:HList,A:Numeric](implicit tail:Aux[T,A]):Aux[A::T,A] = new GenericVecLike[A::T] {
    type Elm = A
    def dim: Int = tail.dim + 1
    def add(a: A :: T, b: A :: T): A :: T = Numeric[A].plus(a.head,b.head) :: tail.add(a.tail,b.tail)
    def sub(a: A :: T, b: A :: T): A :: T = Numeric[A].minus(a.head,b.head) :: tail.sub(a.tail,b.tail)
  }
}

あとはジェネリックなベクトルについて次のような Veclike のインスタンスを与えてやればいいでしょう. Genric.Aux[T,Repr]TT の型情報を保持した型の間のlift/unlift を提供しています. Repr <: HList はコンパイラが持っている情報を扱いやすくするための抽象化と考えましょう. Scala 3 のマクロの $' に似た感覚ですね.

implicit def genericVec[T,Repr <: HList](implicit gen:Generic.Aux[T,Repr],instance:GenericVecLike[Repr]):VecLike[T] = new VecLike[T]  {
  def dim: Int = instance.dim
  def add(a: T, b: T): T = gen.from(instance.add(gen.to(a),gen.to(b)))
  def sub(a: T, b: T): T = gen.from(instance.sub(gen.to(a),gen.to(b)))  
}

これで長さ 2 ~ 22 の Tuple を2 ~ 22次元のベクトルとして扱って加算・減算ができるようになりました. うれしいですね(^ω^)

(1,1,1,1,1,1,1,1,1) |+|(1,1,1,1,1,1,1,1,1) 
// res0: (Int, Int, Int, Int, Int, Int, Int, Int, Int) = (2, 2, 2, 2, 2, 2, 2, 2, 2)
(1.0,1.0,1.0,1.0,1.0) |-| (1.0,1.0,1.0,1.0,1.0)
// res1: (Double, Double, Double, Double, Double) = (0.0, 0.0, 0.0, 0.0, 0.0)

def add(a: T, b: T): Tsub(a: T, b: T): T の制約で abT なので次元が異なる場合はちゃんとコンパイラエラーが出ます.

(1.0,1.0,1.0,1.0,1.0) |-| (1.0,1.0,1.0,1.0)
// too many arguments (found 4, expected 5-tuple)

このように shapeless を使えばボイラープレートを排除した柔軟なコードが書けます(^ω^) mustache などのテンプレートエンジンを使って文字列からコードを生成する方法と違って、コンパイラのサポートを使ってちゃんと型安全に汎用的なコードが書けます. また、本質的でないコード(例えば ホニャララ utils, hoge extensions や Json とのマッピングなど)がコードベースを占領する範囲をかなり減らすことができます. ドメイン知識と関係のないコードがたくさんあるとそれだけで読む気が損なわれるのでこのようにジェネリックなコードを書けるとうれしいですね(^ω^)

Scala 3 では Tuple が HList と同様の性質を持っていて、型レベルのパターンマッチができるので同様の処理をよりスマートに記述できそうですね. また、 Scala 3 の機能をフルに使った shapeless 3 が開発されています.これらについてはまた別の記事で書きます.

Discussion