函數式思惟

函數式編程中有不少優秀的設計理念值得咱們去學習,本文對函數式編程中的基礎理念進行了簡要介紹,但更重要的是思考、總結如何將它們應用到咱們平常開發中,幫助咱們去提高代碼的可讀性、可維護性等。git

本文同時發表於個人我的博客github

©原創文章,轉載請註明出處!shell

Overview


對函數式編程一直有所耳聞,但並未深刻研究過,在平常開發中也不多去思考這方面的問題。 直到最近,在開發 flutter 應用時,因爲 dart 對函數式編程有較好地支持,對函數式編程有了更新的認識。 純函數式的編程對咱們正常的業務開發來講是一種『烏托邦』式的存在,但其中有不少的設計理念值得咱們去學習。 函數式編程相關的文章也有很多,本文的不一樣之處在於其立足點是: 如何利用函數式編程理念幫助咱們寫出更好的代碼,這也是本文標題叫作函數式思惟而不是函數式編程的緣由。編程

函數式編程的理論基礎是λ演算(lambda calculus),但本文並不打算在理論層面上作過多的討論。json

首先,總結一下我我的的觀點:函數式編程能給咱們帶來什麼? 簡單、清晰、易維護、可複用的代碼。redux

簡單、清晰、易維護、可複用能夠說是各類架構設計、設計規範追求的第一目標。設計模式

那函數式編程又是經過什麼方式實現這樣的收益的:緩存

  • 狀態不可變、純函數;
  • 避免引入狀態,Pointfree;
  • 強調組合、提升複用性;
  • 更高層次的抽象,豐富的集合操做。

本文將主要圍繞以上幾方面對函數式編程展開討論。bash

Functional vs. Imperative


函數式編程做爲編程範式(Programming Paradigm)之一,與之對應的,也是咱們最熟悉的命令式編程(Imperative programming,面向對象編程也屬於該範式)。網絡

從思惟模式上說:

  • 命令式編程:『過程導向』,強調怎麼作——關注點在執行步驟,如何一步一步地去完成任務;
  • 函數式編程:『結果導向』,強調作什麼——關注點在執行結果,相比屬於更高層次的抽象,並不關心實現細節。

從理論依據上說:

  • 命令式編程:面向計算機的模型,變量、賦值、控制語句等分別對應計算機的物理存儲、讀寫指令、跳轉指令;
  • 函數式編程:面向數學的模型,將任務以表達式求值的形式表現。

從實現手法上說: 函數式編程是對命令式編程進一步的抽象,屏蔽具體細節,以更加抽象、更加接近天然語言的方式去描述程序的意圖,將實現細節交由語言運行時或三方擴展去完成。 從而,開發人員能夠從實現細節中解脫出來,站在更高的抽象層次上去思考業務問題。

現狀

純函數、高階函數、函數一等公民身份、集合操做三板斧等理念極大地提升了語言的創造力、表現力。 雖然沒法作到純函數式,但愈來愈多的高級語言開始向函數式方向發展,將函數式中的若干重要理念引入自身語法中,如:JavaScript、Swift、Java、dart 等。

純函數


函數式編程中的『函數』並不是咱們平常開發中所說的函數(方法),而是數學意義上的函數——映射。 咱們知道數學上函數(映射)對相同的輸入一定有相同的輸出(映射關係是一一對應的)。 所以,函數式編程中純函數也要知足一樣的特徵:

  • 相同的輸入,一定獲得相同的輸出;
  • 函數調用沒有任何反作用。

相同的輸入,相同的輸出

要知足這一點,意味着函數不能依賴除入參之外的任何外部狀態。 面向對象中類的成員函數隱式地包含this指針,經過它能夠很方便地在成員函數中引用成員變量,這就是純函數的典型反面教材。

爲何? 實現了函數級的解耦,除了入參沒有複雜的依賴關係,這樣的函數可讀性、可維護性就變得很高。 相信你們在日常開發中,也能有這樣的感覺: 在理解、維護一個函數時,若其依賴了大量的外部狀態,一定會形成不小的認知壓力。 除了要理解函數自己的邏輯外,還要去關心其引用的外部狀態信息。 有時不得不跳出函數自己去查看這些依賴的外部信息,閱讀流程也所以被打斷。

無反作用

反作用是指除指望的函數輸出值外的任何產出。

常見的反作用包括,但不限於:

  • 改變外部數據(如類的成員變量、全局變量);
  • 發送網絡請求;
  • 讀寫文件;
  • 執行DB操做;
  • 獲取用戶交互信息(用戶輸入);
  • 讀取系統狀態信息;
  • 打日誌;
  • ...

總之,純函數就是不能與外部有任何的耦合關係,包括對外界的依賴以及對外界的影響。

很明顯,純函數的收益主要有:

  • 可維護性更高;
  • 可測性更強;
  • 可複用性更好;
  • 高併發更容易,沒有多線程問題;
  • 可緩存,因爲相同的輸入,一定有相同的輸出,所以對於高頻、昂貴的操做能夠緩存結果,避免重複計算。

在實際開發中,雖然沒法作到全部函數都是純函數,但純函數意識應該要深植咱們腦海中,儘量地寫更多的純函數。

高階函數(Higher-order function)


函數式編程還有一個重要理念:函數是值,即一等函數(first-class),或者說函數有一等公民身份。 這意味着任何可使用值的地方均可以使用函數,如參數、返回值等。

所謂高階函數就是其參數或返回值至少有一個是函數類型。 高階函數使得複用粒度降到了函數級別,在面向對象中複用粒度通常在類級別。

閉包(closure)是高階函數得以實現的底層支撐能力。

從另外一個角度講,高階函數也實現了更高層級的抽象,由於實現細節能夠經過參數的形式傳入,即在函數級別上實現了依賴注入機制。所以,多種GoF設計模式能夠經過高階函數的形式來實現,如:Template Method模式、Strategy模式等。

柯里化(Currying)


簡單講,柯里化就是將『多參數函數』轉換成『一系列單參數函數』的過程。

// JavaScript
//
function add(x, y) {
  return x + y;
}

var addCurrying = function(x) {
  return function(y) {
    return x + y;
  }
}
複製代碼

如上,add是進行加法運算的函數,其接收2個參數,如add(1, 2)。 而addCurrying是通過柯里化處理過的,本質上addCurrying是單參數函數,其返回值也是一個單參數函數。 add(1, 2),等價於addCurrying(1)(2)

柯里化有什麼做用?

  • 在函數式集合操做上,如:filtermapreduceexpand等只接收單參數函數,所以若是現有的函數是多參數,可經過柯里化轉換成單參數;
  • 當某個函數須要屢次調用,且部分參數相同時,經過柯里化能夠減小重複參數樣板代碼。

如,有屢次調用加法運算的需求,且每次都是加10時,用普通add函數實現:

add(10, 1);
add(10, 2);
add(10, 3);
複製代碼

而經過柯里化的版本:

var addTen = addCurrying(10);
addTen(1);
addTen(2);
addTen(3);
複製代碼

著名的 JavaScript 三方庫lodash提供了curry封裝函數,使得柯里化更加方便,如上面的addCurryinglodash#curry函數實現:

var curry = require('lodash').curry;
var addCurrying = curry(function(a, b) {
  return a + b;
});
複製代碼

對函數式編程來講,柯里化是一項不可或缺的技能。 對咱們而言,即便不寫函數式的代碼,在解決重複參數等問題上柯里化也提供了一種全新的思路。

集合操做三板斧


函數式編程語言和麪向對象語言對待代碼重用的方式不同。面嚮對象語言喜歡大量地創建有不少操做的各類數據結構,函數式語言也有不少的操做,但對應的數據結構卻不多。面嚮對象語言鼓勵咱們創建專門針對某個類的方法,咱們從類的關係中發現重複出現的模式並加以重用。函數式語言的重用表如今函數的通用性上,它們鼓勵在數據結構上使用各類共通的變換,並經過高階函數來調整操做以知足具體事項的要求。

在面向對象的命令式編程語言裏面,重用的單元是類和用做類間通訊的消息,一般能夠表述成一幅類圖(class diagram)。例如這個領域的開拓性著做《設計模式:可複用面向對象軟件的基礎》就給每個模式都至少繪製了一幅類圖。在OOP的世界裏,開發者被鼓勵針對具體的問題創建專門的數據結構,並以方法的形式,將專門的操做關聯在數據結構上。函數式編程語言選擇了另外一種重用思路。它們用不多的一組關鍵數據結構(如list 、set 、map)來搭配專爲這些數據結構深度優化過的操做。咱們在這些關鍵數據結構和操做組成的一套運起色構上面,按須要「插入」另外的數據結構和高階函數來調整機器,以適應具體的問題。例如咱們已經在幾種語言中操練過的filter函數,傳給它的代碼塊就是這麼一個「插入」的部件,篩選的條件由傳入的高階函數肯定,而運起色構則負責高效率地實施篩選,並返回篩選後的列表。 ——摘錄來自: [美] 福特(Neal Ford). 「函數式編程思惟 (圖靈程序設計叢書)。」

正如上述摘錄所說,函數式編程的又一重要理念:在有限的集合(Collection)上提供豐富的操做。 如今,不少高級語言都提供了大量對集合操做的支持,如Swift、Java 八、JavaScript、dart等。 經過這些高度抽象的操做,能夠寫出很是簡潔、易讀的代碼。

下面對一些常見集合操做做一個簡要介紹。

過濾(filter)

過濾就是將列表中不知足指定條件的元素過濾掉,知足條件的元素以新列表的形式返回。 在不一樣的語言中,該操做的名稱有所不一樣:JavaScript、Swift、Java 8中是filter,dart是where

// JavaScript
//
filter(callback(element[, index[, array]])[, thisArg]);
複製代碼
// dart
//
Iterable<E> where(bool test(E element));
複製代碼
// Swift
//
func filter(_ isIncluded: (Self.Element) throws -> Bool) rethrows -> [Self.Element];
複製代碼
// Java
//
Stream<T> filter(Predicate<? super T> predicate);
複製代碼

能夠看到,各語言表現形式上雖有所不一樣,但本質是同樣的,即爲filter注入一個回調,用於判斷其中的元素是否知足指定條件。

以下例,將年齡未滿18的過濾掉:

// JavaScript
//
const ages = [19, 2, 8, 30, 11, 18];
const result = ages.filter(age => age >= 18);
console.log(result);    // 19, 30, 18
複製代碼

經過循環語句實現就不在這列了,二者的對比應該是很明顯的。

映射(map)

map就是將集合中的每一個元素進行一次轉換,獲得一個新的值,其類型能夠相同也能夠不一樣。

// JavaScript
//
map(function callback(currentValue[, index[, array]]); 複製代碼
// dart
//
Iterable<T> map<T>(T f(E e));
複製代碼
// Swift
//
func map<T>(_ transform: (Element) throws -> T) rethrows -> [T];
複製代碼
// Java
//
<R> Stream<R> map(Function<? super T,? extends R> mapper);
複製代碼

map是平常開發中使用頻率最高的操做之一,如將json轉換成dart對象實例:

jsons.map((json) => BusinessModel.fromJson(json)).toList();
複製代碼

摺疊/化約(reduce、fold)

摺疊簡單講就是將指定操做依次做用於集合每一個元素上,操做結果按操做規則依次疊加,並最終返回該疊加結果(結果類型通常是一個具體的值,而不是Iterable,所以常常出如今鏈式調用的末端。)。

// JavaScript
//
reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue]);
複製代碼
// dart
//
E reduce(E combine(E value, E element))
T fold<T>(T initialValue, T combine(T previousValue, E element));
複製代碼
// Swift
//
func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result;
複製代碼
// Java
//
T reduce(T identity, BinaryOperator<T> accumulator);
複製代碼

其中,dart提供了兩個操做方法reducefold,主要區別在於後者能夠提供摺疊時的初始值。

List<int> nums = [1, 3, 5, 7, 9,];
  // reduceResult: 25
  //
  int reduceResult = nums.reduce((value, elemnt) => value + elemnt);
  
  // foldResult: 35
  //
  int foldResult = nums.fold(10, (value, elemnt) => value + elemnt);
複製代碼

如上例,reduce是直接對列表元素求和(結果是25),而fold在求和時提供了初始值10(結果是35).

以上 reducefold都是從左往右進行摺疊,有的語言還提供了從右往左摺疊的版本,如JavaScript:

reduceRight(callback(accumulator, currentValue[, index[, array]])[, initialValue])
複製代碼

集合操做還有不少,在此不一一列舉。當開始使用這些操做後,會驚奇地發現根本停不下來!

集合上的操做還有一個重要特性:不可變性(immutable),即這些操做不會改變它們正在做用的集合,而是生成新集合來提供操做結果。

有不少的模式或框架都有相似的思想,如:flux、redux、bloc等,它們都強調(強制)任何操做都不能直接修改現有數據,而是在現有數據的基礎上生成新數據,最終總體替換掉老數據。

在實際開發中咱們也遇到過相似的問題,網絡請求在子線程返回數據後直接修改了數據源,致使出現數據不一樣步的多線程問題。最好的解決方案是網絡請求返回後在子線程組裝好完整的數據,再到主線程進行一次性替換。

不可變性很好地避免了中間狀態、狀態不一樣步等問題,也較好地規避了多線程問題。 同時,不變性語義使得代碼可讀性、維護推理性變得更好。

由於,經過filtermapreduce等操做,而不是forwhile循環語句操做集合,能夠清楚地表達將會生成一個新集合,而不是修改現有集合的意圖,代碼更加簡潔明瞭。

另外,因爲集合上的這些操做的返回值類型大都是集合,所以,當有多個操做做用於集合時,就能夠以鏈式調用的方式實現。這也進一步簡化了代碼。 看一個 flutter 的例子:

// imperative flutter
  //
  memberIconURLs
    .where(_isValidURL)
    .take(4)
    .map(_memberWidgetBuilder)
    .fold(stack, _addMemberWidget2Stack);
複製代碼
// functional flutter
  //
  int count = 0;
  for (String url in memberIconURLs) {
    if (_isValidURL(url)) {
      Widget memberWidget = _memberWidgetBuilder(url);
      _addMemberWidget2Stack(stack, memberWidget);
      count++;
    }

    if (count >= 4) {
      break;
    }
  }
複製代碼

上面兩個代碼片斷分別用函數式集合操做、普通for循環語句實現相同的功能:將從後臺獲取的用戶頭像url轉換成頭像widget顯示在界面上(最多顯示4個,同時過濾掉無效url)。

再看個例子,進一步感覺一下二者的差別:

// imperative JavaScript
//
var excellentStudentEmails_I = function(students) {
  var emails = [];
  students.forEach(function(item, index, array) {
    if (item.score >= 90) {
      emails.push(item.email);
    }
  });
	
  return emails;
}
複製代碼
// functional JavaScript
//
var excellentStudentEmails_F = students =>
  students
    .filter(_ => _.score >= 90)
    .map(_ => _.email);
複製代碼

上面這兩段代碼都是獲取成績>=90分學生的 email。

很明顯,函數式實現的代碼簡潔、易讀、邏輯清晰、不易出錯 for循環版本須要很當心地維護實現上的細節問題,還引入了沒必要要的中間狀態:counturlmemberWidgetemails等,這些都是滋生 bug 的溫牀!

好了,說到減小中間狀態就不得不提 Pointfree。

Pointfree


仔細分析上節獲取成績>=90分學生 email 的函數式版本,發現整個過程其實能夠分爲2個獨立的步驟:

  • 過濾出成績>=90分的學生;
  • 取學生的 email。

將這兩個步驟獨立成2個小函數:

function excellentStudents(students) {
  return students
    .filter(_ => _.score >= 90);
}

function emails(students) {
  students
    .map(_ => _.email);
}

複製代碼

這時excellentStudentEmails就能夠寫成下面這樣了:

var excellentStudentEmails_N = 
  students => emails(excellentStudents(students));
複製代碼

這種嵌套調用的寫法好像看不出有什麼優點。 但有一點能夠明確:一個函數的輸出(excellentStudents)直接成爲另外一個函數的輸入(emails)。

var compose = (f, g) => x => f(g(x));
複製代碼

咱們引入另一個函數:compose,其入參爲兩個單參數函數(fg),輸出仍是一個單參數函數(x => f(g(x)))。 經過compose來改寫excellentStudentEmails

var excellentStudentEmails_C = compose(emails, excellentStudents);
複製代碼

相比嵌套調用版本excellentStudentEmails_N,組合版本excellentStudentEmails_C具備如下兩點優點:

  • 可讀性更好,從右往左而不是由內而外的閱讀順序更符合咱們的思惟習慣;
  • excellentStudentEmails_C版本自始至終從未說起要操做的數據,減小了中間狀態信息(狀態越多越容易出錯)。

沒有中間狀態,沒有參數,數據直接在組合的函數間流動,這也是 Pointfree 最直接的定義。 從本質上說,Pointfree 就是經過一系列『通用函數的組合』來完成更復雜的任務,其設計理念:

  • 鼓勵寫高內聚、可複用的『小』函數;
  • 強調『組合』,而非『耦合』,複雜任務經過小任務組合完成,而不是將全部操做耦合在一個『大』函數裏。

組合後的函數就像是用管道鏈接的同樣,數據在其中自由流動,無須外界干預:

在 UNIX shell 命令中有專門的管道命令 '|',如:ls | grep Podfile,組合了 ls 與 grep 命令,用於判斷當前目錄下是否有 Podfile 文件。

注意,對於excellentStudentEmails來講,excellentStudentEmails_F版本是更好的寫法,excellentStudentEmails_C只是用於解說 Pointfree 的概念。

小結


咱們不奢望,也沒辦法作到純函數式的編程,但函數式編程中不少優秀的設計理念都值得咱們去學習和借鑑:

  • 狀態不可變,避免過多的中間狀態;
  • 純函數;
  • 高內聚的小函數;
  • 多用組合;
  • 作好抽象,屏蔽細節;
  • ...

參考資料

JS 函數式編程指南

函數式編程思惟

什麼是函數式編程思惟

Collection Pipeline

Functional-Light JavaScript

相關文章
相關標籤/搜索