F# 시그니처 파일
F# 시그니처 파일 (Signature Files)
한 프로그램에 공개된 내보내기를 정리해서 바깥에서 뭘 볼 수 있고 뭘 숨길 건지 결정하는 게, 규모가 커질수록 정말 중요해져요. 그때 쓰는 게 바로 시그니처 파일입니다. 이 페이지에서는 시그니처 파일이 뭔지, 어떻게 작성·관리하는지, 그리고 값이랑 타입 시그니처에 적용되는 규칙을 하나씩 살펴볼게요.
출처
- 원문: Signature files - F# | Microsoft Learn
- 마지막 업데이트: 2021-09-15
- 라이선스: Microsoft Learn 문서 (원문 출처 표기)
본문
시그니처 파일은 F# 프로그램 요소(타입, 네임스페이스, 모듈 등)의 공개 시그니처에 대한 정보를 담아요. 이 파일을 이용해 그 프로그램 요소의 접근 가능성(accessibility) 을 지정할 수 있죠.
Remarks (설명)
F# 코드 파일 하나마다 시그니처 파일을 둘 수 있어요. 시그니처 파일은 코드 파일과 이름이 같되 확장자는 .fs 대신 .fsi를 써요. 명령줄을 직접 사용하는 경우라면 컴파일 명령줄에도 시그니처 파일을 추가할 수 있습니다.
코드 파일과 시그니처 파일을 구분하기 위해, 코드 파일을 구현 파일(implementation file) 이라고 부르기도 해요. 프로젝트에서는 시그니처 파일이 해당 코드 파일보다 앞에 와야 합니다.
시그니처 파일은 대응하는 구현 파일에 들어 있는 네임스페이스, 모듈, 타입, 멤버를 기술해요. 시그니처 파일의 정보를 활용하면, 구현 파일의 코드 중 어느 부분을 그 바깥의 코드에서 접근할 수 있고 어느 부분이 구현 파일 내부 전용인지 지정할 수 있죠.
시그니처 파일에 포함된 네임스페이스·모듈·타입은 반드시 구현 파일에 포함된 것들의 부분집합이어야 해요. 이 주제에서 나중에 언급할 몇 가지 예외를 빼면, 시그니처 파일에 적혀 있지 않은 언어 요소는 구현 파일에 private한 것으로 간주됩니다. 프로젝트나 명령줄에서 시그니처 파일을 찾지 못하면 기본 접근 가능성이 적용돼요.
기본 접근 가능성에 대한 자세한 내용은 Access Control을 참고하세요.
시그니처 파일에서는 타입을 다시 정의하거나 각 메서드·함수의 구현을 반복하지 않아요. 그 대신 각 메서드와 함수에 대해 시그니처를 사용하죠. 시그니처는 곧 모듈이나 네임스페이스 조각이 구현하는 기능의 완전한 명세 역할을 합니다. 타입 시그니처의 문법은 인터페이스·추상 클래스에서 쓰는 추상 메서드 선언의 문법과 같고, IntelliSense나 F# 인터프리터 fsi.exe가 올바르게 컴파일된 입력을 표시할 때도 같은 형식이 보여요.
타입 시그니처 만으로는 그 타입이 sealed인지, 인터페이스 타입인지가 불분명한 경우에는 타입의 성격을 컴파일러에 알려주는 특성(attribute) 을 추가해야 해요. 이 용도로 쓰는 특성은 다음 표에 정리되어 있습니다.
| 특성 | 설명 |
|---|---|
[<Sealed>] |
추상 멤버가 없거나, 확장되지 않아야 하는 타입에 사용 |
[<Interface>] |
인터페이스인 타입에 사용 |
시그니처와 구현 파일의 선언 사이에 이 특성들이 일치하지 않으면 컴파일러가 오류를 냅니다.
값이나 함수 값의 시그니처를 만들 때는 키워드 val을 쓰고, 타입 시그니처는 키워드 type으로 시작해요.
시그니처 파일은 --sig 컴파일러 옵션으로 생성할 수 있습니다. 일반적으로 .fsi 파일은 손으로 직접 쓰지 않아요. 대신 컴파일러로 .fsi 파일을 생성하고, 프로젝트가 있다면 그 프로젝트에 추가한 뒤, 접근을 허용하고 싶지 않은 메서드와 함수를 제거하는 방식으로 편집하죠.
타입 시그니처에는 몇 가지 규칙이 적용됩니다.
- 구현 파일의 타입 약어(type abbreviation) 는 시그니처 파일에서 약어가 없는 타입과 일치해서는 안 됩니다.
- 레코드와 판별 공용체(Discriminated Union)는 필드와 생성자를 전부 노출하거나 전혀 노출하지 않아야 하고, 그 순서도 구현 파일에서의 순서와 일치해야 해요. 클래스는 필드와 메서드를 시그니처에서 일부, 전부, 또는 전혀 드러낼 수 있습니다.
- 생성자가 있는 클래스와 구조체는 기반 클래스의 선언(
inherits선언)을 반드시 드러내야 해요. 또 생성자가 있는 클래스와 구조체는 모든 추상 메서드와 인터페이스 선언도 반드시 노출해야 합니다. - 인터페이스 타입은 자신의 모든 메서드와 인터페이스를 반드시 드러내야 해요.
값 시그니처의 규칙은 다음과 같습니다.
- 접근 가능성 한정자(
public,internal등)와inline·mutable한정자는 시그니처에서도 구현에서와 반드시 일치해야 해요. - 제네릭 타입 매개변수의 개수(암시적으로 추론되든 명시적으로 선언되든)가 일치해야 하고, 제네릭 타입 매개변수의 타입과 타입 제약도 일치해야 합니다.
Literal특성을 쓰는 경우, 시그니처와 구현 양쪽에 모두 있어야 하고 양쪽에서 같은 리터럴 값을 사용해야 해요.- 시그니처와 구현의 매개변수 패턴(일명 arity)은 서로 일관성을 유지해야 합니다.
- 시그니처 파일의 매개변수 이름이 대응하는 구현 파일과 다르면, 시그니처 파일의 이름이 대신 사용돼요. 디버깅이나 프로파일링 때 문제가 될 수 있죠. 이런 불일치를 알림으로 받고 싶다면 프로젝트 파일이나 컴파일러 호출 시에 경고
3218을 켜면 됩니다(--warnon참고, Compiler Options).
다음 코드 예시는 네임스페이스, 모듈, 함수 값, 타입 시그니처에 적절한 특성까지 함께 담은 시그니처 파일 모습을 보여줘요. 대응하는 구현 파일도 함께 나옵니다.
// Module1.fsi
namespace Library1
module Module1 =
val function1 : int -> int
type Type1 =
new : unit -> Type1
member method1 : unit -> unit
member method2 : unit -> unit
[<Sealed>]
type Type2 =
new : unit -> Type2
member method1 : unit -> unit
member method2 : unit -> unit
[<Interface>]
type InterfaceType1 =
abstract member method1 : int -> int
abstract member method2 : string -> unit
대응하는 구현 파일은 다음과 같아요.
namespace Library1
module Module1 =
let function1 x = x + 1
type Type1() =
member type1.method1() =
printfn "type1.method1"
member type1.method2() =
printfn "type1.method2"
[<Sealed>]
type Type2() =
member type2.method1() =
printfn "type2.method1"
member type2.method2() =
printfn "type2.method2"
[<Interface>]
type InterfaceType1 =
abstract member method1 : int -> int
abstract member method2 : string -> unit