Buktikan Kode Android Anda di Masa Depan, Bagian 1: Landasan Pemrograman Fungsional dan Reaktif

Diterbitkan: 2022-08-31

Menulis kode bersih dapat menjadi tantangan: Perpustakaan, kerangka kerja, dan API bersifat sementara dan cepat usang. Tetapi konsep dan paradigma matematika bertahan lama; mereka membutuhkan penelitian akademis bertahun-tahun dan bahkan mungkin bertahan lebih lama dari kita.

Ini bukan tutorial untuk menunjukkan kepada Anda cara melakukan X dengan Library Y. Sebagai gantinya, kami fokus pada prinsip-prinsip abadi di balik pemrograman fungsional dan reaktif sehingga Anda dapat membangun arsitektur Android yang andal dan tahan masa depan, serta menskalakan dan beradaptasi dengan perubahan tanpa kompromi efisiensi.

Artikel ini meletakkan dasar-dasarnya, dan di Bagian 2, kita akan menyelami implementasi pemrograman reaktif fungsional (FRP), yang menggabungkan pemrograman fungsional dan reaktif.

Artikel ini ditulis dengan mempertimbangkan pengembang Android, tetapi konsepnya relevan dan bermanfaat bagi pengembang mana pun yang berpengalaman dalam bahasa pemrograman umum.

Pemrograman Fungsional 101

Pemrograman fungsional (FP) adalah pola di mana Anda membangun program Anda sebagai komposisi fungsi, mengubah data dari $A$ ke $B$, ke $C$, dll., hingga output yang diinginkan tercapai. Dalam pemrograman berorientasi objek (OOP), Anda memberi tahu komputer apa yang harus dilakukan instruksi demi instruksi. Pemrograman fungsional berbeda: Anda menyerahkan aliran kontrol dan mendefinisikan "resep fungsi" untuk menghasilkan hasil Anda sebagai gantinya.

Sebuah persegi panjang hijau di sebelah kiri dengan teks "Input: x" memiliki panah yang menunjuk ke persegi panjang abu-abu muda berlabel "Fungsi: f." Di dalam persegi panjang abu-abu muda, ada tiga silinder dengan panah menunjuk ke kanan: Yang pertama berwarna biru muda berlabel "A(x)," yang kedua berwarna biru tua berlabel "B(x)," dan yang ketiga berwarna abu-abu tua berlabel "C (x)." Di sebelah kanan persegi panjang abu-abu muda, ada persegi panjang hijau dengan teks "Output: f(x)." Bagian bawah persegi panjang abu-abu muda memiliki panah menunjuk ke bawah ke teks "Efek samping."
Pola pemrograman fungsional

FP berasal dari matematika, khususnya kalkulus lambda, sebuah sistem logika abstraksi fungsi. Alih-alih konsep OOP seperti loop, kelas, polimorfisme, atau pewarisan, FP berurusan secara ketat dalam abstraksi dan fungsi tingkat tinggi, fungsi matematika yang menerima fungsi lain sebagai input.

Singkatnya, FP memiliki dua "pemain" utama: data (model, atau informasi yang diperlukan untuk masalah Anda) dan fungsi (representasi dari perilaku dan transformasi di antara data). Sebaliknya, kelas OOP secara eksplisit mengikat struktur data spesifik domain tertentu—dan nilai atau status yang terkait dengan setiap instance kelas—dengan perilaku (metode) yang dimaksudkan untuk digunakan dengannya.

Kami akan memeriksa tiga aspek kunci FP lebih dekat:

  • FP bersifat deklaratif.
  • FP menggunakan komposisi fungsi.
  • Fungsi FP murni.

Tempat awal yang baik untuk terjun ke dunia FP lebih jauh adalah Haskell, bahasa yang sangat fungsional dan diketik dengan kuat. Saya merekomendasikan Learn You a Haskell for Great Good! tutorial interaktif sebagai sumber yang bermanfaat.

Bahan FP #1: Pemrograman Deklaratif

Hal pertama yang akan Anda perhatikan tentang program FP adalah bahwa program itu ditulis dalam gaya deklaratif, bukan imperatif. Singkatnya, pemrograman deklaratif memberi tahu program apa yang perlu dilakukan alih-alih bagaimana melakukannya. Mari kita dasarkan definisi abstrak ini dengan contoh konkret dari pemrograman imperatif versus deklaratif untuk memecahkan masalah berikut: Diberikan daftar nama, kembalikan daftar yang hanya berisi nama dengan setidaknya tiga vokal dan dengan vokal yang ditampilkan dalam huruf besar.

Solusi Penting

Pertama, mari kita periksa solusi imperatif masalah ini di 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] }

Kami sekarang akan menganalisis solusi penting kami dengan mempertimbangkan beberapa faktor pengembangan utama:

  • Paling efisien: Solusi ini memiliki penggunaan memori yang optimal dan berkinerja baik dalam analisis Big O (berdasarkan jumlah perbandingan minimum). Dalam algoritme ini, masuk akal untuk menganalisis jumlah perbandingan antar karakter karena itulah operasi utama dalam algoritme kami. Biarkan $n$ menjadi jumlah nama, dan biarkan $k$ menjadi rata-rata panjang nama.

    • Jumlah perbandingan kasus terburuk: $n(10k)(10k) = 100nk^2$
    • Penjelasan: $n$ (loop 1) * $10k$ (untuk setiap karakter, kita bandingkan dengan 10 kemungkinan vokal) * $10k$ (kita jalankan pemeriksaan isVowel() lagi untuk memutuskan apakah huruf besar akan digunakan—sekali lagi, dalam kasus terburuk, ini dibandingkan dengan 10 vokal).
    • Hasil: Karena panjang nama rata-rata tidak akan lebih dari 100 karakter, kita dapat mengatakan bahwa algoritma kita berjalan dalam waktu $O(n)$ .
  • Kompleks dengan keterbacaan yang buruk: Dibandingkan dengan solusi deklaratif yang akan kita pertimbangkan selanjutnya, solusi ini jauh lebih lama dan lebih sulit untuk diikuti.
  • Rawan kesalahan: Kode memutasi result , vowelsCount , dan transformedChar ; mutasi status ini dapat menyebabkan kesalahan halus seperti lupa menyetel ulang vowelsCount mundur ke 0. Alur eksekusi juga dapat menjadi rumit, dan mudah untuk lupa menambahkan pernyataan break di loop ketiga.
  • Pemeliharaan yang buruk: Karena kode kita rumit dan rawan kesalahan, memfaktorkan ulang atau mengubah perilaku kode ini mungkin sulit. Misalnya, jika masalahnya dimodifikasi untuk memilih nama dengan tiga vokal dan lima konsonan, kita harus memasukkan variabel baru dan mengubah loop, meninggalkan banyak peluang untuk bug.

Solusi contoh kami menggambarkan bagaimana kode imperatif yang kompleks mungkin terlihat, meskipun Anda dapat meningkatkan kode dengan memfaktorkannya kembali menjadi fungsi yang lebih kecil.

Solusi Deklaratif

Sekarang setelah kita memahami apa yang bukan pemrograman deklaratif , mari mengungkap solusi deklaratif kita di 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] }

Menggunakan kriteria yang sama yang kami gunakan untuk mengevaluasi solusi imperatif kami, mari kita lihat bagaimana kode deklaratif bertahan:

  • Efisien: Implementasi imperatif dan deklaratif keduanya berjalan dalam waktu linier, tetapi imperatif sedikit lebih efisien karena saya telah menggunakan name.count() di sini, yang akan terus menghitung vokal hingga akhir nama (bahkan setelah menemukan tiga vokal ). Kita dapat dengan mudah memperbaiki masalah ini dengan menulis fungsi hasThreeVowels(String): Boolean sederhana. Solusi ini menggunakan algoritma yang sama dengan solusi imperatif, sehingga analisis kompleksitas yang sama berlaku di sini: Algoritma kami berjalan dalam waktu $O(n)$ .
  • Ringkas dengan keterbacaan yang baik: Solusi imperatif adalah 44 baris dengan lekukan besar dibandingkan dengan solusi deklaratif kami yang panjangnya 16 baris dengan lekukan kecil. Garis dan tab bukanlah segalanya, tetapi terlihat dari sekilas pada dua file bahwa solusi deklaratif kami jauh lebih mudah dibaca.
  • Kurang rawan kesalahan: Dalam contoh ini, semuanya tidak dapat diubah. Kami mengubah List<String> semua nama menjadi List<String> nama dengan tiga vokal atau lebih dan kemudian mengubah setiap kata String menjadi kata String dengan vokal huruf besar. Secara keseluruhan, tidak memiliki mutasi, loop bersarang, atau putus dan melepaskan aliran kontrol membuat kode lebih sederhana dengan lebih sedikit ruang untuk kesalahan.
  • Pemeliharaan yang baik: Anda dapat dengan mudah memfaktorkan ulang kode deklaratif karena keterbacaan dan ketahanannya. Dalam contoh kita sebelumnya (misalkan masalahnya telah dimodifikasi untuk memilih nama dengan tiga vokal dan lima konsonan), solusi sederhananya adalah dengan menambahkan pernyataan berikut dalam kondisi filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Sebagai positif tambahan, solusi deklaratif kami murni fungsional: Setiap fungsi dalam contoh ini murni dan tidak memiliki efek samping. (Lebih lanjut tentang kemurnian nanti.)

Solusi Deklaratif Bonus

Mari kita lihat implementasi deklaratif dari masalah yang sama dalam bahasa fungsional murni seperti Haskell untuk menunjukkan cara membacanya. Jika Anda tidak terbiasa dengan Haskell, perhatikan bahwa file . operator di Haskell dibaca sebagai "setelah." Misalnya, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels diterjemahkan menjadi "petakan vokal ke huruf besar setelah memfilter nama yang memiliki tiga vokal."

 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"]

Solusi ini bekerja sama dengan solusi deklaratif Kotlin kami, dengan beberapa manfaat tambahan: Dapat dibaca, sederhana jika Anda memahami sintaks Haskell, murni fungsional, dan malas.

Takeaways Kunci

Pemrograman deklaratif berguna untuk FP dan Pemrograman Reaktif (yang akan kita bahas di bagian selanjutnya).

  • Ini menjelaskan "apa" yang ingin Anda capai—bukan "bagaimana" mencapainya, dengan urutan eksekusi pernyataan yang tepat.
  • Ini mengabstraksi aliran kontrol program dan sebaliknya berfokus pada masalah dalam hal transformasi (yaitu, $A \rightarrow B \rightarrow C \rightarrow D$).
  • Ini mendorong kode yang kurang kompleks, lebih ringkas, dan lebih mudah dibaca yang lebih mudah untuk difaktorkan dan diubah. Jika kode Android Anda tidak terbaca seperti kalimat, Anda mungkin melakukan sesuatu yang salah.

Jika kode Android Anda tidak terbaca seperti kalimat, Anda mungkin melakukan sesuatu yang salah.

Menciak

Namun, pemrograman deklaratif memiliki kelemahan tertentu. Dimungkinkan untuk berakhir dengan kode yang tidak efisien yang menghabiskan lebih banyak RAM dan berkinerja lebih buruk daripada implementasi imperatif. Penyortiran, backpropagation (dalam pembelajaran mesin), dan "algoritma bermutasi" lainnya tidak cocok untuk gaya pemrograman deklaratif yang tidak dapat diubah.

Bahan FP #2: Komposisi Fungsi

Komposisi fungsi adalah konsep matematika di jantung pemrograman fungsional. Jika fungsi $f$ menerima $A$ sebagai inputnya dan menghasilkan $B$ sebagai outputnya ($f: A \rightarrow B$), dan fungsi $g$ menerima $B$ dan menghasilkan $C$ ($g: B \rightarrow C$), maka Anda dapat membuat fungsi ketiga, $h$, yang menerima $A$ dan menghasilkan $C$ ($h: A \rightarrow C$). Kita dapat mendefinisikan fungsi ketiga ini sebagai komposisi $g$ dengan $f$, juga dinotasikan sebagai $g \circ f$ atau $g(f())$:

Kotak biru berlabel "A" memiliki panah, "f," menunjuk ke kotak biru berlabel "B" yang memiliki panah, "g," menunjuk ke kotak biru berlabel "C." Kotak "A" juga memiliki panah paralel, "g o f," menunjuk langsung ke kotak "C."
Fungsi f, g, dan h, komposisi g dengan f.

Setiap solusi imperatif dapat diterjemahkan menjadi solusi deklaratif dengan menguraikan masalah menjadi masalah yang lebih kecil, menyelesaikannya secara mandiri, dan menyusun ulang solusi yang lebih kecil menjadi solusi akhir melalui komposisi fungsi. Mari kita lihat masalah nama kita dari bagian sebelumnya untuk melihat konsep ini beraksi. Masalah kami yang lebih kecil dari solusi imperatif adalah:

  1. isVowel :: Char -> Bool : Diberikan Char , kembalikan apakah itu vokal atau tidak ( Bool ).
  2. countVowels :: String -> Int : Diberikan String , kembalikan jumlah vokal di dalamnya ( Int ).
  3. hasThreeVowels :: String -> Bool : Diberikan String , kembalikan apakah ia memiliki setidaknya tiga vokal ( Bool ).
  4. uppercaseVowels :: String -> String : Diberikan String , kembalikan String baru dengan vokal huruf besar.

Solusi deklaratif kami, yang dicapai melalui komposisi fungsi, adalah map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

Diagram atas memiliki tiga kotak biru "[String]" yang dihubungkan oleh panah yang menunjuk ke kanan. Panah pertama diberi label "filter has3Vowels" dan panah kedua diberi label "peta huruf besarVowels." Di bawah, diagram kedua memiliki dua kotak biru di sebelah kiri, "Char" di atas, dan "String" di bawah, menunjuk ke kotak biru di sebelah kanan, "Bool." Panah dari "Char" ke "Bool" diberi label "isVowel," dan panah dari "String" ke "Bool" diberi label "has3Vowels." Kotak "String" juga memiliki panah yang menunjuk ke dirinya sendiri yang berlabel "huruf besar".
Contoh komposisi fungsi menggunakan masalah nama kami.

Contoh ini sedikit lebih rumit daripada rumus $A \rightarrow B \rightarrow C$ sederhana, tetapi ini menunjukkan prinsip di balik komposisi fungsi.

Takeaways Kunci

Komposisi fungsi adalah konsep yang sederhana namun kuat.

  • Ini memberikan strategi untuk memecahkan masalah kompleks di mana masalah dipecah menjadi langkah-langkah yang lebih kecil dan lebih sederhana dan digabungkan menjadi satu solusi.
  • Ini menyediakan blok bangunan, memungkinkan Anda untuk dengan mudah menambahkan, menghapus, atau mengubah bagian dari solusi akhir tanpa khawatir akan merusak sesuatu.
  • Anda dapat menulis $g(f())$ jika output dari $f$ cocok dengan tipe input $g$.

Saat membuat fungsi, Anda tidak hanya dapat meneruskan data tetapi juga berfungsi sebagai input ke fungsi lain—contoh fungsi tingkat tinggi.

Bahan FP #3: Kemurnian

Ada satu elemen kunci lagi untuk komposisi fungsi yang harus kita bahas: Fungsi yang Anda buat harus murni , konsep lain yang diturunkan dari matematika. Dalam matematika, semua fungsi adalah komputasi yang selalu menghasilkan keluaran yang sama ketika dipanggil dengan masukan yang sama; ini adalah dasar dari kemurnian.

Mari kita lihat contoh pseudocode menggunakan fungsi matematika. Asumsikan kita memiliki fungsi, makeEven , yang menggandakan input integer untuk membuatnya genap, dan kode kita mengeksekusi baris makeEven(x) + x menggunakan input x = 2 . Dalam matematika, perhitungan ini akan selalu diterjemahkan menjadi perhitungan $2x + x = 3x = 3(2) = 6$ dan merupakan fungsi murni. Namun, ini tidak selalu benar dalam pemrograman—jika fungsi makeEven(x) bermutasi x dengan menggandakannya sebelum kode mengembalikan hasil kita, maka baris kita akan menghitung $2x + (2x) = 4x = 4(2) = 8$ dan, lebih buruk lagi, hasilnya akan berubah dengan setiap panggilan makeEven .

Mari kita jelajahi beberapa jenis fungsi yang tidak murni tetapi akan membantu kita mendefinisikan kemurnian secara lebih spesifik:

  • Fungsi parsial: Ini adalah fungsi yang tidak didefinisikan untuk semua nilai input, seperti pembagian. Dari sudut pandang pemrograman, ini adalah fungsi yang melempar pengecualian: fun divide(a: Int, b: Int): Float akan mengeluarkan ArithmeticException untuk input b = 0 yang disebabkan oleh pembagian dengan nol.
  • Fungsi total: Fungsi-fungsi ini didefinisikan untuk semua nilai input tetapi dapat menghasilkan output atau efek samping yang berbeda ketika dipanggil dengan input yang sama. Dunia Android penuh dengan fungsi total: Log.d , LocalDateTime.now , dan Locale.getDefault hanyalah beberapa contoh.

Dengan mengingat definisi ini, kita dapat mendefinisikan fungsi murni sebagai fungsi total tanpa efek samping. Komposisi fungsi yang dibuat hanya menggunakan fungsi murni menghasilkan kode yang lebih andal, dapat diprediksi, dan dapat diuji.

Tip: Untuk membuat fungsi total murni, Anda dapat mengabstraksi efek sampingnya dengan meneruskannya sebagai parameter fungsi tingkat tinggi. Dengan cara ini, Anda dapat dengan mudah menguji fungsi total dengan melewatkan fungsi tingkat tinggi yang diejek. Contoh ini menggunakan anotasi @SideEffect dari perpustakaan yang akan kita periksa nanti di tutorial, Ivy FRP:

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

Takeaways Kunci

Kemurnian adalah bahan terakhir yang diperlukan untuk paradigma pemrograman fungsional.

  • Hati-hati dengan fungsi parsial—fungsi tersebut dapat membuat aplikasi Anda mogok.
  • Menyusun fungsi total tidak deterministik; dapat menghasilkan perilaku yang tidak terduga.
  • Bila memungkinkan, tulis fungsi murni. Anda akan mendapat manfaat dari peningkatan stabilitas kode.

Dengan gambaran umum tentang pemrograman fungsional kami selesai, mari kita periksa komponen berikutnya dari kode Android masa depan: pemrograman reaktif.

Pemrograman Reaktif 101

Pemrograman reaktif adalah pola pemrograman deklaratif di mana program bereaksi terhadap perubahan data atau peristiwa alih-alih meminta informasi tentang perubahan.

Dua kotak biru utama, "Observable" dan "State", memiliki dua jalur utama di antara keduanya. Yang pertama adalah melalui "Mengamati (mendengarkan perubahan)." Yang kedua adalah melalui "Memberitahu (keadaan terbaru)," ke kotak biru "UI (API di bagian belakang)," yang masuk melalui "Mengubah input pengguna ke" ke kotak biru "Acara," yang masuk melalui "Pemicu" menjadi biru kotak "Komposisi fungsi", dan terakhir melalui "Menghasilkan (keadaan baru)." "Negara" kemudian juga menghubungkan kembali ke "Komposisi fungsi" melalui "Bertindak sebagai input untuk."
Siklus pemrograman reaktif umum.

Elemen dasar dalam siklus pemrograman reaktif adalah peristiwa, pipa deklaratif, status, dan yang dapat diamati:

  • Peristiwa adalah sinyal dari dunia luar, biasanya dalam bentuk masukan pengguna atau peristiwa sistem, yang memicu pembaruan. Tujuan dari suatu peristiwa adalah untuk mengubah sinyal menjadi input pipa.
  • Pipa deklaratif adalah komposisi fungsi yang menerima (Event, State) sebagai input dan mengubah input ini menjadi State baru (output): (Event, State) -> f -> g -> … -> n -> State . Pipeline harus bekerja secara asinkron untuk menangani banyak peristiwa tanpa memblokir pipeline lain atau menunggu hingga selesai.
  • Negara adalah representasi model data dari aplikasi perangkat lunak pada titik waktu tertentu. Logika domain menggunakan status untuk menghitung status berikutnya yang diinginkan dan membuat pembaruan yang sesuai.
  • Observables mendengarkan perubahan status dan memperbarui pelanggan pada perubahan tersebut. Di Android, yang dapat diamati biasanya diimplementasikan menggunakan Flow , LiveData , atau RxJava , dan mereka memberi tahu UI tentang pembaruan status sehingga dapat bereaksi sesuai dengan itu.

Ada banyak definisi dan implementasi dari pemrograman reaktif. Di sini, saya telah mengambil pendekatan pragmatis yang berfokus pada penerapan konsep-konsep ini pada proyek nyata.

Menghubungkan Titik: Pemrograman Reaktif Fungsional

Pemrograman fungsional dan reaktif adalah dua paradigma yang kuat. Konsep-konsep ini melampaui umur pustaka dan API yang berumur pendek, dan akan meningkatkan keterampilan pemrograman Anda selama bertahun-tahun yang akan datang.

Selain itu, kekuatan FP dan pemrograman reaktif berlipat ganda saat digabungkan. Sekarang setelah kita memiliki definisi yang jelas tentang pemrograman fungsional dan reaktif, kita dapat menyatukan bagian-bagiannya. Di bagian 2 tutorial ini, kami mendefinisikan paradigma pemrograman reaktif fungsional (FRP), dan mempraktikkannya dengan implementasi aplikasi sampel dan pustaka Android yang relevan.

Blog Toptal Engineering mengucapkan terima kasih kepada Tarun Goyal karena telah meninjau contoh kode yang disajikan dalam artikel ini.