1부에서 다루었던 내용을 기반으로 이번 글에서는 F# 함수에 대해 조금 더 깊이 들어가 보도록 하겠다.

클로저

먼저 1부에서 다뤘던 코드를 다시 살펴 보자:

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

fun y -> x + yadd''라는 함수의 내부에서 정의된 이른바 내부 함수다. 그리고 x + y 부분의 x는 외부에 있는 add''함수의 파라미터인 x를 가져와 쓴 것이다. 함수의 파라미터도 로컬 변수의 일종이기 때문에 함수가 리턴하고 나면 자동으로 소멸된다. 그렇지만 이렇게 내부 함수에서 외부 스코프에 있는 로컬 변수를 참조하는 순간 특이한 일이 벌어지는데, 외부 함수가 리턴한 뒤에도 내부 함수에서 참조한 외부 로컬 변수는 소멸되지 않고 계속 유지된다. 이것을 클로저(closure)라고 하고, 외부 스코프에서 가져온 변수를 캡쳐된 변수라고 한다. 여기 예에서는 fun y -> x + y가 클로저이고 x가 캡쳐된 변수가 되겠다.

언뜻 보면 너무 당연하게 보이는 개념이 이름까지 따로 붙은 이유는 구현하기가 생각보다 간단치 않기 때문이다. 이건 나중에 따로 다루도록 하겠다. 여기서는 개념만 알아두어도 충분하다.

튜플

여러개의 원소를 한 덩어리로 묶은 데이터 구조를 튜플(tuple)이라고 한다. 배열이나 리스트도 여러개의 원소를 한 덩어리로 묶은 데이터 구조지만 원소의 타입이 모두 같아야 한다는 제약이 있다. 반면 튜플은 아무 타입의 변수나 값을 마음대로 묶을 수 있다는 차이가 있다. 그렇지만 실제로는 두개의 값을 하나로 묶어서 쓰는 경우가 대부분이고, 많아도 4~5개를 넘지 않는 편이다. 튜플을 배열/리스트 비슷한 용도로 자유롭게 쓰는 일부 언어들과 달리 F#에서는 용도가 정해져 있기 때문에 그렇다.

튜플을 만드려면 단순히 여러개의 값을 ,으로 연결하기만 하면 된다:

> let t = "안녕", 2019, 42;;
val t : string * int * int = ("안녕", 2019, 42)

튜플의 타입은 위에서처럼 원소 타입 사이에 *를 넣어서 표기한다. 즉, 이 튜플의 타입은 string * int * int이다. 튜플 전체를 항상 ()로 둘러싸야 하는 다른 언어들과 달리 F#에서는 다른 식의 중간에 쓸 때에만 ()로 둘러싸 주면 된다.

튜플은 데이터 타입인데 왜 굳이 함수를 다루는 글에서 소개하냐 하면 튜플의 생긴 모양 때문에 모호한 경우가 발생하기 때문이다. 예를 들어

let add(x, y) = x + y

처럼 함수를 정의하면 언뜻 C 계열 언어의 함수 스타일과 매우 유사해 보인다. 그렇지만 실제로 이 함수는 두개의 원소를 가진 튜플 한개를 파라미터로 받는 함수로

let add x y = x + y

와는 의미가 완전히 다르다. 그리고 튜플 파라미터는 커링이나 부분 함수 적용도 불가능해서

let add(x, y) = x + y
let addOne = add 1 // 에러

처럼 쓰는 것도 안된다.

튜플 분해

튜플로부터 개별 원소의 값을 따로 뽑아낼 수도 있는데 이것을 분해(deconstruct)한다고 한다.

> let t = "안녕", 2019, 42;;
val t : string * int * int = ("안녕", 2019, 42)

> let greet, year, mol = t;;
val year : int = 2019
val mol : int = 42
val greet : string = "안녕"

튜플의 일부 원소에서만 값을 가져오고 싶다면 나머지 부분은 _로 받으면 된다:

> let _, year, _ = t;;
val year : int = 2019

연산자 오버로딩

연산자는 기호로 된 이름을 가진 함수로, F#에서는 단항과 이항 연산자를 정의할 수 있다. 아래처럼 연산자 둘레를 ()로 감싸서 정의해 주면 된다:

let (=~) (value: char) (str: string) =
    str.IndexOf value >= 0

파라미터가 두개이기 때문에 이 연산자는 이항 연산자임을 알 수 있다. 실제 코드에서는 value: char가 좌변이 되고 str: string이 우변이 된다:

if c =~ "abcd" then ...

단항 연산자는 연산자 앞에 ~를 붙이고 파라미터를 한개 갖는 함수를 정의하면 된다. 자세한 것은 여기 참조.

파이프라인 연산자 |>

지금까지 설명이 대부분 이 연산자를 설명하는데 필요한 배경지식이라고 해도 과언이 아닐 정도로 파이프라인 연산자는 F#의 핵심 기능중 하나다. 연산자 자체는 굉장히 간단하게 정의되어 있다:

let (|>) x f = f x

실제로 이 연산자가 하는 일이라곤 함수 호출 맨 뒤에 오는 파라미터의 위치를 |> 앞으로 옮기는 것이 전부다. 예를 들어

List.length [1 .. 3]

와 같은 코드를 |>를 쓰는 형태로 고치면

> [1 .. 3] |> List.length;;
val it : int = 3

처럼 된다. 여기서는 별로 차이가 느껴지지 않는데…

파이프라인의 진정한 장점은 복잡한 실행 순서를 일렬로 단순화할 수 있다는 데 있다. 이번에는 예전 글에서 작성했던 로또 번호 생성기를 다시 가져와 보자:

open System

let lottoNumbers minValue maxValue count =
    let rand = Random(int DateTime.Now.Ticks)
    Seq.initInfinite (fun _ -> rand.Next(minValue, maxValue + 1))
    |> Seq.distinct
    |> Seq.take count

만약 파이프라인 연산자를 쓰지 않았다면

open System

let lottoNumbers minValue maxValue count =
    let rand = Random(int DateTime.Now.Ticks)
    Seq.take count (
        Seq.distinct (
            Seq.initInfinite (fun _ -> rand.Next(minValue, maxValue + 1))
        )
    )

처럼 써야했을 것이다. 이렇게 함수 호출 안에 함수 호출이 들어가고 그 안에 또 함수 호출이 들어가고, …처럼 중첩되게 코드를 짜는 것은 쓰기도 힘들지만 읽기는 더더욱 힘들다.

마치며

쓰다 보니 내용이 생각보다 너무 길어져 버렸다. 나머지는 다음 글에…