본문 바로가기

프로그래밍 언어 노트/F#

[F#] F# Computation Expression 톺아보기

F# 의 Computation Expression 의 표현력은 세계 제일!

 

서론. 모나드와 Expression 

Haskell 이나 Scala 같은 타 함수형 언어의 경우 Monad 를 위한 문법으로 Do/For 와 같은 Expression 이 존재한다.

C# 이나 Kotlin 또는 JS/TS 같은 경우는 Future Monad 에 대해서만 유효한 async / await 문법이 존재하고, Null(Optional) Monad 에 대한 ?. (옵셔널 체이닝) 문법이 존재한다.

위 문법은 Syntax Sugar 이며, 없어도 대게 메서드 체이닝으로 쓸수는 있다.

즉 함수형에 가까운 언어는 Do/For 와 같이 그냥 "Monad" 자체에 대한 문법을 제공해서 내가 모나드를 설계하면 동작하고,

그렇지 않은 언어는 언어의 Offically 한 지원이 있는경우 에만 (async / await) 한정하여 모나드 문법이 제공되는데

 

F# 은 조금 특이하다.

Monad는 맘대로 만들되, 해당 Monad 전용 Syntax Sugar 도 Builder 를 통하여 따로 만드는 방식이다.

해당 구문 안에서 let!, do!, for 등 구문이  Builder 의 Bind, For 등의 맴버 함수로 알아서 해석된다.

// builder 를 만들면 아래 처럼 쓸수있다.
builder-expr { cexper }

// 아래는 기본으로 제공되는 builder-expr 중 하나인 async 이다.
async { expression }

// 이런식으로 쓸수있다.
let fetchAsync(name, url:string) =
    async {
        try
            let uri = new System.Uri(url)
            let webClient = new WebClient()
            let! html = webClient.AsyncDownloadString(uri)
            printfn "Read %d characters for %s" html.Length name
        with
            | ex -> printfn "%s" (ex.Message);
    }


// 아래 처럼 builder 를 만들수 도 있다.
type EventuallyBuilder() =
    member x.Bind(comp, func) = Eventually.bind func comp
    member x.Return(value) = Eventually.result value
    member x.ReturnFrom(value) = value
    member x.Combine(expr1, expr2) = Eventually.combine expr1 expr2
    member x.Delay(func) = Eventually.delay func
    member x.Zero() = Eventually.result ()
    member x.TryWith(expr, handler) = Eventually.tryWith expr handler
    member x.TryFinally(expr, compensation) = Eventually.tryFinally expr compensation
    member x.For(coll:seq<_>, func) = Eventually.forLoop coll func
    member x.Using(resource, expr) = Eventually.using resource expr

let eventually = new EventuallyBuilder()

let comp =
    eventually {
        for x in 1..2 do
            printfn $" x = %d{x}"
        return 3 + 4
    }

 

장점은 모나드 별로 명확하게 지정해주므로 DSL 을 설계할때 이쁘다는점, 단점은 보일러플레이트 코드가 생긴다는점등이 있는데

암튼 특정 Context 에 대한 Builder 를 제공해주는 문법을 Computation Expression 이라고 한다.

 

여기까지가 Computation Expression 의 간략한 설명이다.

 

이쁜 DSL With Computation Expression 

builder-expr { cexper }

표현식만 봐도 DSL 으로 써먹기 아주 이쁘장하다.

실제로 Monad 로 모델링해서 Monad Expression 을 활용하는것은 대표적인 DSL 작성법중 하나이다.

- [Monad/Free Monad] 내멋대로 Free 모나드 이해하기 참고

좀더 대중적인 Optional Monad 를 예시로 들어보면. 다음과 같다.

type OptionBuilder() =
    member x.Bind(v,f) = Option.bind f v
    member x.Return v = Some v
    member x.ReturnFrom o = o
    member x.Zero () = None

let option= OptionBuilder()

let computationExpressionWay =
    option {
        let! a = Some 1
        let! b = Some 2
        let! c = Some 3
        return a + b + c
    }

let normalWay = 
    let a = Some 1
    match a with
    | Some a -> 
        let b = Some 2
        match b with
        | Some b -> 
            let c = Some 3
            match c with
            | Some c -> Some (a + b + c)
            | None -> None
        | None -> None
    | None -> None

    
let computationExpressionWay2 =
    option {
        let! a = Some 1
        let! b = Some 2
        let! c = None
        return a + b + c
    }

let normalWay2 = 
    let a = Some 1
    match a with
    | Some a -> 
        let b = Some 2
        match b with
        | Some b -> 
            let c = None
            match c with
            | Some c -> Some (a + b + c)
            | None -> None
        | None -> None
    | None -> None


printfn "%A, %A" computationExpressionWay computationExpressionWay2
printfn "%A, %A" normalWay normalWay2
// 6, None 이 출력된다

 

normalWay 와 computationExpressionWay 는 동일한 동작이다.

Computation Expression 를 사용하는게 훨씬 깔끔하고 DSL 과 같이 HighLevel 한 추상화라는 느낌이 난다.

Computation Expression 을 사용하면, option 이라는 builder 내부에서 "let!" 가 자동으로 bind 되는 요소라는것을 알 수 있다.

Computation Expression 을 만들기 위한  Builder 에서 bind  / return / zero  등 맴버를 정의 했는데, F# 은 해당 정의 에 맞는  문법으로 재 해석 된다.

let! 는 Bind 를 정의 하면 사용 할 수 있다.

let! and! do! yield yield! return return! match! 등등.. 미리 정해진 맴버에 따라서 사용할수 있는 키워드들이 존재하며, 상황에 맞는 Context 에 따라 재정의 하면 된다.

 

더 이쁜 DSL feat Custom Operations

이렇게 Computation Expression 을 사용하면 코드가 추상화되고, DSL 형식으로 깔끔한 표현이 가능하다.

더 깔끔한 DSL 예시를 확인 해 보자 F# 웹 프레임워크인 Saturn 에서 따왔다.

let api = pipeline {
    plug acceptJson
    set_header "x-pipeline-type" "Api"
}

let apiRouter = router {
    not_found_handler (setStatusCode 404 >=> text "Api 404")
    pipe_through api

    forward "/someApi" someScopeOrController
}

pipeline {} / router {} 구문인걸 보아하니, 이 친구들도 Computation Expression 문법인듯하다.

코드만 봐도 감히 아름답다고 할수있다 ...

let! 이나 do! 같은 Computation Expression 구문이 없는걸 확인 할 수있는데, github 에서 코드를 확인해보자.

type RouterBuilder internal () =

	/*skip*/

    ///Adds pipeline to the list of pipelines that will be used for every request
    [<CustomOperation("pipe_through")>]
    member __.PipeThrough(state, pipe) : RouterState =
      {state with Pipelines = pipe::state.Pipelines}

  ///Computation expression used to create routing in Saturn application
  let router = RouterBuilder()

CustomOperation 이라는 Attribute 가 사용된것을 확인 할 수 있다.

 

CustomOperation 를 통하여 Computation Expression  내 Context 내에서 Custom 함수를 작성하여 아주 아름다운 DSL 을 작성 할 수 있다.

 

open System

type Context = {
    Params: Map<string, string>
}

type Route = Context -> obj

type RouteBuilder() =
    // CustomOperation 를 쓰려면 For 또는 Yield 가 필요하다. 여기서는 초기 Context 를 위하여 Yield 메서드 사용
    member _.Yield(_) : list<(string * Route)> = []
    
    // 'get' 커스텀 연산자 정의
    [<CustomOperation("get")>]
    member _.Get(routes: list<(string * Route)>, path: string, handler: Route) : list<(string * Route)> =
        (path, handler) :: routes
    
    // 바로 사용 가능하도록 라우팅 함수를 생성
    member _.Run(routes: list<(string * Route)>) : string -> obj =
        fun (path: string) ->
            // 입력된 경로를 '/'로 분할
            let incomingParts = path.Split('/', StringSplitOptions.RemoveEmptyEntries)
            /* 여기에 Logic 구현, Skip */

let route = RouteBuilder()

/* 아주 아름답다 */
let router =
    route {
        get "/hello" (fun _ -> box "Hello World!")
        get "/add/:x/:y" (fun ctx ->
            let x = int ctx.Params.["x"]
            let y = int ctx.Params.["y"]
            box (x + y)
        )
    }

let result1 = router "/hello"
printfn "Result1: %A" result1  

let result2 = router "/add/10/20"
printfn "Result2: %A" result2

참고로 CustomOperation 을 사용하려면, Yield 또는 For 에 대한 맴버 함수가 반드시 정해져 있어야한다.

List 를 다룰때 (query 같은 Computation Expression) 를 위하여 For, 이렇게 State 만 관리해도 된다면 Yield 만 작성해도 무방하다.

 

Computation Expression에서 사용되는 함수들은 일종의 Monad Chain 이기 때문에 State 관리가  용이하다.

예를들어, 방금 만든 Route 에서 마지막에 의도적으로 stop 이라는 키워드를 작성해야지만 끝낼수 있다고 하면 다음과 같이 파라미터와 리턴값을 적절히 수정하면 된다.

type RouteBuilder() =
    // 'get' 은 list 를 리턴
    [<CustomOperation("get")>]
    member _.Get(routes: list<(string * Route)>, path: string, handler: Route) : list<(string * Route)> =
        (path, handler) :: routes

	// 'stop' 은 튜플을 리턴
    [<CustomOperation("stop")>]
    member _.Stop(routes: list<(string * Route)>) : (list<(string * Route)> * bool) = routes,true
    
    // run 은 튜플을 받으므로, list를 리턴한 get 다음에는 사용 불가능! 즉 get 만 쓰면 컴파일 안됨
    member _.Run(params: (list<(string * Route)> * bool)) : string -> obj =
        let routes, _ = params
        // skip

let route = RouteBuilder()

// **라우터 정의: 컴퓨테이션 표현식을 사용하여 라우트 추가 및 라우팅 함수 생성**
let router =
    route {
        get "/hello" (fun _ -> box "Hello World!")
        get "/add/:x/:y" (fun ctx ->
            let x = int ctx.Params.["x"]
            let y = int ctx.Params.["y"]
            box (x + y)
        )
        stop // stop 을 반드시 써주어야 컴파일 된다!
    }

위는 아주 간략한 예제로, 실제로는 ADT 를 설계하여 Type별로 제한하여 더 Strict 한 DSL 을 만들수 있다.

 

 

 

Nested DSL 

여기까지 왔으면 욕심이 생긴다.

HTML DSL 과 같이 중첩된 DSL 데이터를 표현하고 싶다. F# Bolero 에서 따왔다.

let myElement (name: string) : Node =
    div {
        h1 { "My app" }
        p { $"Hello {name} and welcome to my app!" }
    }

 

이 경우 div 도 h1 도 p 도 Computation Expression 이다.

 

그냥 Computation Expression 도 구문에 불과 하므로, 그냥 쓰는것은 상관 없는데,

원하는것은 내부 구문의 결과를 외부 구문에서 받아서 조합하고 싶은것이다.  (그냥 사용하고 날라가는 변수가 아니라, div 안에 h1 과 p 를 넣고 싶은것!)

 

일단 내부 구문의 결과를 받는 Yield 와 Combine 함수를 구현 하면 된다.

// Node 타입
type Node =
    | Element of string * Node list
    | Text of string

// NodeBuilder 클래스 정의
type NodeBuilder (name: string) =
    // Yield 메서드 -> 여러가지가 올 수 있으므로 여러개를 만들자
    member this.Yield(content: string) = Element(name, [Text content])
    member this.Yield(content: Node) = content
    member this.Yield(contents: Node list) = Element(name, contents)

    // Combine 메서드: 두 개의 Node를 결합하여 내부 결과를 합칠수 있도록 하자
    member this.Combine(content1: Node, content2: Node) =
        match content1 with
        | Element(n, children) when n = name -> Element(n, children @ [content2])
        | _ -> Element(name, [content1; content2])

    // Zero 메서드: 빈 요소를 지원
    member this.Zero() = Element(name, [])

    // Run 메서드: 최종 결과 반환
    member this.Run(content: Node) = content

    member _.Name = name

// 각 HTML 요소에 대한 DSL
let div = NodeBuilder "div"
let h1 = NodeBuilder "h1"
let p = NodeBuilder "p"


let myElement (name: string) : Node =
    div {
        h1 { "My app" }
        p { $"Hello {name} and welcome to my app!" }
    }

// 인터프리터
let rec render node =
    match node with
    | Text(text) -> text
    | Element(tag, children) ->
        let inner = children |> List.map render |> String.concat ""
        $"<{tag}>{inner}</{tag}>"

let element = myElement "Alice"
printfn "%s" (render element)
printfn "%A" element

별거 없이 Yield 와 Combine 을 통하여 내부 h1, p 등에서 Node 들이 반환되도 합치면 된다.

 

띠용..?

은 안되는데, Delay 를 추가 해 주어야 한다..

// Node 타입
type Node =
    | Element of string * Node list
    | Text of string

// NodeBuilder 클래스 정의
type NodeBuilder (name: string) =
    // Yield 메서드 -> 여러가지가 올 수 있으므로 여러개를 만들자
    member this.Yield(content: string) = Element(name, [Text content])
    member this.Yield(content: Node) = content
    member this.Yield(contents: Node list) = Element(name, contents)

    // Combine 메서드: 두 개의 Node를 결합하여 내부 결과를 합칠수 있도록 하자
    member this.Combine(content1: Node, content2: Node) =
        match content1 with
        | Element(n, children) when n = name -> Element(n, children @ [content2])
        | _ -> Element(name, [content1; content2])

    // Delay 메서드: 지연 실행을 지원 <- 이게 없으면 암시적으로 무시된다.
    member this.Delay(f: unit -> Node) = f()

    // Zero 메서드: 빈 요소를 지원
    member this.Zero() = Element(name, [])

    // Run 메서드: 최종 결과 반환
    member this.Run(content: Node) = content

    member _.Name = name

// 각 HTML 요소에 대한 DSL
let div = NodeBuilder "div"
let h1 = NodeBuilder "h1"
let p = NodeBuilder "p"


let myElement (name: string) : Node =
    div {
        h1 { "My app" }
        p { $"Hello {name} and welcome to my app!" }
    }

// 인터프리터
let rec render node =
    match node with
    | Text(text) -> text
    | Element(tag, children) ->
        let inner = children |> List.map render |> String.concat ""
        $"<{tag}>{inner}</{tag}>"

let element = myElement "Alice"
printfn "%s" (render element)
printfn "%A" element

그 이유는 암시적으로 무시 되기때문인데, lazy evaluation 하지 않으면 애초에 해당 자료구조를 만들때 h1 이랑 p 는 필요없는 친구 취급 되니까 평가조차 되지 않는다.

Nested 된 친구먼저 평가되도록 Lazy 하게 지연 평가를 추가해주어야 한다.

 

기타 별 쓸데 없는 팁 <InlineIfLambda>

F# 6 에 인라인 함수 를 위한 <InlineIfLambda> 가 추가 되었다. 파라미터로 들어오는 lambda 도 평탄화 해주는데, Computation Expression 에서도 적용된다. HTML for better rendering performance 참고

 

결론

아무도 쓰지 않지만.. F# 의 표현력은 세게 제일!

개인적으로는,  사실 일반적인 부분은 이미 내가 안만들어도 이미 만들어져있는 완성도 높은 DSL 이 워낙 많아서 그걸 사용하고, 비즈니스 로직정도를 Computation Expression 의 기초적인 기능을 이용해서 Flat 화 하는 정도면 일반 응용 프로그램을 만들때 충분하다고 생각한다.

여기에 만약 라이브러리화 하거나 모든 팀원이 일관된 Interface 를 사용하고자 하면, 이제 Custom Operations 을 사용하는것을 고려해볼듯 하다.

근데 F# 아무도 안쓰자나.. 혼자 공부나 해야지..

 

 

Ref

 

 

728x90