新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。気づけば社会人3年目に突入。

学習記録:ドワンゴ 新人向けScala研修テキスト

14. エラー処理(続き)

エラーを表現するデータ型を使った処理

ここでは正常の値とエラー値のどちらかを表現できるデータ構造を紹介し,Scalaにおける関数型のエラー処理の方法を見ていく。

Option

Scalaでもっとも多用されるデータ型の1つで,Javaのnullの代替として使われることが多い。
Option型には,SomeかNoneの2つの具体的な値が存在し,Someは何かしらの値が格納されているとき,Noneは何も格納されていないときの型になる。

scala> val o:Option[String] = Option("hoge")
o: Option[String] = Some(hoge)

scala> o.isEmpty
res1: Boolean = false

scala> o.get
res0: String = hoge

scala> val n:Option[String] = Option(null)
n: Option[String] = None

scala> n.isEmpty
res2: Boolean = true

scala> n.get
java.util.NoSuchElementException: None.get
  at scala.None$.get(Option.scala:347)
  at scala.None$.get(Option.scala:345)
  ... 42 elided

Option作成時の引数がnullの場合には値がNoneになっていることがわかる。getメソッドを呼び出すとNoSuchElementExceptionが発生しているので,これがぬるぽと同様と思われるかもしれないが,Optionには以下の便利なメソッドによりそれを回避することができる。

scala> n.getOrElse("")
res5: String = ""

上記は中身がNoneだった場合に空文字を返すという意味のコードになる。値以外にも,下記のような処理を書くこともできる。

scala> n.getOrElse(throw new RuntimeException("nullは受られません"))
java.lang.RuntimeException: nullは受け入れられません
  at $anonfun$1.apply(<console>:13)
  at $anonfun$1.apply(<console>:13)
  at scala.Option.getOrElse(Option.scala:121)
  ... 44 elided

また,パターンマッチを使用して処理をすることもできる。

scala> n match {
     |   case Some(str) => println(str)
     |   case None => throw new RuntimeException
     | }
java.lang.RuntimeException
  ... 45 elided

Optionにはコレクションの性質があり,mapなどの関数を中の要素に適用できる。

scala> val o: Option[Int] = Option(3)
o: Option[Int] = Some(3)

scala> o.map(_ * 10)
res11: Option[Int] = Some(30)

値がNoneの場合には,map関数などを適用してもNoneのままになる。

scala> val n: Option[Int] = None
n: Option[Int] = None

scala> n.map(_ * 10)
res10: Option[Int] = None

この性質により,中身がSomeとNoneのどちらであったとしても同様の処理で記述でき,処理を分岐させる必要がない。
Javaで同様の処理を書こうとすると,n.isEmptyなどの結果によって分岐させ,nullの場合は例外を投げるような書き方になる。
Scalaで同様のこと,つまりNoneの場合にのみ実行する処理を記述するためには,fold関数を使用できる。
fold関数により,Noneの際に実行する処理を定義し,かつ関数を適用した中身の値を取得することができる。

scala> o.fold(throw new RuntimeException)(_ * 10)
res13: Int = 30

scala> n.fold(throw new RuntimeException)(_ * 10)
java.lang.RuntimeException
  at $anonfun$2.apply(<console>:13)
  at $anonfun$2.apply(<console>:13)
  at scala.Option.fold(Option.scala:158)
  ... 42 elided

実際の複雑なアプリケーションでは,Optionの値が入れ子になることがある。
例えば,2つの整数値がOptionで返ってきて,それを掛けあわせた値を求めるような場合である。

scala> val v1 = Some(3)
v1: Some[Int] = Some(3)

scala> val v2 = Some(7)
v2: Some[Int] = Some(7)

scala> v1.map(i1 => v2.map(i2 => i1 * i2))
res14: Option[Option[Int]] = Some(Some(21))

シンプルに実装すると,殘念なことにOption[Option[Int]]のように入れ子になってしまう。これを解消するためにはflattenを使う。

scala> v1.map(i1 => v2.map(i2 => i1 * i2)).flatten
res15: Option[Int] = Some(21)

さて,ここまででmapとflattenを説明したが,実際にはこの両方を組み合わせて使うことが多々ある。そのため,両方を適用するflatMapというメソッドが用意されている。

scala> v1.flatMap(i1 => v2.map(i2 => i1 * i2))
res19: Option[Int] = Some(21)

なお,flatMapとmapを複数回使用するような場合,for式を使うとシンプルに書ける。

scala> for { i1 <- v1
     |       i2 <- v2
     |       i3 <- v3 } yield i1 * i2 * i3
res0: Option[Int] = Some(105)
Either

Optionによりnullを使う必要はなくなったが,処理が成功したかどうか(値があるかないかどうか)しかわからないという問題がある。
Noneの場合,値が取得できなかったことはわかるが,エラーの種類まではわからないので,エラーの種類が問題にならないような場合にしか使用でいない。

Eitherは,エラー時にエラーの種類まで取得できる。EitherはRightとLeftの2つの値のどちらかを表現する。

scala> val v1: Either[String, Int] = Right(123)
v1: Either[String,Int] = Right(123)

scala> val v2: Either[String, Int] = Left("abc")
v2: Either[String,Int] = Left(abc)

一般的に,Leftをエラー値,Rightを正常な値に使用することが多い。
エラー値には,代数的データ型(sealed traitとcase classで構成されるデータ型)で定義された値を使うとよい。パターンマッチの章で解説したように,代数的データ型を使うことで,エラーの処理が漏れているかどうかをコンパイラが検知してくれるためである。Throwable型をエラー型に使うのなら,後述のTryを使うとよい。

では,実際にEitherを使ってログインのエラーを表現してみる。

sealed trait LoginError
// パスワードのミス
case object InvalidPassword extends LoginError
// ユーザが見つからない
case object UserNotFound extends LoginError
// パスワードがロックされている
case object PasswordLocked extends LoginError

以下のユーザクラスを使い,ログインに使用するloginメソッドを書いてみる。
loginメソッドは,ユーザ名とパスワードをチェックして,正しい組み合わせの場合はUserオブジェクトを返し,エラーが起きた場合はLoginErrorを返す。

case class User(id: Long, name: String, password: String)

object LoginService {
  def login(name: String, password: String): Either[LoginError, User] = ???
}

loginメソッドを呼び出して,printlnで中身を表示してみる。

LoginService.login(name = "hoge", password = "password") match {
  case Right(user) => println(s"id: ${user.id}")
  case Left(InvalidPassword) => println(s"Invalid Password!")
}

上記コードは,PasswordLockedとUserNotFoundの処理が漏れているため,コンパイル時に警告が出る。

このように,EitherはOptionの拡張版に近いが,mapとflatMapの動作に少し癖がある。
ScalaのEitherは直接mapやflatMapメソッドを持たない。というのも,ScalaのEitherは左右の型を平等に扱っているためである。例えばmapメソッドをEitherに適用するときを考えると,暗黙的に左右どちらかのうち片方を優先するか,明示的に左右どちらを優先するか指定する,の二択になる。前者がHaskellのアプローチで,後者がScalaのアプローチになる。
Eitherにはleftとrightというメソッドがあり,これが左右どちらを優先するか決めるメソッドになる。

scala> val v: Either[String, Int] = Right(123)
v: Either[String,Int] = Right(123)

scala> val e = v.right
e: scala.util.Either.RightProjection[String,Int] = RightProjection(Right(123))

scala> v.right.map(_ * 2)
res0: Product with Serializable with scala.util.Either[String,Int] = Right(246)

Eitherにrightメソッドを使うと,RightProjectionというオブジェクトになり,ListやOptionで見慣れたメソッドを使えるようになる。
ただし,RightProjectionのmapメソッドの返り値がEitherであることに注意してほしい。つまり,mapやflatMapを連鎖させて使う場合,毎回EitherをRightProjectionに変化させる必要があるということだ。
これはHaskellのように暗黙的にRightを優先するのに比べてわずらわしいと言われることもあるが,とりあえず,Eitherのmap/flatMapを使う場合,rightでRightProjectionに変化させるものだと覚えてしまってよい。

Try

ScalaのTryは,Eitherと同じように正常な値とエラー値のどちらかを表現するデータ型になる。
Eitherとの違いは2つの型が平等ではなく,エラー値がThrowableに限定されていることで,具体的にはTryはSuccessかFailureの2つの値をとる。
Successは型変数を取り任意の値を入れることができるが,FailureはThrowableしかいれることができない。

scala> import scala.util.Try
import scala.util.Try

scala> val s: Try[Int] = Try(2)
s: scala.util.Try[Int] = Success(2)

scala> val f: Try[Int] = Try(throw new RuntimeException("hoge"))
f: scala.util.Try[Int] = Failure(java.lang.RuntimeException: hoge)

Tryにはコンパニオンオブジェクトのapplyで生成する際に例外をcatchし,Failureにする機能がある。
この機能を使って,例外が起こりそうな箇所をTryで包み,Failureにして値として扱えるようにするのがTryの特徴である。
また,TryはEitherと異なり正常な値を片方に決めているので,mapやflatMapをそのまま使える。

Option/Either/Tryの使い分け

基本的にJavaでnullを使うような場面はOptionを使うのがよい。コレクションの中に存在しなかったり,ストレージ中から条件に合うものが見つからないような場合にはOptionで十分である。

Eitherは,Optionを使うのでは情報が不足しており,かつエラー状態が代数的データ型として定められるものに使うのがよい。Javaでチェック例外を使っていたようなところ,つまり復帰可能なエラーだけに使うという考えでもよい。Eitherと例外を併用するのもあり。

TryはJavaの例外をどうしても値として扱いたい場合に用いるとよい。非同期プログラミングで使ったり,実行結果を保存してあとで中身を参照したいときなど。

Optionの例外処理をEitherでリファクタする実例

RDBを使う場合,どのタイミングで情報が取得できなかったのかを返さねばならないことがある。

例えば,ユーザとアドレスがそれぞれ格納されており,ユーザidからユーザを検索して,ユーザが持つアドレスidでアドレスを検索し,さらにその郵便番号を取得するような場合を考える。
失敗結果としては,1) ユーザが見つからない,2) ユーザがアドレスを持っていない, 3) アドレスが見つからない, 4) アドレスが郵便番号を持っていない,という4つの失敗パターンがある。
これらを結果オブジェクトとして返さなくてはならない。

  ...(省略)...
 // どこでNoneが生じたか取得しようとするとfor式がつかえず地獄のようなネストになる
  def getPostalCodeResult(userId: Int): PostalCodeResult = {
    findUser(userId) match {
      case Some(user) =>
        user.addressId match {
          case Some(addressId) =>
            findAddress(addressId) match {
              case Some(address) =>
                address.postalCode match {
                  case Some(postalCode) => Success(postalCode)
                  case None => AddressNotHasPostalCode
                }
              case None => AddressNotFound
            }
          case None => UserNotHasAddress
        }
      case None => UserNotFound
    }
  }

  def findUser(userId: Int): Option[User] = {
    userDatabase.get(userId)
  }
  ...(省略)...

Noneをただ処理するだけならflatMapやfor式でスッキリ書けるが,どのタイミングでNoneが取得されたのかを返したい場合にはそういうわけにはいかず,結局match caseの深いネストになってしまう。
このような場合,全てのfindメソッドでEitherを使うようにし,FailureをLeftに正常結果をRightに入れるようにして書き直すことですっきり書ける。

  // 本質的に何をしているかわかりやすくリファクタリング
  def getPostalCodeResult(userId: Int): PostalCodeResult = {
    (for {
      user <- findUser(userId).right
      address <- findAddress(user).right
      postalCode <- findPostalCode(address).right
    } yield Success(postalCode)).merge
  }

  def findUser(userId: Int): Either[Failure, User] = {
    userDatabase.get(userId).toRight(UserNotFound)
  }

学習記録:ドワンゴ 新人向けScala研修テキスト

14. エラー処理

Scalaでのエラー処理は例外を使う方法と,Option/Either/Tryなどのデータ型を使う方法を状況に応じて使い分ける。
まずは扱う必要のあるエラーとエラー処理の性質について確認する。

エラーとは

ユーザからの入力

ユーザから受け取る不正な入力として,文字列が長すぎる,正しいフォーマットではないなどがある。
また,悪意のある攻撃者から攻撃を受けることもある。

外部サービスのエラー

プログラムが利用する外部サービスとの連携エラーも考えられる。
接続できない,回線の都合で接続が切れる,メール送信エラーなど。

内部エラー

内部要因でエラーが発生することも考えられる。
バグによるプログラムの終了,内部で利用するDBサーバが落ちる,メモリやディスク不足,処理時間の長さによるタイムアウトなど。

エラー処理で実現しなければならないこと

例外安全性

例外が発生してもシステムがダウンしたり,データ不整合などの問題が起きないことを例外安全という。
この概念はエラー処理全般に当てはまる。

強い例外安全性

さらに強い概念である「強い例外安全性」といい,これは例外が発生した場合すべての状態が例外発生前に戻らなければならないという制約を指す。
一般的にはこの制約を満たすのは難しいが,例えば課金処理でエラーになった場合は,確実にエラーを検出して処理を取り消す必要がある。
どのような処理に強い例外安全性が求められるか判断し,どう実現するか考える必要がある。

Javaにおけるエラー処理

Javaのエラー処理の方法を適用できることも多い。ここではJavaのエラー処理の注意点について復習する。

nullを返すことでエラーを表現する場合

Javaではnullでエラーを表現することがある。エラー値を他に用意する必要がないという点では便利だが,返り値のnullチェックを忘れるとぬるぽエラーになってしまう。

プリミティブ型以外の参照型はすべてnullになりうるので,メソッドがnullを返すかどうかはドキュメントに書いておかないとわからない(型で判断できない)。
また,nullをエラー値に使うと暗黙的なエラー状態をいたるところに持ち込むことになり,発見困難なバグを生む要因になる。ScalaではOptionというデータ構造によりこれを解決する。

例外を投げる場合

例外は今実行している処理を中断して大域的に実行を移動できる便利な機能だが,濫用すると処理の流れがわかりづらくなる。
例外はエラー状態にのみ利用し,メソッドが正常な値を返す場合には使用すべきではない。

チェック例外

Javaにはメソッドにthrowsを付けることで,メソッドを使う側に例外を処理することを強制するチェック例外という機能もある。
例外の発生を表現しコンパイラにチェックさせるという点で便利だが,使う側が適切に処理できない例外を上げられると無意味なエラー処理コードを書かざるを得なくなる。
チェック例外は利用者側がcatchして回復できる場合にのみ利用すべき。

例外翻訳

今までHTTPで取得したデータをMySQLに保存するようになった場合,HTTPExceptionが投げられていた箇所がSQLExceptionを投げるようになるといったことが考えられる。
このような低レベルの実装の変更で,catchする側で処理を変更するのを防ぐため,途中の層で例外をcatchして適切な例外で包んで投げ直すことを,例外翻訳と呼ぶ。
これも濫用すると例外の種類が増えて例外処理が煩雑になる可能性があるので注意が必要。

例外をドキュメントに書く

チェック例外でない例外はAPIから読み取ることができない。
更に,Scalaではチェック例外がないので,メソッドの型からはどんな例外を投げるかは判別できないので,APIドキュメントには発生しうる例外についても書いておくべき。

例外の問題点

Scalaでも例外は多く使われるが,便利な反面様々な問題もある。
ここで例外の問題点を把握する。

往々にして例外のcatch漏れが発生する。また,catch部分ではどこで発生した例外をcatchしているのか判別できない。

  • 非同期プログラミングでは使えない

送出されたらcatchされるまでコールスタックを遡っていくという性質上,別スレッドなどで実行される非同期プログラミングとは相容れない。

  • 型チェックできない

チェック例外を使わない限り,どんな例外が発生するのかメソッドからは判別できず,catchする側でも正しい例外をキャッチしているかは実行時にしかわからない。

Scalaではチェック例外の機能はなくなっている。上記の問題点のほか,以下のような問題点が理由としてあったと考えられる。

  • 高階関数でチェック例外を扱うのが難しい
  • ボイラープレートが増える
  • 例外翻訳を多用せざるを得ない

Scalaではチェック例外の代替として,エラーを表現するデータ型を使い,エラー処理を型安全にすることができる。

学習記録:ドワンゴ 新人向けScala研修テキスト

13. ケースクラスとパターンマッチング

パターンマッチングはCやJavaのswitch文に似ているが,より強力な機能である。
パターンマッチングの真価を発揮するには,ケースクラスによるデータ型の定義が必要になる。

sealed abstract class DayOfWeek
case object Sunday extends DayOfWeek
case object Monday extends DayOfWeek
...
case object Saturday extends DayOfWeek

上記は曜日を表すデータ型で,CやJavaenumと同じように使うことができる。

scala> val x: DayOfWeek = Sunday
x: DayOfWeek = Sunday

objectまたはその他のデータ型は,パターンマッチングのパターンを使うことができる。
この例では,DayOfWeek型を継承した各objectをパターンマッチングのパターンに使うことができる。

scala> x match {
     |   case Sunday => 0
     |   case Monday => 1
     |   case Tuesday => 2
     |   case Wednesday => 3
     |   case Thursday => 4
     |   case Friday => 5
     | }
<console>:22: warning: match may not be exhaustive.
It would fail on the following input: Saturday
       x match {
       ^
res23: Int = 0

これはxがSundayなら0を,Mondayなら1を...返すパターンマッチであるが,パターンマッチに漏れがあった場合,コンパイラが警告してくれることがわかる。
この警告は,sealed修飾子をスーパークラス/トレイトに付けることによって,そのサブクラス/トレイトは同じファイル内にしか定義できないという性質を利用して実現されている。
この用途以外ではめったにsealedは使われないので,ケースクラスのスーパークラス/トレイトにはsealedを付けるものだと覚えておけばよい。

これだけだとenumとあまり変わらないように見えるが,各々のデータは独立してパラメータを持つことができること,パターンマッチの際にデータを分解することができるのが特徴である。
例えば,四則演算を表す構文木を考える。各ノードExpを継承し,二項演算を表すノードは子として左辺lhsと右辺をrhsを持つ。

sealed abstract class Exp
case class Add(lhs: Exp, rhs: Exp) extends Exp
case class Sub(lhs: Exp, rhs: Exp) extends Exp
case class Mul(lhs: Exp, rhs: Exp) extends Exp
case class Div(lhs: Exp, rhs: Exp) extends Exp
case class Lit(value: Int) extends Exp

葉ノードとして整数リテラルを表すLitも作成する。これはIntの値を取る。
この定義から,1 + ((2 * 3) / 2)という式を表すノードを構築する。

scala> val example = Add(Lit(1), Div(Mul(Lit(2), Lit(3)), Lit(2)))
example: Add = Add(Lit(1),Div(Mul(Lit(2),Lit(3)),Lit(2)))

このexampleノードを元に四則演算を定義する関数を定義してみる。

scala> def eval(exp: Exp): Int = exp match {
     | case Add(l, r) => eval(l) + eval(r)
     | case Sub(l, r) => eval(l) - eval(r)
     | case Mul(l, r) => eval(l) * eval(r)
     | case Div(l, r) => eval(l) / eval(r)
     | case Lit(v) => v
     | }
eval: (exp: Exp)Int

この関数にexampleを渡すと,解として4が返ってくる。

scala> eval(example)
res25: Int = 4

ここで注目すべきは,パターンマッチによって,

  1. ノードの種類と構造によって分岐する
  2. ネストしたノードを分解する
  3. ネストしたノードを分解した結果を変数に束縛する

という3つの動作が同時に行えていることである。これがケースクラスを使ったデータ型とパターンマッチングの組み合わせの強力さになる。

変数宣言におけるパターンマッチング

変数宣言でもパターンマッチングを行うことができる。

scala> case class Point(x: Int, y: Int)
defined class Point

上記ケースクラスPointに対して,

scala> val Point(x, y) = Point(10, 20)
x: Int = 10
y: Int = 20

とすると,xとyに10と20が束縛される。

学習記録:ドワンゴ 新人向けScala研修テキスト

12. Scalaのコレクションライブラリ

Scalaには一度作成したら変更できないimmutableなコレクションと変更できる通常のmutableなコレクションがある。
Scala関数型プログラミングを行うためには,immutableなコレクションを活用する必要がある。

immutableなコレクションにはいくつものメリットがある。

  • 関数型プログラミングで多用する再帰との相性が良い
  • 高階関数を用いて簡潔なプログラムが書ける
  • 一度作ったコレクションが知らない箇所が変更されていないことを保証できる
  • 並行動作するプログラムで安全に受け渡しできる

この節では,Scalaのコレクションライブラリに含まれるArray, Range, List, Map, Setについて概要を説明する。

Array(mutable)

scala> val arr = Array(1, 2, 3, 4, 5)
arr: Array[Int] = Array(1, 2, 3, 4, 5)

配列の型を指定しなくていいのは,要素がIntであるとコンパイラ型推論してくれるため。
省略せずに書くとArray[Int](1,2,3,4,5)というようになる。

Scalaの配列は,他の言語のそれと同じように要素の中身を入れ替えることができ,添字は0から始まる。

scala> arr(0)
res3: Int = 1

scala> arr(0) = 7

scala> arr(0)
res5: Int = 7

scala> arr
res6: Array[Int] = Array(7, 2, 3, 4, 5)

他の言語だとarr[0]のようにアクセスすることが多いが,Scalaではarr(0)とすることに注意。
配列の長さはarr.lengthで取得することができる。

scala> arr.length
res7: Int = 5

Array[Int]はJavaでは int[]と同じ意味になる。
実装上はJVMの配列であり,要素が同じでもequalsの結果がtrueにならない,生成する際にClassTagというものが必要などいくつかの罠があるため,
Arrayはパフォーマンス上必要になる場合以外はあまり積極的に使うものではない。

Range(immutable)

Rangeは範囲を表すオブジェクトで,直接名前を指定して生成するより,toメソッドとuntilメソッドを用いて呼び出すことが多い。
また,toListメソッドでListに変換することもできる。

scala> 1 to 5
res10: scala.collection.immutable.Range.Inclusive = Range(1, 2, 3, 4, 5)

scala> (1 to 5).toList
res11: List[Int] = List(1, 2, 3, 4, 5)

scala> 1 until 5
res12: scala.collection.immutable.Range = Range(1, 2, 3, 4)

List(immutable)

ScalaではArrayを使うことはそれほど多くなく,代わりにListやVectorといったデータ構造をよく使用する。
Listの特徴は,一度作成したら中身を変更できない(immutable)ということである。

scala> val lst = List(1, 2, 3, 4, 5)
lst: List[Int] = List(1, 2, 3, 4, 5)

scala> lst(0) = 7
<console>:13: error: value update is not a member of List[Int]
       lst(0) = 7
       ^

上記のように,Listは一度作成したら値を更新することができない。
しかし,あるListを元に新しいListを作ることはできるので,これが値を更新することの代わりになる。

Nil:空のList

Scalaで空のListを表すにはNilというものを使う。
Scalaではデフォルトでスコープに入っているということ以外は特別ない見はなく,単なるobjectである。
Nilは単体では意味はないが,次に説明する::と合わせて用いることが多い。

:: :Listの先頭に要素をくっつける
(コンス)は既にあるListの先頭に要素をくっつけるメソッドである。
scala> val a1 = 1 :: Nil
a1: List[Int] = List(1)

scala> val a2 = 2 :: a1
a2: List[Int] = List(2, 1)

scala> val a3 = 3 :: a2
a3: List[Int] = List(3, 2, 1)

付け足したい要素を::を挟んでListの前に書くことで,Listの先頭に要素がくっついていることがわかる。
なお,::はやや特別な呼び出し方をするメソッドである。
まず,Scalaでは1引数のメソッドは中置記法で書くことができる。それで1 :: Nilのように書くことができる。
次に,メソッド名の最後が:で終わる場合,非演算子の前と後ろをひっくり返して右結合で呼び出す。
例えば,

scala> 1 :: 2 :: 3 :: 4 :: Nil
res16: List[Int] = List(1, 2, 3, 4)

は,実際には

scala> Nil.::(4).::(3).::(2).::(1)
res17: List[Int] = List(1, 2, 3, 4)

のように解釈される。
Listの要素が演算子の前に来ており,一見数値のメソッドのように見えるにも関わらず,Listのメソッドとして呼び出せるのはこのためである。

++:List同士の連結
    1. はリスト同士を連結するメソッドである。
scala> List(1, 2) ++ List(3, 4)
res19: List[Int] = List(1, 2, 3, 4)

大きなList同士を連結する場合,計算量が大きくなるのでその点は注意が必要。

mkString:文字列のフォーマッティング

このメソッドはScalaで頻繁に利用され,使う機会が多いであろうメソッドである。
このメソッドは引数によって多重定義されており,3バージョンあるのでそれぞれを紹介する。

  • mkString

引数なしバージョンのメソッドは,単にListの各要素を左から順に繋げた文字列を返す。

scala> List(1,2,3,4,5).mkString
res20: String = 12345

注意が必要なのは,引数なしのmkStringは()を付けて呼び出すことができない点である。

scala> List(1,2,3,4,5).mkString()
<console>:12: error: overloaded method value mkString with alternatives:
  => String <and>
  (sep: String)String <and>
  (start: String,sep: String,end: String)String
 cannot be applied to ()
       List(1,2,3,4,5).mkString()

非常にわかりにくいエラーだが,Scalaの0引数メソッドは()なしと()を使った定義の二通りあり,前者の形式で定義されたメソッドは()を付けて呼び出すとこのエラーになってしまう。
逆に,()を使って定義されたメソッドは()を付けても付けなくても良いことになっている。

  • mkString(sep: Strng)

引数にセパレータ文字列sepを取るメソッド。

scala> List(1,2,3,4,5).mkString(",")
res22: String = 1,2,3,4,5
  • mkString(start: String, sep: String, end: String)

sepで区切り,startとendに囲まれた文字列を返す。

scala> List(1,2,3,4,5).mkString("[", ",", "]")
res23: String = [1,2,3,4,5]
foldLeft:左からの畳込み

foldLeftはListにとって非常に基本的なメソッドである。
他の様々なメソッドをfoldLeftを使って実装することができる。

foldLeftの宣言をScalaAPIドキュメントから引用すると,

def foldLeft[B](z: B)(f: (B, A) => B): B

となり,zがfoldLeftの結果の初期値で,リストを左からたどりながらfを適用していく。

scala> List(1,2,3).foldLeft(0)((x,y) => x + y)
res25: Int = 6

上記の例では,初期値を0から初めて,リストの要素を左から順に足していっている。
Listの要素を全て掛けあわせた結果を求めたい場合は以下のようになる。

scala> List(1,2,3).foldLeft(1)((x,y) => x * y)
res26: Int = 6
foldRight:右からの畳込み

foldLeftが左からの畳込みなのに対し,foldRightは右からの畳込みになる。
foldRightの宣言をScalaAPIドキュメントから引用すると,

def foldRight[B](z: B)(op: (A, B) => B): B

となる。foldRightに与える関数であるopの引数の順序がfoldLeftの場合と逆になることに注意。

map:各要素を加工した新しいListを返す

mapメソッドは,1引数の関数を引数に取り,各要素に関数を適用した結果できた要素からなるListを返す。

scala> List(1,2,3,4,5).map(x => x * 2)
res0: List[Int] = List(2, 4, 6, 8, 10)
find:条件に合った最初の要素を返す

Boolean型を返す1引数の関数を引数にとり,各要素に適用して最初にtrueになった要素をSomeでくるんだ値をOption型として返す。

scala> List(1,2,3,4,5).find(x => x % 2 == 0)
res3: Option[Int] = Some(2)

scala> List(1,2,3,4,5).find(x => x % 6 == 0)
res4: Option[Int] = None
takeWhile:先頭から条件を満たしている間を抽出する

findと異なり,結果がtrueの間のみからなるListを返す。

scala> List(1,2,3,4,5).takeWhile(x => x != 3)
res5: List[Int] = List(1, 2)
count:Listの中で条件を満たしている要素の数を計算する

全ての要素に関数を適用して,trueが返ってきた要素の数を計算する。

scala> List(1,2,3,4,5).count(x => x < 4)
res6: Int = 3
flatMap:Listをたいらにする

flatMapの宣言は以下のとおり。

final def flatMap[B](f: (A) ⇒ GenTraversableOnce[B]): List[B]

GenTraversableOnceという変わった型は,ここではあらゆるコレクションを入れることができる型程度に考えておけば良い。
flatMapの引数fの型は(A) => GenTraversableOnce[B]で,これを使って各要素にfを適用していき,結果の要素からなるコレクションを分解してListの要素にする。

scala> List(List(1,2,3),List(4,5,6)).flatMap(e => e.map(g => g + 1))
res8: List[Int] = List(2, 3, 4, 5, 6, 7)

ネストしたListの各要素に,flatMapの中でmapを適用して,Listの各要素に1を足したものをたいらにしている。
これだけだと有り難みがわかりにくいが,少し形を変えると面白い使い方ができる。

scala> List(1,2,3).flatMap{e => List(4,5).map(g => e * g)}
res9: List[Int] = List(4, 5, 8, 10, 12, 15)

List(1,2,3)とList(4,5)の2つのListについてループし,各々の要素を掛けあわせた要素からなるListを抽出している。

Listの性能特性

Listの先頭要素へは高速にアクセスできる反面,ランダムアクセスや末尾へのデータ追加はListの長さに比例した時間がかかってしまう。
この性能特性には十分注意して扱う必要があり,特に他言語のプログラマはうっかりListの末尾に要素を追加するような遅いプログラムを書いてしまうので注意する必要がある。

Vector(immutable)

Vectorは少々変わったデータ構造で,要素へのランダムアクセスや長さの取得,データ挿入や削除などいずれの操作も十分に高速にできる比較的万能なデータ構造である。
immutableなデータ構造を使う場合は,まずVectorを検討すると良い。

Map(immutable/mutable)

Mapはキーから値へのマッピングを提供するデータ構造で,ScalaではimmutableなMapとmutableなMapの2種類を提供している。

scala.collection.immutable.Map

単にMapと書いた場合,こちらのimmutableなMapが使用される。
作成すると変更できず,updatedなどで更新したように見えても,更新したMapを返しているだけで,実際には元のMapは変更されない。

scala> val m = Map("A" -> 1, "B" -> 2)
m: scala.collection.immutable.Map[String,Int] = Map(A -> 1, B -> 2)

scala> m.updated("B", 4)
res10: scala.collection.immutable.Map[String,Int] = Map(A -> 1, B -> 4)

scala> m
res11: scala.collection.immutable.Map[String,Int] = Map(A -> 1, B -> 2)
|<<

*** scala.collection.mutable.Map

変更可能なMapを使うには,scala.collection.mutableにあるMapを使用する。

>|scala|
scala> val m = mutable.Map("A" -> 1, "B" -> 2)
m: scala.collection.mutable.Map[String,Int] = Map(A -> 1, B -> 2)

scala> m("B") = 5

scala> m
res13: scala.collection.mutable.Map[String,Int] = Map(A -> 1, B -> 5)

Set(immutable/mutable)

Setは値の集合を提供するデータ構造で,Setの中では同じ値が2つ以上存在しない。
重複した値を入れて生成しようとすると,重複した値は削除される。

scala> Set(1, 1, 2, 3, 4)
res15: scala.collection.immutable.Set[Int] = Set(1, 2, 3, 4)
scala.collection.immutable.Set

何も指定せずSetと書いた場合,immutableなSetが使われる。

scala> val s = Set(1,2,3,4,5)
s: scala.collection.immutable.Set[Int] = Set(5, 1, 2, 3, 4)

scala> s - 5
res16: scala.collection.immutable.Set[Int] = Set(1, 2, 3, 4)

scala> s
res17: scala.collection.immutable.Set[Int] = Set(5, 1, 2, 3, 4)
scala.collection.mutable.Set

変更可能なSetはscala.collection.mutableにある。

scala> val s = mutable.Set(1,2,3,4,5)
s: scala.collection.mutable.Set[Int] = Set(1, 5, 2, 3, 4)

scala> s -= 5
res20: s.type = Set(1, 2, 3, 4)

scala> s
res21: scala.collection.mutable.Set[Int] = Set(1, 2, 3, 4)

学習記録:ドワンゴ 新人向けScala研修テキスト

参考文献

dwango.github.io

11. 関数

Scalaの関数

Scalaの関数は,Function0〜22までのトレイトの無名サブクラスのインスタンスになる。
例えば,2つの整数を取って加算した値を返すadd関数は次のように定義される。

scala> val add = new Function2[Int, Int, Int] {
     |   def apply(x: Int, y: Int): Int = x + y
     | }
add: (Int, Int) => Int = <function2>

scala> add.apply(100, 200)
res17: Int = 300

scala> add(100, 200)
res18: Int = 300

Function0〜22までの全ての関数は,引数の数に応じたapplyメソッドを定義する必要がある。
applyメソッドはScalaコンパイラから特別扱いされ,x.apply(y)はx(y)のように書くことができる。

無名関数

前項の関数の定義方法では,コードが冗長になり過ぎる。
そこで,ScalaではFunction0〜22までのトレイトのインスタンスを生成するための糖衣構文が用意されている。

scala> val add = (x: Int, y: Int) => x + y
add: (Int, Int) => Int = <function2>

ここで,上記の例では変数addに関数オブジェクトが入っているだけで,関数本体に名前が付いているわけではないことに注意。
このaddの右辺のような定義をScalaでは無名関数と呼ぶ。
無名関数は単なるFunctionNオブジェクトなので,自由に変数や引数に代入したり,返り値として返すことができる。
このような性質を関数が第一級の値(First Class Object)であると言う。

無名関数の一般的な構文は以下のようになる。

(n1: N1, ... , nn: NN) => B

無名関数の返り値の型は,通常はBの型から推論される。

関数の型

(n1: N1, ... , nn: NN) => B

となるような関数の型は,FunctionN[N1, ..., NN, Bの型]と書く代わりに,

(N1, ..., NN) => Bの型

と記述することができる。

関数のカリー化

関数型言語ではカリー化というテクニックがよく使われる。
例えば,(Int, Int) => Int型の関数のような引数を複数取る関数について考える。この関数を,Int => Int => Int型の関数のように,1つの引数を取って残りの引数を取る関数を返す,関数のチェインで表現する。

scala> val add = (x: Int, y: Int) => x + y
add: (Int, Int) => Int = <function2>

scala> add(100, 200)
res21: Int = 300

scala> val addCurried = (x: Int) => ((y: Int) => x + y)
addCurried: Int => (Int => Int) = <function1>

scala> addCurried(100)(200)
res22: Int = 300

scala> addCurried(100)
res23: Int => Int = <function1>

scala> res23(200)
res24: Int = 300

よく見ると,無名関数を定義する構文をネストさせているだけで,特別なことは何もしていないことがわかる。
なお,Scalaではメソッドの引数リストを複数に分けることで,簡単にカリー化できる。

scala> def add(x: Int, y: Int): Int = x + y
add: (x: Int, y: Int)Int

scala> add(1, 2)
res1: Int = 3

scala> def addCurried(x: Int)(y: Int): Int = x + y
addCurried: (x: Int)(y: Int)Int

scala> addCurried(1)(2)
res2: Int = 3

メソッドと関数の違い

本来はdefで始まる構文で定義されたものだけがメソッドだが,説明の便宜上,所属するオブジェクトのないメソッドやREPLで定義したメソッドを関数と呼ぶようなことがある。
書籍やWebでもこの2つを意図的または無意識に混同している例があるので,注意が必要。

再度強調すると,メソッドはdefで始まる構文で定義されたものであり,関数と違って第一級の値ではない。よって,メソッドを取る引数やメソッドを返す関数,メソッドが入った変数といったものはScalaには存在しない。

高階関数

関数を引数に取ったり,関数を返すメソッドや関数のことを高階関数と呼ぶ。
(この場合はメソッドも関数と呼ぶことになる)

scala> def double(n: Int, f: Int => Int): Int = {
     |   f(f(n))
     | }
double: (n: Int, f: Int => Int)Int

上記の例は,与えられた関数fをnに対して2回適用する関数doubleになる。
ちなみに,高階関数に渡される関数は適切な名前を付けられないことも多く,その場合はfやgなどの1文字の名前が良く使用される。
呼び出しは以下のようになる。

scala> double(3, m => m * 2)
res4: Int = 12

scala> double(3, m => m * m)
res5: Int = 81

scala> val sanbai = (x: Int) => x * 3
sanbai: Int => Int = <function1>

scala> double(4, sanbai)
res6: Int = 36

もう少し意味のある例を出してみると,プログラムを書く時の頻出パターンである,初期化・処理・後処理について,これをメソッドにした高階関数aroundが定義できる。

scala> def around(init: () => Unit, body: () => Any, fin: () => Unit): Any = {
     |   init()
     |   try {
     |     body()
     |   } finally {
     |     fin()
     |   }
     | }
around: (init: () => Unit, body: () => Any, fin: () => Unit)Any

try〜finally構文は後の節でも出てくるが,大体Javaのものと同じだと思って良い。
このaroundメソッドは以下のように使うことができる。

scala> around(
     |   () => println("open file"),
     |   () => println("edit file"),
     |   () => println("close file")
     | )
open file
edit file
close file
res1: Any = ()

aroundに渡した関数が順番に呼ばれていることがわかる。
ここで,bodyの部分で例外を発生させてみると,

scala> around(
     |   () => println("open file"),
     |   () => throw new Exception("例外発生!"),
     |   () => println("close file")
     | )
open file
close file
java.lang.Exception: 例外発生!
  at $anonfun$3.apply(<console>:15)
  at $anonfun$3.apply(<console>:15)
  at .around(<console>:14)
  ... 46 elided

finally句に記載したfinの部分がしっかりと実行されていることがわかる。

このように,aroundという高階関数を定義することで,初期化・処理・後処理といったそれぞれの処理を値として部品化して,aroundメソッドを様々な処理に流用することができる。

例えば,Java7で実装されたtry-with-resource文は,後処理を自動化する構文だが,高階関数がある言語であれば,言語の機能に頼らずに自分でそのような働きをするメソッドを定義することができる。

学習記録:ドワンゴ 新人向けScala研修テキスト

参考文献

dwango.github.io

10. 型パラメータと変位指定

型パラメータ

クラスは0個以上の型をパラメータとして取ることができる。
これは,クラスを作る時点では何の型か特定できない場合を表したいときに役立つ。

class クラス名[型パラメータ1, ... 型パラメータN](コンストラクタ引数: コンストラクタ引数の型, ...)
{
  // クラス定義
}

型パラメータ1からNまでは好きな名前を付け,クラス定義の中で使うことができる。
簡単な例として,1個の要素を保持して要素をput/get操作ができるCellクラスを定義してみる。

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@1ad78261

scala> cell.get
res47: Int = 1

scala> cell.put(2)

scala> cell.get
res49: Int = 2

new Cell[Int](1)で,型パラメータとしてInt型を与えて,その初期値として1を与えている。
Cellのように様々な型を与えてインスタンス化したい場合,クラス定義時には特定の型を与えることができないので,型パラメータが役に立ちます。

もう少し実用的な例を見てみる。
メソッドから複数の値を返したいという要求はプログラミングでよく発生するが,そのような場合に型パラメータが無い言語では,

  • 片方を返り値として,もう片方を引数経由で返す
  • 複数の返り値専用クラスを必要になる度に作る

という選択肢しかなく,前者は引数を返り値に使うという点で邪道であり,後者はただ2つの値を返したいといったような場合には小回りが効かず不便である。
こういう場合,型パラメータを二つとるPairクラスを作ってしまえばよい。

scala> class Pair[T1, T2](val t1: T1, val t2: T2) {
     |   override def toString(): String = "(" + t1 + "," + t2 + ")"
     | }
defined class Pair

このPairクラスを利用する例として,割り算の商と余りの両方を返すdevideメソッドを作ってみる。

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(9, 2)
res51: Pair[Int,Int] = (4,1)

このPairは2つの型を返り値として返したい全ての場合に使うことができる。どの型でも同じ処理を行う場合を抽象化できるのが型パラメータの利点になる。
なお,引数の型から型パラメータの型を推測できる場合省略できる。よって,new Pair(m / n, m % n)と書いても同じ意味になる。

ちなみに,PairのようなクラスはScalaではTuple1からTuple22としてあらかじめ用意されており,()で括ることでインスタンス化できるようになっている。

scala> new Tuple2(7 / 3, 7 % 3)
res52: (Int, Int) = (2,1)

scala> (7 / 3, 7 % 3)
res53: (Int, Int) = (2,1)

変位指定

共変

Scalaでは,何も指定しなかった型パラメータは通常は非変になる。
非変というのは,型パラメータT1とT2が等しいときのみ,G[T1] = G[T2]というような代入が許されるという性質を表す。

一方,共変はT1がT2を継承しているときのみ,G[T2] = G[T1]というような代入が許される性質を表す。Scalaでは,クラス定義時に

class G[+T]

のように型パラメータの前に+を付けるとその型パラメータは共変になる。

具体的な例として,配列型を挙げてみる。配列型はJavaでは共変なのに対し,Scalaでは非変であるという違いがある。

Object[] objects = new String[1];
objects[0] = 100;

上記のコードは,Javaのコードとしてはコンパイルを通る。Objectの配列を表す変数にStringの配列を渡すことができるのは理にかなっているようにも思えるが,このコードを実行するとArayStoreExceptionが発生してしまう。
これは,objectsに入っているのがStringの配列なのにも関わらず,2行目でint型の100を渡そうとしていることによるものである。

一方,Scalaでは同様のコードの一行目に相当するコードをコンパイルしようとした時点でコンパイルエラーが出る。

scala> val arr: Array[Any] = new Array[String](1)
<console>:11: error: type mismatch;
 found   : Array[String]
 required: Array[Any]

これは,Scalaでは配列は非変なためである。コンパイル時により多くのプログラミングエラーを補足する方が型安全であるとすれば,配列の設計はScalaの方がJavaより型安全であるといえる。

Scalaでは型パラメータを共変にした時点で安全ではない操作はコンパイルエラーが出るので安心だが,共変をどのような場合に使えるかを知っておくのには意味がある。
例えば,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のような例外が発生する余地がないことがわかる。
一般的には,一度作成したら変更できないイミュータブルな型パラメータなどは共変にしても多くの場合問題がない。

反変

反変は共変とちょうど対になる概念で,T1がT2を継承しているときのみG[T1] = G[T2]という代入が許される性質を表す。
Scalaではクラス定義時に型パラメータの前に-を付ける。

class G[-T]

反変の例として最もわかりやすいのは関数の型である。

val x1: T1 => AnyRef = T2 => AnyRef型の値

というプログラムが成功するためには,T1がT2を継承している必要がある。
仮にT1 = String, T2 = AnyRefとすると,

val x1: String => AnyRef = AnyRef => AnyRef型の値

ここでx1に実際に入っているのはAnyRef => AnyRef型の値であるため,引数としてString型の値を与えても,AnyRef型の引数にString型の値を与えるのと同様,問題なく成功する。

scala> val x1: String => AnyRef = (x: AnyRef) => x
x1: String => AnyRef = <function1>

型パラメータの境界

型パラメータTに対して何も指定しない場合,その型パラメータTはどんな型でも入り得ることしかわからない。
そのため,何も指定しない型パラメータに対して呼び出せるメソッドはAnyに対するもののみとなる。
しかし,順序がある要素からなるリストをソートしたい場合など,Tに対して制約をかけると便利な場合がある。

上限境界

1つ目は,型パラメータがどのような型を継承しているかを指定する上限境界である。
型パラメータの後に

scala> abstract class Show {
     |   def show: String
     | }
defined class Show

scala> class ShowablePair[T1 <: Show, T2 <: Show](val t1: T1, val t2: T2) extends Show {
     |   override def show: String = "(" + t1.show + "," + t2.show + ")"
     | }
defined class ShowablePair

型パラメータT1, T2ともに上限境界としてShowが指定されているため,t1とt2に対してshowを呼び出すことができる。

scala> class Test extends Show {
     |   def show: String = "hoge"
     | }
defined class Test

scala> class Test2 extends Show {
     |   def show: String = "hoge2"
     | }
defined class Test2

scala> val sp = new ShowablePair(new Test, new Test2)
sp: ShowablePair[Test,Test2] = ShowablePair@6dc957cc

scala> sp.show
res16: String = (hoge,hoge2)
下限境界

2つ目は,型パラメータがどのような型のスーパータイプであるかを指定する下限境界である。
下限境界は,共変パラメータとともに用いることが多い。

abstract class Stack[+E]{
  def push(element: E): Stack[E]
  def top: E
  def pop: Stack[E]
  def isEmpty: Boolean
}

上記はイミュータブルなStackクラスの例で,共変にしている。
しかし,この定義は以下のようなコンパイルエラーになる。

error: covariant type E occurs in contravariant position in type E of value element
         def push(element: E): Stack[E]
                           ^

このエラーは,共変な型パラメータEが,反変な型パラメータが出現できる箇所に出現したというエラーになる。
一般に,引数の位置に共変型パラメータが来た場合,型安全性が壊れる可能性があるため,このようなエラーになる。
この問題に対処するには,型パラメータFをpushに追加し,その下限境界としてStackの型パラメータEを指定する。

  def push[F >: E](element: F): Stack[F]

これにより,コンパイラはStackにはEの任意のスーパータイプの値が入れられる可能性があることがわかる。
また,型パラメータFは共変ではないので,引数に出現してもエラーにならない。
このようにして,下限境界を利用して型安全なStackと共変性を両立することができる。

学習記録:ドワンゴ 新人向けScala研修テキスト

参考文献

dwango.github.io

9. トレイト

プログラムの分割(モジュール化)と組み立て(合成)は,オブジェクト指向プログラミングでも関数型プログラミングにおいても重要な設計の概念になる。
Scalaオブジェクト指向プログラミングにおけるモジュール化の中心的な概念になるのがトレイトである。

トレイトの基本

トレイトはクラスに比べて以下のような特徴がある。

複数のトレイトを1つのクラスやトレイトにミックスインできる
// コンパイルできる
class ClassC extends classA with TraitA with TraitB

// コンパイルエラー
class ClassD extends ClassA with ClassB

上記のように,ClassAとClassBを継承したClassDは作ることができない。
複数のクラスを継承させたい場合はクラスをトレイトにする必要がある。

直接インスタンス化できない
trait TraitA

object ObjectA {
  // コンパイルエラー
  val a = new TraitA
}

上記のように,トレイトは直接インスタンス化できない。
この制限を回避するには,インスタンス化できるようにトレイトを継承したクラスを作るか,トレイトに実装を与えるかのどちらかになる。

trait TraitA
// クラスにすればインスタンス化できる
class ClassA extends TraitA

object ObjectA {
  // クラスなのでインスタンス化できる
  val a = new ClassA

  // 実装を与えてもインスタンス化可能
  val b = new TraitA { }
クラスパラメータ(コンストラクタの引数)を取れない
// クラスなのでクラスパラメータを取れる
class ClassA(name: String) {
  def printName() = println(name)
}

// トレイトはパラメータを取れない
trait TraitA(name: String) {
  def printName: Unit = println(name)
}

トレイトに抽象メンバを持たせることで値を渡すことができる。
また,クラスに継承させたり,抽象メンバを実装することでもトレイトに値を渡すことが可能である。

trait TraitA {
  val name: String
  def printName(): Unit = println(name)
}

// クラスにしてnameを上書きする
class ClassA(val name: String) extends TraitA

object ObjectA {
  // nameを上書きするような実装を与えてもよい
  val a = new TraitA { val name = "hoge" }
}

以上のように,トレイトの制限は実用上ほとんど問題にならず,実質的に多重継承と同じようなことができるクラスとして扱うことができる。

トレイトの様々な機能

菱型継承問題

トレイトはクラスに近い機能を持ちながら,実質的な多重継承が可能であるという便利なものだが,菱型継承問題については考えなければならない。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  def greet(): Unit = println("hoge")
}

trait TraitC extends TraitA {
  def greet(): Unit = println("huga")
}

class ClassA extends TraitB with TraitC

上記のプログラムでは,TraitBとTraitCのgreetメソッドの実装が衝突している。
この場合,ClassAのgreetはどのような動作をすべきか?

上記の例をScalaコンパイルすると,以下のようなエラーになる。

ClassA.scala:13: error: class ClassA inherits conflicting members:
  method greet in trait TraitB of type ()Unit  and
  method greet in trait TraitC of type ()Unit
(Note: this can be resolved by declaring an override in class ClassA.)
class ClassA extends TraitB with TraitC
      ^
one error found

Scalaでは,override指定なしの場合メソッド定義の衝突はエラーになる。
この場合の解法の一つは,コンパイルエラーにあるように,ClassAでgreetをoverrideすることである。

class ClassA extends TraitB with TraitC {
   override def greet(): Unit = println("foo")
}

class ClassB extends TraitB with TraitC {
   // superに型を指定して呼び出すことで親トレイトのメソッドも呼び出せる
   override def greet(): Unit = super[TraitB].greet()
}

scala> (new ClassA).greet()
foo

scala> (new ClassB).greet()
hoge

では,TraitBとTraitCの両方のメソッドを呼び出したい場合は?
1つの方法は,super[TraitB].greet()とsuper[TraitC].greet()の両方を明示的に呼び出すことだが,継承関係が複雑になった場合,全てを明示的に呼び出すのは大変である。

そこで,Scalaのトレイトには線形化という機能がある。

線形化

トレイトの線形化とは,トレイトがミックスインされた順番をトレイトの継承順番とみなすことである。

以下の例は,先ほどの例のTraitB/Cのgreetメソッド定義にoverride修飾子をつけたものである。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  override def greet(): Unit = println("hoge")
}

trait TraitC extends TraitA {
  override def greet(): Unit = println("huga")
}

class ClassA extends TraitB with TraitC

この場合はコンパイルエラーにならない。何故か?
それはトレイトの継承順番が線形化され,後からミックスインしたTraitCが優先されているためである。
そのため,ClassAのgreetメソッドを呼び出すと,TraitCのgreetメソッドが呼び出される。

scala> (new ClassA).greet()
huga

superを使うことで,線形化された親トレイトを使うこともできる。

trait TraitA {
  def greet(): Unit = println("TraitA!")
}

trait TraitB extends TraitA {
  override def greet(): Unit = {
    super.greet()
    println("TraitB!")
  }
}

trait TraitC extends TraitA {
  override def greet(): Unit = {
    super.greet()
    println("TraitC!")
  }
}

class ClassA extends TraitB with TraitC
class ClassB extends TraitC with TraitB

このgreetメソッドの結果も,継承された順番によって変わることになる。

scala> (new ClassA).greet()
TraitA!
TraitB!
TraitC!

scala> (new ClassB).greet()
TraitA!
TraitC!
TraitB!

線形化の機能により,ミックスインされた全てのトレイトの処理を簡単に呼び出せるようになった。
線形化によるトレイトの積み重ねの処理を,Scalaでは積み重ね可能なトレイトと呼ぶ。

この線形化がScalaの菱型継承問題に対する対処法となる。

abstract override

メソッドのオーバーライドでsuperを使ってスーパークラスメソッドを呼び出す場合,当然継承元のスーパークラスにそのメソッドの実装が無ければならない。
しかし,Scalaには継承元にメソッドの実装がない場合でもメソッドのオーバーライドが可能なabstract overrideという機能がある。

trait TraitA {
  def greet(): Unit
}

trait TraitB extends TraitA {
  // abstractが無いとコンパイルエラーになる
  abstract override def greet(): Unit = {
    super.greet()
    println("TraitB!")
  }
}

オーバーライドをabstract overrideにすることで,抽象クラスに対しても積み重ねの処理が書けるということになる。
しかし,ミックスインされてクラスが作られるときにはスーパークラスメソッドが実装されている必要がある。

// コンパイルエラー
class ClassA extends TraitB

// TraitAのgreetを呼び出さないトレイトを作成
trait TraitC extends TraitA {
  def greet(): Unit =
    println("Hello!")
}

// こちらはコンパイルできる
// TraitBのsuperで呼び出されるのはTraitCのgreetなため
class ClassB extends TraitC with TraitB

自分型

クラスやトレイトの中では,自分自身の型にアノテーションを記述することができる機能がある。

trait Greeter {
  def greet(): Unit
}

trait Robot {
  self: Greeter =>

  def start(): Unit = greet()
}

このRobotトレイトは,startメソッドを呼び出されるとgreetメソッドを呼び出している。
ここで,Robotは直接Greeterを継承していないのにも関わらず,greetメソッドを使えていることに注意。

このRobotのオブジェクトを実際に作るためには,greetメソッドを実装したトレイトが必要になる。

trait HelloGreeter extends Greeter {
  def greet(): Unit = println("Hello!")
}

これでRobotのオブジェクトを作ることができる。

scala> val r = new Robot with HelloGreeter
r: Robot with HelloGreeter = $anon$1@3352aa2a

scala> r.start
Hello!

自分型を使う場合,抽象トレイト(Greeter)を指定し,後から実装を追加(with HelloGreeter)するという形になる。
このように,後から利用するモジュールの実装を与えることを依存性の注入と呼ぶ。
自分型が使われている場合,この依存性の注入のパターンが使われていると考えてよい。

ではこの自分型の指定は,以下のように直接継承する場合と比べてどう異なるのか。

trait Greeter {
  def greet(): Unit
}

trait Robot2 extends Greeter {
  def start(): Unit = greet()
}

オブジェクトを生成するという点では変わらないが,トレイトを利用する側や,継承したトレイトやクラスにはGreeterトレイトの見え方に違いができる。

scala> val r: Robot = new Robot with HelloGreeter
r: Robot = $anon$1@65a8d818

scala> r.greet()
<console>:16: error: value greet is not a member of Robot
       r.greet()
         ^

scala> val r2: Robot2 = new Robot2 with HelloGreeter
r2: Robot2 = $anon$1@dcd702c

scala> r2.greet()
Hello!

継承で作られたRobot2オブジェクトからは,Greeterトレイトのgreetメソッドを呼び出せてしまう。
一方,自分型で作られたRobotオブジェクトでは,greetメソッドを呼び出すことができない。

Robotが利用を宣言するためにあるGreeterのメソッドが外から呼び出せてしまうことはあまり良いことではない。
この点が自分型を使うメリットとなるが,逆に単に依存性の注入がしたいだけならば,この動作は煩わしく感じられるかもしれない。

依存性の注入を使う場合,継承を使うか自分型を使うかというのは若干悩ましい問題かもしれない。
機能的には継承で問題ないが,上記のような可視性の問題と,自分型を使うことで依存性の注入を利用しているとわかりやすくなるという効果もある。
利用する場合はチームで相談すると良い。

落とし穴:トレイトの初期化順序

トレイトのvalの初期化順序はトレイトを使う上で大きな落とし穴になる。
以下の例では,

  • トレイトAで変数fooを宣言し,
  • トレイトBがfooを使って変数barを作成し,
  • クラスCでfooに値を代入してからbarを使っている。
trait A {
  val foo: String
}

trait B extends A {
  val bar = foo + ", World!"
}

class C extends B {
  val foo = "Hello"

  def printBar(): Unit = println(bar)
}

さて,この状態でCのprintBarメソッドを呼び出すと,

scala> (new C).printBar()
null, World!

クラスCでfooに代入した値が反映されておらず,nullになっている。
この現象の理由は,Scalaのクラスおよびトレイトがスーパークラスから順番に初期化されるためである。
この例でいえば,初期化はトレイトAから順に行われ,変数fooが宣言された後(中身はnull),トレイトBで変数barが宣言されて,nullであるfooと", World!"という文字列から"null, World!"という文字列が出来上がってしまう。

トレイトのvalの初期化順序の回避方法

では,この罠はどうやれば回避できるのだろうか。
上記の例でいえば,使う前にfooが初期化されるように,barの初期化を遅延させることだ。
処理を遅延させるには,lazy valかdefを使う。

scala> :paste
// Entering paste mode (ctrl-D to finish)

trait A {
  val foo: String
}

trait B extends A {
  // def barでも良い
  lazy val bar = foo + ", World!"
}

class C extends B {
  val foo = "Hello"

  def printBar(): Unit = println(bar)
}

// Exiting paste mode, now interpreting.

defined trait A
defined trait B
defined class C

scala> (new C).printBar()
Hello, World!

barの初期化にlazy valを使うことで,barの初期化が宣言時ではなく,実際に使われるまで遅延される。
これにより,printBarメソッドを呼ぶまでbarの初期化はされず,その間にクラスCでfooが初期化されることにより,正常な表示になる。

lazy valはvalに比べて若干処理が重く,複雑な呼び出しでデッドロックが発生する場合がある。
valの代わりにdefを使うと,毎回値を計算してしまうという問題もある。
しかし,両方とも大きな問題にはならない場合が多いので,特にvalの値を使ってvalの値を作り出すような場合には,lazy valかdefの使用を検討すること。

学習記録:ドワンゴ 新人向けScala研修テキスト

参考文献

dwango.github.io

8. オブジェクト

Scalaでは全ての値がオブジェクトであり,全てのメソッドは何らかのオブジェクトに所属している。
そのため,Javaのようにクラスに属するstaticフィールド/メソッドを作成することはできない。
その代わりといっては語弊があるが,objectキーワードによって,同じ名前のシングルトンオブジェクトをグローバルな名前空間に1つ定義することができる。

objectの基本構文はクラスとほとんど同じである。

object オブジェクト名 extends クラス名 with トレイト名1 with トレイト名2 .... {
  本体
}

object構文の主な用途には,以下の3つが挙げられる。(もっぱら最初の2つの用途で使われる)

Scalaでは標準でPredefというobjectが定義・インポートされており,これは最初の使い方に当てはまる。
println()などのメソッドも実はPredef objectのメソッドになる。

2番目の使い方の例として,点を表すPointクラスのファクトリをobjectで作ろうとすると次のようになる。

scala> class Point(val x:Int, val y:Int)
defined class Point

scala> object Point {
     |   def apply(x: Int, y: Int): Point = new Point(x, y)
     | }
defined object Point

applyという名前のメソッドは処理系で特別に扱われ,Point(x)のような記述は(Point objectにapplyメソッドが定義されていれば)Point.apply(x)と解釈される。
これを利用することで,Point(3, 5)のような記述でオブジェクトを生成できるようになる。

scala> Point(3, 5)
res27: Point = Point@30ddf308

これは,new Point()で直接Pointオブジェクトを生成するのに比べ,クラスの実装詳細を内部に隠しておける,Pointではなくそのサブクラスのインスタンスを返すことができる,といったメリットがある。

なお,上記の記述は,ケースクラスを用いてもっと簡単に書ける。
ケースクラスの詳細は後述するが,簡単に言うと,case classを付けたクラスの全てのフィールドを公開し,equels(), hashCode(), toString()などの基本的なメソッドを持ったクラスを生成し,またそのクラスを生成するためのファクトリメソッドを生成する。

scala> case class Point(x: Int, y: Int)
defined class Point

scala> Point(5, 3)
res29: Point = Point(5,3)

scala> println(res29)
Point(5,3)

scala> Point(1, 2).equals(Point(1, 2))
res31: Boolean = true

scala> Point(1, 2).equals(Point(2, 3))
res32: Boolean = false

コンパニオンオブジェクト

クラス名と同じ名前のシングルトンオブジェクトは,コンパニオンオブジェクトと呼ばれる。
コンパニオンオブジェクトは,対応するクラスに対して特権的なアクセス権を持つ。

// ageをprivateに
class Person(private val age: Int)

object Hoge {
  val taro = new Person(13)
  // privateなのでアクセスできない
  println(taro.age)
}

// Personのコンパニオンオブジェクト
object Person {
  val taro = new Person(13)
  // アクセスできる
  println(taro.age)
}

なお,コンパニオンオブジェクトでもprivate[this]なメンバに対してはアクセスできない。

上記のようなコンパニオンオブジェクトを使ったコードをREPLで試す場合は,REPLの:pasteコマンドを使い,クラスとコンパニオンオブジェクトを一緒に貼り付ける必要がある。
(クラスとコンパニオンオブジェクトは同一ファイルに置かれている必要があり,REPLで普通に入力した場合コンパニオン関係を認識できないため)

scala> :paste
// Entering paste mode (ctrl-D to finish)

class Person(private val age: Int)

object Person {
  val taro = new Person(20)
  println(taro.age)
}

// Exiting paste mode, now interpreting.

defined class Person
defined object Person

scala> Person
20
res33: Person.type = Person$@3918a738