Scalaが僕に教えてくれたこと
Scalaが僕に教えてくれたこと
Scalaを約3年くらい業務で書いてみて色々学びがあったのでまとめてみます。 Scala 2系を使います。
ちょっと自己紹介
Scalaを始める前にどんなことを知っていたかを軽く。
- 初めてプログラミングに触れたのは大学生になってから。
- 大学時代で触った言語はC, C++, Pythonで研究ではほとんどPythonだった。
- 講義で少しJavaはやったけどCみたいな書き方しかしてなかった。
- 関数型?なんか頭いい人がやるやつよね?
- Scalaを始めたのは新卒で入社してから。
こんな人がScalaをやってみたらこんな世界を知れたよ!というのが伝われば幸いです。
1. 型という世界
- 例外など副作用を型として扱う
- collectメソッドにMapインスタンスが渡せる
- 型クラス 〜List[Int].sumを例に〜
Scalaを始める前は型になんとなく苦手意識みたいなものがありました。Cを初めて触ってコンパイルが通らずなにか邪魔してくるものみたいな感覚があったからかもしれません。あとはPythonをよく使っていたこともあって型の世界にそんなに慣れ親しんでいなかったというのもある気がします。
そんな僕ですがScalaを始めて型っていいものだなと感じるようになったので例を挙げながら書いていきます。
型で文脈を表現する
永続化されたユーザー情報をとってくるfindByName(name: String)
というメソッドを考えてみます。このメソッドはユーザー名で検索することができます。
class UserRepository() { // 存在しないユーザー名を指定されたら…? def findByName(name: String): User = ??? }
存在しないユーザー名を指定されたらどうでしょうか?例外を出すかもしれないし、nullになるかもしれません。しかしこのシグネチャからはそれを読み取ることはできません。ユーザーがいるかもしれないし、いないかもしれない。そんな文脈をScalaではOption型を使って表現できます。
class UserRepository() { // nameのユーザーがいるかもしれないし、いないかもしれない def findByName(name: String): Option[User] = ??? }
こんな感じです。存在すればSome(user: User)
が、存在しなければNone
が返ってきます。
他にも非同期処理をしたい場合にはFuture[A]
という型もあります。これはいつか型Aのインスタンスが返ってくるかもしれないし、返ってこないかもしれないという文脈を表現しています。成功すればSuccess(Aのインスタンス)
、失敗すればFailure(exception: Throwable)
が返ってきます。
失敗の可能性等も型で表現できるため、シグネチャを見ればどんなメソッドなのかがわかるというのは素敵です。
Map, Set, Seqが関数の性質を持っている
型の世界というよりScalaの世界の話かもしれませんが、面白いなーと感じたので紹介します。
val dictionary = Map("谷" -> "ravine", "口" -> "port") val japaneseSeq = Seq("谷", "ゲーム", "口")
japaneseSeq
の内容をdictionaryに登録されているものは英語に翻訳し、ないものは無視したいとします。例えばこんな風に書けます。
japaneseSeq.filter(dictionary.keySet).map(dictionary) // 結果: Seq("ravine", "port")
もう少し冗長に書くと
val keys = dictionary.keySet // Set("谷", "口") val filteredSeq = japaneseSeq.filter(keys) // List(谷, 口) filteredSeq.map(dictionary)
です。
ここでfilter
のシグネチャを見てみます。
def filter(pred: A => Boolean): C
filter
にはA => Boolean
という関数が要求されています。先ほどの例ではSet[String]
を渡しました。
これが通るということはdictionary.keySet
はSet[String]
ではなくA => Boolean
として扱われているようです。実際にdictionary.keySet
を変数にバインドする際にA => Boolean
という型をつけてみます。
val f: String => Boolean = dictionary.keySet // dictionary.keySetはSet[String]型だと思っていたがこれは通る f("谷") // true f("ゲーム") // false
「え、Set[String]
がString => Boolean
…?」という感じですが、実はapply
というメソッドが省略されています。
val f: String => Boolean = dictionary.keySet.apply
apply
という名前のメソッドは特殊な扱いで省略可能です。dictionary.keySet("谷")
はdictionary.keySet.apply("谷")
と同じです。シンタックスシュガーというやつです。
(余談:Scalaでは関数も型をもっていて、String ⇒ Boolean
はFunction1[String, Boolean]
型ということを表しています。fはFunction1[String, Boolean]
型のインスタンス(値)です。Scalaは全ての値がオブジェクトであるという意味での純粋オブジェクト指向言語ですね。また、すべての関数が値であるという意味でScalaは関数型言語ですね。(https://docs.scala-lang.org/ja/tour/tour-of-scala.html) )
ということでfilter(keys)
という書き方ができるわけです。map(dictionary)
についても同様です。
// mapのシグネチャ def map[B](f: A => B): CC[B]
val f2: String => String = dictionary.apply
dictionary
をString => String
の関数として扱うことができるのでmap
に渡すことができます。
もしかしたらここで疑問に思う方もいるかもしれません。dictionary
に存在しない文字列を渡したらどうなるの?と。その場合は実行時エラーになります。
val dictionary = Map("谷" -> "ravine", "口" -> "port") dictionary.apply("谷口") // 実行時エラー! java.util.NoSuchElementException: key not found: 谷口
今回はあらかじめfilter
でdictionary
に存在するものしか渡ってこないため大丈夫というわけです。
さて、先程はfilter
とmap
を使って「japaneseSeq
の内容をdictionary
に登録されているものは英語に翻訳し、ないものは無視する」ということを実現しましたが、collect
メソッドを使えばもっとシンプルに書けます。
japaneseSeq.collect(dictionary)
こんな短く書けるとは・・・すごい。ちょっとcollect
のシグネチャを見てみます。
def collect[B](pf: PartialFunction[A, B]): CC[B]
dictionary
つまりMap[K, V]
をPartialFunction[A, B]
として渡しているようです。PartialFunction
は部分関数と言って特定の入力のときだけ値を返す関数です。Map
はまさにその性質を持っています。今回の例だと"谷"と"口"しか入力として受け付けないですね。実際にMap
の定義を追っていくとPartialFunction
がMix-inされています。
型クラス
Seq
には要素の合計値を返すsum
というメソッドがあります。
Seq(1, 2, 3).sum // 結果:6
これをSeq[String]
でやろうとするとどうなるか。
Seq("ravine", "port").sum // コンパイルエラー!:could not find implicit value for parameter num: scala.math.Numeric[String]
同じSeq[A]
のsum
メソッドなのにString
だとコンパイルエラーになるのすごいですね。確かに文字列の合計(結合じゃなくて)ってなんやねんという感じです。sum
の定義を見てみます。
def sum[B >: A](implicit num: Numeric[B]): B = if (isEmpty) num.zero else reduce(num.plus)
[B >: A]
はここでは触れないでおいて、implicit num: Numeric[B]
というのが今回のミソです。引数の型が型パラメータを持っていて、Seq[A]
のA
に合わせて暗黙的に引数に渡されるインスタンスが切り替わってくれます。賢い。Seq(1, 2, 3).sum
の例で言えばNumeric[Int]
が渡されています。これはScalaにあらかじめ定義されています。対してSeq("ravine", "port").sum
ではNumeric[String]
がどこにも定義されていないのでコンパイルエラーになりました。実行時ではなくコンパイル時にエラーになるのがすてきです。この仕組みを型クラスといいます。世界の広がりを感じますね。
さて、ここまでで型については終了です。はじめはなにかにつけてプログラムを実行させてくれないちょっと鬱陶しいものだった型でしたが、Scalaを学んでいくうちに型っていいものだなという気持ちとともになにか表現の広がりのようなものも感じるようになりました。
2. 関数型という世界
関数型のかの字もわからなかった僕ですが、Scalaをやっていると自然とその世界に触れています。
実は「Map, Set, Seqが関数の性質を持っている」では関数型言語の一端に触れていました。関数に関数を渡すのはまさに関数型のパラダイムです。気づかぬ間に関数型の世界に触れていたようです。
ここではもう少し関数型の世界を深掘ってみようと思います。
不変な世界
こちらのページの説明を引用させて頂くと
また、Scalaはオブジェクトの不変性(immutability)を意識している言語です。変数宣言は変更可能なvarと変更不可能なvalが分かれており、コレクションライブラリもmutableとimmutableでパッケージがわかれています。 case classもデフォルトではimmutableです。
で、基本的には不変な世界でプログラミングをしていきます。関数型の世界では不変なものを扱います。宣言したものを変えられないとかめちゃくちゃ不便じゃんとはじめは思っていましたが、慣れると基本は不変な世界でコードを書くほうがわかりやすい(個人の感想です)し、バグも生み出しにくい(個人の感想です)と感じるようになりました。Scalaの豊富なコレクションライブラリを駆使して不変な世界でプログラミングしていくうちに気がついたら関数型という概念に割と慣れ親しんでいたという感覚です。
↑の引用でも述べられているように必要であれば可変なものも使うこともできます。懐が広いですね。
ちょっと横道に逸れますが、Scalaを始める前はオブジェクト指向と関数型は反するものというイメージがありました(なぜかはわかりませんが)。ですが、その2つの概念は相反するものではなく、直交するものだということもScalaは教えてくれました。 これに関してはこちらの記事が参考になると思います。
高カインド型、そして圏論へ
高カインド型とはF[_]
と表現されているやつです。なんだかとても抽象的ですね。ちょっと順番に見ていこうと思います。
こちらの図を御覧ください。
慣れ親しんでいる型たちです。MyClassは自分で定義した型です。ここに「1. 型という世界」の世界でも登場したOption[A]
などの型パラメーターを取る型(first-order typeと言います)を加えてみます。
[A]
というのは任意の型を表しており、縦線と横線の交差している点が具体的な型になります。例えばOption[A]
とString
の交差点ならOption[String]
です。
ところでOption[A]
にはmap
というメソッドがあります。そのシグネチャは以下のとおりです。
def map[B](f: A => B): Option[B]
これが意味していることはOption[A]
がSome
なら中の値に関数fを適用してOption[B]
を得て、None
ならなにもしないというメソッドです。
実はSeq[A]
とFuture[A]
にも同じ(Future
はちょっと違うけど許してください)メソッドがあります。
# Seq[A] def map[B](f: A => B): Seq[B] # Future[T] def map[S](f: T => S)(implicit executor: ExecutionContext): Future[S]
Seq
の方は各要素に関数f
を適用していく、Future
の方はSuccess
であればその値に関数f
を適用してFailure
であればなにもしないというメソッドです。
Future
にはimplicitパラメーターがありますがどれもなんだか似てませんか?ちなみにflatMap
というメソッドもあってシグネチャは以下のとおりです。
# Option[A] def flatMap[B](f: A => Option[B]): Option[B] # Seq[A] def flatMap[B](f: A => IterableOnce[B]): Seq[B] # Future[T] def flatMap[S](f: T => Future[S])(implicit executor: ExecutionContext): Future[S]
Seq
のfはIterableOnce[B]
だったり、Future
にはimplicitパラメーターがありますが目をつぶっていただいて…こちらもやはり似た形をしています。
ちょっと違くない?ということを握りつぶして図にするとこんな感じです。
だいぶ握りつぶしましたが、Option[A]
, Seq[A]
, Future[A]
で似たような構造を持っているようです。つまりこれらを抽象化したF[A]
という型(高カインド型と言います)を考えられそうです。F[A]
はmap
とflatMap
を持っています。
// F[A]はこんなシグネチャのメソッドを持つ def map[B](f: A => B): F[B] def flatMap[B](f: A => F[B]): F[B]
こんなイメージです。
これをScalaのコードに落としてみるとこんな感じです。
trait HigherKind[F[_]] { def map[A, B](a: F[A])(f: A => B): F[B] def flatMap[A, B](a: F[A])(f: A => F[B]): F[B] } object HigherKind { implicit val option: HigherKind[Option] = new HigherKind[Option] { override def map[A, B](a: Option[A])(f: A => B): Option[B] = a.map(f) override def flatMap[A, B](a: Option[A])(f: A => Option[B]): Option[B] = a.flatMap(f) } implicit val seq: HigherKind[Seq] = new HigherKind[Seq] { override def map[A, B](a: Seq[A])(f: A => B): Seq[B] = a.map(f) override def flatMap[A, B](a: Seq[A])(f: A => Seq[B]): Seq[B] = a.flatMap(f) } implicit def future(implicit ec: ExecutionContext): HigherKind[Future] = new HigherKind[Future] { override def map[A, B](a: Future[A])(f: A => B): Future[B] = a.map(f) override def flatMap[A, B](a: Future[A])(f: A => Future[B]): Future[B] = a.flatMap(f) } }
Option
, Seq
, Future
をHigherKindとしてまとめることができました。
この構造、聞いたことある方も多いであろうモナドと大いに関係しています。これ以上突っ込むのはやめておこうと思いますが、多少なりともF[A]
のイメージがわけば幸いです。
さらにこのF[A]
、モナドという言葉からもわかるように圏論という分野につながっています(というより圏論由来の言葉です)。興味がわいたので自分なりに勉強してみてこんな発表させてもらったりしました。
https://speakerdeck.com/tanitk/scaladequan-lun-tiyotutoxue-bu
関数型についてはここまでです。関数型と概念だけでなく、圏論という数学のための数学とも言われている分野を知ることができたりで、プログラミングの領域を飛び越えてScalaが学びを与えてくれました。
3. ドメイン駆動設計と事業への関心
Scalaの話題からはちょっとそれるのですが、エンジニアとしての意識が変わったなーという瞬間だったので紹介します。
ドメイン駆動設計(略称:DDD)をご存知でしょうか?ソフトウェア開発の設計スタイルの1つです。DDDがなにかはたくさん記事があるのでここでは述べないことにして、それを通してどんな意識の変化が生まれたかを書いてみます。
DDDはScalaと相性がよいです(参考資料:https://speakerdeck.com/crossroad0201/scala-on-ddd)。 プラス開発現場でもDDDが共通知識としてあったためDDDを学ぶのは必然でした。
最初は設計パターンとしか見ていなかったのですが、ちゃんと本を読んでみるとどうやら「君たちドメインに向き合ってる?ビジネスの人と会話してる?」という主張をしていてそれが本当に言いたいことなのでは?と思うようになりました。 思い返してみると、開発アイテム1つ取ってみてもそれがなんで必要なのか、どんな効果が期待できるのかなどなどをよくわかっていなかったし、ビジネスの人がどういう仕事してるかもよく知らなかったです。DDDは「もしかして自分が携わっているプロダクトと人のことなにも理解していない…?」という気づきを与えてくれました。 ということで、積極的にビジネスの人とコミュニケーション取ってプロダクトの目指すところ、今はなにをしようとしているのかを意識するようになりました。事業部とエンジニアの1on1を企画したりしてビジネスの人たちがどんな人なのか、どんな気持ちで仕事をしているのかを知れたのはとてもよかったです。ビジネスの人と密に連携して目指す世界の解像度を上げ、それをソフトウェアとして表現していくのはとても楽しいです。
「とりあえずなんか作れればええねん」という気持ちから「ビジネスもエンジニアもみんなでプロダクトを作っていくぞ!」という気持ちに変わったのは、Scalaを通してDDDの世界を知れたからに他なりません。
まとめ
Scalaが僕に教えてくれたことをまとめてみました(最後はDDDの話題だったけど)。他にもfor式を使った書き方、Akkaを使ったStream処理、Actorなどなどまだまだ学びはあるのですがここらへんにしておこうと思います。
Scalaを使った開発現場からは遠ざかってしまったのですが今もScalaはとても好きな言語です。ぜひScalaをやってみてください!