函式正規化入門(惰性求值與函式式狀態)

Yumenokanata發表於2019-01-19

第二節 惰性求值與函式式狀態


在下面的程式碼中我們對List資料進行了一些處理

List(1,2,3,4).map(_ + 10).filter(_ % 2 == 0).map(_ * 3)

考慮一下這段程式是如何求值的,如果我們跟蹤一下求值過程,步驟如下:

List(1,2,3,4).map(_ + 10).filter(_ % 2 == 0).map(_ * 3)

List(11,12,13,14).filter(_ % 2 == 0).map(_ * 3)

List(12,14).map(_ * 3)

List(36,42)

我們呼叫map和filter函式的時候,每次都會遍歷自身,對每個元素使用輸入的函式進行求值。


那麼如果我們對一系列的轉換融合為單個函式而避免產生臨時資料效率不是更高?

我們可以在一個for迴圈中手工完成,但更好的方式是能夠自動實現,並保留高階組合風格。

非嚴格求值(惰性求值) 即是實現這種的產物,它是一項提升函數語言程式設計效率和模組化的基礎技術。


嚴格與非嚴格函式

非嚴格求值是一種屬性,稱一個函式是非嚴格求值即是這個函式可以選擇不對它的一個引數或多個引數求值。相反,一個嚴格求值函式總是對它的引數求值。
嚴格求值函式在大部分程式語言中是一種常態,而Haskell語言就是一個支援惰性求值的例子。Haskell不能保證任何語句會順序執行(甚至完全不會執行到),因為Haskell的程式碼只有在需要的時候才會被執行到。

嚴格求值的定義

如果對一個表示式的求值一直執行或丟擲一個錯誤而非返回一個定義的值,我們說這個表示式沒有結束,或者說它是evaluates to bottom(非正常返回)。
如果表示式f(x)對所有的evaluates to bottom的表示式x,也是evaluates to bottom,那麼f是嚴格求值


實際上我們經常接觸非嚴格求值,比如&&||操作符都是非嚴格求值函式

scala> false && { println("!!");true }
res0: Boolean = false 

而另一個例子是if控制結構:

if(input.isEmpty()) sys.error("empty") else input

if語句可以看作一個接收3個引數的函式(實際上Clojure中if就是這樣一個函式),一個Boolean型別的條件引數,一個返回型別為A的表示式在條件為true時執行,另一個同樣返回型別為A的表示式在條件為false時執行。


因此可以說,if函式對條件引數是嚴格求值,而對分支引數是非嚴格求值。

if_(a < 22,
    () -> println("true"),
    () -> println("false"))

而一個表示式的未求值形式我們稱為thunk

Scala中提供一種語法可以自動將表示式包裝為thunk:

def if_[A](cond: Boolean, onTrue: => A, onFalse: => A): A =
    if(cond) onTrue else onFalse

這樣,在使用和呼叫的時候都不需要做任何特殊的寫法scala會為我們自動包裝:

if_(false, sys.error("fail"), 3)

預設情況下以上包裝的thunk在每次引用的時候都會求值一次:

scala> def maybeTwice(b: Boolean, i => int) = 
    if(b) i+i else 0
scala> val x = maybeTwice(true, { println("hi"); 42})
hi
hi
x: Int = 84

所以Scala也提供一種語法可以延遲求值並儲存結果,使後續引用不會觸發重複求值

scala> def maybeTwice(b: Boolean, i => int) = {
     |   lazy val j = i
     |   if(b) j+j else 0
     | }
scala> val x = maybeTwice(true, { println("hi"); 42})
hi
x: Int = 84

Kotlin在1.1版本中通過委託屬性的方式提供了這種特性的實現


惰性列表

定義:

sealed class Stream<out A> {
    abstract val head: () -> A
    abstract val tail: () -> Stream<A>
    
    object Empty : Stream<Nothing>() {...}
    data class Cons<A>(val h: () -> A, 
                       val t: () -> Stream<A>) 
                       : Stream<A>() {...}
    ...
}
sealed class List<out A> {
    abstract val head: A
    abstract val tail: List<A>
    
    object Nil : List<Nothing>() {...}
    data class Cons<out A>(val head: A,
                           val tail: List<A>) 
                           : List<A>() {...}
    ...
}

eg:

Stream.apply(1, 2, 3, 4)
        .map { it + 10 }
        .filter { it % 2 == 0 }
        .toList()

toList()方法請求資料,然後請求到1,然後經過一系列計算回到toList();然後請求下一個資料,依次類推直到無資料可取toList()方法結束。


無限流與共遞迴

eg:

fun ones(): Stream<Int> = Stream.cons({ 1 }, { ones() })

fun <A> constant(a: A): Stream<A> = 
    Cons({ a }, { constant(a) })
val fibs = {
    fun go(f0: Int, f1: Int): Stream<Int> =
            cons({ f0 }, { go(f1, f0+f1) })
    go(0, 1)
}

unfold函式

fun <A, S> unfold(z: S, f: (S) -> Option<Pair<A, S>>)
    : Stream<A> {
    val option = f(z)
    return when {
        option is Option.Some -> {
            val h = option.get.first
            val s = option.get.second
            cons({ h }, { unfold(s, f) })
        }
        else -> empty()
    }
}

似乎和Iterator很像,但這裡返回的是一個惰性列表,並且沒有可變數


共遞迴有時也稱為守護遞迴,生產能力有時也稱為共結束

函數語言程式設計的主題之一便是關注分離,希望能將計算的描述實際執行分開。比如

  • 一等函式:函式體內的邏輯只有在接收到引數的時候才執行
  • Either:將捕獲的錯誤如何對錯誤進行處理進行分離
  • Stream:將如何產生一系列元素的邏輯和實際生成元素進行分離

惰性計算便是實踐這一思想。


惰性計算是隻有在必要時才進行計算固然在很多方面都提供了好處(無限列表、計算延遲等等),但這也意味著對函式的純淨度有更高的要求:
由於不確定傳入的函式什麼時候執行、按什麼順序執行(在效能優化的時候進行指令重排)、甚至會不會執行,如果傳給map或者filter的函式是有副作用的,就會使程式狀態變得不可控。

eg:

public Adapter<String> getAdapter() {
    BaseAdapter adapter = new ListAdapter(getContext());
    
    presenter.getListData()
        .map(m -> m.getName())
        .subscribe(adapter::setData);
        
    return adapter;
}

public Observable<Adapter<String>> getAdapter() {
    return Observable.zip(
    
            Observable.just(getContext())
                    .map(c -> new ListAdapter(c)),
                    
            presenter.getListData()
                    .map(m -> m.getName()),
                    
            (adapter, data) -> adapter.setData(data));
}

public Observable<Adapter<String>> getAdapter(
    Context context,
    Observable<DataModel> dataProvider) {
    return Observable.zip(
    
            Observable.just(context)
                    .map(c -> new ListAdapter(c)),
                    
            dataProvider
                    .map(m -> m.getName()),
                    
            (adapter, data) -> adapter.setData(data));
}

純函式式狀態

eg:
隨機數生成器:

public static int main(String[] args) {
    Random random = new Random();
    System.out.println(random.nextInt());
    System.out.println(random.nextInt());
    System.out.println(random.nextInt());
}

很明顯,原有的Random函式不是引用透明的,這意味著它難以被測試、組合、並行化。


比如現在有一個函式模擬6面色子

fun rollDie(): Int {
    val rng = Random()
    return rng.nextInt(6)
}

我們希望它能返回1~6的值,然而它返回的是0~5,在測試時有一定可能會失敗,然而在失敗時希望重現失敗也不切實際。


也許我們可以通過傳入指定的Random物件保證一致的生成:

fun rollDie(rng : Random): Int {
    return rng.nextInt(6)
}

但呼叫一次nextInt方法後Random上一次的狀態就丟失了,這意味著我們還需要傳一個呼叫次數的引數?

不,我們應該避開副作用


定義:

interface Rng {
  fun nextInt: (Int, Rng)
}
class LcgRng(val seed: Int = System.currentTimeMillis())
: Rng {
    //線性同餘演算法
    fun nextInt(): Pair<Int, Rng> {
        val newSeed = (seed * 0x5DEECE66DL + 0xBL) & 0xFFFFFFFFFFFFL;
        val nextRng = LcgRng(newSeed)
        val n = (newSeed >>> 16).toInt();
        return Pair(n, nextRng);
    }
}

更加範化此生成器我們可以定義:

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

基於這個隨機數生成器我們可以進行更加靈活的組合:

val int: Rand[Int] = _.nextInt

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

def double(rng: RNG): (Double, RNG)

def map2[A,B,C](ra: Rand[A], rb: Rand[B], 
    f: (A, B) => C): Rand[C]

def flatMap[A,B](f: Rand[A])(g: A => Rand[B]): Rand[B]

更為通用的狀態行為資料型別:
scala

case class State[S, +A](run: S => (A, S))

kotlin

class State<S, out T>(val run: (S) -> Pair<T, S>)

java

public final class State<S, A> {
    private final F<S, P2<S, A>> runF;
}

for推導

val ns: Rand[List[Int]] =
    //int為Rand[Int]的值,生成一個隨機整數
    int.flatMap(x => 
        //ints(x)生成一個長度為x的list
        ints(x).map(xs =>
            //list中的每個元素變換為除以y的餘數
            xs.map(_ % y))))

for推導語句:

val ns: Rand[List[Int]] = for {
    x <- int
    y <- int
    xs <- ints(x)
} yield xs.map(_ % y)

類似Haskell的Do語句
for推導使得書寫風格更加命令式、更加易懂

Avocado(為Java中實現do語法的小工具)

DoOption
        .do_(() -> Optional.ofNullable("1"))
        .do_(() -> Optional.ofNullable("2"))
        .do_12((x, y) -> Optional.ofNullable(x + y))
        .return_123((t1, t2, t3) -> t1 + t2 + t3)
        .ifPresent(System.out::println);

經典問題:

實現一個對糖果售貨機建模的有限狀態機。機器有兩種輸入方式:可以投入硬幣,或可以按動按鈕獲取糖果。它可以有一種或兩種狀態:鎖定或非鎖定。它也可以跟蹤還剩多少糖果以及有多少硬幣。

機器遵循下面的規則:

  • 對一個鎖定狀態的售貨機投入一枚硬幣,如果有剩餘的糖果它將變為非鎖定狀態。
  • 對一個非鎖定狀態的售貨機按下按鈕,它將給出糖果並變回鎖定狀態。
  • 對一個鎖定狀態的售貨機按下按鈕或對非鎖定狀態的售貨機投入硬幣,什麼也不做。
  • 售貨機在“輸出”糖果時忽略所有“輸入”

sealed trait Input
case object Coin extends Input
case object Turn extends Input

case class Machine(locked: Boolean, candies: Int,
                   coins: Int)

object Candy {
  def update = (i: Input) => (s: Machine) =>
    (i, s) match {
      case (_, Machine(_, 0, _)) => s
      case (Coin, Machine(false, _, _)) => s
      case (Turn, Machine(true, _, _)) => s
      case (Coin, Machine(true, candy, coin)) =>
        Machine(false, candy, coin + 1)
      case (Turn, Machine(false, candy, coin)) =>
        Machine(true, candy - 1, coin)
    }

  def simulateMachine(inputs: List[Input])
      : State[Machine, (Int, Int)] = for {
    _ <- sequence(inputs map (modify[Machine] _ compose update))
    s <- get
  } yield (s.coins, s.candies)
}

本章知識點:

  1. 惰性求值
  2. 函式式狀態

To be continued

相關文章