Magnolia Tech

いつもコードのことばかり考えている人のために。

『Scala関数型デザイン&プログラミング』の演習問題をScala3で解く その1

昨年末から時間さえ有れば『Scala関数型デザイン&プログラミング』の演習問題の解き直し、というのを久しぶりに(3年ぶり?)をやっていました。

過去に解いた時の記憶がだいぶ飛んでしまっていたので、久しぶりに解き直してみるか、と思ったものの、そのまま解いてもあまり面白くありません。そこで演習問題を都度Scala3ベースに置き換えながら解くことにしました。

なお、原著の『Functional Programming in Scala』は今年Scala3対応版の第2版が出版される予定で、GitHubリポジトリにも既に第2版用のScala3対応版コードが用意されています(が、そちらを見てしまうと練習にはならないので、どうしても分からない時だけ見るようにしました)。

www.manning.com

github.com


環境準備

JDKのインストール

今ならJava11か、Java17になります。

ただし、Java8以降であればどのディストリビューションを使っても大丈夫です。

sbtのインストール

公式サイトのドキュメントに沿ってインストールします。

sbt Reference Manual — Installing sbt

macOSならHomebrewからのインストールできます。

$ brew install sbt

演習問題のリポジトリの用意

GitHubからクローンします。デフォルトのブランチが既にScala3対応のsecond-editionになっていますが、「自分で書き換えてみる」という趣旨なので、first-editionの方のブランチをローカルに取得します。

$ git switch -c first-edition remotes/origin/first-edition

project/build.propertiesの書き換え

sbtのバージョンを置き換えます。

最新バージョンは公式サイトで確認します。

www.scala-sbt.org

project/build.propertiesを書き換えます。この記事を書いている時点での最新バージョンは1.6.1でした。

sbt.version=1.6.1

build.sbtの書き換え

scalaVersionは本日時点での最新版の3.1.1に書き換えます。また、first-editionでは特にテスティングフレームワークの指定はされていませんでしたが、テスト無しに演習問題の結果確認はできないので、ここではテスティングフレームワークとしてAirSpecを追加します(libraryDependenciesと、testFrameworksを追加し、ついでにテストの結果が見やすいように並列実行抑止の指定であるTest / parallelExecution := falseを追加しています)。

(second-editionリポジトリでは、munitが指定されていました)

ThisBuild / scalaVersion := "3.1.1"

lazy val root = (project in file("."))
  .aggregate(exercises, answers)
  .settings(
    name := "fpinscala"
  )

lazy val exercises = (project in file("exercises"))
  .settings(
    name := "exercises",
    libraryDependencies += "org.wvlet.airframe" %% "airspec" % "21.12.1" % Test,
    testFrameworks += new TestFramework("wvlet.airspec.Framework"),
    Test / parallelExecution := false
  )

lazy val answers = (project in file("answers"))
  .settings(
    name := "answers"
  )

コンパイルの準備

リポジトリディレクトリへ移動し、sbtを起動し、プロジェクトをexerciseに移動します。

sbt:fpinscala> projects
[info] In file:/xxx/fpinscala/
[info]     answers
[info]     exercises
[info]   * root
sbt:fpinscala> project exercises
[info] set current project to exercises (in build file:/xxx/fpinscala/)

この時点でコンパイルすると以下のディレクトリでエラーが発生します。かなり後半の演習問題ですし、ここで引っかかってしまうと先に進まなくなるので、この章の演習問題を解き終えるまではディレクトリごと別の場所に移動しておきます。直すのはその章の演習問題を解くまで持ち越しです。

exercises/src/main/scala/fpinscala/applicative/
exercises/src/main/scala/fpinscala/iomonad/
exercises/src/main/scala/fpinscala/streamingio/

上記のディレクトリを一旦移動したらコンパイルします。

> compile
[info] compiling 15 Scala sources to /xxx/fpinscala/exercises/target/scala-3.1.1/classes ...
[warn] -- [E121] Pattern Match Warning: /xxx/fpinscala/exercises/src/main/scala/fpinscala/datastructures/List.scala:31:9
[warn] 31 |    case _ => 101
[warn]    |         ^
[warn]    |Unreachable case except for null (if this is intentional, consider writing case null => instead).
[warn] one warning found
[warn] one warning found
[success] Total time: 4 s, completed Jan 23, 2022, 9:06:34 PM

途中でワーニングが出ていますが、コンパイルは完了しました。

演習問題を解く前に

Scala関数型デザイン&プログラミング』は、本を読むだけだとなかなか理解が追いつかないというか、必要なことは全部書かれていないので、並行して演習問題の回答や、豊富なWikiを読むことをおすすめします。

github.com

また、「Part III 関数型デザインに共通する構造」あたりを読み始める時にcatsの入門ドキュメントなど、関連するトピックを扱った記事を合わせて読むと理解が広がります。

Scala with Cats

Scala With Cats を読む前に知っておきたかったこと - MicroAd Developers Blog

演習問題自体はかなり難易度が高いものも多いので、解けない時は寄り道をして、リフレッシュしていきましょう。

AirSpecの使い方

テスティングフレームにはairframe-logによるログ出力が便利なAirSpecを使うことにしました。アサーションの数が多すぎず、覚えていられるくらいの数、というのもポイントです。

詳しい使い方は公式サイトを見てください。

wvlet.org

ほとんどのテストは、shouldBeの左側にテストしたいコードを書き、右側に期待値を書く、という書き方で事足ります。

たとえば、あらかじめ用意されているMyModule.absという関数のテストをGettingStartedSpecというテストクラスを作って実行してみましょう。

まず、exercises/src/test/scala/fpinscala/gettingstarted/GettingStartedSpec.scalaというファイルを作って、以下のコードを書きます(Scala3らしく、classを囲む中括弧を省略しています...AirSpec traitの後のコロンと、end GettingStartedSpecという記述に注意)。

package fpinscala.gettingstarted

import wvlet.airspec.*

class GettingStartedSpec extends AirSpec:

  test("abs") {
    MyModule.abs(1) shouldBe 1
    MyModule.abs(0) shouldBe 0
    MyModule.abs(-1) shouldBe 1
  }

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

sbt:exercises> test
[info] compiling 1 Scala source to /xxx/fpinscala/exercises/target/scala-3.1.1/test-classes ...
GettingStartedSpec:
 - abs 10.04mses / Test / compileIncremental 0s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 1 s, completed Jan 24, 2022, 1:21:04 AM

テストが完了し、無事にすべて成功しました。

次にテストの失敗を試します。最後のテストの期待値を書き換えます。

package fpinscala.gettingstarted

import wvlet.airspec.*

class GettingStartedSpec extends AirSpec:

  test("abs") {
    MyModule.abs(1) shouldBe 1
    MyModule.abs(0) shouldBe 0
    MyModule.abs(-1) shouldBe -1
  }

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

sbt:exercises> test
[info] compiling 1 Scala source to /xxx/fpinscala/exercises/target/scala-3.1.1/test-classes ...
GettingStartedSpec:
 - abs 16.66ms << failed: 1 didn't match with -1 (GettingStartedSpec.scala:10)
[error] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error]     fpinscala.gettingstarted.GettingStartedSpec
[error] (Test / test) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 1 s, completed Jan 24, 2022, 1:39:24 AM

テストは失敗し、何が一致しなかったのか、どの行で発生しなかったのかが分かるログが出力されているので、これを手がかりに解析を行う、という流れになります。


余談ですがAirSpecのドキュメントには、ログが見づらくなるのでテストが遅くなってもいいから並列処理を止めたい、という時はbuild.sbtTest / parallelExecution := falseを追加する、と書かれています。この辺りの挙動の詳細は以下のブログ記事が参考になります。

xuwei-k.hatenablog.com

今回はすべて同じプロジェクトにまとまっていますので、上記の指定だけで十分です。

Scala3で導入されたOptional Bracesについて

Scala3からOptional Bracesという記法が導入され、これまでScalaにおける基本的なコードのまとまりの単位であった中括弧を使ったC言語Javaっぽい記法に加え、インデントベースのPythonっぽい記法が可能になりました。

詳しくは公式ドキュメントを参照してください。

Optional Braces | Scala 3 Language Reference | Scala Documentation

今回、演習問題を解くにあたっては、Optional Bracesを使って書き換えていきます。

しかし、先ほどのテストコードの例ではまだ中括弧が残っていたままでした。

  test("abs") {
    MyModule.abs(1) shouldBe 1
    MyModule.abs(0) shouldBe 0
    MyModule.abs(-1) shouldBe -1
  }

これはScala3.1.1時点では、関数の引数に渡すコードブロックの中括弧を省略できないためです。将来は標準でそれができるようになる見込みですが、先ほどのドキュメントの最後に書かれているとおり、現状ではexperimentalオプションの指定が必要です。

import language.experimental.fewerBraces

しかも、このオプションは正式リリース版では使えず、nightlyビルドでしか使えません。

nightlyビルドはcentaral repositoryにもアップされていますので、build.sbtに適当に最新のnightlyビルドのバージョンを指定することで下記のように記述できます。

package fpinscala.gettingstarted

import wvlet.airspec.*


class GettingStartedSpec extends AirSpec:

  import language.experimental.fewerBraces

  test("abs"):
    MyModule.abs(1) shouldBe 1
    MyModule.abs(0) shouldBe 0
    MyModule.abs(-1) shouldBe 1

end GettingStartedSpec

この記法だとコードブロックを渡していると読み取りづらい気もしますし、現状はexperimental扱いなので、将来的には変更される可能性があります。この記法は今回は採用しないこととします。

おわりに

これで演習問題を解く準備ができました。

次回からは、実際に第2章の演習問題を解いていきます。