読者です 読者をやめる 読者になる 読者になる

「Scala関数型デザイン&プログラミング」を読んで、コードを書いて、実行する - 第3章 -

引き続き、「Scala関数型デザイン&プログラミング」を読み進めて、第3章に突入します。

Listクラスのオブジェクトを作る

第3章はListクラスの実装から始まります。実装したListクラスの内容は詳細に解説されていますが、割とサクっと進むので、実際には実行結果を丁寧に確認していった方が理解が深まります。

まずは、例として紹介されているコード例を確認します。

$ ./sbt
> project exercises
> console
scala> import fpinscala.datastructures._
scala> val ex1: List[Double] = Nil
ex1: fpinscala.datastructures.List[Double] = Nil

scala> val ex2: List[Int] = Cons(1, Nil)
ex2: fpinscala.datastructures.List[Int] = Cons(1,Nil)

scala> val ex3: List[String] = Cons("a", Cons("b", Nil))
ex3: fpinscala.datastructures.List[String] = Cons(a,Cons(b,Nil))

applyメソッドを使ったパターンが載っていないので、以下のように試してみましょう。

scala> val ex4: List[Int] = List[Int](1, 2, 3)
ex4: fpinscala.datastructures.List[Int] = Cons(1,Cons(2,Cons(3,Nil)))

scala> val ex6: List[Double] = List(1, 2, 3)
ex6: fpinscala.datastructures.List[Double] = Cons(1.0,Cons(2.0,Cons(3.0,Nil)))

scala> val ex7 = List[Double](1, 2, 3)
ex7: fpinscala.datastructures.List[Double] = Cons(1.0,Cons(2.0,Cons(3.0,Nil)))

scala> val ex8 = List(1.0, 2, 3)
ex8: fpinscala.datastructures.List[Double] = Cons(1.0,Cons(2.0,Cons(3.0,Nil)))

scalaが型を推論してくれるので、ex7や、ex8のような書き方でもきちんとList[Double]型になっていることが分かるかと思います(ex8ではパラメータが1.0になっていることに注意!)。

まずは、こうやって色々な組み合わせでREPLからListのオブジェクトを作ってみると、オブジェクトの仕組みやリテラルで書いたデータの型がどうのように推論されるのか、分かると思います。

Nothing型

第3章で一番難しいのが「共変」とNothing型の概念です。

空のリストを作ると、NilだけのListが生成され、List全体がNothing型になります。

scala> val e = List()
e: fpinscala.datastructures.List[Nothing] = Nil

そのリストをConsで引数に渡すと、リスト全体の型は有効な値に合わせて推論されます。

scala> val list = Cons(1, e)
list: fpinscala.datastructures.Cons[Int] = Cons(1,Nil)

これは、Nothing型がすべての型のタイプになっていることで実現されていますが、すぐに理解するのは難しいところです。

下記の記事が理解の参考になるでしょう。

kmizu.hatenablog.com

Listクラスのメソッドの実行

最初からsumメソッド(リストの要素の足し算)と、productメソッド(リストの要素のかけ算)が用意されてますが、これも実行してみましょう。

scala> val r0 = List.sum(List())
r0: Int = 0

scala> val r1 = List.sum(List(1, 2, 3))
r1: Int = 6

scala> val r2 = List.product(List())
r2: Double = 1.0

scala> val r3 = List.product(List(1, 2, 3))
r3: Double = 6.0

よく考えると空のリストの積が1.0になるのは正しいのか?という気もしますが、足し算とかけ算が実行されてることが分かります。

コンパニオンオブジェクトの理解

「object型で定義されるものはシングルトンで、同名のクラスが定義されているobjectはコンパニオンオブジェクトで…」という所も、いきなり色々な概念が登場して、Scalaの分かりづらいと感じるところです。

Scalaのコンパニオンオブジェクト」のコラムと、実際のコードを上から順に追いかけるのが理解の近道です。

パターンマッチの理解

パターンマッチもまたScalaの中でも特に強力な機能の一つです。「Scala関数型デザイン&プログラミング」の中では割とさらっと解説されているだけなので、「Scala スケーラブルプログラミング」の第15章「ケースクラスとパターンマッチ」をよく読んで理解することをお勧めします。

Scalaスケーラブルプログラミング第3版

Scalaスケーラブルプログラミング第3版

exerciseを解く

tail

最初の要素を削除するので、実行結果は下記の通りになります。

scala> val list0 = List(1,2,3)
list0: fpinscala.datastructures.List[Int] = Cons(1,Cons(2,Cons(3,Nil)))

scala> val list1 = List.tail(list0)
list1: fpinscala.datastructures.List[Int] = Cons(2,Cons(3,Nil))

ListがNilの場合について特別に言及されていますが、以下のようなオブジェクトに対してtailを実行することを想定すると、いくつかの設計上の選択肢が有り得ることを示唆しています。

scala> val e = List()
e: fpinscala.datastructures.List[Nothing] = Nil

確かに、「先頭を削除する」ということをよく考えて実装を決めた方が良さそうです。

setHead

scala> val list0 = List(1,2,3)
list0: fpinscala.datastructures.List[Int] = Cons(1,Cons(2,Cons(3,Nil)))

scala> val list2 = List.setHead(list0, 4)
list2: fpinscala.datastructures.List[Int] = Cons(4,Cons(2,Cons(3,Nil)))

回答事例のコードでは、Nilを指定すると、Nilが2回格納された不正なリストができあがります(型もAnyになってしまい、sumメソッドが実行できない)。そこまでは考慮されていないようです。

scala> val list3 = List.setHead(list0, Nil)
list3: fpinscala.datastructures.List[Any] = Cons(Nil,Cons(2,Cons(3,Nil)))

テストコードを書いて、結果を確認する

ここまでsbtのコンソール(REPL)を使って実行結果を確認してきましたが、やはり実行結果はテストコードを書いて、テストで確認した方が、何度でも試すことができるのでお勧めです。exerciseで書いたコードをテストコードで確認する方法を紹介します。

Scalaのテスティングフレームワークとしては、ScalaTestか、Specs2がよく使われていますが、ここではScalaTestを使うことにします。

ScalaTest

依存ライブラリの追加

Build.scalaのoptsに、ScalaTestへの依存を追加します。

resolversの最後にカンマを追加するのを忘れないように。

val opts = Project.defaultSettings ++ Seq(
  scalaVersion := "2.11.7",
  resolvers += "Typesafe Repository" at "http://repo.typesafe.com/typesafe/releases/",
  libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.0" % "test"
)

テストコードの追加

exercises/src/test/scala/fpinscala/datastructures/ListTest.scalaというテストコードのファイルを用意します。パスの3番目がtestになっていることに注意して下さい。Javaではおなじみですが、テストコードはtestディレクトリに保存します。

tailメソッドのテストコードは以下のように書けます。

ScalaTestではいくつかのテストスタイルが使えますが、ここでは一番シンプルに書けるFunSuiteを使っています。

最後のshould equalで期待する値との比較をしています。

import org.scalatest._

import fpinscala.datastructures._

class ListSuite extends FunSuite with Matchers {
  test("tailメソッドは先頭の要素を削除する") {
    val listInt = List(1, 2, 3)
    val listDouble = List(1.0, 2.0, 3.0)
    val listString = List("one", "two", "three")

    List.tail(listInt) should equal (List(2, 3))
    List.tail(listDouble) should equal (List(2.0, 3.0))
    List.tail(listString) should equal (List("two", "three"))
  }
}

テストはsbtから実行します。

$ ./sbt
> test
...
[info] ListSuite:
[info] - tailメソッドは先頭の要素を削除する
...
[success]...

最後に[success]が出力されればテスト全体が成功したことになります。テストが一つでも失敗すると[error]が表示されます。

setHeadメソッドのテストを追加してみます。

import org.scalatest._

import fpinscala.datastructures._

class ListSuite extends FunSuite with Matchers {
  val listInt = List(1, 2, 3)
  val listDouble = List(1.0, 2.0, 3.0)
  val listString = List("one", "two", "three")

  test("tailメソッドは先頭の要素を削除する") {
    List.tail(listInt) should equal (List(2, 3))
    List.tail(listDouble) should equal (List(2.0, 3.0))
    List.tail(listString) should equal (List("two", "three"))
  }

  test("setHeadメソッドは先頭の要素を置き換える") {
    List.setHead(listInt, 4) should equal (List(4, 2, 3))
    List.setHead(listDouble, 4) should equal (List(4.0, 2.0, 3.0))
    List.setHead(listString, "four") should equal (List("four", "two", "three"))
  }
}

この後出てくるdropメソッド以降も同様にテストを書きながら進めていくと良いでしょう。

exerciseのコードの中身については次回以降に見ていきます。