이번 글에서는 F# 함수에 관해 더 자세히 알아보도록 하겠다. F# 코드를 즉석에서 간편하게 실행하는 방법은 몇가지가 있는데 가장 쉬운 것은 F# Interactive라는 이름의 REPL을 이용하는 것이다. F# Interactive는 명령줄에서 fsi
명령으로 실행해도 되고 비주얼 스튜디오 안에서 실행해도 된다:
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
여기서 addOne
은 add
함수에 파라미터를 일부만 적용해서 만든 함수다. 이것을 특별히 부분 함수 적용(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부에 계속…