F# 5의 새로운 기능

F# 5의 새로운 기능

이 글은 F# 언어와 F# Interactive(FSI)에 F# 5가 추가해 준 여러 개선 사항들을 하나씩 살펴보는 내용이에요. F# 5는 .NET 5와 함께 배포됐죠. 최신 .NET SDK는 .NET 다운로드 페이지에서 받을 수 있어요.

출처: What's new in F# 5 — Microsoft Learn

본문

F# 5는 모든 .NET Core 배포판과 Visual Studio 툴링에서 사용할 수 있어요. 자세한 시작 방법은 F# 시작하기 문서를 참고하면 돼요.

F# 스크립트에서의 패키지 참조

F# 5부터는 F# 스크립트에서 패키지 참조를 쓸 수 있어요. 문법은 #r "nuget:..." 형태예요. 예를 들어 이런 패키지 참조를 생각해 볼게요.

#r "nuget: Newtonsoft.Json"

open Newtonsoft.Json

let o = {| X = 2; Y = "Hello" |}

printfn $"{JsonConvert.SerializeObject o}"

패키지 이름 뒤에 명시적 버전을 붙이는 것도 가능해요.

#r "nuget: Newtonsoft.Json,11.0.1"

이 패키지 참조는 ML.NET처럼 네이티브 의존성을 갖는 패키지도 지원해요.

또한 의존 .dll 참조 순서에 특별한 요구 사항이 있는 패키지도 지원돼요. 예를 들어 FParsec 패키지는 예전에는 사용자가 F# Interactive에서 FParsec.dll을 참조하기 전에 먼저 의존하는 FParsecCS.dll을 참조해야만 했어요. 이제는 그럴 필요 없이 다음처럼 패키지를 참조하면 돼요.

#r "nuget: FParsec"

open FParsec

let test p str =
    match run p str with
    | Success(result, _, _)   -> printfn $"Success: {result}"
    | Failure(errorMsg, _, _) -> printfn $"Failure: {errorMsg}"

test pfloat "1.234"

이 기능은 F# 툴링 RFC FST-1027로 구현됐어요. 패키지 참조에 대한 더 자세한 내용은 F# Interactive 튜토리얼을 참고하면 돼요.

문자열 보간(String interpolation)

F# 보간 문자열은 C#이나 JavaScript의 보간 문자열과 꽤 비슷해요. 문자열 리터럴 안의 "구멍"(hole)에 코드를 쓸 수 있게 해 주는 기능이죠. 기본 예시를 볼게요.

let name = "Phillip"
let age = 29
printfn $"Name: {name}, Age: {age}"

printfn $"I think {3.0 + 0.14} is close to {System.Math.PI}!"

그런데 F# 보간 문자열은 sprintf 함수처럼 형식화된 보간(typed interpolation)도 지원해요. 보간 컨텍스트 안의 식이 특정 타입을 따르도록 강제할 수 있죠. 형식 지정자(format specifier)도 같은 것을 사용해요.

let name = "Phillip"
let age = 29

printfn $"Name: %s{name}, Age: %d{age}"

// Error: type mismatch
printfn $"Name: %s{age}, Age: %d{name}"

위 형식화된 보간 예시에서 %s는 보간 값이 string 타입이길 요구하고, %dinteger 타입이길 요구해요.

여기에 더해 보간 컨텍스트 안에는 임의의 F# 식(또는 여러 식)을 넣을 수 있어요. 이렇게 더 복잡한 식을 쓰는 것도 가능하죠.

let str =
    $"""The result of squaring each odd item in {[1..10]} is:
{
    let square x = x * x
    let isOdd x = x % 2 <> 0
    let oddSquares xs =
        xs
        |> List.filter isOdd
        |> List.map square
    oddSquares [1..10]
}
"""

다만 실제 코드에서는 이렇게까지는 자주 쓰지 않는 게 좋아요.

이 기능은 F# RFC FS-1001로 구현됐어요.

nameof 지원

F# 5는 nameof 연산자를 지원해요. 이 연산자는 대상으로 하는 심볼을 해석해서 F# 소스 안에서의 그 이름을 생성해 줘요. 로깅 같은 다양한 상황에서 유용하고, 소스 코드가 바뀌어도 로깅을 안전하게 지켜 줘요.

let months =
    [
        "January"; "February"; "March"; "April";
        "May"; "June"; "July"; "August"; "September";
        "October"; "November"; "December"
    ]

let lookupMonth month =
    if (month > 12 || month < 1) then
        invalidArg (nameof month) (sprintf "Value passed in was %d." month)

    months[month-1]

printfn $"{lookupMonth 12}"
printfn $"{lookupMonth 1}"
printfn $"{lookupMonth 13}"

마지막 줄은 예외를 던질 텐데, 오류 메시지에 "month"가 표시돼요.

거의 모든 F# 구성 요소의 이름을 가져올 수 있어요.

module M =
    let f x = nameof x

printfn $"{M.f 12}"
printfn $"{nameof M}"
printfn $"{nameof M.f}"

여기에 더해 연산자 동작에 관한 변화가 세 가지 더 있어요. 제네릭 타입 매개변수를 위한 nameof<'type-parameter> 형식이 추가됐고, 패턴 매칭 식에서 nameof를 패턴으로 쓸 수 있게 됐으며, 연산자의 이름을 가져올 수 있게 됐죠.

연산자의 이름을 가져오면 그 소스 문자열을 얻어요. 컴파일된 형태가 필요하다면 연산자의 컴파일된 이름을 사용하면 돼요.

nameof(+) // "+"
nameof op_Addition // "op_Addition"

타입 매개변수의 이름을 가져올 때는 문법이 조금 달라요.

type C<'TType> =
    member _.TypeName = nameof<'TType>

이건 typeof<'T>typedefof<'T> 연산자와 비슷한 맥락이에요.

F# 5는 또한 match 식에서 쓸 수 있는 nameof 패턴도 지원해요.

[<Struct; IsByRefLike>]
type RecordedEvent = { EventType: string; Data: ReadOnlySpan<byte> }

type MyEvent =
    | AData of int
    | BData of string

let deserialize (e: RecordedEvent) : MyEvent =
    match e.EventType with
    | nameof AData -> AData (JsonSerializer.Deserialize<int> e.Data)
    | nameof BData -> BData (JsonSerializer.Deserialize<string> e.Data)
    | t -> failwithf "Invalid EventType: %s" t

위 코드는 match 식에서 문자열 리터럴 대신 nameof를 사용해요.

이 기능은 F# RFC FS-1003으로 구현됐어요.

open 형식 선언(Open type declarations)

F# 5는 open 형식 선언도 지원해요. 이는 C#에서 정적 클래스를 여는(open) 것과 비슷한데, F# 의미론에 맞게 문법과 동작이 조금 다르죠.

open 형식 선언을 쓰면 어떤 타입이든 open 해서 그 안의 정적 내용물을 드러낼 수 있어요. 게다가 F#으로 정의된 합집합(union)과 레코드도 open 해서 내용물을 노출할 수 있어요. 예를 들어 모듈 안에 정의된 합집합이 있는데 그 케이스들에 접근하고 싶고, 모듈 전체까지는 열고 싶지 않을 때 유용하죠.

open type System.Math

let x = Min(1.0, 2.0)

module M =
    type DU = A | B | C

    let someOtherFunction x = x + 1

// Open only the type inside the module
open type M.DU

printfn $"{A}"

C#과 달리, F#에서 같은 이름의 멤버를 노출하는 두 타입을 open type 하면 나중에 open된 타입의 멤버가 다른 이름을 가려요(shadowing). 이는 F#에 이미 존재하는 shadowing 의미론과 일치하는 동작이에요.

이 기능은 F# RFC FS-1068로 구현됐어요.

내장 데이터 타입의 일관된 슬라이싱 동작

F# 5 이전에는 내장 FSharp.Core 데이터 타입(배열, 리스트, 문자열, 2D 배열, 3D 배열, 4D 배열)의 슬라이싱 동작이 일관적이지 않았어요. 어떤 극단적인 경우는 예외를 던지고, 어떤 경우는 던지지 않았죠. F# 5에서는 모든 내장 타입이 만들 수 없는 슬라이스에 대해 이제 빈 슬라이스를 반환해요.

let l = [ 1..10 ]
let a = [| 1..10 |]
let s = "hello!"

// Before: would return empty list
// F# 5: same
let emptyList = l[-2..(-1)]

// Before: would throw exception
// F# 5: returns empty array
let emptyArray = a[-2..(-1)]

// Before: would throw exception
// F# 5: returns empty string
let emptyString = s[-2..(-1)]

이 기능은 F# RFC FS-1077로 구현됐어요.

FSharp.Core의 3D/4D 배열 고정 인덱스 슬라이싱

F# 5는 내장 3D, 4D 배열 타입에서 고정 인덱스로 슬라이싱하는 것을 지원해요.

이를 설명하기 위해 다음 3D 배열을 생각해 볼게요.

z = 0

x\y 0 1

0 0 1

1 2 3

z = 1

x\y 0 1

0 4 5

1 6 7

이 배열에서 슬라이스 [| 4; 5 |]를 추출하고 싶다면 어떻게 해야 할까요? 이제 아주 간단해졌어요!

// First, create a 3D array to slice

let dim = 2
let m = Array3D.zeroCreate<int> dim dim dim

let mutable count = 0

for z in 0..dim-1 do
    for y in 0..dim-1 do
        for x in 0..dim-1 do
            m[x,y,z] <- count
            count <- count + 1

// Now let's get the [4;5] slice!
m[*, 0, 1]

이 기능은 F# RFC FS-1077b로 구현됐어요.

F# quotations 개선

F# 코드 quotation은 이제 타입 제약 정보를 보존할 수 있어요. 다음 예시를 볼게요.

open FSharp.Linq.RuntimeHelpers

let eval q = LeafExpressionConverter.EvaluateQuotation q

let inline negate x = -x
// val inline negate: x: ^a ->  ^a when  ^a : (static member ( ~- ) :  ^a ->  ^a)

<@ negate 1.0 @>  |> eval

inline 함수가 생성한 제약이 코드 quotation 안에 보존돼요. 이제 negate 함수의 quotation 형태를 평가할 수 있죠.

이 기능은 F# RFC FS-1071로 구현됐어요.

적용 계산식(Applicative Computation Expressions)

계산식(Computation Expressions, CEs)은 오늘날 "컨텍스트가 있는 계산"(contextual computations), 더 함수형 프로그래밍 친화적인 용어로는 모나딕 계산을 모델링하는 데 쓰여요.

F# 5는 다른 계산 모델을 제공하는 적용 계산식(appplicative CEs)을 도입해요. 적용 계산식은 모든 계산이 서로 독립적일 때 더 효율적인 계산을 가능하게 해 주고, 그 결과물은 마지막에 누적돼요. 계산들이 서로 독립적이면 자연스럽게 병렬화도 쉬워져서, 계산식 작성자가 더 효율적인 라이브러리를 만들 수 있게 돼요. 다만 제약도 하나 따라와요. 이전에 계산된 값에 의존하는 계산은 허용되지 않는다는 점이죠.

다음 예시는 Result 타입을 위한 기본 적용 계산식을 보여줘요.

// First, define a 'zip' function
module Result =
    let zip x1 x2 =
        match x1,x2 with
        | Ok x1res, Ok x2res -> Ok (x1res, x2res)
        | Error e, _ -> Error e
        | _, Error e -> Error e

// Next, define a builder with 'MergeSources' and 'BindReturn'
type ResultBuilder() =
    member _.MergeSources(t1: Result<'T,'U>, t2: Result<'T1,'U>) = Result.zip t1 t2
    member _.BindReturn(x: Result<'T,'U>, f) = Result.map f x

let result = ResultBuilder()

let run r1 r2 r3 =
    // And here is our applicative!
    let res1: Result<int, string> =
        result {
            let! a = r1
            and! b = r2
            and! c = r3
            return a + b - c
        }

    match res1 with
    | Ok x -> printfn $"{nameof res1} is: %d{x}"
    | Error e -> printfn $"{nameof res1} is: {e}"

let printApplicatives () =
    let r1 = Ok 2
    let r2 = Ok 3 // Error "fail!"
    let r3 = Ok 4

    run r1 r2 r3
    run r1 (Error "failure!") r3

만약 지금 라이브러리에서 CE를 노출하는 라이브러리 작성자라면, 알아둬야 할 추가 고려 사항이 몇 가지 있어요.

이 기능은 F# RFC FS-1063으로 구현됐어요.

인터페이스를 서로 다른 제네릭 인스턴스화로 구현

이제 같은 인터페이스를 서로 다른 제네릭 인스턴스화로 구현할 수 있어요.

type IA<'T> =
    abstract member Get : unit -> 'T

type MyClass() =
    interface IA<int> with
        member x.Get() = 1
    interface IA<string> with
        member x.Get() = "hello"

let mc = MyClass()
let iaInt = mc :> IA<int>
let iaString = mc :> IA<string>

iaInt.Get() // 1
iaString.Get() // "hello"

이 기능은 F# RFC FS-1031로 구현됐어요.

기본 인터페이스 멤버 소비(Default interface member consumption)

F# 5에서는 기본 구현(default implementation)을 가진 인터페이스를 소비할 수 있어요.

C#에서 이렇게 정의된 인터페이스를 생각해 볼게요.

using System;

namespace CSharp
{
    public interface MyDim
    {
        public int Z => 0;
    }
}

이 인터페이스는 인터페이스를 구현하는 표준 방법 중 어떤 것으로든 F#에서 소비할 수 있어요.

open CSharp

// You can implement the interface via a class
type MyType() =
    member _.M() = ()

    interface MyDim

let md = MyType() :> MyDim
printfn $"DIM from C#: %d{md.Z}"

// You can also implement it via an object expression
let md' = { new MyDim }
printfn $"DIM from C# but via Object Expression: %d{md'.Z}"

이렇게 하면 사용자가 기본 구현을 소비하길 기대하는, 최신 C#으로 작성된 C# 코드와 .NET 컴포넌트를 안전하게 활용할 수 있어요.

이 기능은 F# RFC FS-1074로 구현됐어요.

nullable 값 타입과의 단순화된 상호 운용

nullable (값) 타입 — 예전에는 Nullable Types이라고 불렸던 — 은 F#이 오랫동안 지원해 왔어요. 하지만 값을 넘길 때마다 Nullable이나 Nullable<SomeType> 래퍼를 직접 만들어야 해서 상호 작용이 전통적으로 꽤 번거로웠죠. 이제 대상 타입이 일치하면 컴파일러가 값 타입을 Nullable<ThatValueType>으로 암시적으로 변환해 줘요. 다음 코드가 가능해진 거예요.

#r "nuget: Microsoft.Data.Analysis"

open Microsoft.Data.Analysis

let dateTimes = PrimitiveDataFrameColumn<DateTime>("DateTimes")

// The following line used to fail to compile
dateTimes.Append(DateTime.Parse("2019/01/01"))

// The previous line is now equivalent to this line
dateTimes.Append(Nullable<DateTime>(DateTime.Parse("2019/01/01")))

이 기능은 F# RFC FS-1075로 구현됐어요.

미리 보기: 역방향 인덱스(Reverse indexes)

F# 5는 역방향 인덱스를 허용하는 미리 보기 기능도 도입해요. 문법은 ^idx예요. 리스트 끝에서 1번째 요소를 가져오는 방법을 볼게요.

let xs = [1..10]

// Get element 1 from the end:
xs[^1]

// From the end slices

let lastTwoOldStyle = xs[(xs.Length-2)..]

let lastTwoNewStyle = xs[^1..]

lastTwoOldStyle = lastTwoNewStyle // true

자체 타입에 대한 역방향 인덱스를 정의할 수도 있어요. 그러려면 다음 메서드를 구현하면 돼요.

GetReverseIndex: dimension: int -> offset: int

Span<'T> 타입의 예시를 볼게요.

open System

type Span<'T> with
    member sp.GetSlice(startIdx, endIdx) =
        let s = defaultArg startIdx 0
        let e = defaultArg endIdx sp.Length
        sp.Slice(s, e - s)

    member sp.GetReverseIndex(_, offset: int) =
        sp.Length - offset

let printSpan (sp: Span<int>) =
    let arr = sp.ToArray()
    printfn $"{arr}"

let run () =
    let sp = [| 1; 2; 3; 4; 5 |].AsSpan()

    // Pre-# 5.0 slicing on a Span<'T>
    printSpan sp[0..] // [|1; 2; 3; 4; 5|]
    printSpan sp[..3] // [|1; 2; 3|]
    printSpan sp[1..3] // |2; 3|]

    // Same slices, but only using from-the-end index
    printSpan sp[..^0] // [|1; 2; 3; 4; 5|]
    printSpan sp[..^2] // [|1; 2; 3|]
    printSpan sp[^4..^2] // [|2; 3|]

run() // Prints the same thing twice

이 기능은 F# RFC FS-1076으로 구현됐어요.

미리 보기: 계산식에서 사용자 지정 키워드의 오버로드

계산식은 라이브러리와 프레임워크 작성자에게 강력한 기능이에요. 잘 알려진 멤버를 정의해 작업 중인 도메인을 위한 DSL을 만들 수 있게 해 주므로, 컴포넌트의 표현력을 크게 높일 수 있죠.

F# 5는 계산식에서 사용자 지정 연산(custom operations)의 오버로딩을 미리 보기로 지원해요. 이 덕분에 다음 코드를 작성하고 소비할 수 있게 됐어요.

open System

type InputKind =
    | Text of placeholder:string option
    | Password of placeholder: string option

type InputOptions =
  { Label: string option
    Kind : InputKind
    Validators : (string -> bool) array }

type InputBuilder() =
    member t.Yield(_) =
      { Label = None
        Kind = Text None
        Validators = [||] }

    [<CustomOperation("text")>]
    member this.Text(io, ?placeholder) =
        { io with Kind = Text placeholder }

    [<CustomOperation("password")>]
    member this.Password(io, ?placeholder) =
        { io with Kind = Password placeholder }

    [<CustomOperation("label")>]
    member this.Label(io, label) =
        { io with Label = Some label }

    [<CustomOperation("with_validators")>]
    member this.Validators(io, [<ParamArray>] validators) =
        { io with Validators = validators }

let input = InputBuilder()

let name =
    input {
    label "Name"
    text
    with_validators
        (String.IsNullOrWhiteSpace >> not)
    }

let email =
    input {
    label "Email"
    text "Your email"
    with_validators
        (String.IsNullOrWhiteSpace >> not)
        (fun s -> s.Contains "@")
    }

let password =
    input {
    label "Password"
    password "Must contains at least 6 characters, one number and one uppercase"
    with_validators
        (String.exists Char.IsUpper)
        (String.exists Char.IsDigit)
        (fun s -> s.Length >= 6)
    }

이 변화 이전에는 InputBuilder 타입 자체는 그대로 쓸 수 있었지만, 예시처럼 사용하는 방식은 쓸 수 없었어요. 이제 오버로드, 선택적 매개변수, 그리고 System.ParamArray 타입까지 허용되니 예상한 대로 모든 것이 동작해요.

이 기능은 F# RFC FS-1056으로 구현됐어요.

더 알아보기