F# 함수(Functions)
F# 함수(Functions)
함수는 어떤 프로그래밍 언어에서든 프로그램 실행의 기본 단위예요. 다른 언어처럼 F# 함수도 이름을 갖고, 매개 변수(parameter)를 받아 인자(argument)로 사용하며, 본문(body)을 가집니다. 여기에 더해 F#은 함수를 값으로 취급하고, 식 안에서 이름 없는 함수를 쓰거나, 함수끼리 합성해 새 함수를 만들고, 커링(currying)된 함수를 사용하며, 인자 일부만 적용해 함수를 암시적으로 정의하는 등 함수형 프로그래밍 특성을 온전히 지원해요.
함수는 let 키워드로 정의하고, 재귀 함수라면 let rec 키워드 조합으로 정의합니다.
출처
- 원문: Functions - F# (Microsoft Learn)
본문
문법(Syntax)
// 비재귀 함수 정의.
let [inline] function-name parameter-list [: return-type ] = function-body
// 재귀 함수 정의.
let rec function-name parameter-list = recursive-function-body
설명(Remarks)
function-name은 함수를 나타내는 식별자입니다. parameter-list는 공백으로 구분된 매개 변수들이 순서대로 나열된 목록이에요. 각 매개 변수에 명시적인 타입을 지정할 수 있는데, 그 내용은 아래 Parameters 섹션에서 다룹니다. 특정 인자 타입을 지정하지 않으면 컴파일러가 함수 본문에서 그 타입을 유추하려 해요. function-body는 하나의 식으로 구성됩니다. 함수 본문을 이루는 식은 보통 여러 개의 식이 모인 복합 식인데, 마지막에 오는 식이 바로 반환 값이 돼요. return-type은 콜론 뒤에 오는 타입이며 선택 사항입니다. 반환 값의 타입을 명시하지 않으면 컴파일러가 마지막 식으로부터 반환 타입을 결정합니다.
간단한 함수 정의는 다음과 같은 모습이에요.
let f x = x + 1
위 예제에서 함수 이름은 f, 인자는 x(타입은 int), 함수 본문은 x + 1, 반환 값의 타입은 int입니다.
함수에는 inline 표시를 붙일 수 있어요. inline에 대한 자세한 내용은 Inline Functions 문서를 참고하세요.
스코프(Scope)
모듈 스코프가 아닌 어떤 스코프 단계에서든 값이나 함수 이름을 재사용하는 것은 오류가 아닙니다. 이름을 재사용하면 나중에 선언한 이름이 먼저 선언된 이름을 가리게(shadow) 돼요. 다만 모듈의 최상위 스코프에서는 이름이 고유해야 합니다. 예를 들어 다음 코드는 모듈 스코프에 놓이면 오류이지만, 함수 안에 놓이면 오류가 아니에요.
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
타입을 지정하면 그 타입은 매개 변수 이름 뒤에 오고, 이름과 콜론으로 구분됩니다. 매개 변수의 타입을 생략하면 컴파일러가 타입을 유추해요. 예를 들어 다음 함수 정의에서 인자 x는 1이 int 타입이므로 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 문서를 참고하세요.