Byrefs
Byrefs (참조 포인터)
F#은 저수준(low-level) 프로그래밍 영역을 다루는 기능을 크게 두 갈래로 제공해요. 바로 관리 포인터(managed pointer)인 byref/inref/outref 타입과, byref<'T>와 비슷한 의미론과 동일한 컴파일 타임 제약을 지닌 구조체인 "byref-like struct"예요. 대표적인 예로 Span<T>가 있죠.
출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/byrefs
본문
개요
F#의 byref 기능은 크게 두 가지 주요 영역으로 나뉘어요:
- byref/inref/outref 타입 — 관리 포인터입니다. 실행 시점(run time)에 유효하지 않은 프로그램이 컴파일되지 않도록 사용에 제약이 붙어요.
- byref-like struct —
byref<'T>와 유사한 의미론과 동일한 컴파일 타임 제약을 갖는 구조체예요. 예를 들면Span<T>가 여기에 해당해요.
Syntax
// Byref types as parameters
let f (x: byref<'T>) = ()
let g (x: inref<'T>) = ()
let h (x: outref<'T>) = ()
// Calling a function with a byref parameter
let mutable x = 3
f &x
// Declaring a byref-like struct
open System.Runtime.CompilerServices
[<Struct; IsByRefLike>]
type S(count1: int, count2: int) =
member x.Count1 = count1
member x.Count2 = count2
Byref, inref, and outref
byref에는 세 가지 형태가 있어요:
inref<'T>— 기본 값을 읽기 위한 관리 포인터예요.outref<'T>— 기본 값에 쓰기 위한 관리 포인터예요.byref<'T>— 기본 값을 읽고 쓸 수 있는 관리 포인터예요.
byref<'T>는 inref<'T>가 필요한 자리에 전달될 수 있고, 마찬가지로 outref<'T>가 필요한 자리에도 전달될 수 있어요.
Using byrefs
inref<'T>를 사용하려면 &로 포인터 값을 얻어야 해요:
open System
let f (dt: inref<DateTime>) =
printfn $"Now: %O{dt}"
let usage =
let dt = DateTime.Now
f &dt // Pass a pointer to 'dt'
outref<'T>나 byref<'T>로 포인터에 쓰려면, 값 자체를 mutable로 선언해 포인터를 잡아야 해요:
open System
let f (dt: byref<DateTime>) =
printfn $"Now: %O{dt}"
dt <- DateTime.Now
// Make 'dt' mutable
let mutable dt = DateTime.Now
// Now you can pass the pointer to 'dt'
f &dt
포인터를 읽지 않고 쓰기만 한다면, byref<'T> 대신 outref<'T>를 쓰는 편이 좋아요.
Inref 시맨틱
다음 코드를 살펴볼게요:
let f (x: inref<SomeStruct>) = x.SomeField
이것은 의미상 다음과 같다는 뜻이에요:
x포인터를 가진 쪽은 값을 읽는 용도로만 사용할 수 있어요.SomeStruct안에 중첩된 구조체 필드로 얻은 어떤 포인터든 타입이inref<_>로 주어져요.
그리고 다음 사실도 함께 성립해요:
- 다른 스레드나 별칭(alias)이
x에 쓰기 접근 권한을 갖지 않는다는 뜻은 아니에요. x가inref라는 이유로SomeStruct가 불변(immutable)이라는 뜻도 아니에요.
다만 불변인 F# 값 타입의 경우, this 포인터는 inref로 추론돼요.
이 모든 규칙을 종합하면, inref 포인터를 가진 쪽은 가리키는 메모리의 즉각적인 내용을 수정할 수 없다는 뜻이 돼요.
Outref 시맨틱
outref<'T>의 목적은 포인터를 쓰기 전용으로만 쓰겠다는 의도를 나타내는 거예요. 그런데 이름과 달리 outref<'T>는 기본 값을 읽는 것도 허용해요. 이는 호환성을 위한 거예요.
의미상 outref<'T>는 byref<'T>와 다를 바 없어요. 딱 한 가지 차이는, outref<'T> 파라미터를 가진 메서드는 [<Out>] 파라미터를 가진 메서드를 호출할 때처럼 자동으로 튜플 반환 타입으로 구성된다는 점이에요.
type C =
static member M1(x, y: _ outref) =
y <- x
true
match C.M1 1 with
| true, 1 -> printfn "Expected" // Fine with outref, error with byref
| _ -> printfn "Never matched"
C#과의 연동 (Interop with C#)
C#은 ref 반환(ref returns)에 더해 in ref, out ref 키워드를 지원해요. 아래 표는 F#이 C#이 내보낸 것(emit)을 어떻게 해석하는지 보여줘요:
| C# construct | F# infers |
|---|---|
ref return value |
outref<'T> |
ref readonly return value |
inref<'T> |
in ref parameter |
inref<'T> |
out ref parameter |
outref<'T> |
다음 표는 F#이 무엇을 내보내는지 보여줘요:
| F# construct | Emitted construct |
|---|---|
inref<'T> argument |
[In] attribute on argument |
inref<'T> return |
modreq attribute on value |
inref<'T> in abstract slot or implementation |
modreq on argument or return |
outref<'T> argument |
[Out] attribute on argument |
타입 추론과 오버로딩 규칙
F# 컴파일러는 다음 경우에 inref<'T> 타입을 추론해요:
IsReadOnly특성이 있는 .NET 파라미터 또는 반환 타입.- mutable 필드가 없는 구조체 타입의
this포인터. - 다른
inref<_>포인터에서 파생된 메모리 위치의 주소.
inref의 암시적 주소를 취할 때는, SomeType 타입의 인자를 받는 오버로드가 inref<SomeType> 타입의 인자를 받는 오버로드보다 우선해요. 예를 들면:
type C() =
static member M(x: System.DateTime) = x.AddDays(1.0)
static member M(x: inref<System.DateTime>) = x.AddDays(2.0)
static member M2(x: System.DateTime, y: int) = x.AddDays(1.0)
static member M2(x: inref<System.DateTime>, y: int) = x.AddDays(2.0)
let res = System.DateTime.Now
let v = C.M(res)
let v2 = C.M2(res, 4)
두 경우 모두 inref<System.DateTime>를 받는 오버로드가 아니라 System.DateTime을 받는 오버로드가 선택돼요.
Byref-like 구조체
byref/inref/outref 세 형제에 더해, byref-like 시맨틱을 따르는 나만의 구조체를 정의할 수도 있어요. 이때는 IsByRefLikeAttribute 특성을 사용해요:
open System
open System.Runtime.CompilerServices
[<IsByRefLike; Struct>]
type S(count1: Span<int>, count2: Span<int>) =
member x.Count1 = count1
member x.Count2 = count2
IsByRefLike는 Struct를 포함하지 않아요. 타입에는 둘 다 있어야 해요.
F#에서 "byref-like" 구조체는 스택에 묶이는(stack-bound) 값 타입이에요. 관리 힙(managed heap)에는 절대 할당되지 않죠. byref-like 구조체는 수명(lifetime)과 비캡처(non-capture)에 관한 강력한 검사를 강제하므로 고성능 프로그래밍에 유용해요. 규칙은 다음과 같아요:
- 함수 파라미터, 메서드 파라미터, 지역 변수, 메서드 반환값으로 사용할 수 있어요.
- 클래스나 일반 구조체의 정적 또는 인스턴스 멤버가 될 수 없어요.
- 어떤 클로저 구조(비동기 메서드나 람다 표현식)에도 캡처될 수 없어요.
- 제네릭 파라미터로 사용할 수 없어요.
- F# 9부터는, 그 제네릭 파라미터가 C#에서
allows ref struct안티-제약(anti-constraint)으로 정의되어 있으면 이 제한이 완화돼요. F#은 그러한 제네릭을 byref-like 타입과 함께 타입과 메서드에서 인스턴스화할 수 있어요. 몇 가지 예로 BCL 델리게이트 타입(Action<>,Func<>), 인터페이스(IEnumerable<>,IComparable<>), 사용자 제공 누적기 함수를 받는 제네릭 인자(String.Create<TState>(int length, TState state, SpanAction<char, TState> action)) 등에 영향을 줘요. - F#에서 byref-like 타입을 지원하는 제네릭 코드를 작성하는 것은 불가능해요.
마지막 요점은 F#의 파이프라인 스타일 프로그래밍에 매우 중요해요. |>는 입력 타입을 파라미터화하는 제네릭 함수이기 때문이죠. |>는 inline이고 본문에서 non-inlined 제네릭 함수를 호출하지 않으므로, 이 제한은 향후 |>에 대해 완화될 수 있어요.
이 규칙들이 사용을 강하게 제한하긴 하지만, 그 덕분에 안전한 방식으로 고성능 컴퓨팅의 약속을 지킬 수 있어요.
Byref 반환 (Byref returns)
F# 함수나 멤버에서의 byref 반환은 만들고 소비할 수 있어요. byref를 반환하는 메서드를 소비할 때는 값이 암시적으로 역참조(dereference)돼요. 예를 들면:
let squareAndPrint (data : byref<int>) =
let squared = data*data // data is implicitly dereferenced
printfn $"%d{squared}"
byref로 값을 반환하려면, 그 값을 담은 변수가 현재 스코프보다 더 오래 살아 있어야 해요. 그리고 byref를 반환할 때는 &value를 사용해요 (value는 현재 스코프보다 더 오래 살아 있는 변수라는 뜻).
let mutable sum = 0
let safeSum (bytes: Span<byte>) =
for i in 0 .. bytes.Length - 1 do
sum <- sum + int bytes[i]
&sum // sum lives longer than the scope of this function.
체이닝된 여러 호출을 통해 참조를 전달하는 것처럼, 암시적 역참조를 피하고 싶다면 &x를 사용해요 (x는 값).
byref 반환값에 직접 할당할 수도 있어요. 다음 (상당히 명령형인) 프로그램을 살펴볼게요:
type C() =
let mutable nums = [| 1; 3; 7; 15; 31; 63; 127; 255; 511; 1023 |]
override _.ToString() = String.Join(' ', nums)
member _.FindLargestSmallerThan(target: int) =
let mutable ctr = nums.Length - 1
while ctr > 0 && nums[ctr] >= target do ctr <- ctr - 1
if ctr > 0 then &nums[ctr] else &nums[0]
[<EntryPoint>]
let main argv =
let c = C()
printfn $"Original sequence: %O{c}"
let v = &c.FindLargestSmallerThan 16
v <- v*2 // Directly assign to the byref return
printfn $"New sequence: %O{c}"
0 // return an integer exit code
실행 결과는 이래요:
Original sequence: 1 3 7 15 31 63 127 255 511 1023
New sequence: 1 3 7 30 31 63 127 255 511 1023
Byref의 스코핑 (Scoping for byrefs)
let로 바인딩된 값의 참조는 값이 정의된 스코프를 벗어날 수 없어요. 예를 들어 다음은 허용되지 않아요:
let test2 () =
let x = 12
&x // Error: 'x' exceeds its defined scope!
let test () =
let x =
let y = 1
&y // Error: `y` exceeds its defined scope!
()
이 제약 덕분에 최적화 여부에 따라 결과가 달라지는 일을 막을 수 있어요.
더 알아보기 (Learn more)
- 원문 문서: Byrefs
- F# 언어 참조(Language Reference) 전체 목록: F# language reference
- 관련 개념:
Span<T>/ byref-like 구조체, C#의ref/in/out및 ref returns 연동