函數式編程中有不少優秀的設計理念值得咱們去學習,本文對函數式編程中的基礎理念進行了簡要介紹,但更重要的是思考、總結如何將它們應用到咱們平常開發中,幫助咱們去提高代碼的可讀性、可維護性等。git
本文同時發表於個人我的博客github
©原創文章,轉載請註明出處!shell
對函數式編程一直有所耳聞,但並未深刻研究過,在平常開發中也不多去思考這方面的問題。 直到最近,在開發 flutter 應用時,因爲 dart 對函數式編程有較好地支持,對函數式編程有了更新的認識。 純函數式的編程對咱們正常的業務開發來講是一種『烏托邦』式的存在,但其中有不少的設計理念值得咱們去學習。 函數式編程相關的文章也有很多,本文的不一樣之處在於其立足點是: 如何利用函數式編程理念幫助咱們寫出更好的代碼,這也是本文標題叫作函數式思惟而不是函數式編程的緣由。編程
函數式編程的理論基礎是
λ
演算(lambda calculus),但本文並不打算在理論層面上作過多的討論。json
首先,總結一下我我的的觀點:函數式編程能給咱們帶來什麼? 簡單、清晰、易維護、可複用的代碼。redux
簡單、清晰、易維護、可複用能夠說是各類架構設計、設計規範追求的第一目標。設計模式
那函數式編程又是經過什麼方式實現這樣的收益的:緩存
本文將主要圍繞以上幾方面對函數式編程展開討論。bash
函數式編程做爲編程範式(Programming Paradigm)之一,與之對應的,也是咱們最熟悉的命令式編程(Imperative programming,面向對象編程也屬於該範式)。網絡
從思惟模式上說:
從理論依據上說:
從實現手法上說: 函數式編程是對命令式編程進一步的抽象,屏蔽具體細節,以更加抽象、更加接近天然語言的方式去描述程序的意圖,將實現細節交由語言運行時或三方擴展去完成。 從而,開發人員能夠從實現細節中解脫出來,站在更高的抽象層次上去思考業務問題。
純函數、高階函數、函數一等公民身份、集合操做三板斧等理念極大地提升了語言的創造力、表現力。 雖然沒法作到純函數式,但愈來愈多的高級語言開始向函數式方向發展,將函數式中的若干重要理念引入自身語法中,如:JavaScript、Swift、Java、dart 等。
函數式編程中的『函數』並不是咱們平常開發中所說的函數(方法),而是數學意義上的函數——映射。 咱們知道數學上函數(映射)對相同的輸入一定有相同的輸出(映射關係是一一對應的)。 所以,函數式編程中純函數也要知足一樣的特徵:
要知足這一點,意味着函數不能依賴除入參之外的任何外部狀態。 面向對象中類的成員函數隱式地包含this
指針,經過它能夠很方便地在成員函數中引用成員變量,這就是純函數的典型反面教材。
爲何? 實現了函數級的解耦,除了入參沒有複雜的依賴關係,這樣的函數可讀性、可維護性就變得很高。 相信你們在日常開發中,也能有這樣的感覺: 在理解、維護一個函數時,若其依賴了大量的外部狀態,一定會形成不小的認知壓力。 除了要理解函數自己的邏輯外,還要去關心其引用的外部狀態信息。 有時不得不跳出函數自己去查看這些依賴的外部信息,閱讀流程也所以被打斷。
反作用是指除指望的函數輸出值外的任何產出。
常見的反作用包括,但不限於:
總之,純函數就是不能與外部有任何的耦合關係,包括對外界的依賴以及對外界的影響。
很明顯,純函數的收益主要有:
在實際開發中,雖然沒法作到全部函數都是純函數,但純函數意識應該要深植咱們腦海中,儘量地寫更多的純函數。
函數式編程還有一個重要理念:函數是值,即一等函數(first-class),或者說函數有一等公民身份。 這意味着任何可使用值的地方均可以使用函數,如參數、返回值等。
所謂高階函數就是其參數或返回值至少有一個是函數類型。 高階函數使得複用粒度降到了函數級別,在面向對象中複用粒度通常在類級別。
閉包(closure)是高階函數得以實現的底層支撐能力。
從另外一個角度講,高階函數也實現了更高層級的抽象,由於實現細節能夠經過參數的形式傳入,即在函數級別上實現了依賴注入機制。所以,多種GoF設計模式能夠經過高階函數的形式來實現,如:Template Method模式、Strategy模式等。
簡單講,柯里化就是將『多參數函數』轉換成『一系列單參數函數』的過程。
// 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)
。
柯里化有什麼做用?
filter
、map
、reduce
、expand
等只接收單參數函數,所以若是現有的函數是多參數,可經過柯里化轉換成單參數;如,有屢次調用加法運算的需求,且每次都是加10
時,用普通add
函數實現:
add(10, 1);
add(10, 2);
add(10, 3);
複製代碼
而經過柯里化的版本:
var addTen = addCurrying(10);
addTen(1);
addTen(2);
addTen(3);
複製代碼
著名的 JavaScript 三方庫lodash
提供了curry
封裝函數,使得柯里化更加方便,如上面的addCurrying
用lodash#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等。 經過這些高度抽象的操做,能夠寫出很是簡潔、易讀的代碼。
下面對一些常見集合操做做一個簡要介紹。
過濾就是將列表中不知足指定條件的元素過濾掉,知足條件的元素以新列表的形式返回。 在不一樣的語言中,該操做的名稱有所不一樣: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就是將集合中的每一個元素進行一次轉換,獲得一個新的值,其類型能夠相同也能夠不一樣。
// 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();
複製代碼
摺疊簡單講就是將指定操做依次做用於集合每一個元素上,操做結果按操做規則依次疊加,並最終返回該疊加結果(結果類型通常是一個具體的值,而不是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提供了兩個操做方法reduce
、fold
,主要區別在於後者能夠提供摺疊時的初始值。
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).
reduce
、
fold
都是從左往右進行摺疊,有的語言還提供了從右往左摺疊的版本,如JavaScript:
reduceRight(callback(accumulator, currentValue[, index[, array]])[, initialValue])
複製代碼
集合操做還有不少,在此不一一列舉。當開始使用這些操做後,會驚奇地發現根本停不下來!
集合上的操做還有一個重要特性:不可變性(immutable),即這些操做不會改變它們正在做用的集合,而是生成新集合來提供操做結果。
有不少的模式或框架都有相似的思想,如:flux、redux、bloc等,它們都強調(強制)任何操做都不能直接修改現有數據,而是在現有數據的基礎上生成新數據,最終總體替換掉老數據。
在實際開發中咱們也遇到過相似的問題,網絡請求在子線程返回數據後直接修改了數據源,致使出現數據不一樣步的多線程問題。最好的解決方案是網絡請求返回後在子線程組裝好完整的數據,再到主線程進行一次性替換。
不可變性很好地避免了中間狀態、狀態不一樣步等問題,也較好地規避了多線程問題。 同時,不變性語義使得代碼可讀性、維護推理性變得更好。
由於,經過filter
、map
、reduce
等操做,而不是for
、while
循環語句操做集合,能夠清楚地表達將會生成一個新集合,而不是修改現有集合的意圖,代碼更加簡潔明瞭。
另外,因爲集合上的這些操做的返回值類型大都是集合,所以,當有多個操做做用於集合時,就能夠以鏈式調用的方式實現。這也進一步簡化了代碼。 看一個 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
循環版本須要很當心地維護實現上的細節問題,還引入了沒必要要的中間狀態:count
、url
、memberWidget
、emails
等,這些都是滋生 bug 的溫牀!
好了,說到減小中間狀態就不得不提 Pointfree。
仔細分析上節獲取成績>=90分學生 email 的函數式版本,發現整個過程其實能夠分爲2個獨立的步驟:
將這兩個步驟獨立成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
,其入參爲兩個單參數函數(f
、g
),輸出仍是一個單參數函數(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 的概念。
咱們不奢望,也沒辦法作到純函數式的編程,但函數式編程中不少優秀的設計理念都值得咱們去學習和借鑑: