모나드를 엔도 펑터 범주에서의 모노이드라고 한다. 무슨 말인가? 앞에서 범주에 대해서 알아보았으니, 펑터에 대해서 먼저 알아보자.
펑터(Functor)
아래 두가지 범주(카테고리)를 가정해보자
범주D - 대상(A, B, C), 사상(f:A → B, g:B → C, h:A → C)
범주E - 대상(X, Y, Z), 사상(F(f):X → Y, F(g):Y → Z,F(h):X → Z)
대상 대응: 펑터F는 범주 D의 대상 A, B, C를 범주 E의 대상 X, Y, Z로 대응시킵니다. F(A) = X, F(B) = Y, F(C) = Z
사상 대응: 펑터F는 범주 D의 사상 f, g, h를 범주 E의 사상 F(f), F(g), F(h)로 대응 시킵니다. F(f) = X → Y, F(g) = Y → Z, F(h) = X → Z
항등 사상 보존: 펑터 F는 범주 D의 모든 대상 A, B, C에 대한 항등 사상을 보존합니다. F(id_A) = id_X
합성 사상 보존: 펑터 F는 범주 D의 모든 사상 f, g, h에 대해 합성 사상을 보존합니다. 범주 D는 h = g•f는 범주 E에서 F(h) = F(g)•F(h)
결국 map이다.
interface Functor<F> {
fun <A, B> map(fa: Kind<F, A>, f: (A) -> B): Kind<F, B>
}
sealed class Option<out A> {
companion object {
fun <A> empty(): Option<A> = None
}
fun <B> map(f: (A) -> B): Option<B> =
when (this) {
is None -> None
is Some -> Some(f(this.get))
}
}
엔도 펑터(endo functor)
endo란 단어는 내부의 나, 내부로라는 뜻. 엔도펑터랑 범주내에서 작동하는 펑터를 의미한다.
typealias Endo<A> = (A) -> A
fun <A> fmap(o: Option<A>, f: Endo<A>): Option<A> = o.map(f)
모나드(Monad)
모나드는 엔도펑터 범주에서의 모노이드다.
모노이드는 집합(Set)과 이항 연산(Binary operation)을 가지며, 결합법칙(Assosiation)과 항등원(identity)을 만족하는 대수 구조이다.
엔도펑터는 범주 C의 대상과 사상을 동일한 범주 C에 대응 시키는 자기 자신으로의 펑터이다.
대상 - 엔도 펑터, 범주 C의 대상과 사상을 동일한 범주 C내에서 대응시키는 펑터
사상 - 이항 연산, 엔도펑터의 합성. 두 엔도펑터를 입력 받아서 새로운 엔도펑터를 반환하는 연산, 이 연산은 범주 C 내에서만
요구사항
결합법칙 - 모든 엔도펑터 F, G, H에 대해 (F•G)•H = F•(G•H)가 성립해야 한다. 즉, 엔도펑터의 합성 순서가 결과에 영향을 주지 않는다.
항등원 - 범주 C의 모든 엔도펑터 F와 합성을 수행할 때 결과가 자기 자신이 되는 엔도펑터를 의미한다. 즉 I•F = F•I = F가 성립해야 한다.
모나드의 결국 아래의 구성 요소를 가진다. 결국 flatMap과 lift이다.
바인드 함수(Bind Function) - 모나드를 받아들이고, 모나드에 포함된 값을 다루는 함수를 받아 들여서, 새로운 모나드를 반환합니다. (flatMap)
리턴 함수(Return Function) - 일반 값을 모나드로 변환시킨다. (lift)
항등사상: 대상 A에 대한 항등 사상(id_A)가 존재해야 한다. 이 사상은 A에서 A로 가는 사상
사상의 연속성: f: A→B와 g: B->C가 주어졌을때, 두 사상의 합성 g∙f: A→C가 존재해야 한다. 두 사상을 연결하여 하나의 사상으로 만들 수 있어야 한다.
합성의 결합법칙: 세 개의 사상 f,g,h가 주어졌을때, (h∙g)∙f = h∙(g∙f)가 성립해야 한다. 사상을 연결할 때 순서가 중요하지 않다.
대상은 수학적으로는 집합이며 프로그래밍에서 익숙한 용어로 바꾸면 타입이다. 예를 들어 정수 타입은 모든 정수를, 부울 타입은 참과 거짓 만을 포함한다.
사상은 화살표(arrow)이다. 화살표는 두 대상 사이의 관계를 나타낸다. 프로그래밍에서는 화살표는 함수, 변환, 매핑 등이 될 수 있다.
코드로 나타내는 아래와 같다.
interface Category<T> {
fun identity(x: T): (T) -> T
fun <A, B, C> compose(f: (A) -> B, g: (B) -> C): (A) -> C
}
fun main() {
val stringCategory = object : Category<String> {
override fun identity(x: String): (String) -> String = { x }
override fun <A, B, C> compose(f: (A) -> B, g: (B) -> C): (A) -> C = { a: A -> g(f(a)) }
}
val f = { s: String -> s.toUpperCase() }
val g = { s: String -> "$s!" }
val h = { s: String -> s.repeat(2) }
val left = stringCategory.compose(stringCategory.compose(f, g), h) // (f•g)•h
val right = stringCategory.compose(f, stringCategory.compose(g, h)) // f•(g•h)
println(left("hello")) // HELLO!HELLO!
println(right("hello")) // HELLO!HELLO!
}
범주론은 추상적인 수학구조로, 대상과 사상의 모음인 범주를 다루는 학문이다. 범주론은 함수형 프로그래밍에 적용할 수 있는데, 대상은 타입, 데이터구조, 모듈과 같은 개념으로, 사상은 화살표(함수, 변환, 매핑)으로 나타낼 수 있다.
모노이드 (Monoid)
추상대수학에서 모노이드(영어: monoid)는 항등원을 갖는, 결합 법칙을 따르는 이항 연산을 갖춘 대수 구조이다. 군의 정의에서 역원의 존재를 생략하거나, 반군의 정의에서 항등원의 존재를 추가하여 얻는다.
범주론적으로, 모노이드는집합과함수의 범주Set 속의모노이드 대상이다. 또한, 하나의 대상만을 갖는작은 범주는 모노이드와 같은 개념이다. 이 경우, 모든 사상은자기 사상이며, 모노이드 이항 연산은자기 사상의 합성이다.
(대상) 집합 (Set): 원소들의 모음.
(사상) 이항 연산 (Binary operation): 두 원소를 입력받아 집합의 다른 원소를 반환하는 연산. 이 연산은 해당 집합내에서만 이루어진다. f: S X S → S로 정의. 대표적인 이항연산은 덧셈,곱셈,나눗셈이 있다.
(사상으로서 만족해야될 요구사항)
결합법칙(Associativity) : 모든 원소 a,b,c에 대해서 (a•b)•c = a•(b•c)가 성립해야한다. 즉 연산의 순서과 결과에 영향을 주지 말아야 한다. (1 + 2) + 3 = 1 + (2 + 3) = 6과 같이 덧셈 연산은 결합 법칙이 성립한다.
항등원(Identity element): 이항 연산에서 특별한 역할을 하는 원소로 집합 S의 모든 원소 a와 연산을 수행한 결과가 a가 자기 자신이여야 한다. 집합 S의 항등원라고 e라고 하면 e•a = a•e = a이다. 예를 들어 덧셈연산의 항등원은 0이고, 곱셈연산의 항등원은 1이다.
코드로 보면 다음과 같다.
interface Monoid<A> {
fun combine(a1: A, a2: A): A
val nil: A
}
val stringMonoid = object : Monoid<String> {
override fun combine(a1: String, a2: String): String = a1 + a2
override val nil: String = ""
}
val intAddition = object: Monoid<Int> {
override fun combine(a1: Int, a2: Int): Int = a1 + a2
override val nil: Int = 0
}
val booleanAnd = object : Monoid<Boolean> {
override fun combine(a1: Boolean, a2: Boolean): Boolean = a1 && a2
override val nil: Boolean = true
}
val optionMonoid = object : Monoid<Option<A>> {
override fun combine(a1: Option<A>, a2: Option<A>): Option<A> = a1.orElse { a2 }
override val nil: Option<A> = None
}
모노이드의 combine 연산은 리스트 접기 연산에 잘 어울린다.
val words = listOf("Hic", "Est", "Index")
val rightResults = words.foldRight(stringMonoid.nil, stringMonoid::combine)
val leftResults = words.foldLeft(stringMonoid.nil, stringMonoid::combine)
fun <A, B> foldMap(la: List<A>, m: Monoid<B>, f: (A) -> B): B =
la.foldLeft(m.nil) { b: B, a: A -> m.combine(b, f(a)) }
val results = foldMap(words, stringMonoid, { it + "!" })
sealed interface Either<out A, out B> {
data class Left<A>(val error: A) : Either<A, Nothing>
data class Right<B>(val value: B) : Either<Nothing, B>
}
계좌의 입출금 문제를 생각해보자
sealed interface Error {
data object NegativeAmount : Error
}
data class Account private constructor(val balance: BigDecimal) {
companion object {
fun create(initial: BigDecimal): Either<Error, Account> {
return if (initial < BigDecimal.ZERO) Left(NegativeAmount)
else Right(Account(initial))
}
}
fun deposit(amount: BigDecimal): Either<Error, Account> {
return if (amount < BigDecimal.ZERO) Left(NegativeAmount)
else Right(Account(balance + amount))
}
fun withdraw(amount: BigDecimal): Either<Error, Account> {
return if (amount < BigDecimal.ZERO) Left(NegativeAmount)
else Right(Account(balance - amount))
}
}
일반적으로 입출금을 수행하는 코드를 작성해보면 다음과 같다.
val accountResult = Account.create(100.toBigDecimal())
val depositResult = when (accountResult) {
is Right -> accountResult.value.deposit(100.toBigDecimal())
is Left -> Left(accountResult.error)
}
val withdrawResult = when (depositResult) {
is Right -> depositResult.value.withdraw(50.toBigDecimal())
is Left -> Left(depositResult.error)
}
withdrawResult shouldBe Right(50.toBigDecimal())
모나드 적용
모나드의 인터페이스는 다음과 같다. 모나드의 특징은 합성 가능하다는 것이다. 그래서 flatMap 연산이 가능하다.
interface Monad<A> {
fun <B> flatMap(f: (A) -> Monad<B>): Monad<B>
fun <A> unit(a: A): Monad<A>
}
두가지 연산을 Either에 추가해보자.
sealed interface Either<out A, out B> {
data class Left<A>(val error: A) : Either<A, Nothing>
data class Right<B>(val value: B) : Either<Nothing, B>
fun <A, C> flatMap(fn: (B) -> Either<A, C>): Either<A, C> = when (this) {
is Right -> fn(this.value)
is Left -> this as Either<A, C>
}
fun <A, B> unit(b: B): Either<A, B> = Right(b)
}
모나드의 가장 좋은 점은 합성 가능하다는 것이다. 합성 가능하다는 특성으로 아래와 같은 일들을 수행할 수 있다.
Nullability: Maybe/Option monad
Error Handling: Either monad
DI (Dependency Injection): Reader monad
Logging: Writer monad
Side Effects : IO monad
State handling: State monad
Collections: List monad
Async Handling : Future monad
Parallel Processing: Par Monad
State Monad
data class State<S, out A>(val run: (S) -> Pair<A, S>) {
fun <B> flatMap(f: (A) -> State<S, B>): State<S, B> =
State { s0 ->
val (a, s1) = run(s0)
f(a).run(s1)
}
fun <B> map(f: (A) -> B): State<S, B> = flatMap { a -> State { s -> Pair(f(a), s) } }
}
// Usage example
data class CounterState(val count: Int)
fun incrementCounter(): State<CounterState, Unit> = State { state ->
Pair(Unit, state.copy(count = state.count + 1))
}
fun doubleCounter(): State<CounterState, Unit> = State { state ->
Pair(Unit, state.copy(count = state.count * 2))
}
val result: Pair<Unit, CounterState> = incrementCounter().flatMap { doubleCounter() }.run(CounterState(0))
Reader 모나드
typealias Reader<E, A> = (E) -> A
fun <E, A, B> Reader<E, A>.flatMap(transform: (A) -> Reader<E, B>): Reader<E, B> =
{ env -> transform(this(env))(env) }
fun <E, A, B> Reader<E, A>.map(transform: (A) -> B): Reader<E, B> =
{ env -> transform(this(env)) }
// Usage example
data class AppConfig(val apiUrl: String)
fun fetchUser(userId: String): Reader<AppConfig, String> =
{ config -> "User $userId fetched from ${config.apiUrl}" }
fun logUser(user: String): Reader<AppConfig, Unit> =
{ config -> println("Logging user: $user to ${config.apiUrl}") }
val program: Reader<AppConfig, Unit> =
fetchUser("123").flatMap { user ->
logUser(user)
}
// Running the program
val config = AppConfig("https://api.example.com")
program(config)
Future Monad
suspend fun <T> asyncOperation(): T = suspendCoroutine { continuation ->
// Simulating an async operation
GlobalScope.launch {
delay(1000) // Simulating a delay
val result = /* Perform some computation */
continuation.resume(result)
}
}
fun <T, R> Deferred<T>.flatMap(transform: (T) -> Deferred<R>): Deferred<R> =
GlobalScope.async { transform(await()).await() }
fun <T, R> Deferred<T>.map(transform: (T) -> R): Deferred<R> =
GlobalScope.async { transform(await()) }
// Usage example
val result: Deferred<Int> = asyncOperation<Int>().flatMap { value ->
asyncOperation<Int>().map { value + it }
}
runBlocking { println(result.await()) }
자바나 스프링을 미리 경험했다면 의존성 주입(Dependency Injection)이라는 것에 익숙할 것이다. 그러나 기존의 방식은 런타임시에 의존성 주입이 발생한다. 앞에서 살펴봤듯이 함수형 프로그래밍에서는 최대한 컴파일러가 타입 안전을 검사하기를 원한다. 코틀린에서는 이 문제를 어떻게 해결할 수 있을지 살펴보자.
Effects
앞에서 효과가 무엇인지 알아봤다. 효과를 명시적으로 함수의 시그니처에 포함시키자는 것이 주요 아이디어이다. 기본적으로 순수 함수라면 함수는 연산만을 수행한다라고 얘기한다.
fun add(x: Int, y: Int): Int = x + y
위의 간단한 순수 함수에 사이드 이펙트를 포함시켜 보자.
fun loggingAdd(x: Int, y: Int): Int {
println("x = $x, y = $y")
return x + y
}
더 심하게 만들어보면 다음과 같을 수도 있다.
fun crazyAdd(x: Int, y: Int): Int {
if (Random.nextInt() == 42) throw WhatsHappeningException()
return x + y
}
위의 잠재적인 행동을 시그니처에 표현하면 다음과 같다.
context(Raise<WhatsHappeningError>, Random)
fun crazyAdd(x: Int, y: Int): Int
만약 네트워크나 파일시스템 혹은 데이터베이스를 사용이라면 다음과 같다.
context(Raise<WhatsHappeningError>, UserRepository)
suspend fun getUserById(id: UserId): User?
하지만 위와 같이 표현할 필요가 없다. 코루틴에서는 이미 suspend 제공하기 때문이다. 아래의 시그니처는 서스펜더블 즉 중지가 가능하다는 것을 의미한다. 외부 효과 및 부수 효과가 있다면 suspend로 표시함으로써 사이드 이펙트가 있음을 명시할 수 있다.
suspend fun getUserById(id: UserId): User?
Context receiver
코틀린 1.6.20 이후 conext receiver를 제안한다. 간단히 얘기하자면 컨텍스트 리시버는 implict 파라미터를 전달하는 방식다. 이는 일련의 빌트인 디펜던시 인젝션 또는 컴파일타임 디펜던시 인젝션이다. UserRepository 인터페이스를 하나 선언해보자.
interface UserRepository {
suspend fun getUserById(id: UserId): User?
}
해당 저장소를 함수의 선언에 넣어서 사용할 수 있게 한다.
context(UserRepository)
suspend fun getUserName(id: UserId): String? =
getUserById(id)?.name
실제 주입하는 코드는 다음과 같다. with를 사용해서 주입한다.
fun example() {
createDbConnection().use { db ->
with(DbUserRepository(db)) {
getUserName(UserId(1))
}
}
}
컨텍스트 리시버를 활용하면 여러가지 기능을 합성할 수도 있다.
context(UserRepository, ResourceScope)
fun whoKnowsWhatItDoes(name: String)
정리
사이드 이펙트는 코틀린의 suspend 함수를 이용해서 표시할 수 있다.
코틀린에서는 context receivers를 이용하여 빌트인 디펜던시 인젝션, 컴파일타임 디펜던시 인젝션을 지원한다.
함수형 도메인 모델링의 목적은 비즈니스 도메인을 정확하게 묘사하는 것이다. 컴파일러를 최대한 활용하여 타입-세이프하고, 버그를 방지하고 유닛테스트를 경감 시키는 것이 목적이다. 앞에서 살펴봤듯이 함수형 프로그래밍은 타입 주도 개발(Type Driven Development)을 따르기도 한다. 타입은 함수와 데이터가 준수해야 하는 엄격한 계약이다. 타입 시스템을 이를 어길 수 없다. 그러므로 다양한 케이스에 대한 유닛테스트도 경감시킬 수 있는 것이다.
코틀린은 함수형 도메인 모델링에 적합하다. 코틀린은 data class, sealed class, enum class, value class를 제공한다. 그리고 애로우를 이용한다면, Either와 같은 흥미로운 데이터 타입을 제공 받을 수 있다.
예제
기본 타입 기반의 Event에 대한 구현을 살펴보자. title, organizer, description은 같은 타입을 공유하고 있다.
data class Event(
val id: Long,
val title: String,
val organizer: String,
val description: String,
val date: LocalDate
)
tiltle, organizer랑 description을 혼동했지만 컴파일러는 Event 객체를 생성하고 만다. 더 많은 케이스에서 문제가 있는데, 예를들어 분해를 할 때이다.
Event(
0L,
"Simon Vergauwen",
"In this blogpost we dive into functional DDD...",
"Functional Domain Modeling",
LocalDate.now()
)
Value 클래스를 이용해서 타입을 명시하자.
@JvmInline value class EventId(val value: Long)
@JvmInline value class Organizer(val value: String)
@JvmInline value class Title(val value: String)
@JvmInline value class Description(val value: String)
data class Event(
val id: EventId,
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate
)
앞의 예제로 돌아가서 아래와 같이 생성을 하려고 한다면 컴파일러는 오류를 발생시킬 것이다.
Event(
EventId(0L),
Organizer("Simon Vergauwen"),
Description("In this blogpost we dive into functional DDD..."),
Title("Functional Domain Modeling"),
LocalDate.now()
)
함수형 프로그래밍에서 위와 같은 타입의 합성을 곱타입 혹은 레코드라고 부른다. 이벤트는 EventId, Title, Organizer, Description, LocalDate로 이루어졌다고 말한다. 아니면 Event는 Long, String, String, String, LocalDate로 이루어졌다고 말해야 할 것이다.
이벤트는 연령 제한이 있다고 가정해보자, MPAA 영화 등급을 따라서 5가지 케이스가 있다면 enum class가 적합할 것이다.
enum class AgeRestriction(val description: String) {
General("All ages admitted. Nothing that would offend parents for viewing by children."),
PG("Some material may not be suitable for children. Parents urged to give \"parental guidance\""),
PG13("Some material may be inappropriate for children under 13. Parents are urged to be cautious."),
Restricted("Under 17 requires accompanying parent or adult guardian. Contains some adult material."),
NC17("No One 17 and Under Admitted. Clearly adult.")
}
함수형 프로그래밍에서 위와 같은 데이터 구성을 합타입이라고 한다. 우리는 연령 제한은 General, PG, PG13, Restricted, NC17이 있다고 설명할 수 있다. 이는 String이라고 말하는 것보다 훨씬 낫다.
다음은 온라인 이벤트와 오프라인 이벤트가 있다고 가정해보자. 온라인 이벤트에는 URL이 오프라인 이벤트에는 주소가 필요할 것이다.
@JvmInline value class Url(val value: String)
@JvmInline value class City(val value: String)
@JvmInline value class Street(val value: String)
data class Address(val city: City, val street: Street)
data class Event(
val id: EventId,
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate,
val ageRestriction: AgeRestriction,
val isOnline: Boolean,
val url: Url?,
val address: Address?
)
아래는 일반적인 인코딩인데, 문제가 있다.
fun printLocation(event: Event): Unit =
if(event.isOnline) {
event.url?.value?.let(::println)
} else {
event.address?.let(::println)
}
sealed class Event {
abstract val id: EventId
abstract val title: Title
abstract val organizer: Organizer
abstract val description: Description
abstract val ageRestriction: AgeRestriction
abstract val date: LocalDate
data class Online(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val url: Url
) : Event()
data class AtAddress(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val address: Address
) : Event()
}
위의 타입은 합타입이다. sealed class는 enum class보다 더 강력한 기능을 제공한다.
object, data class 또는 다른 sealed class의 확장을 제공한다. 인코딩은 훨씬 보기 좋아졌다.
fun printLocation(event: Event): Unit =
when(event) {
is Online -> println(event.url.value)
is AtAddress -> println("${event.address.city}: ${event.address.street}")
}
다음은 애로우 데이터 타입이 우리의 코드를 얼마나 더 명확하게 하는지 살펴보자.
interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Event
}
EventService에서 누락한 것은 어떠한 종류의 에러 시나리오가 발생하는지이다. 이벤트가 없을 경우, 이벤트가 더이상 유효하지 않을 경우를 모델링하면 다음과 같다.
sealed class Error {
data class EventNotFound(val id: EventId): Error()
data class EventPassed(val event: Event): Error()
}
Either는 Error 혹은 Event를 반환 할 수 있는 제너릭한 합타입니다.
interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Either<Error, Event>
}
정리
기본 타입을 제거하고, value class를 사용하여 runtime에 오버헤드를 줄였다.
다른 함수형 에코시스템 즉, 스칼라랑 하스켈에서는 사이드 이펙트의 모나딕 모델을 사용한다. 주요 컴포넌트는 IO라고 불리는 모델이다. 애로우는 다른 모델을 차용하는데 suspend와 탑레벨 확장인 suspend () → A를 기본으로 한다.
IO를 사용하는 이유는 사이드 이펙트 코드를 안전사혹 참조 투명하게 작성하는 것이다. 추가적으로 IO는 강력한 동시 수행과 취소를 제공한다. 이러한 점에서 IO와 suspend는 같은 이점을 가지지만, 컴파일러 자체에서 suspend는 지원한다는 점이 다르다.
Ergonomics
IO 사용의 가장 기본적인 패턴은 flatMap을 활용하는것이다.
fun number(): IO<Int> = IO.pure(1)
fun triple(): IO<Triple<Int, Int, Int>> =
number().flatMap { a ->
number().flatMap { b ->
number().map { c ->
Triple(a, b, c)
}
}
}
코틀린에서는 suspend를 컴파일러에서 지원하니까 다음과 같이 작성할 수 있다.
suspend fun number(): Int = 1
suspend fun triple(): Triple<Int, Int, Int> =
Triple(number(), number(), number())
Safety / Purity / Referential transparency
개념레벨에서 보면 IO는 항상 성공하거나 예외로 마무리된다. Cats 이펙트의 unsafeRunAsync의 결과를 Either<Thowable, A>이다. 이는 IO에서 발생하는 오류의 처리를 강제한다. 여기서 중요한 점은 다른 스레드에서 비동기로 실행되더라도, 예외는 캡처되며 복구를 수행하도록 한다.
suspend는 항상 Result<A>를 반환하는데, 이것은 Either<Thorwable,A>와 같다. 안전함을 보장함에 된다는 점에서 IO와 같다. startCoroutine의 IO의 unsafeAsync와 같다. f: (Either<Throwable,A> → Unit) 대신에 suspend () → A를 실행하기 위해 f: (Result<A>) → Unit을 제공받는다.
Performance
성능은 suspend과 IO보다 월등히 우수하다. 이유는 IO는 런타임에 추가적으로 리소스가 들지만, suspend는 컴파일 타임에 이루어진다.
일단 IO라는 타입을 쓰면 해당 타입을 할당하는데 추가적인 리소스가 사용된다. IO타입을 연산하고, 지연계산을 하는데 스택이나 힙을 추가적으로 사용하게 된다. 반면에 suspend는 코틀린 컴파일러가 각 서스펜드 포인트를 알아내서, 해체한 다음 더 효과적인 코드로 변환한다.
fun number(): IO<Int> = IO.pure(1)
fun triple(): IO<Triple<Int, Int, Int>> =
number().flatMap { a ->
number().flatMap { b ->
number().map { c ->
Triple(a, b, c)
}
}
}
위와 같은 코드를 사용하게 되면 실제로 6개의 IO 클래스를 할당하게 된다.
fun number(): IO<Int> = IO.Just(1)
fun triple(): IO<Triple<Int, Int, Int>> =
IO.FlatMap(IO.Pure(1)) { a ->
IO.FlatMap(IO.Pure(1)) { b ->
IO.Map(IO.Pure(1)) { c -> Triple(a, b, c) }
}
}
정리
지연성(Lazyness)은 기술과 실행의 관심사를 분리함으로써 재사용성을 높이는데 훨씬 더 가치가 있다. IO뿐만 아니라, 비동기, 병렬처리에서도 같은 개념으로 활용된다.
지연성을 이용해서 부수효과를 분리하고, 참조투명성을 확보할 수 있다. 테스트 및 검증에도 용이하다.
코틀린에서 IO보다 성능이 우수하고, 참조투명성을 확보할 수 있는 suspend를 제공한다.
앞에서 보았듯이 함수형 프로그래밍에서는 항상 불변값과 대수적 타입으로 값을 다룬다. 이는 컬렉션의 경우에도 마찬가지이다. 리스트의 가장 기본적인 구현을 보면 아래와 같다.
// sealed 키워드를 통해 패키지 안에서만 상속 가능하도록
sealed class List<out A>
// Empty를 표현
object Nil : List<Nothing>()
// 비어 있지 않는 리스트
data class Cons<out A>(val head: A, val tail: List<A>) : List<A>()
데이터 공유
불변 리스트 xs에 1이라는 원소를 추가한다면 Cons(1, xs) 새로운 리스트를 반환하면 된다. 삭제를 한다면 tail을 반환하면 됨. 이를데이터 공유라고 한다. 데이터 변경이나 오염을 피하기 위해서 복사본을 만들 필요가 없다. 데이터 구조가 불변이기 때문에 복사본은 불필요하다.
함수형 데이터 구조는 영속적(Persistent)이다. 영속적이라는 말은 존재하는 참조가 데이터 구조에 대한 연산을 수행한 다음에도 바뀌지 않고 원래대로 남는다는 것이다.
데이터 공유의 효율
두 리스트를 연결하는 연산을 생각해보자. 실행 시간과 사용량은 a1의 길이에 의해서만 결정된다. 배열로 구현한다면 두 배열의 모든 원소를 배열에 복사해야 한다. 불변 리스트가 훨씬 유리하다.
fun <A> append(a1: List<A>, a2: List<A>): List<A> =
when (a1) {
is Nil -> a2
is Cons -> Cons(a1.head, append(a1.tail, a2))
}
코틀린 표준 라이브러리의 리스트
사실 불변 리스트가 아닌 읽기 전용 리스트이다. 내부적으로 ArrayList를 사용하며, 변경/삭제 연산을 제공하지 않는 것일 뿐이다.
Arrow Collections
애로우에서는 불변 리스트를 다루기 위한 다양한 타입 및 도구들을 제공한다.
Non-Empty Collections
nullable 타입은 값이 null일 수 있다는 것을 표현할 수 있다. 하지만 어떤 시나리오에서는 반드시 하나의 엘리먼트가 존재해야 한다는 경우도 있을 수 있다. 이를 위해 애로우는 NonEmptyList와 NoEmptySet을 제공한다. 예를 들어 에러를 집계할때 타입은 Either<List<Problem>, Result>로 일반적으로 표현할텐데 , Left에 Empty리스트가 있다면 비정상적인 상황이라고 할 수 있다. 이럴 경우 Either<NonEmptyList<Problem>,Result>와 같은 타입으로 대체할 수 있다.
Collectors
일반적으로 리스트의 평균을 계산하는 연산을 살펴보자
val average = list.sum() / list.size
문제는 리스트를 두 번 탐색한다는 것이다. 컬렉터는 집계연산에 대한 description과 실제 연산을 분리한다.
import arrow.collectors.Collectors
import arrow.collectors.collect
import arrow.collectors.zip
fun divide(x: Int, y: Int): Double = x.toDouble() / y.toDouble()
val averageCollector = zip(Collectors.sum, Collectors.length, ::divide)
val average = list.collect(averageCollector)
Recursive functions
함수형에서는 알고리즘 루프보다는 재귀를 사용한다. 하지만 재귀를 사용하다보면 스택오버플로우을 종종 만나게 된다.
Stack-safe deep recursive functions
코틀린은 빌트인으로 DeepRecursiveFunction을 제공한다. 이럴경우 콜스택을 유지하기 위해 평소보다 훨씬 큰 메모리를 힙에 유지한다.
fun fibonacciWorker(n: Int): Int = when (n) {
0 -> 0
1 -> 1
else -> fibonacciWorker(n - 1) + fibonacciWorker(n - 2)
}
fun fibonacci(n: Int): Int {
require(n >= 0)
return fibonacciWorker(n)
}
fun example() {
fibonacci(6) shouldBe 8
}
스택 세이프하게 변경하면 다음과 같다.
val fibonacciWorker = DeepRecursiveFunction<Int, Int> { n ->
when (n) {
0 -> 0
1 -> 1
else -> callRecursive(n - 1) + callRecursive(n - 2)
}
}
Memoized recursive functions
위 피보나치의 개선사항은 같은 연산을 메모이제이션을 할 수 있다는 것이다. 순수 함수는 항상 입력에 대한 출력이 같으므로 메모이제이션에 유리하다.애로우에서는 해당 해법으로 MemoizedDeepRecursiveFunction을 제공한다.
import arrow.core.MemoizedDeepRecursiveFunction
val fibonacciWorker = MemoizedDeepRecursiveFunction<Int, Int> { n ->
when (n) {
0 -> 0
1 -> 1
else -> callRecursive(n - 1) + callRecursive(n - 2)
}
}
예전에 자바를 사용했었다면, NullpointerException을 겪었을 것이다. 널의 가장 큰 문제점은 클라이언트 코드에서 예상할 수 어렵다는데 있었다. 코틀린에서는 ?을 베이스로 null-safety 문제를 해결하고 있다. 코틀린에는 nullable type이 있지만, Arrow에 Option 타입이 있는 이유에 대해서 알아보자.
fun <A> List<A>.firstOrElse(default: () -> A): A = firstOrNull() ?: default()
fun example() {
emptyList<Int?>().firstOrElse { -1 } shouldBe -1
listOf(1, null, 3).firstOrElse { -1 } shouldBe 1
}
Exception in thread "main" java.lang.AssertionError: Expected null but actual was -1
Nested Nullabilty Problem
코틀린에서 Nested Nullability 문제는 Nullable Type(?)이 중첩되었을 때 발생하는 혼란스러운 상황이나 이를 처리하는 방법과 관련된 문제를 말한다. 예를 들어, 코틀린에서는 타입이 중첩된 경우 다음과 같은 구조가 가능하다. null을 사용하면 애매모호한 상황에 빠질 수 있다.
List<String>?: null일 수 있는 리스트
List<String?>: null일 수 있는 요소들을 가진 리스트
List<String?>?: 리스트 자체도 null일 수 있고, 리스트 내부의 요소들도 각각 null일 수 있음
val map = hashMapOf<String, Int?>("key1" to 1, "key2" to null)
val val1: Int? = map.get("key1")
val val2: Int? = map.get("key2")
val val3: Int? = map.get("key3")
println(val1 == null) // false
println(val2 == null) // true
println(val3 == null) // true
Option
Option<A>는 A라는 타입의 옵셔널 컨테이너이다. A 타입의 값이 존재한다면 Option<A>는 A타입의 값을 포함한 Some<A>의 인스턴스이다. 값이 존재하지 않는다면 Option<A>는 None 오브젝트이다. 생성자나 확장 함수를 통해서 생성할 수 있다.
val some: Some<String> = Some("I am wrapped in something")
val none: None = None
val optionA: Option<String> = "I am wrapped in something".some()
val optionB: Option<String> = none<String>()
fun example() {
some shouldBe optionA
none shouldBe optionB
}
하지만 아직도 많은 라이브러리들이 nullable 타입으로 결과를 리턴하기 때문에 nullable type으로 부터 생성하는 방법을 제공한다. fromNullable 함수를 사용해서 A? 타입을 Option으로 리프트 할 수 있다.
val some: Some<String> = Some("I am wrapped in something")
val none: None = None
val optionA: Option<String> = "I am wrapped in something".some()
val optionB: Option<String> = none<String>()
fun example() {
some shouldBe optionA
none shouldBe optionB
}
리프트와 명시적으로 생성하는 것에는 차이점이 있으니 주의하자.
fun example() {
val some: Option<String?> = Some(null)
val none: Option<String?> = Option.fromNullable(null)
some shouldBe null.some()
none shouldBe None
}
fun example() {
Some( "Found value").getOrElse { "No value" } shouldBe "Found value"
None.getOrElse { "No value" } shouldBe "No value"
}
Option도 대수적 타입이기 때문에 when절에서 패턴매치시 exhaustive 검사가 자동적으로 수행된다.
fun example() {
when(val value = 20.some()) {
is Some -> value.value shouldBe 20
None -> fail("$value should not be None")
}
when(val value = none<Int>()) {
is Some -> fail("$value should not be Some")
None -> value shouldBe None
}
}
isSome과 isNone으로 Option 값이 값을 가지고 있는지 확인할 수 있다.
fun example() {
Some(1).isSome() shouldBe true
none<Int>().isNone() shouldBe true
}
?.let { } ?: false 값은 복잡한 널처리 대신에 특정 서술부를 기술할 수도 있다.
fun example() {
Some(2).isSome { it % 2 == 0 } shouldBe true
Some(1).isSome { it % 2 == 0 } shouldBe false
none<Int>().isSome { it % 2 == 0 } shouldBe false
}
?.also { }or?.also { if(it != null) { } }. 대신에 값이 존재할때의 필요한 사이드 이펙트를 실행할 수도 있다.
fun example() {
Some(1).onSome { println("I am here: $it") }
none<Int>().onNone { println("I am here") }
none<Int>().onSome { println("I am not here: $it") }
Some(1).onNone { println("I am not here") }
}
결론
일반적으로 코틀린에서 nullable types을 사용하는 것을 선호할 것이다. 하지만, option이 더 이상적이다.
nested nullable issues나 null value를 지원하지 않는 Reactor나 RxJava와 함께 사용할 때도 그러하다.
예외를 던지는 것은 부수효과이다. 그로 인한 문제점은 제어 상실이다. 제어 상실은 예외가 처리되지 않아 프로그램이 중단되거나, 호출 스택위의 어떤 코드가 예외를 잡아서 예외를 처리하는 상황을 의미한다.
아래의 코드를 보면 치환 모델이 제공하는 단순한 추론을 벗어나서 참조투명성이 깨지는 것을 확인할 수 있다. 예외를 사용하면 프로그램 전체에서 전역적 추론을 해야되는 문제가 발생한다.
fun failingFn(i: Int): Int {
val y: Int = throw Exception("boom") // <1>
return try {
val x = 42 + 5
x + y
} catch (e: Exception) {
43 // <2>
}
}
fun failingFn2(i: Int): Int =
try {
val x = 42 + 5
x + (throw Exception("boom!")) as Int // <1>
} catch (e: Exception) {
43 // <2>
}
그리고 예외는 타입에 안전하지 않다. 고차함수 (Int) -> Int에서는 예외가 발생할 수 있다는 점을 언급할 수 없다. 그러므로 컴파일러도 호출하는 쪽에서 예외를 어떻게 처리할 지 강요받지 못한다.
타입 에러(Typed Errors)
코드가 실행될 때 발생할 수 있는 잠재적인 에러를 명시적인 타입으로 만드는 함수형 프로그래밍 기술을 의미한다. 컴파일러에 에러 처리를 강제하는 방식이다.
Logical Failure vs Real Exceptions
논리적 실패란 도메인적으로 성공은 아니며, 도메인의 영역안에 있는 상황을 의미한다. 예를 들어 UserRepository를 구현 했을 때 유저를 찾을수 없다면 특정 쿼리의 논리적 실패로 볼 수 있다.
예외란 도메인의 영역이 아닌 기술적인 실패 문제를 의미한다. 예를 들어 데이터베이스 연결이 끊어지거나, 네트워크 타임아웃이 발생하거나, 호스트가 unavailable 할 경우이다. 이런 처리는 Arrow resilience 메커니증에서 이점을 얻을 수 있다.
Sucess vs Failure
에러 처리시 success, failure를 구분해야 한다.
이러한 접근에 따라 함수의 시그니처는 실패 가능성 및 실패가 발행한 문제의 범위를 묘사할 수 있어야 한다.
Either
성공과 실패를 인코딩하는 대표적인 타입이다. 가장 기본적인 구현은 아래와 같다. Arrow KT에서도 Either를 제공한다.
sealed class Either<out E, out A>
data class Left<out E>(val value: E) : Either<E, Nothing>()
data class Right<out A>(val value: A) : Either<Nothing, A>()
성공/실패 정의
Right, Left로 성공과 실패를 정의할 수 있다.
object UserNotFound
data class User(val name: String, val age: Int)
val user: Either<UserNotFound, User> = Right(User("John", 33))
val error: Either<UserNotFound,User> = Left(UserNotFound)
결과 검사
Either는 대수적인 타입이므로 코틀린의 when 절로 논리적인 실패나 성공 케이스를 검사할 수 있다. fold나 getOrElse등 다양한 연산을 지원한다.
when (error) {
is Left -> error.value shouldBe UserNotFound
is Right -> fail("A logical failure occurred!")
}
error.fold(
ifLeft = { fail("User not found") },
ifRight = { println("User found: ${it.name}") }
)
error.getOrElse { fail("User not found") }
에러 처리
문제는 두가지 케이스로 분류할 수 있다.
Logical Failure는 일반적인 도메인 로직에서 도메인의 한 부분으로 처리되는 문제점. 예를 들어 유저를 찾았을 때 없다던지, 입력데이터가 유효하지 않다던지 하는 것이다.
Exceptions는 시스템이 동작을 이어가는데, 영향이 되는 문제. 예를 들어 도메인 로직 외부의 데이터베이스 연결이 끊어졌다는지 라는 것이다.
역사적으로, Exceptions에서 두가지 케이스를 다 처리하기도 했었다.(UserNotFoundException, UserNotValidException) 하지만 FP에서는 이러한 타입을 명확히 구분하는 것을 선호한다.
에러 집계
타입 에러을 사용하면 에러를 집계하는 경우에도 유용하다. 애로우에서는 DSL을 제공하여 아래와 같은 validation을 깔끔하게 처리할 수 있다.
sealed interface UserProblem {
data object EmptyName : UserProblem
data class NegativeAge(val age: Int) : UserProblem
}
data class User private constructor(val name: String, val age: Int) {
companion object {
operator fun invoke(name: String, age: Int): Either<NonEmptyList<UserProblem>, User> = either {
zipOrAccumulate(
{ ensure(name.isNotEmpty()) { UserProblem.EmptyName } },
{ ensure(age >= 0) { UserProblem.NegativeAge(age) }}
) { _, _ -> User(name, age)}
}
}
}
fun example() {
User("", -1) shouldBe Either.Left(nonEmptyListOf(UserProblem.EmptyName, UserProblem.NegativeAge(-1)))
}
정리
타입 에러를 사용하면 다음과 같은 장점이 있다.
Type Safety: 타입 에러는 컴파일러에게 타입 미스매치를 미리 알려줌으로써, 제품을 만들기 전에 버그를 더 쉽게 개선할 수 있도록 한다. 하지만 예외는 컴파일 타입에 에러를 검출하기 더 어렵다.
Predictability: 타입 에러를 사용하면, 함수의 타입 명세에 발생가능한 에러가 명시된다. 이는 발생할 에러 조건을 미리 이해하고, 모든 에러 시나리오에 대한 테스트를 작성하기 쉽다.
Composability: 타입 에러는 연속된 함수 호출에서 쉽게 합성되거나 전파될 수 있다. 이는 모듈화가 쉽고 쉽게 합성할 수 있다는 것을 의미한다. 예외를 사용하면, 복잡한 코드페이스에서 어떻게 전파가 되는지 알아차리기 힘들다. 특히 집계와 같은 작업은 타입 에러에서는 간단한데, 예외에서는 매우 어렵다.
Performance: 예외 처리는 성능에 영향을 준다. 특히 예외전용처리 스택이 따로 있지 않은 언어에서는 더욱 그러하다. 타입 에러는 가능한 에러 조건을 더 많은 정보를 컴파일단에서 처리할 수 있도록 함으로써 성능에서도 우위가 있다.
대수적 타입 즉 Algrebraic Data Type은 합타입과 곱타입으로 표현된 타입이다. 그렇다면 합타입과 곱타입이란 무엇인가?
합타입
대표적으로 enum class가 있다. 아래의 RGB 타입은 RED, GREEN, BLUE 3가지만 존재할 수 있다.
enum class RGB {
RED(),
GREEN(),
BLUE()
}
곱타입
대표적인 예로 class가 있다.
data class Person(val name: String, val age: Int, val email: String)
val lazysoul = Person("lazysoul", 33, "kotlin@gmail.com")
val goinhacker = Person("goinhacker", 36, "fp@gmail.com")
val myeongin = Person("myeongin", 33, "myeongin@gmail.comm")
Person객체는 String, Int, String 3개 타입의 프로퍼티로 구성되어있다. 즉 표현할 수 있는 값은 (String으로 표현할 수 있는 갯수) * (Int로 표현할 수 있는 갯수) * (String으로 표현할 수 있는 갯수)이다. 이런 타입을 곱타입이라고 한다.
장점
코클린에서 enum class와 sealed class가 대수적 타입이다. 합타입을 패턴매칭에서 모든 타입에 대한 구현을 컴파일러 레벨에서 강제할 수 있다. Arrow에서 제공하는 가장 기본적인 대수적 타입의 클래스는 Either이다.
sealed class Either<out E, out A>
data class Left<out E>(val value: E) : Either<E, Nothing>()
data class Right<out A>(val value: A) : Either<Nothing, A>()
만약 합타입과 곱타입으로 제한된 대수적 타입을 사용하지 않았다면, 항상 when절에서 미지의 상태를 위한 else 처리를 해야된다.
val actual = if (newDeposit.amount > 0)
Right(newDeposit.amount)
else
Left(DepositNotEnoughError)
when (actual) {
is Right -> TODO("Not implemented yet")
is Left -> TODO("Not implemented yet")
// no need else
}
불변 리스트/ 불변 트리
불변 리스트의 간단한 구현을 살펴봐도 ADT 형태로 되어 있다는 것을 알 수 있다.
아래 코드에서 List<out A>에서 A가 List의 공변적이라는 신호를 보내는 변성 어노테이션이다. 타입 X, Y에 대해 X가 Y의 하위 타입이면 List<X>도 List<Y>의 하위 타입이다. 구현에서 Nothing는 코틀린에서 모든 타입의 하위 타입이므로 Nil은 List<Int>로 간주 될 수 있고 List<Double>로 간주 될 수도 있다.
// sealed 키워드를 통해 패키지 안에서만 상속 가능하도록
sealed class List<out A>
// Empty를 표현
object Nil : List<Nothing>()
// 비어 있지 않는 리스트
data class Cons<out A>(val head: A, val tail: List<A>) : List<A>()
// 연결 함수
fun <A> append(a1: List<A>, a2: List<A>): List<A> =
when (a1) {
is Nil -> a2
is Cons -> Cons(a1.head, append(a1.tail, a2))
}
불변 트리를 생각해보더라도 핵심은 ADT이다.
sealed class Tree<out A>
data class Leaf<A>(val value: A) : Tree<A>()
data class Branch<A>(val left: Tree<A>, val right: Tree<A>) : Tree<A>()
정리
대수적 타입은 합타입과 곱타입으로 표현된 타입니다.
대수적 타입을 사용하면 패턴 매칭등을 통해 모든 경우에 대한 처리를 컴파일러 레벨에서 검사할 수 있다.