Scalaの基本【その3】ケースクラス・エラー処理
Scalaのケースクラス・パターンマッチング・エラー処理(Option, Either, Try)・Implicit(暗黙の型変換・パラメータ)の使い方を徹底解説!サンプルコード付きで詳しく解説します。
動作環境
Scala:2.12
ケースクラスとパターンマッチング
簡単なケースクラスによるデータ型の定義
sealed abstract class DayOfWeek
case object Sunday extends DayOfWeek
case object Monday extends DayOfWeek
case object Tuesday extends DayOfWeek
case object Wednesday extends DayOfWeek
case object Thursday extends DayOfWeek
case object Friday extends DayOfWeek
case object Saturday extends DayOfWeekCやJavaとの列挙型との違いは、
- 各々のデータは独立してパラメータを持つことができること
パターンマッチングを用いて定義することで、
- ノードの種類と構造によって分岐する
- ネストしたノードを分解する
- ネストしたノードを分解した結果を変数に束縛する
という3つの動作が同時に行える。
ある日の次の曜日を返すメソッドnextDayOfWeekをパターンマッチを用いて定義している例:
def nextDayOfWeek(d: DayOfWeek): DayOfWeek = d match {
case Sunday => Monday
case Monday => Tuesday
case Tuesday => Wednesday
case Wednesday => Thursday
case Thursday => Friday
case Friday => Saturday
case Saturday => Sunday
}実行例:
scala> nextDayOfWeek(Sunday)
res0: DayOfWeek = Monday※ 変数宣言でもパターンマッチングを行うことが可能
変数宣言でパターンマッチングを行なっている例:
scala> case class Point(x: Int, y: Int)
defined class Point
scala> val Point(x, y) = Point(10, 20)
x: Int = 10
y: Int = 20部分関数
無名関数とパターンマッチングの2つの機能を組み合わせた、部分関数(PartialFunction)がScalaには存在する
無名関数の具体的なユースケース:
scala> List(1, 2, 3, 4 ,5).collect { case i if i % 2 == 1 => i * 2 }
res0: List[Int] = List(2, 6, 10)ここで、collectメソッドは、pf: PartialFunction[A, B]を引数に取り、pf.isDefinedAt(i)がtrueになる要素のみを残し、さらに、pf.apply(i)の結果の値を元にした新しいコレクションを返します。
isDefinedAtは、
i % 2 == 1の部分から自動的に生成され、パターンがマッチするときのみ真(true)になるように定義される。
エラー処理
Scalaでのエラー処理は
- 例外を使う方法
- OptionやEitherやTryなどのデータ型を使う方法
の2つの方法があり、状況に応じて使い分けることになる。
エラー処理で実現しなければならないこと
- 例外安全性
- 例外が発生してもシステムがダウンしたり、データの不整合などの問題が起きないようにする概念のこと
- 強い例外安全性
- 例外が発生した場合、全ての状態が例外発生前に戻らなければならないという概念のこと
例外の問題点
Javaのエラー処理は例外が中心的な役割を担っていて、Scalaでも例外は多く使われる。しかし、例外は便利な反面、以下のような問題もある。
- 例外と使うとコントロールフローがわかりづらくなる
- 適切に使えば正常系の処理とエラー処理を分離し、コードの可読性を上げ、エラー処理をまとめる効果があるが、例外のcatch漏れや、例外をcatchしているところで、どこで発生した例外をcatchしているのか判別できないため、コードの修正を阻害する場合もある
- 例外は非同期プログラミングでは使えない
- 例外の動作は送出されたらcatchされるまで、コールスタックを遡っていくというもの。そのため、別スレッド・別のイベントループなどで実行される非同期プログラミングでは相容れない
- 例外は型チェックできない
- チェック例外を使わない場合、メソッドの型として表現されない。(Scalaでは、高階関数でチェック例外を扱うことが難しいという問題が大きく、チェック例外の機能は無くなっている)また、間違った例外をキャッチしているかは、実行時にしか分からない。
Option
OptionはScalaで多用されるデータ型の1つ。Option型には、以下の2つの値が存在する。
SomeNone
Optionに具体的な値が入っている場合の実行例:
scala> val o: Option[String] = Option("hoge")
o: Option[String] = Some(hoge)
scala> o.get
res0: String = hoge
scala> o.isEmpty
res1: Boolean = false
scala> o.isDefined
res2: Boolean = trueOptionにnullが入っている場合の実行例:
scala> val o: Option[String] = Option(null)
o: Option[String] = None
scala> o.isEmpty
res0: Boolean = true
scala> o.isDefined
res1: Boolean = falseOptionのパターンマッチ
Optionは型を持っているため、パターンマッチを使って処理することもできる。
実行例:
scala> val s: Option[String] = Some("hoge")
s: Option[String] = Some(hoge)
scala> val result = s match { | case Some(str) => str | case None => "not matched" | }
result: String = hoge上記のようにSomeかNoneにパターンマッチすることで、Someにパターンマッチする場合には、その中身の値をstrという別の変数に束縛することが可能。
Optionの入れ子を解消する
キャッシュから情報を取得する場合、キャッシュヒットする場合と、キャッシュミスする場合があり、それらは、ScalaではOption型で表現されることが多い。以下のようなキャッシュ取得が連続で繰り返された場合、Optionが入れ子になってしまう。
1つ目と2つ目の整数の値がOptionで返ってきて、それをかけた値を求める実行例:
scala> val v1: Option[Int] = Some(3)
v1: Option[Int] = Some(3)
scala> val v2: Option[Int] = Some(5)
v2: Option[Int] = Some(5)
scala> v1.map(i1 => v2.map(i2 => i1 * i2))
res0: Option[Option[Int]] = Some(Some(15))Optionの入れ子を解消するためには、flattenを実行すれば良い。
flattenを実行し、Optionの入れ子を解消している例:
scala> val v1: Option[Int] = Some(3)
v1: Option[Int] = Some(3)
scala> val v2: Option[Int] = None
v2: Option[Int] = None
scala> v1.map(i1 => v2.map(i2 => i1 * i2)).flatten
res0: Option[Int] = None上記のように、v2がNoneである場合でも、flattenは成立する。そのため、キャッシュミスでSomeの値が取れなかった際も問題なく処理が動く。
flatMap
mapとflattenは、実際はこの両方を組み合わせて使うことが多い。flatMapは、Optionにmapをかけてflattenを適用してくれるメソッド
scala> val v1: Option[Int] = Some(3)
v1: Option[Int] = Some(3)
scala> val v2: Option[Int] = Some(5)
v2: Option[Int] = Some(5)
scala> val v3: Option[Int] = Some(7)
v3: Option[Int] = Some(7)
scala> v1.flatMap(i1 => v2.flatMap(i2 => v3.map(i3 => i1 * i2 * i3)))
res0: Option[Int] = Some(105)Either
Eitherは、エラー時にエラーの種類まで取得できる。Optionでは、Noneが返ってきた場合、値が取得できなかったことはわかるがエラーの状態は取得できないので、エラーの種類がわからなくても問題にならない場合のみ使える。具体的には、OptionはSomeとNoneの2つの値を持つが、EitherはRightとLeftの2つの値を持つ。
Try
TryはEitherと同じように正常な値とエラー値のどちらかを表現するデータ型。Eitherとの違いは、2つの型が平等ではなく、エラー値がThrowableに限定されており、型引数を1つしか取らないこと。具体的には、TryはSuccessとFailureの2つの値を持つ。
Successは型変数を取り任意の値を入れることができるが、FailureはThrowableしか入れることができない。そして、Tryには、コンパニオンオブジェクトのapplyで生成する際に、例外をcatchしFailureにする機能がある。
Tryの実行例:
scala> import scala.util.Try
import scala.util.Try
scala> val v: Try[Int] = Try(throw new RuntimeException("to be caught"))
v: scala.util.Try[Int] = Failure(java.lang.RuntimeException: to be caught)この機能を使って、例外が起こりそうな箇所をTryで包み、Failureにして値として扱えるようにするのがTryの特徴
scala> val v1 = Try(3)
v1: scala.util.Try[Int] = Success(3)
scala> val v2 = Try(5)
v2: scala.util.Try[Int] = Success(5)
scala> val v3 = Try(7)
v3: scala.util.Try[Int] = Success(7)
scala> for {
| i1 <- v1
| i2 <- v2
| i3 <- v3
| } yield i1 * i2 * i3
res0: scala.util.Try[Int] = Success(105)NonFatalの例外
Try.applyがcatchするのは全ての例外ではなく、NonFatalという種類の例外のみ。その理由は、NonFatalでないエラーはアプリケーション中で復旧が困難で重度なものであるから。
Try以外でも、扱うことができる全ての例外をまとめて処理する場合などに以下のようなコードが出てくることがある。
import scala.util.control.NonFatal
try { ???
} catch { case NonFatal(e) => // 例外の処理
}OptionとEitherとTryの使い分け
- Optionは、Javaでnullを使うような場面で使うのが良い。
- Eitherは、Optionを使うのでは情報が不足しており、エラー状態が代数的データ型として定められるものに使うのが良い。Javaでチェック例外を使っていたような復帰可能なエラーだけに使うという考え方でも良い。
- Tryは、Javaの例外をどうしても値として扱いたい場合に使うのが良い。非同期プログラミングや、実行結果を保存し中身を参照する場合などに使うことも考えられる。
Implicit
Implicit Conversion(暗黙の型変換)
implicit conversionは暗黙の型変換機能をユーザーが定義できるようにする機能。
implicit conversionの定義例:
implicit def メソッド名(引数名: 引数の型): 返り値の型 = 本体implicit conversionは大きく分けて二通りの使われ方をする
- 新しく定義したユーザー定義の型などを既存の型に当てはめたい場合
- pimp my libraryパターンと呼ばれる、既存のクラスにメソッドを追加し拡張する(ようにみせかける)使い方
1つ目の実行例:
scala> implicit def intToBoolean(arg: Int): Boolean = arg != 0
intToBoolean: (arg: Int)Boolean
scala> if(1) {
| println("1はTrueである")
| }
1はTrueであるといった形で、本来Booleanしか渡せないはずにif式にIntを渡すことができる。※ implicit conversion を定義することで、コンパイラで本来はif文の条件式にはBoolean型の式しか渡せないようチェックしているものを通り抜けてしまうため、本当にその変換が必要であるかよく考える必要がある
2つ目の実行例1:
scala> class RichString(val src: String) {
| def smile: String = src + "(^ ^)"
| }
defined class RichString
scala> implicit def enrichString(arg: String): RichString = new RichString(arg)
enrichString: (arg: String)RichString
scala> "Hi! ".smile
res0: String = Hi! (^ ^)コンパイラは、ある型に対するメソッド呼び出しを見つけたとき、そのメソッドを定義した型がimplicit conversionの返り値の型にないか検索し、型が合ったらimplicit conversionの呼び出しを挿入する。2つ目の実行例1 では、Stringの末尾に"(^ ^)"という文字列を追加して返すimplicit conversionを定義している。
2つ目の実行例1は、Scala 2.10以降では、以下のように書き直すことが可能
2つ目の実行例2:
scala> implicit class RichString(val src: String) {
| def smile: String = src + "(^ ^)"
| }
defined class RichString
scala> "Hi! ".smile
res0: String = Hi! (^ ^)Implicit Parameter(暗黙のパラメータ)
implicit Parameterは大きく分けて二通りの使われ方をする
- メソッドで共通に引き渡されるオブジェクト(ソケットやDBのコネクションなど)を明示的に引き渡すのを省略するために使う
- 型クラスを定義・使用することを可能にするために使う
1つ目の使い方の具体例)以下のようなDBとのコネクションを表すConnection型があるとする。DBと接続するメソッドは、全てこのConnection型を引き渡さなければならない。
def useDatabase1(..../ conn: Connection)
def useDatabase2(..../ conn: Connection)
def useDatabase3(..../ conn: Connection)この3つのメソッドは共通してConnection型を引数に取るにも関わらず、呼び出す度に明示的にConnectionオブジェクトを渡さなければならず面倒である。上のメソッド定義をimplicit parameterを使い以下のように書き換える。
def useDatabsse1(....)(implicit conn: Connection)
def useDatabsse2(....)(implicit conn: Connection)
def useDatabsse3(....)(implicit conn: Connection)Scalaのコンパイラは、このように定義されたメソッドが呼び出されると、現在のスコープからたどって直近のimplicitとマークされた値を暗黙でメソッドに引き渡す。
値をimplicitとしてマークする例:
implicit val connection: Connection = connectDatabase(....)このようなimplicit parameterの使い方は、Play 2 FrameworkやScalaの各種O/Rマッパーで頻出する。
2つめの使い方の具体例として、Listの全ての要素の値を加算した結果を返すsumメソッドを定義したいとする。この際、「何の」Listか全く分かっていない場合、整数の+メソッドをそのまま使うことはできない。この問題を解決するために、2つの手順を踏む。まず、2つの同じ型を足す(0の場合はそれに相当する値を返す)方法を知っている型を定義する。ここでは、その型をAddtiveとする。
Additiveの定義:
trait Additive[A] {
def plus(a: A, b: A): A
def zero: A
}ここで、Additiveの型パラメータAは加算されるListの要素の型を表している。また、
zero:型パラメータAの0に相当する値を返すplus:型パラメータAを持つ2つの値を加算して返す
となっている。
次に、このAdditive型を使って、Listの全ての要素を合計するメソッドを定義する
def sum[A](lst: List[A])(m: Additive[A]) = lst.foldLeft(m.zero)((x, y) => m.plus(x, y))後は、それぞれの型に応じた加算と0の定義を持ったobjectを定義する。ここでは、StringとIntについて定義する。
object StringAdditive extends Additive[String] {
def plus(a: String, b: String): String = a + b
def zero: String = ""
}
object IntAdditive extends Additive[Int] {
def plus(a: Int, b: Int): Int = a + b
def zero: Int = 0
}これで、Int型のListもString型のListのどちらの要素の合計も計算できる汎用的なsumメソッドができた。まとめると、以下のようになる。
Int型のListもString型のListのどちらの要素の合計も計算できるsumメソッドの実装例:
trait Additive[A] {
def plus(a: A, b: A): A
def zero: A
}
object StringAdditive extends Additive[String] {
def plus(a: String, b: String): String = a + b
def zero: String = ""
}
object IntAdditive extends Additive[Int] {
def plus(a: Int, b: Int): Int = a + b
def zero: Int = 0
}
def sum[A](lst: List[A])(m: Additive[A]) = lst.foldLeft(m.zero)((x, y) => m.plus(x, y))sumメソッドの実行例:
scala> sum(List(1, 2, 3))(IntAdditive)
res0: Int = 6
scala> sum(List("S", "w", "i", "f", "t"))(StringAdditive)
res1: String = Swiftこれで、目的は果たすことができたが、sumメソッドを使えば何のListの要素を合計するかは型チェックで分かっているのだから、引数にIntAdditive,StringAdditiveを明示的に渡すことなく、推論してほしい。前置きが長くなってしまったが、これをimplicit parameterで実現することができる。
方法は、
IntAdditive,StringAdditiveの定義の前にimplicitと付けるsumの最後の引数リストのmにimplicitを付ける
だけである。
implicit parameterを使い、IntAdditive,StringAdditiveを明示的に渡すことなく推論可能にした形は以下の通りである。
trait Additive[A] { def plus(a: A, b: A): A def zero: A
}
implicit object StringAdditive extends Additive[String] { def plus(a: String, b: String): String = a + b def zero: String = ""
}
implicit object IntAdditive extends Additive[Int] { def plus(a: Int, b: Int): Int = a + b def zero: Int = 0
}
def sum[A](lst: List[A])(implicit m: Additive[A]) = lst.foldLeft(m.zero)((x, y) => m.plus(x, y))実行例:
scala> sum(List(1, 2, 3))
res0: Int = 6
scala> sum(List("S", "w", "i", "f", "t"))
res1: String = Swiftこの用法は、Scalaの標準ライブラリにも使われており、
scala> List(1, 2, 3, 4).sum
res0: Int = 10
scala> List(1.5, 2.5, 3, 4).sum
res1: Double = 11.0のように、整数や浮動小数点数の合計値を気にすることなく計算することができる。このように、型クラスを定義・使用することを可能にするために使うことで、コード設計の幅が広がる。
おわりに
内容を振り返ってみて、Scalaの基礎文法などを一通り学ぶことができました。これからも、後から見直しやすいようにまとめていくことは続けていきたいと思います。
本記事は、Scala研修テキストよりライセンスCC BY-NC-SA 3.0に基づき、内容をまとめさせていただきました。
書籍だと以下の本が良かったです😄