面向未來的 Android 代碼,第 1 部分:函數式和反應式編程基礎

已發表: 2022-08-31

編寫乾淨的代碼可能具有挑戰性:庫、框架和 API 是臨時的,很快就會過時。 但是數學概念和範式是持久的; 它們需要多年的學術研究,甚至可能比我們更持久。

這不是一個向您展示如何使用庫 Y 進行 X 的教程。相反,我們專注於函數式和反應式編程背後的持久原則,因此您可以構建面向未來且可靠的 Android 架構,並在不妥協的情況下擴展和適應變化效率。

本文奠定了基礎,在第 2 部分中,我們將深入探討函數式反應式編程 (FRP) 的實現,它結合了函數式和反應式編程。

本文是為 Android 開發人員編寫的,但這些概念對任何具有通用編程語言經驗的開發人員都是相關且有益的。

函數式編程 101

函數式編程 (FP) 是一種將程序構建為函數組合的模式,將數據從 $A$ 轉換為 $B$,再轉換為 $C$ 等,直到獲得所需的輸出。 在面向對象編程 (OOP) 中,您逐條告訴計算機要做什麼。 函數式編程是不同的:你放棄控制流並定義一個“函數配方”來產生你的結果。

左側帶有文本“輸入:x”的綠色矩形有一個箭頭,指向標有“功能:f”的淺灰色矩形。在淺灰色的矩形內,有三個箭頭指向右側的圓柱體:第一個是淺藍色標記為“A(x)”,第二個是深藍色標記為“B(x)”,第三個是深灰色標記為“C” (X)。”在淺灰色矩形的右側,有一個帶有文本“輸出:f(x)”的綠色矩形。淺灰色矩形的底部有一個向下指向文本“副作用”的箭頭。
函數式編程模式

FP 起源於數學,特別是 lambda 演算,一種函數抽象的邏輯系統。 與循環、類、多態性或繼承等 OOP 概念不同,FP 嚴格處理抽象和高階函數,即接受其他函數作為輸入的數學函數。

簡而言之,FP 有兩個主要的“參與者”:數據(模型或問題所需的信息)和函數(數據之間的行為和轉換的表示)。 相比之下,OOP 類明確地將特定領域的數據結構——以及與每個類實例關聯的值或狀態——與打算與之一起使用的行為(方法)聯繫起來。

我們將更仔細地研究 FP 的三個關鍵方面:

  • FP 是聲明性的。
  • FP 使用函數組合。
  • FP 函數是純函數。

深入了解 FP 世界的一個很好的起點是 Haskell,這是一種強類型的純函數式語言。 我推薦Learn You a Haskell for Great Good! 交互式教程作為有益的資源。

FP 要素 #1:聲明式編程

關於 FP 程序,您首先會注意到它是用聲明式而不是命令式編寫的。 簡而言之,聲明式編程告訴程序需要做什麼而不是如何去做。 讓我們用一個命令式編程與聲明式編程的具體示例來解決這個抽象定義,以解決以下問題:給定一個名稱列表,返回一個列表,該列表僅包含具有至少三個元音且元音以大寫字母顯示的名稱。

勢在必行的解決方案

首先,讓我們看看這個問題在 Kotlin 中的命令式解決方案:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

現在,我們將考慮幾個關鍵的開發因素來分析我們的命令式解決方案:

  • 最高效:此解決方案具有最佳的內存使用率,並且在 Big O 分析中表現良好(基於最少的比較次數)。 在這個算法中,分析字符之間的比較次數是有意義的,因為這是我們算法中的主要操作。 令$n$ 為名字的數量,令$k$ 為名字的平均長度。

    • 最壞情況比較次數:$n(10k)(10k) = 100nk^2$
    • 解釋:$n$(循環 1)* $10k$(對於每個字符,我們與 10 個可能的元音進行比較)* $10k$(我們再次執行isVowel()檢查以決定是否將字符大寫——同樣,在最壞的情況,這與 10 個元音相比)。
    • 結果:由於平均名稱長度不會超過 100 個字符,我們可以說我們的算法在$O(n)$時間內運行。
  • 可讀性差的複雜:與我們接下來要考慮的聲明式解決方案相比,這個解決方案更長且更難遵循。
  • 容易出錯:代碼transformedCharresultvowelsCount和 encryptedChar ; 這些狀態突變會導致一些細微的錯誤,比如忘記將vowelsCount重置為 0。執行流程也可能變得複雜,很容易忘記在第三個循環中添加break語句。
  • 可維護性差:由於我們的代碼複雜且容易出錯,因此重構或更改此代碼的行為可能很困難。 例如,如果將問題修改為選擇具有三個元音和五個輔音的名稱,我們將不得不引入新變量並更改循環,從而給錯誤留下很多機會。

我們的示例解決方案說明了命令式代碼的複雜性,儘管您可以通過將代碼重構為更小的函數來改進代碼。

聲明式解決方案

現在我們了解了聲明式編程不是什麼,讓我們在 Kotlin 中展示我們的聲明式解決方案:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

使用我們用來評估命令式解決方案的相同標準,讓我們看看聲明性代碼是如何成立的:

  • 高效:命令式和聲明式實現都在線性時間內運行,但命令式的效率更高一些,因為我在這裡使用name.count() ,它將繼續計算元音直到名稱的結尾(即使在找到三個元音之後)。 我們可以通過編寫一個簡單的hasThreeVowels(String): Boolean函數來輕鬆解決這個問題。 此解決方案使用與命令式解決方案相同的算法,因此此處適用相同的複雜性分析:我們的算法在$O(n)$時間內運行。
  • 簡潔且可讀性好:命令式解決方案是 44 行,縮進很大,而我們的聲明性解決方案是 16 行,縮進很小。 行和製表符並不是萬能的,但是從這兩個文件中可以明顯看出,我們的聲明式解決方案更具可讀性。
  • 不易出錯:在此示例中,一切都是不可變的。 我們將所有名稱的List<String> List<String>為具有三個或更多元音的名​​稱的 List<String>,然後將每個String單詞轉換為具有大寫元音的String單詞。 總體而言,沒有突變、嵌套循環或中斷並放棄控制流使代碼更簡單,錯誤空間更小。
  • 良好的可維護性:由於其可讀性和健壯性,您可以輕鬆地重構聲明性代碼。 在我們之前的示例中(假設問題修改為選擇具有三個元音和五個輔音的名稱),一個簡單的解決方案是在filter條件中添加以下語句: val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5

作為補充,我們的聲明式解決方案是純函數式的:此示例中的每個函數都是純函數並且沒有副作用。 (稍後會詳細介紹純度。)

獎金聲明式解決方案

讓我們看一下用 Haskell 之類的純函數式語言對同一問題的聲明式實現,以演示它是如何讀取的。 如果您不熟悉 Haskell,請注意. Haskell 中的運算符讀作“之後”。 例如, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels翻譯為“在過濾具有三個元音的名稱後將元音映射為大寫”。

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

該解決方案的性能與我們的 Kotlin 聲明式解決方案類似,但還有一些額外的好處:如果您了解 Haskell 的語法,它是可讀的、簡單的、純函數式的和惰性的。

關鍵要點

聲明式編程對於 FP 和反應式編程(我們將在後面的部分中介紹)都很有用。

  • 它描述了您想要實現的“什麼”——而不是“如何”實現它,以及語句執行的確切順序。
  • 它抽象了程序的控制流,而是關注於轉換方面的問題(即,$A \rightarrow B \rightarrow C \rightarrow D$)。
  • 它鼓勵更簡單、更簡潔、更易讀、更容易重構和更改的代碼。 如果您的 Android 代碼讀起來不像一個句子,那麼您可能做錯了什麼。

如果您的 Android 代碼讀起來不像一個句子,那麼您可能做錯了什麼。

鳴叫

儘管如此,聲明式編程還是有一些缺點。 最終可能會出現效率低下的代碼,這些代碼消耗更多的 RAM,並且性能比命令式實現更差。 排序、反向傳播(在機器學習中)和其他“變異算法”並不適合不可變的聲明式編程風格。

FP 成分 #2:功能組合

函數組合是函數式編程核心的數學概念。 如果函數 $f$ 接受 $A$ 作為其輸入並產生 $B$ 作為其輸出 ($f: A \rightarrow B$),並且函數 $g$ 接受 $B$ 並產生 $C$ ($g: B \rightarrow C$),然後你可以創建第三個函數 $h$,它接受 $A$ 並產生 $C$ ($h: A \rightarrow C$)。 我們可以將這第三個函數定義為 $g$ 與 $f$ 的組合,也記為 $g \circ f$ 或 $g(f())$:

標有“A”的藍色框有一個箭頭,“f”指向標有“B”的藍色框,該框有一個箭頭“g”,指向標有“C”的藍色框。方框“A”還有一個平行箭頭“g o f”,直接指向方框“C”。
函數 f、g 和 h,g 與 f 的合成。

通過將問題分解為更小的問題,獨立解決它們,並通過函數組合將更小的解決方案重新組合成最終解決方案,每個命令式解決方案都可以轉化為聲明式解決方案。 讓我們看看上一節中的名稱問題,以了解這個概念的實際應用。 命令式解決方案中的小問題是:

  1. isVowel :: Char -> Bool :給定一個Char ,返回它是否是元音( Bool )。
  2. countVowels :: String -> Int :給定一個String ,返回其中元音的數量( Int )。
  3. hasThreeVowels :: String -> Bool :給定一個String ,返回它是否至少有三個元音( Bool )。
  4. uppercaseVowels :: String -> String :給定一個String ,返回一個帶有大寫元音的新String

我們通過函數組合實現的聲明式解決方案是map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels

上圖有三個藍色的“[String]”框,由指向右側的箭頭連接。第一個箭頭標記為“filter has3Vowels”,第二個箭頭標記為“map uppercaseVowels”。下面,第二個圖左側有兩個藍色框,頂部有“Char”,下方有“String”,指向右側的藍色框“Bool”。從“Char”到“Bool”的箭頭標記為“isVowel”,從“String”到“Bool”的箭頭標記為“has3Vowels”。 “字符串”框還有一個指向自身的箭頭,標記為“大寫元音”。
使用我們的名稱問題的函數組合示例。

這個例子比簡單的 $A \rightarrow B \rightarrow C$ 公式要復雜一些,但它展示了函數組合背後的原理。

關鍵要點

函數組合是一個簡單而強大的概念。

  • 它提供了一種解決複雜問題的策略,其中問題被分解成更小、更簡單的步驟並組合成一個解決方案。
  • 它提供了構建塊,允許您輕鬆添加、刪除或更改最終解決方案的部分內容,而不必擔心會破壞某些內容。
  • 如果 $f$ 的輸出與 $g$ 的輸入類型匹配,則可以編寫 $g(f())$。

組合函數時,您不僅可以傳遞數據,還可以將函數作為輸入傳遞給其他函數——高階函數的一個例子。

FP 成分 #3:純度

我們必須解決函數組合的另一個關鍵要素:您組合的函數必須是的,這是另一個源自數學的概念。 在數學中,所有函數都是在使用相同輸入調用時總是產生相同輸出的計算; 這是清淨的基礎。

讓我們看一個使用數學函數的偽代碼示例。 假設我們有一個函數makeEven ,它將整數輸入加倍以使其均勻,並且我們的代碼使用輸入x = 2執行makeEven(x) + x行。 在數學中,這個計算總是轉化為 $2x + x = 3x = 3(2) = 6$ 的計算並且是一個純函數。 然而,這在編程中並不總是正確的——如果函數makeEven(x)在代碼返回我們的結果之前通過將x加倍來改變 x,那麼我們的行將計算 $2x + (2x) = 4x = 4(2) = 8$更糟糕的是,每次調用makeEven都會改變結果。

讓我們探索幾種類型的函數,它們不是純函數,但可以幫助我們更具體地定義純函數:

  • 偏函數:這些函數不是為所有輸入值定義的,例如除法。 從編程的角度來看,這些是引發異常的函數: fun divide(a: Int, b: Int): Float將為輸入b = 0引發由零除引起的ArithmeticException
  • 總函數:這些函數是為所有輸入值定義的,但在使用相同輸入調用時會產生不同的輸出或副作用。 Android 世界充滿了各種功能: Log.dLocalDateTime.nowLocale.getDefault只是其中的幾個例子。

考慮到這些定義,我們可以將純函數定義為沒有副作用的總函數。 僅使用純函數構建的函數組合可以生成更可靠、可預測和可測試的代碼。

提示:要使總函數純粹,您可以通過將其作為高階函數參數傳遞來抽像其副作用。 這樣,您可以通過傳遞模擬的高階函數來輕鬆測試總函數。 此示例使用了我們稍後在教程中檢查的庫 Ivy FRP 中的@SideEffect註釋:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

關鍵要點

純度是函數式編程範式所需的最終要素。

  • 小心使用偏函數——它們可能會使您的應用程序崩潰。
  • 組合總函數不是確定性的; 它會產生不可預測的行為。
  • 盡可能編寫純函數。 您將從提高的代碼穩定性中受益。

完成對函數式編程的概述後,讓我們檢查面向未來的 Android 代碼的下一個組件:反應式編程。

反應式編程101

反應式編程是一種聲明式編程模式,其中程序對數據或事件更改做出反應,而不是請求有關更改的信息。

兩個主要的藍色框,“Observable”和“State”,它們之間有兩條主要路徑。第一個是通過“觀察(監聽變化)”。第二個是通過“通知(最新狀態)”到藍色框“UI(後端 API)”,通過“將用戶輸入轉換為”到藍色框“事件”,通過“觸發器”到藍色框“功能組合”,最後通過“生產(新狀態)”。然後,“狀態”還通過“作為輸入”連接回“功能組合”。
一般的反應式編程週期。

響應式編程週期中的基本元素是事件、聲明性管道、狀態和可觀察對象:

  • 事件是來自外部世界的信號,通常以用戶輸入或系統事件的形式觸發更新。 事件的目的是將信號轉換為管道輸入。
  • 聲明式管道是一個函數組合,它接受(Event, State)作為輸入並將此輸入轉換為新的State (輸出): (Event, State) -> f -> g -> … -> n -> State 。 管道必須異步執行以處理多個事件,而不會阻塞其他管道或等待它們完成。
  • 狀態是軟件應用程序在給定時間點的數據模型表示。 域邏輯使用狀態來計算所需的下一個狀態並進行相應的更新。
  • Observables監聽狀態變化並根據這些變化更新訂閱者。 在 Android 中,可觀察對象通常使用FlowLiveDataRxJava ,它們會通知 UI 狀態更新,以便它可以做出相應的反應。

反應式編程有很多定義和實現。 在這裡,我採取了一種務實的方法,專注於將這些概念應用到實際項目中。

連接點:函數響應式編程

函數式編程和反應式編程是兩個強大的範例。 這些概念超越了庫和 API 的短暫生命週期,並將在未來幾年提高您的編程技能。

此外,FP 和反應式編程的功能結合起來會成倍增加。 現在我們已經對函數式編程和響應式編程有了明確的定義,我們可以將它們組合在一起。 在本教程的第 2 部分中,我們定義了函數響應式編程 (FRP) 範式,並通過示例應用程序實現和相關的 Android 庫將其付諸實踐。

Toptal 工程博客對 Tarun Goyal 對本文中提供的代碼示例的審閱表示感謝。