関数型のパラダイムを学んで業務に活かそうということで、以下のドワンゴオリジナルの新卒エンジニア向けの研修資料でScalaを本格的に勉強してみることにした。
dwango.github.io
※Java8でごにょごにょしないのは、Scalaを趣味でやりたいという意図があるだけです。
どうせなら、Scalaの言語仕様からガッツリ学んで自分のものにしたい。
また、可能な限りGolangとの比較も入れていきます。
学んだことをとにかく走り書きしていきます。
【型パラメータ】
気になった部分を以下に記載
クラスの節では触れませんでしたが、クラスは0個以上の型をパラメータとして取ることができます。これは、クラスを作る時点では何の型か特定できない場合(たとえば、コレクションクラスの要素の型)を表したい時に役に立ちます。型パラメータを入れたクラス定義の文法は次のようになります
class クラス名[型パラメータ1, 型パラメータ2, ..., 型パラメータN](コンストラクタ引数1 :コンストラクタ引数1の型, コンストラクタ引数2 :コンストラクタ引数2の型, ...) { 0個以上のフィールドの定義またはメソッド定義 }型パラメータ1から型パラメータNまでは好きな名前を付け、クラス定義の中で使うことができます。とりあえず、簡単な例として、1個の要素を保持して、要素を入れる(putする)か取りだす(getする)操作ができるクラスCellを定義してみます。Cellの定義は次のようになります。
class Cell[T](var value: T) { def put(newValue: T): Unit = { value = newValue } def get(): T = value }これをREPLで使ってみましょう。
scala> class Cell[T](var value: T) { | def put(newValue: T): Unit = { | value = newValue | } | | def get(): T = value | } defined class Cell scala> val cell = new Cell[Int](1) cell: Cell[Int] = Cell@192aaffb scala> cell.put(2) scala> cell.get() res1: Int = 2 scala> cell.put("something") <console>:10: error: type mismatch; found : String("something") required: Int cell.put("something") ^scala> val cell = new Cell[Int](1) cell: Cell[Int] = Cell@6a01a75bで、型パラメータとしてInt型を与えて、その初期値として1を与えています。型パラメータにIntを与えてCellをインスタンス化したため、REPLではStringをputしようとして、コンパイラにエラーとしてはじかれています。Cellは様々な型を与えてインスタンス化したいクラスであるため、クラス定義時には特定の型を与えることができません。そういった場合に、型パラメータは役に立ちます。
うん、ここまではJavaの型クラスと変わりはないかな?というイメージ。
次に、もう少し実用的な例をみてみましょう。メソッドから複数の値を返したい、という要求はプログラミングを行う上でよく発生します。そのような場合、型パラメータが無い言語では、
・片方を返り値として、もう片方を引数を経由して返す
・複数の返り値専用のクラスを必要になる度に作る
という選択肢しかありませんでした。しかし、前者は引数を返り値に使うという点で邪道ですし、後者の方法は多数の引数を返したい、あるいは解く問題上で意味のある名前の付けられるクラスであれば良いですが、ただ2つの値を返したいといった場合には小回りが効かず不便です。こういう場合、型パラメータを2つ取るPairクラスを作ってしまいます。Pairクラスの定義は次のようになります。toStringメソッドの定義は後で表示のために使うだけなので気にしないでください。class Pair[T1, T2](val t1: T1, val t2: T2) { override def toString(): String = "(" + t1 + "," + t2 + ")" }このクラスPairの利用法としては、たとえば割り算の商と余りの両方を返すメソッドdivideが挙げられます。divideの定義は次のようになります。
def divide(m: Int, n: Int): Pair[Int, Int] = new Pair[Int, Int](m / n, m % n)これらをREPLにまとめて流し込むと次のようになります。
scala> class Pair[T1, T2](val t1: T1, val t2: T2) { | override def toString(): String = "(" + t1 + "," + t2 + ")" | } defined class Pair scala> def divide(m: Int, n: Int): Pair[Int, Int] = new Pair[Int, Int](m / n, m % n) divide: (m: Int, n: Int)Pair[Int,Int] scala> divide(7, 3) res0: Pair[Int,Int] = (2,1)7割る3の商と余りがres0に入っていることがわかります。なお、ここではnew Pair[Int, Int](m / n, m % n)としましたが、引数の型から型パラメータの型を推測できる場合、省略できます。この場合、Pairのコンストラクタに与える引数はIntとIntなので、new Pair(m / n, m % n)としても同じ意味になります。このPairは2つの異なる型(同じ型でも良い)を返り値として返したい全ての場合に使うことができます。このように、どの型でも同じ処理を行う場合を抽象化できるのが型パラメータの利点です。
ちなみに、このPairのようなクラスはScalaではよく使われるため、Tuple1からTuple22(Tupleの後の数字は要素数)があらかじめ用意されています。また、インスタンス化する際も、
scala> val m = 7 m: Int = 7 scala> val n = 3 n: Int = 3 scala> new Tuple2(m / n, m % n) res1: (Int, Int) = (2,1)などとしなくても、
scala> val m = 7 m: Int = 7 scala> val n = 3 n: Int = 3 scala> (m / n, m % n) res2: (Int, Int) = (2,1)とすれば良いようになっています。
うん、タプルでこのようなことができるのは把握した。
標準クラスで準備されているのは嬉しい。
Golangでは2値を返すメソッドが作れたりする。
成功時の結果と失敗時のエラーを常に返すメソッドを作るイメージ。
【変位指定(variance)】
・共変(covariant)
Scalaでは、何も指定しなかった型パラメータは通常は非変(invariant)になります。非変というのは、型パラメータを持ったクラスG、型パラメータT1とT2があったとき、T1 = T2のときにのみ
val : G[T1] = G[T2]というような代入が許されるという性質を表します。これは、違う型パラメータを与えたクラスは違う型になることを考えれば自然な性質です。ここであえて非変について言及したのは、Javaの組み込み配列クラスは標準で非変ではなく共変であるという設計ミスを犯しているからです。
ここでまだ共変について言及していなかったので、簡単に定義を示しましょう。共変というのは、型パラメータを持ったクラスG、型パラメータT1とT2があったとき、T1 が T2 を継承しているときにのみ、
val : G[T2] = G[T1]class G[+T]のように型パラメータの前に+を付けるとその型パラメータは(あるいはそのクラスは)共変になります。
このままだと定義が抽象的でわかりづらいかもしれないので、具体的な例として配列型を挙げて説明します。配列型はJavaでは共変なのに対してScalaでは非変であるという点において、面白い例です。まずはJavaの例です。G = 配列、 T1 = String, T2 = Objectとして読んでください。
Object[] objects = new String[1]; objects[0] = 100;このコード断片はJavaのコードとしてはコンパイルを通ります。ぱっと見でも、Objectの配列を表す変数にStringの配列を渡すことができるのは理にかなっているように思えます。しかし、このコードを実行すると例外 java.lang.ArrayStoreException が発生します。これは、objectsに入っているのが実際にはStringの配列(Stringのみを要素として持つ)なのに、2行目でint型(ボクシング変換されてInteger型)の値である100を渡そうとしていることによります。
共変や非変という概念を初めて聞いたので、ここは勉強になりました。
コンパイルではなく、実行時例外で発生するのは辛い。。
最近見た例だと、JDK のCollections.unmodifiableList()の実行時例外とかか。
以下の記事の中で言及されています。
qiita.com
一方、Scalaでは同様のコードの一行目に相当するコードをコンパイルしようとした時点で、次のようなコンパイルエラーが出ます(Anyは全ての型のスーパークラスで、AnyRefに加え、AnyVal(値型)の値も格納できます)。
scala> val arr: Array[Any] = new Array[String](1) <console>:7: error: type mismatch; found : Array[String] required: Array[Any]このような結果になるのは、Scalaでは配列は非変だからです。静的型付き言語の型安全性とは、コンパイル時により多くのプログラミングエラーを捕捉するものであるとするなら、配列の設計はScalaの方がJavaより型安全であると言えます。
さて、Scalaでは型パラメータを共変にした時点で、安全ではない操作はコンパイラがエラーを出してくれるので安心ですが、共変をどのような場合に使えるかを知っておくのは意味があります。たとえば、先ほど作成したクラスPair[T1, T2]について考えてみましょう。Pair[T1, T2]は一度インスタンス化したら、変更する操作ができませんから、ArrayStoreExceptionのような例外は起こり得ません。実際、Pair[T1, T2]は安全に共変にできるクラスで、class Pair[+T1, +T2]のようにしても問題が起きません。
scala> class Pair[+T1, +T2](val t1: T1, val t2: T2) { | override def toString(): String = "(" + t1 + "," + t2 + ")" | } defined class Pair scala> val pair: Pair[AnyRef, AnyRef] = new Pair[String, String]("foo", "bar") pair: Pair[AnyRef,AnyRef] = (foo,bar)ここで、Pairは作成時に値を与えたら後は変更できず、したがってArrayStoreExceptionのような例外が発生する余地がないことがわかります。一般的には、一度作成したら変更できない(immutable)などの型パラメータは共変にしても多くの場合問題がありません。
immutableだったら、共変にしても問題は起こらないということか。
利用シーンがまだ思い浮かばない。
演習問題
次のimmutableなStack型の定義(途中)があります。???の箇所を埋めて、Stackの定義を完成させなさい。なお、E >: Tは、EはTの継承元である、という制約を表しています。また、Nothingは全ての型のサブクラスであるような型を表現します。Stack[T]は共変なので、Stack[Nothing]はどんな型のStack変数にでも格納することができます。
trait Stack[+T] { def pop: (T, Stack[T]) def push[E >: T](e: E): Stack[E] def isEmpty: Boolean } class NonEmptyStack[+T](private val top: T, private val rest: Stack[T]) extends Stack[T] { def push[E >: T](e: E): Stack[E] = ??? def pop: (T, Stack[T]) = ??? def isEmpty: Boolean = ??? } case object EmptyStack extends Stack[Nothing] { def pop: Nothing = throw new IllegalArgumentException("empty stack") def push[E >: Nothing](e: E): Stack[E] = new NonEmptyStack[E](e, this) def isEmpty: Boolean = true } object Stack { def apply(): Stack[Nothing] = EmptyStack }
解答を見て納得した感じ。
自力では厳しかったっす。。
型を見てなんとなくは解答できても、よくわかっていないと実感。
・反変(contravariant)
次は共変とちょうど対になる性質である反変です。簡単に定義を示しましょう。反変というのは、型パラメータを持ったクラスG、型パラメータT1とT2があったとき、T1 が T2 を継承しているときにのみ、
val : G[T1] = G[T2]というような代入が許される性質を表します。Scalaでは、クラス定義時に
class G[-T]のように型パラメータの前に-を付けるとその型パラメータは(あるいはそのクラスは)反変になります。
反変の例として最もわかりやすいものの1つが関数の型です。たとえば、型T1とT2があったとき、
val x1: T1 => AnyRef = T2 => AnyRef型の値 x1(T1型の値)というプログラムの断片が成功するためには、T1がT2を継承する必要があります。その逆では駄目です。仮に、T1 = String, T2 = AnyRef として考えてみましょう。
val x1: String => AnyRef = AnyRef => AnyRef型の値 x1(String型の値)ここでx1に実際に入っているのはAnyRef => AnyRef型の値であるため、引数としてString型の値を与えても、AnyRef型の引数にString型の値を与えるのと同様であり、問題なく成功します。T1とT2が逆で、T1 = AnyRef, T2 = Stringの場合、String型の引数にAnyRef型の値を与えるのと同様になってしまうので、これはx1へ値を代入する時点でコンパイルエラーになるべきであり、実際にコンパイルエラーになります。
実際にREPLで試してみましょう。
scala> val x1: AnyRef => AnyRef = (x: String) => (x:AnyRef) <console>:7: error: type mismatch; found : String => AnyRef required: AnyRef => AnyRef val x1: AnyRef => AnyRef = (x: String) => (x:AnyRef) ^ scala> val x1: String => AnyRef = (x: AnyRef) => x x1: String => AnyRef = <function1>このように、先ほど述べたような結果になっています。
反変という概念は初めて聞いたけど、こっちは制限をかけるというイメージで使えるので、使いどころはいつかはあるかもという印象。
【型パラメータの境界(bounds)】
型パラメータTに対して何も指定しない場合、その型パラメータTは、どんな型でも入り得ることしかわかりません。そのため、何も指定しない型パラメータTに対して呼び出せるメソッドはAnyに対するもののみになります。しかし、たとえば、順序がある要素からなるリストをソートしたい場合など、Tに対して制約を書けると便利な場合があります。そのような場合に使えるのが、型パラメータの境界(bounds)です。型パラメータの境界には2種類あります。
・上限境界(upper bounds)
1つ目は、型パラメータがどのような型を継承しているかを指定する上限境界(upper bounds)です。上限境界では、型パラメータの後に、
abstract class Show { def show: String } class ShowablePair[T1 <: Show, T2 <: Show](val t1: T1, val t2: T2) extends Show { override def show: String = "(" + t1.show + "," + t2.show + ")" }ここで、型パラメータT1、T2ともに上限境界としてShowが指定されているため、t1とt2に対してshowを呼び出すことができます。なお、上限境界を明示的に指定しなかった場合、Anyが指定されたものとみなされます。
うん、ここは記法が違うだけで、Javaと同じイメージ。
Javaはextendsキーワードを使う。
・下限境界(lower bounds)
2つ目は、型パラメータがどのような型のスーパータイプであるかを指定する下限境界(lower bounds)です。下限境界は、共変パラメータと共に用いることが多い機能です。実際に例を見ます。
まず、共変の練習問題であったような、イミュータブルなStackクラスを定義します。このStackは共変にしたいとします。
abstract class Stack[+E]{ def push(element: E): Stack[E] def top: E def pop: Stack[E] def isEmpty: Boolean }しかし、この定義は、以下のようなコンパイルエラーになります。
error: covariant type E occurs in contravariant position in type E of value element def push(element: E): Stack[E] ^このコンパイルエラーは、共変な型パラメータEが反変な位置(反変な型パラメータが出現できる箇所)に出現したということを言っています。一般に、引数の位置に共変型パラメータEの値が来た場合、型安全性が壊れる可能性があるため、このようなエラーが出ます。しかし、このStackは配列と違ってイミュータブルであるため、本来ならば型安全性上の問題は起きません。この問題に対処するために型パラメータの下限境界を使うことができます。型パラメータFをpushに追加し、その下限境界として、Stack の型パラメータEを指定します。
abstract class Stack[+E]{ def push[F >: E](element: F): Stack[F] def top: E def pop: Stack[E] def isEmpty: Boolean }このようにすることによって、コンパイラは、StackにはEの任意のスーパータイプの値が入れられる可能性があることがわかるようになります。そして、型パラメータFは共変ではないため、どこに出現しても構いません。このようにして、下限境界を利用して、型安全な Stackと共変性を両立することができます。
>:で下限境界は設定できるのか。
うん、ここは記法が違うだけで、Javaと同じイメージ。
Javaはsuperキーワードを使う。
Scalaではなぜワイルドカード指定がないのだろうと思ったけど、それ相当のことが今まで紹介したものでできるのかな?
以下の記事の中で解説あり
itpro.nikkeibp.co.jp
ワイルドカードは,型を使う側に付加するものであり,共変/反変な型の変数を宣言する必要があるたびに記述しなければならないため,煩雑であるという欠点がありますが,一方で,型の定義時点では共変/反変でないものを共変/反変であるものとして扱うことができるという利点もあります。
Scalaでも,Existential Typeという機能を使うことで,ワイルドカードと同じことを実現することができます。例えば,上記のワイルドカードを使ったJavaのコードと同じことは,次のようなScalaのコードによって実現できます。
//java.util.List[_ <: Any]と略記することもできる var s1: java.util.List[String] = new java.util.ArrayList var s2: java.util.List[T] forSome { type T <: Any } = s1 //java.util.List[_ >: String]と略記することもできる var s3: java.util.List[Any] = new java.util.ArrayList var s4: java.util.List[T] forSome { type T >: String} = s3
型システム、奥が深いでござる。
他の参考記事
Scalaのジェネリックスを学ぶ - じゅんいち☆かとうの技術日誌
ちなみに、Golangはクラスではなく、すべてInterfaceや構造体で継承のようなことをやるので、今回は全体的に記述なし。
Programming in Scala: Updated for Scala 2.12
- 作者: Martin Odersky,Lex Spoon,Bill Venners
- 出版社/メーカー: Artima Inc
- 発売日: 2016/05/10
- メディア: ペーパーバック
- この商品を含むブログを見る
Scala関数型デザイン&プログラミング―Scalazコントリビューターによる関数型徹底ガイド
- 作者: Paul Chiusano,Rúnar Bjarnason,株式会社クイープ
- 出版社/メーカー: インプレス
- 発売日: 2015/04/30
- メディア: Kindle版
- この商品を含むブログ (2件) を見る
- 作者: アンドリュー・フィリップス,ネルミン・セリフォヴィック,竹添直樹,島本多可子
- 出版社/メーカー: 翔泳社
- 発売日: 2016/02/05
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (2件) を見る
- 作者: デイビッド・ポラック,羽生田栄一,大塚庸史
- 出版社/メーカー: 日経BP社
- 発売日: 2010/03/18
- メディア: 単行本
- 購入: 14人 クリック: 251回
- この商品を含むブログ (31件) を見る