Scala 遅延評価とAOP

val, lazy val, def について

評価に関連してval, lazy val, def について確認しておく。
これらは評価されるタイミングが異なる。

  • val: valで宣言された変数が所属するスコープがロード(初期化)されたとき評価される。
  • lazy val: valとは異なり、ロードされたときは評価されない。その値が参照されたときに初めて評価される。
  • def: 実行するたびに評価される。

valとdefの違い

val a = {
println("testA")
  10
}
def b = {
println("testB")
  20
}
println(a)
println(a)
println(a)
println(b)
println(b)
println(b)

上記のコードを実行すると出力は以下のようになる

testA
10
10
10
testB
20
testB
20
testB
20

ロードされたときに変数aは評価されてprintln("testA")が実行される。その後実行されるprintln(a)はすべて評価された10が出力される。 defで宣言したメソッドbに格納された値は毎回評価し直すこととなる。そのため毎回testBと20出力結果になる。

このことからdefを使用すると毎回評価されるため、複数回呼ぶ場合は気をつける必要がある。 計算結果のみ参照したい場合はvalで宣言して評価結果を固定するなど意識した方がよさそう。

valと lazy valの違い

具体例

object Main {
  val regularVal: Int = {
    println("Initializing regularVal")
    10
  }

  lazy val lazyVal: Int = {
    println("Initializing lazyVal")
    20
  }

  def main(args: Array[String]): Unit = {
    println("In main method")
    println(regularVal)
    println(lazyVal)
  }
}

上記のコードを実行すると、出力は以下のようになる。

Initializing regularVal  // オブジェクトの初期化時にregularValが評価され、regularValには10という値が格納される
In main method  // mainメソッドが実行されるので、println("In main method")が呼び出される
10  // regularValに格納されている10が出力される
Initializing lazyVal // lazy valは参照時に初めて評価されるのでここで出力される
20

遅延評価とは

評価を遅延して行うこと。値が必要になるまで計算しないこと。

Scalaの遅延評価はlazy valを使う方法と名前付きパラメータを使う方法がある。

lazy valによる遅延評価

lazy valで変数を定義した場合、変数に格納した値の評価を処理開始まで遅らせることができる。

まずはlazy valを使わない例

// Statusによって異なるメソッドを実行する
def executeMethod: Unit = {
        Status match {
        case StatusA => methodA
        case StatusB => methodB
                case StatusC if boolMethod => methodC
                case StatusD if !boolMethod => methodD
        }
}

def methodA: Unit = {}
def methodB: Unit = {}
def methodC: Unit = {}
def methodD: Unit = {}
def boolMethod: Boolean = {
// 複雑な計算を行ったあとbool値を返す
}

StatusDの判定処理が行われた時、2回目のboolMethodが呼ばれる。boolMethodは複雑な計算処理を要するのでできれば評価回数を少なくしたい。そこでlazy valを使って評価回数を削減する。

lazy valを使った例

// Statusによって異なるメソッドを実行する
def executeMethod: Unit = {
        lazy val bool = boolMethod
        Status match {
        case StatusA => methodA
        case StatusB => methodB
                case StatusC if bool => methodC
                case StatusD if !bool => methodD
        }
}

def methodA: Unit = {}
def methodB: Unit = {}
def methodC: Unit = {}
def methodD: Unit = {}
def boolMethod: Boolean = {
// 複雑な計算を行ったあとbool値を返す
}

上記のようにlazy valを使用するとboolMethodが呼び出されるまで評価を遅らせて、boolMethodが複数回呼ばれたとしても評価回数は1回で済む。 ここでvalを使用すると評価回数は1回になるが、boolMethodが呼び出されなかったとしても評価はされるためlazy valを使用する方が性能的には良い。

名前付きパラメータによる遅延評価

メソッドに渡された引数の値の評価をその値が参照されるまで遅らせることができる。

名前付きパラメータを使用しない例

def executeMethod(x: T)(heavyMethod: U): Unit = {
        if (boolMethod(x)) {
                heavyMethod
        }
}

def heavyMethod: U = {
        // 複雑な計算が行われる
        ???
}
def boolMethod(x: T): Boolean = {
        // 単純な処理でbool値を返す
}

上記の場合、heavyMethodが引数に渡された時点で計算される。もしboolMethodがfalseを返す場合、本来計算する必要のない複雑な処理を計算してしまうことになる。このようなケースを回避するために名前付きパラメータを使用する。

名前付きパラメータを使用した例

def executeMethod(x: T)(heavyMethod: => U): Unit = {
        if (boolMethod(x)) {
                heavyMethod
        }
}

def heavyMethod: U = {
        // 複雑な計算が行われる
        ???
}
def boolMethod(x: T): Boolean = {
        // 単純な処理でbool値を返す
}

名前付きパラメータを使用するときは引数を以下のように定義する

(heavyMethod: => U)

このようにすることでheavyMethodは実行されるときに初めて評価される。 そのためboolMethodがfalseの場合はheavyMethodの計算は行われない。

遅延評価を使ったAOP

AOPとは

AOPAspect-Oriented Programming、アスペクト指向プログラミング)は、プログラミングのパラダイムの一つで、クロスカッティングコンサーン(複数のモジュールや関数を横断する機能や関心事)をモジュール化することに重点を置いています。これにより、ソフトウェアのモジュール性を向上させることができます。クロスカッティングコンサーンの例としては、ログ出力、認証、トランザクション管理などが挙げられます。これらの機能はアプリケーションの複数の部分にわたって繰り返し適用されるため、これを繰り返し実装するのは非効率的であり、変更やメンテナンスも難しくなります。

ログ出力を例とする。あらゆるメソッドでログ出力を行いたい場合、メソッドごとにログ出力を書いてしまうと、コードの散乱または重複を引き起こすことになり保守性を低下させてしまう。

遅延評価によりAOPを実現したログ出力

def withLogging[T](taskName: String)(task: => T): T = {
    val startTs = System.currentTimeMillis
    logger.debug(s"[$taskName][Start]")
    val result = task
    val endTs = System.currentTimeMillis
    val durationMs = endTs - startTs
    logger.debug(s"[$taskName][END]($durationMs ms)")
    result
  }

withLogging("Main task"){
        // 実行したい処理を記述
}

上記の例では横断的関心事であるログ出力がビジネスロジックと分離され、保守性と可読性を向上させている。(遅延評価することでログ出力とtaskに渡される処理を明示的に分離している)

Scala 例外を使わないエラー処理(Option編)

用語

純粋関数とは

参照透過

式eがあるとしたら、全体のプログラムpにおいて、pの意味に影響を与えることなくp内のすべてのeをeの評価結果と置き換えることができたらeは参照透過であると言える。

以下の簡単なプログラム(値2, 3を加算した結果を返す関数)があるとする。

2 + 3 = 5

上記のプログラムは加算関数(+)を用いて実現している。このプログラムは左辺の式を評価結果に置き換えると必ず5になるので左辺の式は参照透過であると言える。

純粋関数

すべての式が参照透過性を持っているのであれば、その関数を純粋関数と呼ぶ。

例外を使わないエラー処理

例外をスローすると副作用が発生して、参照透過性が損なわれてしまう。この副作用を取り除くためにScalaでは例外を使わないエラー処理を行うことができる標準のライブラリOption、Eitherなどの型が存在する。そこで今回はOption型を使ってどうやったら例外が発生するプログラムで副作用を取り除くことができるかを確認していく。

例外によって参照透過性が損なわれる理由

以下のメソッドを実行したとする。

def failingFn(i: Int): Try[Int] = {
    val y: Int = throw new Exception("fail!")
    
    Try {
      val x = 42 + 5
      x + y
    }.recover{
      case e: Exception => 43
    }
  }

実行結果は以下になる。

java.lang.Exception: fail!
at .failingFn(<console>:8)

これは想定通りの結果だと思う。

次に例外を直接yに置き換えると、43が返ってくる。

def failingFn(i: Int): Try[Int] = {    
    Try {
      val x = 42 + 5
      x + throw new Exception("fail!")
    }.recover{
      case e: Exception => 43
    }
  }

これも想定通りだが、上記の2つのプログラムから以下のことがわかる。

参照透過な式は参照先の式と置き換えることが可能で、この置き換えによってプログラムの意味が変化しないことを示す。この場合、上記の2つ同じ関数で参照先を変更しただけにも関わらず結果が異なるので、例外よって参照透過性が損なわれていることが言える。

さらに参照透過は以下の性質があることがわかる。

  • 参照透過な式は関数の文脈に依存せず、ローカルでの推論が可能
  • 参照透過でない式は関数の文脈に依存して、よりグローバルな推論が必要

よって例外処理というのは参照透過性を損なわせ、ローカルでの推論を難しくするということがわかる。

例外処理によって参照透過性が損なわれることによる問題

例外処理には2つの問題がある。

  • 上記で示したように、例外は関数の文脈に依存をもたらすため、例外ベースで複雑なコードを記述でき、単純な推論から遠ざかってしまう。本来例外はエラー処理にのみ使用すべきであり、制御フローには使用すべきではない。
  • 例外は型安全ではない。上記の関数 failingFnの型 Int ⇒ Int から例外が発生することなど何もわからない。

例外に代わる手法

以下のメソッドで考える

def mean(xs: Seq[Double]): Double =
    if (xs.isEmpty)
      throw new ArithmeticException("mean of empty list!")
    else xs.sum / xs.length

このメソッド内はDouble型の戻り値だが例外を返すケースが存在する。そのため副作用を持つメソッドであることが言える。これを解消するための方法をいくつか考えてみる。

例外の場合にDouble型の偽の値を返す

上記の例で入力値が空の場合はDouble.Nanに相当する 0.0/0.0を返す

これで副作用がない戻り値 Double型のメソッドを作成することができるが、この場合いくつかの欠点がある。

  • 呼び出し元で条件分岐を行う必要がある
    • 上記の例では呼び出し元でDouble.Nanかどうか確認する必要がある
    • これは例外判定を特定の値で決める必要がありボイラープレートコードが量産される
  • 呼び出し元のチェックし忘れ
    • エラーが隠れて呼び出し元に伝搬されるが、呼び出し元がチェックしないとエラーの場合正常に動かない可能性がある
    • これはコンパイルエラーにもならないためバグとなり発見される可能性がある

例外が発生したときの代替の値を呼び出し元から引数で渡す

def mean_1(xs: IndexedSeq[Double], onEmpty: Double): Double =
  if (xs.isEmpty) onEmpty else xs.sum / xs.length

これも副作用をなくすことはできているが、以下の欠点がある

  • 呼び出し元がメソッドの内容を知っている前提になっている
  • 空の場合のハンドリングがDouble型で返すケースしか用意できない
    • 例えば他の処理で以下の実装を行いたいとした場合、この作りでは不可能
      • meanが未定の場合にその計算を中止したい
      • meanが未定の場合に他の条件分岐に移りたい

上記のことから例外ハンドリングの方法は決断を先送りにして最も適切なタイミングで処理してあげた方がよいことがわかる。

Optionで例外を吸収する

Optionを使用することでこれまで挙げてきた欠点を解決することができる。Optionを使用するとエラーの処理戦略を呼び出し元に委ねることができる。

上で考えたmeanメソッドの戻り値をOption型にする。

def mean(xs: Seq[Double]): Option[Double] = 
	if (xs.isEmpty) None
	else Some(xs.sum / xs.length)

そうすることで宣言された型の結果(Option[Double])が必ず返るので、meanメソッドを副作用のない純粋関数にすることができる。

Optionは部分関数に対して使用することが多い。部分関数とは一部の入力に対して定義されていない関数のこと。meanメソッドで空配列の場合の記述が書かれてないケースなど。

Optionの使い方

部分関数においてOptionを使う場合、パターンマッチングを使用することも可能だが、map、flatMap、filterなどの高階関数を使用することが推奨される。パターンマッチングを使用すると冗長なコードになりがちなため、できるだけ高階関数使用する。

map、flatMapを使用する例

mapはOptionに結果が存在する場合に後続処理を実行することができ、エラーが発生していないという前提で処理を進めることができる。そのためエラーを後続のコードに先送りする方法の一つである。

flatMapはOption[Optino[T]]のような型でmap同様エラーを後続のコードに先送りすることができる。

case class Employee(name: String, department: String)
def lookupByName(name: String): Option[Employee] = ???
val joeDepartment: Option[String] = lookupByName("Joe").map(_.department)

上記のコードのjoeDepartmentはJoeという従業員がすればその部署を取得する関数である。Joeという従業員が存在しなければ、後続の処理は実行されない。つまり、エラー処理を先送りしている。

filterを使用する例

filterを使うと成功した後の処理を失敗にすることができる。

val dept: String = lookupByName("Joe").map(_.dept).filter(_ != "Accounting").getOrElse("Default Dept")

ここでは、Option[String]をStringに変換するためにgetOrElseを使用している。

このようにJoeという従業員が存在しない場合、またはJoeの部署がAccountingの場合は"Default Dept”が返る。

例えばOptionがNoneの場合にエラーログを出力したい場合は、以下のようにfilterを使えば実現できそう。

ただnonEmptyの判定が入る。Emptyであればログ出力したいのが目的なのにやっていることがわかりづらい。

// aに何らかの処理を行った結果がOptionで入る
val a = Option("a")
// nonEmptyでなければlog出力する
a.filter(_.nonEmpty).getOrElse(logger.error("error message"))

Optionの合成

Optionを使ってしまうと、コード全体への影響はどのみち避けれないと思ってしまうかもしれない。しかしOptionが発生しても例外処理のタイミングは自由に決めることができ、呼び出し元の都合で例外ハンドリングを先送りにすることができる。

ユーザの入力値を受け取って何らかの計算を行うプログラムが以下にある。

これはaとbから何らかの計算を行うメソッドである。

def calculate(a: Int, b: Int): Int

上記のメソッドを呼び出したいが、ユーザからの入力値が文字列でなので、parseするメソッドを以下に用意した。またtoIntを使用して文字列から整数への変換を行っているが、文字列が整数に置き換えれない場合は例外が返されるため、例外を吸収するtryToOptionメソッドを用意した。

def parseForCalculate( a: String, b: String): Option[Int] = {
    val optA: Option[Int] = tryToOption(a.toInt)
    val optB: Option[Int] = tryToOption(b.toInt) 
    calculate(optA, optB)
  }

def tryToOption[A](a: => A): Option[A] = {
  Try(a).toOption
}

ただし上記は一つの問題を抱えている。それは文字列を解析して整数に変換したあとOption型で返ってくることである。このままではcalculateメソッドの引数の型に合わず、calculateメソッドを使えない。ここでcalculateメソッドの引数をOptionに変換することはしたくない。なぜならOptionを引数にすることでcalculateメソッドの中でも引数に入ってきた値が成功値か失敗値かの判断が必要になってくる。またcalculateメソッドが別モジュールで定義されていて、そもそも変更することができない場合もある。

ここでOptionの合成を行う。flatMapとmapを使うことで複数のOptionを扱う場合でもエラーの先送りすることができる。

def parseForCalculate( a: String, b: String): Option[Double] = {
    val optA: Option[Int] = tryToOption(a.toInt)
    val optB: Option[Int] = Try(b.toInt)
    optA.flatMap(A => optB.map(B => calculate(A, B)))
  }

def tryToOption[A](a: => A): Option[A] = {
  Try(a).toOption
}

Optionの合成の数が多くなった場合はfor式を使う。

関数シグネチャとして Option 型を使うべきか

関数シグネチャに Option 型は存在しない方が望ましい。つまり、関数への入力値は必ず存在し、関数から返す値も必ず存在することが望ましいということ。ただし、実際にはプログラミングにおいて、一度でも Option 型に関わった場合、Option 型が付きまとうことがある。そのため、関数の出力に Option 型が存在するのは一般的で、これは意図した例外が発生する可能性があることを示唆している。一方、関数の入力に Option 型が存在する場合は、設計が適切でない可能性が高い。