함수형 프로그래밍이란?
#
명령어 스타일 (imperative style)
#
- 컴퓨터에게 정해진 명령 또는 지시를 하나하나 내림으로써 각 명령 단계마다 시스템의 상태를 바꾼다.
- 처음에는 단순화하려는 의도나, 시스템이 커질수록 복잡해지며, 그 결과 코드를 더이상 유지보수할 수 없게 되고, 테스트 하기 어려워지며 코드를 추론하는데에 어려워진다.
함수형 프로그래밍 (FP, Functional Programming)
#
- 위 명령어 스타일의 대안으로, ‘부수 효과’를 완전히 없애는 개념이다.
- 함수형 프로그래밍의 전제는, 순수 함수를 통해 프로그램을 구성한다는 것이다.
- 부수 효과란?
- 결과를 반환하는 것 외에 무언가 다른 일을 하는 함수는 부수 효과가 있는 함수다.
- 변경이 일어나는 블록 외부 영역에 있는 변수를 변경한다.
- 데이터 구조를 인플레이스로 변경한다. (즉, 메모리의 내용을 직접 변경한다.)
- 객체의 필드를 설정한다.
- 예외를 던지거나 예외를 발생시키면서 프로그램을 중단시킨다.
- 콘솔에 출력을 하거나 사용자 입력을 읽는다.
- 파일을 읽거나 쓴다.
- 화면에 무언가를 그린다.
함수형 프로그래밍(FP)의 장점 : 예제로 알아보기
#
val listing1 = {
class CreditCard {
fun charge(price: Float): Unit = TODO()
}
data class Coffee(val price: Float = 2.50F)
//tag::init1[]
class Cafe {
fun buyCoffee(cc: CreditCard): Coffee {
val cup = Coffee() // <1>
cc.charge(cup.price) // <2>
return cup // <3>
}
}
- CreditCard 객체의 charge() 메서드를 호출한다. 이로써 부수 효과가 생긴다.
- 신용카드를 청구하려면, 신용 카드사에 요청해야하므로 외부에서 부수적으로 벌어지는 일이다. 반환하는 객체는 단지 Coffee 객체다.
- 이 부수효과로 인해서, 테스트가 어려워진다. 실제 신용 카드사에 접속해서 요청하는 것은 원하지 않는다.
- CreditCard는 신용카드사에 접속해 비용을 청구하는 방법을 알아서는 안된다.
- CreditCard가 이런 관심사를 알지 못하게 하고, buyCoffee에 Payments 객체를 넘김으로써 이 코드를 좀더 모듈화하고 테스트성을 향상시킬 수 있다.
val listing2 = {
data class Coffee(val price: Float = 2.95F)
class CreditCard
class Payments {
fun charge(cc: CreditCard, price: Float): Unit = TODO()
}
//tag::init2[]
class Cafe {
fun buyCoffee(cc: CreditCard, p: Payments): Coffee {
val cup = Coffee()
p.charge(cc, cup.price)
return cup
}
}
//end::init2[]
}
- Payments를 인터페이스로 선언할 수 있고, 이 인터페이스에에 대해 테스트에 적합한 mock 객체를 구현할 수 있다.
- 불필요하게 Payments를 인터페이스로 선언해야한다.
- buyCoffee()를 재사용하기가 어렵다.
- 한 고객이 커피를 12잔 주문할 경우, for문으로 buyCoffee()를 호출할 것이다. 이런 식으로 호출하면 charge() 메서드가 12번 수행되어 신용카드사에 12번 연결해서 청구라는 행위를 수행하게 된다.
- 위 문제의 처리 방안으로, buyCoffess()라는 새로운 함수를 작성해서 한꺼번에 청구하는 로직을 넣을 수 있다.
함수형 해법
#
- 부수 효과를 제거하고 buyCoffee가 Coffee와 함께 청구할 금액을 반환하게하자.
val listing3 = {
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)
}
}
}
- 두 관심사로 분리했다.
- 어떤 금액 청구를 만드는 관심사 = Coffee
- 청구를 처리하거나 해석하는 관심사 = Charge
- Charge
- CreditCard와 amount를 포함한다.
- 같은 CreditCard에 대한 청구를 하나로 묶어줄때 편리하게 쓸 수 있는 combine 함수를 제공한다.
val listing4 = {
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"
)
}
}
buyCoffees 생성
#
- 이제는 우리 바람대로 buyCoffee를 바탕으로 이 함수를 구현할 수 있다.
val listing5 = {
class CreditCard
data class Coffee(val price: Float = 2.50F)
data class Charge(val cc: CreditCard, val amount: Float) {
fun combine(other: Charge): Charge = TODO()
}
//tag::init5[]
class Cafe {
fun buyCoffee(cc: CreditCard): Pair<Coffee, Charge> = TODO()
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 값이 어떻게 처리되는지와는 무관하다.
같은 카드에 청구하는 금액을 모두 합치기
#
val listing6 = {
class CreditCard
data class Charge(val cc: CreditCard, val amount: Float) {
fun combine(other: Charge): Charge = TODO()
}
//tag::init6[]
fun List<Charge>.coalesce(): List<Charge> =
this.groupBy { it.cc }.values
.map { it.reduce { a, b -> a.combine(b) } }
}
- 청구 금액의 리스트를 취해서 사용한 신용카드에 따라 그룹으로 나누고, 각 그룹의 청구 금액을 하나로 합쳐서 카드마다 하나씩 청구로 만들어낸다.
순수 함수란?
#
- 어떤 함수가 주어진 입력으로부터 결과를 계산하는 것 외에 다른 어떤 관찰 가능한 효과가 없다 -> “부수효과가 없다”
- “부수 효과가 없는 함수” -> “순수 함수”
- ex) String의 length 함수 : 주어진 문자열에 대해 항상 같은 길이가 반환되며, 다른 일은 발생하지 않는다.
- 참조 투명성(RT, Referential Transparency)이라는 개념을 사용해 형식화할 수 있다.
- 예시로 이해하자. 2 + 3은 순수함수 plus(2, 3)에 적용하는 식이다. 이 식에는 아무 부수효과가 없다.
- 결과는 언제나 5다.
- 실제로 프로그램에서 2 + 3을 볼때마다 이 식을 5로 치환할 수 있다. 이렇게 해도 프로그램의 의미가 전혀 바뀌지 않는다.
- 이는 어떤 식이 참조 투명하다는 말이 지닌 뜻의 전부다.
- 어떤 프로그램에서 프로그램의 의미를 변경하지 않으면서 식을 그 결괏값으로 치환할 수 있다면, 이 식은 참조 투명하다.
- 어떤 함수를 참조 투명한 인자를 사용해 호출한 결과가 참조 투명하다면 이 함수도 참조 투명하다.
참조 투명성 예제
#
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()) : 아무일도 하지 않는다.
참조 투명성 예제(2)
#
>>> 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 객체의 다른 두 값을 가르킨다.