函數範式入門(什麼是函數式編程)

第一節 函數式範式


1. 什麼是函數式編程

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

2. 函數式的基本特性

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

2.1 無反作用

  • 對於程序p,若是它包含的表達式e知足 引用透明 ,則全部的e均可以被替換爲它的運算結果而不會改變程序p的含義。
  • 假設存在一個函數f,若表達式f(x)對全部引用透明的表達式x也是引用透明的,那麼這個f是一個 純函數
函數範式其實核心解決的就是 反作用的問題, 不管是 隔離反作用的IO仍是 不可變都是爲了解決這個問題而存在. 因此對於 反作用的理解必定要足夠深入. 對於GUI開發人員而言反作用尤爲無處不在, 實際開發中會使用大量的手法和技巧去隔離它們.

引用透明

引用透明的另外一種理解方式是,引用透明的表達式不依賴上下文,能夠本地推導,而那些非引用透明的表達式是依賴上下文,而且須要全局推導。java


3. 函數式數據結構


3.1 基本數據類型

100個函數操做一種數據結構的組合要好過10個函數操做10種數據結構的組合。

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

而函數式採用了另外一種重用思路,它們用不多一組關鍵數據結構(如list、set、map)來搭配專爲這些數據結構深度優化過的操做。在這些關鍵數據結構和操做構成的一套運起色構上,按須要插入另外的數據結構(以後會講解)和高階函數來調整以適應具體問題。github

好比在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不作改變。

這是否意味着咱們要對數據作不少額外的複製?答案是否認的

咱們能夠先檢驗一下最廣泛存在的函數式數據結構:單項列表app

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模式在不一樣的範式下已經發生了許多的變化:

  • 模式已經被吸取爲語言的一部分
  • 模式中描述的解決辦法在函數式範式下依然成立,但實現細節有所變化。
  • 因爲在新的語言或範式下得到了本來沒有的能力,產生了新的解決方案。
詳細: Scala與Clojure函數式編程模式:Java虛擬機高效編程

科裏化與部分施用

科裏化:

(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

相關文章
相關標籤/搜索