Common Lisp (8) - objekty a výnimky

09.09.2008 22:20 | Články | Adam Sloboda
Čas ubehol a dopracovali sme sa ku poslednej časťi nášho seriálu o Common Lispe. Povieme si niečo o Objektovo orientovanom programovaní, conditions (podmienkach) a dám vám zopár tipov.

Pôvodne som plánoval v tomto seriáli detailnejšie popísať aj použitie OOP v Common Lispe, ale z dôvodu veľkého množštva podstatných detailov si to tu nemôžem dovoliť. Peter Seibel venuje tejto téme tri kapitoly (viac než 40 strán) svojej knihy (viď odkaz na túto a ďalšie publikácie o Lispe v závere), kde detailne popisuje všetky dôležité detaily a vymoženosti týkajúce sa tried, dedenia, generických funkcií a conditions. Ja sa v tejto časti zameriam najmä na stručný opis podstaty OOP v Common Lispe a "výnimkám" s krátkymi ukážkami.

OOP à la Common Lisp

Aj keď sa to nezdá, Lisp je pod kapotou poriadne objektový, používané dátové typy ako čísla, reťazce a zoznamy majú tiež svoje triedy.

Triedy sú podobné triedam v iných jazykoch, no neobsahujú metódy. Ako je to možné? Common Lisp má generic functions. Generická funkcia definovaná makrom defgeneric sa použije (vyberie) podľa typov argumentov, kde sa vyberá vždy čo najšpecifickejšia funkcia vyhovujúca všetkým argumentom funkcie. Pripomína vám to niečo? Multiple dispatch je samozrejmosťou.

Na ukážke je typický príklad multiple dispatch:

(defgeneric kolizia (x y)
  (:documentation "Kolízia objektu X s objektom Y."))

defgeneric neprijíma žiadnu implementáciu, tú definujeme makrom defmethod:

(defmethod kolizia ((x asteroid) (y vesmirna-lod))
  (format t "Asteroid narazil do vesmirnej lode.~%"))
 
(defmethod kolizia ((x asteroid) (y velka-vesmirna-lod))
  (format t "Asteroid narazil do velkej vesmirnej lode.~%"))

Tieto dve metódy sú jednoznačne rozlíšené podľa argumentov. Typ nie je nutné definovať, keď na ňom nezáleží, napríklad miesto (y vesmirna-lod) by sme mohli dať len y. Ďalšia vychytávka je, že je možné definovať napr. metódu, ktorá sa bude spúšťať vždy po nej:

(defmethod kolizia :after (x y)
  (format t "Kolizia bola ukoncena.~%"))

Iné bežné kombinácie metód:before a :around. Vysvetlenie výberu, poradia a iných možností tohoto systému je nad rámec nášho článku. Teraz si skúsime vytvoriť použité triedy:

(defclass asteroid () nil)
(defclass vesmirna-lod ()
  (meno))
(defclass velka-vesmirna-lod (vesmirna-lod) nil)

Common Lisp umožňuje viacnásobnú dedičnosť, pričom poradie zadaných nadtried sa používa v prípade kolízíznych situácií (mená slotov).

Teraz otestujeme naše metódy s týmito objektami:

(defparameter *suter* (make-instance 'asteroid))
(defparameter *mala* (make-instance 'vesmirna-lod))
(defparameter *velka* (make-instance 'velka-vesmirna-lod))

Keď zavoláme (kolizia x y), kde za x dosadíme *suter* a za y *mala* alebo *velka*, dostaneme správny výpis z metódy podľa typov a navyše výpis z :after metódy, pretože tá sedí na akýkoľvek typ argumentov. Naopak sa dostaneme do debuggeru, ak dosadíme za x argument iného typu než asteroid.

V triede vesmirna-lod sme si definovali jeden slot meno. Ten je teraz úplne prázdny a volanie (slot-value *mala* 'meno) nás znovu dostane do debuggeru. slot-value je možné použiť ako argument setf (rovnako ako napríklad funkciu nth pre prvok zoznamu, getf pre property list, správanie setf si dokonca sami môžeme rozšíriť podobnými funkciami pri vlastných typoch). Nastavíme do slotu reťazec volaním (setf (slot-value *mala* 'meno) "meno").

Netreba sa ale ničoho báť, triedy nie sú také primitívne, že by sme hodnoty museli nastavovať takto – naopak, umožňujú toho viac než v iných jazykoch. Veľmi pohodlné je použitie implicitných getterov a setterov (tieto názvy sa používajú v iných jazykoch a sú odvodené od mien metód začínajúcich slovom get/set):

(defclass vesmirna-lod ()
  ((meno :reader meno :writer (setf meno))))

Toto sa dá ešte zjednodušiť použitím voľby :accessor meno. Ekvivalentný kód, ktorý nemusíme písať, by vyzeral asi takto:

;; reader
(defgeneric meno (vesmirna-lod))
(defmethod meno ((lod vesmirna-lod))
  (slot-value lod 'meno))
 
;; writer (definicia pre setf funkciu)
(defun (setf meno) (meno-lode lod)
  (setf (slot-value lod 'meno) meno-lode))

Teraz môžeme volať writer (meno instancia-lode) a tento argument používať vo funkcii setf.

Ďalšie dôležité možnosti definície:

  • :initarg pre definíciu keyword pri vytváraní inštancie
  • :initform definuje výraz, ktorý sa vykoná, ak nebola zadaná hodnota (napríklad error, viď ďalej)
  • všadeprítomný dokumentačný reťazec – :documentation
(defclass vesmirna-lod ()
  ((meno
    :initarg :meno
    :initform (error "Lod musi mat meno!")
    :accessor meno
    :documentation "Meno lode.")))
 
;; korektne vytvorenie instancie
(make-instance 'vesmirna-lod :meno "meno")

Na záver treba spomenúť, že Common Lisp je tzv. "Lisp 2" a pre triedy, funkcie a tiež premenné má oddelené namespace (priestor mien). Okrem toho je tiež možné definovať balíky, ako sme si už ukázali.

conditions

V objektovom systéme by nemali chýbať výnimky, conditions sú obdobou výnimiek, presnejšie – sú to výnimky na steroidoch. Používajú vlastný prototyp triedy odlišný od standard-class, definujú sa makrom define-condition (inštanciu vytvára makro make-condition). error je bežne používaná podtrieda condition.

(define-condition my-error (error) nil)
;; signalizujeme condition:
(error 'my-error)

Základné použitie, aké je dobre známe z iných jazykov (try-catch) vyzerá nasledovne:

(handler-case
    (progn
      (func1)
      (func2))
    (exception1 (e) (func3 e)))

conditions v Lispe umožňuje rozhodovať o správaní aj z vyššej úrovne, keď sa použije restart-case miesto handler-case a miesto mien tried výnimiek (condition) sa používajú mená, ktoré vyjadrujú akciu, ktorá sa vykoná (je to meno, ktoré sa bude používať v kóde, ktorý bude rozhodovať ako zareagovať na výnimku).

(handler-bind (binding*) form*)
 
(handler-bind ((exception1
                #'(lambda (c)
                    (invoke-restart 'meno-akcie))))
    (func4)
    (func5))

Tento kód je možné umiestniť do vyšších úrovní (medzi miestom, kde došlo k výnimke a miestom, kde sa rozhoduje o akcii nemusí byť vo funkcii, ktorá ju volala) a volací zásobník zostane neporušený, akcia sa vykoná akoby sa rozhodovalo priamo na mieste. Miesto lambdy je možné použiť aj pomenovanú funkciu. Samozrejme je možné definovať viacero reštartov a tiež je možné im predávať argumenty, systém conditions a ich signalizovanie a ošetrovanie nám dáva veľkú voľnosť.

Okrem error môžeme signalizovať aj menej striktný warning:

(define-condition my-warning (warning) nil)
;; signalizujeme condition:
(warn 'my-warning)

warn v prípade neúspechu nespúšťa debugger, len vypíše condition na chybový výstup.

Tipy a vychytávky

Kompilácia

SBCL kód kompiluje do natívneho kódu a tak je možné ľahko vytvoriť samostatný spustiteľný súbor:

;; načítame náš program
(asdf:operate 'asdf:load-op :mandelbrot)
;; funkcia main, ktorá vráti nulu (nutné pre normálne ukončenie programu)
(defun main () (mandelbrot:start) 0)
;; uloženie programu do samostatného spustiteľného súboru
(sb-ext:save-lisp-and-die "mandelbrot" :executable t :toplevel 'main)

Výstupný program môže byť v prípade SBCL veľký, vývojári sa riešeniu tohoto problému venujú. Na druhej strane komerčné implementácie by na tom mali byť v tomto lepšie.

Jedna, dve, tri hviezdičky

*, **, *** uchovávajú posledné 3 vrátené hodnoty, takže pri interaktívnom písaní kódu sa k nim šikovne môžeme dostať ak sme ich neukladali (najmä pokiaľ sa jedná o výsledok nejakej dlhšie vykonávanej funkcie).

Profiler

Občas sa zíde otestovať výkonnosť, na to SBCL poskytuje pekný profiler:

;; zapneme profilovanie funkcie
(sb-profile:profile mandelbrot:start)
;; kontrola
(sb-profile:profile)

Môžeme zavolať kód, ktorý chceme otestovať (v tomto prípade bez argumentov) a potom si nechať vypísať správu (sb-profile:report). Výsledok (jedná sa o optimalizovanú verziu ukážkového kódu):

  seconds  | consed | calls |  sec/call  |  name  
----------------------------------------------------
     0.212 | 94,384 |     1 |   0.211999 | MANDELBROT:START
----------------------------------------------------
     0.212 | 94,384 |     1 |            | Total

estimated total profiling overhead: .000 seconds
overhead estimation parameters:
  2.4e-8s/call, 1.424e-6s total profiling, 5.6e-7s internal profiling

Záver

Na záver poslednej časti by som chcel odporučiť uznávanú literatúru o Lispe. Veľmi vhodná pre začiatočníkov aj pokročilých je kniha Practical Common Lisp od Petra Seibela, v ktorej opisuje Common Lisp do väčšej hĺbky aj šírky než tento seriál (zachádza do detailov štandardnej knižnice, dátových typov, makier a uvádza praktické príklady), a upozorní a vysvetlí niektoré nie celkom zrejmé detaily jazyka. Je to kniha odporúčaná mnohými skúsenými Lisp programátormi a každý koho Lisp zaujal by ju určite nemal prehliadnuť.

Dalšia mnohými odporúčaná kniha zaoberajúca sa praktickým využitím Lispu je On Lisp od Paula Grahama.

S Lispom súvisí aj publikácia Structure and Interpretation of Computer Programs (SICP), ktorá je uznávaným textom v odbore a na ukážky používa Scheme, derivát Lispu.

A posledná súvisiaca kniha je Paradigms of Artificial Intelligence Programming (PAIP) Petra Norviga, ktorá sa zaoberá hlavne umelou inteligenciou.

Ospravedlňujem sa, ak sa mi "podarilo" niečo podstatné vynechať, Common Lisp je jazyk, ktorý obsahuje množstvo možností a množstvo funkcií v štandardnej knižnici, ale našťastie je vo vývojovom prostredí dostupná celá dokumentácia štandardu.