Android コードの将来性を保証する、パート 1: 関数型およびリアクティブ型プログラミングの基礎

公開: 2022-08-31

きれいなコードを書くのは難しい場合があります。ライブラリ、フレームワーク、および API は一時的なものであり、すぐに古くなります。 しかし、数学的概念とパラダイムは永続的です。 それらは何年にもわたる学術研究を必要とし、私たちよりも長持ちする可能性さえあります。

これは、ライブラリ Y を使用して X を実行する方法を示すチュートリアルではありません。代わりに、関数型プログラミングとリアクティブ プログラミングの背後にある永続的な原則に焦点を当てているため、将来にわたって使用できる信頼性の高い Android アーキテクチャを構築し、妥協することなく変更にスケーリングして適応することができます。効率。

この記事では基礎を築き、パート 2 では、関数型プログラミングとリアクティブ型プログラミングの両方を組み合わせた関数型リアクティブ プログラミング (FRP) の実装について詳しく説明します。

この記事は Android 開発者を念頭に置いて書かれていますが、概念は一般的なプログラミング言語の経験を持つすべての開発者に関連し、有益です。

関数型プログラミング 101

関数型プログラミング (FP) は、目的の出力が得られるまでデータを $A$ から $B$、$C$ などに変換して、関数の組み合わせとしてプログラムを作成するパターンです。 オブジェクト指向プログラミング (OOP) では、命令ごとに何をすべきかをコンピューターに指示します。 関数型プログラミングは異なります。制御フローを放棄し、「関数のレシピ」を定義して代わりに結果を生成します。

左側の「Input: x」というテキストが表示された緑色の四角形には、「Function: f」というラベルの付いた薄い灰色の四角形を指す矢印があります。薄い灰色の四角形の内側には、右向きの矢印が付いた 3 つの円柱があります。1 つ目は「A(x)」とラベル付けされた薄い青色、2 つ目は「B(x)」とラベル付けされた濃い青色、3 つ目は「C」とラベル付けされた濃い灰色です。 (バツ)。"薄い灰色の四角形の右側には、「出力: f(x)」というテキストが表示された緑色の四角形があります。薄い灰色の四角形の下部には、「副作用」というテキストを指す下向きの矢印があります。
関数型プログラミング パターン

FP は数学、具体的には関数抽象化の論理システムであるラムダ計算に由来します。 ループ、クラス、ポリモーフィズム、継承などの OOP 概念の代わりに、FP は厳密に抽象化と高階関数 (他の関数を入力として受け入れる数学関数) を扱います。

簡単に言えば、FP には 2 つの主要な「プレーヤー」があります。データ (モデル、または問題に必要な情報) と関数 (データ間の動作と変換の表現) です。 対照的に、OOP クラスは、特定のドメイン固有のデータ構造 (および各クラス インスタンスに関連付けられた値または状態) を、それと共に使用することを意図した動作 (メソッド) に明示的に関連付けます。

FP の 3 つの重要な側面をさらに詳しく調べます。

  • 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)$時間で実行されると言えます。
  • 複雑で可読性が低い:次に検討する宣言型ソリューションと比較すると、このソリューションははるかに長く、理解するのが困難です。
  • エラーが発生しやすい:コードはresultvowelsCount 、およびtransformedCharを変更します。 これらの状態の変化は、 vowelsCountを 0 にリセットするのを忘れるなどの微妙なエラーにつながる可能性があります。実行の流れも複雑になる可能性があり、3 番目のループにbreakステートメントを追加するのを忘れがちです。
  • 保守性が低い:コードは複雑でエラーが発生しやすいため、このコードのリファクタリングや動作の変更は困難な場合があります。 たとえば、母音 3 つと子音 5 つの名前を選択するように問題を修正した場合、新しい変数を導入してループを変更する必要があり、多くのバグの可能性が残ります。

このサンプル ソリューションは、複雑な命令型コードがどのように見えるかを示していますが、コードをより小さな関数にリファクタリングすることでコードを改善できます。

宣言的ソリューション

宣言型プログラミングがそうでないものを理解したところで、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()をここで使用したため、命令型の実装の方が少し効率的です (3 つの母音を見つけた後でも)。 )。 シンプルなhasThreeVowels(String): Boolean関数を書くことで、この問題を簡単に修正できます。 このソリューションは命令型ソリューションと同じアルゴリズムを使用するため、ここでも同じ複雑さの分析が適用されます。このアルゴリズムは$O(n)$時間で実行されます。
  • 簡潔で読みやすい:命令型のソリューションは、小さなインデントを含む 16 行の長さの宣言型ソリューションと比較して、大きなインデントを含む 44 行です。 行とタブがすべてではありませんが、2 つのファイルを見れば、宣言型ソリューションの方がはるかに読みやすいことがわかります。
  • エラーが発生しにくい:このサンプルでは、​​すべてが不変です。 すべての名前のList<String>を 3 つ以上の母音を持つ名前のList<String> <String> に変換してから、各Stringの単語を大文字の母音を持つString列の単語に変換します。 全体として、ミューテーション、ネストされたループ、またはブレークがなく、制御フローを放棄することで、コードが単純になり、エラーの余地が少なくなります。
  • 優れた保守性:読みやすさと堅牢性により、宣言型コードを簡単にリファクタリングできます。 前の例 (3 つの母音と 5 つの子音を持つ名前を選択するように問題修正されたとしましょう) では、単純な解決策は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 の operator は「after」と読みます。 たとえば、 solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowelsは、「3 つの母音を持つ名前をフィルタリングした後、母音を大文字にマッピングする」と解釈します。

 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$) の場合、$A$ を受け入れて $C$ ($h: A \rightarrow C$) を生成する 3 番目の関数 $h$ を作成できます。 この 3 番目の関数は、$g$ と $f$ の合成として定義でき、$g \circ f$ または $g(f())$ とも表記されます。

「A」というラベルの付いた青いボックスには、「C」というラベルの付いた青いボックスを指している矢印「g」がある「B」というラベルの付いた青いボックスを指す矢印「f」があります。ボックス「A」にも、ボックス「C」を直接指す平行矢印「g o of」があります。
関数 f、g、および h、g と f の合成。

すべての命令型ソリューションは、問題を小さな問題に分解し、それらを個別に解決し、関数合成によって小さなソリューションを最終的なソリューションに再構成することにより、宣言型のソリューションに変換できます。 この概念の実際の動作を確認するために、前のセクションの名前の問題を見てみましょう。 命令的な解決策からの小さな問題は次のとおりです。

  1. isVowel :: Char -> Bool : Charを指定すると、それが母音かどうかを返します ( Bool )。
  2. countVowels :: String -> Int : Stringを指定すると、その中の母音の数を返します ( Int )。
  3. hasThreeVowels :: String -> Bool : Stringを指定すると、少なくとも 3 つの母音があるかどうかを返します ( Bool )。
  4. uppercaseVowels :: String -> String : String String返します。

関数合成によって達成される宣言的なソリューションは、 map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels

上のダイアグラムには、右向きの矢印で接続された 3 つの青い「[文字列]」ボックスがあります。最初の矢印には「filter has3Vowels」というラベルが付けられ、2 番目の矢印には「map uppercaseVowels」というラベルが付けられています。下の 2 番目の図には、左側に 2 つの青いボックスがあり、上に "Char"、下に "String" があり、右側の青いボックス "Bool" を指しています。 「Char」から「Bool」への矢印は「isVowel」とラベル付けされ、「String」から「Bool」への矢印は「has3Vowels」とラベル付けされます。 「String」ボックスには、「uppercaseVowels」というラベルの付いたそれ自体を指す矢印もあります。
私たちの名前の問題を使用した関数合成の例。

この例は、単純な $A \rightarrow B \rightarrow C$ 式よりも少し複雑ですが、関数合成の背後にある原則を示しています。

重要ポイント

関数合成は、単純ですが強力な概念です。

  • 複雑な問題を解決するための戦略を提供し、問題をより小さく単純なステップに分割し、1 つのソリューションに結合します。
  • 構成要素を提供するため、何かを壊すことを心配することなく、最終的なソリューションの一部を簡単に追加、削除、または変更できます。
  • $f$ の出力が $g$ の入力タイプと一致する場合、$g(f())$ を作成できます。

関数を構成する場合、データだけでなく関数を他の関数への入力として渡すことができます (高階関数の例)。

FP成分3:純度

関数の合成にはもう 1 つ重要な要素があります。それは、合成する関数は純粋でなければならないということです。これは、数学から派生したもう 1 つの概念です。 数学では、すべての関数は、同じ入力で呼び出されたときに常に同じ出力を生成する計算です。 これが純潔の基本です。

数学関数を使用した疑似コードの例を見てみましょう。 整数入力を倍にして偶数にする関数makeEvenがあり、コードが入力x = 2を使用してmakeEven(x) + x行を実行するとします。 数学では、この計算は常に $2x + x = 3x = 3(2) = 6$ の計算に変換され、純関数です。 ただし、これはプログラミングでは常に当てはまるとは限りません。コードが結果を返す前に、関数makeEven(x)xを 2 倍にして突然変異させた場合、この行は $2x + (2x) = 4x = 4(2) = 8$ を計算します。さらに悪いことに、結果は各makeEven呼び出しで変化します。

純粋ではありませんが、純粋をより具体的に定義するのに役立ついくつかのタイプの関数を調べてみましょう。

  • 部分関数:除算など、すべての入力値に対して定義されていない関数です。 プログラミングの観点からすると、これらは例外をb = 0するArithmeticException fun divide(a: Int, b: Int): Float
  • 合計関数:これらの関数は、すべての入力値に対して定義されますが、同じ入力で呼び出されたときに異なる出力または副作用を生成する可能性があります。 Android の世界は関数でいっぱいです。 Log.dLocalDateTime.nowLocale.getDefaultはほんの一例です。

これらの定義を念頭に置いて、純粋関数を副作用のない全関数として定義できます。 純粋な関数のみを使用して構築された関数構成は、より信頼性が高く、予測可能で、テスト可能なコードを生成します。

ヒント: total 関数を純粋にするには、その副作用を高階関数パラメーターとして渡すことで抽象化できます。 このように、モック化された高階関数を渡すことで、合計関数を簡単にテストできます。 この例では、チュートリアルの後半で調べるライブラリ Ivy FRP の@SideEffectアノテーションを使用します。

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

重要ポイント

純度は、関数型プログラミング パラダイムに必要な最終的な要素です。

  • 部分的な関数には注意してください。アプリがクラッシュする可能性があります。
  • 合計関数の構成は決定論的ではありません。 予期しない動作を引き起こす可能性があります。
  • 可能な限り、純粋な関数を記述します。 コードの安定性が向上するというメリットがあります。

関数型プログラミングの概要が完成したので、将来の保証のある Android コードの次のコンポーネントであるリアクティブ プログラミングを調べてみましょう。

リアクティブプログラミング101

リアクティブ プログラミングは、プログラムが変更に関する情報を要求する代わりに、データまたはイベントの変更に反応する宣言型プログラミング パターンです。

「Observable」と「State」という 2 つの主要な青いボックスには、それらの間に 2 つの主要なパスがあります。 1 つ目は、「監視 (変更をリッスン)」によるものです。 2 つ目は、「(最新の状態の) 通知」を介して青いボックス「UI (バックエンドの API)」に移動し、「ユーザー入力を変換する」を介して青いボックス「イベント」に移動し、「トリガー」を介して青に移動します。ボックス「機能構成」、そして最後に「プロデュース(新しい状態)」を介して。 「状態」は、「入力として機能する」を介して「関数合成」にも接続されます。
一般的なリアクティブ プログラミング サイクル。

リアクティブ プログラミング サイクルの基本要素は、イベント、宣言型パイプライン、状態、およびオブザーバブルです。

  • イベントは、通常はユーザー入力またはシステム イベントの形式で、更新をトリガーする外界からのシグナルです。 イベントの目的は、信号をパイプライン入力に変換することです。
  • 宣言型パイプラインは、 (Event, State)を入力として受け取り、この入力を新しいState (出力) に変換する関数合成です: (Event, State) -> f -> g -> … -> n -> State 。 パイプラインは、他のパイプラインをブロックしたり、それらの終了を待機したりせずに複数のイベントを処理するために、非同期で実行する必要があります。
  • 状態は、特定の時点でのソフトウェア アプリケーションのデータ モデルの表現です。 ドメイン ロジックは、状態を使用して目的の次の状態を計算し、対応する更新を行います。
  • オブザーバブルは状態の変化をリッスンし、それらの変化についてサブスクライバーを更新します。 Android では、Observable は通常、 FlowLiveData 、またはRxJavaを使用して実装され、状態の更新を UI に通知して、それに応じて反応できるようにします。

リアクティブ プログラミングには多くの定義と実装があります。 ここでは、これらの概念を実際のプロジェクトに適用することに焦点を当てた実用的なアプローチを採用しました。

点をつなぐ: 関数型リアクティブ プログラミング

関数型プログラミングとリアクティブ プログラミングは、2 つの強力なパラダイムです。 これらの概念は、ライブラリや API の寿命が短いだけでなく、今後何年にもわたってプログラミング スキルを向上させます。

さらに、FP とリアクティブ プログラミングの能力は、組み合わせると倍増します。 関数型プログラミングとリアクティブ型プログラミングの明確な定義が得られたので、それらをまとめることができます。 このチュートリアルのパート 2 では、関数型リアクティブ プログラミング (FRP) パラダイムを定義し、サンプル アプリの実装と関連する Android ライブラリを使用してそれを実践します。

Toptal Engineering Blog は、この記事で紹介したコード サンプルをレビューしてくれた Tarun Goyal に感謝の意を表します。