F# 컴포넌트 설계 가이드라인
F# 컴포넌트 설계 가이드라인
F# 프로그래밍의 컴포넌트를 어떻게 설계하고, 또 어떻게 코드를 작성해야 하는지 정리한 공식 가이드예요. 컴포넌트가 될 수 있는 대상은 F# 프로젝트 안에서 다른 곳이 가져다 쓰는 계층(layer), 어셈블리 경계를 넘어 F# 코드가 소비하는 라이브러리, 아니면 어떤 .NET 언어든 쓸 수 있는 라이브러리까지 다양해요. 라이브러리 설계자가 실제로 부딪히는 현실적인 문제들을 하나씩 짚어 주면서, F# 고유의 구성 요소를 어떻게 다뤄야 할지, 그리고 vanilla .NET 라이브러리를 만들 때는 또 무엇을 조심해야 할지 설명해 드릴게요.
출처
- 원문: F# component design guidelines (Microsoft Learn 공식 문서)
- 이 문서는 F# Component Design Guidelines, v14 (Microsoft Research)와 F# Software Foundation이 원래 관리하던 버전을 바탕으로 하고 있어요.
본문
개요
우리는 F# 컴포넌트를 설계하고 코딩할 때 마주치는 여러 가지 문제를 살펴볼 거예요. 여기서 컴포넌트란 다음 중 하나를 뜻해요.
- F# 프로젝트 안에서 외부 소비자가 있는 하나의 계층(layer)
- 어셈블리 경계를 넘어 F# 코드가 소비하도록 만들어진 라이브러리
- 어셈블리 경계를 넘어 어떤 .NET 언어든 소비할 수 있게 만들어진 라이브러리
- NuGet 같은 패키지 저장소를 통해 배포되는 라이브러리
이 문서에서 다루는 기법들은 Five principles of good F# code를 따르기 때문에, 상황에 맞게 함수형 프로그래밍과 객체 프로그래밍을 함께 활용해요.
어떤 방법론을 쓰든, 컴포넌트와 라이브러리 설계자는 개발자가 가장 쉽게 쓸 수 있는 API를 만들기 위해 수많은 실용적이고 일상적인 문제들을 해결해야 해요. .NET Library Design Guidelines을 꼼꼼히 적용하면, 소비하기 즐거운 일관된 API 집합을 만드는 방향으로 나아갈 수 있어요.
일반 가이드라인
F# 라이브러리에는 대상 독자가 누구든 상관없이 적용되는 몇 가지 보편적인 가이드라인이 있어요.
.NET Library Design Guidelines 익히기
어떤 종류의 F# 코딩을 하든, .NET Library Design Guidelines을 실무적으로 알고 있는 게 가치 있어요. 대부분의 F#·.NET 프로그래머는 이 가이드라인에 익숙하고, .NET 코드가 이를 따르기를 기대하거든요.
.NET Library Design Guidelines은 이름 짓기, 클래스와 인터페이스 설계, 멤버 설계(속성, 메서드, 이벤트 등) 등에 대한 일반적인 지침을 제공하고, 다양한 설계 지침의 좋은 첫 번째 참고 자료가 돼요.
코드에 XML 문서 주석 추가하기
공개 API에 XML 문서를 달면 사용자가 그 타입과 멤버를 쓸 때 훌륭한 Intellisense와 Quickinfo를 누릴 수 있고, 라이브러리용 문서 파일도 만들 수 있어요. xmldoc 주석 안에서 추가 마크업으로 쓸 수 있는 다양한 XML 태그에 대해서는 XML Documentation을 참고하세요.
/// 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) 고려하기
F# 라이브러리에서 명시적 시그니처 파일을 쓰면 공개 API를 간결하게 요약할 수 있어요. 덕분에 라이브러리의 전체 공개 표면(public surface)을 정확히 알 수 있고, 공개 문서와 내부 구현 세부사항을 깔끔하게 분리할 수 있죠. 다만 시그니처 파일은 공개 API를 바꾸려면 구현 파일과 시그니처 파일 양쪽을 함께 고쳐야 해서, 변경에 마찰이 생겨요. 그래서 보통은 API가 꽤 굳어져서 더 이상 크게 바뀔 일이 없을 때 시그니처 파일을 도입하는 게 좋아요.
.NET에서 문자열을 다루는 모범 사례 따르기
프로젝트 범위가 요구한다면 Best Practices for Using Strings in .NET 지침을 따르세요. 특히 문자열 변환·비교에서 적용 가능한 곳에는 *문화적 의도(cultural intent)*를 명시적으로 밝히는 걸 강조해요.
F#을 대상으로 하는 라이브러리 가이드라인
이 절은 F# 개발자가 소비하는 공개 API를 갖춘 라이브러리, 즉 F#을 대상으로 하는 공개 라이브러리를 개발할 때의 권장안을 제시해요. F#에 특히 적용되는 라이브러리 설계 권장안은 종류가 다양해요. 아래에 나오는 구체적인 권장안이 없을 때는 .NET Library Design Guidelines이 기본 지침이 돼요.
명명 규칙
.NET 명명·대문자 규칙 사용하기
아래 표는 .NET 명명·대문자 규칙을 따른 거예요. 여기에 F# 구성 요소를 추가로 포함했죠. 이 권장안은 특히 F#에서 F#으로만 넘어가지 않는 API, 즉 .NET BCL과 대부분의 라이브러리의 관용구에 맞는 API를 겨냥한 거예요.
| 구성 요소 | 표기 | 품사 | 예시 | 비고 |
|---|---|---|---|---|
| 구체 타입 (Concrete types) | PascalCase | 명사/형용사 | List, Double, Complex |
구체 타입은 구조체, 클래스, 열거형, 대리자, 레코드, 유니온을 말해요. OCaml에서는 타입 이름을 전통적으로 소문자로 쓰지만, F#은 타입에 .NET 명명 체계를 채택했어요. |
| DLL | PascalCase | Fabrikam.Core.dll |
||
| 유니온 태그 (Union tags) | PascalCase | 명사 | Some, Add, Success |
공개 API에서는 접두사를 쓰지 마세요. 내부에서는 "type Teams = TAlpha | TBeta | TDelta"처럼 접두사를 써도 돼요. |
| 이벤트 (Event) | PascalCase | 동사 | ValueChanged / ValueChanging |
|
| 예외 (Exceptions) | PascalCase | WebException |
이름은 "Exception"으로 끝나야 해요. | |
| 필드 (Field) | PascalCase | 명사 | CurrentName |
|
| 인터페이스 타입 (Interface types) | PascalCase | 명사/형용사 | IDisposable |
이름은 "I"로 시작해야 해요. |
| 메서드 (Method) | PascalCase | 동사 | ToString |
|
| 네임스페이스 (Namespace) | PascalCase | Microsoft.FSharp.Core |
일반적으로 <Organization>.<Technology>[.<Subnamespace>] 형식을 쓰되, 기술이 조직과 무관하다면 조직 부분을 생략해요. |
|
| 매개변수 (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 같은 부정형은 피하세요. |
약어 피하기
.NET 지침은 약어 사용을 권장하지 않아요(예: "OnBtnClick보다 OnButtonClick을 쓰세요"). 다만 "Asynchronous"를 뜻하는 Async 같은 흔한 약어는 용인돼요. 함수형 프로그래밍에서는 이 지침이 어쩔 때 무시되기도 해요. 예를 들어 List.iter는 "iterate"의 약어를 쓰죠. 그래서 F#에서 F#으로 쓰는 프로그래밍에서는 약어가 좀 더 관대하게 받아들여지지만, 공개 컴포넌트 설계에서는 여전히 일반적으로 피하는 게 좋아요.
대소문자로 인한 이름 충돌 피하기
.NET 지침에 따르면 대소문자만으로는 이름 충돌을 구분할 수 없어요. 일부 클라이언트 언어(예: Visual Basic)는 대소문자를 구분하지 않기 때문이에요.
적절한 곳에 약어(acronym) 사용하기
XML 같은 약어(acronym)는 축약어가 아니고, .NET 라이브러리에서 대문자를 쓰지 않은 형태(Xml)로 널리 쓰여요. 잘 알려지고 널리 인정받는 약어만 사용해야 해요.
제네릭 매개변수 이름에 PascalCase 사용하기
공개 API에서는 제네릭 매개변수 이름에 PascalCase를 쓰세요. F#을 대상으로 하는 라이브러리에서도 마찬가지예요. 특히 임의의 제네릭 매개변수에는 T, U, T1, T2 같은 이름을 쓰고, 특정한 이름이 의미 있을 때는 F# 대상 라이브러리에서 Key, Value, Arg 같은 이름을 쓰세요 (예를 들어 TKey 같은 건 피하세요).
F# 모듈의 공개 함수·값에는 PascalCase 또는 camelCase 사용하기
camelCase는 정규화 없이(unqualified) 쓰도록 설계된 공개 함수(예: invalidArg)와 "표준 컬렉션 함수"(예: List.map)에 사용돼요. 두 경우 모두 함수 이름이 마치 언어의 키워드처럼 작동해요.
객체, 타입, 모듈 설계
타입과 모듈을 담을 네임스페이스 또는 모듈 사용하기
컴포넌트의 각 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의 일급 개념이어서, 보통 Functor가 해 주는 일을 인터페이스로 그대로 이룰 수 있어요. 게다가 함수 레코드로는 못 하는 존재 타입(existential type)을 프로그램에 인코딩할 수도 있어요.
컬렉션에 작용하는 함수를 묶으려면 모듈 사용하기
컬렉션 타입을 정의할 때는 새 컬렉션 타입을 위해 CollectionType.map과 CollectionType.iter 같은 표준 연산 세트를 제공하는 걸 고려해 보세요.
module CollectionType =
let map f c =
...
let iter f c =
...
이런 모듈을 포함한다면 FSharp.Core에 있는 함수들의 표준 명명 규칙을 따르세요.
공통·정규(canonical) 함수를 묶으려면 모듈 사용하기, 특히 수학·DSL 라이브러리에서
예를 들어 Microsoft.FSharp.Core.Operators는 FSharp.Core.dll이 제공하는 최상위 함수들(예: abs, sin)이 자동으로 열리도록 모은 것(automatically opened collection)이에요.
마찬가지로 통계 라이브러리에는 erf와 erfc 함수를 담은 모듈을 두고, 그 모듈이 명시적으로든 자동으로든 열리도록 설계할 수 있어요.
RequireQualifiedAccess 사용을 고려하고 AutoOpen 속성은 신중히 적용하기
모듈에 [<RequireQualifiedAccess>] 특성을 붙이면, 그 모듈은 open할 수 없고 모듈의 요소를 참조하려면 반드시 정규화된 접근(qualified access)을 요구하게 돼요. 예를 들어 Microsoft.FSharp.Collections.List 모듈이 이 특성을 갖고 있어요.
모듈 안의 함수와 값 이름이 다른 모듈의 이름과 충돌할 가능성이 있을 때 이게 유용해요. 정규화된 접근을 요구하면 라이브러리의 장기적 유지보수성과 진화 가능성(evolvability)을 크게 높일 수 있어요.
FSharp.Core가 제공하는 모듈(예: Seq, List, Array)을 확장하는 사용자 모듈에는 [<RequireQualifiedAccess>] 특성을 붙이는 걸 강력히 권장해요. 그 모듈들은 F# 코드에서 널리 쓰이고 이미 [<RequireQualifiedAccess>]가 정의되어 있거든요. 더 일반적으로, 그런 특성이 있는 모듈을 가리거나(grows shadow) 확장하는 사용자 모듈이 그 특성 없이 정의되는 건 권장하지 않아요.
모듈에 [<AutoOpen>] 특성을 붙이면, 그 모듈을 담은 네임스페이스가 열릴 때 그 모듈도 함께 열려요. [<AutoOpen>] 특성은 어셈블리에도 적용할 수 있는데, 그 경우 어셈블리를 참조할 때 자동으로 열리는 모듈을 지정하게 돼요.
예를 들어 통계 라이브러리 MathsHeaven.Statistics에 erf와 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# 코딩에서는 이 타입을 멤버 제약(member constraints)이 있는 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로 드러나지 않아요. 런타임에 CLR(Common Language Runtime)은 실행 내내 사용할 확정된 구현을 알고 있지만, 컴파일된 코드 자체는 구체적 표현에 의존하지 않아요.
확장을 위해 구현 상속 사용 피하기
F#에서 구현 상속은 거의 사용되지 않아요. 게다가 상속 계층은 복잡하고 새 요구사항이 오면 바꾸기 어려운 경우가 많아요. 구현 상속이 F#에 여전히 남아 있는 건 호환성 때문이고, 문제의 최선의 해결책이 되는 드문 경우 때문이에요. 다형성을 위해 설계할 때는 인터페이스 구현 같은 대안 기법을 F# 프로그램에서 찾아야 해요.
함수·멤버 시그니처
관련 없는 값 여러 개를 소수 반환할 때는 반환값에 튜플 사용하기
반환 타입에 튜플을 쓰는 좋은 예는 다음과 같아요:
val divrem: BigInteger -> BigInteger -> BigInteger * BigInteger
많은 구성 요소를 담은 반환 타입이거나, 구성 요소들이 식별 가능한 단일 개체와 관련된 경우에는 튜플 대신 이름 있는 타입을 고려하세요.
F# API 경계에서 비동기 프로그래밍에는 Async 사용하기
Operation이라는 이름의 동기식 연산이 T를 반환한다면, 비동기 연산은 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() =
...
예외
예외, results, options를 적절히 언제 쓰는지는 Error Management를 참고하세요.
확장 멤버
F#-F# 컴포넌트에서는 F# 확장 멤버를 신중히 적용하기
F# 확장 멤버는 일반적으로 타입과 연관된 본질적 연산(intrinsic operations)의 폐포(closure) 안에 있는 연산에만 써야 해요. 흔한 용도 하나는 다양한 .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
유니온 타입
트리 구조 데이터에는 클래스 계층 대신 판별 유니온 사용하기
트리 같은 구조는 재귀적으로 정의돼요. 상속으로는 어색한데 판별 유니온(Discriminated Unions)으로는 우아하게 표현돼요.
type BST<'T> =
| Empty
| Node of 'T * BST<'T> * BST<'T>
트리 같은 데이터를 판별 유니온으로 표현하면 패턴 매칭의 완전성(exhaustiveness)도 누릴 수 있어요.
케이스 이름이 충분히 고유하지 않은 유니온 타입에는 [] 사용하기
같은 이름이 서로 다른 것을 가리키는 최선의 이름인 도메인에 처할 수 있어요. 판별 유니온 케이스가 그 대표적인 예죠. [<RequireQualifiedAccess>]로 케이스 이름을 명확히 구분하면, open 문장의 순서에 따라 달라지는 섀도잉(shadowing) 때문에 혼란스러운 오류가 생기는 걸 피할 수 있어요.
바이너리 호환 API의 경우 타입이 진화할 가능성이 있으면 판별 유니온의 표현 숨기기
유니온 타입은 간결한 프로그래밍 모델을 위해 F# 패턴 매칭 형태에 의존해요. 앞서 말했듯이 타입 설계가 진화할 가능성이 있다면 구체적 데이터 표현을 드러내지 말아야 해요.
예를 들어 판별 유니온의 표현은 private 또는 internal 선언을 하거나 시그니처 파일을 써서 숨길 수 있어요.
type Union =
private
| CaseA of int
| CaseB of string
판별 유니온을 무분별하게 드러내면 사용자 코드를 깨지 않고 라이브러리 버전을 올리기 어려워져요. 대신 활성 패턴(active patterns)을 하나 이상 드러내서 타입의 값들에 대해 패턴 매칭을 하도록 하는 걸 고려해 보세요.
활성 패턴은 F# 유니온 타입을 직접 노출하지 않으면서도 F# 소비자에게 패턴 매칭을 제공하는 대안이 돼요.
인라인 함수와 멤버 제약
암시적 멤버 제약과 정적으로 결정되는 제네릭 타입을 쓰는 인라인 함수로 제네릭 수치 알고리즘 정의하기
산술 멤버 제약과 F# 비교 제약(comparison constraints)은 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로 손색없어요.
타입 클래스와 덕 타이핑을 흉내 내려고 멤버 제약 사용 피하기
F# 멤버 제약으로 "덕 타이핑"을 흉내 내는 게 가능해요. 하지만 이걸 쓰는 멤버는 일반적으로 F#-F# 라이브러리 설계에 써서는 안 돼요. 익숙하지 않거나 비표준적인 암시적 제약에 기반한 라이브러리 설계는 사용자 코드를 유연하지 않게 만들고, 특정 프레임워크 패턴에 묶이게 하는 경향이 있기 때문이에요.
게다가 이런 방식으로 멤버 제약을 과하게 쓰면 컴파일 시간이 매우 길어질 가능성도 커요.
연산자 정의
사용자 정의 기호 연산자 피하기
사용자 정의 연산자는 어떤 상황에서는 필수적이고, 대규모 구현 코드 안에서는 매우 유용한 표기 장치(notational device)예요. 하지만 라이브러리를 처음 쓰는 사용자에게는 이름 있는 함수가 종종 더 쓰기 쉬워요. 게다가 사용자 정의 기호 연산자는 문서화하기 어렵고, IDE와 검색 엔진의 기존 한계 때문에 사용자가 연산자에 대한 도움말을 찾기도 더 어려워요.
그래서 기능을 이름 있는 함수와 멤버로 공개하고, 표기상의 이점이 문서화·인지 비용보다 클 때만 그 기능에 대한 연산자를 추가로 노출하는 게 최선이에요.
측정 단위 (Units of Measure)
F# 코드에 타입 안전성을 더하려면 측정 단위를 신중히 사용하기
측정 단위에 대한 추가 타입 정보는 다른 .NET 언어에서 볼 때 지워져요(erased). .NET 컴포넌트, 도구, 리플렉션은 단위가 빠진 타입을 보게 된다는 점을 명심하세요. 예를 들어 C# 소비자는 float<kg>이 아니라 float를 보게 돼요.
타입 약어 (Type Abbreviations)
F# 코드를 단순화하려면 타입 약어를 신중히 사용하기
.NET 컴포넌트, 도구, 리플렉션은 타입의 약어 이름을 보지 못해요. 타입 약어를 많이 쓰면 도메인이 실제보다 복잡해 보이게 만들어 소비자를 혼란스럽게 할 수도 있어요.
약어 대상 타입에 있는 멤버·속성과 본질적으로 달라야 하는 공개 타입에는 타입 약어 피하기
이 경우 약어 대상 타입(the type being abbreviated)이 실제로 정의하려는 타입의 표현을 너무 많이 드러내요. 대신 약어를 클래스 타입이나 단일 케이스 판별 유니온으로 감싸는 걸 고려하세요 (성능이 중요할 때는 약어를 감쌀 struct 타입을 쓰는 걸 고려해 보세요).
예를 들어 F# map의 특수한 경우로 멀티맵을 정의하고 싶은 유혹이 들 수 있어요:
type MultiMap<'Key,'Value> = Map<'Key,'Value list>
하지만 이 타입에 대한 논리적 점 표기(dot-notation) 연산은 Map의 연산과 같지 않아요. 예를 들어 조회 연산자 map[key]가 키가 사전에 없을 때 예외를 던지는 대신 빈 리스트를 반환하는 게 합리적이죠.
다른 .NET 언어에서 쓰기 위한 라이브러리 가이드라인
다른 .NET 언어에서 쓰기 위한 라이브러리를 설계할 때는 .NET Library Design Guidelines을 따르는 게 중요해요. 이 문서에서는 F# 고유 구성 요소를 제약 없이 쓰는 F# 대상 라이브러리와 대비해서, 이런 라이브러리를 vanilla .NET 라이브러리라고 부를게요. vanilla .NET 라이브러리를 설계한다는 건, 공개 API에서 F# 고유 구성 요소 사용을 최소화해서 .NET Framework 나머지와 일관된 익숙하고 관용적인 API를 제공한다는 뜻이에요. 규칙은 다음 절들에서 설명할게요.
네임스페이스와 타입 설계 (다른 .NET 언어에서 쓰기 위한 라이브러리)
컴포넌트의 공개 API에 .NET 명명 규칙 적용하기
약어 이름과 .NET 대문자 지침 사용에 특별히 주의하세요.
type pCoord = ...
member this.theta = ...
type PolarCoordinate = ...
member this.Theta = ...
컴포넌트의 주 조직 구조로 네임스페이스, 타입, 멤버 사용하기
공개 기능이 있는 모든 파일은 namespace 선언으로 시작해야 하고, 네임스페이스에서 공개적으로 드러나는 개체는 타입뿐이어야 해요. F# 모듈은 쓰지 마세요.
구현 코드, 유틸리티 타입, 유틸리티 함수를 담는 데는 비공개(non-public) 모듈을 쓰세요.
모듈보다 정적 타입(static types)을 선호하세요. 정적 타입은 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# 레코드 타입 사용하기
F# 레코드 타입은 단순한 .NET 클래스로 컴파일돼요. 그래서 API의 단순하고 안정된 일부 타입에 적합해요. 자동 생성되는 인터페이스를 억제하려면 [<NoEquality>]와 [<NoComparison>] 특성 사용을 고려하세요. vanilla .NET API에서는 가변 레코드 필드도 공개 필드를 노출하므로 피하세요. 그리고 클래스가 API의 향후 진화에 더 유연한 선택지가 될지 항상 고려하세요.
예를 들어 다음 F# 코드는 C# 소비자에게 공개 API를 노출해요:
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; }
}
vanilla .NET API에서 F# 유니온 타입의 표현 숨기기
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 설계 패턴을 적용하세요. 사용자 인터페이스 프로그래밍의 모델에는 System.Collections.ObjectModel에서 찾을 수 있는 것 같은 이벤트·알림 기반 컬렉션 설계 패턴을 쓰세요.
객체와 멤버 설계 (다른 .NET 언어에서 쓰기 위한 라이브러리)
.NET 이벤트를 노출하려면 CLIEvent 특성 사용하기
Event(기본적으로 FSharpHandler 타입만 쓰는) 대신, object와 EventArgs를 받는 특정 .NET 대리자 타입으로 DelegateEvent를 구성해서, 이벤트가 다른 .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)는 .NET에서 실행 중인 비동기 계산을 나타내는 데 쓰여요. 태스크는 일반적으로 F# Async<T> 객체보다 조합성(compositional)이 떨어져요. "이미 실행 중인" 태스크를 나타내고, 병렬 조합을 수행하거나 취소 신호와 기타 컨텍스트 매개변수의 전파를 숨기는 방식으로는 조합될 수 없거든요.
그럼에도 불구하고 태스크를 반환하는 메서드는 .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.Func와 System.Action 대리자가 .NET 개발자가 이 API를 부드럽게 소비할 수 있게 하는 올바른 공개 API예요. (.NET Framework 2.0을 대상으로 할 때는 시스템 정의 대리자 타입이 더 제한적이에요. System.Converter<T,U> 같은 미리 정의된 대리자 타입을 쓰거나 특정 대리자 타입을 정의하는 걸 고려하세요.)
반대로 .NET 대리자는 F# 대상 라이브러리에는 자연스럽지 않아요(다음 F# 대상 라이브러리 절을 보세요). 그래서 vanilla .NET 라이브러리용 고차 메서드를 개발할 때 흔한 구현 전략은 모든 구현을 F# 함수 타입으로 작성한 다음, 실제 F# 구현 위에 대리자를 얇은 파사드로 쓴 공개 API를 만드는 거예요.
F# option 값을 반환하는 대신 TryGetValue 패턴 사용하고, F# option 값을 인자로 받는 것보다 메서드 오버로딩 선호하기
API에서 F# option 타입을 쓰는 흔한 패턴은 vanilla .NET API에서는 표준 .NET 설계 기법으로 구현하는 게 더 좋아요. F# option 값을 반환하는 대신 "TryGetValue" 패턴처럼 bool 반환 타입에 out 매개변수를 쓰는 걸 고려하세요. 또 F# option 값을 매개변수로 받는 대신 메서드 오버로딩이나 선택적 인자(optional arguments)를 쓰는 걸 고려하세요.
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>, .NET 구체 컬렉션 타입 Dictionary<Key,Value> 같은 구체 컬렉션 타입 사용은 피하세요. .NET Library Design Guidelines에는 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 매개변수를 쓰는 걸 선호하세요. 튜플과 구조체 튜플이 .NET에 존재하지만(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로 라이브러리와 문서가 개발자가 기대하는 대로 보이는지 확인할 수도 있어요.
부록
다른 .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# 코드에서 소비하기 훨씬 좋아져요.