Programowanie funkcyjne/Moduły

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

Wprowadzenie

Każdy duży system powinien być podzielony na mniejsze, łatwiejsze do ogarnięcia składowe. Jednocześnie, interakcje między tymi składowymi powinny być ograniczone do niezbędnego minimum. Stosując taką strukturalizację jesteśmy w stanie ogarnąć, jeśli nie cały system, to jedną składową na raz, wraz ze wszystkimi elementami mającymi wpływ na nią. W przypadku programów, takie składowe to moduły.
Moduł można określić jako fragment systemu polegający na wykonaniu wyodrębnionego zadania programistycznego. Każdy moduł ma ściśle określony interfejs --- zestaw obiektów programistycznych, które realizuje. Jedne moduły mogą korzystać z obiektów zaimplementowanych przez inne. Zwykle sformułowaniu zadania programistycznego towarzyszy (mniej lub bardziej formalna) specyfikacja własności, które powinny posiadać obiekty programistyczne implementowane przez moduł.
Moduł ma charakter czarnej skrzynki (ang. black-box approach). Na zewnątrz modułu widoczne są wyłącznie te obiekty programistyczne, które tworzą interfejs. Natomiast sposób ich implementacji, jak i ew. obiekty pomocnicze są ukryte wewnątrz modułu. Specyfikacja modułu nie powinna odwoływać się do sposobu implementacji modułu, a jedynie do takich właściwości modułu, które może zaobserwować użytkownik modułu. Co więcej, użytkownik nie tylko nie ma możliwości, ale i nie powinien (w swym programie) wnikać w sposób implementacji modułu (ang. information hiding). Takie zasady konstrukcji modułów pozwalają (po podzieleniu systemu na moduły) na niezależne ich opracowywanie. Podobnie, moduły można też niezależnie kompilować. Na poziomie języka programowania, wszystkie informacje konieczne do skompilowania modułu są zawarte w interfejsach wykorzystywanych przez niego modułów.
W Ocamlu możemy osobno definiować interfejsy i implementacje modułów. Interfejsy modułów nazywamy sygnaturami natomiast ich implementacje strukturami. Dodatkowo istnieje pojęcie modułów sparametryzowanych --- funktorów. Są to moduły, które mają określony interfejs, a także korzystają z modułów o określonych interfejsach, ale nie sprecyzowanej implementacji. Dopiero po podaniu implementacji uzyskujemy wynikowy moduł. Funktory można traktować jak konstrukcje przekształcające moduły w moduły.

Proste struktury

Definiując strukturę zbieramy razem definicje pojęć, które ta struktura ma udostępniać, otaczamy je słowami struct ... end i nadajemy modułowi nazwę.

<definicja> ::= module <Identyfikator> = <struktura> 
<struktura> ::= struct { <definicja> }* end      
     

Tak zdefiniowana struktura udostępnia wszystkie pojęcia zdefiniowane wewnątrz --- ma największy z możliwych interfejsów. Do pojęć zdefiniowanych wewnątrz struktury możemy dostać się stosując nazwy kwalifikowane postaci:

<identyfikator> ::= <Identyfikator> . <identyfikator>
     

Możemy też "otworzyć" strukturę, tzn. wyłuskać z niej wszystkieudostępniane przez nią pojęcia tak, aby były dostępne bez kwalifikowania nazwą modułu.

<jednostka kompilacji> ::= open <Identyfikator>
     

Przykład [Proste przykłady modułów]

module Modulik = 
  struct
    type typik = int list
    let lista = [2; 3; 7]
    let prod l = fold ( * ) 1 l
  end;;
  
Modulik.prod Modulik.lista;;
open Modulik;;
prod lista;;
  
module Pusty = 
  struct
  end;;

Przykład [Moduły mogą zwierać wewnątrz moduły]

module M = 
  struct
    module A = 
      struct
        let a = 27
      end
    module B = 
      struct
        let b = 15
      end
  end;;
  
M.A.a + M.B.b;;

Sygnatury

Sygnatura, to interfejs modułu --- określa, które elementy struktury mają być dostępne na zewnątrz. Wszystko czego nie widać w sygnaturze jest ukryte. Sygnatura może zawierać deklaracje:

  • wartości, z podaniem typu wartości,
  • typu wraz z jego definicją,
  • typu abstrakcyjnego (bez podania definicji),
  • sygnatury lokalnej
  • wyjątków.
<definicja>   ::= module type <Identyfikator> = <sygnatura> 
<sygnatura>   ::= sig { <deklaracja> }* end 
<deklaracja>  ::= type { <parametr typowy> }* <identyfikator> [ = <typ> ] |
                  val <identyfikator> : <typ> |
                  module type <Identyfikator> =
                  <sygnatura> | 
                  exception <wariant>
     

Przykład [Taka sobie sygnatura]

module type S = 
  sig
    type abstrakcyjny
    type konkretny = int * float
    val x : abstrakcyjny * konkretny
    module type Pusty = sig end
    exception Wyjatek of abstrakcyjny
  end;;
       
   

Przykład [Sygnatura kolejek FIFO]

module type FIFO = 
  sig
    exception EmptyQueue
    type 'a queue
    val empty : 'a queue
    val insert : 'a queue -> 'a -> 'a queue
    val front : 'a queue -> 'a
    val remove : 'a queue -> 'a queue
  end;;
       

Sygnatury można rozszerzać. Definiując jedną sygnaturę można "wciągnąć" do niejzawartość innej sygnatury.

Przykład [Sygnatura kolejek dwustronnych]

module type QUEUE = 
  sig
    include FIFO 
    val back : 'a queue -> 'a
    val insert_front : 'a queue -> 'a -> 'a queue
    val remove_back : 'a queue -> 'a queue
  end;;

Sygnatury można ukonkretniać. Na przykład, jeżeli sygnatura zawiera typ abstrakcyjny, to można go ukonkretnić. Można podać jaka ma być jego implementacja.

Przykład [Ukonkretnianie sygnatur]

module type LIST =  FIFO with type 'a queue = 'a list;;

Łączenie struktur i sygnatur

Podając jawnie sygnaturę dla struktury ograniczamy wgląd do środka sygnatury. Można to zrobić na kilka sposobów: treść sygnatury i struktury można podać explicite, lub odwołać się do zdefiniowanej sygnatury/struktury.

<definicja> ::= module <Identyfikator> [ : <sygnatura> ] = <struktura> 
<struktura> ::= <struktura> : <sygnatura> <struktura> ::= <Identyfikator> 
<sygnatura> ::= <Identyfikator>      

Przykład [Różne sposoby definiowania modułu FIFO]

module Fifo_implementation = 
  struct
    exception EmptyQueue
    type 'a queue = 'a list
    let empty = []
    let insert q x = q @ [x]
    let front q = 
      match q with
        [] -> raise EmptyQueue |
        h::_ -> h
    let remove q = 
      match q with
        [] -> raise EmptyQueue |
        _::t -> t
  end;;
  
module Fifo : FIFO = Fifo_implementation;;
module Fifo = (Fifo_implementation : FIFO);;
  
module Fifo : sig ... end = struct ... end;;
        

Podobnie jak można rozszerzać sygnatury, można też rozszerzać moduły:

Przykład [Liczby wymierne z dwiema barierami abstrakcji]

Zwróćmy uwagę, że operacje arytmetyczne są zaimplementowane bez znajomości implementacji struktury danych ułamków.
module type ULAMKI = 
  sig 
    type t
    val ulamek : int -> int -> t
    val licznik : t -> int
    val mianownik : t -> int
  end;;
  
module type RAT = 
  sig
    include ULAMKI 
    val plus : t -> t -> t
    val minus : t -> t -> t
    val razy : t -> t -> t
    val podziel : t -> t -> t
    val rowne : t -> t -> bool
  end;;
  
module Ulamki : ULAMKI = 
  struct
    type t = int * int
    let ulamek l m = (l, m)
    let licznik (l, _) = l
    let mianownik (_, m) = m
  end;;
  
module Rat : RAT =
    struct
      include Ulamki 
      let plus x y = 
        ulamek 
          (licznik x * mianownik y + licznik y * mianownik x) 
          (mianownik x * mianownik y)
      let minus x y = 
        ulamek 
          (licznik x * mianownik y - licznik y * mianownik x) 
          (mianownik x * mianownik y)
      let razy x y = 
        ulamek
          (licznik x * licznik y)
          (mianownik x * mianownik y)
      let podziel x y = 
        ulamek
          (licznik x * mianownik y)
          (mianownik x * licznik y)
      let rowne x y = 
        (licznik x * mianownik y) = (licznik y * mianownik x)
    end;;

Jak wyodrębniać moduły?

Jakimi zasadami kierować się dzieląc program na moduły? Każdy program można podzielić na moduły: pierwsze 100 linii, drugie 100 linii, itd. Oczywiście nie każdy podział jest właściwy. Podział programu na moduły powinien być taki, aby:

  • powiązania między modułami były jak najmniejsze;
  • jak najmniej szczegółów budowy jednego modułu miało wpływ na budowę innego modułu,
  • jeden moduł powinien koncentrować się na jednej decyzji
  • projektowej, jednym "sekrecie";
  • nie należy łączyć nie związanych ze sobą sekretów w jednym module.

Powyższe zasady są znane pod nazwą separation of concerns. Rozważając kilka możliwych podziałów na moduły możemy porównać je stosując następujący eksperyment myślowy. Przygotowujemy listę potencjalnych zmian w programie. Lista ta nie może być wydumana, ani nie może to być lista zmian, które łatwo wprowadzić do programu, ale lista realnych zmian, które mogą wynikać z potrzeb użytkownika programu. Dla każdej z tych zmian i każdego z podziałów na moduły badamy ile modułów należy zmodyfikować w celu prowadzenia danej zmiany. Im więcej modułów, tym gorzej.
Przykład: [D.L.Parnas,On the Criteria To Be Used in Decomposing Systems into Modules, CACM 12 (15), 1972] Przykład problemu: tekst, rotacje cykliczne wierszy. Możliwe podziały mogą organizować się albo wokół faz działania programu, albo wokół danych. Ten drugi sposób jest lepszy.

Laboratoria i ćwiczenia

Laboratoria i ćwiczenia do wykładu