Moduly a balíky

Ciele

  1. Naučiť sa používať Cabal na definovanie projektov.
  2. Naučiť sa použivať moduly.
  3. Naučiť sa používať externé knižnice.
  4. Naučiť sa pracovať s realistickými údajovými štruktúrami.
  5. Vyskúšať použitie monád na prácu s I/O a ošetrenie chýb.

Úvod

Na dnešnom cvičení si vyskúšame aplikovať doteraz získané vedomostí na projekte hry Mínu, ktorá je témou 3. zadania. Skúsime vytvoriť projekt postupne, aby ste sa zoznámili s jeho dotailami.

Postup

Krok 1: Inicializácia projektu

Na definovanie a kompiláciu komplexného programu alebo knižnice sa používa nástroj cabal-install. Je to nástroj pre zostavenie a zároveň správca závislostí.

Úloha 1.1

Aktualizujte databázu balíkov pre Haskell pomocou príkazu cabal update.

Úloha 1.2

Vytvorte nový adresár s názvom tuke-minesweeper a so základnými súbormi projektu.

mkdir tuke-minesweeper
cd tuke-minesweeper

V tomto adresári tuke-minesweeper vytvorte tri súbory. Prvý z nich je tuke-minesweeper.cabal, ktorý definuje vlastnosti projektu:

cabal-version:      >=1.10

name:               tuke-minesweeper
version:            0.1.0.0
synopsis:           Simple console minesweeper game
license:            MIT
author:             Sergej Chodarev
build-type:         Simple

executable minesweeper
  main-is:          Main.hs
  build-depends:    base >=4.13 && <5
  default-language: Haskell2010
  ghc-options:      -Wall

Ďalší súbor je Setup.hs, ktorý v našom prípade obsahuje iba predvolenú logiku:

import Distribution.Simple
main = defaultMain

Tretím súborom je kód našej aplikácie — Main.hs:

module Main where

main :: IO ()
main = putStrLn "Hello, Haskell!"

Poznámka

Pre vytvorenie projektu je možné použiť aj príkaz cabal init. Tento príkaz vygeneruje kostru projektu automaticky.

Úloha 1.3

Preložte a spustite program. Na preloženie programu slúži príkaz cabal build a na jeho spustenie cabal run minesweeper.

Poznámka

Väčšinou stačí použiť iba run, ktorý automaticky preloži program, ak boli zmenené zdrojové kódy.

Krok 2: Doplnenie modulu a závislostí

Úloha 2.1

Pridajte do súboru tuke-minesweeper.cabal ďalší modul s názvom Minesweeper.Board a doplňte závislosti podľa vzoru:

executable minesweeper
  main-is:          Main.hs
  other-modules:    Minesweeper.Board
  build-depends:    base >=4.13 && <5,
                    containers ==0.6.*
  default-language: Haskell2010

Týmto sme definovali, že okrem modulu Main sa kód nášho programu bude nachádzať v jednom ďalšom module. Zároveň sme zväčšili rozsah podporovaných verzií štandardnej knižnice Haskellu a doplnili knižnicu containers.

Krok 3: Moduly

Úloha 3.1

Vytvorte súbor Minesweeper/Board.hs, ktorý bude obsahovať definíciou hracieho poľa hry Míny.

Kód modulu musí obsahovať hlavičku definujúcu jeho názov a zoznam exportovaných symbolov:

module Minesweeper.Board
    (
    -- * Board
    -- ** Types
      Config(..)
    , Board
    , Pos(..)
    , Tile(..)
    -- ** Board creation
    , createBoard
    , smallBoard
    ) where

Zápis Config(..) znamená, že exportovaný je dátový typ spolu so všetkými jeho konštruktormi. Na rozdiel od toho, typ Board neexportuje konštruktory, takže v iných moduloch nie je možné ani vytvárať jeho hodnoty, ani ich rozkladať pomocou porovnávania vzorov. Prístup k jeho hodnotam je teda možný iba pomocou poskytnutých funkcií, čo zabezpečí skrytie jeho implementácie.

Za hlavičkou modulu nasledujú importy iných modulov:

import Data.Set (Set, member, notMember)  -- most used set operations
import qualified Data.Set as Set          -- the rest of operations

Následne máme definície dátových typov:

-- Board definition ---------------------------------------------------------

-- | Configuration of the board.
data Config = Config
    { configSize :: Pos      -- ^ board size
    , configNumMines :: Int  -- ^ number of mines
    } deriving (Eq, Show)

{- | Complete state of the board. It includes the configuration and hidden
properties.

The structure of the board is not exported. To create the board, use
'createBoard' smart constructor.
 -}
data Board = Board
    { bConfig :: Config
    , bMines :: Set Pos
    , bOpened :: Set Pos
    , bMarked :: Set Pos
    } deriving (Show)  -- only for testing


-- | Position of the tile or size of the board.
data Pos = Pos
    { posRow :: Int
    , posCol :: Int
    } deriving (Eq, Ord, Show)

-- | Tile state.
data Tile
    = Open Int -- ^ Open tile with number of neighbour mines
    | Closed   -- ^ Closed tile
    | Marked   -- ^ Tile marked for mine
    | Exploded -- ^ Exploded (opened) mine
    deriving (Eq, Show)

-- Board creation -----------------------------------------------------------

-- | Create a new board with random mines.
createBoard :: Config -> [Pos] -> Board
createBoard config positions = Board
    { bConfig  = config
    , bMines   = Set.fromList positions
    , bOpened  = Set.empty
    , bMarked  = Set.empty
    }

-- | Configuration for a small board.
smallBoard :: Config
smallBoard = Config (Pos 8 8) 10

Úloha 3.2

Upravte modul Main, tak aby ste si vyskúšali prácu s hracím poľom.

module Main where

import Minesweeper.Board

main :: IO ()
main = putStrLn (show exampleBoard)
  where
    exampleBoard = createBoard smallBoard [Pos x y | x <-[1, 3 .. 10], y <- [2, 4]]

Krok 4: Doplnenie funkcií

Úloha 4.1

Doplňte deklaráciu ďalších exportovaných funkcií v module Minesweeper.Board:

    -- ** Properties
    , boardSize
    , boardNumMines
    , boardNumMarked
    , validPos
    , tileState
    , isExploded
    , isOpen
    , isMarked
    , isClosed
    , neighbours

Úloha 4.2

Doplňte implementáciu chýbajúcich funkcií:

  • isClosed – je poliťko na zadanej pozícií ešte zatvorené?
  • neighbours – zoznam pozícií okolitých políčok (s ošetrením okrajov hracieho poľa)
  • clue – číslo, ktoré sa zobrazí na políčku, ak je otvorené

Môžete použiť príklady definícií hodnôt a typové definície:

-- | Size of the board.
boardSize :: Board -> Pos
boardSize = configSize . bConfig

-- | Number of mines on the board.
boardNumMines :: Board -> Int
boardNumMines = configNumMines . bConfig

-- | Number of marked tiles on the board.
boardNumMarked :: Board -> Int
boardNumMarked = Set.size . bMarked

-- | Check if the position is within bounds of the board.
validPos :: Board -> Pos -> Bool
validPos board (Pos row col) =
    row >= 1 && row <= maxRow && col >= 1 && col <= maxCol
  where
    (Pos maxRow maxCol) = boardSize board

-- | Get state of the tile at specific position.
tileState :: Board -> Pos -> Tile
tileState board pos
    | isExploded board pos = Exploded
    | isOpen board pos     = Open (clue board pos)
    | isMarked board pos   = Marked
    | otherwise            = Closed

-- | Is tile on the position an opened mine?
isExploded :: Board -> Pos -> Bool
isExploded board pos = pos `member` bOpened board && pos `member` bMines board

-- | Is tile on the position open?
isOpen :: Board -> Pos -> Bool
isOpen board pos = pos `member` bOpened board && not (pos `member` bMines board)

-- | Is tile on the position marked?
isMarked :: Board -> Pos -> Bool
isMarked board pos = pos `member` bMarked board

-- | Is tile on the position closed?
isClosed :: Board -> Pos -> Bool
isClosed board pos = undefined

-- | Get positions of neighbour tiles of specified tile.
neighbours :: Board -> Pos -> [Pos]
neighbours = undefined

clue :: Board -> Pos -> Int
clue = undefined

Krok 5: I/O v zadaní

Posledná úloha je pomôckou pre implementáciu prvej časti posledného zadania. Preto už použijeme úplnú verziu projektu.

Úloha 5.1

Vyklonujte si kompletnú kostru zadnia z gitu:

git clone git@git.kpi.fei.tuke.sk:kpi/fp/project-minesweeper.git

Úloha 5.2

Implementujte modul Minesweeper.HumanSolver z projektu.

Hlavná funkcia humanSolver bude riešiť vstup a výstup. V prípade chybného vstupu tiež pomocou rekurzie zabezpečí opakované čítanie vstupu.

-- | Read a command from a user.
humanSolver :: Solver
humanSolver board = do
    putStrLn "Please enter your choice: <O A1> for open, <M A1> for mark"
    input <- getLine
    ...

Funkcia humanSolver sa dá implementovať rôznymi spôsobmi. Aby bolo možné zobraziť používateľovi presné hlasenie o tom, kde vo vstupe urobil chybu, odporúčame použiť typ Either na reprezentáciu výsledku funkcie alebo chyby. Teda môžeme spojiť v jednom riešení dva použitia monád: monádu IO pre realizáciu vstupu a výstupu, a monádu Either pre ošetrovanie chýb.

O spracovanie vstupu od používateľa sa bude starať samostatná funkcia parseInput. Ta dostane hraciu plochu (kvôli validácií ťahov) a reťazec zadaný používateľom. Jej výsledkom je buď zadaný ťah, alebo hlásenie o chybe.

parseInput :: Board -> String -> Either String Move

Samotná funkcia však iba normalizuje vstup a kontroluje, či nie je prázdny. O jednotlivé kroky spracovania sa budú starať pomocné funkcie, ktoré už nebudú priamo pracovať s IO:

-- Remove unnecessary spaces and normalize case.
normalizeInput :: String -> String

-- Parse whole command and return either a specific error string or the Move.
-- Expects nonempty normalized string.
parseMove :: Board -> String -> Either String Move

-- Parse beginning of the move: O (for open) or M (for mark).
-- Return either an error message or Move constructor.
parseCommand :: Char -> Either String (Pos -> Move)

-- Parse position
parsePosition :: String -> Either String Pos

Poznámka

Takéto rozdelenie funkcií je iba odporúčaním.

Zdroje

  1. A Gentle Introduction to Haskell: Modules
  2. Dokumentácia nástroja Cabal