DEV Community

CloudHolic
CloudHolic

Posted on

F# Tutorial (5) - Function

이번 글에선 F#의 함수에 대해 알아보겠습니다.

함수의 type

다음의 간단한 함수를 봅시다.

let f x = x + 1
Enter fullscreen mode Exit fullscreen mode

이 함수의 이름은 f이고, 인자를 x란 이름으로 1개 받아 거기에 +1한 값을 리턴합니다. x의 type은 명시되지 않았지만 + 1을 통해 xint임을 알 수 있습니다. 따라서 이 함수는 int를 1개 받아 int를 리턴하는 함수이며 f의 type은 int -> int로 정의할 수 있습니다. 즉, F#에서는 (인자의 type) -> (리턴 type)의 형태로 함수의 type을 결정하게 됩니다.

함수 값

F#에서는 함수를 정의할 때 값을 정의할 때와 마찬가지로 let을 사용합니다.

let a = 1
let f x = x + 1
Enter fullscreen mode Exit fullscreen mode

저번에 이에 대해서 언급했을 때는 a 역시 인자가 0개고 1을 리턴하는 함수로 생각하는게 이해하기 편할 것이라고 했습니다. 하지만 이는 이해를 돕기 위한 서술일 뿐 엄밀히 말해서 틀린 표현입니다. F#에서는 모든 함수를 값으로 취급합니다. 즉, a1로 정의된 값이고, fx -> x + 1로 정의된 값입니다.
이 말은 곧 타 언어에서의 변수와 함수의 구분이 없다는 의미입니다. 타 언어에서 함수 내에 변수를 선언하거나 함수의 인자로 변수를 받듯이 F#에서는 함수 내에 함수를 선언하거나 함수를 인자로 받는 것이 자연스럽다는 의미입니다. 애초에 둘은 동일한 것이니까요.

let result x =
    let function (a, b) = a + b
    100 * function (x, 2)    

let circleArea x =
    let pi = 3.1415
    pi * x * x

let apply1 (transform: int -> int) y = transform y
let increment x = x + 1
let result2 = apply1 increment 10           // 11
Enter fullscreen mode Exit fullscreen mode

함수의 리턴값은 가장 마지막에 실행된 식의 값으로 결정됩니다. 물론 return 키워드를 사용해 명시적으로 지정해도 상관없습니다.
저번에도 언급했듯, 둘 이상의 인자가 있으면 괄호 없이 순서대로 나열합니다.
여담으로 위의 코드에서 circleArea 함수의 경우 3.1415를 곱하는 연산이 포함되었기 때문에 컴파일러는 이 함수의 리턴값을 float으로 판단합니다. 물론 인자에는 여전히 정수가 들어갈 수 있죠.

Lambda 함수

이제는 굉장히 흔한 개념이 되어버린 익명 함수입니다. 말 그대로 이름이 정해지지 않은 함수로 F#에서는 fun 키워드를 사용하여 나타냅니다.

fun x -> x + 1
fun (a:int) (b:int) -> a * b
fun x y -> let swap (a, b) = (b, a) in swap (x, y)
Enter fullscreen mode Exit fullscreen mode

물론 익명 함수라고 해서 다른 함수를 정의하지 말라는 법은 없죠. 위의 코드에서처럼 얼마든지 다른 함수를 정의해서 사용해도 됩니다.
마지막 익명 함수의 in 키워드는 let 바인딩이 유효한 구간을 명시적으로 지정할 때 쓰입니다.

이러한 익명 함수는 물론 다른 함수의 인자 혹은 리턴값으로 사용될 수 있습니다.

let list = List.map (fun i -> i + 1) [1; 2; 3]  // [2; 3; 4]
Enter fullscreen mode Exit fullscreen mode

map에 대한 건 이 글의 뒷부분에 나옵니다. 이런 방식으로 익명 함수를 사용할 수 있다는 것을 알아두시면 되겠습니다.

재귀 함수

재귀 함수라는 개념 자체는 딱히 F#을 비롯한 FP언어만의 전유물은 아닙니다. F#이라고 해서 그 의미가 달라지지도 않고요. 하지만 F#에는 재귀 함수에 대한 특별한 룰이 한 가지 존재합니다. 함수를 정의할 때 let을 썼었는데, 재귀 함수의 경우 rec 키워드를 let 다음에 이어서 써야 합니다.

let rec fib n = 
    match n with
    | 1 -> 1
    | 2 -> 1
    | _ -> fib (n - 1) + fib (n - 2)
Enter fullscreen mode Exit fullscreen mode

(match|에 대해서는 나중에 다룹니다.)
매우 간단하게 구현한 피보나치 수열을 계산하는 함수입니다.

Haskell의 경우 재귀 함수라고 해서 특별한 키워드를 붙이진 않습니다. 하지만 F#에서는 반드시 rec 키워드가 필요한데요, 이는 내부적인 구현의 차이 때문입니다.
Haskell에서는 함수를 메모리에 저장할 때 실질적인 계산은 전혀 수행하지 않습니다. 그냥 하나의 'thunk'를 메모리에 할당하며 이 thunk의 계산이 실제로 필요할 때 그제서야 수행하게 돼죠. 이러한 구현 방식에선 이 thunk가 다른 thunk를 가리키든, 자기 자신을 가리키든 아무런 문제가 되지 않습니다.
하지만 F#에서는 다릅니다. F#에서는 함수를 메모리에 저장할 때 이미 계산을 수행하며, 따라서 그 함수는 내부적인 로직이 전부 정해져 있어야 합니다. 만일 함수 내에서 자기 자신을 다시 호출한다면 이는 미완성된 로직이며 무한루프가 발생할 수 있습니다. 그 때문에 일반 함수와 재귀 함수를 반드시 구분해야 하고, 이를 위해 rec이라는 키워드가 필요합니다.
어떻게 보면 Haskell과 F#의 철학의 차이를 엿볼 수 있는 부분이 되겠습니다.

Currying

위에서 함수의 type을 얘기할 때 인자가 1개인 함수에 대해서만 다뤘습니다. 인자가 2개 이상인 함수에 대해서 다루기 위해서는 currying 이라는 개념을 알아야 합니다.
다음의 함수를 생각해 봅시다.

let add (a:int) (b:int) = a + b
Enter fullscreen mode Exit fullscreen mode

위에서 정의한 add 함수는 int 2개를 받아서 그걸 더한 결과를 리턴합니다.
그러면 add 5와 같이 인자를 불완전하게 제공하면 이건 뭐가 될까요?

add 5add에서 첫 번째 인자만 제공한 형태이므로 제대로 계산하기 위해서는 인자가 1개 더 필요하고, 이 두 번째 인자에 5를 더한 값을 리턴할 것입니다. 바꿔 말해 add 5int형 인자를 1개 받아 거기에 5를 더한 값을 리턴하는 함수라는 이야기고, 따라서 다음과 같은 코드가 성립할 수 있습니다.

let add5 = add 5
let result = add5 1     // 6
Enter fullscreen mode Exit fullscreen mode

아예 add 5를 별도의 함수로 지정해도 아무 문제가 없습니다. 이처럼 F#에서는 어떤 함수가 받은 인자가 필요한 인자보다 부족하다면, 이것을 나머지 인자를 요구하는 새 함수로 취급합니다. 이러한 개념을 currying이라고 부르며, F#은 Haskell과 함께 FP 언어 중에서도 currying 개념을 잘 지원하는 언어 중 하나입니다.

그럼 currying을 염두에 두고 add 함수를 다시 살펴봅시다.

let add (a:int) (b:int) = a + b
Enter fullscreen mode Exit fullscreen mode

add 함수에 대해 이번엔 다르게 해석해 보죠. 먼저 add a 함수는 int를 1개 받아서 그 값과 a를 더한 값을 리턴합니다.
그러면 add 함수는 int를 1개 받아서 add a 함수를 리턴한다고 볼 수 있습니다.
add함수는 int를 1개 받아서 int -> int 함수를 리턴하는 함수이므로 add의 type은 int -> (int -> int)라고 볼 수 있습니다.
F#은 currying 지원이 잘 되어있기 때문에 결국 괄호를 생략해도 무방하며 int -> int -> int로 표기할 수 있습니다.

즉, F#에서는 인자가 여러 개 있는 함수라면 자동적으로 currying이 된 것으로 취급하며 따라서 중간에서 얼마든지 끊어서 쓸 수 있다는 이야기입니다. 그리고 그러한 함수의 type은 (인자1의 type) -> (인자2의 type) -> ... -> (리턴 type)의 형태가 되겠죠.

만일 특별한 이유가 있어 currying을 막고 싶다면 어떻게 해야 할까요? F#에서는 그럴 경우 tuple을 사용하라고 합니다.

let add1 a b = a + b
let add2 (a, b) = a + b
Enter fullscreen mode Exit fullscreen mode

이 두 함수는 결과적으로는 같은 일을 하지만 세부적으론 좀 다릅니다. add1의 type은 int -> int -> int이고, add2의 type은 int * int -> int입니다. 또한 add1은 currying이 가능하여 add1 1과 같은 식으로 쓸 수 있지만, add2는 currying이 불가능하여 이런 식으로 나눌 수 없습니다.

inline 함수

F#에도 inline 함수가 있으며 함수 선언에 inline 키워드를 붙여서 만듭니다.

let inline increment x = x + 1
Enter fullscreen mode Exit fullscreen mode

여기까지만 보면 타 언어에서도 흔히 볼 수 있는 inline 함수지만, FP 언어로써의 특성과 F#의 type inference로 인해 다소 특이한 사용법이 존재합니다.

let add1 a b = a + b
let inline add2 a b = a + b
Enter fullscreen mode Exit fullscreen mode

다시 한번 add 함수를 가져오겠습니다. add1 함수의 경우 두 인자 ab의 type이 정해져있지 않지만 F#의 type inference로 인해 적절한 type으로 고정되게 됩니다.

let result1 = add1 1 2          // 3
let result2 = add1 "dog" "cat"      // error!
Enter fullscreen mode Exit fullscreen mode

즉, result1을 실행하면서 F# compiler는 add1int -> int -> int 함수라는 것을 알게 되고, 따라서 result2를 실행할 땐 주어진 인자가 int가 아니므로 에러가 발생하게 됩니다.

let result3 = add2 1 2          // 3
let result4 = add2 "dog" "cat"      // "dogcat"
Enter fullscreen mode Exit fullscreen mode

inline 함수는 좀 달라집니다. result3을 실행하면서 F# compiler는 add2의 type을 고려하지 않고 본문을 그대로 옮겨놓으며, 따라서 여기서의 add2int -> int -> int가 됩니다. result 4를 실행할 때에도 역시 본문을 그대로 옮겨놓으며, 따라서 여기서의 add2string -> string -> string이 됩니다. 즉, 어떠한 type을 가져다놔도 add2 함수 자체에서 에러가 나는 것이 아닌 이상 문제가 없습니다.

이를 통해 동일한 표현식의, 서로 다른 type에서 적용되어야 할 함수를 inline 키워드를 사용하여 마치 오버로딩과 유사하게 흉내낼 수 있습니다.

iter, map, filter, fold

이번엔 저번에 미처 다루지 못한 나머지 collection 내장 함수들 중 일부를 다루겠습니다.
여기 적힌 함수들은 foldBack을 제외하면 전부 list, 배열, sequence에서 사용할 수 있으며, 각각의 collection에서 특정 조건에 따라 함수를 사용할 수 있게 해주는 내장 함수입니다.

1) iter
iter는 collection의 각각의 원소들에 대해 주어진 함수를 실행할 수 있게 해줍니다.

let list1 = [1; 2; 3]
List.iter (fun x -> printf "%d " x) list1
List.iteri (fun i x -> printfn "Index %d: %d " i x) list1
Enter fullscreen mode Exit fullscreen mode

위의 코드에서도 알 수 있듯이 collection의 각각의 원소에 대해 같이 인자로 넘겨주는 함수를 실행시킬 뿐 그 리턴값은 전혀 신경쓰지 않습니다. iter는 각 원소를 넘겨주며, 만일 index도 같이 필요하다면 iteri를 사용하시면 됩니다. 이 경우 index, 원소 순서대로 인자를 넘겨주게 됩니다.

만일 2개의 collection에 대해 사용하고자 한다면 iter2, iteri2 함수를 사용하면 됩니다.

2) map
map은 collection의 각각의 원소들에 대해 주어진 함수를 실행하여, 그 결과를 모아 새 collection으로 리턴해줍니다.

let list1 = [1; 2; 3]
let result1 = List.map (fun x -> x + 1) list1       // [2; 3; 4]
let result2 = List.mapi (fun i x -> x + i) list1    // [1; 3; 5]
Enter fullscreen mode Exit fullscreen mode

iter와의 차이점을 눈치채셨나요? iter는 리턴값 없이 실행만 할 뿐이지만, map은 구체적인 리턴값이 존재해야 합니다. 그래야 그 결과를 모아 collection으로 만들어 줄 수 있거든요. iteriteri의 관계와 마찬가지로, index가 필요하다면 mapi를 사용하면 됩니다.

또한 iter와 마찬가지로 map2, mapi2, map3, mapi3 등 여러 개의 collection에 대한 함수들도 존재합니다.

3) filter
filter는 collection의 각각의 원소들에 대해 주어진 함수를 실행하여, 그 중 true가 나오는 것들만 모아 새 collection으로 리턴해줍니다.

let list1 = [1; 2; 3]
let evenList = List.filter (fun x -> x % 2 = 0) list1   // [2]
Enter fullscreen mode Exit fullscreen mode

filter를 사용할 때 같이 제공되어야 할 함수는 반드시 bool을 리턴해야 합니다.

또한 mapfilter를 합친 느낌의 choose라는 내장 함수도 존재합니다. choose는 collection의 각각의 원소들에 대해 주어진 함수를 실행하여, 그 중 Some(x)가 나오는 것들의 x값만 모아 새 collection으로 리턴해줍니다.

4) fold
여태까지 살펴본 내장 함수들은 전부 각 원소들에 대해 독립적으로 수행했습니다. 하지만 때로는 이전 원소까지의 결과를 누적해서 적용해야 하는 경우도 있죠. 그럴 때 fold를 사용합니다.
fold는 인자로 넘겨줄 함수가 원소값을 받을 인자 외에 'accumulator'라는 인자를 하나 더 가져야 합니다. 이 accumulator는 그 이전 원소까지의 실행 결과를 담게 되죠.

let sumList list = List.fold (fun acc x -> acc + x) 0 list
let result1 = sumList [1; 2; 3; 4; 5]       // 15

let reverseList list = List.fold (fun acc x -> x::acc) [] list
let result2 = reverseList [1; 2; 3; 4; 5]   // [5; 4; 3; 2; 1]
Enter fullscreen mode Exit fullscreen mode

위의 코드에서 보시다시피 accumulator에는 무엇이든 올 수 있으며, 그 type은 fold의 두 번째 인자인 '초기 accumulator 값'으로 정해집니다.

fold에도 파생된 다른 함수들이 존재합니다. 먼저, collection을 역순으로 탐색하는 foldBack이 있으며, 2개의 collection에 대해 동일한 작업을 수행하는 fold2foldBack2 함수가 존재합니다. 주의할 점은 foldBackfoldBack2 함수의 경우 해당 collection의 마지막부터 역순으로 탐색하기 때문에, 무한대의 개념을 다루고 lazy evaluation을 수행하는 sequence에서는 지원되지 않는다는 것입니다.

또한 fold는 모든 중간 계산을 생략하고 제일 마지막에 계산된 값을 리턴하는데, 그 중간 과정을 모두 보고 싶다면 scan을 사용하면 됩니다. scan은 주어진 함수를 각 원소에 대해 순차적으로 실행하며 각각의 원소마다 나오는 중간 결과값(즉, accumulator 값)을 모은 collection을 리턴합니다.


혹시 지금까지의 예시 코드 중에 조건문과 반복문이 전혀 없다는 것을 눈치채셨나요? FP에서는 조건문과 반복문에 직접적으로 대응되는 iffor, while 등을 사용하는 것을 지양합니다. 직접적인 control flow를 사용하게 되면 함수가 간단하지 못하거든요. 물론 F#은 C#과의 호환성을 위해 해당 statement가 존재하지만, 가급적 사용하지 않는 것이 좋습니다.

그렇다고 해서 조건문과 반복문에 해당하는 기능이 아예 존재하지 않는다는 이야기는 아닙니다. 반복문의 경우 위에서 살펴본 재귀 함수, 혹은 방금 다뤘던 다양한 내장 함수를 사용하면 되며 이게 FP 스타일에 더 알맞습니다.

조건문을 FP 스타일로 어떻게 쓰는지는 나중에 알아보겠습니다.

Etc.

지금까지는 매우 단편적인 함수들만 살펴봤습니다. 하지만 F#도 엄연히 자체적으로 실행이 가능한 언어인 만큼, 진입점이 존재합니다.
F#의 진입점은 다음과 같이 정의됩니다.

[<EntryPoint>]
let main args =
    // Do whatever wants.
    0
Enter fullscreen mode Exit fullscreen mode

main 함수의 위에 [<EntryPoint>]라는 특성을 달아야 하며, main 함수의 type은 string array -> int로 고정됩니다.
리턴값은 다른 C 계열 언어가 그렇듯 0이면 정상 종료, 그 이외의 값이면 비정상 종료입니다.


지금까지 함수를 선언할 때 항상 let만을 사용했습니다. 물론 let이 메모리에 값을 대입시키는 거의 유일한 방법인 것은 맞으나, 때로는 굳이 그 결과를 저장하지 않고 수행'만' 필요한 경우도 있습니다. 이럴 때 do를 사용합니다.

do printf "Hello World"
Enter fullscreen mode Exit fullscreen mode

printf 함수를 실행하기만 하고 그 리턴값을 취하지 않는다는 의미이며, 많은 경우 do를 생략할 수 있습니다.
주의할 점은, do를 사용할 경우 그 다음에 오는 함수의 리턴값은 반드시 unit이어야 한다는 점입니다.



다음 글에서는 F#만의 고유한 문법인 pipeline에 대해 다루겠습니다.

Top comments (0)