F# 컴포넌트 설계 지침

F# 컴포넌트 설계 지침 (Interoperability)

F#은 .NET 위에서 돌아가는 언어라서, C#이나 Visual Basic 같은 다른 .NET 언어와 함께 쓰는 게 아주 자연스러워요. 그런데 F# 고유의 함수형 문법(모듈, 튜플, option, function 타입 등)을 그대로 공개 API에 노출하면 다른 언어 개발자가 쓰기 어려운 코드가 되기 쉬워요. 이 문서는 바로 그 "경계(boundary)"를 어떻게 설계해야 다른 .NET 언어에서도 편하게 호출할 수 있는지, 그리고 F#끼리 쓸 때는 또 어떻게 해야 하는지 알려주는 공식 설계 지침이에요. 즉 F#의 상호운용성(interoperability)을 제대로 다루는 가장 핵심적인 공식 문서라고 보면 돼요.

이 문서는 Microsoft Research의 "F# Component Design Guidelines, v14"와 F# Software Foundation이 관리하던 버전을 바탕으로 만들어졌고, F# 컴포넌트(라이브러리)의 설계와 코딩과 관련된 여러 실무 문제를 다뤄요.

출처: F# component design guidelines - .NET

본문

개요 (Overview)

이 문서는 F# 컴포넌트 설계와 코딩과 관련된 여러 문제를 살펴봐요. 여기서 "컴포넌트"는 다음 중 어떤 것이라도 될 수 있어요.

  • 프로젝트 안에서 외부 소비자(consumer)를 가진 한 계층(layer)
  • 어셈블리 경계를 넘어 F# 코드가 쓰기 위한 라이브러리
  • 어셈블리 경계를 넘어 어떤 .NET 언어든 쓰기 위한 라이브러리
  • NuGet 같은 패키지 저장소로 배포하기 위한 라이브러리

여기서 설명하는 기법들은 좋은 F# 코드의 다섯 가지 원칙을 따르면서, 함수형 프로그래밍과 객체 프로그래밍을 적절히 함께 활용해요.

어떤 방법론을 쓰든, 컴포넌트와 라이브러리를 설계하는 사람은 개발자가 가장 쓰기 쉬운 API를 만들려다 보면 여러 실용적이고 소박한 문제에 부딪히게 돼요. .NET 라이브러리 설계 지침을 꼼꼼히 적용하면 일관성 있고 쓰기 좋은 API를 만드는 데 큰 도움이 돼요.

일반 지침 (General guidelines)

라이브러리 사용 대상과 무관하게 F# 라이브러리 전체에 적용되는 보편적인 지침이 몇 가지 있어요.

.NET 라이브러리 설계 지침 익히기

어떤 종류의 F# 코딩을 하든, .NET 라이브러리 설계 지침을 실무적으로 알고 있으면 매우 유용해요. 대부분의 다른 F#·.NET 프로그래머는 이 지침을 잘 알고 있고, .NET 코드가 그에 맞춰져 있기를 기대해요.

.NET 라이브러리 설계 지침은 이름 짓기, 클래스와 인터페이스 설계, 멤버 설계(속성·메서드·이벤트 등)에 대한 일반 지침을 제공하며, 다양한 설계 안내의 첫 번째 참고 자료로 쓰기 좋아요.

공개 API에 XML 문서를 달면 사용자가 이 타입과 멤버를 쓸 때 훌륭한 Intellisense와 Quickinfo를 얻을 수 있고, 라이브러리의 문서 파일도 만들 수 있어요. xmldoc 주석 안에서 쓸 수 있는 다양한 XML 태그는 XML 문서를 참고하세요.

 /// A class for representing (x,y) coordinates type Point = /// Computes the distance between this point and another member DistanceTo: otherPoint:Point -> float

XML 주석은 짧은 형식(/// comment)이나 표준 XML 주석(///<summary>comment</summary>) 중 아무거나 써도 돼요.

안정적인 라이브러리·컴포넌트 API에는 명시적 서명 파일(.fsi) 고려하기

명시적 서명 파일을 쓰면 공개 API를 간결하게 요약해주고, 라이브러리의 전체 공개 표면을 정확히 알 수 있게 도와주며, 공개 문서와 내부 구현 세부 사항을 깔끔하게 분리해줘요. 다만 서명 파일은 API를 바꿀 때 구현 파일과 서명 파일 양쪽을 함께 고쳐야 하기 때문에 변경에 마찰을 더해요. 그래서 서명 파일은 보통 API가 어느 정도 굳어져서 더 이상 크게 바뀌지 않을 때 도입하는 게 좋아요.

.NET의 문자열 사용 모범 사례 따르기

프로젝트 규모가 필요로 한다면 .NET의 문자열 사용 모범 사례 지침을 따라요. 특히 문자열 변환과 비교에서 (적용 가능한 곳에) *문화적 의도(cultural intent)*를 명시하는 게 중요해요.

F# 사용자를 위한 라이브러리 지침

이 절은 F# 개발자가 쓰도록 만든 공개 F# 라이브러리에 대한 권장 사항이에요. F#에 특히 적용되는 라이브러리 설계 권장 사항이 다양하게 있어요. 아래에서 따로 언급하지 않은 경우에는 .NET 라이브러리 설계 지침이 기본 안내로 적용돼요.

명명 규칙 (Naming conventions)

.NET 명명·대문자 규칙 사용하기

아래 표는 .NET 명명 및 대문자 표기 규칙을 따르고, F# 구문도 약간 추가한 내용이에요. 이 권장 사항은 특히 F#-F# 경계를 넘는 API를 위한 것으로, .NET BCL과 대부분의 라이브러리 관용구에 맞춘 거예요.

구문(Construct) 대소문자(Case) 품사(Part) 예시(Examples) 비고(Notes)
구체 타입(Concrete types) PascalCase 명사/형용사 List, Double, Complex 구체 타입은 struct, class, enumeration, delegate, record, union을 가리켜요. OCaml에서는 타입 이름이 전통적으로 소문자지만, F#은 타입에 .NET 명명 체계를 채택했어요.
DLL PascalCase Fabrikam.Core.dll
유니언 태그(Union tags) PascalCase 명사 Some, Add, Success 공개 API에서는 접두사를 붙이지 마세요. 내부적으로는 `type Teams = TAlpha
이벤트(Event) PascalCase 동사 ValueChanged / ValueChanging
예외(Exceptions) PascalCase WebException 이름은 "Exception"으로 끝나야 해요.
필드(Field) PascalCase 명사 CurrentName
인터페이스 타입(Interface types) PascalCase 명사/형용사 IDisposable 이름은 "I"로 시작해야 해요.
메서드(Method) PascalCase 동사 ToString
네임스페이스(Namespace) PascalCase Microsoft.FSharp.Core 일반적으로 <조직>.<기술>[.<하위네임스페이스>]를 쓰되, 기술이 조직과 무관하면 조직은 생략해도 돼요.
매개변수(Parameters) camelCase 명사 typeName, transform, range
let 값(내부) camelCase 또는 PascalCase 명사/동사 getValue, myTable
let 값(외부) camelCase 또는 PascalCase 명사/동사 List.map, Dates.Today let 바인딩 값은 전통적인 함수형 설계 패턴에서는 공개인 경우가 많아요. 다만 다른 .NET 언어에서 쓸 수 있는 식별자라면 보통 PascalCase를 써요.
속성(Property) PascalCase 명사/형용사 IsEndOfFile, BackColor 불리언 속성은 Is와 Can을 쓰고 긍정형이어야 해요. IsEndOfFile처럼 써야지 IsNotEndOfFile로 쓰면 안 돼요.
약어(abbreviation) 피하기

.NET 지침은 약어 사용을 권장하지 않아요 (예: "OnBtnClick 대신 OnButtonClick"). Async("Asynchronous"의 약어)처럼 흔한 약어는 허용돼요. 함수형 프로그래밍에서는 이 지침이 가끔 무시되기도 해요. 예를 들어 List.iter는 "iterate"의 약어를 써요. 그래서 F#-F# 프로그래밍에서는 약어를 좀 더 관대하게 쓰곤 하지만, 공개 컴포넌트 설계에서는 여전히 피하는 게 좋아요.

대소문자만 다른 이름 충돌 피하기

.NET 지침은 일부 클라이언트 언어(예: Visual Basic)가 대소문자를 구분하지 않기 때문에, 이름 충돌을 구분하는 데 대소문자만 쓸 수 없다고 해요.

적절한 곳에는 약어(acronym) 쓰기

XML 같은 약어는 축약어가 아니고 .NET 라이브러리에서 소문자 형태(Xml)로 널리 쓰여요. 잘 알려지고 널리 인정받는 acronym만 사용해야 해요.

제네릭 매개변수 이름은 PascalCase 쓰기

공개 API에서는 (F# 사용자용 라이브러리도 포함해서) 제네릭 매개변수 이름에 PascalCase를 써요. 특히 임의의 제네릭 매개변수에는 T, U, T1, T2 같은 이름을 쓰고, 의미 있는 이름이 있다면 F# 사용자용 라이브러리에서는 Key, Value, Arg 같은 이름을 쓰세요 (단, TKey 같은 이름은 쓰지 마세요).

F# 모듈의 공개 함수·값에는 PascalCase 또는 camelCase 쓰기

camelCase는 한정 없이 쓰도록 설계된 공개 함수(예: invalidArg)와 "표준 컬렉션 함수"(예: List.map)에 사용돼요. 이 두 경우 모두 함수 이름이 언어의 키워드처럼 작동해요.

객체, 타입, 모듈 설계 (Object, Type, and Module design)

타입과 모듈을 담으려면 네임스페이스나 모듈 사용하기

컴포넌트의 각 F# 파일은 네임스페이스 선언이나 모듈 선언으로 시작해야 해요.

 namespace Fabrikam.BasicOperationsAndTypes type ObjectType1() = ... type ObjectType2() = ... module CommonOperations = ...

또는

 module Fabrikam.BasicOperationsAndTypes type ObjectType1() = ... type ObjectType2() = ... module CommonOperations = ...

최상위에서 모듈과 네임스페이스로 코드를 구성할 때의 차이는 다음과 같아요.

  • 네임스페이스는 여러 파일에 걸칠 수 있어요
  • 네임스페이스는 내부 모듈 안에 있지 않으면 F# 함수를 담을 수 없어요
  • 어떤 모듈의 코드는 반드시 하나의 파일 안에 있어야 해요
  • 최상위 모듈은 내부 모듈 없이도 F# 함수를 담을 수 있어요

최상위 네임스페이스와 모듈 중 무엇을 고르느냐는 코드의 컴파일 형태에 영향을 주고, 그래서 여러분의 API가 결국 F# 코드 밖에서 쓰이게 된다면 다른 .NET 언어에서 보이는 모습에도 영향을 줘요.

객체 타입에 고유한 연산은 메서드와 속성으로 구현하기

객체를 다룰 때는 사용 가능한 기능을 그 타입의 메서드와 속성으로 구현하는 게 가장 좋아요.

 type HardwareDevice() = member this.ID = ... member this.SupportedProtocols = ... type HashTable<'Key,'Value>(comparer: IEqualityComparer<'Key>) = member this.Add(key, value) = ... member this.ContainsKey(key) = ... member this.ContainsValue(value) = ...

특정 멤버의 기능 대부분이 반드시 그 멤버 안에 구현되어야 하는 건 아니지만, 그 기능 중 소비자가 쓰는 부분은 그래야 해요.

가변 상태를 캡슐화하려면 클래스 사용하기

F#에서는 그 상태가 이미 클로저, 시퀀스 식, 비동기 계산 같은 다른 언어 구문에 캡슐화되어 있지 않을 때만 이렇게 하면 돼요.

 type Counter() = // let-bound values are private in classes. let mutable count = 0 member this.Next() = count <- count + 1 count

일련의 연산을 나타낼 때는 인터페이스 타입을 사용하세요. 함수 튜플이나 함수 레코드 같은 다른 선택지보다 이게 더 선호돼요.

 type Serializer = abstract Serialize<'T> : preserveRefEq: bool -> value: 'T -> string abstract Deserialize<'T> : preserveRefEq: bool -> pickle: string -> 'T

다음과 같이 쓰기보다는 위처럼 쓰세요.

 type Serializer<'T> = { Serialize: bool -> 'T -> string Deserialize: bool -> string -> 'T }

인터페이스는 .NET에서 일급 개념(first-class)이어서, Functor가 보통 해주는 일을 성취하는 데 쓸 수 있어요. 또 존재 타입(existential type)을 프로그램에 인코딩하는 데도 쓸 수 있는데, 함수 레코드로는 그게 불가능해요.

컬렉션에 작용하는 함수는 모듈로 묶기

컬렉션 타입을 정의할 때는 새 컬렉션 타입에 CollectionType.map, CollectionType.iter 같은 표준 연산 집합을 제공하는 걸 고려해보세요.

 module CollectionType = let map f c = ... let iter f c = ...

그런 모듈을 포함하면 FSharp.Core에 있는 함수들의 표준 명명 규칙을 따르세요.

특히 수학·DSL 라이브러리에서 일반적이고 표준적인 함수를 모듈로 묶기

예를 들어 Microsoft.FSharp.Core.Operators는 FSharp.Core.dll이 제공하는 최상위 함수(abs, sin 같은)의 자동으로 열리는(automatically opened) 컬렉션이에요.

마찬가지로 통계 라이브러리에는 erf, erfc 함수가 있는 모듈을 넣을 수 있고, 이 모듈은 명시적으로든 자동으로든 열리도록 설계돼요.

RequireQualifiedAccess 고려하고 AutoOpen 속성은 신중히 적용하기

모듈에 [<RequireQualifiedAccess>] 속성을 붙이면 그 모듈은 열 수 없게 되고, 모듈의 요소를 참조하려면 명시적으로 한정된 접근이 필요해요. 예를 들어 Microsoft.FSharp.Collections.List 모듈에 이 속성이 있어요.

이는 모듈의 함수와 값 이름이 다른 모듈의 이름과 충돌할 가능성이 있을 때 유용해요. 한정된 접근을 요구하면 라이브러리의 장기적인 유지·진화 가능성이 크게 높아져요.

FSharp.Core가 제공하는 모듈(예: Seq, List, Array)을 확장하는 커스텀 모듈에는 [<RequireQualifiedAccess>] 속성을 두는 걸 강력히 권장해요. 그 모듈들은 F# 코드에서 널리 쓰이고 속성이 정의되어 있으니까요. 보다 일반적으로, 그런 속성이 있는 다른 모듈을 가리거나(shadow) 확장하는 커스텀 모듈이 그 속성이 없는 채로 정의되는 것은 권장하지 않아요.

모듈에 [<AutoOpen>] 속성을 붙이면, 포함하는 네임스페이스가 열릴 때 그 모듈도 열려요. [<AutoOpen>] 속성은 어셈블리에도 적용할 수 있는데, 그 어셈블리를 참조할 때 자동으로 열리는 모듈을 나타내요.

예를 들어 통계 라이브러리 MathsHeaven.Statisticserf, erfc 함수를 담은 module MathsHeaven.Statistics.Operators가 있다고 해볼게요. 이 모듈을 [<AutoOpen>]으로 표시하는 게 합리적이에요. 그러면 open MathsHeaven.Statistics가 이 모듈도 열어서 erf, erfc 이름을 스코프 안에 가져오게 돼요. [<AutoOpen>]의 또 다른 좋은 용도는 확장 메서드를 담은 모듈이에요.

[<AutoOpen>]를 남용하면 네임스페이스가 오염되므로 신중히 써야 해요. 특정 도메인의 특정 라이브러리에서는 [<AutoOpen>]을 적절히 쓰면 사용성이 좋아질 수 있어요.

잘 알려진 연산자를 쓰는 게 적절한 곳에서는 클래스에 연산자 멤버 정의 고려하기

때때로 클래스는 Vector 같은 수학적 구조를 모델링하는 데 쓰여요. 모델링하는 도메인에 잘 알려진 연산자가 있을 때, 그 연산자를 클래스에 고유한 멤버로 정의하면 도움이 돼요.

 type Vector(x: float) = member v.X = x static member (*) (vector: Vector, scalar: float) = Vector(vector.X * scalar) static member (+) (vector1: Vector, vector2: Vector) = Vector(vector1.X + vector2.X) let v = Vector(5.0) let u = v * 10.0

이 안내는 이 타입들에 대한 일반적인 .NET 안내와 일치해요. 하지만 F# 코딩에서 특히 중요한 이유는, 이렇게 하면 이런 타입을 멤버 제약이 있는 F# 함수·메서드(List.sumBy 같은)와 함께 쓸 수 있기 때문이에요.

다른 .NET 언어 사용자를 위해 CompiledName으로 .NET 친화적 이름 제공 고려하기

때때로 F# 사용자에게는 어떤 스타일로 이름 짓고 싶지만(예: 소문자 정적 멤버를 마치 모듈 바인딩 함수처럼 보이게), 어셈블리로 컴파일될 때 이름 스타일을 다르게 하고 싶을 때가 있어요. [<CompiledName>] 속성으로 F#이 아닌 코드가 어셈블리를 쓸 때의 다른 스타일을 제공할 수 있어요.

 type Vector(x:float, y:float) = member v.X = x member v.Y = y [<CompiledName("Create")>] static member create x y = Vector (x, y) let v = Vector.create 5.0 3.0

[<CompiledName>]을 쓰면 F#이 아닌 어셈블리 소비자를 위해 .NET 명명 규칙을 사용할 수 있어요.

더 단순한 API가 된다면 멤버 함수에 메서드 오버로딩 사용하기

메서드 오버로딩은 비슷한 기능을 다른 옵션이나 인자로 수행해야 할 때 API를 단순화하는 강력한 도구예요.

 type Logger() = member this.Log(message) = ... member this.Log(message, retryPolicy) = ...

F#에서는 인자의 타입보다 인자의 개수로 오버로딩하는 게 더 흔해요.

객체의 구체적인 표현(concrete representation)을 드러내는 것은 피하세요. 예를 들어 DateTime 값의 구체적 표현은 .NET 라이브러리 설계의 외부 공개 API로 드러나지 않아요. 런타임에 Common Language Runtime은 실행 전반에 걸쳐 쓰일 확정된 구현을 알고 있어요. 하지만 컴파일된 코드 자체가 구체적 표현에 대한 의존성을 갖지는 않아요.

확장성에 구현 상속(implementation inheritance) 사용 피하기

F#에서는 구현 상속이 거의 쓰이지 않아요. 게다가 상속 계층은 종종 복잡하고 새 요구사항이 오면 바꾸기 어려워요. 구현 상속은 호환성과 극히 드문 최선의 해결책을 위해 F#에 여전히 남아 있지만, 다형성을 설계할 때는 인터페이스 구현 같은 대안적 기법을 F# 프로그램에서 찾아봐야 해요.

함수와 멤버 시그니처 (Function and member signatures)

반환 타입에 튜플을 쓴 좋은 예는 다음과 같아요.

 val divrem: BigInteger -> BigInteger -> BigInteger * BigInteger

많은 구성 요소를 담거나, 구성 요소들이 하나의 식별 가능한 개체와 관련된 반환 타입이라면 튜플 대신 이름 있는 타입을 쓰는 걸 고려하세요.

F# API 경계에서는 비동기 프로그래밍에 Async<T> 사용하기

T를 반환하는 동기 연산이 Operation이라면, Async<T>를 반환하면 비동기 연산은 AsyncOperation, Task<T>를 반환하면 OperationAsync라고 이름 지어야 해요. Begin/End 메서드를 노출하는 흔한 .NET 타입에는 Async.FromBeginEnd를 사용해 확장 메서드를 작성해, 그 .NET API들에 F# 비동기 프로그래밍 모델을 제공하는 파사드(façade)를 만들 수 있어요.

 type SomeType = member this.Compute(x:int): int = ... member this.AsyncCompute(x:int): Async<int> = ... type System.ServiceModel.Channels.IInputChannel with member this.AsyncReceive() = ...

예외 (Exceptions)

예외, 결과(result), option의 적절한 사용에 대해서는 오류 관리를 참고하세요.

확장 멤버 (Extension Members)

F#-F# 컴포넌트에서 F# 확장 멤버는 신중히 적용하기

F# 확장 멤버는 일반적으로, 해당 타입과 함께 그 타입의 고유 연산의 클로저 안에 있는 연산에만 써야 해요. 흔한 용도 중 하나는 다양한 .NET 타입에 F#에 더 관용적인 API를 제공하는 것이에요.

 type System.ServiceModel.Channels.IInputChannel with member this.AsyncReceive() = Async.FromBeginEnd(this.BeginReceive, this.EndReceive) type System.Collections.Generic.IDictionary<'Key,'Value> with member this.TryGet key = let ok, v = this.TryGetValue key if ok then Some v else None

유니언 타입 (Union Types)

트리 구조 데이터에는 클래스 계층 대신 구별된 유니언(Discriminated Unions) 사용하기

트리 같은 구조는 재귀적으로 정의돼요. 상속으로는 어색하지만 Discriminated Unions로는 우아하게 표현돼요.

 type BST<'T> = | Empty | Node of 'T * BST<'T> * BST<'T>

트리 같은 데이터를 Discriminated Unions로 표현하면 패턴 매칭의 완전성(exhaustiveness) 이점도 누릴 수 있어요.

케이스 이름이 충분히 고유하지 않은 유니언 타입에는 [<RequireQualifiedAccess>] 사용하기

어떤 도메인에서는 Discriminated Union 케이스처럼 서로 다른 것에 같은 이름이 가장 좋은 이름인 경우가 있어요. [<RequireQualifiedAccess>]로 케이스 이름의 모호함을 없애서, open 문의 순서에 의존하는 shadowing 때문에 생기는 헷갈리는 오류를 피할 수 있어요.

유니언 타입은 간결한 프로그래밍 모델을 위해 F# 패턴 매칭 형식에 의존해요. 앞서 언급했듯이, 이 타입들의 설계가 진화할 가능성이 있다면 구체적인 데이터 표현을 드러내는 걸 피해야 해요.

예를 들어 구별된 유니언의 표현은 private/internal 선언이나 서명 파일로 숨길 수 있어요.

 type Union = private | CaseA of int | CaseB of string

구별된 유니언을 무분별하게 드러내면, 사용자 코드를 깨뜨리지 않고 라이브러리의 버전을 올리기 어려워질 수 있어요. 대신 활성 패턴(active pattern)을 하나 이상 드러내서 여러분의 타입 값에 대한 패턴 매칭을 허용하는 걸 고려해보세요.

활성 패턴은 F# Union 타입을 직접 노출하지 않으면서 F# 소비자에게 패턴 매칭을 제공하는 또 다른 방법이에요.

인라인 함수와 멤버 제약 (Inline Functions and Member Constraints)

암시된 멤버 제약과 정적으로 해석되는 제네릭 타입으로 인라인 함수를 사용해 제네릭 수치 알고리즘 정의하기

산술 멤버 제약과 F# 비교 제약은 F# 프로그래밍의 표준이에요. 예를 들어 다음 코드를 봐요.

 let inline highestCommonFactor a b = let rec loop a b = if a = LanguagePrimitives.GenericZero<_> then b elif a < b then loop a (b - a) else loop (a - b) b loop a b

이 함수의 타입은 다음과 같아요.

 val inline highestCommonFactor : ^T -> ^T -> ^T when ^T : (static member Zero : ^T) and ^T : (static member ( - ) : ^T * ^T -> ^T) and ^T : equality and ^T : comparison

이것은 수학 라이브러리에서 공개 API로 쓰기 적합한 함수예요.

멤버 제약으로 타입 클래스나 오리 타이핑(duck typing)을 흉내 내는 것 피하기

F# 멤버 제약으로 "오리 타이핑"을 흉내 낼 수는 있어요. 하지만 이를 이용하는 멤버는 일반적으로 F#-F# 라이브러리 설계에 쓰지 말아야 해요. 낯설거나 비표준적인 암시적 제약에 기반한 라이브러리 설계는 사용자 코드를 유연하지 않게 만들고 특정 프레임워크 패턴 하나에 묶이는 경향이 있기 때문이에요.

또한 이런 방식으로 멤버 제약을 많이 쓰면 컴파일 시간이 매우 길어질 가능성도 커요.

연산자 정의 (Operator Definitions)

커스텀 기호 연산자 정의 피하기

커스텀 연산자는 어떤 상황에서는 필수이고, 구현 코드 덩어리 안에서는 매우 유용한 표기 도구예요. 라이브러리를 새로 쓰는 사용자에게는 이름 있는 함수가 보통 더 쓰기 쉬워요. 게다가 IDE와 검색 엔진의 기존 한계 때문에 커스텀 기호 연산자는 문서화하기 어렵고, 사용자가 연산자에 대한 도움말을 찾기도 더 어려워요.

그래서 기능은 이름 있는 함수와 멤버로 공개하고, 표기상의 이점이 문서화·인지 비용보다 클 때만 그 기능에 대한 연산자도 함께 노출하는 게 가장 좋아요.

측정 단위 (Units of Measure)

F# 코드에 타입 안전성을 더하려면 측정 단위를 신중히 사용하기

측정 단위에 대한 추가 타입 정보는 다른 .NET 언어에서 보면 지워져요(erased). .NET 컴포넌트, 도구, 리플렉션은 단위 없는 타입을 보게 된다는 점을 알아두세요. 예를 들어 C# 소비자는 float<kg>가 아니라 float를 보게 돼요.

타입 약어 (Type Abbreviations)

F# 코드 단순화를 위해 타입 약어는 신중히 사용하기

.NET 컴포넌트, 도구, 리플렉션은 타입의 약어 이름을 보지 못해요. 타입 약어를 많이 쓰면 도메인이 실제보다 복잡해 보일 수도 있고, 소비자를 헷갈리게 할 수 있어요.

멤버·속성이 약어 대상 타입과 본질적으로 달라야 하는 공개 타입에는 타입 약어 피하기

이 경우 약어 대상 타입이 실제로 정의되는 타입의 표현을 너무 많이 드러내요. 대신 약어를 클래스 타입이나 단일-케이스 구별된 유니언으로 감싸는 걸 고려하세요 (성능이 중요할 때는 struct 타입으로 약어를 감싸는 걸 고려하세요).

예를 들어 다중 맵(multi-map)을 F# map의 특별한 경우로 정의하고 싶을 수 있어요.

 type MultiMap<'Key,'Value> = Map<'Key,'Value list>

하지만 이 타입의 논리적 점 표기(dot-notation) 연산은 Map의 연산과 같지 않아요. 예를 들어 키가 딕셔너리에 없을 때 예외를 던지기보다 map[key] 조회 연산자가 빈 리스트를 반환하는 게 합리적이에요.

다른 .NET 언어에서 쓰기 위한 라이브러리 지침 (Guidelines for libraries for Use from other .NET Languages)

다른 .NET 언어에서 쓰기 위한 라이브러리를 설계할 때는 .NET 라이브러리 설계 지침을 따르는 게 중요해요. 이 문서에서는 이런 라이브러리를 F# 구문에 제약 없이 쓰는 F# 사용자용 라이브러리와 대비하여 vanilla .NET 라이브러리라고 부를게요. vanilla .NET 라이브러리를 설계한다는 건, 공개 API에 F# 고유 구문의 사용을 최소화해서 .NET 프레임워크의 나머지와 일관된 익숙하고 관용적인 API를 제공하는 것을 의미해요. 그 규칙들은 다음 절에서 설명할게요.

네임스페이스와 타입 설계 (다른 .NET 언어용)

컴포넌트의 공개 API에 .NET 명명 규칙 적용하기

특히 약어 이름과 .NET 대문자 표기 지침 사용에 신경을 써야 해요.

 type pCoord = ... member this.theta = ... type PolarCoordinate = ... member this.Theta = ...
컴포넌트의 기본 구성 구조로 네임스페이스·타입·멤버 사용하기

공개 기능을 가진 모든 파일은 namespace 선언으로 시작해야 하고, 네임스페이스의 유일한 공개 개체는 타입이어야 해요. F# 모듈은 사용하지 마세요.

구현 코드, 유틸리티 타입, 유틸리티 함수를 담는 데는 비공개(non-public) 모듈을 쓰세요.

정적 타입이 모듈보다 선호돼요. 정적 타입은 API가 미래에 오버로딩이나 F# 모듈에서 못 쓰는 다른 .NET API 설계 개념을 쓸 수 있도록 진화를 허용하기 때문이에요.

예를 들어 다음 공개 API 대신:

 module Fabrikam module Utilities = let Name = "Bob" let Add2 x y = x + y let Add3 x y z = x + y + z

이렇게 쓰는 걸 고려하세요.

 namespace Fabrikam [<AbstractClass; Sealed>] type Utilities = static member Name = "Bob" static member Add(x,y) = x + y static member Add(x,y,z) = x + y + z
타입 설계가 진화하지 않을 거라면 vanilla .NET API에 F# record 타입 사용하기

F# record 타입은 단순한 .NET 클래스로 컴파일돼요. API의 몇몇 단순하고 안정된 타입에 적합해요. 인터페이스의 자동 생성을 억제하려면 [<NoEquality>], [<NoComparison>] 속성을 쓰는 걸 고려하세요. 또 vanilla .NET API에서 가변 record 필드를 쓰는 것은 피하세요. 공개 필드를 노출하게 되니까요. API의 미래 진화에 클래스가 더 유연한 선택이 될지 항상 고려하세요.

예를 들어 다음 F# 코드는 공개 API를 C# 소비자에게 노출해요.

F#:

 [<NoEquality; NoComparison>] type MyRecord = { FirstThing: int SecondThing: string }

C#:

 public sealed class MyRecord { public MyRecord(int firstThing, string secondThing); public int FirstThing { get; } public string SecondThing { get; } }

F# 유니언 타입은 F#-F# 코딩에서조차 컴포넌트 경계를 넘어 흔히 쓰이지는 않아요. 컴포넌트와 라이브러리 내부에서 쓰면 훌륭한 구현 수단이 돼요.

vanilla .NET API를 설계할 때는 private 선언이나 서명 파일로 유니언 타입의 표현을 숨기는 걸 고려하세요.

 type PropLogic = private | And of PropLogic * PropLogic | Not of PropLogic | True

내부적으로 유니언 표현을 쓰는 타입에 원하는 .NET 지향 API를 제공하는 멤버를 추가할 수도 있어요.

 type PropLogic = private | And of PropLogic * PropLogic | Not of PropLogic | True /// A public member for use from C# member x.Evaluate = match x with | And(a,b) -> a.Evaluate && b.Evaluate | Not a -> not a.Evaluate | True -> true /// A public member for use from C# static member CreateAnd(a,b) = And(a,b)

GUI 및 기타 컴포넌트는 프레임워크의 설계 패턴으로 설계하기

.NET에는 WinForms, WPF, ASP.NET처럼 다양한 프레임워크가 있어요. 이 프레임워크에서 쓸 컴포넌트를 설계한다면, 각각의 명명·설계 규칙을 사용해야 해요. 예를 들어 WPF 프로그래밍이라면 설계하는 클래스에 WPF 설계 패턴을 적용하세요. UI 프로그래밍의 모델에는 System.Collections.ObjectModel에 있는 것 같은 이벤트와 알림 기반 컬렉션 같은 설계 패턴을 쓰세요.

객체와 멤버 설계 (다른 .NET 언어용)

.NET 이벤트를 노출하려면 CLIEvent 속성 사용하기

object와 EventArgs를 받는 특정 .NET 대리자 타입으로 DelegateEvent를 구성하세요 (기본적으로 FSharpHandler 타입만 쓰는 Event 대신). 그래야 이벤트가 다른 .NET 언어에 익숙한 방식으로 게시돼요.

 type MyBadType() = let myEv = new Event<int>() [<CLIEvent>] member this.MyEvent = myEv.Publish type MyEventArgs(x: int) = inherit System.EventArgs() member this.X = x /// A type in a component designed for use from other .NET languages type MyGoodType() = let myEv = new DelegateEvent<EventHandler<MyEventArgs>>() [<CLIEvent>] member this.MyEvent = myEv.Publish
비동기 연산은 .NET task를 반환하는 메서드로 노출하기

Task는 .NET에서 실행 중인 비동기 계산을 나타내는 데 쓰여요. Task는 일반적으로 F# Async<T> 객체보다 구성성(composability)이 떨어져요. "이미 실행 중인" 작업을 나타내고, 병렬 구성을 수행하거나 취소 신호·다른 컨텍스트 매개변수의 전파를 숨기는 방식으로 합성될 수 없기 때문이에요.

하지만 그럼에도 Task를 반환하는 메서드는 .NET의 비동기 프로그래밍 표준 표현이에요.

 /// A type in a component designed for use from other .NET languages type MyType() = let compute (x: int): Async<int> = async { ... } member this.ComputeAsync(x) = compute x |> Async.StartAsTask

명시적 취소 토큰을 받는 것도 자주 원할 거예요.

 /// A type in a component designed for use from other .NET languages type MyType() = let compute(x: int): Async<int> = async { ... } member this.ComputeAsTask(x, cancellationToken) = Async.StartAsTask(compute x, cancellationToken)
F# 함수 타입 대신 .NET 대리자 타입 사용하기

여기서 "F# 함수 타입"은 int -> int 같은 "화살표" 타입을 뜻해요.

이렇게 쓰지 말고:

 member this.Transform(f: int->int) = ...

이렇게 쓰세요:

 member this.Transform(f: Func<int,int>) = ...

F# 함수 타입은 다른 .NET 언어에 class FSharpFunc<T,U>로 보이고, 대리자 타입을 이해하는 언어 기능·도구에는 덜 적합해요. .NET Framework 3.5 이상을 대상으로 하는 고차 메서드를 작성할 때는 System.FuncSystem.Action 대리자를 공개 API로 써서 .NET 개발자가 마찰 없이 그 API를 쓰도록 하는 게 맞아요. (.NET Framework 2.0을 대상으로 할 때는 시스템 정의 대리자 타입이 더 제한적이므로, System.Converter<T,U> 같은 미리 정의된 대리자 타입을 쓰거나 특정 대리자 타입을 정의하는 걸 고려하세요.)

반대로 .NET 대리자는 F# 사용자용 라이브러리에는 자연스럽지 않아요 (다음 F# 사용자용 라이브러리 절 참고). 그래서 vanilla .NET 라이브러리의 고차 메서드를 개발할 때 흔한 구현 전략은, 모든 구현을 F# 함수 타입으로 작성한 다음 공개 API만 대리자로 만들어 실제 F# 구현 위에 얇은 파사드를 두는 것이에요.

F# option 값을 반환하는 대신 TryGetValue 패턴 사용하기, 그리고 인자로 F# option 값 받는 대신 메서드 오버로딩 선호하기

API에서 F# option 타입을 쓰는 흔한 패턴은 vanilla .NET API에서는 표준 .NET 설계 기법으로 더 잘 구현돼요. F# option 값을 반환하는 대신 "TryGetValue" 패턴처럼 bool 반환 타입에 out 매개변수를 쓰는 걸 고려하고, F# option 값을 매개변수로 받는 대신 메서드 오버로딩이나 선택적 인자(optional argument)를 쓰는 걸 고려하세요.

 member this.ReturnOption() = Some 3 member this.ReturnBoolAndOut(outVal: byref<int>) = outVal <- 3 true member this.ParamOption(x: int, y: int option) = match y with | Some y2 -> x + y2 | None -> x member this.ParamOverload(x: int) = x member this.ParamOverload(x: int, y: int) = x + y
매개변수·반환 값에는 .NET 컬렉션 인터페이스 타입 IEnumerable, IDictionary<Key,Value> 사용하기

.NET 배열 T[], F# 타입 list<T>, Map<Key,Value>, Set<T>, 그리고 Dictionary<Key,Value> 같은 .NET 구체 컬렉션 타입의 사용은 피하세요. .NET 라이브러리 설계 지침은 IEnumerable<T> 같은 다양한 컬렉션 타입을 언제 쓸지에 대한 좋은 조언을 담고 있어요. 일부 상황에서는 성능상 이유로 배열(T[]) 사용이 허용돼요. 특히 seq<T>IEnumerable<T>의 F# 별칭일 뿐이므로, seq가 vanilla .NET API에서 종종 적절한 타입이라는 점을 기억하세요.

F# 리스트 대신:

 member this.PrintNames(names: string list) = ...

F# 시퀀스를 쓰세요:

 member this.PrintNames(names: seq<string>) = ...
unit 타입은 메서드의 유일한 입력 타입으로 써서 인자 없는 메서드를 만들거나, 유일한 반환 타입으로 써서 void 반환 메서드를 만들 때만 쓰기

unit 타입의 다른 용도는 피하세요. 다음은 좋은 예시예요.

 ✔ member this.NoArguments() = 3 ✔ member this.ReturnVoid(x: int) = ()

이것은 나빠요.

 member this.WrongUnit( x: unit, z: int) = ((), ())
vanilla .NET API 경계에서 null 값 확인하기

F# 구현 코드는 불변 설계 패턴과 F# 타입에 대한 null 리터럴 사용 제한 덕분에 null 값이 적은 경향이 있어요. 다른 .NET 언어는 null을 값으로 훨씬 더 자주 써요. 그래서 vanilla .NET API를 노출하는 F# 코드는 API 경계에서 매개변수의 null을 확인하고, 그 값을 F# 구현 코드 깊숙이 흘려보내지 않아야 해요. isNull 함수나 null 패턴에 대한 패턴 매칭을 쓸 수 있어요.

 let checkNonNull argName (arg: obj) = match arg with | null -> nullArg argName | _ -> () let checkNonNull' argName (arg: obj) = if isNull arg then nullArg argName else ()

F# 9부터는 새로운 | null 문법을 활용해서 컴파일러가 가능한 null 값을 파악하고 어디서 처리해야 하는지 알려주게 할 수 있어요.

 let checkNonNull argName (arg: obj | null) = match arg with | null -> nullArg argName | _ -> () let checkNonNull' argName (arg: obj | null) = if isNull arg then nullArg argName else ()

F# 9에서는 컴파일러가 가능한 null 값이 처리되지 않았음을 감지하면 경고를 내보내요.

 let printLineLength (s: string) = printfn "%i" s.Length let readLineFromStream (sr: System.IO.StreamReader) = // `ReadLine` may return null here - when the stream is finished let line = sr.ReadLine() // nullness warning: The types 'string' and 'string | null' // do not have equivalent nullability printLineLength line

이 경고들은 매칭에서 F# null 패턴을 사용해 다뤄야 해요.

 let printLineLength (s: string) = printfn "%i" s.Length let readLineFromStream (sr: System.IO.StreamReader) = let line = sr.ReadLine() match line with | null -> () | s -> printLineLength s
반환 값으로 튜플을 쓰지 않기

대신 집계 데이터를 담은 이름 있는 타입을 반환하거나, out 매개변수로 여러 값을 반환하는 걸 선호해요. 튜플과 struct 튜플이 .NET에 존재하지만(struct 튜플에 대한 C# 언어 지원 포함), 대부분 .NET 개발자에게 이상적이고 기대되는 API를 제공하지는 않아요.

매개변수 커링(currying) 사용 피하기

대신 .NET 호출 규칙 Method(arg1,arg2,…,argN)을 사용하세요.

 member this.TupledArguments(str, num) = String.replicate num str

팁: 어떤 .NET 언어에서도 쓸 수 있는 라이브러리를 설계한다면, 실제로 C#과 Visual Basic 프로그래밍을 실험해봐서 라이브러리가 그 언어에서 "제대로 느껴지는지" 확인하는 것에 대체할 수 없어요. .NET Reflector나 Visual Studio Object Browser 같은 도구를 써서 라이브러리와 그 문서가 개발자에게 기대한 대로 보이는지 확인할 수도 있어요.

부록 (Appendix)

다른 .NET 언어가 쓰도록 F# 코드를 설계하는 종합 예시

다음 클래스를 고려해볼게요.

 open System type Point1(angle,radius) = new() = Point1(angle=0.0, radius=0.0) member x.Angle = angle member x.Radius = radius member x.Stretch(l) = Point1(angle=x.Angle, radius=x.Radius * l) member x.Warp(f) = Point1(angle=f(x.Angle), radius=x.Radius) static member Circle(n) = [ for i in 1..n -> Point1(angle=2.0*Math.PI/float(n), radius=1.0) ]

이 클래스의 추론된 F# 타입은 다음과 같아요.

 type Point1 = new : unit -> Point1 new : angle:double * radius:double -> Point1 static member Circle : n:int -> Point1 list member Stretch : l:double -> Point1 member Warp : f:(double -> double) -> Point1 member Angle : double member Radius : double

이 F# 타입이 다른 .NET 언어를 쓰는 프로그래머에게 어떻게 보이는지 살펴볼게요. 예를 들어 대략적인 C# "시그니처"는 다음과 같아요.

 // C# signature for the unadjusted Point1 class public class Point1 { public Point1(); public Point1(double angle, double radius); public static Microsoft.FSharp.Collections.List<Point1> Circle(int count); public Point1 Stretch(double factor); public Point1 Warp(Microsoft.FSharp.Core.FastFunc<double,double> transform); public double Angle { get; } public double Radius { get; } }

F#이 여기서 구문들을 어떻게 나타내는지 주목할 만한 중요한 점이 몇 가지 있어요. 예를 들면:

  • 인자 이름 같은 메타데이터가 보존돼요.
  • 두 인자를 받는 F# 메서드는 두 인자를 받는 C# 메서드가 돼요.
  • 함수와 리스트는 F# 라이브러리의 대응하는 타입에 대한 참조가 돼요.

다음 코드는 이런 점들을 고려해 그 코드를 조정하는 방법을 보여줘요.

 namespace SuperDuperFSharpLibrary.Types type RadialPoint(angle:double, radius:double) = /// Return a point at the origin new() = RadialPoint(angle=0.0, radius=0.0) /// The angle to the point, from the x-axis member x.Angle = angle /// The distance to the point, from the origin member x.Radius = radius /// Return a new point, with radius multiplied by the given factor member x.Stretch(factor) = RadialPoint(angle=angle, radius=radius * factor) /// Return a new point, with angle transformed by the function member x.Warp(transform:Func<_,_>) = RadialPoint(angle=transform.Invoke angle, radius=radius) /// Return a sequence of points describing an approximate circle using /// the given count of points static member Circle(count) = seq { for i in 1..count -> RadialPoint(angle=2.0*Math.PI/float(count), radius=1.0) }

이 코드의 추론된 F# 타입은 다음과 같아요.

 type RadialPoint = new : unit -> RadialPoint new : angle:double * radius:double -> RadialPoint static member Circle : count:int -> seq<RadialPoint> member Stretch : factor:double -> RadialPoint member Warp : transform:System.Func<double,double> -> RadialPoint member Angle : double member Radius : double

이제 C# 시그니처는 다음과 같아요.

 public class RadialPoint { public RadialPoint(); public RadialPoint(double angle, double radius); public static System.Collections.Generic.IEnumerable<RadialPoint> Circle(int count); public RadialPoint Stretch(double factor); public RadialPoint Warp(System.Func<double,double> transform); public double Angle { get; } public double Radius { get; } }

이 타입을 vanilla .NET 라이브러리에서 쓸 수 있게 준비하기 위해 가한 수정들은 다음과 같아요.

  • 여러 이름을 조정했어요: Point1, n, l, f가 각각 RadialPoint, count, factor, transform이 됐어요.
  • [ ... ] 리스트 구성을 IEnumerable<RadialPoint> 시퀀스 구성을 바꿔서 반환 타입이 RadialPoint list 대신 seq<RadialPoint>가 됐어요.
  • F# 함수 타입 대신 .NET 대리자 타입 System.Func을 사용했어요.

이렇게 하면 C# 코드에서 소비하기가 훨씬 더 좋아져요.

더 알아보기