Tour of F#
Tour of F# (F# 둘러보기)
F#을 제대로 배우는 가장 좋은 방법은 그냥 F# 코드를 읽고 또 직접 써 보는 거예요. 이 글은 그런 마음으로 준비한 F#의 주요 기능 투어입니다. 각 기능을 설명하는 코드 조각들을 여러분의 컴퓨터에서 그대로 실행해 볼 수 있도록 실어 두었어요. 개발 환경을 어떻게 세팅하는지부터 알고 싶다면 Getting Started 문서를 먼저 확인해 주세요.
F#에는 두 가지 핵심 개념이 있는데요, 바로 함수와 타입입니다. 이 투어는 바로 이 두 개념에 속하는 언어 기능들을 중심으로 짚어 갑니다.
출처: Tour of F# — .NET | Microsoft Learn (일부 의역)
본문
코드를 온라인으로 실행하기
아직 F#이 여러분의 컴퓨터에 설치되어 있지 않다면, 모든 샘플을 브라우저에서 바로 실행할 수 있어요. Try F# in Fable이 바로 그 도구입니다. Fable은 브라우저에서 바로 실행되는 F#의 한 방언(dialect)이에요. 아래 코드들을 이 REPL에 직접 올려 보고 싶다면, Fable REPL 왼쪽 메뉴에서 Samples > Learn > Tour of F# 을 선택해 주세요.
함수와 모듈 (Functions and Modules)
이제 투어를 시작해 볼게요. F# 프로그램을 이루는 가장 기본적인 조각은 모듈(module) 안에 정리된 함수(function) 입니다. 함수는 입력을 받아 출력을 만들어 내는 작업을 수행하고, F#에서 여러 가지를 묶는 기본 단위인 모듈 아래에 정리돼요. 그리고 함수는 이름과 인자를 정해 주는 let 바인딩으로 정의합니다.
module BasicFunctions =
/// You use 'let' to define a function. This one accepts an integer argument and returns an integer.
/// Parentheses are optional for function arguments, except for when you use an explicit type annotation.
let sampleFunction1 x = x*x + 3
/// Apply the function, naming the function return result using 'let'.
/// The variable type is inferred from the function return type.
let result1 = sampleFunction1 4573
// This line uses '%d' to print the result as an integer. This is type-safe.
// If 'result1' were not of type 'int', then the line would fail to compile.
printfn $"The result of squaring the integer 4573 and adding 3 is %d{result1}"
/// When needed, annotate the type of a parameter name using '(argument:type)'. Parentheses are required.
let sampleFunction2 (x:int) = 2*x*x - x/5 + 3
let result2 = sampleFunction2 (7 + 4)
printfn $"The result of applying the 2nd sample function to (7 + 4) is %d{result2}"
/// Conditionals use if/then/elif/else.
///
/// Note that F# uses white space indentation-aware syntax, similar to languages like Python.
let sampleFunction3 x =
if x < 100.0 then
2.0*x*x - x/5.0 + 3.0
else
2.0*x*x + x/5.0 - 37.0
let result3 = sampleFunction3 (6.5 + 4.5)
// This line uses '%f' to print the result as a float. As with '%d' above, this is type-safe.
printfn $"The result of applying the 3rd sample function to (6.5 + 4.5) is %f{result3}"
let 바인딩은 값에 이름을 붙이는 방법이기도 해요. 다른 언어의 변수와 비슷하다고 생각하면 되는데, 큰 차이가 하나 있어요. let 바인딩은 기본적으로 불변(immutable) 입니다. 즉 한 번 이름에 값이나 함수를 묶으면 그 자리에서 내용을 바꿀 수 없어요. 다른 언어의 변수들이 가변(mutable) 이라서 언제든 값을 바꿀 수 있는 것과는 정반대죠. 만약 값을 바꿔야 하는 바인딩이 필요하다면 let mutable ... 구문을 사용하면 됩니다.
module Immutability =
/// Binding a value to a name via 'let' makes it immutable.
///
/// The second line of code compiles, but 'number' from that point onward will shadow the previous definition.
/// There is no way to access the previous definition of 'number' due to shadowing.
let number = 2
// let number = 3
/// A mutable binding. This is required to be able to mutate the value of 'otherNumber'.
let mutable otherNumber = 2
printfn $"'otherNumber' is {otherNumber}"
// When mutating a value, use '<-' to assign a new value.
//
// Note that '=' is not the same as this. Outside binding values via 'let', '=' is used to test equality.
otherNumber <- otherNumber + 1
printfn $"'otherNumber' changed to be {otherNumber}"
여기서 잠깐 짚고 넘어갈 게 있어요. 위 주석에서도 언급했듯이, 값을 바꿀 때는 <- 연산자를 쓰고, =는 평등(동일함)을 검사하는 데 씁니다. 헷갈리기 쉬운 포인트예요.
숫자, 불리언, 문자열 (Numbers, Booleans, and Strings)
F#은 .NET 언어이므로 .NET에 존재하는 동일한 기본 타입들(primitive types)을 그대로 지원해요. F#에서 다양한 숫자 타입이 어떻게 표현되는지 볼까요?
module IntegersAndNumbers =
/// This is a sample integer.
let sampleInteger = 176
/// This is a sample floating point number.
let sampleDouble = 4.1
/// This computed a new number by some arithmetic. Numeric types are converted using
/// functions 'int', 'double' and so on.
let sampleInteger2 = (sampleInteger/4 + 5 - 7) * 4 + int sampleDouble
/// This is a list of the numbers from 0 to 99.
let sampleNumbers = [ 0 .. 99 ]
/// This is a list of all tuples containing all the numbers from 0 to 99 and their squares.
let sampleTableOfSquares = [ for i in 0 .. 99 -> (i, i*i) ]
// The next line prints a list that includes tuples, using an interpolated string.
printfn $"The table of squares from 0 to 99 is:\n{sampleTableOfSquares}"
불리언 값과 기본적인 조건 로직은 이런 모습이에요.
module Booleans =
/// Booleans values are 'true' and 'false'.
let boolean1 = true
let boolean2 = false
/// Operators on booleans are 'not', '&&' and '||'.
let boolean3 = not boolean1 && (boolean2 || false)
// This line uses '%b'to print a boolean value. This is type-safe.
printfn $"The expression 'not boolean1 && (boolean2 || false)' is %b{boolean3}"
기본적인 문자열(string) 다루기는 이런 식으로 하면 돼요.
module StringManipulation =
/// Strings use double quotes.
let string1 = "Hello"
let string2 = "world"
/// Strings can also use @ to create a verbatim string literal.
/// This will ignore escape characters such as '\', '\n', '\t', etc.
let string3 = @"C:\Program Files\"
/// String literals can also use triple-quotes.
let string4 = """The computer said "hello world" when I told it to!"""
/// String concatenation is normally done with the '+' operator.
let helloWorld = string1 + " " + string2
// This line uses '%s' to print a string value. This is type-safe.
printfn "%s" helloWorld
/// Substrings use the indexer notation. This line extracts the first 7 characters as a substring.
/// Note that like many languages, Strings are zero-indexed in F#.
let substring = helloWorld[0..6]
printfn $"{substring}"
튜플 (Tuples)
튜플(Tuples)은 F#에서 매우 중요한 개념이에요. 이름은 없지만 순서가 있는 값들의 묶음으로, 그 자체로 하나의 값처럼 취급됩니다. 쉽게 말해 다른 값들로부터 모아진 값이라고 생각하면 돼요. 함수에서 여러 값을 한 번에 반환하거나, 여러 값을 임시로 묶어야 할 때처럼 쓰임새가 아주 많습니다.
module Tuples =
/// A simple tuple of integers.
let tuple1 = (1, 2, 3)
/// A function that swaps the order of two values in a tuple.
///
/// F# Type Inference will automatically generalize the function to have a generic type,
/// meaning that it will work with any type.
let swapElems (a, b) = (b, a)
printfn $"The result of swapping (1, 2) is {(swapElems (1,2))}"
/// A tuple consisting of an integer, a string,
/// and a double-precision floating point number.
let tuple2 = (1, "fred", 3.1415)
printfn $"tuple1: {tuple1}\ttuple2: {tuple2}"
튜플은 struct로도 만들 수 있어요. 이 struct 튜플은 C# 7 / Visual Basic 15의 튜플과 완전히 상호 운용되는데, 그쪽 튜플도 역시 struct 튜플이거든요.
/// Tuples are normally objects, but they can also be represented as structs.
///
/// These interoperate completely with structs in C# and Visual Basic.NET; however,
/// struct tuples are not implicitly convertible with object tuples (often called reference tuples).
///
/// The second line below will fail to compile because of this. Uncomment it to see what happens.
let sampleStructTuple = struct (1, 2)
//let thisWillNotCompile: (int*int) = struct (1, 2)
// Although you can
let convertFromStructTuple (struct(a, b)) = (a, b)
let convertToStructTuple (a, b) = struct(a, b)
printfn $"Struct Tuple: {sampleStructTuple}\nReference tuple made from the Struct Tuple: {(sampleStructTuple |> convertFromStructTuple)}"
위 예제는 함수 파라미터에서 (struct(a, b))처럼 패턴 매칭으로 튜플을 분해해서 각 요소를 꺼내는 방법을 보여줘요. 패턴 매칭과 튜플 분해에 대해 더 자세히 알고 싶다면 Tuples 문서를 참고하세요.
한 가지 꼭 알아둬야 할 점이 있어요. struct 튜플은 값 타입(value type)이라서 참조 튜플(reference tuple)로 암시적으로 변환되지 않고, 그 반대도 마찬가지예요. 참조 튜플과 struct 튜플 사이를 오가려면 반드시 명시적으로 변환해야 합니다.
파이프라인 (Pipelines)
파이프 연산자 |>는 F#에서 데이터를 처리할 때 아주 자주 쓰여요. 이 연산자 덕분에 함수들의 "파이프라인"을 유연하게 만들 수 있죠. 아래 예제를 따라가다 보면 이 연산자들을 활용해서 간단한 함수형 파이프라인을 어떻게 짜는지 볼 수 있습니다.
module PipelinesAndComposition =
/// Squares a value.
let square x = x * x
/// Adds 1 to a value.
let addOne x = x + 1
/// Tests if an integer value is odd via modulo.
///
/// '<>' is a binary comparison operator that means "not equal to".
let isOdd x = x % 2 <> 0
/// A list of 5 numbers. More on lists later.
let numbers = [ 1; 2; 3; 4; 5 ]
/// Given a list of integers, it filters out the even numbers,
/// squares the resulting odds, and adds 1 to the squared odds.
let squareOddValuesAndAddOne values =
let odds = List.filter isOdd values
let squares = List.map square odds
let result = List.map addOne squares
result
printfn $"processing {numbers} through 'squareOddValuesAndAddOne' produces: {squareOddValuesAndAddOne numbers}"
/// A shorter way to write 'squareOddValuesAndAddOne' is to nest each
/// sub-result into the function calls themselves.
///
/// This makes the function much shorter, but it's difficult to see the
/// order in which the data is processed.
let squareOddValuesAndAddOneNested values =
List.map addOne (List.map square (List.filter isOdd values))
printfn $"processing {numbers} through 'squareOddValuesAndAddOneNested' produces: {squareOddValuesAndAddOneNested numbers}"
/// A preferred way to write 'squareOddValuesAndAddOne' is to use F# pipe operators.
/// This allows you to avoid creating intermediate results, but is much more readable
/// than nesting function calls like 'squareOddValuesAndAddOneNested'
let squareOddValuesAndAddOnePipeline values =
values
|> List.filter isOdd
|> List.map square
|> List.map addOne
printfn $"processing {numbers} through 'squareOddValuesAndAddOnePipeline' produces: {squareOddValuesAndAddOnePipeline numbers}"
/// You can shorten 'squareOddValuesAndAddOnePipeline' by moving the second `List.map` call
/// into the first, using a Lambda Function.
///
/// Note that pipelines are also being used inside the lambda function. F# pipe operators
/// can be used for single values as well. This makes them very powerful for processing data.
let squareOddValuesAndAddOneShorterPipeline values =
values
|> List.filter isOdd
|> List.map(fun x -> x |> square |> addOne)
printfn $"processing {numbers} through 'squareOddValuesAndAddOneShorterPipeline' produces: {squareOddValuesAndAddOneShorterPipeline numbers}"
/// Lastly, you can eliminate the need to explicitly take 'values' in as a parameter by using '>>'
/// to compose the two core operations: filtering out even numbers, then squaring and adding one.
/// Likewise, the 'fun x -> ...' bit of the lambda expression is also not needed, because 'x' is simply
/// being defined in that scope so that it can be passed to a functional pipeline. Thus, '>>' can be used
/// there as well.
///
/// The result of 'squareOddValuesAndAddOneComposition' is itself another function which takes a
/// list of integers as its input. If you execute 'squareOddValuesAndAddOneComposition' with a list
/// of integers, you'll notice that it produces the same results as previous functions.
///
/// This is using what is known as function composition. This is possible because functions in F#
/// use Partial Application and the input and output types of each data processing operation match
/// the signatures of the functions we're using.
let squareOddValuesAndAddOneComposition =
List.filter isOdd >> List.map (square >> addOne)
printfn $"processing {numbers} through 'squareOddValuesAndAddOneComposition' produces: {squareOddValuesAndAddOneComposition numbers}"
위 예제는 F#의 여러 기능을 한꺼번에 활용해요. 리스트 처리 함수, 일급 함수(first-class functions), 그리고 부분 적용(partial application)까지요. 이 개념들은 다소 고급이라 어렵게 느껴질 수 있는데요, 그럼에도 파이프라인을 만들면서 함수가 데이터를 얼마나 손쉽게 처리하는지가 명확하게 보였을 거예요.
리스트, 배열, 시퀀스 (Lists, Arrays, and Sequences)
리스트, 배열, 시퀀스는 F# 핵심 라이브러리의 세 가지 주요 컬렉션 타입입니다.
리스트(Lists)는 같은 타입의 요소들로 이루어진, 순서가 있는 불변(immutable) 컬렉션이에요. 단일 연결 리스트(singly linked list)라서 열거(enumeration)에는 적합하지만, 크기가 클 때 무작위 접근이나 이어 붙이기에는 좋지 않은 선택입니다. 다른 인기 언어들의 리스트는 대개 단일 연결 리스트로 구현되지 않는다는 점과 대조되죠.
module Lists =
/// Lists are defined using [ ... ]. This is an empty list.
let list1 = [ ]
/// This is a list with 3 elements. ';' is used to separate elements on the same line.
let list2 = [ 1; 2; 3 ]
/// You can also separate elements by placing them on their own lines.
let list3 = [
1
2
3
]
/// This is a list of integers from 1 to 1000
let numberList = [ 1 .. 1000 ]
/// Lists can also be generated by computations. This is a list containing
/// all the days of the year.
///
/// 'yield' is used for on-demand evaluation. More on this later in Sequences.
let daysList =
[ for month in 1 .. 12 do
for day in 1 .. System.DateTime.DaysInMonth(2017, month) do
yield System.DateTime(2017, month, day) ]
// Print the first 5 elements of 'daysList' using 'List.take'.
printfn $"The first 5 days of 2017 are: {daysList |> List.take 5}"
/// Computations can include conditionals. This is a list containing the tuples
/// which are the coordinates of the black squares on a chess board.
let blackSquares =
[ for i in 0 .. 7 do
for j in 0 .. 7 do
if (i+j) % 2 = 1 then
yield (i, j) ]
/// Lists can be transformed using 'List.map' and other functional programming combinators.
/// This definition produces a new list by squaring the numbers in numberList, using the pipeline
/// operator to pass an argument to List.map.
let squares =
numberList
|> List.map (fun x -> x*x)
/// There are many other list combinations. The following computes the sum of the squares of the
/// numbers divisible by 3.
let sumOfSquares =
numberList
|> List.filter (fun x -> x % 3 = 0)
|> List.sumBy (fun x -> x * x)
printfn $"The sum of the squares of numbers up to 1000 that are divisible by 3 is: %d{sumOfSquares}"
위 예제에서 리스트가 계산식으로 생성된다는 걸 확인했을 거예요. yield는 필요할 때(요청 시) 평가되는 데 쓰이는데, 이건 뒤에서 시퀀스를 다룰 때 더 자세히 살펴볼게요.
배열(Arrays)은 같은 타입의 요소들로 이루어진, 고정 크기의 가변(mutable) 컬렉션이에요. 요소에 대한 빠른 무작위 접근을 지원하고, 연속된 메모리 블록이라서 F# 리스트보다 빠릅니다.
module Arrays =
/// This is The empty array. Note that the syntax is similar to that of Lists, but uses `[| ... |]` instead.
let array1 = [| |]
/// Arrays are specified using the same range of constructs as lists.
let array2 = [| "hello"; "world"; "and"; "hello"; "world"; "again" |]
/// This is an array of numbers from 1 to 1000.
let array3 = [| 1 .. 1000 |]
/// This is an array containing only the words "hello" and "world".
let array4 =
[| for word in array2 do
if word.Contains("l") then
yield word |]
/// This is an array initialized by index and containing the even numbers from 0 to 2000.
let evenNumbers = Array.init 1001 (fun n -> n * 2)
/// Sub-arrays are extracted using slicing notation.
let evenNumbersSlice = evenNumbers[0..500]
/// You can loop over arrays and lists using 'for' loops.
for word in array4 do
printfn $"word: {word}"
// You can modify the contents of an array element by using the left arrow assignment operator.
//
// To learn more about this operator, see: https://learn.microsoft.com/dotnet/fsharp/language-reference/values/index#mutable-variables
array2[1] <- "WORLD!"
/// You can transform arrays using 'Array.map' and other functional programming operations.
/// The following calculates the sum of the lengths of the words that start with 'h'.
///
/// Note that in this case, similar to Lists, array2 is not mutated by Array.filter.
let sumOfLengthsOfWords =
array2
|> Array.filter (fun x -> x.StartsWith "h")
|> Array.sumBy (fun x -> x.Length)
printfn $"The sum of the lengths of the words in Array 2 is: %d{sumOfLengthsOfWords}"
배열 문법이 [| ... |]라는 점만 빼면 리스트와 비슷하다는 걸 알 수 있죠.
시퀀스(Sequences)는 모두 같은 타입인 요소들의 논리적인 연속(series)이에요. 리스트와 배열보다 더 일반적인 타입이라서 어떤 논리적 요소의 연속이든 그 "뷰(view)" 역할을 할 수 있어요. 여기에 더해 시퀀스는 지연(lazy) 될 수 있다는 특징이 돋보이는데, 이는 요소가 실제로 필요할 때만 계산된다는 뜻이에요.
module Sequences =
/// This is the empty sequence.
let seq1 = Seq.empty
/// This a sequence of values.
let seq2 = seq { yield "hello"; yield "world"; yield "and"; yield "hello"; yield "world"; yield "again" }
/// This is an on-demand sequence from 1 to 1000.
let numbersSeq = seq { 1 .. 1000 }
/// This is a sequence producing the words "hello" and "world"
let seq3 =
seq { for word in seq2 do
if word.Contains("l") then
yield word }
/// This is a sequence producing the even numbers up to 2000.
let evenNumbers = Seq.init 1001 (fun n -> n * 2)
let rnd = System.Random()
/// This is an infinite sequence which is a random walk.
/// This example uses yield! to return each element of a subsequence.
let rec randomWalk x =
seq { yield x
yield! randomWalk (x + rnd.NextDouble() - 0.5) }
/// This example shows the first 100 elements of the random walk.
let first100ValuesOfRandomWalk =
randomWalk 5.0
|> Seq.truncate 100
|> Seq.toList
printfn $"First 100 elements of a random walk: {first100ValuesOfRandomWalk}"
yield!가 부분 시퀀스의 각 요소를 그대로 되돌려 주면서 무한 시퀀스를 만들어 내는 모습도 확인할 수 있어요.
재귀 함수 (Recursive Functions)
F#에서 컬렉션이나 시퀀스의 요소들을 처리할 때는 보통 재귀(recursion)를 사용해요. F#이 루프와 명령형 프로그래밍을 지원하긴 하지만, 재귀가 선호되는 이유는 정확성을 보장하기 더 쉽기 때문입니다.
Note
아래 예제는
match식을 이용한 패턴 매칭을 사용하고 있어요. 이 근본적인 구성 요소는 이 글의 뒷부분에서 다룹니다.
module RecursiveFunctions =
/// This example shows a recursive function that computes the factorial of an
/// integer. It uses 'let rec' to define a recursive function.
let rec factorial n =
if n = 0 then 1 else n * factorial (n-1)
printfn $"Factorial of 6 is: %d{factorial 6}"
/// Computes the greatest common factor of two integers.
///
/// Since all of the recursive calls are tail calls,
/// the compiler will turn the function into a loop,
/// which improves performance and reduces memory consumption.
let rec greatestCommonFactor a b =
if a = 0 then b
elif a < b then greatestCommonFactor a (b - a)
else greatestCommonFactor (a - b) b
printfn $"The Greatest Common Factor of 300 and 620 is %d{greatestCommonFactor 300 620}"
/// This example computes the sum of a list of integers using recursion.
///
/// '::' is used to split a list into the head and tail of the list,
/// the head being the first element and the tail being the rest of the list.
let rec sumList xs =
match xs with
| [] -> 0
| y::ys -> y + sumList ys
/// This makes 'sumList' tail recursive, using a helper function with a result accumulator.
let rec private sumListTailRecHelper accumulator xs =
match xs with
| [] -> accumulator
| y::ys -> sumListTailRecHelper (accumulator+y) ys
/// This invokes the tail recursive helper function, providing '0' as a seed accumulator.
/// An approach like this is common in F#.
let sumListTailRecursive xs = sumListTailRecHelper 0 xs
let oneThroughTen = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
printfn $"The sum 1-10 is %d{sumListTailRecursive oneThroughTen}"
재귀 호출이 모두 꼬리 호출(tail call)이라서 컴파일러가 함수를 루프로 바꿔 성능을 높이고 메모리 사용을 줄여 주는 부분도 눈여겨보세요. F#은 꼬리 호출 최적화(Tail Call Optimization)도 완전히 지원해서, 재귀 호출을 루프 구조만큼 빠르게 만들어 줍니다.
레코드와 판별 공용체 타입 (Record and Discriminated Union Types)
레코드(Record) 타입과 공용체(Union) 타입은 F# 코드에서 사용되는 두 가지 기본 데이터 타입으로, F# 프로그램에서 데이터를 표현하는 가장 좋은 방법이에요. 다른 언어의 클래스와 비슷해 보이지만 가장 큰 차이는 이 타입들이 구조적 동등성(structural equality) 을 가진다는 점입니다. 다시 말해 이 타입들은 "본래부터" 비교가 가능하고, 동등성 판단도 단순해요. 그냥 서로 같은지 하나씩 확인하면 되니까요.
레코드(Records)는 이름이 붙은 값들의 묶음이며, 선택적으로 멤버(메서드 같은 것)를 가질 수 있어요. C#이나 Java에 익숙하다면 POCO나 POJO와 비슷하다고 느낄 텐데, 단지 구조적 동등성을 갖고 있고 형식적인 절차(ceremony)가 적다는 차이만 있어요.
module RecordTypes =
/// This example shows how to define a new record type.
type ContactCard =
{ Name : string
Phone : string
Verified : bool }
/// This example shows how to instantiate a record type.
let contact1 =
{ Name = "Alf"
Phone = "(206) 555-0157"
Verified = false }
/// You can also do this on the same line with ';' separators.
let contactOnSameLine = { Name = "Alf"; Phone = "(206) 555-0157"; Verified = false }
/// This example shows how to use "copy-and-update" on record values. It creates
/// a new record value that is a copy of contact1, but has different values for
/// the 'Phone' and 'Verified' fields.
///
/// To learn more, see: https://learn.microsoft.com/dotnet/fsharp/language-reference/copy-and-update-record-expressions
let contact2 =
{ contact1 with
Phone = "(206) 555-0112"
Verified = true }
/// This example shows how to write a function that processes a record value.
/// It converts a 'ContactCard' object to a string.
let showContactCard (c: ContactCard) =
c.Name + " Phone: " + c.Phone + (if not c.Verified then " (unverified)" else "")
printfn $"Alf's Contact Card: {showContactCard contact1}"
/// This is an example of a Record with a member.
type ContactCardAlternate =
{ Name : string
Phone : string
Address : string
Verified : bool }
/// Members can implement object-oriented members.
member this.PrintedContactCard =
this.Name + " Phone: " + this.Phone + (if not this.Verified then " (unverified)" else "") + this.Address
let contactAlternate =
{ Name = "Alf"
Phone = "(206) 555-0157"
Verified = false
Address = "111 Alf Street" }
// Members are accessed via the '.' operator on an instantiated type.
printfn $"Alf's alternate contact card is {contactAlternate.PrintedContactCard}"
레코드는 [<Struct>] 특성을 붙이면 struct로도 표현할 수 있어요.
[<Struct>]
type ContactCardStruct =
{ Name : string
Phone : string
Verified : bool }
판별 공용체(Discriminated Unions, DU)는 여러 개의 명명된 형태(case) 중 하나일 수 있는 값을 나타내요. 타입에 저장된 데이터는 몇 가지 서로 다른 값 중 하나가 될 수 있죠.
module DiscriminatedUnions =
/// The following represents the suit of a playing card.
type Suit =
| Hearts
| Clubs
| Diamonds
| Spades
/// A Discriminated Union can also be used to represent the rank of a playing card.
type Rank =
/// Represents the rank of cards 2 .. 10
| Value of int
| Ace
| King
| Queen
| Jack
/// Discriminated Unions can also implement object-oriented members.
static member GetAllRanks() =
[ yield Ace
for i in 2 .. 10 do yield Value i
yield Jack
yield Queen
yield King ]
/// This is a record type that combines a Suit and a Rank.
/// It's common to use both Records and Discriminated Unions when representing data.
type Card = { Suit: Suit; Rank: Rank }
/// This computes a list representing all the cards in the deck.
let fullDeck =
[ for suit in [ Hearts; Diamonds; Clubs; Spades] do
for rank in Rank.GetAllRanks() do
yield { Suit=suit; Rank=rank } ]
/// This example converts a 'Card' object to a string.
let showPlayingCard (c: Card) =
let rankString =
match c.Rank with
| Ace -> "Ace"
| King -> "King"
| Queen -> "Queen"
| Jack -> "Jack"
| Value n -> string n
let suitString =
match c.Suit with
| Clubs -> "clubs"
| Diamonds -> "diamonds"
| Spades -> "spades"
| Hearts -> "hearts"
rankString + " of " + suitString
/// This example prints all the cards in a playing deck.
let printAllCards() =
for card in fullDeck do
printfn $"{showPlayingCard card}"
위 예제에서 Suit의 각 case가 카드의 무늬를, Rank가 카드의 숫자(rank)를 나타내는 걸 볼 수 있어요. DU를 레코드(Card)와 함께 조합해서 데이터를 표현하는 일이 흔합니다.
DU는 단일 case 판별 공용체(Single-Case Discriminated Unions) 로도 쓸 수 있는데, 이는 기본 타입(primitive type) 위에 도메인 모델링을 하려 할 때 유용해요. 문자열이나 다른 기본 타입으로 어떤 것을 표현하다 보면 그 값에 특별한 의미를 부여하게 되죠. 그런데 기본 표현만 쓰다 보면 잘못된 값을 실수로 할당할 위험이 있어요. 각 정보를 서로 다른 단일 case 공용체로 표현하면 이런 시나리오에서 정확성을 강제할 수 있습니다.
// Single-case DUs are often used for domain modeling. This can buy you extra type safety
// over primitive types such as strings and ints.
//
// Single-case DUs cannot be implicitly converted to or from the type they wrap.
// For example, a function which takes in an Address cannot accept a string as that input,
// or vice versa.
type Address = Address of string
type Name = Name of string
type SSN = SSN of int
// You can easily instantiate a single-case DU as follows.
let address = Address "111 Alf Way"
let name = Name "Alf"
let ssn = SSN 1234567890
/// When you need the value, you can unwrap the underlying value with a simple function.
let unwrapAddress (Address a) = a
let unwrapName (Name n) = n
let unwrapSSN (SSN s) = s
// Printing single-case DUs is simple with unwrapping functions.
printfn $"Address: {address |> unwrapAddress}, Name: {name |> unwrapName}, and SSN: {ssn |> unwrapSSN}"
위 예제에서 보듯, 단일 case 공용체에서 감싸고 있는 값을 꺼내려면 반드시 명시적으로 풀어야(unwrap) 해요.
거기에 더해 DU는 재귀 정의도 지원해서 트리나 본질적으로 재귀적인 데이터를 쉽게 표현할 수 있어요. 예를 들어 exists와 insert 함수를 가진 이진 탐색 트리(Binary Search Tree)는 이렇게 표현할 수 있습니다.
/// Discriminated Unions also support recursive definitions.
///
/// This represents a Binary Search Tree, with one case being the Empty tree,
/// and the other being a Node with a value and two subtrees.
///
/// Note 'T here is a type parameter, indicating that 'BST' is a generic type.
/// More on generics later.
type BST<'T> =
| Empty
| Node of value:'T * left: BST<'T> * right: BST<'T>
/// Check if an item exists in the binary search tree.
/// Searches recursively using Pattern Matching. Returns true if it exists; otherwise, false.
let rec exists item bst =
match bst with
| Empty -> false
| Node (x, left, right) ->
if item = x then true
elif item < x then (exists item left) // Check the left subtree.
else (exists item right) // Check the right subtree.
/// Inserts an item in the Binary Search Tree.
/// Finds the place to insert recursively using Pattern Matching, then inserts a new node.
/// If the item is already present, it does not insert anything.
let rec insert item bst =
match bst with
| Empty -> Node(item, Empty, Empty)
| Node(x, left, right) as node ->
if item = x then node // No need to insert, it already exists; return the node.
elif item < x then Node(x, insert item left, right) // Call into left subtree.
else Node(x, left, insert item right) // Call into right subtree.
DU 덕분에 트리의 재귀적 구조를 데이터 타입 자체로 표현할 수 있어요. 그래서 이 재귀 구조를 다루는 작업이 직관적이고 정확성을 보장하기 쉽습니다. 아래에서 보듯 패턴 매칭에서도 자연스럽게 쓰이고요.
패턴 매칭 (Pattern Matching)
패턴 매칭(Pattern Matching)은 F# 타입에 대해 안전하게 작업할 수 있게 해 주는 F#의 기능이에요. 위 예제들에서 match x with ... 구문을 꽤 많이 봤을 텐데요, 이 구성 요소는 데이터 타입의 "형태"를 이해할 수 있는 컴파일러가, 완전한 패턴 매칭(Exhaustive Pattern Matching) 을 통해 그 데이터 타입을 쓸 때 가능한 모든 경우를 빠짐없이 처리하도록 강제합니다. 이는 정확성 측면에서 엄청나게 강력한데, 보통 런타임에나 신경 쓸 문제를 컴파일 타임의 문제로 "끌어올리는(lift)" 데 영리하게 활용할 수 있어요.
module PatternMatching =
/// A record for a person's first and last name
type Person = {
First : string
Last : string
}
/// A Discriminated Union of 3 different kinds of employees
type Employee =
| Engineer of engineer: Person
| Manager of manager: Person * reports: List<Employee>
| Executive of executive: Person * reports: List<Employee> * assistant: Employee
/// Count everyone underneath the employee in the management hierarchy,
/// including the employee. The matches bind names to the properties
/// of the cases so that those names can be used inside the match branches.
/// Note that the names used for binding do not need to be the same as the
/// names given in the DU definition above.
let rec countReports(emp : Employee) =
1 + match emp with
| Engineer(person) ->
0
| Manager(person, reports) ->
reports |> List.sumBy countReports
| Executive(person, reports, assistant) ->
(reports |> List.sumBy countReports) + countReports assistant
다음 예제에서 소개할 _ 패턴은 여러분이 이미 본 적이 있을 거예요. 이를 와일드카드 패턴(Wildcard Pattern)이라고 하는데, "그게 뭐든 상관없다"라고 말하는 방법이에요. 편리하긴 하지만, 조심하지 않으면 완전한 패턴 매칭을 우회해서 컴파일 타임의 강제를 놓칠 수 있어요. 분해한 타입의 특정 조각에 신경 쓰지 않을 때, 또는 패턴 매칭 식에서 의미 있는 모든 경우를 다 열거한 다음 마지막 절에서 주로 쓰는 게 좋습니다.
아래 예제는 파싱 작업이 실패했을 때 _ case를 사용합니다.
/// Find all managers/executives named "Dave" who do not have any reports.
/// This uses the 'function' shorthand to as a lambda expression.
let findDaveWithOpenPosition(emps : List<Employee>) =
emps
|> List.filter(function
| Manager({First = "Dave"}, []) -> true // [] matches an empty list.
| Executive({First = "Dave"}, [], _) -> true
| _ -> false) // '_' is a wildcard pattern that matches anything.
// This handles the "or else" case.
/// You can also use the shorthand function construct for pattern matching,
/// which is useful when you're writing functions which make use of Partial Application.
let private parseHelper (f: string -> bool * 'T) = f >> function
| (true, item) -> Some item
| (false, _) -> None
let parseDateTimeOffset = parseHelper DateTimeOffset.TryParse
let result = parseDateTimeOffset "1970-01-01"
match result with
| Some dto -> printfn "It parsed!"
| None -> printfn "It didn't parse!"
// Define some more functions which parse with the helper function.
let parseInt = parseHelper Int32.TryParse
let parseDouble = parseHelper Double.TryParse
let parseTimeSpan = parseHelper TimeSpan.TryParse
패턴 매칭에 함께 쓰면 강력한 또 하나의 구성 요소가 활성 패턴(Active Patterns)이에요. 활성 패턴은 입력 데이터를 사용자 정의 형태로 분할해서, 패턴 매칭이 일어나는 지점에서 그 데이터를 분해(decompose)할 수 있게 해 줍니다. 매개변수화할 수도 있어서 분할을 함수로 정의할 수 있죠. 앞선 예제를 확장해서 활성 패턴을 지원하도록 만들면 이렇게 됩니다.
let (|Int|_|) = parseInt
let (|Double|_|) = parseDouble
let (|Date|_|) = parseDateTimeOffset
let (|TimeSpan|_|) = parseTimeSpan
/// Pattern Matching via 'function' keyword and Active Patterns often looks like this.
let printParseResult = function
| Int x -> printfn $"%d{x}"
| Double x -> printfn $"%f{x}"
| Date d -> printfn $"%O{d}"
| TimeSpan t -> printfn $"%O{t}"
| _ -> printfn "Nothing was parse-able!"
// Call the printer with some different values to parse.
printParseResult "12"
printParseResult "12.045"
printParseResult "12/28/2016"
printParseResult "9:01PM"
printParseResult "banana!"
옵션 (Options)
판별 공용체 타입의 특별한 경우 하나가 바로 옵션(Option) 타입인데, 워낙 유용해서 F# 핵심 라이브러리에 포함되어 있어요.
옵션 타입(The Option Type)은 두 가지 경우 중 하나를 나타내는 타입입니다. 값이 있거나, 아무것도 없거나요. 특정 연산의 결과로 값이 나올 수도 있고 안 나올 수도 있는 모든 시나리오에서 사용됩니다. 이렇게 되면 두 경우를 모두 처리하도록 강제되어, 런타임 문제가 아니라 컴파일 타임의 문제가 돼요. 특정 연산의 결과가 없을 때 null을 쓰는 대신 옵션을 쓰는 API가 많은데, 그러면 여러 상황에서 NullReferenceException을 걱정할 필요가 없어집니다.
module OptionValues =
/// First, define a zip code defined via Single-case Discriminated Union.
type ZipCode = ZipCode of string
/// Next, define a type where the ZipCode is optional.
type Customer = { ZipCode: ZipCode option }
/// Next, define an interface type that represents an object to compute the shipping zone for the customer's zip code,
/// given implementations for the 'getState' and 'getShippingZone' abstract methods.
type IShippingCalculator =
abstract GetState : ZipCode -> string option
abstract GetShippingZone : string -> int
/// Next, calculate a shipping zone for a customer using a calculator instance.
/// This uses combinators in the Option module to allow a functional pipeline for
/// transforming data with Optionals.
let CustomerShippingZone (calculator: IShippingCalculator, customer: Customer) =
customer.ZipCode
|> Option.bind calculator.GetState
|> Option.map calculator.GetShippingZone
위 예제에서 Option.bind, Option.map 같은 콤비네이터로 옵셔널 데이터를 처리하는 함수형 파이프라인을 만드는 모습을 볼 수 있어요.
측정 단위 (Units of Measure)
F#의 타입 시스템에는 숫자 리터럴에 문맥을 부여해 주는 측정 단위(Units of Measure)라는 기능이 있어요. 측정 단위를 쓰면 숫자 타입에 "미터(Meters)" 같은 단위를 연결할 수 있고, 함수가 숫자 리터럴 자체가 아니라 단위에 대해 작업하도록 할 수 있습니다. 이렇게 하면 컴파일러가 전달된 숫자 리터럴의 타입이 특정 문맥에서 말이 되는지 검증해서, 그런 류의 작업과 관련된 런타임 오류를 없애 줍니다.
module UnitsOfMeasure =
/// First, open a collection of common unit names
open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames
/// Define a unitized constant
let sampleValue1 = 1600.0<meter>
/// Next, define a new unit type
[<Measure>]
type mile =
/// Conversion factor mile to meter.
static member asMeter = 1609.34<meter/mile>
/// Define a unitized constant
let sampleValue2 = 500.0<mile>
/// Compute metric-system constant
let sampleValue3 = sampleValue2 * mile.asMeter
// Values using Units of Measure can be used just like the primitive numeric type for things like printing.
printfn $"After a %f{sampleValue1} race I would walk %f{sampleValue2} miles which would be %f{sampleValue3} meters"
F# 핵심 라이브러리는 많은 SI 단위 타입과 단위 변환을 정의하고 있어요. 더 알고 싶다면 FSharp.Data.UnitSystems.SI.UnitSymbols Namespace 문서를 확인해 보세요.
객체 지향 프로그래밍 (Object Programming)
F#은 클래스, 인터페이스(Interfaces), 추상 클래스(Abstract Classes), 상속(Inheritance) 등을 통해 객체 지향 프로그래밍을 완전히 지원해요.
클래스(Classes)는 .NET 객체를 나타내는 타입으로, 프로퍼티·메서드·이벤트를 그 멤버(Members)로 가질 수 있어요.
module DefiningClasses =
/// A simple two-dimensional Vector class.
///
/// The class's constructor is on the first line,
/// and takes two arguments: dx and dy, both of type 'double'.
type Vector2D(dx : double, dy : double) =
/// This internal field stores the length of the vector, computed when the
/// object is constructed
let length = sqrt (dx*dx + dy*dy)
// 'this' specifies a name for the object's self-identifier.
// In instance methods, it must appear before the member name.
member this.DX = dx
member this.DY = dy
member this.Length = length
/// This member is a method. The previous members were properties.
member this.Scale(k) = Vector2D(k * this.DX, k * this.DY)
/// This is how you instantiate the Vector2D class.
let vector1 = Vector2D(3.0, 4.0)
/// Get a new scaled vector object, without modifying the original object.
let vector2 = vector1.Scale(10.0)
printfn $"Length of vector1: %f{vector1.Length}\nLength of vector2: %f{vector2.Length}"
제네릭 클래스를 정의하는 것도 간단해요.
module DefiningGenericClasses =
type StateTracker<'T>(initialElement: 'T) =
/// This internal field store the states in a list.
let mutable states = [ initialElement ]
/// Add a new element to the list of states.
member this.UpdateState newState =
states <- newState :: states // use the '<-' operator to mutate the value.
/// Get the entire list of historical states.
member this.History = states
/// Get the latest state.
member this.Current = states.Head
/// An 'int' instance of the state tracker class. Note that the type parameter is inferred.
let tracker = StateTracker 10
// Add a state
tracker.UpdateState 17
인터페이스를 구현하려면 interface ... with 구문이나 객체 표현식(Object Expression)을 사용할 수 있어요.
module ImplementingInterfaces =
/// This is a type that implements IDisposable.
type ReadFile() =
let file = new System.IO.StreamReader("readme.txt")
member this.ReadLine() = file.ReadLine()
// This is the implementation of IDisposable members.
interface System.IDisposable with
member this.Dispose() = file.Close()
/// This is an object that implements IDisposable via an Object Expression
/// Unlike other languages, such as C#, a new type definition is not needed
/// to implement an interface.
let interfaceImplementation =
{ new System.IDisposable with
member this.Dispose() = printfn "disposed" }
두 번째 경우처럼 객체 표현식을 쓰면 C# 같은 다른 언어와 달리 새 타입 정의 없이도 인터페이스를 구현할 수 있는 게 포인트예요.
어떤 타입을 써야 할까 (Which Types to Use)
클래스, 레코드, 판별 공용체, 튜플이 다 있다 보니 중요한 질문이 생겨요. 그럼 뭘 써야 하지? 인생의 대부분이 그렇듯, 답은 상황에 달려 있어요.
- 튜플은 함수에서 여러 값을 반환하거나, 값들을 임시로 묶어 하나의 값처럼 쓰기에 아주 좋아요.
- 레코드는 튜플에서 한 단계 "업그레이드"된 것으로, 이름이 붙은 레이블과 선택적 멤버를 지원해요. 프로그램을 오가는 데이터를 형식적인 절차 없이 표현하기에 좋고, 구조적 동등성을 가지므로 비교와 함께 쓰기 쉽습니다.
- 판별 공용체는 쓰임새가 정말 많은데, 핵심 이점은 패턴 매칭과 함께 사용해서 데이터가 가질 수 있는 모든 "형태"를 빠짐없이 처리하게 해 준다는 점이에요.
- 클래스는 정보를 표현하면서 동시에 그 정보를 기능과 엮어야 할 때처럼 이유가 많아요. 경험칙으로 말하면, 어떤 데이터에 개념적으로 묶인 기능이 있다면 클래스와 객체 지향 프로그래밍 원칙을 쓰는 게 상당한 이점이 됩니다. 또한 클래스는 C#이나 Visual Basic과의 상호 운용에서 선호되는 데이터 타입인데, 그 언어들은 거의 모든 것을 클래스로 표현하거든요.
더 알아보기
이제 언어의 주요 기능들을 훑어봤으니, 첫 F# 프로그램을 작성할 준비가 됐어요! Getting Started 문서에서 개발 환경을 세팅하고 코드를 작성하는 법을 배워 보세요.
또한 F# Language Reference 문서에서 F#에 관한 포괄적인 개념 자료 모음을 확인할 수 있어요.