The Crafting Strider

Асабісты блог Рамана Бут-Гусаіма. Думкі і тэхнічныя публікацыі з жыцця распрацоўшчыка праграмнага забеспячэння

Заданне Bank Account (Exercism) на F#

Я заўсёды цікавіўся функцыянальным праграмаваннем. Першы раз я пазнаёміўся з ім ва ўніверсітэце, калі ў адным з апошніх семестраў нам чыталі базавы, але ж разам з тым даволі заблытаны курс. Тэарэтычная частка не мела амаль ніякага дачынення да тэмы і прайшла амаль цалкам міма, а вось на практыцы малады і таленавіты выкладчык пазнаёміў нас з мовай Clojure. Не магу сказаць, што пад канец семестру я добра валодаў прадметнай базай, але ж нейкае пачатковае разуменне меў. А галоўнае, была цікавасць больш пазнаёміцца з падыходам.

Пасля гэтага было некалькі спроб увайсці ў функцыянальнае праграмаванне - спачатку асновы F#, потым OCaml, Haskell на зусім базавым узроўні, і далей ужо на працягу года ў вольны час я знаёміўся з Erlang, Elixir і фантастычнай віртуальнай машынай BEAM. Зараз я зноў звярнуўся да F# праз яго неверагодную і даволі моцную статычную сістэму тыпаў і больш-менш знаёмую мне платформу dotnet, на якой ён працуе. Найлепшы шлях вывучаць новае ў праграмаванні - пачынаць карыстацца і спрабаваць пісаць маленькія праграмы. І вось для гэтага вельмі добра падыходзіць платформа з назвай Exercism, якая дазваляе вывучаць мову праграмаванне з дапамогай простых (і часамі не вельмі) заданняў.

У рамках курса па F# я сутыкнуўся з адным з такіх заданняў - Bank Account. Нягледзячы на тое, што заданне само па сабе зусім не складанае, але ж у ім ёсць вельмі цікавая ўмова:

Create an account that can be accessed from multiple threads/processes (terminology depends on your programming language).

Доступ да рахунку павінен ажыцяўляцца з некалькіх патокаў/працэсаў (тэрміналогія залежыць ад вашай мовы праграмавання).

“Файна, вось тут трэба глядзець у бок сінхранізацыі патокаў і паралельнага праграмавання” падумаў я. Платформа dotnet уключае шмат канструкцый для сінхранізацыі патокаў. У якасці прыклада можна прывесці манітор і яго сінтаксічны дапаможнік у выглядзе lock аператара, клас Interlocked, м’ютэкс, семафор і шмат іншых. Сам я з’яўляюся адданым прыхільнікам BEAM і мадэлі актараў. Нягледзячы на тое, што віртуальная машына BEAM дакладна не прытрымлівае мадэлі актараў, яна ўсё роўна абапіраецца на падыход абмену паведамленнямі (message-passing) для ажыццяўлення паралельнага праграмавання (як і актары). Амаль адразу я ўспомніў, што ў стандартнай бібліятэцы для F# была нешта звязанае з актарамі, вось толькі ведаў пра гэта ў мяне не засталося (а можа і не было :). Пошук у сеці інтэрнэт хутка выдаў мне фундаментальны артыкул ад знакамітага гуру F# Scott Wlaschin. Акрамя тэорыі ў гэтым артыкуле можна знайсці яшчэ і практычную даведку пра актары ў F#.

Найбольш каштоўнай інфармацыя з артыкула для мяне было тое, што я дазнаўся пра існаванне класа MailboxProcssor, які з’яўляецца нейкай формай актара альбо агента у F#. Фактычна, MailboxProcessor гэта чарга паведамленняў у памяці, якая можа атрымліваць паведамленні з розных патокаў, але апрацоўваць іх будзе ў адным патоку. Відавочна, што аўтары натхняліся Erlang, але зрабілі нашмат больш простую реэлізацыю.

Каб глыбей пазнаёміцца з тэмай, я прайшоўся па серыі артыкулаў тут, тут і тут. Аказалася, што MailboxProcessor мае ўсе неабходныя для вырашэння задачы метады: Post для асінхроннай адпраўкі паведамленняў, PostAndReply - для адпраўкі паведамленняў з чаканнем адказу.

Тым не менш, галоўным пытаннем для мяне заставалася яшчэ зразумець, якім чынам гэты BankAccount клас павінен выглядаць у кодзе на F#, каб ён інкапсуліраваў сваю залежнасць ад чаргі паведамленняў. У Elixir, напрыклад, стваралі набор функцый (функцыянальны інтэрфейс) вакол GenServer, што значна спрашчала карыстальнікам працу па інтэграцыі з такімі канструкцыямі. Класы у F# як раз і дапамагаюць стварыць функцыянальны інтэрфейс і схаваць узаемадзеянне з агентамі. Кожная аперацыя з BankAccount павінна абапірацца на свой тып паведамлення, а pattern matching і discriminated union у F# вельмі зручна дапамагаюць апрацоўваць гэтыя паведамленні ў кодзе. Трэба адзначыць, што Elixir карыстаецца такімі ж падыходамі нягледзячы на тое, што гэта дынамічная мова праграмавання.

Напрыканцы, клас BankAccount выглядаў неяк так (АСЦЯРОЖНА, СПОЙЛЕР):

module BankAccount

type Agent<'T> = MailboxProcessor<'T>

type BankAccountOperation =
    | Open
    | Close
    | GetBalance of AsyncReplyChannel<decimal option>
    | UpdateBalance of decimal

type BankAccountStatus =
    | Active
    | Inactive

type BankAccountState =
    { Status: BankAccountStatus
    Balance: decimal option }

type BankAccount() =

    let mutable state = { Status = Inactive; Balance = None }

    let openAccount () =
        state <- { Status = Active; Balance = Some 0.0m }

    let closeAccount () =
        state <- { Status = Inactive; Balance = None }

    let getBalance () = state.Balance

    let updateBalance amount =
        match state.Status with
        | Active ->
            let balance = getBalance () |> Option.get
            let newBalance = balance + amount
            state <- { state with Balance = Some newBalance }
        | _ -> ()

    let agent =
        Agent.Start(fun inbox ->
            let rec messageLoop () =
                async {
                    let! msg = inbox.Receive()

                    match msg with
                    | Open -> openAccount ()
                    | Close -> closeAccount ()
                    | GetBalance replyChannel ->
                      let balance = getBalance ()
                      replyChannel.Reply balance
                    | UpdateBalance amount ->
                        updateBalance amount

                    return! messageLoop ()
                }
            messageLoop ())

    member _.Open() = agent.Post Open

    member _.Close() = agent.Post Close

    member _.GetBalance() =
        agent.PostAndReply(
            (fun replyChannel -> GetBalance replyChannel), 10000)

    member _.UpdateBalance amount =
        agent.Post(UpdateBalance amount)

let mkBankAccount () = BankAccount()

let openAccount (account: BankAccount) =
    account.Open()
    account

let closeAccount (account: BankAccount) =
    account.Close()
    account

let getBalance (account: BankAccount) = account.GetBalance()

let updateBalance (change: decimal) (account: BankAccount) =
    account.UpdateBalance change
    account