Чаму мяне цягне да функцыянальных моў праграмавання?

ADT Meme

Некаторыя чытачы пэўна і так ведаюць, што я працую большую частку часу з такімі мовамі праграмавання, як Python і Go. А гэты радок існуе выключна, каб расказаць пра гэта астатнім :)

Аднак першы пост у гэтым блогу адразу прысвечаны Gleam, функцыянальнай мове праграмавання. Дакладней будзе сказаць, што яна бярэ крышачку і ад працэдурнага свету. Адзін у адзін як Rust. Я нават лёгка згадаю, што калі я знаёміўся з Rust, мяне проста так і свярбела — вось бы мець такую самую мову, але са зборкай смецця. Бо мне, каб вырашаць бізнес задачы, не патрэбны такі кантроль над памяццю, а самая павольная частка праграмы звычайна база. Ды і што хаваць, большую частку часу твой бэкэнд толькі і робіць што перакладае json з базы ў сокет ды назад.

Аднак, я ўсё роўна ўвесь час імкнуся палепшыць свой досвед як распрацоўшчыка. І адна рэч, якая мне безумоўна вельмі падабаецца ў большасці функцыянальных мовах праграмавання, гэта ADT, Algebraic Data Types, алгебраічныя тыпы даных. Акурат пра гэтую асаблівасць і будзе ісці гаворка.

Для тых, хто не знаёмы з ADT. Алгебраічны тып даных — гэта тып даных, які прадстаўлены наборам канструктараў. Кожны канструктар прымае нейкія параметры і вяртае гэты тып. Напрыклад (з мінулага артыкула):

type GetUserError {
  HttpError(httpc.HttpError)
  DecodeError(json.DecodeError)
}

Гэта азначае, што ў нас ёсць дзве функцыі, што выглядаюць (такі код нельга напісаць на Gleam, але дзеля лепшага разумення я яго напішу):

fn HttpError(arg0: httpc.HttpError) -> GetUserError
fn DecodeError(arg0: json.DecodeError) -> GetUserError

Файна, але што нам гэта дае? Самая распаўсюджаная аперацыя над алгебраічнымі тыпамі — гэта Pattern Matching, супастаўленне з шаблонам, вось просты прыклад:

fn print_get_user_error(e: GetUserError) -> Nil {
  case e {
    HttpError(_) -> io.println("Памылка падчас запыту знешняга сэрвісу")
    JsonError(_) -> io.println("Памылка падчас парсінгу адказу")
  }
}

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

У супастаўленні шаблону ёсць вельмі крутая перавага. Уявім, мы даведаліся нешта новае пра гэты свет, змяніліся патрабаванні, і цяпер каб атрымаць карыстальніка са знешняга сэрвісу, нам спачатку трэба сканвертаваць радок у звычайнае чысло. Гэтая працэдура таксама можа скончыцца з памылкай, наш тып абзавядзецца новым канструктарам:

type GetUserError {
  IDParseError
  HttpError(httpc.HttpError)
  DecodeError(json.DecodeError)
}

Як толькі мы гэта зрабілі, наш код больш не кампілюецца, выкідваючы наступную памылку, спасылаючыся на нашу функцыю print_get_user_error:

Inexhaustive patterns

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

IDParseError

То-бок кампілятар адразу паведамляе, што не ўсе выпадкі былі апрацаваныя. Часцей за ўсё, гэта прыводзіць да бага на прадакшане, а тут мы нават не можам сабраць код у праграму, каб давесці да прадакшана. І аніякіх юніт-тэстаў не трэба пісаць. І гэта можна і трэба выкарыстоўваць на сваю карысць.

Рабіць немагчымыя станы немагчымымі

Гэты раздзел — вольны пераказ даклада Рычарда Фэлдмана (Richard Fldman) "Making imposible states imposible". Рычард важны ўдзельнік супольнасці мовы праграмавання Elm, а таксама з камандай пакрысе распрацоўвае новую мову праграмавання Roc.

Я, як прыклад, буду спасылацца на праблему, якая даволі часта сустракаецца сярод распрацоўшчыкаў фронтэндаў, але яна не ёсць эксклюзіўнай для іх. Увага! Я не фронтэнд распрацоўшчык, таму магчыма такую праблему можна вырашыць, дадаўшы TypeScript у праект, што цяпер стандарт дэ-факта, але нават з TypeScript у вас дастаткова магчымасцяў зрабіць інакш.

Часцей за ўсё, калі сэрвіс мае нейкую аўтарызацыю, карыстальнік недзе на старонцы, бачыць выяву прафайла, магчыма імя карыстальніка, ці нешта такое, калі ён аўтарызаваны. Або прапанову аўтарызавацца і зарэгістравацца. Часцей за ўсё ў стане праграмы будзе нейкае поле currentUser, якое можа быць прадстаўлена, або не, адлюстроўваючы абодва выпадкі. Сучасны вэб пры гэтым напічканы рознай інтэрактыўнасцю і імкненнем не перазагружаць старонку, калі адбываецца нейкае дзеянне. Таму можна ўявіць, што карыстальнік, калі хоча аўтарызавацца, уводзіць свой пароль і становіцца аўтарызаваным, пры гэтым старонка не абнаўляецца. Аднак сведкі пра карыстальніка ў такім выпадку будуць загружацца асінхронна. А гэта значыць, што падчас загрузкі інфармацыі заўжды можа адбыцца памылка, плюс і сама загрузка гэта працэс, які займае нейкі час. Таму нашая мадэль даных становіцца падобнай на нешта такое:

{
  isAuthorized: true,
  isLoading: false,
  user: {
    image: "https://example.com/profile.jpg"
  },
  error: null,
}

То-бок, карыстальнік можа быць не аўтарызаваным (за што адказвае поле isAuthorized), або аўтарызаваным, але дэталі ўсё яшчэ загружаюцца (isLoading), або маем загружаныя дэталі, ці, магчыма, памылку, што адбылася падчас загрузкі.

Ідуць гады, функцыянальнасць праграмы павялічваецца, і ўявіце сабе, у адзін дзень у сістэме маніторынгу вашай праграмы вы бачыце наступнае пра нашага карыстальніка:

{
  isAuthorized: false,
  isLoading: true,
  user: {
    image: "https://example.com/profile.jpg"
  },
  error: [{
    field: "password",
    code: "invalid",
    message: "username or password aren't match"
  }]
}

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

Як гэтую праблему мы можам вырашыць, калі ў нас ёсць такая моцная функцыянальнасць як ADT? Прадставім інфармацыю пра карыстальніка наступным чынам:

type User {
  Unauthorized
  Loading
  LoadingError(Error)
  Authorized(UserDetails)
}

А цяпер напішам функцыю, якая мусіць паказаць адпаведны стан на старонцы:

// Тыпу Widget не існуе на самай справе, але, спадзяюся,
// ўсё роўна зразумела, што адбываецца
fn render_profile(user: User) -> Widget {
  case user {
    Unauthorized -> render_login_button()
    Loading -> render_indeterminate_loader()
    LoadingError(e) -> render_error(e)
    Authorized(details) -> render_user_profile(details)
  }
}

І вось, у нас проста не можа быць станаў, калі мы адначасова загрузілі дэталі карыстальніка і пры гэтым мы ўсё яшчэ не аўтарызаваныя.

У дакладзе, што згаданы на пачатку гэтага раздзела можна пабачыць больш глыбокія прыклады дызайнаў мадэляў, якія не дапускаюць немагчымыя станы, вельмі раю азнаёміцца.

Фантомныя тыпы

Звычайна, у функцыянальных мовах праграмавання, тыпы могуць быць параметрызаваныя. Напрыклад Result (у некаторых мовах Either), адзін з самых шырока выкарыстоўваемых тыпаў са стандартнай бібліятэкі:

type Result(value, error) {
  Ok(value)
  Error(error)
}

Тут value і error гэта параметры тыпу Result. Замест кожнага параметра вы можаце падставіць любы іншы тып. Напрыклад Result(User, GetUserError) азначае, што значэнне пераменнай такога тыпу можа быць або Ok(User) або Error(GetUserError).

Аднак, нам не абавязкова падстаўляць пераменную тыпу ў адзін ці некалькі канструктараў.

type PhantomType(a) {
  TypeVariableNotUsed(String)
} 

Такія пераменныя тыпу называюцца фантомнымі тыпамі. Дзе нам можа быць гэта патрэбна? Тут ёсць ужо даволі класічны прыклад з валютамі або адзінкамі мер (кілаграмы, метры). Напрыклад:

let talers = 212.30
let euros = 10
let total = talers + euros

Гэта відавочная лагічная памылка, бо мы складваем розныя валюты і наўрад ці мы калісьці гэтага б хацелі. У канкрэтным выпадку памылку заўважыць проста, дзякуючы назвам пераменных, але я лёгка ўяўляю выпадкі ў вялікім праекце, дзе праз вялікі стэк выклікаў функцый гэта можа здарыцца і адлавіць такую памылку стане складана. А калі я скажу вам, што сам кампілятар можа сказаць вам пра падобную памылку? Так, вы не скампілюеце код, у якім так ці інакш сустрэнецца сума долараў і талераў! Для гэтага нам і спатрэбяцца фантомныя тыпы.

type Currency(a) {
  Currency(Float) // не варта трымаць грошы ў Float, але для прыклада пойдзе
}

type Talers
type Euros

fn talers(value: Float) -> Currency(Talers) {
  Currency(value)
}

fn euros(value: Float) -> Currency(Euros) {
  Currency(value)
}

fn add(first: Currency(a), second: Currency(a)) -> Currency(a) {
  let Currency(first_value) = first
  let Currency(second_value) = second
  Currency(first_value +. second_value)
}

pub fn main() -> Nil {
  let tlrs = talers(212.30)
  let eurs = euros(10.0)
  let _ = echo add(tlrs, eurs)
  Nil
}

І ўсё, кампілятар адразу лаецца

Type mismatch
Expected type:
    Currency(Talers)
Found type:
    Currency(Euros)

Прод выратаваны! Але што, калі мы сапраўды хочам скласці значэнні? А тут усё як у жыцці, трэба спачатку вызначыць правілы, па якіх гэтыя тыпы можна скласці, то-бок абвесціць працэдуры прывядзення аднаго тыпа да іншага.

type ExchangeRate(from, to) {
  ExchangeRate(Float)
}

fn talers_to_euros() -> ExchangeRate(Talers, Euros) {
  ExchangeRate(2.0) // за аднаго талера два еўрыкі даюць :)
}

fn exchange(currency: Currency(a), exchange_rate: ExchangeRate(a, b)) -> Currency(b) {
  let Currency(value) = currency
  let ExchangeRate(rate) = exchange_rate
  Currency(value *. rate)
}

pub fn main() -> Nil {
  let tlrs = talers(212.30)
  let eurs = euros(10.0)
  let _ = tlrs
    |> exchange(talers_to_euros())
    |> add(eurs)
    |> echo // У кансолі Currency(434.6)
  Nil
}

Такім чынам, ADT гэта моцны інструмент, які дазваляе вам дадаць гарантый у ваш код проста праз тое, што ён існуе. Слухачы падкасту ўжо не першы раз ад мяне чулі звярнуць увагу на Elm, але гэтая парада тычыцца любой функцыянальнай мовы праграмавання. Нават сам факт знаёмства з імі можа палепшыць ваш код у імператыўных мовах праграмавання. Вядома, алгебраічных тыпаў даных няма ні ў python, ні ў go, але некаторыя, даволі звычайныя практыкі з функцыянальных моў праграмавання могуць быць паспяхова перанесены ў імператыўныя. Дзякуй за ўвагу!

Каментары