Paradygmaty programowania/Wykład 8: U podstaw programowania funkcyjnego — rachunek lambda

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania


Wstęp

W latach trzydziestych XX wieku Alonzo Church i Haskell Curry, pracując niezależnie, opracowali notację dla funkcji — rachunek lambda. Spojrzenie na funkcje w rachunku lambda jest nieco inne od klasycznego, teoriomnogościowego rozumienia funkcji jako zbioru par argument-wartość. W rachunku lambda funkcje rozumiane są jako przepisy, aby podkreślić ich obliczalność. Rachunek lambda ma dwa zasadnicze warianty: nietypowany, zwany po prostu rachunkiem lambda, oraz typowany. Najpierw przedstawimy nieco obszerniej ten pierwszy; później powiemy krótko o rachunku z typami.

Podstawowe definicje i własności

Napisy w rachunku lambda nazywamy termami. Zbiór Λ termów rachunku lambda konstruuje się indukcyjnie jako zbiór słów nad alfabetem Var{(,),λ}, gdzie Var jest przeliczalnym zbiorem ziennych. Do zbioru Λ należą tylko termy skonstruowane w poniższy sposób:

  • Dowolna zmienna ze zbioru Var jest termem rachunku lambda, czyli jeżeli xVar, to xΛ.
  • Jeżeli weźmiemy dowolny term M ze zbioru Λ, oraz dowolną zmienną x ze zbioru Var, to term postaci (λx.M) jest termem rachunku lambda, czyli jeżeli MΛ i xVar, to (λx.M)Λ. Powyższy sposób tworzenia termu nazywamy abstrakcją.
  • Inna metoda to metoda aplikacji, czyli jeżeli termy MΛ i NΛ, to (MN)Λ.

Nawiasy są często pomijane według zasady łączności lewostronnej: na przykład MNPQ(((MN)P)Q). Wieloargumentowe funkcje są reprezentowane przez funkcje jednoargumentowe, których argumentami są funkcje. Zatem to, co zwykle jest zapisywane w postaci F(N,M), w rachunku lambda zapisuje się ((FN)M)FNM. Powtórzenia symbolu λ pomijane są według zasady łączności prawostronnej, tzn. λxyz.M(λx.(λy.(λz.M))).

W termie rachunku lambda zmienna może być wolna albo związana. Wolnym wystapieniem zmiennej x nazywamy tylko takie wystąpienie tej zmiennej, które nie jest „w zasięgu” żadnego λx.

Zbiór zmiennych wolnych termu M, oznaczany przez FV(M), zdefiniowany jest, w zależności od budowy termu M, w następujący sposób:

  • Jeżeli M jest pojedynczą zmienną, tzn. Mx, to zmienna ta jest wolna, a zatem FV(M)={x}.
  • Jeżeli M jest postaci λx.M, to λx wiąże wszystkie wolne wystąpienia zmiennej x w termie M, a zatem FV(M)=FV(M){x}.
  • Jeżeli M jest postaci PQ, to zmiennymi wolnymi termu M są wszystkie zmienne wolne występujące w termach PQ, czyli FV(M)=FV(P)FV(Q).

Zmienna jest zmienną wolną termu M, jeżeli należy do zbioru FV(M), a zmienną związaną w przeciwnym wypadku.

Term M jest domknięty wtedy i tylko wtedy, gdy FV(M)=.

Przykład

  • W termie M=y(λx.x) zmienna y jest zmienną wolną, a x zmienną związaną. W termie λy.M nie ma zmiennych wolnych, jest to więc term domknięty.
  • W termie M=x(λx.x) tylko pierwsze wystąpienie zmiennej x jest wolne, chociaż FV(M)={x}. Oczywiście term M nie jest termem domkniętym.

Na termy w rachunku lambda patrzymy jako na przepisy, jak z zadanej wartości argumentu otrzymać wartość funkcji; zatem termy to procedury, których parametry specyfikowane są poprzez lambda abstrakcję:


λx1xnparametry.Mciało procedury


Alfa-konwersja i beta-redukcja, czyli obliczanie

Termy rachunku lambda możemy przekształcać, korzystając z α-konwersji i β-redukcji.

α-konwersja to po prostu zamiana nazw zmiennych związanych. Zdarza się, że na przykład poprzez stosowanie „podprocedur” lub parametrów pochodzących z wykonania innych procedur dochodzi do konfliktu nazw zmiennych. Aby zapewnić poprawne wykonanie, stosujemy α-konwersję, która pozwala zapewnić unikalność nazw.


λx.Mαλy.M[x/y],


gdzie y∉FV(M), a M[x/y] oznacza wynik postawienia zmiennej y za każde wolne wystąpienie zmiennej x w termie M.

Będziemy używać oznaczenia T1=αT2, aby powiedzieć, że termy T1T2 są równe modulo α-konwersja, tzn. że mogą być zredukowane do tego samego termu dzięki zastosowaniu skończonej liczby α-konwersji.

Przykład Niech x, y, z oznaczają różne zmienne. Wówczas


λx.x=αλy.yλx.xz=αλy.yzλx.xyαλx.xz.


β-redukcja — obliczenia wykonywane przez procedurę symulowane są w rachunku lambda poprzez proces zwany β-redukcją. Aplikujemy procedurę λx.M do argumentu N:


(λx.M)NβM[x/N]


w taki sposób, że każda zmienna wolna termu N pozostaje wolna po podstawieniu. Jest to oczywiście możliwe do osiągnięcia po ewentualnym wcześniejszym zastosowaniu α-konwersji. Na przykład:


(λxy.xy)yλy.yy


jest nieprawidłowym użyciem β-konwersji, gdyż dochodzi do konfliktu oznaczeń i do utraty pierwotnego znaczenia termu-procedury. Jednakże można temu zaradzić, stosując α-konwersję:


(λxy.xy)y=α(λxz.xz)y=βλz.yz.


Używamy oznaczenia T1=βT2, aby powiedzieć, że termy T1T2 są równe modulo β-konwersja, tzn. że mogą być zredukowane do tego samego termu dzięki zastosowaniu skończonej liczby β-konwersji.

Aplikację PQ nazywamy β-redeksem, jeżeli term P jest w postaci λ-abstrakcji. Term M jest w postaci normalnej, jeżeli nie zawiera β-redeksów, tzn. żaden podterm termu M nie jest β-redeksem.

Własność Churcha-Rossera

Poniższe twierdzenie zwane jest własnością Churcha-Rossera lub własnością karo:

Jeżeli term M poprzez pewne ciągi β-redukcji redukuje się do termów N1N2, to istnieje term M taki, że zarówno N1, jak i N2 można zredukować do M (poprzez ciągi β-redukcji):


Paradygmaty programowania/Moduł8
Paradygmaty programowania/Moduł8


Powyższa własność mówi, że postać normalna (o ile istnieje) jest unikalna z dokładnością do α-konwersji.

Term T jest normalizowalny, jeżeli można go zredukować do postaci normalnej, a jest silnie normalizowalny, jeżeli każda droga redukcji prowadzi do postaci normalnej. Beztypowy rachunek lambda nie ma żadnej z tych własności. Wprowadźmy oznaczenie: Δ=λx.xx. Najprostszym przykładem termu nie posiadającego postaci normalnej jest ΔΔ:


ΔΔ=(λx.xx)Δ=βΔΔ.


Przykładem termu, w którym nie każda droga redukcji prowadzi do postaci normalnej, jest:


(λx.y)(ΔΔ(1))(2)=β(λx.y)(ΔΔ)=β| redukując (1)=βy| redukując (2)


Istnieją strategie redukcji, które zawsze doprowadzają do postaci normalnej, oczywiście gdy postać normalna istnieje. Taką strategią jest na przykład metoda redukowania zawsze najbardziej lewego redeksu.

Lambda-definiowalność funkcji na liczbach naturalnych

Do tej pory nie przedstawiliśmy żadnych termów oznaczających liczby naturalne czy funkcje operujące na liczbach naturalnych, wydaje się więc rzeczą dziwną mówienie o lambda reprezentowalności tych funkcji. Okazuje się jednak, że w rachunku lambda nie potrzeba żadnych dodatkowych oznaczeń: pojęcie liczby i funkcji daje się wprowadzić na bazie do tej pory przyjętych definicji.

Popatrzmy na liczbę naturalną n jak na procedurę, która n-krotnie stosuje następnik do zera. Oczywiste jest, że taka procedura w sposób jednoznaczny określa liczbę, i na odwrót. Mając dany przepis wiemy, jaką liczbę naturalną liczy, a mając liczbę naturalną doskonale wiemy, który przepis zastosować.

  • Term λsx. x reprezentuje liczbę naturalną 0.
  • Term λsx. s(s(...(s n razyx)...)) reprezentuje liczbę naturalną n.

Term λsx. x to po prostu algorytm: „weź następnik s, weź zero x i zwróć zero”; natomiast term λsx. s(s(...(s n razyx)...)) to algorytm: „weź następnik s, weź zero x, zastosuj n-krotnie następnik do zera i zwróć wynik”. Zatem liczba naturalna w naszym rozumieniu to procedura, która ma dwa parametry: s (następnik) oraz x (zero).

Oczywiście powyższe termy podane są z dokładnością do α-konwersji. Np. term λty.t(ty) reprezentuje 2, gdyż λty.t(ty)=αλsx.s(sx).

Mówimy, że term E rachunku lambda reprezentuje funkcję na liczbach naturalnych e:k wtedy i tylko wtedy, gdy En1_...nk_=αβe(n1,...,nk)_, gdzie n_ oznacza reprezentację liczby n w rachunku lambda.


Operacje na liczbach

Znając reprezentację liczb naturalnych w rachunku lambda, możemy zdefiniować podstawowe operacje na liczbach.

Term

A=λmnsx.(ms)(nsx)

reprezentuje dodawanie. Jest to procedura, która oczekuje na podanie dwóch parametrów: m i n, a w wyniku daje procedurę liczącą liczbę naturalną (będącą sumą liczb m i n). Jak wygląda algorytm otrzymany w wyniku działania procedury A? Jest to algorytm, który m-krotnie stosuje następnik do n.


Przykład Zobaczmy, jak działa term A na liczbach 1 i 2:

A1_ 2_={[λm. (λn. (λsx. (ms)(nsx))]1_}2_|dopisując nawiasy i λ=β[λn. (λsx. (2_s)(nsx)]1_|m:=2=βλsx. (2_s)(1_sx)|n:=1=λsx. (2_s){[(λt. (λy. ty))s]x}| dopisując nawiasy i λ=βλsx. (2_s)[(λy. sy)x]|t:=s=βλsx. (2_s)(sx)|y:=x=λsx. {[λt. (λy.t(ty))] procedura licząca 2s}(sx) „zero”| dopisując nawiasy i λ=βλsx. (λy. s(sy))(sx)|t:=s=βλsx. s(s(sx))|y:=sx=3_

Term

M=λmnsx.n(ms)x

reprezentuje mnożenie. Procedura mnożenia oczekuje na podanie dwóch argumentów m i n, a w wyniku daje procedurę, która liczy liczbę naturalną w sposób następujący: m-krotnie dodaje liczbę n, zaczynając od zera. Liczba naturalna obliczona w taki sposób to mn.

Przykład

M2_ 2_={[λm. (λn. (λsx. n(ms)x))]2_}2_|dopisując nawiasy i λ=β[λn. (λsx. n(2_s)x)]2_|m:=2=βλsx. 2_(2_s)x|n:=2=λsx. 2_[(λt. (λy. t(ty))) procedura licząca 2s]x| dopisując nawiasy i λ=βλsx. 2_[λy. s(sy)]x|t:=s=λsx. [λt. (λz.t(tz))] procedura licząca 2[λy.s(sy)] dodawanie 2x| dopisując nawiasy i λ=βλsx. (λz. [λy.s(sy)]([λy.s(sy)]z))x|t:=[λy.s(sy)]=βλsx. [λy.s(sy)]([λy.s(sy)]z)|z:=x=βλsx. s(s[s(sx)])|y:=s(sx)=4_

Term

C=λmnksx.m(λy.nsx)(ksx)

reprezentuje funkcję ifthenelse. Term C oczekuje na podanie trzech argumentów i tak jak w poprzednich przykładach zwraca procedurę obliczającą liczbę naturalną. W przypadku, gdy pierwszy parametr m jest zerem, procedura wynikowa liczy liczbę k, w przeciwnym przypadku — liczbę n.

Przykład Zobaczmy dwa przykłady działania termu C -- pierwszy, gdy m=0, oraz drugi, gdy m0.

Przykład pierwszy

C0_ n_ k_=βλsx. 0_(λy. n_sx)(k_sx)=λsx. (λtz.z)0_(λy. n_sx)(k_sx)=βλsx. k_sx=βk_

Przykład drugi

Cm_ n_ k_=βλsx.m_(λy.n_sx)(k_sx)=λsx. [λtz. t(...(tm0 razyy)...)](λy. n_sx)(k_sx)=βλsx.[λy. n_sx](...([λy. n_sx](k_sx))...)=βλsx.n_sx

Przekazanie funkcji jako parametru

Musimy częściowo zapomnieć o naszych intuicjach, gdy spojrzymy na term reprezentujący funkcję wykładniczą. Co prawda jest to term, który jak wszystkie poprzednie oczekuje na podanie (dwóch) parametrów,  w wyniku daje procedurę liczącą liczbę naturalną, ale trudno tu dopatrzeć się analogii do tych języków programowania, które przekazują parametry tylko przez wartość.

Term


E=λmn.(mn)


reprezentuje funkcję e(m,n)=nm i ma pewną „niezwykłą” własność: w wyniku zwraca algorytm, który „zero” jednego z parametrów traktuje jako następnik. Oto przykład:


E2_ 2_=[λmn. (mn)]2_ 2_=β2_ 2_|m:=2_, n:=2_=[λsx. s(sx)]2_2_=βλx. 2_(2_x)|s:=2_=αλs. 2_(2_s)|x:=s=λs.[λtx. t(tx)](2_s)=βλsx. (2_s)((2_s)x)|t:=2_s=λsx. ([λty.t(ty)]2_s)(([λpz.p(pz)]2_s)x)=βλsx. [λy. s(sy)](s(sx))|t:=s, p:=s, z:=x=βλsx. s(s[s(sx)])|y:=s(sx)=4_


Ciekawym termem jest też term reprezentujący poprzednik:


P=λn.(n(λz.<A1_(z0_),(z0_)>)<0_,0_>)1_,


gdzie A oznacza wspominany już wcześniej term dodawania, z jest parą uporządkowaną, której pierwsza składowa to z0_, natomiast druga to z1_. Jaką procedurę opisuje term P? Mówi on tyle: „weź liczbę naturalną n, a następnie n razy wykonaj algorytm weź parę z i utwórz nową parę <1+z0,z0>, startując od pary <0_,0_>; jako wynik zwróć drugą składową ostatnio otrzymanej pary”. Uwaga: term rerprezentujący parę uporządkowaną zapisujemy tu w nawiasach kątowych, by odróżnić je od nawiasów grupujących fragmenty termów.

Wygląda to następująco:

<0,0> <1,0>     krok 1<2,1>     krok 2...<n,n1>     krok n


Zatem term P definiuje funkcję p(n)=(f(n)(0,0))2, gdzie f(x,y)=<x+1,x>.

Przykład Zobaczmy teraz, jak działa term opisujący powyższą procedurę. Policzmy:


P2_={λn.(n(λz.<A1_(z0_),(z0_)>)<0_,0_>)1_}2_=β(2_(λz.<A1_(z0_),(z0_)>)<0_,0_>)1_=((λsx. s(sx) procedura licząca 2)(λz.<A1_(z0_),(z0_)> następnik)<0_,0_> zero)1_=β((λz.<A1_(z0_),(z0_)>)[(λz.<A1_(z0_),(z0_)>)<0_,0_>])1_=β((λz.<A1_(z0_),(z0_)>)<A1_0_,0_>)1_=β((λz.<A1_(z0_),(z0_)>)<1_,0_>)1_=β(<A1_1_,1_>)1_=β(<2_,1_>)1_=β1_

Operator punktu stałego

Podaliśmy tutaj tylko przykłady funkcji reprezentowalnych w rachunku lambda. Okazuje się, że beztypowy rachunek lambda to model obliczeń równoważny funkcjom rekurencyjnym (obliczalnym). Oznacza to, że każdą funkcję obliczalną można reprezentować w rachunku lambda oraz że każdy term rachunku lambda reprezentuje pewną funkcję obliczalną.

Aby móc dowolną funkcję rekurencyjną reprezentować w rachunku lambda, konieczne jest wprowadzenie operatora punktu stałego 𝒴. Operator punktu stałego to term, który dla dowolnego termu M daje jego punkt stały, tzn.


𝒴M=βM(𝒴M).


Zauważmy, że term


𝒴=λy.(λx.y(xx))(λx.y(xx))


jest oczekiwanym operatorem. Zobaczmy, jak term 𝒴 zachowuje się zaaplikowany do dowolnego termu M:


𝒴M=(λy.(λx.y(xx))(λx.y(xx)))M| z definicji 𝒴=β(λx.M(xx))(λx.M(xx))B|y:=M=βM((λx.M(xx))B(λx.M(xx))B)|x:=B=βM(𝒴M)|𝒴M=(λx.M(xx))(λx.M(xx))

Typowany rachunek lambda

Znaczenie typowanego rachunku lambda dla informatyki bierze się z analogii do struktury typów w typowanych językach programowania. W rachunku tym każda zmienna ma przypisany typ, tzn. zakresem jej zmienności jest zbiór opisywany przez ten typ. Typ wyrażenia konstruuje się indukcyjnie z typów jego składowych. Term może być stworzony poprzez aplikację funkcji do argumentu jedynie wtedy, gdy typy funkcji i argumentu są zgodne, tzn. argument należy do dziedziny funkcji.

Rachunek lambda z typami prostymi wprowadzony został przez Churcha i Curry'ego. Jednakże istnieją istotne różnice pomiędzy zaproponowanymi przez nich systemami typów. W systemie Curry'ego termy typowanego rachunku lambda są po prostu wyróżnione spośród termów beztypowego rachunku poprzez relację ustalającą typy. Tak więc term λx.x jest typowalny i należy do wyróżnionego zbioru termów, a term λx.xx typowalny nie jest i do zbioru nie należy. W systemie Churcha reguły nadające typy zostały wbudowane w reguły tworzenia termów. Nie będziemy omawiać żadnego z systemów konkretnie, a jedynie przedstawimy intuicje związane z systemem Churcha nadawania typów.

Zakładamy, że mamy nieskończony, przeliczalny zbiór zmiennych (różnych od zmiennych ze zbioru Var). Typ to wyrażenie zdefiniowane indukcyjnie:

  • Każda zmienna jest typem, zwanym typem atomowym.
  • Jeżeli στ są typami, to (στ) jest typem, zwanym typem złożonym.

Typy atomowe oznaczamy najczęściej α, β, γ, a typy ogólnie σ, τ, ... Nawiasy są często pomijane zgodnie z zasadą łączności prawostronnej, tzn.

γστ(γ(στ)).


Fakt, że term M jest typu τ (ma przypisany typ τ), zapisujemy jako Mτ lub poprzez indeks dolny, na przykład λx.Mτ. Typ, który nie może zostać przypisany żadnemu termowi, nazywamy typem pustym.

Termom nadajemy typy w następujący sposób:

  • Jeżeli term M jest pojedynczą zmienną typu τ, to M jest typu τ, czyli Mτ.
  • Jeżeli term M=λx:τ.N:σ, to M(τσ). Innymi słowy, jeżeli tworzymy program, który oczekuje na parametr typu τ, a zwraca wynik typu σ, to całość jest typu τσ.
  • Jeżeli term M=PQ, gdzie P(τσ), a Qτ, to Mσ. Innymi słowy, jeżeli aplikujemy „funkcję” P typu τσ do argumentu Q typu τ, to wynik jest typu σ.

Pytanie, czy term jest danego typu, jest problemem rozstrzygalnym, tzn. istnieje algorytm, który dla zadanego termu M oraz typu τ odpowiada, czy M jest typu τ. Rozstrzygalny jest także problem, czy danemu termowi przypisać można typ zgodnie z zasadami opisanymi powyżej.

Przykład


𝐓𝐞𝐫𝐦:𝐓𝐲𝐩:HLINE TBDλx:α.xααλx:αβ.x(αβ)(αβ)λx:α.λy:β.xα(βα)λx:αα.λy:α.x(αα)(α(αα))λx:α.λy:β.yα(ββ)λx:αβ.λy:γα.λz:γ.x(yz)(αβ)((γα)(γβ))λx.xxterm nie jest typowalnyλxyz.xy(xz)term nie jest typowalnytyp pusty(αα)βtyp pusty((αβ)α)α


Jeżeli τ1,,τn, τ są typami, to zapis τ1,...,τnτ oznacza typ τ1(τ2...(τnτ)...). Zatem każdy typ może być przedstawiony w postaci τ1,...,τnα, gdzie n0, a α jest typem atomowym.