What's New in F# 8
What's New in F# 8 (F# 8의 새로운 기능)
F# 8은 .NET 8과 함께 출시된 버전이에요. Visual Studio 2022의 새 업데이트와 .NET 8 SDK에 포함되어 있어요. 이 글에서는 언어 변경 사항, 새 진단(diagnostic), 삶의 질 개선, 프로젝트 컴파일 성능 향상, 그리고 FSharp.Core 표준 라이브러리의 개선까지 — F# 8이 가져온 주요 변화를 한 번에 정리해 드릴게요. 코드를 읽을 때뿐 아니라 실제로 작성할 때 바로 체감할 수 있는 변화들이라, 하나씩 따라가다 보면 F# 8이 무엇을 노렸는지 분명히 보일 거예요.
출처
- 원문: What's new in F# 8 - .NET Blog (Announcing F# 8)
- F# 공식 문서: What's new in F# 8
- 원문 저작권: © .NET Blog (Microsoft), 본 문서는 학습·요약 목적의 한국어 번역입니다.
본문
들어가며
F# 8은 .NET 8과 함께 출시되었어요. Visual Studio 2022의 새 업데이트와 .NET 8 SDK에 포함되어 있습니다.
F# 8은 F# 프로그램을 더 단순하고, 더 일관성(uniform) 있고, 더 빠르게 만들어 주는 많은 기능을 가져와요. 언어 변경, 새 진단, 삶의 질 개선, 프로젝트 컴파일 성능 향상, 그리고 FSharp.Core 표준 라이브러리에 대한 업그레이드에 대해 자세히 다룰게요.
이 발표는 F#의 오픈소스 코드 저장소에서 개발된 F# 8의 주요 변경 사항을 정리한 것입니다.
.NET 8 자체에 대해 더 알고 싶다면, 11월 14일~16일에 열리는 온라인 이벤트 .NET Conf 2023에서 다양한 발표자들과 함께 관심을 더해 보세요.
F#을 처음 접하시나요? 학습 자료·예시·YouTube 동영상이 담긴 .NET의 F# 가이드에서 여정을 시작해 보세요.
다른 사람들이 F#을 어떻게 쓰고 있는지 보고 싶다면 F# 커뮤니티 가상 컨퍼런스인 올해의 fsharpConf 녹화본을 확인해 보세요. 고급 주제와 F# 컴파일러 및 라이브러리 생태계에 대한 라이브 기여 녹화는 F#을 성장시키는 커뮤니티 이니셔티브인 Amplifying F#의 세션에서 볼 수 있어요.
F#의 최신 소식을 놓치고 싶지 않다면 소셜 미디어에서 @fsharponline을 팔로우하세요 – LinkedIn, X, Hachyderm.
F# 언어 변경 사항
이 절에서는 언어 자체의 업데이트, 즉 F# 코드를 쓰거나 읽을 때 가장 크게 체감하는 변화를 설명해요. 이 블로그 포스트의 대부분의 코드 예시는 FSharp 8 News 저장소에 F# 프로젝트로 중복되어 있어요. 최신 .NET 8 도구를 설치했다면 그 저장소를 내려받아 바로 코드를 실험해 볼 수 있습니다.
새 기능에 대한 흥미로운 사용 사례를 다른 사람들과 공유하고 싶으신가요? 그 저장소에 새 이슈로 알려 주시거나 곧바로 풀 리퀘스트를 열어 주세요!
_.Property 단축 문법: (fun x -> x.Property)
가장 먼저 소개할 기능은 단순한 람다 함수를 정의하기 위한 단축 문법이에요. 람다가 람다 인자에 대해 원자적 표현(atomic expression)만 할 때 유용하죠. 여기서 원자적 표현이란, 메서드 호출 괄호로 감싸여 있지 않은 한 공백이 없는 표현을 말해요.
이 기능 적용 전과 후를 실용적인 예시로 먼저 볼게요.
적용 전:
type Person = {Name : string; Age : int}
let people = [ {Name = "Joe"; Age = 20} ; {Name = "Will"; Age = 30} ; {Name = "Joe"; Age = 51}]
let beforeThisFeature =
people
|> List.distinctBy (fun x -> x.Name)
|> List.groupBy (fun x -> x.Age)
|> List.map (fun (x,y) -> y)
|> List.map (fun x -> x.Head.Name)
|> List.sortBy (fun x -> x.ToString())
적용 후:
type Person = {Name : string; Age : int}
let people = [ {Name = "Joe"; Age = 20} ; {Name = "Will"; Age = 30} ; {Name = "Joe"; Age = 51}]
let possibleNow =
people
|> List.distinctBy _.Name
|> List.groupBy _.Age
|> List.map snd
|> List.map _.Head.Name
|> List.sortBy _.ToString()
보시다시피 (fun x -> x.) 조각이 그냥 _.으로 대체됐고, 괄호도 필요 없어졌어요. 이 기능은 F#의 콤비네이터를 List, Option 등 많은 모듈에서 쓰는, |>로 이어지는 파이프라인 호출에서 특히 유용하죠. 단일 프로퍼티 접근, 중첩된 프로퍼티 접근, 메서드 호출, 심지어 인덱서에서도 동작해요. 예시는 레코드 목록을 보여주지만, 일반 람다 함수에서 사용할 수 있는 모든 값, 즉 객체, 원시 타입, 익명 레코드, 판별 공용체(D.U.)에도 동작합니다.
let getIdx5 : {| Foo : int array |} -> int = _.Foo[5]
또한:
게다가 이 기능은 함수 호출 밖에서 독립된 람다를 정의할 때도 사용할 수 있어요. 두 번째 예시의 getNameLength에서 볼 수 있듯이, 정의에 타입 주석 없이도 동작합니다.
let ageAccessor : Person -> int = _.Age
let getNameLength = _.Name.Length
같은 문법을 SRTP 문법으로 접근자(accessor)를 정의하는 데도 쓸 수 있어요. 공통 인터페이스를 공유하지 않아도 같은 멤버를 가진 모든 항목이 같은 바인딩을 쓸 수 있게 되죠.
let inline myPropGetter (x: 'a when 'a:(member WhatANiceProperty:string)) =
x |> _.WhatANiceProperty
이 문법이 어울리지 않는 상황도 하나 있어요. 바로 주변 스코프가 이미 _ 밑줄 기호를 사용하고 있을 때인데요, 보통 매개변수를 버릴 때(discard) 그렇죠.
let a : string -> string = (fun _ -> 5 |> _.ToString())
이런 코드는 FS3570 경고를 만들어 내요. 내용은 이렇습니다: "The meaning of _ is ambiguous here. It cannot be used for a discarded variable and a function shorthand in the same scope."
중첩 레코드 필드 복사-업데이트(copy-and-update)
다음 새 기능은 중첩된 레코드를 위한 복사-업데이트 향상이에요. 마찬가지로 적용 전과 후의 예시로 보여드릴게요.
적용 전:
type SteeringWheel = { Type: string }
type CarInterior = { Steering: SteeringWheel; Seats: int }
type Car = { Interior: CarInterior; ExteriorColor: string option }
let beforeThisFeature x =
{ x with Interior = { x.Interior with
Steering = {x.Interior.Steering with Type = "yoke"}
Seats = 5
}
}
적용 후:
let withTheFeature x = { x with Interior.Steering.Type = "yoke"; Interior.Seats = 5 }
두 블록은 완전히 같은 변경을 만들어요. 중첩된 with 키워드를 여러 번 써야 했던 것을, 새 언어 기능 덕분에 점 표기법(dot-notation)으로 중첩 레코드의 더 깊은 단계까지 내려가서 바로 업데이트할 수 있게 되었죠. 예시에서 보듯 같은 표현에서 여러 필드를 동시에 복사-업데이트하는 것도 여전히 가능해요. 같은 표현 안의 각 복사-업데이트는 서로 다른 중첩 단계일 수 있어요(위 예시의 Interior.Steering.Type과 Interior.Seats가 그렇죠).
익명 레코드에도 동작해요:
같은 문법 확장을 익명 레코드에서 쓰거나, 일반 레코드를 익명 레코드로 업데이트할 때도 사용할 수 있어요. 위 예시와 같은 타입 정의를 사용해서, 새 기능으로 Interior.Seats 필드를 업데이트하면서 같은 표현 안에 완전히 새로운 필드 Price를 추가할 수도 있습니다.
let alsoWorksForAnonymous (x:Car) = {| x with Interior.Seats = 7; Price = 99_999 |}
타입과 레코드 필드 사이의 이름 충돌 주의:
이 기능을 써 보면, 필드 이름을 기존 타입과 같게 지었을 때 충돌이 날 수 있어요. F# 8 이전에는 Type.Field 표기법으로 레코드 업데이트를 한정(qualify)하는 것이 가능했죠. 하위 호환성을 위해 이 동작은 여전히 남아 있고, 새 언어 기능보다 우선순위가 높습니다.
type Author = {
Name: string
YearBorn: int
}
type Book = {
Title: string
Year: int
Author: Author
}
let oneBook = { Title = "Book1"; Year = 2000; Author = { Name = "Author1"; YearBorn = 1950 } }
let codeWhichWorks = {oneBook with Book.Author.Name = "Author1Updated"}
let codeWhichLeadsToAnError = {oneBook with Author.Name = "Author1Updated"}
마지막 예시는 기존 Author 타입을 위한 기존 Type.Field 표기법을 우선해야 하기 때문에 오류로 이어져요:
This expression was expected to have type 'Book' but here has type 'Author'
해결 방법:
이런 경우에는 codeWhichWorks 예시처럼 올바른 타입으로 업데이트를 한정하면 충분해요. Book.Author.Name은 잘 동작합니다.
while!
while! (while bang) 기능은 이전 프리뷰에서 이미 발표됐었어요. while!을 소개하는 블로그 포스트에서 자세히 읽을 수 있습니다.
while!은 무엇을 하나요? 계산 표현식(computation expression)이 먼저 평가해야 하는 boolean 조건(예: async{} 블록 안)을 반복할 때 계산 표현식 사용을 단순화해요.
이 기능 적용 전:
let mutable count = 0
let asyncCondition = async {
return count < 10
}
let doStuffBeforeThisFeature =
async {
let! firstRead = asyncCondition
let mutable read = firstRead
while read do
count <- count + 2
let! nextRead = asyncCondition
read <- nextRead
return count
}
while! 적용 후:
let doStuffWithWhileBang =
async {
while! asyncCondition do
count <- count + 2
return count
}
두 코드 블록은 동작이 동일해요. while!가 추가되면서, 반복하던 mutable read boolean 변수를 유지하기 위해 필요한 상용구(boilerplate) 코드가 줄었습니다. 일반 while(bang 없는)은 bool 값만 반복할 수 있고, async<bool>(또는 다른 계산 표현식의 비슷한 래퍼)은 반복할 수 없어요. while!이 그 가능성을 언어에 가져다 주었죠.
별도의 블로그 포스트에서는 이 기능을 켜기 위해 <LangVersion>preview</LangVersion> 프로젝트 설정을 사용합니다. .NET 8이 출시되면서 그럴 필요는 없어졌어요. 참고로 언어 버전에 대한 설명처럼, 새로 개발된 언어·컴파일러 기능은 최종 버전(여기서는 8)이 출시되기 전에 preview 값을 써서 시험해 볼 수 있습니다.
확장된 문자열 보간 문법
F# 8은 C#의 보간된 원시 문자열 리터럴에서 영감을 받아, F#의 기존 보간 문자열에 대한 지원을 개선했어요.
보간 문자열에서 리터럴 텍스트 출력은 값을 괄호 쌍 {}로 감싸 표현식과 결합할 수 있죠. 따라서 중괄호는 특수 기호라서, 출력의 리터럴 부분으로 쓰려면 {{와 }}처럼 두 배로 늘려 이스케이프해야 합니다.
그런데 텍스트에 중괄호가 자연스럽게 많으면 문제가 돼요. 예를 들어 F# 문자열 안에 다른 언어를 임베딩하는 경우 — JSON, CSS, 또는 mustache 같은 HTML 기반 템플릿 언어가 그렇죠.
F# 문자열에 CSS를 임베딩하기 — 적용 전: 리터럴 출력을 위한 모든 중괄호가 두 배로 되어야 했던 점에 주목하세요.
let classAttr = "item-panel"
let cssOld = $""".{classAttr}:hover {{background-color: #eee;}}"""
확장된 보간 문법은 보간 문자열 리터럴의 시작 부분에 $ 달러 기호를 여러 개 넣어서 그 동작을 바꿀 수 있게 해요. 시작 달러의 개수가 보간 모드에 들어가기 위해 필요한 중괄호 개수를 결정합니다. 더 작은 개수의 중괄호는 이스케이프 없이 그냥 출력의 일부가 되죠.
새 기능 적용 후:
let cssNew = $$""".{{classAttr}}:hover {background-color: #eee;}"""
HTML 템플릿:
HTML 기반 템플릿 언어에서는 변수를 렌더링하기 위해 두 배 중괄호를 흔히 사용해요. 아래 예시에서 F# 문자열이 확장 보간 문법 유무에 따라 어떻게 달라지는지 볼 수 있습니다.
let templateOld = $"""
<div class="{classAttr}">
<p>{{{{title}}}}</p>
</div>
"""
let templateNew = $$$"""
<div class="{{{classAttr}}}">
<p>{{title}}</p>
</div>
"""
이 기능에 대한 더 자세한 내용은 F# 8의 새로운 문자열 보간 문법 블로그 포스트에서 읽을 수 있어요.
printf 및 관련 함수를 위한 문자열 리터럴 사용과 구성
이번 릴리스에서 문자열 리터럴이 한 번 더 업데이트됐는데요, 내장 출력 함수(printfn, sprintfn 그 외)를 사용할 때 관련이 있습니다.
F# 8 적용 전:
let renderedCoordinatesOld = sprintf "(%f,%f)" 0.25 0.75
let renderedTextOld = sprintf "Person at coordinates(%f,%f)" 0.25 0.75
F# 8 이전에는 형식 문자열(format string)이 사용 위치에 직접 입력한 문자열 리터럴이어야 했어요. F# 8에서는 다른 곳에서 정의된 문자열 리터럴도 지원됩니다. 게다가 기존 문자열 리터럴의 연결(concatenation)로 문자열 리터럴을 정의할 수도 있어요. 덕분에 자주 반복되는 형식 지정자를 패턴처럼 재사용해서, 코드베이스 곳곳에 같은 문자열 조각을 반복하지 않아도 됩니다.
왜 컴파일 타임에 알려진 [<Literal>] 문자열이어야 할까요? 컴파일러가 형식 문자열을 다른 인자와 함께 타입체크하기 때문에, 그 내용 전체가 컴파일 타임에 알려져야 하고 런타임 값일 수 없기 때문이에요.
F# 8에서의 문자열 형식 재사용:
[<Literal>]
let formatBody = "(%f,%f)"
[<Literal>]
let formatPrefix = "Person at coordinates"
[<Literal>]
let fullFormat = formatPrefix + formatBody
let renderedCoordinates = sprintf formatBody 0.25 0.75
let renderedText = sprintf fullFormat 0.25 0.75
리터럴 안의 산술 연산자
숫자 리터럴도 업데이트를 받았어요. 과거에는 상수 값으로 완전히 지정해야 했습니다.
적용 전:
module ArithmeticLiteralsBefore =
let [<Literal>] bytesInKB = 1024f
let [<Literal>] bytesInMB = 1048576f
let [<Literal>] bytesInGB = 1073741824
let [<Literal>] customBitMask = 0b01010101uy
let [<Literal>] inverseBitMask = 0b10101010uy
F# 8에서는 숫자 리터럴을 기존 연산자와 다른 리터럴로 표현할 수도 있어요. 컴파일러가 표현식을 컴파일 타임에 평가하고, 결과 값을 만들어 낸 어셈블리에 저장합니다. Visual Studio에서 정의된 리터럴(또는 그 사용처)에 마우스를 올리면 계산된 값을 보여주는 것을 확인할 수 있어요.
-
숫자 타입에 지원:
+,-,*, /, %, &&&, |||, <<<, >>>, ^^^, ~~~, **- 연산자는 리터럴이 아닌 표현식에서와 같은 의미를 가져요.
-
bool에 지원:
not, &&, ||
F# 8을 쓴 예시:
let [<Literal>] bytesInKB = 2f ** 10f
let [<Literal>] bytesInMB = bytesInKB * bytesInKB
let [<Literal>] bytesInGB = 1 <<< 30
let [<Literal>] customBitMask = 0b01010101uy
let [<Literal>] inverseBitMask = ~~~ customBitMask
enum 값과 리터럴에도 동작해요:
이 기능은 enum 값이나 특성 매개변수처럼 리터럴 값이 필요한 곳에서 쓸 수 있어요.
type MyEnum =
| A = (1 <<< 5)
| B = (17 * 45 % 13)
| C = bytesInGB
[<System.Runtime.CompilerServices.MethodImplAttribute(enum(1+2+3))>]
let doStuff = ()
enum 값의 경우 산술 표현식을 위 예시처럼 괄호로 감싸야 합니다.
타입 제약 교집합 문법
F# 8은 유연한 타입(flexible types)을 사용해서 여러 개의 교집합 제네릭 제약을 정의하는 것을 단순화하는 새 기능을 가져와요.
F# 8 이전 코드:
let beforeThis(arg1 : 't
when 't:>IDisposable
and 't:>IEx
and 't:>seq<int>) =
arg1.h(arg1)
arg1.Dispose()
for x in arg1 do
printfn "%i" x
이 정의는 제네릭 타입 인자 't를 절마다 반복해야 하고, and 키워드로 연결해야 했어요. F# 8에서는 교집합 제약을 & 문자로 지정해서 같은 결과를 얻을 수 있습니다:
let withNewFeature (arg1: 't & #IEx &
#IDisposable & #seq<int>) =
arg1.h(arg1)
arg1.Dispose()
for x in arg1 do
printfn "%i" x
같은 문법은 시그니처 지정, 예를 들어 시그니처 파일이나 추상 함수 정의에서도 동작해요:
type IEx =
abstract h: #IDisposable & #seq<int> -> unit
확장된 fixed 바인딩
F#에는 fixed 키워드가 있어서 메모리를 핀(pin)하고 그 주소를 nativeptr<int>로 얻을 수 있어요. 저수준 프로그래밍 시나리오에 사용되죠.
F# 8은 이 기능을 확장해서 다음과 같은 대상에도 추가적으로 허용합니다:
-
byref<'t> -
inref<'t> -
outref<'t> -
인스턴스/확장 메서드
GetPinnableReference : unit -> byref<'t>를 가진 모든 타입 'a -
인스턴스/확장 메서드
GetPinnableReference : unit -> inref<'t>를 가진 모든 타입 'a
마지막 두 추가는 ReadOnlySpan이나 Span 같은 타입과 함께 커져 가는 생태계에 특히 관련이 큽니다.
이제 가능해요:
open System
open FSharp.NativeInterop
#nowarn "9"
// "Warning no. 9 is a warning about using unsafe code with nativeint. We are disabling it here when we know we know what we are doing"
let pinIt (span: Span<char>, byRef: byref<int>, inRef: inref<int>) =
// Calls span.GetPinnableReference()
// The following lines wouldn't compile before
use ptrSpan = fixed span
use ptrByRef = fixed &byRef
use ptrInref = fixed &inRef
NativePtr.copyBlock ptrByRef ptrInref 1
더 쉬운 [<Extension>] 메서드 정의
[<Extension>] 특성은 C# 스타일 확장 메서드를 정의하기 위해 존재하며, F#과 C# 양쪽에서 사용할 수 있어요. 하지만 C# 컴파일러를 만족시키기 위해 이 특성을 멤버뿐 아니라 타입에도 적용해야 했습니다:
적용 전:
open System.Runtime.CompilerServices
[<Extension>]
type Foo =
[<Extension>]
static member PlusOne (a:int) : int = a + 1
let f (b:int) = b.PlusOne()
F# 8에서는 컴파일러가 확장 메서드에만 특성이 있으면 되고, 타입 수준 특성을 자동으로 추가해 줍니다.
적용 후:
open System.Runtime.CompilerServices
type Foo =
[<Extension>]
static member PlusOne (a:int) : int = a + 1
let f (b:int) = b.PlusOne()
F#을 더 일관성 있게 만들기
다음 변경들은 기존 구문을 이전에는 금지되었던 맥락에서 허용함으로써 F#을 더 일관성 있게 만들어요. 초심자의 혼란을 줄이고, 우회 작업(workaround)의 필요성을 낮추고, 더 간결한 코드로 이끄는 것을 목표로 합니다.
인터페이스의 정적 멤버
이 변경은 인터페이스에서 정적 멤버를 선언하고 구현할 수 있게 해요. F# 7의 인터페이스 정적 추상 멤버와 혼동하지 마세요. 지금 논의하는 변경은 인터페이스의 구체적인 멤버와 그 구현에 관한 것입니다.
오늘날에는 같은 일을 보통 타입 아래에 구현된 함수들을 가진 모듈을 두는 방식으로 해결했습니다.
적용 전:
[<Interface>]
type IDemoableOld =
abstract member Show: string -> unit
module IDemoableOld =
let autoFormat(a) = sprintf "%A" a
F# 8에서 인터페이스는 그 위에 구체적인 멤버를 가질 수 있어서, 분리된 모듈이 필요 없어졌어요.
적용 후:
[<Interface>]
type IDemoable =
abstract member Show: string -> unit
static member AutoFormat(a) = sprintf "%A" a
판별 공용체, 레코드, 구조체, 주 생성자가 없는 타입에서의 static let
일관성의 두 번째 예시는 더 많은 F# 타입에서 정적 바인딩을 가능하게 하는 것입니다. 여기서 말하는 구문은 다음과 같아요:
-
static let -
static let mutable -
static do -
static member val
F# 8 이전에는 이것들이 일반 클래스 정의에서만 가능했어요. F# 8에서는 다음에도 추가할 수 있습니다:
-
판별 공용체
-
레코드
-
구조체
[<Struct>]공용체와 레코드 포함
-
주 생성자가 없는 타입
이 추가는 분리된 module을 선언하고 그 모듈에 바인딩을 두는 대신, 타입 정의 안에 데이터와 로직을 캡슐화하는 데 다시 도움이 돼요.
이제 가능해요: 예를 들어 문자열을 판별 공용체의 case로 변환하는 간단한 조회(lookup)를 이제 타입 정의 안에 직접 선언할 수 있습니다.
open FSharp.Reflection
type AbcDU = A | B | C
with
static let namesAndValues =
FSharpType.GetUnionCases(typeof)
|> Array.map (fun c -> c.Name, FSharpValue.MakeUnion (c,[||]) :?> AbcDU)
static let stringMap = namesAndValues |> dict
static let mutable cnt = 0
static do printfn "Init done! We have %i cases" stringMap.Count
static member TryParse text =
let cnt = Interlocked.Increment(&cnt)
stringMap.TryGetValue text, sprintf "Parsed %i" cnt
이 예시는 static mutable을 어떻게 추가하는지, 그리고 static do를 통해 부수 효과(side effect)를 어떻게 촉발하는지도 보여줘요. 이 기능은 파일 안의 선언 순서뿐 아니라 하나의 module 안의 타입·바인딩 선언 순서도 존중합니다.
또한 가능해요:
이 문법은 'member val' 키워드로 자동 프로퍼티를 가진 명시적 정적 필드를 정의하는 것도 허용합니다.
type AnotherDu = D | E
with
static member val X = 42 with get,set
활성 패턴(active pattern)도 static let 바인딩에 구현할 수 있어요. 그 활성 패턴은 타입에 비공개지만, 같은 타입 정의 안의 이어지는 멤버들은 사용할 수 있습니다.
type AB =
| A
| B of int
static let (|B0PatPrivate|_|) value = if value = 0 then Some (B 999) else None
static member ParseUsingActivePattern x = match x with | B0PatPrivate x -> x | _ -> A
let testThis = AB.ParseUsingActivePattern 0
알아 두면 좋아요:
이 기능은 런타임에 실제 타입이 아닌 '타입'에서는 동작하지 않아요. 특히 컴파일 중에 지워지는 타입 별칭(type alias)과 측정 단위(units of measure), 그리고 일반 .NET enum이 해당돼요.
.NET의 제네릭 타입의 다른 정적 요소들과 마찬가지로, 모든 정적 값은 제네릭 인스턴스화마다 생성되며 제공된 제네릭 매개변수(예: 리플렉션을 통한 것)를 활용할 수 있습니다.
다음 예시는 제네릭 타입, 특히 주 생성자가 없는 타입의 static let을 보여줘요.
type EmptyT =
static let cachedName =
let name = typeof.Name
printfn "Accessing name for %s" name
name
static member Name = cachedName
이것은 제네릭 타입 인스턴스화마다 리플렉션이나 sizeof 명령 같은 런타임 전용 기능으로 얻은 정보를 검색하고 캐시하는 데 유용할 수 있어요.
[<Struct>]
type MyUnion =
| A of aval:'A
| B of bval:'B
| C
static let sizeOfTCached =
printfn "Creating cached val for %s * %s" (typeof.Name) (typeof.Name)
sizeof<MyUnion>
seq{}, [], [||] 컬렉션 표현식 안의 try-with
'일관성' 범주의 세 번째 추가는 컬렉션 빌더 안에서 try-with 코드 구문을 새로 지원하는 것입니다 – IEnumerable 정의를 위한 seq{}, 목록 빌더 [], 배열 빌더 [||]가 그 대상이에요.
이 변경으로 이 표현식들에서도 예외 처리가 가능해졌습니다. 다음 조합이 가능해요:
-
'try' 부분은 값을 만들어낼 수 있고, 'with'는 부수 효과(예: 로깅)만 일으킬 수 있다
-
'try'와 'with' 모두 값을 만들어낼 수 있다
-
'try'가 값 생성 측면에서 비어 있고 'with'만 데이터를 만들어낼 수 있다
- 이 시나리오는 실제 코드에서 의미가 있을 가능성이 적지만, 지원은 됩니다
아래 예시에서는 0으로 나누기를 해서 예외를 시뮬레이션하며, .NET에서 DivideByZeroException을 만들어냅니다.
이제 가능해요:
let sum =
[ for x in [0;1] do
try
yield 1
yield (10/x)
yield 100
with _ ->
yield 1000 ]
|> List.sum
실행하면 코드는 [1;1000;1;10;100] 목록을 만들어내고, 합은 1112가 됩니다.
또한: 재귀 호출과 yield!
예외 핸들러 안에서의 재귀 호출도 지원돼요. seq{}의 경우 지연(lazy) 의미론을 유지하며 한 번에 한 값씩만 코드를 실행합니다. 아래 예시는 예외 핸들러가 같은 계산을 재귀적으로 yield!하여 "재시도"할 수 있는 방법을 보여줘요.
예외 처리('with' 핸들러)는 .NET에서 무시할 수 없는 비용이 있고, 반복적으로 예외가 던져지고 처리될 때 눈에 띄게 된다는 점을 유의하세요.
let rec f () = seq {
try
yield 123
yield (456/0)
with exn ->
eprintfn "%s" exn.Message
yield 789
yield! f()
}
let first5 =
f()
|> Seq.take 5
|> Seq.toArray
실행하면 코드는 [|123; 789; 123; 789; 123|]를 만들어냅니다.
새 진단(diagnostics)
F# 8 개발의 상당 부분은 새롭고 개선된 진단에 할애되었어요. 진단이란 컴파일러가 문제를 만났을 때 보고하는 오류, 경고, 정보 메시지를 말합니다. 이 절에서는 진단 개선의 몇 가지 선별된 범주를 다룰게요. F# 7과 F# 8 사이에 F# 진단 메시지 정의에 34개의 새 진단 오류·메시지가 추가되었습니다.
TailCall 특성
F#에서는 'rec' 키워드로 재귀 함수를 만들 수 있어요. 재귀 함수의 중요한 측면은 깊은 스택과 결국 StackOverflowException 오류를 막기 위해 꼬리 재귀(tail recursion)를 할 수 있다는 것입니다.
F# 프로그래밍 언어와 무관하게 꼬리 재귀가 무엇인지 알고 싶다면 이 Q&A도 읽어보세요.
F# 8.0부터 TailCall 특성을 써서 꼬리 재귀 함수를 정의하겠다는 의도를 컴파일러에 명시적으로 알릴 수 있어요. 그러면 함수가 꼬리 재귀가 아닌 호출을 만들면 컴파일러가 경고를 냅니다. 이 특성은 메서드와 모듈 수준 함수에 사용할 수 있어요.
이 특성은 순전히 컴파일러에 정보를 주는 용도이며 생성된 코드에는 영향을 주지 않아요. 컴파일러가 꼬리 호출 일관성을 검사하고 충족되지 않으면 경고를 올리도록 보장합니다.
이제 가능해요:
첫 번째 함수인 factorialClassic은 함수형 프로그래밍 입문과 팩토리얼 함수 구현의 교과서적 예시입니다. 이 코드 조각에서는 꼬리 재귀가 아니에요. 프로젝트를 빌드하면 "warning FS3569: The member or function 'factorialClassic' has the 'TailCallAttribute' attribute, but is not being used in a tail recursive way." 경고가 나옵니다.
두 번째 함수인 factorialWithAcc는 누적기(accumulator) 기법으로 계산을 꼬리 재귀 방식으로 구현해요. 따라서 특성을 적용해도 경고가 나오지 않습니다.
[<TailCall>]
let rec factorialClassic n =
match n with
| 0u | 1u -> 1u
| _ -> n * (factorialClassic (n - 1u))
// This produces a warning
[<TailCall>]
let rec factorialWithAcc n accumulator =
match n with
| 0u | 1u -> accumulator
| _ -> factorialWithAcc (n - 1u) (n * accumulator)
// This is a tail call and does NOT produce a warning
정적 클래스에 대한 진단
F#에는 static class를 만들기 위한 전용 키워드 집합이 없어요. 하지만 sealed 타입은 상속될 수 없고, abstract 타입은 인스턴스화될 수 없죠. 실제로 이는 그러한 타입에서 정적 멤버만 접근할 수 있다는 뜻입니다.
런타임 오류와 죽은 코드(dead code)를 없애기 위해, 유효하지 않은 시나리오를 감지하기 위한 새 경고 묶음이 만들어졌어요. 다음 진단이 F# 8에서 활성화된 경고로 추가되었습니다:
새로 내보내는 경고:
타입이 [<Sealed>]와 [<AbstractClass>] 특성을 모두 사용한다면 정적 타입이라는 뜻입니다. 즉 다음을 의미해요:
-
인스턴스 let 바인딩은 허용되지 않는다.
-
인터페이스 구현은 허용되지 않는다.
-
명시적 필드 선언은 허용되지 않는다.
-
인자가 있는 생성자는 허용되지 않는다.
-
추가 생성자는 허용되지 않는다.
-
추상 멤버 선언은 허용되지 않는다.
[<Obsolete>] 사용에 대한 진단
[<Obsolete>] 특성은 사용하지 않는 것이 좋은 타입과 멤버를 표시하는 데 쓸 수 있어요. 그러면 사용이 감지될 때 컴파일러가 사용자 정의 메시지(메시지는 특성 내용에서 가져옴)와 함께 경고를 내보내야 합니다.
obsolete 멤버 주변의 진단은 F# 8에서 다음 지원을 받았어요:
-
enum 값 사용 감지
-
이벤트 감지
-
레코드 복사-업데이트 문법(
with)에서 obsolete 필드가 업데이트의 일부일 때만 감지
이제 경고를 만드는 코드 예시
open System
type Color =
| [<Obsolete("Use B instead")>] Red = 0
| Green = 1
let c = Color.Red // warning "This construct is deprecated. Use B instead" at this line
obj가 추론될 때의 선택적 경고
F#은 매개변수 타입과 반환 타입 모두에 강한 타입 추론을 가져요. 특정 API를 사용할 때 자동 일반화(automatic generalization)가 값을 제네릭으로 추론하지 못하고, 대신 타입을 obj로 추론하는 일이 생길 수 있습니다.
대부분의 경우(항상은 아니지만) 이것은 의도하지 않은 동작의 신호예요. F# 8은 번호 FS3559, 다음과 같은 텍스트의 새 선택적 정보 수준 진단을 가져와요:
"A type has been implicitly inferred as 'obj', which may be unintended. Consider adding explicit type annotations. You can disable this warning by using '#nowarn \"3559\"' or '--nowarn:3559'."
이를 촉발할 수 있는 코드 예시:
([] = [])
이 경고는 기본적으로 꺼져 있어요. 즉 .fsproj 프로젝트 파일에서 <WarnOn>FS3559</WarnOn>을 사용해 명시적으로 활성화해야 합니다.
복사-업데이트가 모든 필드를 바꿀 때의 선택적 경고
F# 레코드는 with 키워드를 사용해 선택된 필드를 수정한 복사본을 만드는 편리한 문법을 제공해요. 프로젝트는 복사 문법이 레코드의 모든 필드를 바꾸게 되는 상황을 감지하도록 <WarnOn>FS3560</WarnOn>으로 구성할 수 있습니다. 그런 경우 처음부터 새 레코드를 만들어 모든 필드를 직접 채우는 것이 더 짧고 효율적이에요. 그러면 컴파일러가 다음과 같은 경고를 보고합니다:
"This copy-and-update record expression changes all fields of record type '..name of your type..'. Consider using the record construction syntax instead."
삶의 질 개선
F# 8은 새 언어 기능이나 새 진단과 무관한 많은 삶의 질 개선도 가져와요. 몇 가지를 골라 보면:
컴파일러 생성 코드의 트리밍(trimmability)
.NET 플랫폼은 .NET 6부터 트리밍을 지원해요. F# 컴파일러가 생성한 코드의 트리밍을 더 잘 지원하기 위한 3가지 개선이 이루어졌습니다:
-
판별 공용체가 이제 트리밍 가능.
-
익명 레코드가 이제 트리밍 가능.
-
트리밍된 레코드에
printfn "%A"를 사용하는 코드가 이제 트리밍 가능.
파서 복구(parser recovery)
파서 복구는 F# 파서가 유효하지 않은 코드 구문을 만났을 때, 그래도 최선을 다해 파일의 나머지 부분을 계속 파싱하려 시도할 때 작동해요.
이것은 문법 오류가 있는 코드나 타이핑 중이라 아직 끝나지 않은 코드에서도 색상화, 탐색 같은 IDE 기능이 계속 동작하도록 보장해요. 빠진 등호, 끝나지 않은 선언, 잘못된 들여쓰기 같은 실수에 대해 파서 복구가 크게 개선되어, F# 사용자에게 더 나은 타이핑 경험을 줍니다.
이것은 이를 대상으로 한 풀 리퀘스트 수를 고려하면 F# 8에서 가장 개선이 많은 영역일 가능성이 높아요.
파서 복구를 깊이 있게 배우고 싶다면 이 비디오 세션을 보세요.
엄격한 들여쓰기 규칙
개선된 파서 복구를 위한 작업의 일부로, F# 8은 엄격한 들여쓰기 모드를 켭니다. 이 모드는 들여쓰기에 대한 언어 규칙을 존중하며, 이전 언어 버전이 경고만 냈던 유효하지 않은 시나리오에서는 오류를 보고합니다.
F# 언어 버전 7 이하를 대상으로 하는 프로젝트는 현재 동작을 유지하고, F# 8 이상의 프로젝트는 엄격한 들여쓰기 규칙이 자동으로 켜집니다.
기본 선택은 컴파일러 스위치로 구성할 수 있어요:
--strict-indentation[+|-] Override indentation rules implied by the language version
이것은 프로젝트 파일에서도 끌 수 있는데, 다음과 같이 지정하면 됩니다:
<OtherFlags>--strict-indentation-</..>로 끄거나
<OtherFlags>--strict-indentation+</..>로 켭니다.
자동 완성 개선
F# 컴파일러는 F# 코드의 자동 완성 로직을 Visual Studio, Visual Studio Code, Rider 같은 좋아하는 편집기에 가져다 주는 라이브러리를 제공해요.
개선은 기억(recall)과 정밀도(precision)를 모두 다룹니다 – 주어진 맥락에서 필요한 이름을 제안하고, 거기서 쓸 수 없는 개체는 제안하지 않는 것이죠.
다음 완성 시나리오가 개선되었어요:
-
패턴 안의 레코드 완성
-
패턴 안의 공용체 필드
-
반환 타입 주석
-
오버라이드(override)를 위한 메서드 완성
-
패턴 매칭에서 상수 값 완성(예:
System.Double타입의 상수와 매칭) -
enum 값의 표현식
-
공용체 case 필드의 레이블이 정의되어 있으면 그 레이블을 기반으로 이름 제안
-
익명 레코드 컬렉션에 대한 완성
-
특성 완성에서 설정 가능한 프로퍼티(settable property)
[<Struct>] 공용체는 이제 49개보다 많은 case를 가질 수 있어요
F# 8의 일부로 많은 이슈가 해결됐고, 그 모두를 이 블로그 포스트에서 언급하지는 않을게요. 그중 F# 코드베이스의 제한 요소였던 하나를 꼽자면:
F# 8 이전에는 49개보다 많은 case를 가진 struct 공용체 선언이 예상치 못한 런타임 오류를 일으켰어요. 해결 방법으로 보통 일반 클래스 기반 공용체로 변환해야 했습니다.
F# 8에서 이 제한은 제거되었고 struct 공용체는 크기 제한이 없어요 – 더 긴 공용체 case 정의에 적합해졌죠. 예를 들어 국가나 전화 지역번호의 공용체가 될 수 있어요.
컴파일러 성능
컴파일러 성능은 F# 컴파일러와 관련 도구에게 큰 주제예요. 컴파일러는 독립 빌드, IDE의 언어 기능, 그리고 그 위에 구축된 인기 라이브러리(예: F# 코드 포맷터 Fantomas)를 구동합니다.
이번 F# 릴리스에서 특별한 주목을 받은 두 영역은 – Reference assemblies 기능을 통한 큰 프로젝트 그래프의 증분 빌드와, 컴파일러 프로세스의 CPU 병렬화입니다.
Reference assemblies
Reference assemblies는 .NET의 특별한 어셈블리로, 디자인 타임과 빌드 타임 시나리오에서 사용할 수 있어요.
그것들은 API의 공개 모양(표면적)을 유지하되 구현 세부 사항을 제거하고 "throw null" 명령으로 대체하는 방식으로 동작합니다. 그렇게 해서 더 작아지고, 더 중요한 것은 변경에 더 견고해집니다. 실제 프로젝트의 구현 세부 사항이 바뀌어도, API가 바뀌지 않는 한 reference assembly는 그대로일 가능성이 높아요.
이것은 F#에서 이미 동작하던 기존 기능이지만, F# 컴파일러가 생성한 리소스 때문에 잠재력을 다 활용하지 못했어요. F# 컴파일러는 만들어진 어셈블리에 임베디드 리소스, F# 시그니처 정보를 나타내는 바이너리 데이터, 그리고 어셈블리 간 F# 지원과 어셈블리 간 함수 인라인을 위한 F# 최적화 프로그램을 풍부하게 담아 넣습니다.
reference assembly를 위해 최적화 데이터는 DEBUG 빌드에서 "let inline" 정의로만 줄어들었고, F# 시그니처 데이터는 F# 타입·모듈·네임스페이스의 공개 표면을 다루는 커스텀 해시 함수로 대체되었습니다.
이 두 변경으로 저수준 F# 프로젝트의 구현 세부 사항이 바뀌어도 그것에 전이적으로 의존하는 모든 프로젝트를 완전히 다시 빌드할 필요가 없어지죠. 특히 서로 연결된 프로젝트들이 많은 큰 솔루션에서 큰 요인이 될 수 있어요.
Visual Studio를 쓸 때 reference assembly의 이점은 Visual Studio에서 최신 상태 검사를 대체(replicating)함으로써 msbuild.exe를 전혀 호출하지 않아도 되어 더 나아갈 수 있어요. AccelerateBuildsInVisualStudio 블로그가 이 기능을 더 자세히 설명합니다.
시도해 보고 싶다면 .fsproj 프로젝트 속성에 다음을 추가하세요:
<AccelerateBuildsInVisualStudio>true</..>
컴파일러 병렬화 스위치
언급하고 싶은 두 번째 개선 영역은 컴파일러 병렬화입니다. 역사적으로 F# 컴파일러는 단일 스레드였어요. 최근 F# 버전에서는 파싱과, 시그니처 파일이 뒤따르는 구현 파일의 타입체크에 대해 병렬화가 활성화되었습니다.
F# 8은 컴파일 과정의 다른 3단계를 다루기 위해 추가된 3개의 새 실험 기능을 가져와요. 현재는 기본적으로 켜져 있지 않고 명령줄 컴파일러의 전용 --test: 플래그로만 구성할 수 있어요. 프로젝트 파일을 기반으로도 전달할 수 있는데, F# 컴파일러 옵션에 대해 다음 속성 문법을 사용하면 됩니다:
<OtherFlags>--test:..</..>
그래프 기반 타입체크(graph-based typechecking)는 빌드 과정을 가장 크게 가속화할 잠재력이 있어요. 비싼 타입체크 단계 전에 타입 없는 문법 트리(untyped syntax tree)에서 F# 파일들의 의존성을 파생하고, 파일의 연결된 그래프를 따라 병렬로 타입체크할 수 있는 파일들을 제어하는 방식으로 동작합니다.
이 기능은 Amplifying F#의 그래프 기반 타입체크 세션에서 시연되었고, 이 블로그 포스트에 설명되어 있어요.
이것은 다음 플래그로 켤 수 있습니다:
--test:GraphBasedChecking
컴파일 과정의 다음 단계는 F# 코드에 적용되는 최적화입니다. 선택한 접근 방식의 기술 설명은 이 기능을 구현한 풀 리퀘스트에 잘 설명되어 있어요.
이 기능이 있을 때(Parallel)와 없을 때(Sequential)의 차이를 보여주는 FSharp.Compiler.Service 타이밍:
8코어/16스레드 CPU에서 테스트 실행. 한 번에 7개 항목 이상은 처리되지 않는다는 점을 언급할 가치가 있어요.
| Optimize | Mode | Optimization Time | Total Time |
|---|---|---|---|
| + | Sequential | 12.9s | 31.0s |
| + | Parallel | 7.1s (-45%) | 25.5s (-18%) |
| – | Sequential | 5.6s | 24.3s |
| – | Parallel | 3.7s (-34%) | 23.6s (-3%) |
이 기능은 다음 플래그로 활성화할 수 있습니다:
--test:ParallelOptimization
F# 빌드 과정의 마지막 단계는 IL 명령의 코드 생성과 어셈블리 생성입니다. 이 단계에서는 메서드 본문을 .NET IL로 병렬 변환함으로써 병렬화가 일어나요. F# 저장소의 주요 테스트 프로젝트에서 이 변경은 IL 변환을 0.6s에서 0.4s로 개선했고, 따라서 -33% 속도 향상입니다.
이 기능은 다음 플래그로 활성화할 수 있습니다:
--test:ParallelIlxGen
또는 모든 기능을 FSHARP_EXPERIMENTAL_FEATURES 환경 변수로 전역 활성화할 수 있어요. 예를 들어 PowerShell에서 이렇게: $env:FSHARP_EXPERIMENTAL_FEATURES = '1'.
FSharp.Core 표준 라이브러리 개선
인라인(inlining)
F#에는 이미 인라인이라는 강력한 기능이 있어요. 컴파일러는 함수를 직접 호출하는 대신 호출된 함수의 본문(=인라인)을 호출 지점에 넣기로 결정할 수 있습니다. 그렇게 하면 함수 호출이 제거될 뿐 아니라, 호출 지점에서 사용할 수 있는 타입 인자와 함수 인자를 바탕으로 더 최적화할 수도 있어요. 예를 들어 일반적인 동등성(equality) 제네릭 함수는 표준 인터페이스와 계약(예: .Equals() 메서드)을 거쳐야 합니다. 정수에 사용하는 호출 지점에서 제네릭 용법을 인라인하면, 두 정수를 비교하는 단일 명령으로 대체할 수 있죠.
또한 F#은 [<InlineIfLambda>] 특성도 있어서, 인라인이 일어날 때 람다 함수를 직접 호출로 대체할 수 있게 해요. 이전의 모든 장점 위에, 이것은 람다 클로저(closure)의 할당도 절약한다는 뜻입니다.
F# 8은 FSharp.Core의 두 표준 모듈에 인라인 변경을 가져와요:
람다 할당이 제거되어, ValueOption 함수의 경우 할당이 완전히 0이 됩니다. 오버헤드 감소의 예로, None 값의 매핑이 이제 2.77ns 대신 0.17ns가 걸리는데, 시간 기준 16배 개선입니다.
또 다른 예시는 이 List.contains 성능 이슈에서 발견된 동등성 인라인 이슈예요. 원인은 재귀 스코프 때문에 인라인이 멈춘 것이었죠 – 함수가 재귀적일 때는 인라인할 수 없습니다(인라인은 인라인된 함수의 코드를 호출 지점에 임베딩하는 것을 의미하는데, 재귀 함수라면 무한히 포함하게 될 테니까요). 동등성 검사를 재귀 스코프 밖으로 옮기면, 동등성 검사를 인라인하고 제대로 특수 처리할 수 있게 됩니다.
이것이 그것을 개선한 최소 코드 변경이며, 특정 시나리오에서 16배 개선으로 이어졌어요.
| Method | Mean | Error | StdDev | Median | Gen0 | Allocated |
|---|---|---|---|---|---|---|
| 'int – List.containsOld' | 9,284.4 μs | 158.63 μs | 148.38 μs | 9,266.7 μs | 1906.2500 | 24024049 B |
| 'int – List.containsNew' | 548.4 μs | 10.35 μs | 15.17 μs | 541.9 μs | – | 41 B |
| 'string – List.containsOld' | 2,393.9 μs | 43.59 μs | 83.98 μs | 2,359.5 μs | – | 42 B |
| 'string – List.containsNew' | 838.6 μs | 21.62 μs | 62.38 μs | 824.1 μs | – | 41 B |
| 'record – List.containsOld' | 3,796.8 μs | 66.80 μs | 113.44 μs | 3,753.0 μs | – | 45 B |
| 'record – List.containsNew' | 691.4 μs | 13.61 μs | 16.20 μs | 688.9 μs | – | 41 B |
개선
Array.Parallel.* API
FSharp.Core는 주요 컬렉션 타입 – 배열, 목록, 시퀀스 – 을 위한 많은 표준 함수를 제공해요. Array에는 CPU 집약적인 대형 배열 작업을 위한 중첩 모듈 Array.Parallel이 있어요. F# 8 이전에는 그 모듈이 병렬이 아닌 함수들과 어깨를 나란히 하지 못했고, 많은 기본 함수가 빠져 있었어요. 이번 릴리스에서 멀티스레드 프로그래밍을 지원하는 많은 새 API가 추가되었습니다.
각 함수는 사용 가능한 모든 논리 프로세서를 사용하려 시도해요(기본이 마음에 들지 않으면 DOTNET_PROCESSOR_COUNT 환경 변수로 미세 조정할 수 있습니다). CPU 집약적인 처리에 특히 유용하죠. 즉 배열이 매우 크거나, 전달된 함수 매개변수 자체가 계산 비용이 크거나, 둘 다인 경우입니다.
다만 새 태스크를 만들고 조정하는 오버헤드를 지니므로, 단순한 입력에서는 병렬이 아닌 함수들보다 느릴 수 있어요.
-
exists
-
forAll
-
tryFindIndex
-
tryFind
-
tryPick
-
reduceBy
-
reduce
-
minBy
-
min
-
sumBy
-
sum
-
maxBy
-
max
-
averageBy
-
average
-
zip
-
groupBy
-
filter
-
sortInPlaceWith
-
sortInPlaceBy
-
sortInPlace
-
sortWith
-
sortBy
-
sort
-
sortByDescending
-
sortDescending
선별된 벤치마크
Array, Array.Parallel, PLINQ의 차이를 보여주기 위해 이 블로그 포스트에 몇 가지 벤치마크가 포함되어 있어요. 모두 500,000개의 무작위 생성 구조체 배열로 실행했으며, 다음 구조체 정의와 "복잡한 CPU 집약적 비즈니스 로직"을 나타내는 인공 함수를 사용했습니다.
이제 가능해요:
[<Struct>]
type SampleRecord = {Age : int; Balance : int64; Molecules : float; IsMiddle : bool}
let complexLogic (sr:SampleRecord) =
let mutable total = float sr.Balance
total <- total + sin sr.Molecules
total <- atan total
for a=0 to sr.Age do
total <- total + cos (float a)
total <- total + float (hash sr)
total
이것은 16 논리 코어와 8 물리 코어를 가진 11세대 Intel Core i9-11950H 2.60GHz 머신에서 실행되었어요. 비교는 세 가지 다른 접근 방식에 걸쳐 이루어졌습니다:
-
FSharp.Core의 Array 모듈
-
PLINQ(
AsParallel()을 통한 병렬 Enumerable), .NET의 안정적이고 견고한 구현 -
Array.Parallel모듈에 새로 추가된 함수
비교의 주요 결과는 고전적인 "그때그때 다르다(it depends)" 조언입니다. 500,000개 요소도 순차적으로 매우 빠르게 처리할 수 있고(최대 ms 시간 범위에 있음을 유의), 계산이 단순한 경우(예: 비싼 람다 함수가 아니라 그냥 프로퍼티/필드 접근자)에는 Array 모듈의 병렬화되지 않은 함수가 가장 좋은 성능을 냅니다. 결과에서 'GroupBy – field only', 'Sort – by int field', 'SumBy(plain field access)' 범주를 참고하세요. 병렬 버전은 위의 complexLogic 함수처럼 모든 요소에 적용되는 계산이 더 복잡해질 때 이점을 얻기 시작해요. 그런 경우 Array.Parallel 모듈은 고전 버전보다 더 빠른 속도와, PLINQ보다 나은 할당 공간(footprint)을 모두 가져다 줍니다.
MinBy 계산에서 상당한 성능 개선이 일어나는데, Array.Parallel.minBy 버전은 Array.minBy보다 68%, PLINQ의 AsParallel().MinBy(..)보다 70% 더 빨라요. MinBy는 max, sum, average처럼 집계(aggregation) 함수의 예시입니다. 그것들은 모두 map-reduce 루틴을 활용하는 더 저수준의 reduce 함수 위에 구축되어 있어, 그 모두에서 매우 유사한 성능이 기대됩니다.
| Method | Categories | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| ArrayGroupBy2 | GroupBy – calculation | 169,024.8 us | baseline | 70.17 MB | – |
| PlinqGroupBy2 | GroupBy – calculation | 74,683.8 us | -56% | 103.93 MB | +48% |
| ArrayParallelGroupBy2 | GroupBy – calculation | 62,574.3 us | -63% | 70.61 MB | +1% |
| ArrayGroupBy | GroupBy – field only | 14,274.3 us | baseline | 57.28 MB | – |
| PlinqGroupBy | GroupBy – field only | 30,933.6 us | +117% | 88.77 MB | +55% |
| ArrayParallelGroupBy | GroupBy – field only | 18,318.6 us | +29% | 47.72 MB | -17% |
| ArrayMinBy | MinBy(calculationFunction) | 157,463.5 us | baseline | 11.44 MB | – |
| PlinqMinBy | MinBy(calculationFunction) | 160,243.5 us | +2% | 11.44 MB | +0% |
| ArrayParallelMinBy | MinBy(calculationFunction) | 48,768.7 us | -68% | 11.45 MB | +0% |
| ArraySort | Sort – by int field | 27,352.1 us | baseline | 17.17 MB | – |
| PlinqSort | Sort – by int field | 38,723.7 us | +42% | 172.89 MB | +907% |
| ArrayParallelSort | Sort – by int field | 76,744.8 us | +179% | 112.76 MB | +557% |
| ArraySortBy | SortBy – calculation | 214,042.4 us | baseline | 30.52 MB | – |
| PlinqSortBy | SortBy – calculation | 97,214.3 us | -55% | 193.99 MB | +536% |
| ArrayParallelSortBy | SortBy – calculation | 125,951.7 us | -41% | 130.49 MB | +328% |
| ArraySumBy | SumBy(plain field access) | 466.7 us | baseline | – | NA |
| PlinqSumBy | SumBy(plain field access) | 984.1 us | +112% | 0.01 MB | NA |
| ArrayParallelSumBy | SumBy(plain field access) | 687.6 us | +47% | 0.01 MB | NA |
| ArrayTryFind | TryFind – calculationFunction | 76,509.7 us | baseline | 5.72 MB | – |
| PlinqTryFind | TryFind – calculationFunction | 41,256.7 us | -47% | 10.74 MB | +88% |
| ArrayParallelTryFind | TryFind – calculationFunction | 23,094.4 us | -69% | 5.73 MB | +0% |
async 개선
-
task{}안의Async<>의Bind가 이제 같은 스레드에서 시작- 컴퓨테이션을 같은 .NET 스레드에 유지해 새 스레드를 시작하지 않으므로 리소스를 절약
-
MailBoxProcessor가 이제 공개.Dispose()멤버를 가짐-
MailboxProcessor가IDisposable을 구현하므로 반드시 dispose해야 한다는 것을 더 잘 드러냄 -
dispose 전에
(mailboxProcessor :> IDisposable).Dispose()처럼IDisposable으로 수동 캐스팅할 필요를 제거
-
-
MailBoxProcessor가 이제StartImmediate를 가짐-
기존
Start메서드는 스레드 풀에서 실행을 시작해요. 새StartImmediate는 호출하는 스레드와 같은 스레드에서 시작을 보장합니다. -
이것은 MailboxProcessor가 주어진 스레드에서 시작하도록 강제하거나, 수명 내내 단일 스레드에서 실행하도록 하고 싶은 상황에 유용해요. 예를 들어 스레드 안전하지 않은 창이나 일부 커뮤니케이션 세션 같은 비관리 상태를 래핑하고 싶을 때가 있는데, 현재는
MailboxProcessor.Start로는 그게 불가능합니다.
-
감사 인사
F#은 .NET Foundation, F# Software Foundation, 그 멤버들, 그리고 Microsoft를 포함한 다른 기여자들 간의 협업으로 개발됩니다. F# 커뮤니티는 혁신, 설계, 구현, 전달의 모든 단계에 관여하며, 우리는 이 커뮤니티의 기여하는 일부가 된 것을 자랑스럽게 생각합니다.
2022년 10월과 2023년 10월 사이에 dotnet/fsharp 저장소는 그 기간 동안 커밋이 있는 33명의 기여자를 추적합니다. 그 위에 이슈를 제출하고, 제안을 올리고 투표하며, 기능 설계에 기여하는 수많은 기여자들이 있습니다. 모두 감사합니다!
아래는 F# 8 개발 기간에 걸친 dotnet/fsharp 저장소에 대해 GitHub의 자동 통계에 나열된 프로필 사진으로 만들어진 벽입니다. 링크를 방문해 그들이 F#에 가져온 변경 사항을 포함한 모든 기여자를 보세요. 여기 프로필 사진의 순서는 ASCII 알파벳순입니다.
우리는 모든 기여자, 커뮤니티 멤버, F# 사용자에게 감사하고 싶어요. 그 위에, 특히 많이 기여한 다음 멤버들과 그들의 F# 저장소에서의 최근 작업 중 일부를 명시적으로 짚어 드릴게요:
-
@kerams – 대부분의 새 언어 기능, 완성 개선, 그리고 F#에 많은 다른 추가 사항
-
@auduchinok – 파서 복구 개선, 엄격한 들여쓰기 모드, F# 컴파일러 자체의 많은 성능 개선
-
@nojaf – 문법 트리 추가, 코드 출력 추가, 시그니처 파일 개선, 그래프 기반 타입체크
-
@safesparrow – 컴파일러 관측성과 트레이싱, 그래프 기반 타입체크, 병렬 최적화
-
@majocha – 사용자 경험, 편집기 속도, 코드 검색 경험을 개선하는 VS 편집기 지원 개선
-
@edgarfp – 오류 처리 개선, 기존 진단의 사용자 친화성, 많은 새 F# 진단 추가
기여자 쇼케이스
지난 릴리스에서처럼 F#에 기여하는 개인들을 조명하고, 그들의 말로 그들에 대한 짧은 글을 게시하고 싶어요.
dawedawe
저는 독일 쾰른 근처에 사는 David Schaefer, 일명 dawe입니다.
대학 시절 이론 컴퓨터 과학을 접하면서 함수형 프로그래밍(FP)과 사랑에 빠졌죠. 그때 저는 OpenBSD의 일부 Haskell 패키지를 유지하며 오픈소스 기여에 첫발을 내디뎠습니다. 대학 졸업 후에는 FP 일자리를 찾지 못해 패키지 유지 관리에서 멀어졌지만, 최소한 C# 직장에서 자유 시간에 F#을 쓰며 FP와 접점을 유지할 수 있었어요.
F#을 전문적으로 쓰고 싶다는 동기로 저는 F#을 상당히 많이 쓰는 멋진 소규모 회사(Rhein-Spree)에 합류했습니다. 그것은 Fantomas에 작은 기여로 이어졌고, Florian의 훌륭한 멘토십 덕분에 공동 유지자까지 올라갈 수 있었죠.
2023년 3월 G-Research 오픈소스 팀에 합류해 F# 생태계에 풀타임으로 일하게 되면서 여정은 정말 거칠어졌습니다. 병행해서 우리는 Amplifying F# 이니셔티브를 시작했어요. 더 알고 싶다면 fsharpconf 2023의 우리 비디오를 보세요. 오늘 저는 생태계의 여러 부분을 유지하는 것을 돕고 있으며, 그에 필요한 신뢰를 얻은 것을 자랑스럽게 생각합니다.
Data Science in F# 컨퍼런스가 아직 생생한 가운데, 인간적인 면이 저에게 얼마나 중요한지 강조하고 싶어요. 전 세계 친구들과 커피나 맥주를 나누고, 앞으로 나아갈 새 아이디어를 논의하고, 승리를 함께 축하하고 패배 후 서로를 위로하는 것 – 그게 제가 이 일을 하는 큰 이유입니다. 그리고 물론 GitHub의 모든 초록색 사각형들도요.
auduchinok
저는 Eugene Auduchinok이고, JetBrains Rider에서 F# 지원을 작업하며 현재 암스테르담에 살고 있어요. 대학에서 일부 과정이 F#을 사용해서 처음 만났죠. F#은 그 작업들에 잘 맞았고 정말 재미있게 쓸 수 있었습니다. Rider가 발표되었을 때 기뻤어요, Mac에서 작업하고 IntelliJ를 쓰는 것을 좋아했거든요. 문제는 F#을 전혀 지원하지 않는다는 것이었고, 그래서 인턴십에 지원해 그걸 실현하려 했습니다. 그것은 잘 풀렸고 이제 매일 사용하며 다른 사람들도 쓰는 모습을 보는 것을 즐깁니다. 🙂 그 이후로 F# 도구의 여러 부분에 기여하고, 훌륭한 사람들과 일하고, 심지어 언어 설계 자체에도 영향을 미칠 기회를 가졌어요.
다음은 무엇인가?
우리는 F#을 계속 작업합니다 – 언어 자체든, 컴파일러 성능이든, Visual Studio 기능과 개선이든, F#의 다른 많은 측면이든.
-
전체 작업 추적 이슈 보기
-
돕고 싶으신가요? “help wanted” 이슈 목록이 있습니다
-
컴파일러 코드베이스 시작하기? 선별된 good first issues 목록이 있습니다