純粹的 functional 狀態 (2)

延續 純粹的 functional 狀態 (1)

巢狀 state transitions

有些情況下,map 和 map2 可能會無法符合組合要求,例如以下這個產生非負數且要小於特定值的隨機數 function,

  def nonNegativeLessThan(n: Int): Rand[Int] =
    map(nonNegativeInt)(_ % n)

這裡有個問題是 Int.maxValue 無法每次都被 n 整除,所以某些數字的出現機率會比較高,所以我們要調整 map 的 high-order function 定義,若發生這種情況我們要 recursive 的再調用 nonNegativeLessThan 一次,但此時我們沒有辦法拿到新狀態的 RNG,

  def nonNegativeLessThan(n: Int): Rand[Int] =
    map(nonNegativeInt) { i =>
      val mod = i % n
      if i + (n - 1) - mod >= 0 then
        mod
      else
        nonNegativeLessThan(n)(???) // 這裡我們沒有新狀態的 rng 給 nonNegativeLessThan 使用
    }

雖然我們可以土法煉鋼的這樣實現,

  def nonNegativeLessThan(n: Int): Rand[Int] =
    rng =>
      val (i, rng1) = nonNegativeInt(rng)
      val mod = i % n
      if i + (n - 1) - mod >= 0 then
        (mod, rng1)
      else
        nonNegativeLessThan(n)(rng1)

但總是不夠優雅,所以此時要請出大神 flatMap function 來救場了,

  def flatMap[A, B](r: Rand[A])(f: A => Rand[B]): Rand[B] =
    rng =>
      val (a, rng2) = r(rng)
      f(a)(rng2)

然後我們的 nonNegativeLessThan 就能以 flatMap 的方式實現了,這裡我新加入了名為 unit 的 function,作用為把任意值 a 包裝成 (A, RNG) 後回傳。

  def unit[A](a: A): Rand[A] =
    rng => (a, rng)

  def nonNegativeLessThanViaFlatMap(n: Int): Rand[Int] =
    flatMap(nonNegativeInt) {
      i =>
        val mod = i % n
        if i + (n - 1) - mod >= 0 then
          unit(mod)
        else
          nonNegativeLessThan(n)
    }    

因為有了 flatMap,我們可以更進一步的把 map 和 map2 都改成用 flatMap 來實現。

  def mapViaFlatMap[A, B](r: Rand[A])(f: A => B): Rand[B] =
    flatMap(r)(a => unit(f(a)))

  def map2ViaFlatMap[A, B, C](ra: Rand[A], rb: Rand[B])(f: (A, B) => C): Rand[C] =
    flatMap(ra)(a => mapViaFlatMap(rb)(b => f(a, b)))

對最初的 rollDie function 有什麼影響

我們可以用 nonNegativeLessThanViaFlatMap(產生非負數且小於特定值的隨機數)function 來改寫 Day 12 的 rollDie function,此實現跟原本的一樣,會骰出 0~5 的數字,

def rollDie: Rand[Int] = nonNegativeLessThanViaFlatMap(6)

但因為該 function 變純粹了,不會在 RNG 類別內部有任何狀態改變,所以現在我們可以成功地覆現骰出 0 的問題,

scala> val num = rollDie(SimpleRNG(5))._1
val num: Int = 0

能成功覆現就可以將它加入測試案例中,然後調整 rollDie 程式後,它就能真正的骰出 1~6 的數字啦!

def rollDie: Rand[Int] = map(nonNegativeLessThanViaFlatMap(6))(_ + 1)

一般化的 State 型態

昨天我們透過 Rand 型態來簡化型態表示,如果不用別名的話,map function 會長下面這個樣子,

// with alias
type Rand[+A] = RNG => (A, RNG)

def map[A, B](s: Rand[A])(f: A => B): Rand[B]

// without alias
def mapNonAlias[RNG, A, B](s: RNG => (A, RNG))(f: A => B): RNG => (B, RNG)

但其實 map 並不在意它處理的是 RNG 還是誰,所以我們可以進一步把 RNG 抽象成泛型型態 S,

def map[S, A, B](s: S => (A, S))(f: A => B): S => (B, S)

然後我們應該要在更一般化的定義 S => (A, S),來處理所有的狀態改變,

type State[S, +A] = S => (A, S)

因為 State 跟 Rand 一樣,都是輸入某物件後,將結果隨同改變狀態後的物件回傳,但 State 更一般化,所以我們需要一個地方來包裹所有會用到的 function,在 Scala 3 我們可以這樣定義:

opaque type State[S, +A] = S => (A, S)

object State:

  extension[S, A] (underlying: State[S, A])

    def run(s: S): (A, S) = underlying(s)

    def flatMap[B](f: A => State[S, B]): State[S, B] =
      s =>
        val (a, s1) = underlying(s)
        f(a)(s1)

    def map[B](f: A => B): State[S, B] =
      flatMap(a => unit(f(a)))

  def unit[S, A](a: A): State[S, A] =
    s => (a, s)

  def apply[S, A](f: S => (A, S)): State[S, A] = f

關鍵字 opaque 和 extension 都是 Scala 3 才有的東西,opaque 可以讓 JVM 減少 boxing 開銷,所以就不會發生以下這種情況,

scala> object Logarithms:
     |   opaque type Logarithm = Double
     |
     |   object Logarithm:
     |     def apply(d: Double): Logarithm = math.log(d)
     |

scala> val log2 = Logarithm(2.0)
scala> val d: Double = log2
-- [E007] Type Mismatch Error:

沒加 opaque 的話,這個 val d: Double = log2 的宣告會因為 boxing 而執行成功;

而 extension 在 Scala 2 可以視為 implicit,就是在不改變原類別定義的情況下,加入擴充功能,

scala> case class Circle(x: Double, y: Double, radius: Double)

scala> extension (c: Circle)
     |   def circumference: Double = c.radius * math.Pi * 2

scala> val aCircle = Circle(2, 3, 5)
scala> aCircle.circumference
val res0: Double = 31.41592653589793

我能直接用變數 aCirlce 來調用 circumference function。

現在我就能用 State 來包裹 RNG,然後用 map function 將 int 轉為 double,

scala> val stateA: State[RNG, Int] = State(_.nextInt)
scala> val rng = SimpleRNG(-1)

scala> val (d, nextRng) = stateA.map(_.toDouble)(rng)
val d: Double = -384749.0
val nextRng: RNG = SimpleRNG(281449761806750)

然後我們就可以依樣畫葫蘆,把一些好用的 function 加到 object State 底下來做各種轉換。

總結

在 functional programming 中改變狀態的方法就是將新的狀態跟著原來要回傳的值綁在一起返回,確保不會發生 side effect,最終產生了 State 型別來包裹所有需要改變狀態的物件,其中的轉換 function 如 map, flatMap 可以組合式的把 State 轉換成下一個 State,雖然寫起來有點麻煩,但適當的使用我相信能在越來越複雜的系統中幫助工程師更好除錯以及完善測試。

tshine73
tshine73
文章: 50

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *