Sveučilište u Zagrebu Fakultet elektrotehnike i računarstva PROGRAMIRANJE U HASKELLU Ak.god. 2011/12. LEKCIJA 13: ulazno/izlazne operacije 2 v1.0 (c) 2011 Jan Šnajder ============================================================================== > import Data.Char > import Data.List > import qualified Data.Map as M > import Control.Monad > import System.IO > import System.Directory > import System.IO.Error > import System.Environment > import System.FilePath > import System.Random > import Control.Exception (bracket) == ČITANJE PODATKOVNOG TOKA ================================================== Do sada smo podatke sa standardnog ulaza učitavali liniju o liniju (funkcija 'geLine'). Možemo također učitavati znak po znak (funkcija 'getChar'). Npr., program koji čita znakove sa standardnog ulaza, pretvara mala slova u velika i ispisuje ih na standardni izlaz: > main1 :: IO () > main1 = do > c <- getChar > putChar $ toUpper c > eof <- isEOF > if eof then return () else main1 Isto možemo napraviti liniju po liniju: > main2 :: IO () > main2 = do > c <- getLine > putStrLn $ map toUpper c > eof <- isEOF > if eof then return () else main2 U ovakvim situacijama, kada želimo učitati sve podatke sa standardnog ulaza (ili iz datoteke), bolje je raditi s PODATKOVNIM TOKOM (engl. stream). Podatkovni tok je zapravo podatak tipa 'String' u kojem je sadržan kompletan ulaz. No, budući da je Haskell lijen, neće se u string odmah učitati cijeli ulaz, već samo onoliko koliko je potrebno. Kako program bude zahtijevao nove elemente toka (nove znakove ili linije), tako će se ti elementi učitavati u string. S druge strane, kad program obradi neki element toka (znak ili liniju) i kad on više nije potreban, on će se izbrisati iz memorije (garbage collection). Za čitanje podatkovnog toka sa standardnog ulaza koristi se funkcija getContents :: IO String Npr., program koji učitava tekst sa standardnog ulaza i pretvara sva slova u velika slova: > main3 :: IO () > main3 = do > s <- getContents > putStr $ map toUpper s Ovo će, npr., učitati najviše prvih 10 znakova: > main4 :: IO () > main4 = do > s <- getContents > putStr . take 10 $ map toUpper s A ovo će učitati najviše prvih 10 linija: > main5 :: IO () > main5 = do > s <- getContents > putStr . unlines . take 10 . lines $ map toUpper s Funkcija koja čita standardni ulaz i na izlaz ispisuje sve linije koje nisu prazne: > main6 :: IO () > main6 = do > s <- getContents > putStr . unlines . filter (not . null) $ lines s Često se nalazimo u situaciji da trebamo učitati podatke sa standardnog ulaza, transformirati ih, i zatim ispisati na standardni izlaz. To možemo napraviti pomoću funkcije 'interact', koja je definirana ovako: interact :: (String -> String) -> IO () interact f = do s <- getContents putStr (f s) Npr., prethodne funkcije mogli smo definirati ovako: > main7 :: IO () > main7 = interact (map toUpper) > main8 :: IO () > main8 = interact (unlines . filter (not . null) . lines) == VJEŽBA 1 ================================================================== 1.1. - Napišite funkciju koja iz standardnog ulaza izbacuje svaku drugu liniju i ispisuje rezultat na standardni izlaz. filterOdd :: IO () 1.2. - Napišite funkciju numberLines :: IO () koja učitava standardni ulaz i linije prefiksira brojevima (broj + razmak). 1.3. - Napišite funkciju filterWords :: Set String -> IO () koja čita standardni ulaz i izbacuje iz njega sve riječi koje se nalaze u zadanom skupu riječi (tj. funkcija filtrira standardni ulaz). == RAD S DATOTEKAMA ========================================================== Funkcije koje smo dosada koristili su iz modula 'System.IO' i rade sa standardim ulazom i izlazom. U istom modulu postoje slične funkcije za rad s datotekama: hPutStr :: Handle -> String -> IO () hPutStrLn :: Handle -> String -> IO () hGetLine :: Handle -> IO String hGetChar :: Handle -> IO Char hGetContents :: Handle -> IO String Ove funkcije uzimaju ručicu (engl. handle) datotekom koji je tipa 'Handle'. Rukovatelj pohranjuje informacije o vrsti datoteke. Rukovatelj se dobiva otvaranjem datoteke: openFile :: FilePath -> IOMode -> IO Handle 'Filepath' je sinonim za 'String'. 'IOMode' je način otvaranja datoteke: data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode Datoteku je nakon uporabe potrebno zatvoriti pomoću funkcije hClose :: Handle -> IO () Npr., funkcija koja otvara zadanu datoteku i ispisuje na ekran njezin sadržaj: > cat1 :: FilePath -> IO () > cat1 f = do > h <- openFile f ReadMode > printLines h > hClose h > > printLines :: Handle -> IO () > printLines h = do > l <- hGetLine h > putStrLn l > eof <- hIsEOF h > if eof then return () else printLines h Pravi Haskellaš ovo će napisati mnogo ljepše pomoću podatkovnog toka: > cat2 :: FilePath -> IO () > cat2 f = do > h <- openFile f ReadMode > s <- hGetContents h > putStr s > hClose h Verzija koja ispisuje brojeve linija: > cat3 :: String -> IO () > cat3 f = do > h <- openFile f ReadMode > s <- hGetContents h > forM_ (zip [0..] (lines s)) $ \(i,l) -> > putStrLn $ show i ++ ": " ++ l > hClose h ili > cat4 :: String -> IO () > cat4 f = do > h <- openFile f ReadMode > s <- hGetContents h > putStr . unlines . zipWith (\i l -> show i ++ ": " ++ l) [0..] $ lines s > hClose h Ova situacija (otvaranje datoteke, obrada, zatvaranje) je učestala, pa za to postoji posebna funkcija: withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a Ova se funkcija ujedno brine o tome da se datoteka zatvori čak i ako dođe do pogreške. Ispis datoteke s brojevima linija: > cat5 :: String -> IO () > cat5 f = withFile f ReadMode $ \h -> do > s <- hGetContents h > putStr . unlines . zipWith (\i l -> show i ++ ": " ++ l) [0..] $ lines s Za standardni ulaz i standardni izlaz postoje već otvorene ručice (koje nije potrebno niti otvarati niti zatvarati): 'stdin', 'stdout' i 'stderr'. Vrijedi: getLine = hGetLine stdin putStr = hPutStr stdout putStrLn = hPutStrLn stdout getContents = hGetContents stdin ... Još neke važnije funkcije iz 'System.IO': hFileSize :: Handle -> IO Integer hSeek :: Handle -> SeekMode -> Integer -> IO () hTell :: Handle -> IO Integer hFlush :: Handle -> IO () hIsEOF :: Handle -> IO Bool == VJEŽBA 2 ================================================================== 2.1. - Napišite funkciju wc :: FilePath -> IO (Int, Int, Int) koja u zadanoj datoteci prebrojava broj znakova, riječi i linija. 2.2. - Napišite funkciju copyLines :: [Int] -> FilePath -> FilePath -> IO () koja kopira zadane linije iz prve datoteke u drugu. ============================================================================== Čitanje podatkovnog toka iz datoteke (pomoću 'hGetContents') i pisanje podatkovnog toka u datoteku (pomoću 'hPutStr') su učestale operacije, pa za to postoje posebne funkcije koje nam omogućavaju da to činimo jednostavnije, bez ručice i bez eksplicitnog otvaranja i zatvaranja datoteke: readFile :: FilePath -> IO String writeFile :: FilePath -> String -> IO () > cat6 :: String -> IO () > cat6 f = do > s <- readFile f > putStr . unlines . zipWith (\i l -> show i ++ ": " ++ l) [0..] $ lines s > uppercaseFile :: FilePath -> IO () > uppercaseFile f = do > s <- readFile f > putStr (map toUpper s) > interlaceFiles :: FilePath -> FilePath -> FilePath -> IO () > interlaceFiles f1 f2 f3 = do > s1 <- readFile f1 > s2 <- readFile f2 > writeFile f3 . unlines $ interlace (lines s1) (lines s2) > where interlace xs ys = concat $ zipWith (\x1 x2 -> [x1,x2]) xs ys Serijalizacija podatkovne strukture (zapisivanje podatkovne strukture na disk) i deserijalizacija (učitavanje podatkovne strukture s diska) mogu se vrlo jednostavno ostvariti pomoću 'readFile' odnosno 'writeFile', u kombinaciji s 'read' odnosno 'show'. Npr. serijalizacija liste: > main9 :: IO () > main9 = do > let l = [(x,y) | x <- [0..100], y <- [x..100]] > writeFile "lista.txt" $ show l Deserijalizacija: > main10 :: IO () > main10 = do > s <- readFile "lista.txt" > let l = read s :: [(Int,Int)] > print l Kao i inače kada koristimo 'read', treba dodatno specificirati tip te treba paziti da ono što se nalazi u stringu doista odgovara tom tipu (u suprotnom dobivamo pogrešku "no parse"). Primjer: imao rječnik implementiran kao 'Data.Map' koji pohranjujemo u datoteci. Preko tipkovnice postavljamo upite, a za riječi koje ne postoje možemo dodati prijevod. > type Dict = M.Map String String > dictFile = "dict.txt" > main11 :: IO () > main11 = do > d1 <- readDict > d2 <- useDict d1 > writeFile dictFile $ show d2 > readDict :: IO Dict > readDict = do > e <- doesFileExist dictFile > if e then do > s <- readFile dictFile > return $ read s > else return M.empty > useDict :: Dict -> IO Dict > useDict d = do > putStrLn "Unesi pojam: " > w1 <- getLine > if null w1 then return d else > case M.lookup w1 d of > Just w2 -> do > putStrLn w2 > useDict d > Nothing -> do > putStrLn $ "Nema unosa. Koji je prijevod za " ++ w1 ++ "?" > w2 <- getLine > useDict $ M.insert w1 w2 d Napomena: Inače nije dobra ideja otvoriti datoteku s 'readFile' i zatim pisati u tu istu datoteku s 'writeFile'. To je moguće samo ako je cijeli podatkovni tok konzumiran prije pisanja u datoteku. U prethodnom primjeru to se doista događa jer funkcija 'lookup' prisiljava da se učita cijeli rječnik. Međutim, ovo neće raditi: > main12 :: IO () > main12 = do > d1 <- readDict > writeFile "dict.txt" $ show d1 Postoje situacije (npr. izmjena postojeće datoteke) u kojima je potrebno stvoriti privremenu datoteku. Za to treba koristiti funkciju openTempFile :: FilePath -> String -> IO (FilePath, Handle) Funkcija uzima stazu i ime datoteke, a vraća jedinstveno ime privremene datoteke i ručicu. Nakon korištenja, datoteku je potrebno zatvoriti pomoću 'hClose'. Npr., funkcija koja alfabetski sortira linije u zadanoj datoteci: > sortFile :: FilePath -> IO () > sortFile f = do > (ft,ht) <- openTempFile "" f > s <- readFile f > hPutStr ht . unlines . sort $ lines s > hClose ht > renameFile ft f Napomena: U većini slučajeva bolje je izmijenjenu datoteku ispisati na standardni izlaz, nego mijenjati izvornu datoteku. Na taj način se korisniku omogućava da provjeri izlaz i da ga preusmjerava po želji. == VJEŽBA 3 ================================================================== 3.1. - Napišite funkciju wordTypes :: FilePath -> IO Int koja vraća broj različitih riječi u datoteci zadanog imena. 3.2. - Napišite funkciju diff :: FilePath -> FilePath -> IO () koja uzima dvije datoteke, uspoređuje odgovarajuće linije te na standardni izlaz ispisuje linije na kojima se datoteke razlikuju. Linije treba ispisati jednu ispod druge, prefiksirane s "< " za prvu datoteku i "> " za drugu datoteku. 3.3. - Napišite funkciju removeSpaces :: FilePath -> IO () koja u datoteci uklanja razmake na kraju retka (engl. trailing spaces). Funkcija izmjenjuje izvornu datoteku. == RUKOVANJE IZNIMKAMA ======================================================= IO-akcije (ali i čisto funkcijski izračuni) mogu rezultirati iznimkama (engl. exceptions). Iznimke je moguće obrađivati, ali samo unutar IO-monade. Za rukovanje iznimkama u Haskellu nema posebnih sintaktičkih konstrukcija, nego se one obrađuju funkcijama. Funkcije za obradu UI-iznimaka nalaze se u modulu 'System.IO.Error'. Za obradu iznimke koristimo funkcije: try :: IO a -> IO (Either IOError a) catch :: IO a -> (IOError -> IO a) -> IO a Funkcija 'try' uzima akciju 'IO a' i vraća 'IO (Right a)' ako nije došlo do iznimke, odnosno 'Left e' ako je došlo do iznimke. Npr. > cat7 :: String -> IO () > cat7 f = do > r <- try $ cat6 f > case r of > Left _ -> putStrLn "Neka pogreška" > _ -> return () Funkcija 'catch' uzima akciju koja može uzrokovati iznimku i drugu akciju koja obrađuje iznimku. Npr.: > cat8 :: String -> IO () > cat8 f = catch (cat6 f) $ \e -> > if isDoesNotExistError e then putStrLn "Pogreška: datoteka ne postoji" > else ioError e -- prosljeđivanje iznimke Funkcije 'try' i 'catch' omogućavaju da se odgovarajuće obradi iznimka i da se program oporavi od pogreške. Ako želimo samo "počistiti" nakon pogreške (npr. zatvoriti datoteke), koristimo sljedeće funkcije iz 'Control.Exception': bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c Funkcija 'bracket' uzima tri akcije: akcija 'IO a' zauzima resurs, akcija 'a -> IO b' ga otpušta, a akcija 'a -> IO c' je ona koja zapravo radi nešto s tim resursom. Ako dođe do iznimke, ipak će se izvesti akcija koja otpušta resurs, a iznimka će se proslijediti dalje. Tipično definiramo: bracket (openFile f ReadMode) hClose (\h-> do ...) Zapravo je funkcija 'withFile', koju smo ranije koristili, ovako definirana: withFile f mode = bracket (openFile f mode) hClose Npr. funkcija 'withTempFile': > withTempFile :: FilePath -> String -> ((FilePath, Handle) -> IO c) -> IO c > withTempFile path f = bracket > (openTempFile path f) > (\(f,h) -> do hClose h; removeFile f) Napomena: U modulu 'Control.Exception' definirane su općenite funkcije za obradu iznimaka (ne samo UI-iznimaka). Funkcije 'try' i 'catch' definirane su u oba modula, pa na to treba paziti kod importa. http://www.haskell.org/ghc/docs/latest/html/libraries/base/Control-Exception.html == VARIJABLE OKOLINE ========================================================= Programu se argumenti mogu zadati preko komandne linije. Za preuzimanje tih argumenata koriste se sljedeće funkcije iz modula 'System.Environment': getProgName :: IO String getArgs :: IO [String] > main13 :: IO () > main13 = do > xs <- getArgs > x <- getProgName > putStrLn $ "Program " ++ x ++ " pozvan je s argumentima " ++ show xs Rad ove funkcije može se isprobati iz interpretera korištenjem funkcije 'withArgs' ili naredbe ':main' (ali tada funkciju treba preimenovati u 'main'): withArgs ["arg1","arg2"] main13 :main arg1 arg2 Primjer: 'sort' koji otvara zadanu datoteku, ako postoji, a inače uzima ulaz sa standardnog ulaza. Sortirani ulaz se ispisuje na standardni izlaz: > main14 :: IO () > main14 = do > xs <- getArgs > h <- case xs of > (f:_) -> do e <- doesFileExist f > if e then openFile f ReadMode else return stdin > [] -> return stdin > s <- hGetContents h > putStr . unlines . sort $ lines s Napomena: Za sofisticiranu ("unixoidnu") obradu opcija predanih programu preko komandne linije preporuča se koristiti funkcije iz modula 'System.Console.GetOpt'. http://www.haskell.org/ghc/docs/latest/html/libraries/base/System-Console-GetOpt.html == VJEŽBA 4 ================================================================== 4.1. - Napišite funkciju fileHead :: IO () koja na standardni ispisuje prvih 'n' linija datoteke. Ime datoteke i broj linija preuzimaju se s komandne linije, npr.: filehead -5 input.txt Ako nedostaje broj linija, treba ih ispisati 10. Ako nedostaje ime datoteke, treba učitati standardni ulaz. Ako datoteka ne postoji, treba vratiti pogrešku. 4.2. - Napišite funkciju sortFiles :: IO () koja sortira linije iz više datoteka i ispisuje ih na standardni izlaz. Imena datoteka preuzimaju se s komandne linije. "sortFiles file1.txt file2.txt file3.txt" Ako neka od datoteka ne postoji, treba vratiti pogrešku. == FUNKCIJE ZA RAD S DATOTEČNIM SUSTAVOM ===================================== U modulu 'System.IO.Directory' nalaze se razne funkcije za rad s datotečnim sustavom. Neke od najvažnijih su: copyFile :: FilePath -> FilePath -> IO () createDirectory :: FilePath -> IO () doesDirectoryExist :: FilePath -> IO Bool doesFileExist :: FilePath -> IO Bool removeFile :: FilePath -> IO () renameFile :: FilePath -> FilePath -> IO () getDirectoryContents :: FilePath -> IO [FilePath] Funkcije za manipulaciju stazama datoteka nalaze se u modulu 'System.FilePath'. Neke od važnijih funkcija: () :: FilePath -> FilePath -> FilePath takeBaseName :: FilePath -> String takeDirectory :: FilePath -> FilePath takeExtension :: FilePath -> String takeFileName :: FilePath -> FilePath == GENERATOR SLUČAJNIH BROJEVA =============================================== Generiranje (pseudo)slučajnih brojeva u IO-monadi ostvaruje se funkcijama iz modula 'System.Random'. Osnovna (i najopćenitija) funkcija za generiranje slučajnih brojeva je random :: (RandomGen g, Random a) => g -> (a, g) 'Random' je tipski razred za sve tipove čije vrijednosti mogu biti slučajno generirane, a 'RandomGen' je tipski razred za sve moguće generatore slučajnih brojeva (to je zapravo sučelje za generator slučajnih brojeva). Uobičajeno se za generator slučajnih brojeva koristi standardni generator 'StdGen'. Tip 'StdGen' je instanca razreda 'RandomGen'. Za stvaranje standardnog generatora slučajnih brojeva koristimo funkciju mkStdGen :: Int -> StdGen koja uzima sjemensku vrijednost (engl. seed) i vraća generator slučajnih brojeva. > g = mkStdGen 13 > (r1,g2) = random g :: (Int, StdGen) > (r2,g3) = random g2 :: (Int, StdGen) > (r3,g4) = random g3 :: (Int, StdGen) randoms :: (RandomGen g, Random a) => g -> [a] > xs = randoms g :: [Float] > fiveCoins = take 5 $ randoms g :: [Bool] Za generiranje slučajnih brojeva u zadanom intervalu koristimo randomR :: (RandomGen g, Random a) => (a, a) -> g -> (a, g) randomRs :: (RandomGen g, Random a) => (a, a) -> g -> [a] > fiveDice = take 5 $ randomRs (1,6) g :: [Int] Očiti problem je što stalno moramo vući sa sobom zadnju instancu generatora slučajnih brojeva. Drugi problem je što krećemo s unaprijed definiranom sjemenskom vrijednošću. Oba ova problema nestaju ako generator slučajnih brojeva koristimo unutar IO-monade. Za stvaranje generatora unutar IO-monade koristimo funkciju getStdGen :: IO StdGen > main15 :: IO () > main15 = do > g <- getStdGen > putStrLn $ take 10 (randomRs ('a','z') g) > g2 <- getStdGen > putStrLn $ take 10 (randomRs ('a','z') g2) > main16 :: IO () > main16 = do > g <- getStdGen > putStrLn $ take 10 (randomRs ('a','z') g) > g2 <- newStdGen > putStrLn $ take 10 (randomRs ('a','z') g2) Kraće je: getStdRandom :: (StdGen -> (a, StdGen)) -> IO a > main17 :: IO () > main17 = do > x <- getStdRandom (randomR (0,100)) :: IO Int > print x Napomena: Za generiranje slučajnih brojeva možete koristiti i monadu Random iz modula 'Control.Monad.Random': http://hackage.haskell.org/packages/archive/MonadRandom/0.1.6/doc/html/Control-Monad-Random.html == VJEŽBA 5 ================================================================== 5.1. - Napišite svoju implementaciju za randoms' :: (RandomGen g, Random a) => g -> [a] 5.2. - Napišite funkciju randomPositions :: Int -> Int -> Int -> Int -> IO [(Int,Int)] koja vraća listu slučajno generiranih cjelobrojnih koordinata u zadanom rasponu. randomPositions 0 10 0 10 => [(2,1),(4,3),(7,7),...