함수형 프로그래밍이란? (with 코틀린 예제)
- Coding/Kotlin
- 2023. 11. 22.
반응형
728x90
반응형
명령어 스타일 (imperative style)
-
컴퓨터에게 정해진 명령 또는 지시를 하나하나 내림으로써 각 명령 단계마다 시스템의 상태를 바꾼다.
-
처음에는 단순화하려는 의도나, 시스템이 커질수록 복잡해지며, 그 결과 코드를 더이상 유지보수할 수 없게 되고, 테스트 하기 어려워지며 코드를 추론하는데에 어려워진다.
함수형 프로그래밍 (FP, Functional Programming)
-
위 명령어 스타일의 대안으로, '부수 효과'를 완전히 없애는 개념이다.
-
함수형 프로그래밍의 전제는, 순수 함수를 통해 프로그램을 구성한다는 것이다.
부수 효과 (Side Effect)
결과를 반환하는 것 외에 무언가 다른 일을 하는 함수는 부수 효과가 있는 함수다.
-
변경이 일어나는 블록 외부 영역에 있는 변수를 변경한다.
-
데이터 구조를 인플레이스로 변경한다. (즉, 메모리의 내용을 직접 변경한다.)
-
객체의 필드를 설정한다.
-
예외를 던지거나 예외를 발생시키면서 프로그램을 중단시킨다.
-
콘솔에 출력을 하거나 사용자 입력을 읽는다.
-
파일을 읽거나 쓴다.
-
화면에 무언가를 그린다.
함수형 프로그래밍(FP)의 장점 : 예제로 알아보기
1. 부수 효과가 있는 프로그램 (=순수하지 않은 프로그램)
class CreditCard {
fun charge(price: Float): Unit = println("charge")
}
data class Coffee(val price: Float = 2.50F)
class Cafe {
fun buyCoffee(cc: CreditCard): Coffee {
val cup = Coffee() // <1>
cc.charge(cup.price) // <2>
return cup // <3>
}
}
main()
fun main() {
val cafe = Cafe()
val card = CreditCard()
val cup = cafe.buyCoffee(card)
print(cup.price)
}
실행결과
charge
2.5
1) CreditCard 객체의 charge() 메서드를 호출한다. 이로써 부수 효과가 생긴다.
charge는 input은 있지만, return값이 없어서 어떤 수행을 하는지 알 수 없다.
cc.charge(cup.price) // <2>
-
신용카드를 청구하려면, 신용 카드사에 요청해야하므로 외부에서 부수적으로 벌어지는 일이다. 반환하는 객체는 단지 Coffee 객체다.
-
이 부수효과로 인해서, 테스트가 어려워진다. 실제 신용 카드사에 접속해서 요청하는 것은 원하지 않는다.
-
CreditCard는 신용카드사에 접속해 비용을 청구하는 방법을 알아서는 안된다.
-
CreditCard가 이런 관심사를 알지 못하게 하고, buyCoffee에 Payments 객체를 넘김으로써 이 코드를 좀더 모듈화하고 테스트성을 향상시킬 수 있다.
2. 테스트성 향상
data class Coffee(val price: Float = 2.95F)
class CreditCard {
}
class Payments {
fun charge(cc: CreditCard, price: Float): Unit = println("charge")
}
//tag::init2[]
class Cafe {
fun buyCoffee(cc: CreditCard, p: Payments): Coffee {
val cup = Coffee()
p.charge(cc, cup.price)
return cup
}
}
main()
fun main() {
val cafe = Cafe()
val card = CreditCard()
val payments = Payments()
val cup = cafe.buyCoffee(card, payments)
print(cup.price)
}
실행결과
charge
2.95
1) Payments를 인터페이스로 선언할 수 있고, 이 인터페이스에에 대해 테스트에 적합한 mock 객체를 구현할 수 있다.
@Test
fun chargeTest() {
...
val p: Payments = mock()
...
}
- 불필요하게 Payments를 인터페이스로 선언해야한다.
2) buyCoffee()를 재사용하기가 어렵다.
-
한 고객이 커피를 12잔 주문할 경우, for문으로 buyCoffee()를 호출할 것이다. 이런 식으로 호출하면 charge() 메서드가 12번 수행되어 신용카드사에 12번 연결해서 청구라는 행위를 수행하게 된다.
-
위 문제의 처리 방안으로, buyCoffess()라는 새로운 함수를 작성해서 한꺼번에 청구하는 로직을 넣을 수 있다.
3. 함수형 해법
부수 효과를 제거하고 buyCoffee가 Coffee와 함께 청구할 금액을 반환하게하자.
class CreditCard
data class Coffee(val price: Float = 2.50F)
data class Charge(val cc: CreditCard, val amount: Float)
//tag::init3[]
class Cafe {
fun buyCoffee(cc: CreditCard): Pair<Coffee, Charge> {
val cup = Coffee()
return Pair(cup, Charge(cc, cup.price)) // 어떤 금액 청구를 만드는 관심사(Coffee), 청구를 처리하거나 해석하는 관심사(Charge)
}
}
main()
fun main() {
val cafe = Cafe()
val card = CreditCard()
val pair = cafe.buyCoffee(card)
println(pair.first)
println(pair.second)
}
실행결과
Coffee(price=2.5)
Charge(cc=chapter1.sec1.CreditCard@531be3c5, amount=2.5)
1) 두 관심사로 분리했다.
이제 Coffee와 신용카드 청구건인 Charge를 Pair로 함께 리턴한다.
return Pair(cup, Charge(cc, cup.price))
-
어떤 금액 청구를 만드는 관심사 = Coffee
-
청구를 처리하거나 해석하는 관심사 = Charge
2) Charge()
-
CreditCard와 amount를 포함한다.
-
같은 CreditCard에 대한 청구를 하나로 묶어줄때 편리하게 쓸 수 있는 combine 함수를 제공한다.
4. combine() 함수 구현
class CreditCard
//tag::init4[]
data class Charge(val cc: CreditCard, val amount: Float) { // <1> 생성자와 불변 필드가 있는 데이터 클래스 선언
fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
else throw Exception(
"Cannot combine charges to different cards"
)
}
main()
fun main() {
val card = CreditCard()
val charge1 = Charge(card, 2.5F)
val charge2 = Charge(card, 7.5F)
val getCharge = charge1.combine(charge2)
println(getCharge.cc)
println(getCharge.amount)
}
실행결과
chapter1.sec1.CreditCard@617c74e5
10.0
5. buyCoffees 생성
이제는 우리 바람대로 buyCoffee를 바탕으로 이 함수를 구현할 수 있다.
class CreditCard
data class Coffee(val price: Float = 2.50F)
data class Charge(val cc: CreditCard, val amount: Float) {
fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
else throw Exception(
"Cannot combine charges to different cards"
)
}
//tag::init5[]
class Cafe {
fun buyCoffee(cc: CreditCard): Pair<Coffee, Charge> {
val cup = Coffee()
return Pair(cup, Charge(cc, cup.price)) // 어떤 금액 청구를 만드는 관심사(Coffee), 청구를 처리하거나 해석하는 관심사(Charge)
}
fun buyCoffees(
cc: CreditCard,
n: Int // 구매할 커피잔 수
): Pair<List<Coffee>, Charge> {
val purchases: List<Pair<Coffee, Charge>> =
List(n) { buyCoffee(cc) } // <1> 자체적으로 초기화되는 리스트를 생성한다.
val (coffees, charges) = purchases.unzip() // <2> Pair의 리스트를 두 리스트로 분리한다. List<Coffee>, List<Charge>
return Pair(
coffees,
charges.reduce { c1, c2 -> c1.combine(c2) }
) // <3> coffees를 한 Charge로 합친 출력을 생성한다.
}
}
-
이제는 buyCoffees 함수를 정의할때 직접 buyCoffee를 재사용할 수 있다.
-
Payments 인터페이스에 대한 복잡한 mock 구현을 정의하지 않아도 이 두 함수를 아주 쉽게 테스트할 수 있다.
-
Cafe 객체는 이제 Charge 값이 어떻게 처리되는지와는 무관하다.
main()
fun main() {
val card = CreditCard()
val cafe = Cafe()
val pair = cafe.buyCoffees(card, 10)
println(pair.first)
println(pair.second)
}
실행결과
[Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5)]
Charge(cc=chapter1.sec1.CreditCard@4590c9c3, amount=25.0)
디버깅으로 다시한번 분석하자.
1) 리스트를 생성한다.
n이 10이므로, 아래와 같이 List의 size가 10이다.
2) unzip() 메서드로 각 coffees, charges 리스트로 나눈다.
▶ coffees
▶ charges
3) Pair 객체를 리턴한다.
6. 같은 카드에 청구하는 금액을 모두 합치기
class CreditCard
data class Coffee(val price: Float = 2.50F)
data class Charge(val cc: CreditCard, val amount: Float) {
fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
else throw Exception(
"Cannot combine charges to different cards"
)
}
fun List<Charge>.coalesce(): List<Charge> =
this.groupBy { it.cc }.values
.map { it.reduce { a, b -> a.combine(b) } }
- 청구 금액의 리스트를 취해서 사용한 신용카드에 따라 그룹으로 나누고, 각 그룹의 청구 금액을 하나로 합쳐서 카드마다 하나씩 청구로 만들어낸다.
main()
fun main() {
val card = CreditCard()
val card2 = CreditCard()
val charge1 = Charge(card, 2.5F)
val charge2 = Charge(card, 7.5F)
val charge3 = Charge(card2, 5.5F)
var charges = listOf(charge1, charge2, charge3)
val coalesce = charges.coalesce()
println(coalesce)
}
실행결과
[Charge(cc=chapter1.sec1.CreditCard@32e6e9c3, amount=10.0)
, Charge(cc=chapter1.sec1.CreditCard@5056dfcb, amount=5.5)]
순수 함수란?
-
어떤 함수가 주어진 입력으로부터 결과를 계산하는 것 외에 다른 어떤 관찰 가능한 효과가 없다 -> "부수효과가 없다"
-
"부수 효과가 없는 함수" -> "순수 함수"
- ex) String의 length 함수 : 주어진 문자열에 대해 항상 같은 길이가 반환되며, 다른 일은 발생하지 않는다.
- 참조 투명성(RT, Referential Transparency)이라는 개념을 사용해 형식화할 수 있다.
- 예시로 이해하자. 2 + 3은 순수함수 plus(2, 3)에 적용하는 식이다. 이 식에는 아무 부수효과가 없다.
-
결과는 언제나 5다.
-
실제로 프로그램에서 2 + 3을 볼때마다 이 식을 5로 치환할 수 있다. 이렇게 해도 프로그램의 의미가 전혀 바뀌지 않는다.
-
이는 어떤 식이 참조 투명하다는 말이 지닌 뜻의 전부다.
-
- 어떤 프로그램에서 프로그램의 의미를 변경하지 않으면서 식을 그 결괏값으로 치환할 수 있다면, 이 식은 참조 투명하다.
- 어떤 함수를 참조 투명한 인자를 사용해 호출한 결과가 참조 투명하다면 이 함수도 참조 투명하다.
- 예시로 이해하자. 2 + 3은 순수함수 plus(2, 3)에 적용하는 식이다. 이 식에는 아무 부수효과가 없다.
순수 함수를 만족하지 못하는 예제
class CreditCard {
fun charge(price: Float): Unit = TODO()
}
data class Coffee(val price: Float = 2.50F)
//tag::init[]
fun buyCoffee(cc: CreditCard): Coffee {
val cup = Coffee()
cc.charge(cup.price)
return cup
}
-
buyCoffee()는 cc.charge(cup.price)의 반환 타입과 관계없이 이 함수 호출의 반환값을 무시한다.
-
따라서 buyCoffee()를 평가한 결과는 그냥 cup이고, 이 값은 Coffee()와 동일하다.
- 순수 함수가 되기 위해서는 p에 관계없이 p(buyCoffee(aliceCreditCard)), p(Coffee())가 똑같이 작동해야한다.
-
성립되지 않는다.
-
p(buyCoffee(aliceCreditCard)) : 카드사를 통해 커피 값을 청구한다.
-
p(Coffee()) : 아무일도 하지 않는다.
-
- 순수 함수가 되기 위해서는 p에 관계없이 p(buyCoffee(aliceCreditCard)), p(Coffee())가 똑같이 작동해야한다.
참조 투명성 예제
>>> val x = "Hello, World"
>>> val r1 = x.reversed()
>>> val r2 = x.reversed()
x가 등장하는 부분을 x가 가리키는 식으로 바꿔치기하자.
>>> val r1 = "Hello, World".reversed()
>>> val r2 = "Hello, World".reversed()
-
위 r1, r2가 같은 값으로 평가된다.
-
x가 참조 투명하기 때문에 r1, r2 값은 예전과 같다. -> r1, r2도 참조 투명하다.
참조 투명성을 위배하는 예제
>>> val x = StringBuilder("Hello")
>>> val y = x.append(", World")
>>> val r1 = y.toString()
>>> val r2 = y.toString()
-
append() 함수 : StringBuilder에 작용하며 객체 내부를 변화시킨다. append()가 호출될 때마다 StringBuilder의 이전 상태가 파괴된다.
-
StringBuilder에 대해 toString()을 여러번 호출해도 항상 똑같은 결과를 얻는다.
>>> val x = StringBuilder("Hello")
>>> val r1 = x.append(", World").toString()
>>> val r2 = x.append(", World").toString()
- y를 모두 append() 호출로 치환했다. -> 순수 함수가 아니라고 결론을 내릴 수 있다.
-
StringBuilder에 대해 toString()을 여러번 호출해도 결코 같은 결과가 생기지 않는다.
-
r1, r2는 같은 식처럼 보이지만, 실제로는 같은 StringBuilder 객체의 다른 두 값을 가르킨다.
반응형
'Coding > Kotlin' 카테고리의 다른 글
[Kotlin 기초문법] 총정리 (2) | 2022.10.29 |
---|---|
[Kotlin in Action] 26. 수신 객체 지정 람다 (with, apply) (0) | 2022.06.19 |
[Kotlin in Action] 25. SAM 생성자 (0) | 2022.06.19 |
[Kotlin in Action] 24. 지연 계산(lazy) 컬렉션 연산 - 시퀀스(sequence) 사용 (0) | 2022.06.13 |
[Kotlin in Action] 23. 컬렉션 함수형 API (filter, map, all, any, count, find, groupBy, flatMap, flatten) (0) | 2022.06.01 |