1. 程式人生 > 程式設計 >(譯)Functional Programming – a Comparison

(譯)Functional Programming – a Comparison

書名 Hands-On Functional Programming in Rust
原書可以在 www.packtpub.com 購買
歡迎評論區提翻譯建議

Functional programming 函式語言程式設計(簡稱 FP)是僅次於面向物件(OOP)的第二大流行的程式設計正規化。多年來,這兩種不同的程式設計正規化衍生出不同的語言,所以不要混淆兩者。而多正規化語言想要同時支援這兩種正規化。Rust 就是這樣一門多正規化語言。

通常情況下,函式語言程式設計強調使用可組合和最大可重用函式來定義程式行為。使用這些技術,我們將展示函式語言程式設計如何將巧妙的解決方案應用於許多常見難題中。本章將概述本書中將要介紹的大多數概念。其餘章節將專門幫助你掌握每種技術。

我們希望你能學會如下技能:

  • 能使用函式式風格來減少程式碼量以及降低程式碼的複雜性
  • 可以利用安全的抽象來寫出具有健壯安全的程式碼
  • 可以使用函式式原理設計工程複雜的專案

環境要求

要執行我們提供的例子,需要最新的 Rust,可以在這裡找到:
www.rust-lang.org/en-US/insta…
本章的程式碼也可在 GitHub 上找到:
github.com/PacktPublis…
每章的 README.md 檔案包含了特定的安裝和構建說明。

減少程式碼量和複雜度

函式語言程式設計可以大大減少完成任務所需的程式碼量以及降低複雜度。特別是在 Rust 中,正確使用函式式原理可以簡化通常情況下很複雜的設計,同時提高開發效率。

讓通用型別更通用

讓通用型別更通用的思想源於函式語言程式設計的引數化資料結構和功能實踐。在 Rust 和其他一些語言中,這被稱為泛型(generics)。型別和函式都可以被引數化。可以在通用型別上增加單個或多個約束來達到 trait 和生命週期的要求。

假如沒有泛型,結構體(struct)的定義可能會更繁瑣。這裡我們定義了三個各包含相同型別的 Point 結構體。但是,這些結構體分別使用不同的數值型別,所以在 intro_generics.rs 中將它們作為三個單獨的 PointN 來定義:

struct PointU32
{
    x: u32,y: u32
}

struct PointF32
{ x: f32,y: f32 } struct PointI32 { x: i32,y: i32 } 複製程式碼

其實,我們可以使用泛型來精簡重複的程式碼同時讓程式碼更健壯。加了泛型的程式碼更容易適應新的需求,因為許多行為(或需求)可以被引數化。假如需要改需求,也只要改一行而不是改上百行。

這段程式碼定義了一個引數化的 Point 結構體。現在,在 intro_generics.rs 只要一個定義就可以匹配所有可能的數值型別:

struct Point<T>
{
    x: T,y: T
}
複製程式碼

要是沒有泛型的話函式也存在同樣的問題。

這裡有個簡單的函式用來計算某數的平方。可惜為了匹配儘可能多的數值型別,我們需要在 intro_generics.rs 定義三個不同的函式:

fn foo_u32(x: u32) -> u32
{
    x*x
}

fn foo_f32(x: f32) -> f32
{
    x*x
}

fn foo_i32(x: i32) -> i32
{
    x*x
}
複製程式碼

類似的函式可能需要 trait bounds(指定一個或多個特定 trait 約束)讓函式可以使用該型別的任意行為。

這裡是一個使用引數化型別(譯註:原文 “parameterized type”,在 Haskell 等語言中很常見)重新定義的 foo 函式。只要一個函式就可以定義包含所有數值型別的運算。不過必須設定顯示的範圍包括基本的運算,譬如乘法及其他具備 copy 行為資料的運算,intro_generics.rs

fn foo<T>(x: T) -> T
where T: std::ops::Mul<Output=T> + Copy
{
    x*x
}
複製程式碼

甚至函式也可以作為引數。我們稱之為高階函式。 這是一個簡單的函式,它接收一個函式和引數,然後使用該函式和引數,同時返回結果。注意 trait bound Fn,這表示需要提供的函式是一個閉包(closure)。為了使某物件可以被呼叫,它必須實現 fnFnFnMut,或者 FnOnce traitintro_generics.rs

fn bar<F,T>(f: F,x: T) -> T
where F: Fn(T) -> T
{
    f(x)
}
複製程式碼

函式作為值

函式語言程式設計最明顯的特點就是函式。具體講,將函式作為值是函式語言程式設計的基礎。由於涉及很多細節,我們會先介紹 “閉包” 來為之後的內容作鋪墊。“閉包” 是實現了 fnFnFnMut,或者 FnOnce trait 的物件。

簡單的閉包可以使用內建的閉包語法來定義。因為通常情況下使用此語法可以自動實現 fnFnFnMut,或者 FnOnce trait。這個語法非常適合資料快捷操作。

現在建立一個 0 到 10 範圍的迭代器,並對每個值做平方對映。這個平方對映操作是給迭代器的 map 函式傳一個內聯閉包。表示式如下,intro_functions.rs

(0..10).map(|x| x*x);
複製程式碼

如果使用 block 語法,則閉包內可以寫多條語句的複雜主體。

以下是一個 0 到 10 範圍的迭代器,對映一個複雜的方程。閉包給迭代器 map 提供一個函式定義和變數繫結,intro_functions.rs

(0..10).map(|x| {
    fn f(y: u32) -> u32 {
        y*y
    }
    let z = f(x+1) * f(x+2);
    z*z
})
複製程式碼

可以定義接收閉包作為引數的函式或方法。要將閉包作為可供呼叫函式,就必須指定 FnFnMutFnOnce trait bound

這是一個接收函式 g 和引數 x 的高階函式(HoF)。它限定 gx 處理 u32 型別,並定義跟 g 相關的運算。高階函式 f 緊跟著簡單的內聯閉包用來呼叫,intro_function.rs

fn f<T>(g: T,x: u32) -> u32
where T: Fn(u32) -> u32
{
    g(x+1) * g(x+2)
}
fn main()
{
    f(|x|{ x*x },2);
}
複製程式碼

標準庫內多個區域,特別是迭代器(iterators)大量使用函式作為引數。

這裡有一個從 0 到 10 範圍的迭代器鏈式呼叫一堆迭代組合器。map 函式從原始值返回新的值。inspect 遍歷每個值,雖然不會改變它,但是該函式會產生副作用。filter 會忽略所有不滿足謂詞(譯註:可以理解為邏輯條件)的值。filter_map 使用單個函式來做過濾和對映。fold(譯註:學過 Java8 或者 JavaScript 之類語言的同學可以類比 reduce 來理解,還有一個 rfold 右摺疊函式,在 Haskell 中對應 foldl foldr) 會把所有的結果從初始值從左到右減少到單個值。 intro_functions.rs

(0..10).map(|x| x*x)
        .inspect(|x|{ println!("value {}",*x) })
        .filter(|x| *x<3)
        .filter_map(|x| Some(x))
        .fold(0,|x,y| x+y);
複製程式碼

Iterators(迭代器)

迭代器在 OOP 語言中是一個常見的特性,Rust 也能很好地支援迭代器。Rust 在設計迭代器之初還考慮了函式語言程式設計,可以讓程式設計師編寫更清晰的程式碼。這裡要說的概念是可組合性(composability)。當迭代器可以被操控,轉換以及組合的時候,混亂的 for 迴圈可以使用函式代替。這類例子可以在 intro_iterators.rs 檔案中找到。下表對例子進行了描述:

函式名和描述 例子
chain 連線兩個迭代器:first...second (0..10).chain(10..20);
zip 函式將兩個迭代器迭代組合成元組,直到最短迭代器結束:(a1,b1),(a2,b2),... (0..10).zip(10..20);
enumerate 是一種 zip 的特殊情況,它建立帶編號的元組 (0,a1),(1,a2),... (0..10).enumerate();
inspect 會對迭代器所有的值應用一遍函式 (0..10).inspect(|x|{println!("value {}",*x) });
map 會將一個函式應用到每個元素,並將函式返回值返回 (0..10).map(|x| x*x);
filter 將滿足謂詞的元素篩出來 (0..10).filter(|x| *x<3);
fold 可以累計所有的值到一個結果 (0..10).fold(0,y| x+y);
當你應用迭代器的時候,你可以使用 for 迴圈或者呼叫 collect for i in (0..10) {} (0..10).collect::<Vec<u64>>();

簡潔明瞭的表示式

在函式式語言中,每一項都是表示式。在函式體中沒有語句,只有一個表示式。所有的控制流都表現為具有返回值的表示式。在 Rust 中,也幾乎是這種情況。唯一的非表示式是 let 繫結和宣告項(譯註:在 Rust 中,宣告結構體、函式、型別、靜態變數、traitimplmod等等這些是語句 statement;其他的可以認為都是 expression,甚至包括運運算元,具體請繼續往下看)。

這兩個語句都能被包裹到 block 中配合其他語句建立表示式。譬如在 intro_expressions.rs 下面這個例子:

let x = {
    fn f(x: u32) -> u32 {
        x * x
    }
    let y = f(5);
    y * 3
};
複製程式碼

這種巢狀形式不太常見,但是它足以說明 Rust 語言的允許程度。

回到函式式風格的表示式,我們應重點放在編寫可讀性強,簡單或簡潔的程式碼上。當別人閱讀你的程式碼的時候,能立馬理解什麼意思。理想情況下,程式碼是自解釋的。如果你經常發現自己在寫重複程式碼,一遍在寫,一遍註釋,那麼你應該考慮一下你的程式碼是不是優秀的實踐。

先從函式式的一些例子入手,讓我們看一下大多數語言中存在的表示式,即三元表示式。在正常的 if 語句中,條件必須佔據其自己的那行,因此不能用作子表示式。 下面這是一個傳統的 if 語句,初始化一個變數,intro_expressions.rs

let x;
if true {
    x = 1;
} else {
    x = 2;
}
複製程式碼

使用三元運運算元,可以將該分配移到一行,如 intro_expressions.rs 中所展示:

let x = if true { 1 } else { 2 };
複製程式碼

Rust 中幾乎所有的 OOP 的語句是一個表示式,譬如 ifforwhile 等等。在 OOP 語言中不太常見的甚至更特別的表示式之一直接構造器表示式。所有的 Rust 型別都可以通過單個表示式例項化。只有在特定情況下,譬如,當內部欄位需要複雜的初始化時,才需要建構函式。以下是 intro_expressions.rs 中的簡單結構和等效的元組:

struct MyStruct
{
    a: u32,b: f32,c: String
}

fn main()
{
    MyStruct {
        a: 1,b: 1.0,c: "".to_string()
    };
    
    (1,1.0,"".to_string());
}
複製程式碼

函式式語言的另一個獨特的表示式是 pattern matching(模式匹配)。模式匹配可以被認為是 switch 語句的強化版本。任何表示式都可以傳到模式表示式進行解構,同時在執行分支表示式之前把內部資訊繫結到區域性變數。模式表示式特別適合於列舉。兩者可以完美結合。

下面的程式碼段定義了一個 Term 作為表示式 options 的標記聯合。在主函式中,構造了一個 Term t,然後進行模式匹配。 值得注意的是,標記聯合的定義與 intro_expressions.rs 中的模式表示式內部的匹配之間在語法上相似:

enum Term
{
    TermVal { value: String },TermVar { symbol: String },TermApp { f: Box<Term>,x: Box<Term> },TermAbs { arg: String,body: Box<Term> }
}

fn main()
{
    let mut t = Term::TermVar {
        symbol: "".to_string()
    };
    match t {
        Term::TermVal { value: v1 } => v1,Term::TermVar { symbol: v1 } => v1,Term::TermApp { f: ref v1,x: ref v2 } =>
            "TermApp(?,?)".to_string(),Term::TermAbs { arg: ref mut v1,body: ref mut v2 } =>  
            "TermAbs(?,?)".to_string()
    };
}
複製程式碼

嚴格的抽象意味著安全的抽象

具有嚴格的型別系統並不是講程式碼就會有更多的限制或者會更復雜。與其使用 strict typing 不如考慮用 expressive typingexpressive typing 能給編譯器提供更多資訊。這些額外的資訊可以幫助編譯器在編譯。同時還能用於豐富超程式設計系統。這是除了程式碼安全健壯以外的全部。

區域性資料繫結

Rust 中對變數的處理比大部分其他語言更嚴格。全域性變數幾乎是不被允許的。區域性變數會被密切監視,在超出作用域之前(不會提前)被解構。跟蹤變數的所屬範圍這一概念被稱為 ownershiplifetime

下面是一個簡單的例子,被分配了記憶體的資料結構在超出作用域的時候將被自動解構,不需要手動管理記憶體。intro_binding.rs

fn scoped() {
    vec![1,2,3];
}
複製程式碼

再稍微複雜一點的示例中,可以將分配的資料結構作為返回值傳遞或引用,而這些簡單作用域的異常也會在其中解決:

fn scope2() -> Vec<u32>
{
    vec![1,3]
}
複製程式碼

這種使用情況使得跟蹤可能變得複雜(或無法確定),因此 Rust 有一套規則來限制變數何時可以進行上下文轉義。 我們稱這種複雜的規則為 ownership(所有權)。 可以通過 intro_binding.rs 中的程式碼進行理解:

fn scoped3()
{
    let v1 = vec![1,3];
    let v2 = v1;
    // 現在沒法使用 v1 了
    // ownership 已經被轉移到 v2
}
複製程式碼

如果不可能或不希望轉移所有權,Rust 鼓勵使用 clone trait 來建立引用的資料的副本。intro_binding.rs

fn scoped4()
{
    vec![1,3].clone();
    "".to_string().clone();
}
複製程式碼

克隆或複製其實不是完美的解決方案,會帶來一些效能開銷。 為了讓 Rust 更快,再快,於是也有了借用的概念。 借用是一種接收直接引用某些資料的機制,並會承諾 ownership 將在某個特定點返回。引用以 “&” 號表示。考慮下面的示例,intro_binding.rs

fn scoped5()
{
   fn foo(v1: &Vec<u32>)
   {
       for v in v1
       {
           println!("{}",v);
       }
   }

   let v1 = vec![1,3];
   foo(&v1);

   // v1 仍然可用
   // ownership 被還回去了
   v1;
}
複製程式碼

還沒翻譯完