본문 바로가기

프로그래밍 기술 노트/Functional Study

[Functional Programming] 함수형 훑어보기

공용 Notion 에 정리

같이 공부하는 동아리 사람들에게 함수형에 대하여 알려주기 위하여 공용 Notion 에 정리한 글을 이왕 정리한 김에 블로그에도 같이 올리려고합니다.

친한 지인들께 설명하기 위한 글 + 알려주기 위한글 이므로 완전 초심자가 보기에도 전문가가 보기에도 부족할 내용이지만, 말 그대로 한번 훑어보면서 이러한것이 있구나 정도로 파악하시는데 쓰시면 좋을 듯 합니다.

또한, 무조건적으로 제가 쓴글이 옳은건은 아니지만, 설명하는 내용 특성상 단언하는듯이 적힌 내용이 있습니다.

 

함수형 패러다임이란 무엇인가?

"자, 빠르게 대답해보라. 함수형 프로그래밍이 무엇을 의미하는가? 틀렸다."

함수형의 시초는 존 메카시의 LISP 이며, 이는 알론소 처치의 람다계산법을 컴퓨터 언어로 만든것이다.

"세 번째 패러다임은 최근에 들어서야 겨우 도입되기 시작했지만, 세 패러다임 중 가장 먼저 만들어졌다. 사실 함수형 프로그래밍은 컴퓨터 프로그래밍 자체보다 먼저 등장했다" - Clean Archtecture 중

패러다임이란 절대적인 법칙이나 지켜야되는 룰이 아니고, 추상적인 레벨의 요소이다.

~~ 패러다임 언어라는것은 ~~패러다임 진형에서 자주 사용하는 Feature (또는 Tool) 을 제공해줄뿐이다.

그렇기 때문에 언어에 완전히 종속되는 요소가 아니다, 실제로 C++ 가 나오기 전에도 C 로  OOP 를 만들어서 사용했었다.

함수형 패러다임이 지향하는 바를 다음과 같다

  • 불변을 이용한 신뢰성 확보
  • 순수함수를 이용한 신뢰성 확보

즉 중요한것은 내가 SW 를 얼마나 "신뢰" 하느냐 이다.

함수형이라는것을 공부할때 나오는 수많은 Keyword 들, 고차함수, 꼬리 재귀, 커링, 레이지, 모나드, 패턴매칭, 타입시스템 등 이러한것은 단순히 언어에서 제공하는 Feature 에 불과하다. 물론 "불변을 강제하는것" 자체도 Feature 이다. 패러다임이라는것은 이러한 Feature (또는 Tool) 과 독립적인 요소이므로 이러한 Feature 자체와 함수형 패러다임을 과도하게 연결시킬 필요가 없다.

함수형또한 우리를 괴롭히기위하여나온 패러다임이 아니라 "신뢰성"을 확보해서 "안전한" 소프트웨를 만들기위한 추상적 레벨의 규약일 뿐이다.

 

 

불변

불변을 쓰는 이유

불변이라는 것은 한번 할당하면 변하지 않는다는 것이다. 변하지 않는 것이 "보장" 되면 이 데이터는 내가 "신뢰" 할 수 있다 "시간" , "실행흐름(쓰레드)" 등 거침에도 내가 변하지 않는것을 신뢰할수있기 때문에, 쓰레드 세이프하고, 안전한 프로그램이 가능하다.

좀더 알아보기 - ADT 와 Record

FP Feature 를 제공해주는 언어에서는 불변을 위한 기능을 제공한다. 일반적으로 OOP 에서는 Class (Struct) 를 통하여 다수의 데이터를 묶어 표현하는데, FP에서는 이러한 데이터 묶음을 Record 라고 표현한다.

Kotlin/C# 의 data class , Scala 의 case class , F#의 record , clojure 의 record (단 clojure 에서는 자료구조 map을 주로 사용함) 등이 Record 의 일종이다.

Record는 대표적인 곱 타입이다

ADT (대수적 데이터 타입) 은 다른 자료형의 값을 가지는 자료형이다.

Record, Class 등은 다른 데이터들을 여러개 (And) 가지고 있으므로 곱타입이라고 표현하고

Enum 과 같이 여러개중 하나를 택해서 가지고있는(Or) 타입은 합타입 이라고 표현한다.

순수함수와 참조 투명성

순수함수는 Input 로 인해서만 Output 이 정해지며, Side Effect 가 없는 함수를 말한다.

Input 으로 인해서만 Output 이 정해지므로, 같은 input 이면 같은 Output 을 보장한다.

즉 해당 함수는 내가 Input 만 주면 이 Input 에 대한 Output 을 예측 할 수 있고 신뢰 할 수 있다. (구현이 정확하다는 의미가 아닌, 다른 값이 나올 이유가 없다는것에 대한 신뢰)

  • 따라서 Testable 하다. 다른 부수작용이 끼칠 이유가 없다.
  • 따라서 Composable하다. A→B B→C C→D 라면, 해당 함수를 합성하면 A → D 가 나오는것은 당연하다.
  • 따라서 Memoziation 이 가능하다, Output 이 일정하므로 캐싱할수있다.

람다와 클로저(closure/폐쇠)

람다란?

람다란 이름없는 함수이다. 일반적으로 람다가 지원되는 언어는 함수가 1급객체이고, 파라미터로 넘길수있으므로, 람다함수를 매개변수로써 사용할 수 있다.

클로저란?

클로저라는것은 당시의 Status 를 Capture 하여 기억하고있는 함수다.

Class 에서 맴버를 이용하여 Status 를 기억하고 DI 를 받고 하는것처럼,

FP 에서는 파라미터 또는 외부 변수 Capture 를 통하여 Status 를 기억하고 DI 를 받을수있다.

커링

커링은 파라미터를 2개 이상받는 함수를, 1개 받고 함수를 리턴하는 함수의 결합으로 표현하는것이다.

FSharp, Haskell 등은 자동적으로 2개이상 받는 함수가 1개 받는 함수의 결합으로 표현된다.

커링이 되면 함수파라미터를 통한 DI 를 쉽게 할수있다.

위 의 WriteFunction 함수는 파라미터를 2개 받는 함수이다.

ConsoleWriter 는 WriterFunction에 첫번째 파라미터(writer)만 넘겨주었으므로, 파라미터 2개중 한개만(info) 남았다. 즉 파라미터를 1개 받는 함수가 된것이다.

재귀함수

함수가 자기 자신을 호출하면 재귀함수이다. FP 에서는 반복문보다 재귀함수를 선호한다.

재귀함수를 선호하는 이유는 다음과 같다.

  • 함수다.
  • 가변상태가 없다, 필요하다면 Input 으 로 넘기며, Input 으로 넘기는것은 순수함수의 법칙을 벗어나지 않는다.
  • 수학의 점화식을 그대로 따른다. → 수학적 정교성을 기반으로 두는 FP 에 맞다

꼬리재귀

재귀함수는 지속적으로 자기 자신을 호출하므로, Call Stack 에 함수콜이 지속적으로 쌓이게 된다. 이로인한 스택 오버플로우가 발생 할 수 있다.

FP 에서는 이러한 스택오버플로우를 방지하기위하여 꼬리 재귀를 사용한다.

꼬리 재귀는 간단히 말하면 값을 계산후, 값과 함께 다음 함수를 호출하게되어 스택에 쌓아둘 필요가 없다.

실제로 스택에 쌓아두지 않으면, 이를 꼬리재귀최적화(TCO)가 되어있다고 표현하며 이는 컴파일러의 지원이 필요하다.

JVM 은 TCO 가 되어있지 않으므로, 일종의 트릭을 이용하여 TCO 를 달성한다.

Scala 의 @TailRec 나 clojure 의 recur, F# 의 rec keyword 등이 이 와 같다.

해당 키워드를 이용하면, 내부적으로 반복문 (For/ While) 등으로 변환하여 동작하게 된다.

반복문으로 변경된다면 반복문을 쓰면 되는것이 아닌지? → 실제 동작자체는 중요한 요소가 아니다. 반복문은 가변상태가 존재하며, 프로그머가 신뢰할 수 없는 동작을 구현할수있다. 내부적으로 반복문을 이용한다고 해도, 이는 신뢰할 만한 내부 동작 메커니즘에 의해 동작하므로 추상적인 관점에서는 신경쓰지 않아도 된다.

상호재귀(트램폴린)

두 함수 A 와 B 가 존재한다고 하였을때,

A 가 B 를 호출하고, B 가 A를 호출하는 꼴을 상호재귀라고 한다.

상호재귀또한, 서로를 호출하기위한 호출스택이 쌓기고, 스택오버플로우가 발생할수있다.

상호재귀에서 스택오버플로우를 방지하기 위한 기술이 트램폴린 이다.

트램폴린을 사용하면, 사용하는쪽에서 꼬리재귀 최적화를 진행한다.

고차함수

고차함수란?

함수를 매개변수로 사용하거나, 함수 자체를 반환해주는 함수를 "고차함수" 라 칭한다.

고차 함수를 통하여, 함수를 생성하거나, 함수를 파라미터로 넘길수있으며, 함수자체를 일종의 컨트롤 할 수 있는 요소로 활용할 수 있다.

데이터 처리의 영원한 친구 Map Reduce Filter

연속적인 데이터 (aka 스트림) 을 처리하기 위해 자주 사용되는 고차함수가 Map/Reduce/Filter 이다.

FP 에서는 반복적인 행위에 대하여 반복문보다, 재귀함수를 사용하며, low-level 의 재귀함수를 순수 구현하는것보다, 재귀함수를 추상화한 고차함수를 사용한다.

Map

모든 요소에 특정 함수를 적용한 결과를 반환한다.

let square x = x*x

let input = [|1;2;3;4|]
let output = input |> Array.map square

printfn "%A" output // 1 4 9 16

 

Filter

특정 함수의 결과가 참인 요소만 반환한다.

let isEven x = if x % 2 = 0 then true else false

let input = [|1;2;3;4|]
let output = input |> Array.filter isEven

printfn "%A" output // 2 4

 

Reduce

모든 요소를 하나의 값으로 변환한다.

let sum x y = x + y

let input = [|1;2;3;4|]
let output = input |> Array.reduce sum

printfn "%A" output // 10

// 1 + 2 = 3, rest : 3,4
// (1 + 2) + 3  = 6, rest : 4
// ((1 + 2) + 3) + 4, rest: 없음
// result : 10

 

  • Reduce 또는 Fold 라고 불리며, 구현 및 명칭은 언어마다 다르다.
  • 초기값을 받는 경우도 있고 아닌경우도 있다.
  • Operator 를적용하는 방향에 따라 Right / Left 로 나뉘기도 한다.

느긋하게 살기 Lazy

느긋한 계산법(Lazy evaluation)은 계산의 결과값이 필요할 때까지 계산을 늦추는 기법이다. 결과를 선언과 함께 계산하면 조급한 계산법(Eager evaluation) 이라고 한다.

Why Lazy

  • 바로 계산하지 않고, 필요할 때에 계산하므로, 논리적으로 무한한 시퀀스를 만들수있다.
  • 수학적인 무한한 시퀀스를 만들고, 필요할때만 take 하는 전략을 취한다.

Lazy 사용시 주의

  • 사이드 이펙트가 있는 함수를 사용할때, 가변 데이터를 사용할때 주의해야한다. 의도하지 못한 동작이 나올수가 있다.
  • 무한한 시퀀스를 사용할때, 무한한 계산이 필요하면 프로그램이 멈춘다. (ex 무한한 시퀀스를 출력 or 무한한 시퀀스의 사이즈 계산)

문 과 식 (문과 이과 할때 문과 아님 ㅎㅎ)

한 구문을 문(statement)이라고 한다.

if "문" switch "문"

해당 문이 반환값(결과값)을 가지는 경우 식(expression) 이라고 한다.

함수형에서는 "식"을 더 선호한다.

식을 쓰는 이유는 다음과 같다.

  • 수학적이다 (수학적 수식을 표현할수있다) → 마치 함수를 쓰는것과 같다.
  • 문을 쓰게되면, 변수의 재할당이 필수불가결이다.

리턴값을 할필요가 없을때에도 아무것도 아님을 뜻하는 값(보통 Unit) 을 리턴하게 된다.

Unit 도 일종의 "값" 이므로 Void 와 달리 컨트롤할 수 있는 장점이 있다.

모던한 언어로 취급되는것들은, 내가 컨트롤할수있는것을 최대한 끌어올리고, 반대로 컨트롤하기 힘든것은 컴파일러/언어 에 맞기는 전략을 취한다.

그외 제공되는 Feature 들

구조분해

구조를 분해하는것. 예를 들어 튜플에서 (a,b) = ("key","value") 면 a와 b에 각각 "key", "value" 가 알아서 할당된다. 람다함수, 패턴매칭과 같이 사용하여 귀찮게 자료구조에서 데이터를 명시적으로 꺼내올 필요가 없이, 데이터를 사용할 수 가 있다.

패턴매칭

대상이 특정한 패턴을 가지고 있는가를 확인한다. 기존의 if문 타입 체크나 switch-case 문의 발전형식이다.

  • 언어별로 수많은 패턴 매칭 방법이 있는데, 지원되는 매칭을 다 알고있다는 가정하에 매칭하는 흐름이 깔끔해진다, 특히 재귀나 List 를 처리할 때 우아하고 심플한 코드로 표현할수있다.

 

728x90