F# 모듈

F# 모듈 (Modules)

F#에서 모듈(module) 은 값, 타입, 함수 값 같은 F# 코드를 하나로 묶는 단위예요. 코드를 모듈로 묶으면 서로 관련된 코드를 함께 두기 쉬워지고, 프로그램 안에서 이름이 충돌하는 문제도 피할 수 있어요. 이 글에서는 모듈을 선언하는 방법부터 중첩 모듈, 재귀 모듈까지 차근차근 살펴볼게요.

출처

원문: Modules - F# | Microsoft Learn

본문

구문 (Syntax)

모듈은 파일 전체를 담는 최상위 모듈과, 파일 일부를 담는 로컬 모듈로 나눠요.

// 최상위 모듈 선언.
module [accessibility-modifier] [qualified-namespace.]module-name
declarations
// 로컬 모듈 선언.
module [accessibility-modifier] module-name =
    declarations

설명 (Remarks)

F# 모듈은 타입, 값, 함수 값, do 바인딩 속 코드 같은 F# 코드 구성 요소를 묶는 단위예요. 모듈은 공용 언어 런타임(CLR)에서 정적 멤버만 가지는 클래스로 구현됩니다. 모듈 선언에는 파일 전체를 모듈에 담느냐에 따라 두 종류가 있어요. 하나는 최상위 모듈 선언, 다른 하나는 로컬 모듈 선언이에요. 최상위 모듈 선언은 파일 전체를 모듈에 포함시키며, 파일에서 첫 번째 선언으로만 나타날 수 있어요.

최상위 모듈 선언의 구문에서 qualified-namespace는 모듈을 담고 있는 중첩된 네임스페이스 이름들의 나열이에요. 이 자격 있는 네임스페이스는 미리 선언해 둘 필요가 없어요.

최상위 모듈에서는 선언을 들여쓰지 않아도 돼요. 하지만 로컬 모듈에서는 모든 선언을 반드시 들여써야 해요. 로컬 모듈 선언에서는 그 모듈 선언 아래로 들여쓴 선언들만 그 모듈에 속하게 돼요.

코드 파일이 최상위 모듈 선언이나 네임스페이스 선언으로 시작하지 않으면, 파일의 전체 내용(로컬 모듈 포함)은 파일 이름에서 확장자를 뺀 뒤 첫 글자를 대문자로 바꾼 이름을 가진 암시적으로 만들어진 최상위 모듈의 일부가 돼요. 예를 들어 다음 파일을 살펴볼게요.

// program.fs 파일에 있는 내용.
let x = 40

이 파일은 사실 다음과 같이 작성된 것처럼 컴파일돼요.

module Program
let x = 40

파일 안에 여러 모듈이 있다면 각 모듈마다 로컬 모듈 선언을 사용해야 해요. 감싸는 네임스페이스가 선언되어 있으면 이 모듈들은 그 네임스페이스에 속하고, 감싸는 네임스페이스가 선언되어 있지 않으면 암시적으로 만들어진 최상위 모듈에 속하게 돼요. 다음 코드 예시는 여러 모듈을 담고 있는 코드 파일을 보여줘요. 컴파일러는 암시적으로 Multiplemodules라는 최상위 모듈을 만들고, MyModule1MyModule2는 그 최상위 모듈 안에 중첩돼요.

// multiplemodules.fs 파일에 있는 내용.
// MyModule1
module MyModule1 =
    // 등호로 선언된 모듈 안의 모든 프로그램 요소는 들여써야 해요.
    let module1Value = 100

    let module1Function x =
        x + 10

// MyModule2
module MyModule2 =

    let module2Value = 121

    // 자격 있는 이름을 사용해 MyModule1의 함수에 접근해요.
    let module2Function x =
        x * (MyModule1.module1Function module2Value)

프로젝트나 단일 컴파일에 파일이 여러 개 있거나, 라이브러리를 만들 때는 파일 맨 위에 네임스페이스 선언이나 모듈 선언을 꼭 넣어야 해요. F# 컴파일러가 모듈 이름을 암시적으로 정하는 경우는 프로젝트나 컴파일 명령줄에 파일이 하나뿐이면서 애플리케이션을 만들 때뿐이에요.

accessibility-modifierpublic, private, internal 중 하나가 될 수 있어요. 자세한 내용은 Access Control 문서를 참고하세요. 기본값은 public이에요.

모듈의 코드 참조하기 (Referencing Code in Modules)

다른 모듈의 함수, 타입, 값을 참조할 때는 자격 있는 이름(qualified name) 을 쓰거나 모듈을 열어야(open) 해요. 자격 있는 이름을 쓰려면 접근하려는 프로그램 요소의 네임스페이스, 모듈, 식별자를 모두 지정해야 해요. 자격 있는 경로의 각 부분은 점(.)으로 구분해요.

Namespace1.Namespace2.ModuleName.Identifier

코드를 단순하게 만들고 싶다면 모듈이나 네임스페이스 하나 또는 여러 개를 열 수 있어요. 네임스페이스와 모듈 여는 방법에 대한 자세한 내용은 Import Declarations: The open Keyword 문서를 참고하세요.

다음 코드 예시는 파일 끝까지의 모든 코드를 담고 있는 최상위 모듈을 보여줘요.

module Arithmetic

let add x y =
    x + y

let sub x y =
    x - y

같은 프로젝트의 다른 파일에서 이 코드를 사용하려면 자격 있는 이름을 쓰거나, 함수를 사용하기 전에 모듈을 열면 돼요. 다음 예시를 볼게요.

// 함수 이름을 완전히 자격 있는 이름으로 써요.
let result1 = Arithmetic.add 5 9
// 모듈을 열어요.
open Arithmetic
let result2 = add 5 9

중첩 모듈 (Nested Modules)

모듈은 중첩될 수 있어요. 내부 모듈은 새 모듈이 아니라 내부 모듈임을 나타내도록 바깥 모듈 선언만큼 들여써야 해요. 예를 들어 다음 두 예시를 비교해 볼게요. 아래 코드에서 모듈 Z는 내부 모듈이에요.

module Y =
    let x = 1

    module Z =
        let z = 5

하지만 다음 코드에서 모듈 Z는 모듈 Y와 형제(sibling) 관계예요.

module Y =
    let x = 1

module Z =
    let z = 5

다음 코드에서 모듈 Z도 같은 이유로 형제 모듈이에요. 모듈 Y의 다른 선언들만큼 들여쓰지 않았기 때문이에요.

module Y =
        let x = 1

    module Z =
        let z = 5

마지막으로, 바깥 모듈에 선언이 없고 바로 뒤에 다른 모듈 선언이 따라오면, 새 모듈 선언은 내부 모듈로 취급돼요. 하지만 두 번째 모듈 정의가 첫 번째보다 더 들여쓰지 않으면 컴파일러가 경고를 띄워요.

// 이 코드는 경고를 만들지만 Z를 내부 모듈로 취급해요.
module Y =
module Z =
    let z = 5

경고를 없애려면 내부 모듈을 들여써야 해요.

module Y =
    module Z =
        let z = 5

파일의 모든 코드를 단일 바깥 모듈에 담고 그 안에 내부 모듈을 두고 싶다면, 바깥 모듈에는 등호가 필요 없고 내부 모듈 선언을 포함한 바깥 모듈에 들어갈 선언들도 들여쓰지 않아도 돼요. 다만 내부 모듈 선언 안쪽의 선언은 반드시 들여써야 해요. 다음 코드가 이 경우를 보여줘요.

// 파일 이름이 TopLevel.fs나 topLevel.fs이고 애플리케이션에서 유일한 파일이면
// 최상위 모듈 선언을 생략할 수 있어요.
module TopLevel

let topLevelX = 5

module Inner1 =
    let inner1X = 1
module Inner2 =
    let inner2X = 5

재귀 모듈 (Recursive modules)

F# 4.1부터는 담겨 있는 모든 코드가 서로 재귀적인(상호 참조 가능한) 모듈을 만들 수 있게 됐어요. 이건 module rec로 작성해요. module rec를 쓰면 타입과 모듈 사이에서 서로 참조하는 코드를 작성하지 못해 생기던 여러 불편을 덜 수 있어요. 다음이 그 예시예요.

module rec RecursiveModule =
    type Orientation = Up | Down
    type PeelState = Peeled | Unpeeled

    // 이 예외는 아래의 타입에 의존해요.
    exception DontSqueezeTheBananaException of Banana

    type Banana(orientation : Orientation) =
        member val Orientation = orientation with get, set
        member val Sides: PeelState list = [ Unpeeled; Unpeeled; Unpeeled; Unpeeled ] with get, set

        member self.IsPeeled =
            self.Sides |> List.forall ((=) Peeled)

        member self.Peel() =
            BananaHelpers.peel self
            |> fun peeledSides -> self.Sides <- peeledSides

        member self.SqueezeJuiceOut() =
            raise (DontSqueezeTheBananaException self)

    module BananaHelpers =
        let peel (banana: Banana) =
            let flip (banana: Banana) =
                match banana.Orientation with
                | Up ->
                    banana.Orientation <- Down
                    banana
                | Down -> banana

            let peelSides (banana: Banana) =
                banana.Sides
                |> List.map (function
                             | Unpeeled -> Peeled
                             | Peeled -> Peeled)

            banana |> flip |> peelSides

여기서 DontSqueezeTheBananaException 예외와 Banana 클래스가 서로를 참조하는 걸 볼 수 있어요. 또 BananaHelpers 모듈과 Banana 클래스도 서로를 참조해요. RecursiveModule 모듈에서 rec 키워드를 빼면 이런 방식은 F#에서 표현할 수 없어요.

이 기능은 F# 4.1의 네임스페이스에서도 사용할 수 있어요.

더 알아보기