F# 9의 새로운 기능
F# 9의 새로운 기능 (What's new in F# 9)
F# 9는 여러분이 짜는 프로그램을 더 안전하고, 더 튼튼하고, 더 빠르게 만들어 주는 다양한 개선을 가져왔어요. 이 글은 F# 오픈소스 코드 저장소에서 만들어지는 F# 9의 주요 변화를 집중해서 다뤄볼게요.
F# 9는 .NET 9에서 사용할 수 있어요. 최신 .NET SDK는 .NET 다운로드 페이지에서 받으면 됩니다.
본문
Nullable 참조 형식 (Nullable reference types)
F#은 원래 null을 피하도록 설계되었지만, C#으로 작성된 .NET 라이브러리와 연동할 때는 null이 스며들 수밖에 없어요. 이제 F#은 null을 유효한 값으로 가질 수 있는 참조 형식을 타입 안전하게 다루는 방법을 제공합니다.
더 자세한 내용은 Nullable Reference Types in F# 9 블로그 글을 참고해 주세요.
몇 가지 예시를 볼게요.
// Declared type at let-binding
let notAValue: string | null = null
let isAValue: string | null = "hello world"
let isNotAValue2: string = null // gives a nullability warning
let getLength (x: string | null) = x.Length // gives a nullability warning since x is a nullable string
// Parameter to a function
let len (str: string | null) =
match str with
| null -> -1
| s -> s.Length // binds a non-null result - compiler eliminated "null" after the first clause
// Parameter to a function
let len (str: string | null) =
let s = nullArgCheck "str" str // Returns a non-null string
s.Length // binds a non-null result
// Declared type at let-binding
let maybeAValue: string | null = hopefullyGetAString()
// Array type signature
let f (arr: (string | null)[]) = ()
// Generic code, note 'T must be constrained to be a reference type
let findOrNull (index: int) (list: 'T list) : 'T | null when 'T : not struct =
match List.tryItem index list with
| Some item -> item
| None -> null
여기서 string | null과 같이 형식에 | null을 붙이는 게 핵심이에요. match로 null인 경우를 먼저 처리하면 컴파일러가 이후에는 null이 아닌 값이라는 걸 알아서 알려줄 수 있고요, null이 들어올 수 있는 값에서 길이를 재는 등 위험한 연산을 하면 nullability 경고를 내줍니다.
판별 공용체(DU)의 .Is* 속성 (Discriminated union .Is* properties)
판별 공용체는 이제 각 case마다 자동 생성되는 속성을 갖게 돼요. 덕분에 어떤 값이 특정 case에 해당하는지를 손쉽게 확인할 수 있습니다. 예를 들어 이런 형식이 있다고 해볼게요.
type Contact =
| Email of address: string
| Phone of countryCode: int * number: string
type Person = { name: string; contact: Contact }
예전에는 이렇게 매번 match를 써야 했어요.
let canSendEmailTo person =
match person.contact with
| Email _ -> true
| _ -> false
이제는 이렇게 깔끔하게 쓸 수 있어요.
let canSendEmailTo person =
person.contact.IsEmail
부분 active pattern이 unit option 대신 bool을 반환할 수 있어요 (Partial active patterns can return bool instead of unit option)
예전에는 부분 active pattern이 매치가 되면 Some ()를, 그렇지 않으면 None을 반환했어요. 이제는 bool을 반환해도 됩니다.
예를 들어, 아래와 같은 active pattern이 있다고 해볼게요.
match key with
| CaseInsensitive "foo" -> ...
| CaseInsensitive "bar" -> ...
예전에는 이렇게 작성했었죠.
let (|CaseInsensitive|_|) (pattern: string) (value: string) =
if String.Equals(value, pattern, StringComparison.OrdinalIgnoreCase) then
Some ()
else
None
이제는 이렇게 그냥 bool을 반환하면 됩니다.
let (|CaseInsensitive|_|) (pattern: string) (value: string) =
String.Equals(value, pattern, StringComparison.OrdinalIgnoreCase)
Some ()라는 표시용 값에 신경 쓸 필요 없이, 매치 여부를 판단하는 그 자체의 식을 그대로 던져 주면 되는 거예요.
인자가 주어지면 intrinsic 속성보다 확장 메서드를 우선하도록 (Prefer extension methods to intrinsic properties when arguments are provided)
일부 .NET 라이브러리에서는 intrinsic 속성과 같은 이름으로 확장 메서드를 정의하는 패턴을 써요. 이런 패턴에 맞추기 위해, F#은 이제 이 확장 메서드를 해석해서 타입 검사를 실패시키지 않습니다.
예시를 볼게요.
type Foo() =
member val X : int = 0 with get, set
[<Extension>]
type FooExt =
[<Extension>]
static member X (f: Foo, i: int) = f.X <- i; f
let f = Foo()
f.X(1) // We can now call the extension method to set the property and chain further calls
f.X(1)처럼 속성에 인자를 넘겨 호출하면, 이제는 이름이 충돌한다고 실패하는 대신 인자를 받는 확장 메서드로 해석해서 속성을 세팅한 뒤 다시 f를 반환해 체이닝까지 이어갈 수 있어요.
빈 본문의 계산식 (Empty-bodied computation expressions)
F#은 이제 빈 계산식(computation expression)을 지원해요.
let xs = seq { } // Empty sequence
let html =
div {
p { "Some content." }
p { } // Empty paragraph
}
빈 계산식을 쓰면 계산식 빌더의 Zero 메서드가 호출됩니다.
이전에 쓰던 builder { () } 같은 문법보다 훨씬 자연스러운 문법이에요.
해시 지시문에 문자열이 아닌 인자를 쓸 수 있어요 (Hash directives are allowed to take non-string arguments)
컴파일러에 전달하는 해시 지시문은 예전에는 따옴표로 감싼 문자열 인자만 허용했어요. 이제는 어떤 형식의 인자든 받을 수 있습니다.
예전에는 이랬다면,
#nowarn "0070"
#time "on"
지금은 이렇게 쓸 수 있어요.
#nowarn 0070
#time on
이 변경은 아래에 나올 두 가지 변경과도 이어지는 내용이에요.
fsi의 #help 지시문 확장 — REPL에서 문서 보여주기 (Extended #help directive in fsi to show documentation in the REPL)
F# Interactive의 #help 지시문은 이제 특정 객체나 함수에 대한 문서를 보여주며, 따옴표 없이 그대로 넘겨줄 수 있어요.
> #help List.map;;
Description:
Builds a new collection whose elements are the results of applying the given function
to each of the elements of the collection.
Parameters:
- mapping: The function to transform elements from the input list.
- list: The input list.
Returns:
The list of transformed elements.
Examples:
let inputs = [ "a"; "bbb"; "cc" ]
inputs |> List.map (fun x -> x.Length)
// Evaluates to [ 1; 3; 2 ]
Full name: Microsoft.FSharp.Collections.ListModule.map
Assembly: FSharp.Core.dll
더 자세한 내용은 Enhancing #help in F# Interactive 블로그 글을 참고해 주세요.
#nowarn이 FS 접두사를 지원해 경고를 끌 수 있어요 (Allow #nowarn to support the FS prefix on error codes to disable warnings)
예전에는 경고를 끄려고 #nowarn "FS0057"이라고 쓰면 Invalid warning number 'FS0057' 같은 오류가 났어요. 경고 번호 자체는 맞는데 그냥 FS 접두사가 붙으면 안 되는 거였죠.
지금은 번호가 접두사를 포함해도 그대로 받아들여지니까 그런 문제로 시간을 낭비하지 않아도 됩니다. 아래가 모두 동작해요.
#nowarn 57
#nowarn 0057
#nowarn FS0057
#nowarn "57"
#nowarn "0057"
#nowarn "FS0057"
프로젝트 안에서는 한 가지 스타일로 통일해서 쓰는 게 좋아요.
비재귀 함수나 let 바인딩 값에 TailCall 속성을 쓰면 경고 (Warning about TailCall attribute on non-recursive functions or let-bound values)
F#은 이제 [<TailCall>] 속성을 어울리지 않는 곳에 붙이면 경고를 내줘요. 이 속성은 코드가 하는 일에는 아무 영향이 없지만, 읽는 사람을 헷갈리게 할 수 있으니까요.
예를 들어, 이런 사용은 이제 경고를 발생시켜요.
[<TailCall>]
let someNonRecFun x = x + x
[<TailCall>]
let someX = 23
[<TailCall>]
let rec someRecLetBoundValue = nameof(someRecLetBoundValue)
속성 대상 강제 (Enforce attribute targets)
컴파일러는 이제 let 값, 함수, 공용체 case 선언, 암시적 생성자, 구조체(struct), 클래스에 대해 AttributeTargets를 올바르게 강제해요. 덕분에 Xunit 테스트에 unit 인자를 빠뜨리는 것 같은 눈에 잘 띄지 않는 버그를 막을 수 있습니다.
예전에는 이렇게 쓸 수 있었어요.
[<Fact>]
let ``this test always fails`` =
Assert.True(false)
dotnet test로 테스트를 돌리면 그냥 통과했죠. 왜냐하면 그건 실제로 함수가 아니라서 테스트 러너가 그냥 무시했기 때문이에요.
이제는 속성 대상이 올바르게 강제되므로 FS0842: 이런 오류를 받게 됩니다 — 이 속성은 이 언어 요소에는 사용할 수 없습니다.
표준 라이브러리(FSharp.Core) 업데이트
컬렉션을 위한 랜덤 함수 (Random functions for collections)
List, Array, Seq 모듈에 무작위 샘플링과 셔플을 위한 함수들이 새로 추가됐어요. 데이터 과학, 머신러닝, 게임 개발처럼 랜덤성이 필요한 시나리오에서 F#을 훨씬 쓰기 편하게 만들어 줍니다.
모든 함수에는 다음과 같은 변형(variant)이 있어요.
-
스레드 안전한 공유
Random인스턴스를 암시적으로 사용하는 것 -
Random인스턴스를 인자로 받는 것 -
0.0이상1.0미만의float값을 반환해야 하는 커스텀 randomizer 함수를 받는 것
사용 가능한 함수는 네 가지(각각 변형이 세 개씩)예요: Shuffle, Choice, Choices, Sample.
Shuffle
Shuffle 함수는 같은 형식과 크기의 새 컬렉션을 반환하는데, 각 항목이 무작위로 섞인 위치에 놓여요. 특정 위치에 놓일 확률은 컬렉션 길이에 따라 균등하게 가중됩니다.
let allPlayers = [ "Alice"; "Bob"; "Charlie"; "Dave" ]
let round1Order = allPlayers |> List.randomShuffle // [ "Charlie"; "Dave"; "Alice"; "Bob" ]
배열(array)에는 새 배열을 만들지 않고 기존 배열의 항목을 그 자리에서 섞는 InPlace 변형도 있어요.
Choice
Choice 함수는 주어진 컬렉션에서 무작위 요소 하나를 반환해요. 무작위 선택은 컬렉션 크기에 균등하게 가중됩니다.
let allPlayers = [ "Alice"; "Bob"; "Charlie"; "Dave" ]
let randomPlayer = allPlayers |> List.randomChoice // "Charlie"
Choices
Choices 함수는 입력 컬렉션에서 무작위 순서로 N개 요소를 선택하는데, 같은 요소가 여러 번 선택될 수 있어요.
let weather = [ "Raining"; "Sunny"; "Snowing"; "Windy" ]
let forecastForNext3Days = weather |> List.randomChoices 3 // [ "Windy"; "Snowing"; "Windy" ]
Sample
Sample 함수는 입력 컬렉션에서 무작위 순서로 N개 요소를 선택하는데, 같은 요소가 두 번 선택될 수는 없어요. N은 컬렉션 길이보다 클 수 없습니다.
let foods = [ "Apple"; "Banana"; "Carrot"; "Donut"; "Egg" ]
let today'sMenu = foods |> List.randomSample 3 // [ "Donut"; "Apple"; "Egg" ]
전체 함수 목록과 각 변형은 (RFC #1135)에서 확인할 수 있어요.
CustomOperationAttribute의 매개변수 없는 생성자 (Parameterless constructor for CustomOperationAttribute)
이 생성자는 계산식 빌더에 커스텀 연산을 만들기 훨씬 쉽게 해줘요. 대부분의 경우 이름이 메서드 이름과 같으니까, 명시적으로 이름을 주는 대신 메서드 이름을 그대로 사용할 수 있어요.
type FooBuilder() =
[<CustomOperation>] // Previously had to be [<CustomOperation("bar")>]
member _.bar(state) = state
C# 컬렉션 식에서 F# 리스트·셋 지원 (C# collection expression support for F# lists and sets)
C#에서 F# 리스트와 셋을 사용할 때, 이제 컬렉션 식(collection expression)으로 초기화할 수 있어요.
이런 대신에,
FSharpSet<int> mySet = SetModule.FromArray([1, 2, 3]);
이렇게 쓸 수 있어요.
FSharpSet<int> mySet = [ 1, 2, 3 ];
컬렉션 식 덕분에 C#에서 F#의 불변(immutable) 컬렉션을 훨씬 쓰기 편해졌어요. System.Collections.Immutable 컬렉션에는 없는 구조적 동등성(structural equality) 이 필요할 때 F# 컬렉션을 쓰고 싶어질 수 있는데, 바로 그럴 때 유용합니다.
개발자 생산성 개선 (Developer productivity improvements)
파서 복구 (Parser recovery)
파서 복구가 계속해서 개선되고 있어요. 즉, 코드를 편집하는 중간에 문법적으로 완전하지 않아도 도구(예: 구문 강조)가 계속 동작한다는 뜻입니다.
예를 들어 파서는 이제 미완성 as 패턴, object expression, enum case 선언, 레코드 선언, 복잡한 primary constructor 패턴, 해결되지 않은 긴 식별자, 빈 match 절, 누락된 공용체 case 필드, 누락된 공용체 case 필드 형식 등에서도 복구할 수 있어요.
진단 (Diagnostics)
진단, 즉 컴파일러가 여러분의 코드에서 뭘 마음에 들어 하지 않는지 이해하는 건 F# 사용자 경험의 중요한 부분이에요. F# 9에는 새롭거나 개선된 진단 메시지, 또는 더 정확한 진단 위치가 여럿 추가됐어요.
이런 것들이 포함돼요:
-
object expression 안의 모호한 override 메서드
-
추상 멤버를 비추상 클래스에서 사용한 경우
-
판별 공용체 case와 같은 이름을 가진 속성
-
active pattern 인자 개수 불일치
-
중복 필드를 가진 공용체
-
계산식에서
use!를and!와 함께 사용한 경우
또한 생성된 IL에서 메서드가 65,520개가 넘는 클래스에 대한 새로운 컴파일 타임 오류도 생겼어요. 이런 클래스는 CLR이 로드할 수 없어서 런타임 오류가 나거든요. (여러분이 그렇게 많은 메서드를 직접 작성하진 않겠지만, 생성된 코드에서는 그런 사례가 있었어요.)
실제 가시성 (Real visibility)
F#이 어셈블리를 생성하는 방식에는 private 멤버를 IL에 internal로 기록해 버리는 특성이 있어요. 이 때문에 InternalsVisibleTo를 통해 F# 프로젝트에 접근할 수 있는 다른 언어의 프로젝트가 private 멤버에 부적절하게 접근할 수 있었습니다.
이제 --realsig+ 컴파일러 플래그로 이 동작을 수정하는 옵션을 선택할 수 있어요. 프로젝트 몇 개가 이 동작에 의존하는지 확인해 보려고 여러분의 솔루션에서 시도해 보시면 됩니다. .fsproj 파일에는 이렇게 추가할 수 있어요.
<PropertyGroup>
<RealSig>true</RealSig>
</PropertyGroup>
성능 개선 (Performance improvements)
최적화된 동등성 검사 (Optimized equality checks)
동등성 검사(equality check)가 이제 더 빠르고 메모리도 덜 할당해요.
예를 들어:
[<Struct>]
type MyId =
val Id: int
new id = { Id = id }
let ids = Array.init 1000 MyId
let missingId = MyId -1
// used to box 1000 times, doesn't box anymore
let _ = ids |> Array.contains missingId
이전에는 Array.contains가 요소마다 박싱(boxing)을 하느라 느렸는데, 이제는 박싱 없이 비교하도록 최적화됐어요.
영향을 받는 배열 함수들의 벤치마크 결과 (2-멤버 struct에 적용)
Before:
| Method | Mean | Error | Gen0 | Allocated |
|---|---|---|---|---|
| ArrayContainsExisting | 15.48 ns | 0.398 ns | 0.0008 | 48 B |
| ArrayContainsNonexisting | 5,190.95 ns | 103.533 ns | 0.3891 | 24000 B |
| ArrayExistsExisting | 17.97 ns | 0.389 ns | 0.0012 | 72 B |
| ArrayExistsNonexisting | 5,316.64 ns | 103.776 ns | 0.3891 | 24024 B |
| ArrayTryFindExisting | 24.80 ns | 0.554 ns | 0.0015 | 96 B |
| ArrayTryFindNonexisting | 5,139.58 ns | 260.949 ns | 0.3891 | 24024 B |
| ArrayTryFindIndexExisting | 15.92 ns | 0.526 ns | 0.0015 | 96 B |
| ArrayTryFindIndexNonexisting | 4,349.13 ns | 100.750 ns | 0.3891 | 24024 B |
After:
| Method | Mean | Error | Gen0 | Allocated |
|---|---|---|---|---|
| ArrayContainsExisting | 4.865 ns | 0.3452 ns | - | - |
| ArrayContainsNonexisting | 766.005 ns | 15.2003 ns | - | - |
| ArrayExistsExisting | 8.025 ns | 0.1966 ns | 0.0004 | 24 B |
| ArrayExistsNonexisting | 834.811 ns | 16.2784 ns | - | 24 B |
| ArrayTryFindExisting | 16.401 ns | 0.3932 ns | 0.0008 | 48 B |
| ArrayTryFindNonexisting | 1,140.515 ns | 22.7372 ns | - | 24 B |
| ArrayTryFindIndexExisting | 14.864 ns | 0.3648 ns | 0.0008 | 48 B |
| ArrayTryFindIndexNonexisting | 990.028 ns | 19.7157 ns | - | 24 B |
모든 세부 내용은 여기에서 읽을 수 있어요: F# Developer Stories: How we've finally fixed a 9-year-old performance issue.
struct 판별 공용체의 필드 공유 (Field sharing for struct discriminated unions)
struct 판별 공용체의 여러 case가 같은 이름과 같은 형식의 필드를 가진다면, 그 필드는 같은 메모리 위치를 공유할 수 있어요. 덕분에 struct의 메모리 크기가 줄어듭니다. (예전에는 같은 필드 이름이 허용되지 않았으므로 바이너리 호환성 문제는 없어요.)
예를 들어:
[<Struct>]
type MyStructDU =
| Length of int64<meter>
| Time of int64<second>
| Temperature of int64<kelvin>
| Pressure of int64<pascal>
| Abbrev of TypeAbbreviationForInt64
| JustPlain of int64
| MyUnit of int64<MyUnit>
sizeof<MyStructDU> // 16 bytes
이전 버전(고유한 필드 이름을 써야 했던 때)과 비교하면:
[<Struct>]
type MyStructDU =
| Length of length: int64<meter>
| Time of time: int64<second>
| Temperature of temperature: int64<kelvin>
| Pressure of pressure: int64<pascal>
| Abbrev of abbrev: TypeAbbreviationForInt64
| JustPlain of plain: int64
| MyUnit of myUnit: int64<MyUnit>
sizeof<MyStructDU> // 60 bytes
공유 전에는 60바이트였던 크기가, 필드 이름이 같아지면서 16바이트까지 줄어들었어요. 이건 각 case가 동시에 하나만 활성화된다는 점을 활용해 같은 자리를 재사용한 결과예요.
정수 범위 최적화 (Integral range optimizations)
컴파일러는 이제 더 많은 start..finish 및 start..step..finish 식에 대해 최적화된 코드를 생성해요. 예전에는 형식이 int/int32이고 step이 상수 1 또는 -1일 때만 최적화됐었는데, 다른 정수 형식과 다른 step 값은 비효율적인 IEnumerable 기반 구현을 사용했거든요. 이제는 모두 최적화됩니다.
이 덕분에 루프에서 1.25배에서 최대 8배까지 빨라져요.
for … in start..finish do …
리스트/배열 식:
[start..step..finish]
그리고 컴프리헨션(내포 식):
[for n in start..finish -> f n]
리스트·배열 컴프리헨션에서 for x in xs -> … 최적화 (Optimized for x in xs -> … in list and array comprehensions)
비슷한 맥락에서, for x in xs -> … 형태의 컴프리헨션이 리스트와 배열에 대해 최적화됐어요. 특히 배열에서 눈에 띄는 개선이 있는데, 최대 10배까지 빨라지고 할당량은 1/3~1/4 수준으로 줄어듭니다.
도구 개선 (Improvements in tooling)
Visual Studio의 라이브 버퍼 (Live buffers in Visual Studio)
이전에는 선택 사항(opt-in)이던 이 기능이 충분히 테스트를 거쳐 이제 기본적으로 켜져 있어요. IDE를 구동하는 백그라운드 컴파일러가 이제 라이브 파일 버퍼와 함께 동작하므로, 변경 사항을 적용하기 위해 파일을 디스크에 저장할 필요가 없어졌습니다. 예전에는 (특히 편집했지만 저장하지 않은 파일에 있는 심볼을 rename하려고 하면) 예상치 못한 동작이 일어나기도 했죠.
불필요한 괄호 제거를 위한 분석기와 코드 수정 (Analyzer and code fix for removing unnecessary parentheses)
가끔 괄호는 명확성을 위해 쓰이지만, 때로는 그냥 노이즈일 뿐이에요. 후자의 경우 이제 Visual Studio에서 괄호를 제거해 주는 코드 수정(code fix)을 받을 수 있어요.
예를 들어:
let f (x) = x // -> let f x = x
let _ = (2 * 2) + 3 // -> let _ = 2 * 2 + 3
Visual Studio에서 F# 커스텀 시각화 지원 (Custom visualizer support for F# in Visual Studio)
Visual Studio의 디버거 시각화 장치(debugger visualizer)가 이제 F# 프로젝트에서도 동작해요.
파이프라인 중간에 시그니처 툴팁 표시 (Signature tooltips shown mid-pipeline)
예전에는 아래처럼 파이프라인 중간에 있는 함수에 이미 curried 인자(예: 람다)가 복잡하게 적용되어 있는 상황에서는 시그니처 도움말이 제공되지 않았어요. 이제는 다음 인자(state)에 대한 시그니처 툴팁이 표시됩니다.
F# 9는 프로그램을 안전하고 빠르게 만들기 위한 기능도 많지만, 개발자 경험을 더 편하게 만들어 주는 개선도 함께 담고 있어요. 특히 표준 라이브러리에 무작위 함수가 추가되고, 성능이 개선된 건 실제 코드에서 바로 체감할 수 있는 변화니까, 한번 직접 써보시면 좋겠어요.