Functions
Functions (함수)
F#에서 함수는 그 어떤 것보다 자주 마주치게 되는 핵심 단위예요. 함수는 이름을 갖고, 매개변수와 인자를 받고, 본문을 갖는다는 점에서 다른 언어와 비슷하지만, F#은 여기서 한 걸음 더 나아가요. 함수를 값처럼 다루고, 이름 없는 함수를 식 안에서 바로 쓰고, 함수를 조합해 새 함수를 만들고, 커링(currying)이라는 기법으로 함수를 부분 적용해 새 함수를 만들어내는 식의 함수형 프로그래밍 구성을 모두 지원하거든요. 이 글에서는 F# 함수를 정의하고 다루는 기본적인 방법을 하나씩 살펴볼게요.
출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/
본문
함수를 정의할 때는 let 키워드를 쓰고, 재귀 함수라면 let rec 키워드 조합을 써요.
구문 (Syntax)
// Non-recursive function definition.
let [inline] function-name parameter-list [: return-type ] = function-body
// Recursive function definition.
let rec function-name parameter-list = recursive-function-body
설명 (Remarks)
function-name은 함수를 나타내는 식별자예요. parameter-list는 공백으로 구분된 연속적인 매개변수 목록이고요. 각 매개변수에 명시적인 타입을 붙일 수도 있는데, 그 부분은 뒤에 나오는 매개변수(Parameters) 절에서 자세히 다뤄요. 인자 타입을 지정하지 않으면 컴파일러가 함수 본문에서 타입을 추론하려고 해요.
function-body는 식(expression)으로 구성돼요. 보통 여러 식이 이어지다가 마지막 식에서 결과를 만들어내는 합성 식 형태로 작성하죠. return-type은 콜론 다음에 타입을 적는 부분인데, 생략할 수 있어요. 반환 타입을 명시하지 않으면 컴파일러가 마지막 식의 타입을 반환 타입으로 결정해요.
간단한 함수 정의는 다음과 같이 생겼어요.
let f x = x + 1
아까 예시에서 함수 이름은 f, 인자는 타입이 int인 x, 함수 본문은 x + 1, 그리고 반환 값의 타입은 int예요.
함수에는 inline 표시를 붙일 수 있어요. inline에 대한 자세한 내용은 Inline Functions에서 다뤄요.
스코프 (Scope)
모듈 스코프가 아닌 어떤 스코프에서든 값이나 함수 이름을 재사용해도 오류가 나지 않아요. 이름을 재사용하면 나중에 선언한 이름이 그 앞에서 선언한 이름을 가려요(섀도잉). 다만 모듈의 최상위 스코프에서는 이름이 반드시 유일해야 해요. 예를 들어 다음 코드는 모듈 스코프에 있으면 오류가 나지만, 함수 안에 있으면 오류가 나지 않아요.
let list1 = [ 1; 2; 3 ]
// Error: duplicate definition.
let list1 = []
let function1 () =
let list1 = [ 1; 2; 3 ]
let list1 = []
list1
그런데 다음 코드는 어떤 스코프에서도 문제없이 동작해요.
let list1 = [ 1; 2; 3 ]
let sumPlus x =
// OK: inner list1 hides the outer list1.
let list1 = [ 1; 5; 10 ]
x + List.sum list1
매개변수 (Parameters)
매개변수의 이름은 함수 이름 뒤에 나열해요. 매개변수에 타입을 지정하려면 다음 예시처럼 작성하면 돼요.
let f (x: int) = x + 1
타입을 지정하면 매개변수 이름 뒤에 콜론으로 구분해서 붙여요. 타입을 생략하면 컴파일러가 매개변수 타입을 추론해요. 예를 들어 다음 함수 정의에서 1이 int 타입이므로 인자 x도 int 타입으로 추론돼요.
let f x = x + 1
여기서 컴파일러는 함수를 가능한 한 제네릭으로 만들려고 해요. 다음 코드를 살펴볼게요.
let f x = (x, x)
이 함수는 어떤 타입의 인자 하나에서 튜플을 만들어요. 타입이 지정되어 있지 않으므로 어떤 타입의 인자와도 함께 쓸 수 있죠. 자세한 내용은 Automatic Generalization을 참고해요.
함수 본문 (Function Bodies)
함수 본문에는 지역 변수와 지역 함수의 정의가 들어갈 수 있어요. 이런 변수·함수는 현재 함수 본문 안에서만 스코프를 갖고, 밖에서는 접근할 수 없어요. 정의가 함수 본문 안에 있다는 걸 나타내려면 반드시 들여쓰기를 사용해야 해요. 다음 예시를 볼게요.
let cylinderVolume radius length =
// Define a local value pi.
let pi = 3.14159
length * pi * radius * radius
자세한 내용은 Code Formatting Guidelines와 Verbose Syntax를 참고해요.
반환 값 (Return Values)
컴파일러는 함수 본문의 마지막 식을 기준으로 반환 값과 타입을 결정해요. 그리고 이전 식들에서 마지막 식의 타입을 추론하기도 하죠. 아까 본 cylinderVolume 함수에서 pi의 타입은 리터럴 3.14159의 타입에 맞춰 float가 돼요. 컴파일러는 이 pi의 타입을 이용해 length * pi * radius * radius 식의 타입도 float로 결정하고, 따라서 함수 전체의 반환 타입도 float가 돼요.
반환 타입을 명시적으로 지정하려면 다음과 같이 작성해요.
let cylinderVolume radius length : float =
// Define a local value pi.
let pi = 3.14159
length * pi * radius * radius
이렇게 쓰면 컴파일러가 float를 함수 전체에 적용해요. 매개변수 타입에도 적용하고 싶다면 다음처럼 써야 해요.
let cylinderVolume (radius: float) (length: float) : float
함수 호출 (Calling a Function)
함수를 호출할 때는 함수 이름 다음에 공백을 두고, 공백으로 구분된 인자들을 차례로 적어요. 예를 들어 cylinderVolume 함수를 호출하고 그 결과를 값 vol에 저장하려면 다음처럼 작성해요.
let vol = cylinderVolume 2.0 3.0
인자의 부분 적용 (Partial Application of Arguments)
지정된 인자 수보다 적은 인자를 넘기면 나머지 인자를 기대하는 새 함수가 만들어져요. 이런 인자 처리 방식을 커링(currying)이라 부르고, F# 같은 함수형 프로그래밍 언어의 특징이에요. 예를 들어 반지름이 2.0인 파이프와 3.0인 파이프 두 종류를 다룬다고 해볼게요. 파이프의 부피를 구하는 함수를 다음과 같이 만들 수 있어요.
let smallPipeRadius = 2.0
let bigPipeRadius = 3.0
// These define functions that take the length as a remaining
// argument:
let smallPipeVolume = cylinderVolume smallPipeRadius
let bigPipeVolume = cylinderVolume bigPipeRadius
그다음에는 두 종류 파이프의 여러 길이에 맞춰 마지막 인자만 넘겨주면 돼요.
let length1 = 30.0
let length2 = 40.0
let smallPipeVol1 = smallPipeVolume length1
let smallPipeVol2 = smallPipeVolume length2
let bigPipeVol1 = bigPipeVolume length1
let bigPipeVol2 = bigPipeVolume length2
재귀 함수 (Recursive Functions)
재귀 함수는 자기 자신을 호출하는 함수예요. let 다음에 rec 키워드를 붙이는 게 필수 조건이죠. 함수 본문 안에서 다른 함수를 호출하듯 그대로 자기 자신을 호출하면 돼요. 다음 재귀 함수는 n번째 피보나치 수를 계산해요. 피보나치 수열은 고대부터 알려진 수열로, 각 항이 바로 앞 두 항의 합이 되는 수열이에요.
let rec fib n =
if n < 2 then 1 else fib (n - 1) + fib (n - 2)
꼬리 재귀(tail recursion), 누적자(accumulator), 연속(continuation) 같은 특별한 기법을 알고 주의해서 작성하지 않으면 일부 재귀 함수는 프로그램 스택을 넘치게 하거나 비효율적으로 동작할 수 있어요.
함수 값 (Function Values)
F#에서는 모든 함수가 값으로 취급돼요. 그래서 이런 함수를 함수 값(function value)이라고 불러요. 함수가 값이기 때문에 다른 함수의 인자로 넘기거나, 값이 쓰이는 다른 자리에서도 그대로 쓸 수 있어요. 다음은 함수 값을 인자로 받는 함수의 예시예요.
let apply1 (transform: int -> int) y = transform y
함수 값의 타입은 -> 토큰으로 표현해요. 이 토큰의 왼쪽이 인자 타입, 오른쪽이 반환 값이에요. 아까 예시에서 apply1은 함수 transform을 인자로 받는 함수인데, transform은 정수를 받아 정수를 반환하는 함수예요. apply1을 사용하는 코드는 다음과 같아요.
let increment x = x + 1
let result1 = apply1 increment 100
위 코드가 실행되면 result의 값은 101이 돼요.
여러 인자는 -> 토큰을 이어 붙여서 표현해요. 다음 예시를 볼게요.
let apply2 (f: int -> int -> int) x y = f x y
let mul x y = x * y
let result2 = apply2 mul 10 20
결과는 200이에요.
람다 식 (Lambda Expressions)
람다 식은 이름이 없는 함수예요. 아까 예시에서 이름 있는 함수 increment와 mul을 정의하는 대신, 람다 식을 이렇게 쓸 수도 있어요.
let result3 = apply1 (fun x -> x + 1) 100
let result4 = apply2 (fun x y -> x * y) 10 20
람다 식은 fun 키워드로 정의해요. 함수 정의와 비슷하지만, 인자 목록과 함수 본문을 구분할 때 = 대신 -> 토큰을 쓴다는 점이 달라요. 일반 함수 정의에서처럼 인자 타입을 추론하거나 명시할 수 있고, 람다 식의 반환 타입은 본문의 마지막 식 타입에서 추론돼요. 자세한 내용은 Lambda Expressions: The fun Keyword를 참고해요.
파이프라인 (Pipelines)
파이프 연산자 |>는 F#에서 데이터를 처리할 때 아주 널리 쓰여요. 이 연산자는 함수의 "파이프라인"을 유연하게 만들 수 있게 해주는데, 파이프라인을 쓰면 함수 호출을 연속된 작업으로 이어 붙일 수 있어요.
let result = 100 |> function1 |> function2
다음 예시는 이런 연산자들을 이용해 간단한 함수형 파이프라인을 만드는 과정을 보여줘요.
/// Square the odd values of the input and add one, using F# pipe operators.
let squareAndAddOdd values =
values |> List.filter (fun x -> x % 2 <> 0) |> List.map (fun x -> x * x + 1)
let numbers = [ 1; 2; 3; 4; 5 ]
let result = squareAndAddOdd numbers
결과는 [2; 10; 26]이에요. 이 예시는 파이프라인을 만들 때 함수로 데이터를 처리하는 방법을 보여주기 위해 목록 처리 함수를 사용한 거예요. 파이프라인 연산자 자체는 F# 핵심 라이브러리에 다음과 같이 정의되어 있어요.
let (|>) x f = f x
함수 합성 (Function composition)
F#의 함수는 다른 함수들로부터 합성할 수 있어요. function1과 function2 두 함수의 합성은 function1을 적용한 다음 function2를 적용하는 것을 나타내는 또 하나의 함수예요.
let function1 x = x + 1
let function2 x = x * 2
let h = function1 >> function2
let result5 = h 100
결과는 202이에요.
합성 연산자 >>는 두 함수를 받아 함수를 반환해요. 반면 파이프라인 연산자 |>는 값과 함수를 받아 값을 반환하죠. 다음 코드 예시는 두 연산자의 함수 시그니처와 사용법 차이를 보여줘요.
// Function composition and pipeline operators compared.
let addOne x = x + 1
let timesTwo x = 2 * x
// Composition operator
// ( >> ) : ('T1 -> 'T2) -> ('T2 -> 'T3) -> 'T1 -> 'T3
let Compose2 = addOne >> timesTwo
// Backward composition operator
// ( << ) : ('T2 -> 'T3) -> ('T1 -> 'T2) -> 'T1 -> 'T3
let Compose1 = addOne << timesTwo
// Result is 5
let result1 = Compose1 2
// Result is 6
let result2 = Compose2 2
// Pipelining
// Pipeline operator
// ( |> ) : 'T1 -> ('T1 -> 'U) -> 'U
let Pipeline2 x = addOne x |> timesTwo
// Backward pipeline operator
// ( <| ) : ('T -> 'U) -> 'T -> 'U
let Pipeline1 x = addOne <| timesTwo x
// Result is 5
let result3 = Pipeline1 2
// Result is 6
let result4 = Pipeline2 2
함수 오버로딩 (Overloading Functions)
함수는 오버로딩할 수 없고, 타입의 메서드만 오버로딩할 수 있어요. 자세한 내용은 Methods를 참고해요.