Android Kodunuzu Geleceğe Hazırlayın, Bölüm 1: İşlevsel ve Reaktif Programlama Temelleri
Yayınlanan: 2022-08-31Temiz kod yazmak zor olabilir: Kitaplıklar, çerçeveler ve API'ler geçicidir ve hızla geçersiz hale gelir. Ancak matematiksel kavramlar ve paradigmalar kalıcıdır; yıllarca akademik araştırma gerektiriyorlar ve hatta bizden daha uzun ömürlü olabilirler.
Bu, Kitaplık Y ile X'i nasıl yapacağınızı gösteren bir öğretici değildir. Bunun yerine, geleceğe dönük ve güvenilir Android mimarisi oluşturabilmeniz ve ödün vermeden değişikliklere ölçeklendirip uyum sağlayabilmeniz için işlevsel ve reaktif programlamanın ardındaki kalıcı ilkelere odaklanıyoruz. yeterlik.
Bu makale temelleri atıyor ve Bölüm 2'de hem işlevsel hem de reaktif programlamayı birleştiren bir işlevsel reaktif programlama (FRP) uygulamasına dalacağız.
Bu makale Android geliştiricileri düşünülerek yazılmıştır, ancak kavramlar genel programlama dillerinde deneyime sahip herhangi bir geliştirici için alakalı ve faydalıdır.
Fonksiyonel Programlama 101
İşlevsel programlama (FP), programınızı, istenen çıktı elde edilene kadar verileri $A$'dan $B$'a, $C$'a vb. dönüştürerek işlevlerin bir bileşimi olarak oluşturduğunuz bir kalıptır. Nesne yönelimli programlamada (OOP), bilgisayara talimatla talimatla ne yapacağını söylersiniz. Fonksiyonel programlama farklıdır: Kontrol akışından vazgeçersiniz ve bunun yerine sonucunuzu üretmek için bir “fonksiyon tarifi” tanımlarsınız.
FP, matematikten, özellikle de bir fonksiyon soyutlama mantık sistemi olan lambda hesabından kaynaklanır. Döngüler, sınıflar, polimorfizm veya kalıtım gibi OOP kavramları yerine, FP kesinlikle soyutlama ve daha yüksek dereceli fonksiyonlarla, diğer fonksiyonları girdi olarak kabul eden matematiksel fonksiyonlarla ilgilenir.
Özetle, FP'nin iki ana "oyuncusu" vardır: veriler (sorununuz için gerekli model veya bilgi) ve işlevler (veriler arasındaki davranış ve dönüşümlerin temsilleri). Buna karşılık, OOP sınıfları, belirli bir alana özgü veri yapısını ve her sınıf örneğiyle ilişkili değerleri veya durumu, onunla kullanılması amaçlanan davranışlara (yöntemlere) açıkça bağlar.
FP'nin üç temel yönünü daha yakından inceleyeceğiz:
- FP bildirimseldir.
- FP, işlev bileşimini kullanır.
- FP fonksiyonları saftır.
FP dünyasına daha fazla dalmak için iyi bir başlangıç noktası, güçlü bir şekilde yazılmış, tamamen işlevsel bir dil olan Haskell'dir. Büyük İyilik için Size Bir Haskell Öğrenin'i tavsiye ederim! Yararlı bir kaynak olarak etkileşimli öğretici.
FP Bileşen 1: Bildirime Dayalı Programlama
Bir FP programı hakkında farkedeceğiniz ilk şey, onun zorunlu değil, bildirimsel bir tarzda yazılmış olmasıdır. Kısacası, bildirimsel programlama, bir programa nasıl yapılacağı yerine ne yapılması gerektiğini söyler. Bu soyut tanımı, aşağıdaki sorunu çözmek için zorunlu ve bildirimsel programlamanın somut bir örneğiyle temellendirelim: Bir ad listesi verildiğinde, yalnızca en az üç sesli harf içeren ve sesli harfleri büyük harflerle gösterilen adları içeren bir liste döndürün.
Zorunlu Çözüm
İlk olarak, bu sorunun Kotlin'deki zorunlu çözümünü inceleyelim:
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] }
Şimdi, birkaç önemli geliştirme faktörünü göz önünde bulundurarak zorunlu çözümümüzü analiz edeceğiz:
En verimli: Bu çözüm optimum bellek kullanımına sahiptir ve Big O analizinde iyi performans gösterir (minimum karşılaştırma sayısına göre). Bu algoritmada, karakterler arasındaki karşılaştırma sayısını analiz etmek mantıklıdır çünkü algoritmamızda baskın olan işlem budur. $n$ isimlerin sayısı ve $k$ isimlerin ortalama uzunluğu olsun.
- En kötü durum karşılaştırma sayısı: $n(10k)(10k) = 100nk^2$
- Açıklama: $n$ (döngü 1) * $10k$ (her karakter için, 10 olası sesli harfle karşılaştırırız) * $10k$ (karakterin büyük harf olup olmayacağına karar vermek için
isVowel()
kontrolünü tekrar uygularız - yine, en kötü durumda, bu 10 sesli harfle karşılaştırılır). - Sonuç: Ortalama isim uzunluğu 100 karakterden fazla olmayacağı için algoritmamızın $O(n)$ zamanında çalıştığını söyleyebiliriz.
- Okunabilirliği zayıf olan karmaşık: Daha sonra ele alacağımız bildirimsel çözümle karşılaştırıldığında, bu çözüm çok daha uzun ve takip etmesi daha zor.
- Hataya açık: Kod
result
,vowelsCount
vetransformedChar
Karakteri değiştirir; bu durum mutasyonlarıvowelsCount
tekrar 0'a sıfırlamayı unutmak gibi ince hatalara yol açabilir. Yürütme akışı da karmaşık hale gelebilir ve üçüncü döngüdebreak
ifadesini eklemeyi unutmak kolaydır. - Kötü sürdürülebilirlik: Kodumuz karmaşık ve hataya açık olduğundan, bu kodun davranışını yeniden düzenlemek veya değiştirmek zor olabilir. Örneğin, problem üç sesli ve beş sessiz harfli isimleri seçmek için değiştirilseydi, yeni değişkenler eklememiz ve döngüleri değiştirmemiz gerekecekti, bu da hatalar için birçok fırsat bırakacaktı.
Örnek çözümümüz, kodu daha küçük işlevlere dönüştürerek iyileştirebilmenize rağmen, zorunlu kodun ne kadar karmaşık görünebileceğini gösterir.
Bildirim Çözümü
Artık bildirimsel programlamanın ne olmadığını anladığımıza göre, Kotlin'deki bildirimsel çözümümüzü açıklayalım:
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] }
Zorunlu çözümümüzü değerlendirmek için kullandığımız kriterleri kullanarak, bildirim kodunun nasıl işlediğini görelim:
- Verimli: Emir ve bildirim uygulamalarının her ikisi de doğrusal zamanda çalışır, ancak zorunlu olan biraz daha verimli çünkü burada
name.count()
kullandım, bu ismin sonuna kadar sesli harfleri saymaya devam edecek (üç sesli harf bulduktan sonra bile) ). Basit birhasThreeVowels(String): Boolean
function yazarak bu sorunu kolayca çözebiliriz. Bu çözüm, zorunlu çözümle aynı algoritmayı kullanır, dolayısıyla aynı karmaşıklık analizi burada da geçerlidir: Algoritmamız $O(n)$ zamanında çalışır. - İyi okunabilirlik ile özlü: Zorunlu çözüm, bildirime dayalı çözümümüzün küçük girintili 16 satırlık uzunluğuna kıyasla büyük girintili 44 satırdır. Çizgiler ve sekmeler her şey değildir, ancak iki dosyaya bir bakışta bildirimsel çözümümüzün çok daha okunabilir olduğu açıktır.
- Daha az hataya açık: Bu örnekte her şey değişmez. Tüm isimlerin bir <Dize
List<String>
List<String>
, üç veya daha fazla sesli harf içeren bir <Dize> Listesine dönüştürürüz ve ardından herString
kelimesini büyük sesli harfli birString
kelimesine dönüştürürüz. Genel olarak, mutasyon, iç içe döngüler veya kesintiler olmaması ve kontrol akışından vazgeçmesi, hata için daha az yer ile kodu daha basit hale getirir. - İyi sürdürülebilirlik: Okunabilirliği ve sağlamlığı nedeniyle bildirim kodunu kolayca yeniden düzenleyebilirsiniz. Önceki örneğimizde (diyelim ki problem , üç sesli ve beş sessiz harfli isimleri seçmek için değiştirildi), basit bir çözüm,
filter
koşuluna aşağıdaki ifadeleri eklemek olabilir:val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
.
Ek bir olumlu olarak, bildirimsel çözümümüz tamamen işlevseldir: Bu örnekteki her işlev saftır ve hiçbir yan etkisi yoktur. (Saflık hakkında daha sonra.)
Bonus Bildirim Çözümü
Nasıl okunduğunu göstermek için aynı sorunun Haskell gibi tamamen işlevsel bir dilde bildirimsel uygulamasına bir göz atalım. Haskell'e aşina değilseniz, .
Haskell'deki operatör “sonra” olarak okur. Örneğin, solution = map uppercaseVowels . filter hasThreeVowels
solution = map uppercaseVowels . filter hasThreeVowels
, "üç sesli harf içeren adları filtreledikten sonra ünlüleri büyük harfe eşle" anlamına gelir.
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"]
Bu çözüm, bazı ek avantajlarla birlikte Kotlin bildirim çözümümüze benzer şekilde çalışır: Haskell'in sözdizimini anlarsanız okunabilir, basit, tamamen işlevsel ve tembeldir.
Önemli Çıkarımlar
Bildirimsel programlama, hem FP hem de Reaktif Programlama için faydalıdır (daha sonraki bir bölümde ele alacağız).
- İfadelerin tam olarak yürütülme sırası ile "nasıl" başaracağınızdan ziyade "ne" elde etmek istediğinizi açıklar.
- Bir programın kontrol akışını soyutlar ve bunun yerine probleme dönüşümler açısından odaklanır (yani, $A \rightarrow B \rightarrow C \rightarrow D$).
- Yeniden düzenlemesi ve değiştirmesi daha kolay olan daha az karmaşık, daha özlü ve daha okunabilir kodu teşvik eder. Android kodunuz bir cümle gibi okunmuyorsa, muhtemelen yanlış bir şey yapıyorsunuzdur.
Android kodunuz bir cümle gibi okunmuyorsa, muhtemelen yanlış bir şey yapıyorsunuzdur.
Cıvıldamak
Yine de, bildirimsel programlamanın bazı dezavantajları vardır. Daha fazla RAM tüketen ve zorunlu bir uygulamadan daha kötü performans gösteren verimsiz kodla sonuçlanmak mümkündür. Sıralama, geri yayılım (makine öğreniminde) ve diğer "mutasyona uğratan algoritmalar", değişmez, bildirimsel programlama stili için uygun değildir.
FP Bileşen #2: İşlev Bileşimi
Fonksiyon kompozisyonu, fonksiyonel programlamanın kalbindeki matematiksel kavramdır. $f$ işlevi girdi olarak $A$'ı kabul eder ve çıktı olarak $B$ üretirse ($f: A \rightarrow B$) ve $g$ işlevi $B$'ı kabul eder ve $C$ ($g: B) üretirse \rightarrow C$), sonra $A$'ı kabul eden ve $C$ ($h: A \rightarrow C$) üreten $h$ adlı üçüncü bir işlev oluşturabilirsiniz. Bu üçüncü işlevi $g$'ın $f$ ile bileşimi olarak tanımlayabiliriz, ayrıca $g \circ f$ veya $g(f())$ olarak da gösterilir:
Her zorunlu çözüm, problemi daha küçük problemlere ayırarak, bunları bağımsız olarak çözerek ve daha küçük çözümleri fonksiyon kompozisyonu aracılığıyla nihai çözüme yeniden birleştirerek bildirimsel bir çözüme dönüştürülebilir. Bu kavramı eylem halinde görmek için önceki bölümden isim problemimize bakalım. Zorunlu çözümden daha küçük sorunlarımız:
-
isVowel :: Char -> Bool
: BirChar
verildiğinde, sesli harf olup olmadığını döndürür (Bool
). -
countVowels :: String -> Int
: BirString
verildiğinde, içindeki sesli harf sayısını döndürür (Int
). -
hasThreeVowels :: String -> Bool
: BirString
verildiğinde, en az üç sesli harf olup olmadığını döndürür (Bool
). -
uppercaseVowels :: String -> String
: BirString
verildiğinde, büyük harfli yeni birString
döndür.
İşlev bileşimi aracılığıyla elde edilen bildirimsel çözümümüz, map uppercaseVowels . filter hasThreeVowels
map uppercaseVowels . filter hasThreeVowels
.
Bu örnek, basit bir $A \rightarrow B \rightarrow C$ formülünden biraz daha karmaşıktır, ancak fonksiyon kompozisyonunun arkasındaki prensibi gösterir.
Önemli Çıkarımlar
Fonksiyon kompozisyonu basit ama güçlü bir kavramdır.
- Problemlerin daha küçük, daha basit adımlara bölündüğü ve tek bir çözümde birleştirildiği karmaşık problemleri çözmek için bir strateji sağlar.
- Herhangi bir şeyi kırma konusunda endişelenmeden nihai çözümün parçalarını kolayca eklemenize, çıkarmanıza veya değiştirmenize olanak tanıyan yapı taşları sağlar.
- $f$ çıktısı $g$ girdi türüyle eşleşirse $g(f())$ oluşturabilirsiniz.
İşlevleri oluştururken, yalnızca verileri değil, aynı zamanda diğer işlevlere girdi olarak işlevleri de iletebilirsiniz - daha yüksek dereceli işlevlere bir örnek.
FP Bileşeni #3: Saflık
İşlev bileşimi için ele almamız gereken bir anahtar öğe daha var: Oluşturduğunuz işlevler saf olmalıdır, matematikten türetilen başka bir kavram. Matematikte tüm işlevler, aynı girdiyle çağrıldıklarında her zaman aynı çıktıyı veren hesaplamalardır; saflığın temeli budur.
Matematik fonksiyonlarını kullanan bir sözde kod örneğine bakalım. Bir tamsayı girdisini çift yapmak için iki katına çıkaran ve makeEven(x) + x
satırını x = 2
girdisini kullanarak çalıştıran makeEven
bir fonksiyonumuz olduğunu varsayalım. Matematikte, bu hesaplama her zaman 2x + x = 3x = 3(2) = 6$ hesaplamasına dönüşür ve saf bir fonksiyondur. Ancak bu, programlamada her zaman doğru değildir—eğer makeEven(x)
işlevi, kod sonucumuzu döndürmeden önce onu ikiye katlayarak x
mutasyona uğrattıysa, satırımız 2x + (2x) = 4x = 4(2) = 8$ hesaplayacaktır. ve daha da kötüsü, her makeEven
çağrısında sonuç değişecekti.
Saf olmayan ancak saflığı daha spesifik olarak tanımlamamıza yardımcı olacak birkaç fonksiyon türünü inceleyelim:
- Kısmi işlevler: Bunlar, bölme gibi tüm giriş değerleri için tanımlanmayan işlevlerdir. Programlama açısından, bunlar bir istisna oluşturan işlevlerdir:
fun divide(a: Int, b: Int): Float
, sıfıra bölmenin neden olduğub = 0
girişi için birArithmeticException
oluşturur. - Toplam fonksiyonlar: Bu fonksiyonlar tüm girdi değerleri için tanımlanır ancak aynı girdi ile çağrıldığında farklı bir çıktı veya yan etkiler üretebilir. Android dünyası toplam işlevlerle doludur:
Log.d
,LocalDateTime.now
veLocale.getDefault
sadece birkaç örnektir.
Bu tanımları göz önünde bulundurarak, saf fonksiyonları hiçbir yan etkisi olmayan toplam fonksiyonlar olarak tanımlayabiliriz. Yalnızca saf işlevler kullanılarak oluşturulan işlev bileşimleri daha güvenilir, öngörülebilir ve test edilebilir kodlar üretir.
İpucu: Bir toplam işlevi saf hale getirmek için, yan etkilerini daha yüksek dereceli bir işlev parametresi olarak ileterek soyutlayabilirsiniz. Bu şekilde, sahte bir üst düzey işlevi geçerek toplam işlevleri kolayca test edebilirsiniz. Bu örnek, Ivy FRP öğreticisinde daha sonra inceleyeceğimiz bir kitaplıktan @SideEffect
ek açıklamasını kullanır:
suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())
Önemli Çıkarımlar
Saflık, işlevsel programlama paradigması için gerekli olan son bileşendir.
- Kısmi işlevlere dikkat edin; uygulamanızı çökertebilirler.
- Toplam fonksiyonları oluşturmak deterministik değildir; öngörülemeyen davranışlar üretebilir.
- Mümkün olduğunda, saf işlevler yazın. Artan kod kararlılığından yararlanacaksınız.
İşlevsel programlamaya genel bakışımız tamamlandıktan sonra, geleceğe yönelik Android kodunun bir sonraki bileşenini inceleyelim: reaktif programlama.
Reaktif Programlama 101
Reaktif programlama, programın değişiklikler hakkında bilgi istemek yerine verilere veya olay değişikliklerine tepki verdiği bildirimsel bir programlama modelidir.
Bir reaktif programlama döngüsündeki temel unsurlar olaylar, bildirimsel ardışık düzen, durumlar ve gözlemlenebilirlerdir:
- Olaylar , güncellemeleri tetikleyen, tipik olarak kullanıcı girişi veya sistem olayları biçimindeki dış dünyadan gelen sinyallerdir. Bir olayın amacı, bir sinyali boru hattı girdisine dönüştürmektir.
- Bildirimsel ardışık düzen ,
(Event, State)
girdi olarak kabul eden ve bu girdiyi yeni birState
(çıktı) dönüştüren bir işlev bileşimidir:(Event, State) -> f -> g -> … -> n -> State
. İşlem hatları, diğer işlem hatlarını engellemeden veya bitmesini beklemeden birden çok olayı işlemek için zaman uyumsuz olarak gerçekleştirmelidir. - Durumlar , zaman içinde belirli bir noktada yazılım uygulamasının veri modelinin temsilidir. Etki alanı mantığı, istenen sonraki durumu hesaplamak ve ilgili güncellemeleri yapmak için durumu kullanır.
- Gözlenebilirler , durum değişikliklerini dinler ve aboneleri bu değişikliklerle ilgili olarak günceller. Android'de, gözlemlenebilirler genellikle
Flow
,LiveData
veyaRxJava
kullanılarak uygulanır ve buna göre tepki verebilmesi için kullanıcı arayüzüne durum güncellemelerini bildirir.
Reaktif programlamanın birçok tanımı ve uygulaması vardır. Burada, bu kavramları gerçek projelere uygulamaya odaklanan pragmatik bir yaklaşım benimsiyorum.
Noktaları Birleştirme: Fonksiyonel Reaktif Programlama
Fonksiyonel ve reaktif programlama iki güçlü paradigmadır. Bu kavramlar, kitaplıkların ve API'lerin kısa ömürlü ömrünün ötesine geçer ve gelecek yıllarda programlama becerilerinizi geliştirecektir.
Ayrıca, FP ve reaktif programlamanın gücü birleştiğinde katlanarak artıyor. Artık işlevsel ve reaktif programlamanın net tanımlarına sahip olduğumuza göre, parçaları bir araya getirebiliriz. Bu öğreticinin 2. bölümünde, işlevsel reaktif programlama (FRP) paradigmasını tanımlıyor ve bunu örnek bir uygulama uygulaması ve ilgili Android kitaplıkları ile uygulamaya koyuyoruz.
Toptal Engineering Blog, bu makalede sunulan kod örneklerini incelediği için Tarun Goyal'a şükranlarını sunar.