식별 공용체

식별 공용체 (Discriminated Unions)

F#에서 데이터를 "여러 가지 모습 중 하나"로 표현하고 싶을 때 쓰는 도구예요. 예를 들어 도형 하나가 원이 될 수도, 사각형이 될 수도, 삼각기둥이 될 수도 있는 상황을 생각해 볼게요. 객체 계층 구조(class 상속)로도 만들 수 있지만, 식별 공용체를 쓰면 훨씬 가볍고 안전하게 같은 걸 표현할 수 있어요. 이 글에서는 이 식별 공용체가 무엇이고, 어떤 경우에 빛을 발하는지 하나씩 살펴볼게요.

출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/discriminated-unions

본문

식별 공용체(discriminated unions)는 값이 여러 개의 이름 붙은 케이스(case) 중 하나일 수 있게 해 주는 기능이에요. 각 케이스는 저마다 서로 다른 값과 타입을 가질 수 있죠. 이식적(heterogeneous)인 데이터, 즉 성공 값과 오류 값처럼 특별한 형태가 여러 개 있는 데이터를 다룰 때 특히 유용해요. 또 인스턴스마다 타입이 달라지는 데이터나, 작은 객체 계층 구조를 만들 때 그 대안으로도 잘 쓰여요. 이 밖에도 재귀적인 식별 공용체는 트리 자료구조를 표현하는 데 흔히 사용돼요.

구문 (Syntax)

기본 형태는 이렇게 생겼어요.

[ attributes ]
type [accessibility-modifier] type-name =
    | case-identifier1 [of [ fieldname1 : ] type1 [ * [ fieldname2 : ] type2 ...]
    | case-identifier2 [of [fieldname3 : ]type3 [ * [ fieldname4 : ]type4 ...]

    [ member-list ]

첫 번째 파이프 문자(|)는 생략할 수 있어요. 케이스가 하나뿐인 식별 공용체라면 이렇게 써도 되죠.

type SingleCase = Case of string

자세한 설명 (Remarks)

식별 공용체는 다른 언어의 union 타입과 비슷하면서도 다른 점이 있어요. C++의 union 타입이나 Visual Basic의 variant 타입처럼, 값에 저장된 데이터가 고정되어 있지 않고 여러 옵션 중 하나가 될 수 있어요. 다만 이 언어들에서와 달리, F#에서는 **각 옵션마다 케이스 식별자(case identifier)**를 붙여요. 이 식별자는 이 타입의 객체가 가질 수 있는 여러 값 형태에 붙은 이름이에요. 케이스에 값이 딸려 있지 않을 수도 있는데, 그럴 때는 열거형(enumeration) 케이스와 똑같이 동작해요. 값이 있다면 그 값은 지정된 타입 하나일 수도, 서로 같거나 다른 타입의 여러 필드를 묶은 튜플일 수도 있어요. 개별 필드에 이름을 붙일 수도 있는데, 같은 케이스 안의 다른 필드가 이름이 있더라도 필드 이름은 선택 사양이에요.

식별 공용체의 접근성(accessibility) 기본값은 public이에요.

예를 들어, Shape 타입을 이렇게 선언해 볼게요.

type Shape =
    | Rectangle of width : float * length : float
    | Circle of radius : float
    | Prism of width : float * float * height : float

위 코드는 세 케이스(Rectangle, Circle, Prism) 중 하나를 값으로 가질 수 있는 Shape 식별 공용체를 선언해요. 케이스마다 필드 구성이 달라요. Rectangle은 이름 붙은 필드 두 개(width, length)를 갖고 둘 다 타입이 float예요. Circle은 이름 붙은 필드 하나(radius)만 있어요. Prism은 필드가 세 개인데, 그중 widthheight 두 개가 이름 붙은 필드예요. 이름 없는 필드는 **무명 필드(anonymous fields)**라고 불러요.

객체를 만들 때는 이름 붙은 필드와 무명 필드에 값을 넣어 주면 돼요. 아래 예시를 볼게요.

let rect = Rectangle(length = 1.3, width = 10.0)
let circ = Circle (1.0)
let prism = Prism(5., 2.0, height = 3.0)

이 코드는 두 가지 방법을 보여줘요. 초기화할 때 이름 붙은 필드를 직접 쓸 수도 있고(위에서 rect처럼), 아니면 선언할 때의 필드 순서를 믿고 값을 차례대로 넣을 수도 있어요(circ처럼요). 그리고 순서대로 넣는 방식과 이름 붙은 방식을 섞어 쓸 수도 있어요. prism을 만들 때가 그 예죠.

option 타입은 F# 코어 라이브러리에 들어 있는 아주 단순한 식별 공용체예요. option 타입은 이렇게 선언되어 있어요.

// The option type is a discriminated union.
type Option<'a> =
    | Some of 'a
    | None

위 코드는 Option 타입이 SomeNone 두 케이스를 가진 식별 공용체임을 나타내요. Some 케이스는 무명 필드 하나를 값으로 갖는데, 그 타입은 타입 매개변수 'a로 표현돼요. None 케이스는 딸린 값이 없어요. 그래서 option 타입은 "어떤 타입의 값을 갖거나, 아무 값도 갖지 않거나" 하는 제네릭 타입을 지정해 주죠. Option은 소문자 타입 별칭 option도 갖고 있는데, 실제로는 이 소문자 쪽이 훨씬 흔히 쓰여요.

케이스 식별자는 그 식별 공용체 타입의 **생성자(constructor)**로 쓸 수 있어요. 예를 들어 아래 코드는 option 타입의 값을 만들 때 쓰여요.

let myOption1 = Some(10.0)
let myOption2 = Some("string")
let myOption3 = None

케이스 식별자는 패턴 매칭 표현식에서도 쓰여요. 패턴 매칭에서는 각 케이스에 딸린 값을 받을 식별자를 지정해 줄 수 있어요. 아래 코드에서 x가 바로 option 타입의 Some 케이스에 딸린 값을 받는 식별자예요.

let printValue opt =
    match opt with
    | Some x -> printfn "%A" x
    | None -> printfn "No value."

패턴 매칭에서는 이름 붙은 필드를 이용해 식별 공용체 매치를 지정할 수 있어요. 아까 선언한 Shape 타입에서, 필드 값을 뽑아내려면 이름 붙은 필드를 아래처럼 쓰면 돼요.

let getShapeWidth shape =
    match shape with
    | Rectangle(width = w) -> w
    | Circle(radius = r) -> 2. * r
    | Prism(width = w) -> w

보통은 케이스 식별자를 공용체 이름으로 한정(qualify)하지 않고 그냥 써요. 항상 공용체 이름을 붙여 쓰도록 강제하고 싶다면 공용체 타입 정의에 RequireQualifiedAccess 특성을 붙이면 돼요.

식별 공용체 풀어내기 (Unwrapping Discriminated Unions)

F#에서 식별 공용체는 도메인 모델링에서 하나의 타입을 감싸는(wrapping) 용도로 자주 쓰여요. 그 안의 실제 값을 뽑아내는 것도 패턴 매칭으로 간단히 할 수 있어요. 케이스가 하나뿐이라면 match 표현식을 쓰지 않아도 돼요.

let ([UnionCaseIdentifier] [values]) = [UnionValue]

아래 예시가 이걸 보여줘요.

type ShaderProgram = | ShaderProgram of id:int

let someFunctionUsingShaderProgram shaderProgram =
    let (ShaderProgram id) = shaderProgram
    // Use the unwrapped value
    ...

패턴 매칭은 함수 매개변수에도 직접 쓸 수 있어서, 그 위치에서 단일 케이스를 풀어낼 수도 있어요.

let someFunctionUsingShaderProgram (ShaderProgram id) =
    // Use the unwrapped value
    ...

구조체 식별 공용체 (Struct Discriminated Unions)

식별 공용체는 구조체(struct)로도 표현할 수 있어요. 이때는 [<Struct>] 특성을 쓰면 돼요.

[<Struct>]
type SingleCase = Case of string

[<Struct>]
type Multicase =
    | Case1 of string
    | Case2 of int
    | Case3 of double

구조체 식별 공용체는 참조 타입이 아니라 값 타입이라, 참조 식별 공용체와는 달리 몇 가지 추가로 신경 써야 할 점이 있어요.

  • 값 타입으로 복사되고, 값 타입 시맨틱스를 가져요.
  • 멀티케이스 구조체 식별 공용체에서는 재귀 타입 정의를 쓸 수 없어요.
  • F# 9 이전에는 공용체 안에서 각 케이스가 고유한 이름을 가져야 한다는 요구 사항이 있었는데, F# 9부터는 그 제약이 사라졌어요.

객체 계층 구조 대신 식별 공용체 사용하기

작은 객체 계층 구조 대신 식별 공용체를 더 단순한 대안으로 쓸 수 있는 경우가 많아요. 예를 들어 아래 식별 공용체는 Shape 기본 클래스를 두고 circle, square 같은 파생 타입을 만드는 대신 쓸 수 있어요.

type Shape =
    // The value here is the radius.
    | Circle of float
    // The value here is the side length.
    | EquilateralTriangle of double
    // The value here is the side length.
    | Square of double
    // The values here are the height and width.
    | Rectangle of double * double

면적이나 둘레를 계산하는 가상 메서드(virtual method)를 두는 대신(객체지향 구현에서 그렇게 하죠), 패턴 매칭으로 도형에 맞는 공식을 골라 계산할 수 있어요. 아래 예시는 도형에 따라 서로 다른 공식으로 넓이를 계산해요.

let pi = 3.141592654

let area myShape =
    match myShape with
    | Circle radius -> pi * radius * radius
    | EquilateralTriangle s -> (sqrt 3.0) / 4.0 * s * s
    | Square s -> s * s
    | Rectangle(h, w) -> h * w

let radius = 15.0
let myCircle = Circle(radius)
printfn "Area of circle that has radius %f: %f" radius (area myCircle)

let squareSide = 10.0
let mySquare = Square(squareSide)
printfn "Area of square that has side %f: %f" squareSide (area mySquare)

let height, width = 5.0, 10.0
let myRectangle = Rectangle(height, width)
printfn "Area of rectangle that has height %f and width %f is %f" height width (area myRectangle)

출력은 이렇게 나와요.

Area of circle that has radius 15.000000: 706.858347
Area of square that has side 10.000000: 100.000000
Area of rectangle that has height 5.000000 and width 10.000000 is 50.000000

트리 자료구조에 식별 공용체 사용하기

식별 공용체는 재귀적일 수 있어요. 즉 공용체 자신이 한 개 이상의 케이스 타입 안에 포함될 수 있단 뜻이죠. 재귀 식별 공용체는 트리 구조를 만드는 데 쓰이는데, 프로그래밍 언어에서 표현식(expression)을 모델링할 때 흔히 사용돼요. 아래 코드는 재귀 식별 공용체로 이진 트리 자료구조를 만들어요. 이 공용체는 두 케이스로 이뤄져 있는데, Node는 정수 값과 왼쪽·오른쪽 서브트리를 갖는 노드이고, Tip은 트리를 끝맺는 케이스예요.

type Tree =
    | Tip
    | Node of int * Tree * Tree

let rec sumTree tree =
    match tree with
    | Tip -> 0
    | Node(value, left, right) -> value + sumTree (left) + sumTree (right)

let myTree =
    Node(0, Node(1, Node(2, Tip, Tip), Node(3, Tip, Tip)), Node(4, Tip, Tip))

let resultSumTree = sumTree myTree

위 코드에서 resultSumTree의 값은 10이 돼요. myTree의 트리 구조는 아래 그림처럼 생겼어요.

식별 공용체는 트리의 노드들이 이식적(heterogeneous)일 때 아주 잘 맞아요. 아래 코드에서 Expression 타입은 숫자와 변수의 덧셈·곱셈을 지원하는 아주 단순한 프로그래밍 언어의 추상 구문 트리(abstract syntax tree)를 나타내요. 일부 케이스는 재귀적이지 않은데, 숫자(Number)나 변수(Variable)를 나타내죠. 다른 케이스는 재귀적인데, 피연산자도 마찬가지로 표현식인 연산(Add, Multiply)을 나타내요. Evaluate 함수는 match 표현식으로 구문 트리를 재귀적으로 처리해요.

type Expression =
    | Number of int
    | Add of Expression * Expression
    | Multiply of Expression * Expression
    | Variable of string

let rec Evaluate (env: Map<string, int>) exp =
    match exp with
    | Number n -> n
    | Add(x, y) -> Evaluate env x + Evaluate env y
    | Multiply(x, y) -> Evaluate env x * Evaluate env y
    | Variable id -> env[id]

let environment = Map [ "a", 1; "b", 2; "c", 3 ]

// Create an expression tree that represents
// the expression: a + 2 * b.
let expressionTree1 = Add(Variable "a", Multiply(Number 2, Variable "b"))

// Evaluate the expression a + 2 * b, given the
// table of values for the variables.
let result = Evaluate environment expressionTree1

이 코드를 실행하면 result의 값은 5가 돼요.

상호 재귀 식별 공용체 (Mutually Recursive Discriminated Unions)

F#의 식별 공용체는 상호 재귀적일 수도 있어요. 즉 여러 공용체 타입이 서로를 재귀적으로 참조할 수 있다는 뜻이죠. 계층적이거나 서로 연결된 구조를 모델링할 때 유용해요. 상호 재귀 식별 공용체를 정의하려면 and 키워드를 쓰면 돼요.

예를 들어, 표현식이 문장(statement)을 포함할 수 있고 문장이 다시 표현식을 포함할 수 있는 추상 구문 트리(AST)를 생각해 볼게요.

type Expression =
    | Literal of int
    | Variable of string
    | Operation of string * Expression * Expression
and Statement =
    | Assign of string * Expression
    | Sequence of Statement list
    | IfElse of Expression * Statement * Statement

멤버 (Members)

식별 공용체에도 멤버를 정의할 수 있어요. 아래 예시는 프로퍼티를 정의하고 인터페이스를 구현하는 방법을 보여줘요.

open System

type IPrintable =
    abstract Print: unit -> unit

type Shape =
    | Circle of float
    | EquilateralTriangle of float
    | Square of float
    | Rectangle of float * float

    member this.Area =
        match this with
        | Circle r -> Math.PI * (r ** 2.0)
        | EquilateralTriangle s -> s * s * sqrt 3.0 / 4.0
        | Square s -> s * s
        | Rectangle(l, w) -> l * w

    interface IPrintable with
        member this.Print () =
            match this with
            | Circle r -> printfn $"Circle with radius %f{r}"
            | EquilateralTriangle s -> printfn $"Equilateral Triangle of side %f{s}"
            | Square s -> printfn $"Square with side %f{s}"
            | Rectangle(l, w) -> printfn $"Rectangle with length %f{l} and width %f{w}"

케이스의 .Is* 프로퍼티

F# 9부터 식별 공용체는 각 케이스마다 자동 생성되는 .Is* 프로퍼티를 노출해요. 덕분에 특정 값이 어떤 케이스인지 쉽게 확인할 수 있어요.

이렇게 쓰면 돼요.

type Contact =
    | Email of address: string
    | Phone of countryCode: int * number: string

type Person = { name: string; contact: Contact }

let canSendEmailTo person =
    person.contact.IsEmail      // .IsEmail is auto-generated

자주 쓰는 특성 (Common attributes)

식별 공용체에서 흔히 볼 수 있는 특성은 이렇습니다.

[<RequireQualifiedAccess>]

[<NoEquality>]

[<NoComparison>]

[<Struct>]

더 알아보기 (Learn more)

  • F# 언어 참조 — 식별 공용체 외에도 패턴 매칭, option 타입 등 F#의 다른 문법 요소를 함께 살펴보면 전체 그림이 잡혀요.