Android 코드의 미래 대비, 1부: 함수형 및 반응형 프로그래밍 기초

게시 됨: 2022-08-31

깨끗한 코드를 작성하는 것은 어려울 수 있습니다. 라이브러리, 프레임워크 및 API는 일시적이며 빠르게 사용되지 않습니다. 그러나 수학적 개념과 패러다임은 지속됩니다. 그들은 수년간의 학문적 연구를 필요로 하며 심지어 우리보다 더 오래 갈 수도 있습니다.

이것은 라이브러리 Y를 사용하여 X를 수행하는 방법을 보여주는 튜토리얼이 아닙니다. 대신 기능 및 반응 프로그래밍의 이면에 있는 영구적인 원칙에 초점을 맞춰 미래에 대비하고 안정적인 Android 아키텍처를 구축하고 손상 없이 변경 사항에 맞게 확장 및 적응할 수 있습니다. 능률.

이 기사는 기초를 다지고, 2부에서는 함수형 프로그래밍과 반응형 프로그래밍을 결합한 FRP(함수형 반응 프로그래밍)의 구현에 대해 자세히 설명합니다.

이 기사는 Android 개발자를 염두에 두고 작성되었지만 개념은 일반 프로그래밍 언어에 대한 경험이 있는 모든 개발자에게 관련성이 있고 유익합니다.

함수형 프로그래밍 101

함수형 프로그래밍(FP)은 원하는 출력이 달성될 때까지 데이터를 $A$에서 $B$, $C$ 등으로 변환하는 함수의 구성으로 프로그램을 빌드하는 패턴입니다. 객체 지향 프로그래밍(OOP)에서는 명령을 통해 컴퓨터에게 무엇을 할 것인지 지시합니다. 함수형 프로그래밍은 다릅니다. 제어 흐름을 포기하고 대신 결과를 생성하기 위해 "함수 레시피"를 정의합니다.

"Input: x"라는 텍스트가 있는 왼쪽의 녹색 사각형에는 "Function: f"라는 레이블이 지정된 밝은 회색 사각형을 가리키는 화살표가 있습니다. 밝은 회색 직사각형 내부에는 오른쪽을 가리키는 화살표가 있는 세 개의 실린더가 있습니다. 첫 번째 실린더는 "A(x)"라는 밝은 파란색, 두 번째는 "B(x)"라고 표시된 진한 파란색, 세 번째는 "C"라고 표시된 짙은 회색입니다. (엑스)." 밝은 회색 사각형의 오른쪽에는 "출력: f(x)"라는 텍스트가 있는 녹색 사각형이 있습니다. 밝은 회색 사각형의 아래쪽에는 "부작용"이라는 텍스트를 가리키는 화살표가 있습니다.
함수형 프로그래밍 패턴

FP는 수학, 특히 함수 추상화의 논리 시스템인 람다 미적분학에서 유래합니다. 루프, 클래스, 다형성 또는 상속과 같은 OOP 개념 대신 FP는 추상화 및 고차 함수, 다른 함수를 입력으로 받아들이는 수학 함수를 엄격하게 다룹니다.

간단히 말해서 FP에는 데이터(문제에 필요한 모델 또는 정보)와 기능(데이터 간의 동작 및 변환 표현)이라는 두 가지 주요 "플레이어"가 있습니다. 대조적으로 OOP 클래스는 특정 도메인별 데이터 구조 및 각 클래스 인스턴스와 관련된 값 또는 상태를 함께 사용하도록 의도된 동작(메서드)에 명시적으로 연결합니다.

FP의 세 가지 주요 측면을 더 자세히 살펴보겠습니다.

  • FP는 선언적입니다.
  • FP는 함수 합성을 사용합니다.
  • FP 함수는 순수합니다.

FP 세계로 더 깊이 들어가기 위한 좋은 출발점은 강력한 형식의 순수 함수형 언어인 Haskell입니다. 저는 Learn You Haskell for Great Good을 추천합니다! 유익한 리소스로서의 대화형 튜토리얼.

FP 구성요소 #1: 선언적 프로그래밍

FP 프로그램에 대해 가장 먼저 알 수 있는 것은 명령형이 아닌 선언적 스타일로 작성되었다는 것입니다. 간단히 말해서, 선언적 프로그래밍은 프로그램을 수행하는 방법 대신 수행해야 하는 작업을 알려줍니다. 다음 문제를 해결하기 위해 명령형 대 선언적 프로그래밍의 구체적인 예를 사용하여 이 추상적인 정의를 기반으로 합시다. 이름 목록이 주어지면 모음이 대문자로 표시되고 모음이 3개 이상인 이름만 포함하는 목록을 반환합니다.

필수 솔루션

먼저 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)$ 시간에 실행된다고 말할 수 있습니다.
  • 가독성이 좋지 않은 복잡함: 다음에 고려할 선언적 솔루션과 비교할 때 이 솔루션은 훨씬 더 길고 따르기 어렵습니다.
  • 오류가 발생하기 쉬운 코드: 이 코드는 result , vowelsCount 수 및 transformedChar 를 변경합니다. 이러한 상태 돌연변이는 vowelsCount 를 다시 0으로 재설정하는 것을 잊는 것과 같은 미묘한 오류를 유발할 수 있습니다. 실행 흐름도 복잡해질 수 있으며 세 번째 루프에서 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() 를 사용했기 때문에 좀 더 효율적입니다. 여기서 이름이 끝날 때까지 모음을 계속 계산합니다(세 개의 모음을 찾은 후에도 ). 간단한 hasThreeVowels(String): Boolean 함수를 작성하여 이 문제를 쉽게 고칠 수 있습니다. 이 솔루션은 명령형 솔루션과 동일한 알고리즘을 사용하므로 동일한 복잡성 분석이 여기에 적용됩니다. 우리의 알고리즘은 $O(n)$ 시간에 실행됩니다.
  • 가독성이 좋은 간결함: 명령형 솔루션은 들여쓰기가 작은 16줄의 선언적 솔루션에 비해 큰 들여쓰기가 포함된 44줄입니다. 줄과 탭이 전부는 아니지만 두 파일을 보면 우리의 선언적 솔루션이 훨씬 더 읽기 쉽다는 것이 분명합니다.
  • 오류 발생 가능성 감소: 이 샘플에서는 모든 것이 변경 불가능합니다. 모든 이름의 List<String> List<String> 3개 이상의 모음이 있는 이름의 List<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의 연산자는 "후"로 읽습니다. 예를 들어, 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$)를 생성하는 세 번째 함수 $h$를 만들 수 있습니다. 이 세 번째 함수는 $g \circ f$ 또는 $g(f())$로도 표기되는 $f$와 $g$의 합성 으로 정의할 수 있습니다.

"A"라고 표시된 파란색 상자에는 "B"로 표시된 파란색 상자를 가리키는 화살표 "f"가 있고 "C"라고 표시된 파란색 상자를 가리키는 화살표 "g"가 있습니다. 상자 "A"에는 상자 "C"를 직접 가리키는 평행 화살표 "g o f"도 있습니다.
함수 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 .

상단 다이어그램에는 오른쪽을 가리키는 화살표로 연결된 세 개의 파란색 "[문자열]" 상자가 있습니다. 첫 번째 화살표는 "filter has3Vowels" 레이블이 지정되고 두 번째 화살표는 "map uppercaseVowels" 레이블이 지정됩니다. 아래 두 번째 다이어그램에는 왼쪽에 두 개의 파란색 상자가 있고 상단에 "Char"가 있고 오른쪽에 파란색 상자인 "Bool"을 가리키는 "String"이 있습니다. "Char"에서 "Bool"로의 화살표는 "isVowel"로 레이블이 지정되고 "String"에서 "Bool"로의 화살표는 "has3Vowels"로 레이블이 지정됩니다. "String" 상자에는 "uppercaseVowels"라는 레이블이 붙은 자체 화살표가 있습니다.
이름 문제를 사용한 함수 구성의 예.

이 예제는 간단한 $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 는 0으로 나누기에 의해 발생하는 입력 b = 0 에 대해 ArithmeticException 을 발생시킵니다.
  • 전체 함수: 이 함수는 모든 입력 값에 대해 정의되지만 동일한 입력으로 호출될 때 다른 출력 또는 부작용을 생성할 수 있습니다. Android 세계는 전체 기능으로 가득 차 있습니다. Log.d , LocalDateTime.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 . 파이프라인은 다른 파이프라인을 차단하거나 완료될 때까지 기다리지 않고 여러 이벤트를 처리하기 위해 비동기식으로 수행해야 합니다.
  • 상태 는 주어진 시점에서 소프트웨어 애플리케이션의 데이터 모델 표현입니다. 도메인 논리는 상태를 사용하여 원하는 다음 상태를 계산하고 해당 업데이트를 수행합니다.
  • Observable 은 상태 변경을 수신 대기하고 이러한 변경에 대해 구독자를 업데이트합니다. Android에서 옵저버블은 일반적으로 Flow , LiveData 또는 RxJava 를 사용하여 구현되며 UI에 상태 업데이트를 알려 그에 따라 반응할 수 있습니다.

반응형 프로그래밍에는 많은 정의와 구현이 있습니다. 여기서는 이러한 개념을 실제 프로젝트에 적용하는 데 중점을 둔 실용적인 접근 방식을 취했습니다.

점 연결하기: 함수형 반응 프로그래밍

함수형 프로그래밍과 반응형 프로그래밍은 두 가지 강력한 패러다임입니다. 이러한 개념은 라이브러리 및 API의 수명이 짧고 앞으로 몇 년 동안 프로그래밍 기술을 향상시킬 것입니다.

게다가, FP와 반응형 프로그래밍의 힘은 결합될 때 배가됩니다. 이제 우리는 함수형 프로그래밍과 반응형 프로그래밍에 대한 명확한 정의를 얻었으므로 조각을 함께 모을 수 있습니다. 이 자습서의 2부에서는 FRP(기능적 반응 프로그래밍) 패러다임을 정의하고 샘플 앱 구현 및 관련 Android 라이브러리를 사용하여 이를 실행합니다.

Toptal 엔지니어링 블로그는 이 기사에 제공된 코드 샘플을 검토한 Tarun Goyal에게 감사를 표합니다.