이번 글에서는 F# 함수에 관해 더 자세히 알아보도록 하겠다. F# 코드를 즉석에서 간편하게 실행하는 방법은 몇가지가 있는데 가장 쉬운 것은 F# Interactive라는 이름의 REPL을 이용하는 것이다. F# Interactive는 명령줄에서 fsi 명령으로 실행해도 되고 비주얼 스튜디오 안에서 실행해도 된다:

F# Interactive

F# Interactive에서 코드를 실행할 때는 입력 마지막에 ;;를 꼭 붙여 주어야 한다:

F# Interactive

또한 아래 코드 블록 중에 >로 시작하는 것들은 F# Interactive에 입력했다는 뜻이다.

함수의 기본 형태와 타입 추론

int 타입인 두 정수의 합을 돌려주는 함수를 만든다고 해보자. 다음과 같이 작성할 수 있다:

let add (x: int) (y: int) : int = x + y

F#에는 리턴값에 쓰는 return 키워드가 없고(있기는 있는데 전혀 다른 용도로 쓴다) 마지막으로 실행되는 식의 결과가 함수의 리턴값이 된다.

타입 추론 덕분에 아래처럼 타입 지정을 완전히 생략해도 동일한 코드가 된다:

let add x y = x + y

이것을 F# Interactive에서 실행해 보면

> let add (x: int) (y: int) : int = x + y;;
val add : x:int -> y:int -> int

> add 1 2;;
val it : int = 3

> let add x y = x + y;;
val add : x:int -> y:int -> int

> add 1 2;;
val it : int = 3

둘이 동일한 함수임을 알 수 있다. 이렇게 타입 지정을 전부 생략해도 되는 이유는 F# 컴파일러가 기본적으로 + 연산에 대해 피연산자의 타입을 int로 추론하기 때문이다. 그럼 int 말고 다른 타입의 합을 구한다면? 가령 두 int64 정수의 합을 구하는 함수를 만든다면

let add (x: int64) y = x + y
let add x (y: int64) = x + y
let add x y : int64 = x + y

처럼 일부에만 int64를 지정해 주면 된다. 실행해 보면

> let add (x: int64) y = x + y;;
val add : x:int64 -> y:int64 -> int64

> let add x (y: int64) = x + y;;
val add : x:int64 -> y:int64 -> int64

> let add x y : int64 = x + y;;
val add : x:int64 -> y:int64 -> int64

나머지 생략한 타입들도 정확하게 추론되는 것을 볼 수 있다.

익명 함수

위의 add 함수를 익명 함수 문법을 써서 정의해 보면 다음과 같다(F#에서는 ' 기호도 이름의 일부로 사용할 수 있다):

let add' = fun (x: int) (y: int) -> x + y

이것을 F# Interactive에서 실행해 보면

> let add' = fun (x: int) (y: int) -> x + y;;
val add' : x:int -> y:int -> int

> add' 1 2;;
val it : int = 3

앞의 add 함수와 결과가 동일한 것을 확인할 수 있다. 익명 함수에서도 타입을 생략할 수 있기 때문에 실제로는 아래처럼 쓴다:

let add' = fun x y -> x + y

여기까지는 이해에 별 문제가 없는데…

커링

이번에는 다음과 같은 코드를 생각해 보자:

let add'' x = fun y -> x + y

파라미터를 한개 받아 파라미터를 한개 받는 익명 함수를 돌려주는 함수다. 말로 쓰니까 표현이 이상하긴 한데 이것도 실행해 보면:

> let add'' x = fun y -> x + y
val add'' : x:int -> y:int -> int

> add'' 1 2;;
val it : int = 3

그리고 한단계 더 나아가면:

> let add''' = fun x -> fun y -> x + y;;
val add''' : x:int -> y:int -> int

> add''' 1 2;;
val it : int = 3

앞의 add, add'와 결과가 동일한 것을 알 수 있다(!). 즉, 이 넷은 모두 같은 함수의 다른 표현이다.

이렇게 두개 이상의 파라미터를 갖는 함수를 단 한개의 파라미터만 갖는 함수의 연속으로 잘개 쪼개는 것을 커링(currying)이라고 한다. 커링은 먹는 카레(curry)와는 무관하고 하스켈 커리라는 수학자를 기리기 위해 붙인 이름이다(하스켈 언어의 이름도 이 분한테서 왔다).

모든 함수를 단 한개의 파라미터를 갖는 함수로 일반화하면 생기는 장점은 함수들끼리 합성하기가 매우 용이해진다는 것이다. 또한 정통 함수형 프로그래밍 언어와 함수형 프로그래밍을 지원하는 비함수형 프로그래밍 언어를 구분짓는 근본적인 차이이기도 하다. 예를 들어 자바나 C#은 언어 차원에서 커링을 지원하지 않기 때문에 굳이 구현하려면 매번 번거로운 수작업을 거쳐야 한다.

커링을 이용하면 다음과 같은 함수들도 전부 동치다:

let add3 x y z = x + y + z
let add3' x y = fun z -> x + y + z
let add3'' x = fun y -> fun z -> x + y + z
let add3''' = fun x -> fun y -> fun z -> x + y + z

실행해 보면:

> let add3 x y z = x + y + z
let add3' x y = fun z -> x + y + z
let add3'' x = fun y -> fun z -> x + y + z
let add3''' = fun x -> fun y -> fun z -> x + y + z;;
val add3 : x:int -> y:int -> z:int -> int
val add3' : x:int -> y:int -> z:int -> int
val add3'' : x:int -> y:int -> z:int -> int
val add3''' : x:int -> y:int -> z:int -> int

> add3 1 2 3;;
val it : int = 6

> add3' 1 2 3;;
val it : int = 6

> add3'' 1 2 3;;
val it : int = 6

> add3''' 1 2 3;;
val it : int = 6

함수 시그너쳐가 의미하는 것

이번에는 add3 함수의 시그너쳐를 살펴 보자:

> let add3 x y z = x + y + z;;
val add3 : x:int -> y:int -> z:int -> int

각 파라미터가 ->로 연결되어 있는 것을 해석하면 이 함수는 x, y, z 세 파라미터를 받아 int 타입을 리턴하는 함수이면서 동시에 x, y 두 파라미터를 받아 z:int -> int와 같은 함수를 리턴하는 함수이기도 하고 동시에 x 한 파라미터를 받아 y:int -> z:int -> int와 같은 함수를 리턴하는 함수이기도 하다는 뜻이다.

리턴값에 괄호를 쳐보면 더 쉽게(?) 이해가 갈 수도 있다:

val add3 : x:int -> y:int -> z:int -> (int)
val add3' : x:int -> y:int -> (z:int -> int)
val add3'' : x:int -> (y:int -> z:int -> int)
val add3''' : (x:int -> y:int -> z:int -> int)

함수의 평가 순서와 부분 함수 적용

위에서 설명한 개념에 의해 F#의 함수는 독특한 방식으로 평가된다. 예를 들어

> add 1 2;;
val it : int = 3

와 같은 함수 호출은 사실은

> (add 1) 2;;
val it : int = 3

처럼 평가된다. (add 1)이라는 함수 호출이 파라미터를 한개 갖는 함수를 리턴하면 여기에 2를 넘겨줘서 전체 호출을 완성한다는 뜻이다. 마찬가지로

> add3 1 2 3;;
val it : int = 6

> ((add3 1) 2) 3;;
val it : int = 6

와 동일하다.

도대체 왜 이렇게 이상한 방식으로 함수를 평가하느냐면 아래와 같은 용법이 가능해지기 때문이다:

> let addOne = add 1;;
val addOne : (int -> int)

> addOne 2;;
val it : int = 3

여기서 addOneadd 함수에 파라미터를 일부만 적용해서 만든 함수다. 이것을 특별히 부분 함수 적용(partial function application)이라고 하고, 커링과 함께 함수형 프로그래밍에서 매우 중요한 개념중 하나다. 부분 함수 적용을 이용하면 다음 코드도 가능하다:

> let addOneAndTwo = add3 1 2;;
val addOneAndTwo : (int -> int)

> addOneAndTwo 3;;
val it : int = 6

C#으로 커링과 부분 함수 적용을 한다면

이론적으로는 C#에서도 커링과 부분 함수 적용을 구현하는 것이 가능하다. 람다식을 써서 최대한 짧게 작성해 보면(C# Interactive에서 실행):

> Func<int,int> Add(int x) => y => x + y;
> int AddOne(int y) => Add(1)(y);
> AddOne(2)
3

그리고 한단계 더 나아가면

> Func<int,Func<int,int>> Add => x => y => x + y;
> int AddOne(int y) => Add(1)(y);
> AddOne(2)
3

(),를 포함한 문법상의 군더더기가 너무 많아서 코드가 오히려 훨씬 더 지저분하고 이해하기 힘들게 되어 버렸다. 😫

요약

  • F#은 강력한 타입 추론 기능을 가지고 있어서 거의 대부분의 경우 타입 지정을 생략해도 된다.
  • 익명 함수는 fun 파라미터1 파라미터2 ... -> 함수 본체처럼 쓴다. 익명 함수를 다른 말로 람다(lambda)라고도 한다.
  • 여러개의 파라미터를 가진 함수를 단 한개의 파라미터만 갖는 함수의 연속으로 변형하는 것을 커링이라고 한다.
  • 여러개의 파라미터를 가진 함수에 파라미터를 일부만 적용해서 함수를 새로 만드는 것을 부분 함수 적용이라고 한다.

커링과 부분 함수 적용은 함수형 프로그래밍에서 매우 중요한 개념이지만 이 글의 내용만으로는 그 이유가 명확하지 않다. 다음 글에서 연산자의 용법과 함께 더 알아보도록 하겠다.

2부에 계속…