1. 程式人生 > >函式正規化入門(什麼是函數語言程式設計)

函式正規化入門(什麼是函數語言程式設計)

第一節 函式式正規化

1. 什麼是函數語言程式設計

  • 函數語言程式設計(英語:functional programming)或稱函式程式設計,又稱泛函程式設計,是一種程式設計範型,它將電腦運算視為數學上的函式計算,並且避免使用程式狀態以及易變物件。函式程式語言最重要的基礎是 λ演算 (lambda calculus)。而且λ演算的函式可以接受函式當作輸入(引數)和輸出(傳出值)。
  • 比起指令式程式設計,函數語言程式設計更加強調程式執行的結果而非執行的過程,倡導利用若干簡單的執行單元讓計算結果不斷漸進,逐層推導複雜的運算,而不是設計一個複雜的執行過程。

2. 函式式的基本特性

  • 不可變性
  • 無副作用(純函式、引用透明)
  • 惰性計算
  • 高階函式
  • ...

2.1 無副作用

  • 對於程式p,如果它包含的表示式e滿足 引用透明 ,則所有的e都可以被替換為它的運算結果而不會改變程式p的含義。
  • 假設存在一個函式f,若表示式f(x)對所有引用透明的表示式x也是引用透明的,那麼這個f是一個 純函式
函式正規化其實核心解決的就是副作用的問題, 無論是隔離副作用的IO還是不可變都是為了解決這個問題而存在. 所以對於副作用的理解一定要足夠深刻. 對於GUI開發人員而言副作用尤其無處不在, 實際開發中會使用大量的手法和技巧去隔離它們.

引用透明

引用透明的另一種理解方式是,引用透明的表示式不依賴上下文,可以本地推導,而那些非引用透明的表示式是依賴上下文,並且需要全域性推導。

3. 函式式資料結構

3.1 基本資料型別

100個函式操作一種資料結構的組合要好過10個函式操作10種資料結構的組合。

函式式鼓勵採用很少一組關鍵資料結構(如list、set、map)來搭配專為這些資料結構深度優化過的操作。在這些關鍵資料結構和操作構成的一套運轉機構上,按需要插入另外的資料結構(之後會講解)和高階函式來調整以適應具體問題。

在OOP的世界中,開發者被鼓勵針對具體問題建立專門的資料結構,並以方法的形式,將專門的操作關聯在資料結構上。

而函式式採用了另一種重用思路,它們用很少一組關鍵資料結構(如list、set、map)來搭配專為這些資料結構深度優化過的操作。在這些關鍵資料結構和操作構成的一套運轉機構上,按需要插入另外的資料結構(之後會講解)和高階函式來調整以適應具體問題。

比如在xml解析問題上,java語音xml解析框架繁多,每一種都有自己定製的資料結構和方法語義。而函式式語言Clojure做法相反,它不鼓勵使用專門的資料結構,而是將xml解析成標準的map結構,而Clojure本身有極為豐富的工具可以與map結構相配合,甚至可以說有幾乎無數的方法可以操作這些核心資料結構。只要將之適配到這些已有結構上,就可以以統一的方式完成工作

這種構建思想被稱為"面向組合子程式設計", 其實是一種真正更加貼近"資料結構+演算法"的構建方式, 以後也許會詳細講到

3.2 Sample: 異常處理

fun failingFn2(i: Int): Int {
    val y: Int = (throw Exception("fail"))
    try {
        val x = 42 + 5
        return x + y
    } catch (e: Exception) {
        return 43
    }
}

可以證明y不是引用透明的。我們用表示式的值替代y,卻會得到不同的語義

fun failingFn2(i: Int): Int {
    try {
        val x = 42 + 5
        // 引用透明的概念中,表示式可以被它引用的值替代,
        // 這種替代保持程式的含義。
        // 而我們對 x+y 表示式中的y替代為
        // throw Exception("fail")會產生不同的結果
        return x + ((throw Exception("fail")) as Int)
    } catch (e: Exception) {
        return 43
    }
}

異常存在的兩個問題

  1. 正如我們所討論的,異常破壞了引用透明並引入了上下文依賴,讓替代模型的簡單推導無法適用,並可能寫出令人困惑的程式碼
  2. 異常不是型別安全的,函式簽名failingFn和(Int) → Int 的函式型別沒有告訴我們可能發生什麼樣的異常,導致異常在執行時才能被檢測到。
坊間的一個習慣是建議異常應該只用於錯誤處理而非控制流

檢測異常

Java的異常檢測最低程度地強制了是處理錯誤還是丟擲錯誤,但它們導致了呼叫者對於簽名模板的修改。
但最重要的是它們不適用於高階函式,因為高階函式不可能感知由它的引數引起的特定的異常。
例如考慮對List定義的map函式:

fun <A, B> map(l: List<A>, f: (A) -> B): List<B>

這個函式很清晰,高度泛化,但與使用檢測異常不同的是,我們不能對每一個被f丟擲的異常的檢測都有一個版本的map。

異常的其他選擇

Either型別

sealed class Either<L, R> {
  data class Left<L, R>(val value: L): Either<L, R>()
  data class Right<L, R>(val value: R): Either<L, R>()
}

eg:

data class Person(val name: Name, val age: Age)
data class Name(val value: String)
data class Age(val value: Int)

fun mkName(name: String): Either<String, Name> =
        if(name.isEmpty()) Either.Left("Name is empty.")
        else Either.Right(Name(name))

fun mkAge(age: Int): Either<String, Age> =
        if(age < 0) Either.Left("Age is out of range.")
        else Either.Right(Age(age))

fun mkPerson(name: String, age: Int)
        : Either<String, Person> =
        mkName(name).map2(mkAge(age))
        { n, a -> Person(n, a) }

3.3 函式式資料結構

函式式資料結構被定義為不可變的,函式式資料結構只能被純函式操作。

比如連線兩個list會產生一個新的list,對輸入的兩個list不做改變。
這是否意味著我們要對資料做很多額外的複製?答案是否定的

我們可以先檢驗一下最普遍存在的函式式資料結構:單項列表

sealed class List<out A> {
    object Nil: List<Nothing>()
    data class Cons<A>(val head: A,
                       val tail: List<A>): List<A>()
}

定義一些基本操作

sealed class List<out A> {
    ...
    // 伴生物件
    companion object {
        fun sum(ints: List<Int>): Int =
                when(ints) {
                    is Nil -> 0
                    is Cons -> ints.head + sum(ints.tail)
                }

        fun <A> apply(vararg args: A): List<A> =
                if (args.isEmpty()) Nil
                else Cons(args.head, apply(*args.tail))

        fun product(ds: List<Double>): Double =
                when(ds){
                    is Nil -> 1.0
                    is Cons ->
                        if(ds.head == 0.0) 0.0
                        else ds.head * product(ds.tail)
                }
    }
}

伴生物件

除了經常宣告的資料型別和資料構造器之外,我們也經常宣告伴生物件。它只是與資料型別同名的一個單例,通常在裡面定義一些用於建立或處理資料型別的便捷方法。
正如之前所說,函式式需要保持不變性,對於類就包括屬性的不變和方法的不變:

  1. 屬性不變,所以我們只能使用純函式操作它們,而容納這些操作函式的地方通常就是在伴生物件。
  2. 方法不變,所以通常情況下函式正規化不鼓勵繼承(這也是為什麼Kotlin的類預設不可繼承),而是鼓勵我們通過各種組合子和介面(在不同語言有不同叫法,Swift叫協議、Scala叫特質,Haskell本來就沒有OO中的“類”概念所以叫做“型別類”)的組合來描述複雜的關係。

用伴生物件是Scala中的一種慣例。Kotlin也引入了它。

關於型變

在sealed class List<out A>的聲明裡,泛型A前的“out”是一個型變符號,代表A是協變的,類似Java中的“extend”。意味著如果Dog是Animal的子類,那麼List<Dog>是List<Animal>的子型別。
型變分為三種:

  • 協變 是可以用自己替換需要自己父親的位置而是允許的,也就是當引數需要父型別引數時你可以傳入子型別
  • 逆變 就是可以用父親替換兒子的位置而是允許的,也就是當引數需要子型別的時候可以傳入父型別
  • 不變 就是不能改變引數型別

它是理解型別系統的重要基石,但通常不用太在意型變註釋,不用型變註釋也會使函式簽名簡單些,只是對於介面而言會失去很多靈活性。

模式匹配

Kotlin的模式匹配太簡單了,還是看看Scala的模式匹配吧

sealed trait List[+A]
case object Nil extends List[Nothing]
case class Cons[+A](head: A,
                    tail: List[A]) extends List[A]

object List {
  def sum(ints: List[Int]): Int = ints match {
    case Nil => 0
    case Cons(x,xs) => x + sum(xs)
  }

  def product(ds: List[Double]): Double = ds match {
    case Nil => 1.0
    case Cons(0.0, _) => 0.0
    case Cons(x,xs) => x * product(xs)
  }

  def apply[A](as: A*): List[A] = 
    if (as.isEmpty) Nil
    else Cons(as.head, apply(as.tail: _*))

模式匹配

模式匹配類似一個別致的switch宣告,它可以侵入到表示式的資料結構內部(Kotlin只做到了自動型別轉換)。
Scala中用關鍵字“match”和花括號封裝的一系列case語句構成。
每一條case語句由“=>”箭頭左邊的模式和右邊的結果構成,如果匹配其中一種模式就返回右邊的對應結果。

  def product(ds: List[Double]): Double = ds match {
    case Nil => 1.0
    case Cons(0.0, _) => 0.0
    case Cons(x,xs) => x * product(xs)
  }

Haskell實現

data List a = Nil | Cons a (List a) deriving (Show)

sum :: List Integer -> Integer
sum Nil = 0
sum (Cons x xs) = x + (sum xs)

product :: List Double -> Double
product Nil = 1
product (Cons 0 t) = 0
product (Cons x xs) = x * product xs

apply :: [a] -> List a
apply [] = Nil
apply (x:xs) = Cons x (apply xs)

Avocado中的模式匹配DSL(早期為Java實現模式匹配開發的小工具)

public Integer sum(List<Integer> ints) {
    return match(ints)
            .empty(() -> 0)
            .matchF((x, xs) -> x + sum(xs))
            .el_get(0);
}

public Double product(List<Double> ds) {
    return match(ds)
            .empty(() -> 1.0)
            .matchF(0.0, xs -> 0.0)
            .matchF((x, xs) -> x * product(xs))
            .el_get(0.0);
}

函式式資料結構中的資料共享

當我們對一個已存在的列表xs在前面新增一個元素1的時候,返回一個新的列表,即Cons(1, xs),既然列表是不可變的,我們不需要去複製一份xs,可以直接複用它,這稱為 資料共享
共享不可變資料可以讓函式實現更高的效率。我們可以返回不可變資料結構而不用擔心後續程式碼修改它,不需要悲觀地複製一份以避免對其修改或汙染。
所有關於更高效支援不同操作方式的純函式式資料結構,其實都是找到一種聰明的方式來利用資料共享。
正因如此,在大型程式裡,函數語言程式設計往往能取得比依賴副作用更好的效能。

4. 函式式設計模式

函式正規化是不同於面向物件的程式設計正規化,如果我們以物件為本,就很容易不自覺地按照物件的術語來思考所有問題的答案。
我們需要改變思維,學會用不同的正規化處理不同的問題。

因為函式式世界用來搭建程式的材料不一樣了,所以解決問題的手法也不一樣了。GoF模式在不同的正規化下已經發生了許多的變化:

  • 模式已經被吸收為語言的一部分
  • 模式中描述的解決辦法在函式式正規化下依然成立,但實現細節有所變化。
  • 由於在新的語言或正規化下獲得了原本沒有的能力,產生了新的解決方案。

科裡化與部分施用

科裡化:

(A, B, C) -> D  ==> (A) -> (B) -> (C) -> D 
//Java lambda
a -> b -> c -> d

部分施用

(A, B, C) -> D  ==> (A, C) -> D 

記憶模式

對純函式的呼叫結果進行快取,從而避免執行相同的計算。
由於在給定引數不變的情況下,純函式始終會返回相同的值,所以我們可以採用快取的呼叫結果來替代重複的純函式呼叫。

  1. 所有被記憶的函式必須保證:
  • 沒有副作用
  • 不依賴任何外部資訊
  1. Groovy和Clojure有都內建的memoize函式。

Scala和Kotlin可以擴充套件實現(內部的實現方法可以看做區域性作用,關於區域性作用最後一章會詳講)

尾遞迴模式

在不使用可變狀態且沒有棧溢位的情況下完成對某個計算的重複執行。

tailrec fun findFixPoint(x: Double = 1.0): Double = 
        if (x == Math.cos(x)) x
        else findFixPoint(Math.cos(x))

Trampoline:蹦床

尾遞迴對函式寫法有嚴格的要求,但一方面有些語言不支援(如Java),另一方面尾遞迴被大量使用,因此引入了應對棧溢位的通用解決方案:Trampoline

sealed trait Free[F[_],A] {
  def flatMap[B](f: A => Free[F,B]): Free[F,B] =
    FlatMap(this, f)
  def map[B](f: A => B): Free[F,B] =
    flatMap(f andThen (Return(_)))
}
case class Return[F[_],A](a: A) extends Free[F, A]
case class Suspend[F[_],A](s: F[A]) extends Free[F, A]
case class FlatMap[F[_],A,B](s: Free[F, A],
                             f: A => Free[F, B]) extends Free[F, B]

未優化:

val f = (x: Int) => x
val g = List.fill(10000)(f).foldLeft(f)(_ compose _)

scala> g(42)
java.lang.StackOverflowError

Trampoline:

val f: Int => Free[Int] = (x: Int) => Return(x)
val g = List.fill(10000)(f).foldLeft(f) {
    (a, b) => x => Suspend(() => a(x).flatMap(b))
}

虛擬碼:

FlatMap(a1, a1 => 
    FlatMap(a2, a2 => 
        FlatMap(a3, a4 =>
            ...
            DlatMap(aN, aN => Return(aN)))))

當程式在JVM中進行函式呼叫時,它將棧中壓入呼叫幀。而Trampoline將這種控制邏輯在Trampoline資料結構中顯式地描述了出來。
當解釋Free程式時,它將決定程式是否請求使用Suspend(s)執行某些“作用”,還是使用FlatMap(x,f)呼叫子程式。
取代採用呼叫棧,它將呼叫x,然後通過在結果上呼叫f繼續。而且f無論何時都立即返回,再次將控制交於執行的run()函式。

這種方式其實可以認為是一種 協程

Scala中的Trampoline採用語言原生的尾遞迴優化實現:

@annotation.tailrec
def runTrampoline[A](a: Free[Function0,A]): A = (a) match {
  case Return(a) => a
  case Suspend(r) => r()
  case FlatMap(x,f) => x match {
    case Return(a) => runTrampoline { f(a) }
    case Suspend(r) => runTrampoline { f(r()) }
    case FlatMap(a0,g) => runTrampoline { a0 flatMap { a0 => g(a0) flatMap f } }
  }
}
尾遞迴實現涉及大量後面會講的知識,因此此處不會詳解

FunctionalJava中則直接通過迴圈替換遞迴的方式實現:

public final A run() {
  Trampoline<A> current = this;
  while (true) {
    final Either<P1<Trampoline<A>>, A> x = 
        current.resume();
    for (final P1<Trampoline<A>> t : x.left()) {
      current = t._1();
    }
    for (final A a : x.right()) {
      return a;
    }
  }
}

OOP開發人員習慣於框架級別的重用;在面向物件的語言中進行重用所需的必要構件需要非常大的工作量,他們通常會將精力留給更大的問題。

函式級別的重用

面向物件系統由一群互相傳送訊息(或者叫做呼叫方法)的物件組成。如果我們從中發現了一小群有價值的類以及相應的訊息,就可以將這部分類關係提取出來,加以重用。它們重用的單元是類以及與這些類進行通訊的訊息。
該領域的開創性著作是《設計模式》,至少為每個模式提供一個類圖。在 OOP 的世界中,鼓勵開發人員建立獨特的資料結構,以方法的形式附加特定的操作。
圖片描述

函式級的封裝支援在比構建自定義類結構更細的基礎級別上進行重用,在列表和對映等基本資料結構之上通過高階函式提供定製,從而實現重用。
例如,在下面的程式碼中,filter() 方法接受使用一個程式碼塊作為 “外掛” 高階函式(該函式確定了篩選條件),而該機制以有效方式應用了篩選條件,並返回經過篩選的列表。

List<Integer> transactionsIds = transactions.parallelStream()
    .filter(t -> t.getType() == Transaction.GROCERY)
    .collect(toList());
在後面的《函式設計的通用結構》這一節中我們會詳細體會到函式正規化中這種高抽象度的重用思想

本章知識點:

  1. 函式正規化的定義及基本特性
  2. 函式式資料結構
  3. 函式式設計模式

To be continued