Chương 9: Đầu vào và đầu ra

Trở về Mục lục cuốn sách

Chúng tôi đã nói rằng Haskell là ngôn ngữ lập trình hàm thuần túy. Nếu như trong ngôn ngữ lập trình mệnh lệnh bạn thường hoàn thành công việc bằng cách giao cho máy tính một loạt các bước để thực thi, thì lập trình hàm lại thiên về định nghĩa vấn đề cần giải quyết. Trong Haskell, một hàm không thể thay đổi trạng thái nào, như thay đổi nội dung của một biến (khi một hàm thay đổi trạng thái, ta nói rằng nó có hiệu ứng phụ). Điều duy nhất mà hàm có thể thực hiện trong Haskell là trả lại cho ta một kết quả nào đó dựa trên các tham số mà ta đưa cho nó. Nếu như hàm được gọi hai lần với cùng các tham số, thì nó sẽ phải trả lại cùng kết quả. Mặc dù điều này dường như bị hạn chế khi bạn đã từng lập trình theo thức mệnh lệnh, song ta đã thấy rằng thực ra đó lại là điều hay. Trong ngôn ngữ mệnh lệnh, không có đảm bảo gì với bạn rằng một hàm đơn giản mà dùng để tính những có số sẽ không gây hại làm cháy nhà bạn, bắt đi con cún của bạn và dùng củ khoai tây cào xước xe hơi của bạn trong quá trình tính toán. Chẳng hạn, khi chúng ta đang tạo lập một cây tìm kiếm nhị phân, ta không gài phần tử vào cây bằng cách sửa đổi cây ngay tại chỗ. Hàm ta viết nhằm gài phần tử vào cây tìm kiếm nhị phân thực ra sẽ trả lại một cây mới, vì nó không thể thay đổi cây ban đầu được.

poor dog

Mặc dù các hàm không thể thay đổi trạng thái là điều hay vì nó giúp ta suy luận về chương trình được viết, nhưng có một vấn đề với điều này. Nếu một hàm không thể thay đổi thứ gì trong môi trường lập trình thì làm sao mà nó báo cho ta nó vừa tính được gì? Để báo cho ta biết thứ tính được, hàm phải thay đổi trạng thái của một thiết bị đầu ra (thường là trạng thái của màn hình), và rồi phát ra các photon truyền đến não của chúng ta và thay đổi trạng thái của hệ thần kinh của ta, bạn ạ.

Xin đừng thất vọng, chúng ta chưa mất hết. Hóa ra Haskell là một hệ thống khéo léo khi xử lý các hàm; nó có hiệu ứng phụ được phân biệt rõ nét phần của chương trình thuần túy [theo phong cách lập trình hàm] với phần chương trình không thuần túy, tức là chứa các tàn dư của việc trao đổi thông tin với bàn phím và màn hình. Với hai phần đó phân biệt rõ, ta vẫn có thể suy luận trong phạm vi phần chương trình thuần khiết và lợi dụng tất cả những đặc điểm có trong lập trình hàm thuần túy, như tính lười biếng, tính mạnh và tính độc lập giữa các bộ phận (module) trong khi vẫn trao đổi thông tin hiệu quả với môi trường bên ngoài.

Hello, world!

HELLO!

Cho đến giờ, chúng ta luôn nạp các hàm vào GHCI để kiểm tra và nghịch chơi. Chúng ta cũng khám phá các hàm trong thư viện chuẩn bằng cách đó. Nhưng bây giờ, sau 8 chương gì đó, cuối cùng chúng ta cũng bắt tay vào viết chương trình Haskell đầu tiên theo đúng nghĩa! Hoan hô! Và chắc chắn rồi, ta sẽ viết chương trình truyền thống "hello, world".

Ê này! Để phục vụ mục đích của chương này, tôi sẽ giả dụ rằng bạn đang dùng một môi trường tựa Unix để học Haskell. Nếu bạn đang dùng Windows, theo tôi thì bạn nên tải về Cygwin, đây là một môi trường tựa Linux chạy trong Windows, tức là chính thứ mà bạn đang cần.

Và bây giờ, những bạn mới học, hay gõ dòng lệnh sau đây vào trong trình soạn thảo văn bản chữ ưa thích của bạn:

main = putStrLn "hello, world"

Ta vừa định nghĩa một cái tên gọi là main và trong đó ta gọi một hàm tên là putStrLn với tham số "hello, world". Nhìn ngon ơ, nhưng không phải vậy, như ta sẽ thấy ngay đây. Hãy lưu tập tin này với tên helloworld.hs.

Và bây giờ, ta sắp sửa làm một việc mà trước đây ta chưa từng làm. Chúng ta chuẩn bị biên dịch chương trình vừa viết! Tôi vui biết bao! Hãy mở cửa sổ dòng lệnh và chuyển đến thư mục chứa tập tin helloworld.hs rồi gõ vào dòng lệnh:

$ ghc --make helloworld
[1 of 1] Compiling Main             ( helloworld.hs, helloworld.o )
Linking helloworld ...

Được rồi! Chẳng cần đến may mắn thì bạn cũng nhận được kết quả trên và bây giờ bạn có thể chạy chương trình bằng cách gõ ./helloworld.

$ ./helloworld
hello, world

Và như vậy là chương trình chương trình đầu tiên ta viết, qua biên dịch, đã in ra dòng chữ lên cửa sổ lệnh. Thật là chán!

Ta hãy xem xét mình vừa viết những gì. Trước hết, hãy xem kiểu của hàm putStrLn.

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello, world"
putStrLn "hello, world" :: IO ()

Ta có thể đọc kiểu của putStrLn như thế này: putStrLn nhận vào một chuỗi rồi trả lại một thao tác I/O với kiểu kết quả là () (nghĩa là một bộ rỗng, còn gọi là “đơn vị”). Một thao tác I/O là thứ khi được thực hiện, sẽ tiến hành một hoạt động mang hiệu ứng phụ (đó thường hoặc là đọc từ thiết bị đầu vào [bàn phím] hoặc là in ra màn hình) và cũng sẽ chứa trong nó một giá trị trả về nào đó. Việc in một chuỗi ra màn hình thực ra không có bất kì giá trị trả về nòa có ý nghĩa cả, vì vậy một giá trị giả là () được dùng đến.

Bộ rỗng có một giá trị () và nó cũng có kiểu ().

Như thế thì khi nào một thao tác I/O sẽ được thực hiện? À, như vậy đã đên lúc chúng tôi giới thiệu main. Một thao tác I/O sẽ được thực hiện khi ta cho nó cái tên main rồi chạy chương trình.

Việc có chương trình của bạn chỉ là một thao tác I/O dường như thật là hoạn chế. Đó là lý do ta có thể dùng cú pháp do để đính liền nhiều thao tác I/O lại thành một. Hãy xem ví dụ dưới đây:

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

A, hay thật, cú pháp mới! Và cách viết này đọc rất giống với chương trình viết theo thức mệnh lệnh. Nếu bạn biên dịch nó và thử chạy, có thể nó sẽ biểu hiện giống như bạn trông đợi. Lưu ý rằng ta ta nói do rồi tiếp theo là một loạt các bước thực hiện, như thể chúng ta viết một chương trình theo thức mệnh lệnh. Mỗi bước trong số này là một thao tác I/O. Bằng cách xếp chúng lại với nhau dùng cú pháp do, ta đã dính liền chúng thành một thao tác I/O. Thao tác mà ta thu được có kiểu là IO (), vì đó là kiểu của thao tác I/O cuối cùng nằm trong đó.

Vì lý do trên, main luôn có dấu ấn kiểu là main :: IO something, trong đó something là một kiểu cụ thể nào đó. Theo thông lệ, ta thường không viết một lời khai báo kiểu cụ thể cho main.

Một điều hay mà trước đây ta chưa từng gặp là ở dòng thứ ba, tức là name <- getLine. Trông như nó đọc vào một dòng từ đầu vào rồi lưu trữ dòng này vào một biến gọi là name. Có thực vậy không? Ồ, ta hãy kiểm tra kiểu của getLine.

ghci> :t getLine
getLine :: IO String

luggage

À ha, được rồi. getLine là một thao tác I/O có chứa kết quả thuộc kiểu String. Điều này có lý, vì nó sẽ đợi ngươi dùng nhập vào thứ gì đó từ bán phím rồi sau đó một thứ khác sẽ được biểu diễn bằng một chuỗi. Thế thì name <- getLine nghĩa là gì? Bạn có thể đọc đoạn mã đó như sau: thực hiện thao tác I/O getLine rồi gắn kết quả tìm được với name. getLine có kiểu là IO String, vì vậy name sẽ phải có kiểu là String. Bạn có thể hình dung thao tác I/O như một cái hộp bước trên đôi bàn chân nhỏ nhắn ra ngoài thế giới và thực hiện việc gì ở đó (như vẽ graffiti lên tường) rồi có thể sẽ mang theo dữ liệu gì đó lúc trở về. Một khi nó mang dữ liệu đó về cho bạn, thì cách duy nhất để mở hộp lấy dữ liệu bên trong đó là dùng cấu trúc <-. Và nếu ta đang nói tới dữ liệu từ thao tác I/O, thì ta chỉ có thể lấy ra khi ta đang ở trong một thao tác I/O khác. Đây là cách mà Haskell cố gắng phân biệt một cách rõ rệt giữa các phần thuần túy và không thuần túy trong mã lệnh ta viết. getLine trên khía cạnh nào đó là không thuần túy vì giá trị kết quả của nó không lấy gì đảm bảo được sẽ như nhau khi thực hiện hai lần. Đó là lý do mà nó bị constructor kiểu IO lây nhiễm và ta chỉ có thể lấy được giá trị đó ra trong mã lệnh I/O. Và vì mã lệnh I/O cũng bị lây nhiễm, bất kì phép tính nào phụ thuộc vào dữ liệu lây nhiễm I/O cũng sẽ có kết quả lây nhiễm.

Khi nói lây nhiễm, tôi không có ý nói lây nhiễm đến mức ta không bao giờ có thể dùng kết quả chứa trong một thao tác I/O vào lai một đoạn mã thuần túy. Không, tạm thời chúng ta ngăn không lây nhiễm dữ liệu bên trong của thao tác I/O khi gắn nó với một cái tên. Khi viết name <- getLine, thì name chỉ là một chuỗi bình thường, vì nó biểu diễn cho những gì có trong hộp. Ta có thể có một hàm thực sự phức tạp để, chắng hạn như nhận vào họ tên của bạn (dưới dạng chuỗi) làm tham số và rồi báo cho bạn biết hậu vận dựa vào họ tên vừa nhập vào. Ta có thể viết như sau:

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn $ "Read this carefully, because this is your future: " ++ tellFortune name

tellFortune (hoặc bất kì hàm nào truyền name đến) không buộc phải biết điều gì về I/O, đó chỉ là một hàm String -> String thông thường!

Hãy xem đoạn mã sau. Nó có hợp lệ không?

nameTag = "Hello, my name is " ++ getLine

Nếu bạn nói không, hãy tự thưởng cho mình một miếng bánh. Nếu nói có, hãy rót nước sôi ra uống! Ấy chết, đùa thôi, đừng uống! Lý do mà đoạn mã trên không hoạt động là vì ++ yêu cầu cả hai tham số của nó đều là danh sách chứa cùng kiểu. Tham số bên trái có kiểu là String (hay [Char] nếu bạn thích viết như vậy), còn getLine thì có kiểu IO String. Bạn không thể kết nối một chuỗi với một thao tác I/O. Trước hết ta phải lôi kết quả ra khỏi thao tác I/O để nhận được giá trị có kiểu String và cách duy nhất để làm được điều này là viết một dòng lệnh như name <- getLine bên trong một thao tác I/O nào đó khác. Nếu ta muốn xử lý dữ liệu không thuần túy, ta phải thực hiện trong môi trường không thuần túy. Như vậy sự lây nhiễm “tạp chất” không thuần túy cũng như bệnh tật lây lan và tốt nhất là ta giữ cho phần I/O càng gọn càng tốt trong mã lệnh được viết ra.

Mỗi thao tác I/O được thực hiện đều chứa kết quả trong đó. Vì vậy, chương trình ví dụ ở trên cũng có thể được viết lại như sau:

main = do
    foo <- putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

Tuy nhiên, foo sẽ chỉ có một giá trị (), vì vậy việc làm này chẳng có ý nghĩa gì. Lưu ý rằng ta không gắn putStrLn sau cùng với thứ gì. Đó là bởi trong khối lệnh do, hành động cuối cùng không thể gắn được vào một tên gọi như hai hành động trước đó. Sau này ta sẽ thấy được một cách chính xác lý do tại sao, khi ta rong ruổi đến thế giới các monad. Còn bây giờ, bạn có thể hình dung nó là khối do tự kết xuất giá trị từ hành động cuối cùng và gắn giá trị này vào kết quả của bản thân nó.

Ngoại trừ dòng cuối cùng, mỗi dòng trong khối do nếu không được gắn thì cũng có thể viết lại dưới hình thức gắn. Vì vậy putStrLn "BLAH" có thể được viết thành _ <- putStrLn "BLAH". Nhưng việc làm này là vô ích, vì vậy ta sẽ bỏ hết các dấu <- ở những thao tác I/O nào không chứa kết quả quan trọng, như putStrLn something.

Những người mới học đôi khi nghĩ rằng viết mã lệnh

name = getLine

sẽ có tác dụng đọc từ đầu vào rồi gắn giá trị đọc được vào name. À, sẽ không được đâu, việc làm vừa rồi chỉ cho thao tác I/O getLine một tên mới, gọi là name. Hãy nhớ rằng, để lấy được giá trị ra khỏi một thao tác I/O, bạn cần phải thực hiện nó bên trong một thao tác I/O khác bằng cách gắn nó vào với một cái tên bằng <-.

Thao tác I/O sẽ chỉ được thực hiện khi ta đặt tên cho chúng là main hoặc khi chúng nằm trong một thao tác I/O lớn hơn mà ta đã viết bằng một khối lệnh do. Ta cũng có thể dùng một khối lệnh do để gắn một vài thao tác I/O rồi sau đó có thể dùng thao tác I/O đó trong khối do khác, và cứ như vậy. Bằng cách nào đi nữa, thao tác I/O sẽ được thực hiện chỉ khi rốt cuộc chúng nằm trong main.

A, đúng rồi, cũng có một trường hợp nữa mà thao tác I/O sẽ được thực hiện. Khi ta gõ lên một thao tác I/O trong GHCI rồi ấn Enter, nó sẽ được thực hiện.

ghci> putStrLn "HEEY"
HEEY

Ngay cả khi ta mới nhập vào một con số hoặc gọi một hàm trong GHCI rồi ấn Enter, Haskell sẽ định lượng nó (ở mức độ thích hợp) rồi gọi show với kết quả tìm được, sau đó in chuỗi này lên màn hình bằng putStrLn; tất cả công việc đều được ngầm thực hiện.

Bạn còn nhớ phép gắn bằng let chứ? Nếu quên rồi, bạn có thể ôn lại bằng cách đọc mục này. Chúng phải có hình thức let bindings in expression, trong đó bindings là các tên gọi được đưa đến biểu thức còn expression là biểu thức cần định lượng có chứa các tên này. Ta nói cũng rằng trong dạng gộp danh sách, phần in là không cần thiết. À, bạn có thể dùng chúng trong khối do giống như cách dùng chúng trong dạng gộp danh sách. Hãy kiểm tra đoạn mã lệnh này:

import Data.Char

main = do
    putStrLn "What's your first name?"
    firstName <- getLine
    putStrLn "What's your last name?"
    lastName <- getLine
    let bigFirstName = map toUpper firstName
        bigLastName = map toUpper lastName
    putStrLn $ "hey " ++ bigFirstName ++ " " ++ bigLastName ++ ", how are you?"

Bạn đã thấy các thao tác I/O trong khối do xếp hàng ra sao chưa? Cũng lưu ý cách mà let xếp hàng cùng những thao tác I/O khác và các tên trong let hếp hàng cùng nhau? Đó là thói quen lập trình tốt, vì dóng hàng (viết thụt đầu dòng) rất quan trọng trong Haskell. Bây giờ, ta đã viết map toUpper firstName, để chuyển những thứ như "John" thành chuỗi đẹp hơn như "JOHN". Ta gắn chuỗi viết in đó vào một ên gọi rồi sau này sẽ dùng nó vào trong chuỗi được in ra màn hình.

Có thể bạn sẽ tự hỏi rằng khi nào nên dùng <- và khi nào thì dùng phép gắn let? Ồ, hãy nhớ rằng (cho đến giờ) <- là để thực hiện những thao tác I/O và gắn kết quả của chúng vào các tên gọi. Tuy vậy, map toUpper firstName, lại không phải là thao tác I/O. Nó là biểu thức thuần túy trong Haskell. Vì vậy hãy dùng <- khi bạn muốn gắn kết quả của thao tác I/O vào với tên gọi và bạn có thể dùng cách gắn let để gắn những biểu thức thuần túy vào với tên gọi. Nếu ta đã viết dòng lệnh như let firstName = getLine, khi đó ta sẽ chỉ đặt cho thao tác I/O getLine một tên gọi khác mà vẫn phải thực hiện nó bằng một cú pháp <-.

Bây giờ ta sẽ viết một chương trình để liên tục đọc vào dòng chữ và in dòng đó ra màn hình nhưng các từ được đảo ngược lại. Chương trình sẽ chấm dứt khi người dùng nhập vào một dòng trống. Sau đây là chương trình:

main = do 
    line <- getLine
    if null line
        then return ()
        else do
            putStrLn $ reverseWords line
            main

reverseWords :: String -> String
reverseWords = unwords . map reverse . words

Để hình dung xem chương trình này làm việc gì, bạn có thể chạy nó trước khi xem xét mã lệnh.

Mẹo: Để chạy một chương trình, bạn có thể biên dịch nó rồi chạy tập tin thực thi bằng cách gõ ghc --make helloworld, tiếp theo là ./helloworld; hoặc bạn cũng có thể dùng lệnh runhaskell như sau: runhaskell helloworld.hs và chương trình của bạn sẽ được thực thi một cách trực tiếp.

Đầu tiên, ta hãy xem hàm reverseWords. Đó chỉ là một hàm thông thường nhận vào một chuỗi như "hey there man" rồi gọi words với chuỗi này để tạo ra một danh sách các từ như ["hey","there","man"]. Sau đó ta ánh xạ reverse lên danh sách để nhận được ["yeh","ereht","nam"] rồi đặt danh sách đó trở lại thành một chuỗi bằng cách dùng unwords và kết quả cuối cùng sẽ là "yeh ereht nam". Bạn đã thấy cách dùng hàm hợp ở đây. Nếu không có hàm hợp, ta đã phải viết lệnh kiểu như reverseWords st = unwords (map reverse (words st)).

Thế còn main thì sao? Trước hết, ta lấy một dòng từ bàn phím bằng cách thực hiện getLine; gọi dòng đó là line. Và bây giờ, ta có một biểu thức điều kiện. Hãy nhớ rằng trong Haskell, mỗi if phải có một else tương ứng vì mỗi biểu thức phải có một kiểu giá trị nào đó. Ta khiến if hoạt động sao cho khi điều kiện là đúng (trong trường hợp này, đó là dòng nhập vào là dòng trống), ta sẽ thực hiện một thao tác I/O và nếu không, thao tác I/O trong vế else được thực hiện. Đó là lý do mà trong khối I/O do, biểu thức if cần có hình thức if điều kiện then thao tác I/O else thao tác I/O.

Trước hết, ta hãy xem điều gì xảy ra bên trong vế else. Bởi vì ta chỉ được phép có đúng một thao tác I/O theo sau else, nên ta dùng một khối do để nhóm hai thao tác I/O này làm một. Bạn cũng có thể viết phần mã lệnh đó thành:

        else (do
            putStrLn $ reverseWords line
            main)

Điều này khiến cho mọi việc rõ ràng hơn khi khối do block có thể được xem như một thao tác I/O, nhưng trông cũng xấu hơn. Dù sao, bên trong khối do block, ta gọi reverseWords đối với dòng đã nhận được từ getLine rồi in nó ra màn hình. Sau đó, ta chỉ việc thực hiện main. Nó được gọi một cách đệ quy và điều này hợp lệ, vì bản thân main là một thao tác I/O. Bằng cách này ta quay lại điểm đầu chương trình.

Bây giờ chuyện gì sẽ xảy ra khi null line còn đúng? Khi đó những gì đứng sau then sẽ được thực hiện. Nếu ta nhìn lên phía trên, ta sẽ thấy mã lệnh viết then return (). Nếu bạn đã lập trình bằng các ngôn ngữ mệnh lệnh như C, Java hoặc Python, có thể bạn sẽ nghĩ rằng bạn biết cái return này để làm gì và có thể bạn sẽ bỏ không đọc đoạn văn dài này nữa. Nhưng sự thực là thế này: cái return trong Haskell không có gì giống với return trong đa số các ngôn ngữ khác! Dù có cùng tên gọi, và điều này làm bao người nhầm lẫn, nhưng hai cái return khác hẳn nhau. Trong ngôn ngữ mệnh lệnh, return thường kết thúc việc thực thi một phương thức hay chương trình con và khiến nó báo lại một giá trị nào đó cho phần chương trình gọi phương thức/chương trình con này. Trong Haskell (cụ thể là trong thao tác I/O), return tạo ra một thao tác I/O từ một giá trị thuần túy. Nếu bạn hình dung đến “cái hộp” trước đây ta đã đề cập, thì nó lấy một giá trị và bọc lại thành cái hộp. Thao tác I/O thu được thực ra chẳng làm gì, nó chỉ có giá trị chứa trong đó là kết quả. Vì vậy theo cách hiểu I/O thì return "haha" sẽ có kiểu là IO String. Vậy đâu là mục đích của việc chỉ chuyển từ giá trị thuần túy sang một thao tác I/O không làm gì cả? Việc gì phải làm lây nhiễm chương trình được viết bằng IO trong khi không cần thiết? Ồ, ta cần một thao tác I/O nào đó để thực hiện trong trường hợp dòng chữ nhập vào là trống. Đó là lý do ta chỉ lập nên thao tác I/O không làm gì bằng cách viết return ().

Việc dùng return không kết thúc việc thực thi khối do I/O. Chẳng hạn, chương trình sau sẽ vui vẻ thực hiện hết đến dòng cuối cùng:

main = do
    return ()
    return "HAHAHA"
    line <- getLine
    return "BLAH BLAH BLAH"
    return 4
    putStrLn line

Tất cả những việc mà các return này thực hiện là chúng tạo ra những thao tác I/O mà bản thân không làm gì cụ thể ngoại trừ việc đựng một kết quả trong đó rồi kết quả đó sẽ bị vứt đi vì nó không được gắn vào với tên gọi nào. Ta có thể dùng return kết hợp với <- để gắn các thứ vào với tên gọi.

main = do
    a <- return "hell"
    b <- return "yeah!"
    putStrLn $ a ++ " " ++ b

Như bạn đã thấy, return giống như thứ đối lập với <-. Nếu như return nhận vào một giá trị rồi đựng nó trong một cái hộp thì <- lấy cái hộp (đồng thời thao tác với hộp đó) rồi lấy kết quả ra khỏi hộp, gắn kết quả này vào một tên gọi. Nhưng làm việc này là thừa, đặc biệt là do bạn có thể dùng cách gắn let trong các khối do để gắn với các tên gọi, như:

main = do
    let a = "hell"
        b = "yeah"
    putStrLn $ a ++ " " ++ b

Khi xử lý các khối do của I/O, ta thường dùng return hoặc là vì cần tạo ra thao tác I/O không thực sự làm điều gì, hoặc là ta không muốn thao tác I/O tạo nên từ khối do có được kết quả từ hành động cuối cùng của nó, mà ta muốn nó có một giá trị kết quả khác, vì vậy ta dùng return để làm cho một thao tác I/O luôn có được kết quả mong muốn chứa trong đó và ta sẽ đặt nó ở cuối cùng.

Một khối do cũng có thể chỉ có một thao tác I/O. Trong trường hợp này cũng giống như việc chỉ viết mỗi thao tác I/O. Có người ưa viết then do return () trong trường hợp này vì else cũng có một do.

Trước khi chuyển sang phần tập tin, ta hãy xét một số hàm hữu dụng khi xử lý I/O.

putStr rất giống với putStrLn ở chỗ nó nhận tham số là một chuỗi rồi trả lại một thao tác I/O để in chuỗi đó lên màn hình, chỉ khác là putStr không nhảy sang một dòng mới sau khi in chuỗi, còn putStrLn thì có.

main = do   putStr "Hey, "
            putStr "I'm "
            putStrLn "Andy!"
$ runhaskell putstr_test.hs
Hey, I'm Andy!

Dấu ấn kiểu của nó là putStr :: String -> IO (), vì vậy kết qủa gói trong thao tác I/O thu được sẽ là “đơn vị” (unit). Một thứ vô dụng, vì vậy bạn không cần phải gắn giá trị thu được này.

putChar nhận một kí tự rồi trả lại một thao tác I/O để in nó ra màn hình.

main = do   putChar 't'
            putChar 'e'
            putChar 'h'
$ runhaskell putchar_test.hs
teh

putStr thực ra đã được định nghĩa một cách đệ quy với sự giúp đỡ từ phía putChar. Điều kiện biên của putStr la chuỗi rỗng, vì vậy nếu ta in ra một chuỗi rỗng, thì hàm này sẽ chỉ việc trả lại một thao tác I/O không làm việc gì bằng cách return (). Nếu chuỗi đó không rỗng, thì nó in ra kí tự thứ nhất của chuỗi bằng putChar rồi in những kí tự còn lại của chuỗi bằng putStr.

putStr :: String -> IO ()
putStr [] = return ()
putStr (x:xs) = do
    putChar x
    putStr xs

Hãy xem cách mà ta có thể dùng đệ quy trong I/O, cũng như cách dùng nó trong mã lệnh thuần túy: ta định nghĩa trường hợp biên rồi hình dung xem kết quả khi đó là gì. Trường hợp biên này là một thao tác mà trước hết xuất kí tự đầu tiên rồi xuất phần còn lại của chuỗi.

print nhận vào một giá trị có kiểu bất kì miễn là thực thể của lớp Show (nghĩa là ta biết cách biểu diễn giá trị này dưới dạng chuỗi), gọi show đối với giá trị đó để biến nó thành rồi đưa chuỗi đó ra màn hình. Về cơ bản, đó chỉ là putStrLn . show. Đầu tiên, nó chạy show đối với một giá trị rồi đem kết quả đó đưa vào putStrLn, để trả lại một thao tác I/O nhằm in ra giá trị mong muốn.

main = do   print True
            print 2
            print "haha"
            print 3.2
            print [3,4,3]
$ runhaskell print_test.hs
True
2
"haha"
3.2
[3,4,3]

Bạn thấy đó, print là một hàm rất hữu ích. Bạn có nhớ rằng ta đã nói rằng thao tác I/O chỉ thực hiện khi chúng rơi vào trong main hoặc khi ta cố gắng định lượng chúng từ dấ nhắc GHCI chứ? Khi ta gõ vào một giá trị (như 3 hoặc [1,2,3]) rồi ấn Enter, thực ra GHCI sẽ dùng print đối với giá trị này để hiển thị nó ra màn hình!

ghci> 3
3
ghci> print 3
3
ghci> map (++"!") ["hey","ho","woo"]
["hey!","ho!","woo!"]
ghci> print (map (++"!") ["hey","ho","woo"])
["hey!","ho!","woo!"]

Khi muốn in ra các chuỗi, ta thường dùng putStrLn vì ta không muốn có cặp dấu nháy bao quanh chuỗi đó, nhưng để in các giá trị có kiểu khác ra màn hình thì print là thông dụng nhất.

getChar là một thao tác I/O để đọc vào một kí tự từ thiết bị đầu vào. Vì vậy, dấu ấn kiểu của nó là getChar :: IO Char, vì kết quả chứa trong thao tác I/O là một Char. Lưu ý rằng do bộ đệm mà việc đọc kí tự sẽ không xảy ra cho đến khi người dùng gõ phím Enter.

main = do   
    c <- getChar
    if c /= ' '
        then do
            putChar c
            main
        else return ()

Chương trình này trông như là để đọc vào một kí tự rồi kiểm tra xem đó có phải là dấu cách hay không. Nếu đúng thì ngừng chương trình và nếu sai thì in kí tự đó ra màn hình rồi lặp lại công đoạn nói trên. À, về chừng mực nào thì nó cũng hoạt động đây, nhưng không như bạn trông đợi. Hãy kiểm tra này:

$ runhaskell getchar_test.hs
hello sir
hello

Dòng thứ hai là những gì gõ vào. Ta gõ hello sir rồi ấn Enter. Do bộ đệm mà việc thực thi chương trình sẽ chỉ bắt đầu sau khi ta ấn Enter rồi chứ không phải sau mỗi kí tự nhập vào. Nhưng một khi gõ Enter thì chương trình chạy như thể ta đã gõ toàn bộ vào rồi vậy. Hãy thử nghịch chơi chương trình này để có cảm nhận về cách hoạt động của nó!

Hàm when có trong Control.Monad (để truy cập đến nó, hãy viết import Control.Monad). Hàm này rất thú vị bởi trong một khối do nó trông giống như một lệnh định hướng thực hiện chương trình, song thực ra nó là một hàm thông thường. Hàm nhận vào một giá trị boole cùng một thao tác I/O; nếu giá trị boole đó bằng True, nó sẽ trả lại chính thao tác I/O mà ta vừa cung cấp. Tuy nhiên, nếu giá trị boole bằng False, thì hàm sẽ trả lại thao tác return (), tức là một thao tác I/O không làm việc gì cả. Sau đây là cách viết lại đoạn mã lệnh trên mà ta đã giới thiệu getChar, nhưng lần này có dùng when:

import Control.Monad 

main = do
    c <- getChar
    when (c /= ' ') $ do
        putChar c
        main

Bạn thấy đó, hàm when rất hữu ích trong việc gói mẫu lệnh if thứ nào đó then do thao tác I/O nào đó else return ().

sequence nhận vào một danh sách những thao tác I/O rồi trả lại một thao tác I/O nhằm thực hiện lần lượt các thao tác đó. Kết quả chứa trong thao tác I/O này sẽ là một danh sách những kết quả của tất cả các thao tác I/O được thực hiện. Dấu ấn kiểu của nó là sequence :: [IO a] -> IO [a]. Việc viết:

main = do
    a <- getLine
    b <- getLine
    c <- getLine
    print [a,b,c]

cũng có tác dụng giống như viết:

main = do
    rs <- sequence [getLine, getLine, getLine]
    print rs

Như vậy sequence [getLine, getLine, getLine] tạo ra một thao tác I/O để thực hiện getLine ba lần. Nếu ta gắn thao tác đó vào một tên gọi, thì kết quả sẽ là một danh sách gồm tất cả những kết quả, và trong trường hợp này, là một danh sách gồm ba thứ mà người dùng nhập vào từ dấu nhắc.

Một mẫu thông dụng với sequence là khi ta ánh xạ một hàm như print hoặc putStrLn lên các danh sách. Việc viết map print [1,2,3,4] sẽ không tạo ra thao tác I/O. Nó sẽ tạo ra một danh sách các thao tác I/O, vì như thế cũng giống với viết [print 1, print 2, print 3, print 4]. Nếu ta muốn chuyển đổi danh sách những thao tác I/O thành một thao tác I/O thôi, thì ta phải dùng sequence với nó.

ghci> sequence (map print [1,2,3,4,5])
1
2
3
4
5
[(),(),(),(),()]

Cái [(),(),(),(),()] ở cuối là gì vậy? À, khi ta định lượng một thao tác I/O trong GHCI, nó sẽ được thực hiện rồi kết quả được in ra, trừ khi kết quả là (), trong trường hợp này thì không được in ra. Điều này lý giải tại sao việc định lượng putStrLn "hehe" trong GHCI chỉ in ra hehe (vì kết quả chứa trong putStrLn "hehe"()). Nhưng khi ta viết getLine trong GHCI, kết quả của thao tác I/O đó sẽ được in ra, vì getLine có kiểu là IO String.

Vì việc ánh xạ một hàm trả về thao tác I/O lên một danh sách rồi dùng sequence để xâu chuỗi với nó là rất thông dụng, nên để thuận tiện, các hàm mapMmapM_ đã được đưa vào. mapM nhận một hàm và một danh sách, rồi ánh xạ hàm lên danh sách sau đó xâu chuỗi nó. mapM_ cũng làm như vậy, nhưng sau đó thì vứt bỏ kết quả. Ta thường dùng mapM_ khi không quan tâm đến kết quả của của thao tác I/O sau khi xâu chuỗi là gì.

ghci> mapM print [1,2,3]
1
2
3
[(),(),()]
ghci> mapM_ print [1,2,3]
1
2
3

forever nhận vào một thao tác I/O rồi trả về một thao tác I/O chỉ bằng cách lặp lại vĩnh viễn thao tác I/O đó. Hàm này nằm trong Control.Monad. Chương trình nhỏ sau đây sẽ mãi yêu cầu người dùng nhập vào dữ liệu rồi bắn trở lại những dữ liệu đó, VIẾT IN HOA:

import Control.Monad
import Data.Char

main = forever $ do
    putStr "Give me some input: "
    l <- getLine
    putStrLn $ map toUpper l

forM (nằm trong Control.Monad) cũng giống như mapM, chỉ khác ở chỗ nó có tham số đảo ngược đi. Tham số thứ nhất là danh sách còn tham số thứ hai là một hàm ánh xạ lên danh sách, vốn sau đó được xâu chuỗi. Như vậy thì có ích gì chứ? À, với cách khéo léo sử dụng lambda và do, ta có thể thực hiện những việc như sau:

import Control.Monad

main = do 
    colors <- forM [1,2,3,4] (\a -> do
        putStrLn $ "Which color do you associate with the number " ++ show a ++ "?"
        color <- getLine
        return color)
    putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
    mapM putStrLn colors

Cụm (\a -> do ... ) là một hàm nhận vào một con rồi trả lại một thao tác I/O. Ta phải bao bọc nó trong cặp ngoặc tròn, vì nếu không thì lambda sẽ nghĩ rằng hai thao tác I/O sau cùng cũng thuộc về nó. Lưu ý rằng ta đã viết return color bên trong khối do. Ta đã làm như vậy để cho thao tác I/O mà do định nghĩa sẽ chứa trong nó kết quả của màu nhập vào. Thật sự ta không cần phải viết như vậy, vì getLine đã có sẵn công dụng này rồi. Viết color <- getLine tiếp theo là return color đồng nghĩa với việc tháo gỡ kết quả thu được từ getLine rồi lại gói nó vào, vì vậy cũng giống như chỉ viết getLine. Hàm forM (gọi cùng hai tham số kèm theo) sẽ tạo ra một thao tác I/O, mà kết quả của nó được ta gắn vào colors. colors chỉ là một danh sách bình thường để chứa các chuỗi. Cuối cùng, ta in tất cả những tên gọi màu đó bằng cách viết mapM putStrLn colors.

Bạn có thể hình dung forM theo nghĩa là: tạo ra một thao tác I/O cho mỗi phần tử trong danh sách này. Việc mỗi thao tác I/O sẽ thực hiện thì phụ thuộc vào phần tử được dùng để tạo nên thao tác đó. Sau cùng, hãy thực hiện những thao tác này rồi gắn những kết quả thu được vào thứ gì đó. Ta không nhất thiết phải gắn mà cũng có thể vứt bỏ chúng đi.

$ runhaskell form_test.hs
Which color do you associate with the number 1?
white
Which color do you associate with the number 2?
blue
Which color do you associate with the number 3?
red
Which color do you associate with the number 4?
orange
The colors that you associate with 1, 2, 3 and 4 are:
white
blue
red
orange

Thật sự ta cũng có thể làm vậy mà không cần forM, chỉ có điều forM làm cho mã lệnh dễ đọc hơn. Thông thường ta viết forM khi muốn ánh xạ và xâu chuỗi một số thao tác mà ta định nghĩa tại chỗ bằng cách viết do. Cũng theo mạch trên, ta có thể đã thay thế dòng cuối cùng bằng forM colors putStrLn.

Trong mục này, ta đã học những điều cơ bản về đầu vào và đầu ra. Ta cũng thấy được những thao tác I/O là gì, chúng giúp ta nhập và xuất dữ liệu như thế nào, và khi nào thì thực sự chúng được thực hiện. Xin được nhắc lại, thao tác I/O là những giá trị giống như mọi giá trị khác trong Haskell. Ta có thể truyền chúng làm tham số vào các hàm, đồng thời các hàm cũng có thể trả kết quả là những thao tác I/O. Điều đặc biệt về thao tác I/O là nếu chúng rơi vào hàm main (hoặc là những kết quả trên một dòng GHCI), thì chúng sẽ được thực hiện. Và khi đó chúng sẽ viết chữ lên màn hình hoặc phát đoạn nhạc Yakety Sax ra loa máy tính. Mỗi thao tác I/O cũng có thể chứa một kết quả, là thứ để báo cho bạn biết rằng thao tác I/O đã thu được kết quả này từ môi trường ngoài.

Đừng nghĩ một hàm như putStrLn nhận vào một chuỗi và in chuỗi đó ra màn hình. Hãy nghĩ nó như một hàm nhận vào một chuỗi rồi trả về một thao tác I/O. Thao tác I/O đó, khi thực hiện, sẽ in ra những vần thơ tuyệt hay lên màn hình.

Tập tin và dòng dữ liệu

streams

getChar là một thao tác I/O để đọc một kí tự từ bàn phím. getLine là một thao tác I/O để đọc một dòng chữ từ bàn phím. Cả hai đều thật dễ hiểu và hầu hết các ngôn ngữ lập trình đều có các hàm hoặc câu lệnh cung cấp tính năng tương đương. Nhưng bây giờ, ta hãy làm quen với getContents. getContents là một thao tác I/O nhằm đọc vào mọi thứ tự thiết bị đầu vào chuẩn đến khi nó bắt gặp một kí tự kết thúc tập tin. Nó mang kiểu getContents :: IO String. Điều hay ở getContents là nó thực hiện I/O một cách lười biếng. Khi ta viết foo <- getContents, nó không hề đọc toàn bộ dữ liệu đầu vào ngay lập tức, lưu vào bộ nhớ rồi gắn nó với foo. Không phải, vì nó lười biếng mà! Nó sẽ bảo rằng: “Rồi rồi, tôi sẽ đọc dữ liệu đầu vào từ thiết bị vào / bàn phím sau này, khi chúng ta hợp tác với nhau, lúc mà bạn thực sự cần nó đã!”.

getContents thực sự có ích khi ta dẫn kết quả đầu ra từ một chương trình đến làm số liệu đầu vào của chương trình đang làm việc. Trong trường hợp bạn không biết cách ống dẫn [pipe] trong các hệ thống kiểu Unix làm việc như thế nào, thì sau đây là một lời giới thiệu qua. Ta hãy lập một tập tin văn bản chữ có chứa bài thơ haiku ngắn sau:

I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless

Ồ, thơ dở ẹc, bạn nhỉ? Nếu ai biết đến sách hay dạy làm thơ haiku thì cho tôi biết với.

Bây giờ, ta hãy nhớ lại chương trình ngắn mà ta đã viết khi giới thiệu hàm forever. Nó nhắc người dùn nhập vào một dòng chữ, trả lại kết quả là dòng chữ VIẾT IN rồi lặp lại tất cả những điều đó mãi mãi. Để giúp bạn khỏi phải quay về tìm, thì đây là nội dung chương trình:

import Control.Monad
import Data.Char

main = forever $ do
    putStr "Give me some input: "
    l <- getLine
    putStrLn $ map toUpper l

Ta sẽ lưu chương trình này với tên capslocker.hs hoặc gì đó rồi biên dịch nó. Sau đó, ta sẽ dùng một ống dẫn Unix để trực tiếp truyền nội dung tập tin chữ này đến chương trình ngắn ta đã viết. Ta sẽ dùng đến chương trình tự do (GNU) có tên là cat, có tác dụng in một tập tin được cung cấp dưới dạng tham số. Hãy kiểm tra nhé!

$ ghc --make capslocker 
[1 of 1] Compiling Main             ( capslocker.hs, capslocker.o )
Linking capslocker ...
$ cat haiku.txt
I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless
$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS
capslocker <stdin>: hGetLine: end of file

Bạn thấy đấy, việc dẫn kết quả đầu ra từ một chương trình (trong trường hợp này là cat) đến đầu vào của chương trình khác (capslocker) được thực hiện bằng dấu |. Điều ta đã làm tương đương với chính việc chạy capslocker, gõ nội dung bài haiku từ bàn phím và rồi nhập một kí tự kết thúc tập tin (thường là Ctrl-D [trong hệ Unix, và Ctrl-Z trong Windows]). Nó cũng như chạy cat haiku.txt và rồi nói rằng: “Đợi đã, đừng in bài này ra màn hình, hãy bảo capslocker làm việc đó!”.

Như vậy điều mà ta làm khi dùng forever ở đây là nhận dữ liệu đầu vào rồi chuyển nó thành kết quả đầu ra nào đó. Đó là lý do mà ta có thể dùng getContents để làm cho chương trình được viết thậm chí còn ngắn hơn và hay hơn:

import Data.Char

main = do
    contents <- getContents
    putStr (map toUpper contents)

Ta chạy thao tác I/O getContents rồi đặt tên cho chuỗi mà nó tạo ra là contents. Sau đó, ta ánh xạ toUpper lên chuỗi này và in chuỗi ra màn hình. Hãy ghi nhớ rằng vì chuỗi về cơ bản là danh sách, và vốn có tính lười biếng, còn getContents là thao tác I/O lười biếng, nên nó sẽ không cố gắng đọc toàn bộ nội dung ngay để lưu vào bộ nhớ trước khi in dòng chữ viết in ra màn hình; mà nó sẽ in ra chữ viết in trong quá trình đọc. Nguyên nhân là vì nó chỉ đọc một dòng từ thiết bị đầu vào khi thực sự cần thiết.

$ cat haiku.txt | ./capslocker
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS

Hay đấy, chương trình hoạt động rồi. Thế còn nếu ta chỉ chạy capslocker rồi tự tay gõ các dòng chữ vào thì sao?

$ ./capslocker
hey ho
HEY HO
lets go
LETS GO

Ta thoát khỏi chương trình bằng cách nhấn Ctrl-D. Thật tuyệt! Bạn thấy đấy, nó in ra chữ được viết in lại cho ta, theo từng dòng một. Khi kết quả của getContents được gắn vào contents, nó không được biểu diễn trong bộ nhớ dưới dạng một chuỗi thực sự, mà giống như là một lời hứa sẽ sau này sẽ tạo ra chuỗi. Khi ta ánh xạ toUpper lên contents, đó cũng là một lời hứa ánh xạ hàm này lên nội dung mà sau này sẽ được tạo ra. Và cuối cùng khi putStr xảy ra, nó nó với lời hứa trước đây rằng: “Này, tôi cần một dòng chữ được chuyển thành viết in!”. Vì chưa có trong tay dòng chữ nào, nên nó sẽ nó với contents là: “Này, việc thực sự lấy dòng chữ từ bàn phím được đến đâu rồi?”. Vậy đó là khi getContents thực sự đọc từ thiết bị vào và đưa một dòng chữ đến cho đoạn mã lệnh đã yêu cầu nó phải tạo ra một dữ liệu cụ thể. Sau đó, đoạn mã lệnh này sẽ ánh xạ toUpper lên dòng chữ này và đưa nó vào putStr, và hàm này làm nhiệm vụ in dòng chữ đó. Và rồi putStr nói: “Này, tôi cần dòng tiếp theo, nhanh lên nào!” và quá trình này tiếp tục lặp lại cho đến khi không còn dữ liệu đầu vào nữa, thể hiện bởi sự có mặt của kí tự kết thúc tập tin.

Ta hãy viết một chương trình nhận một dữ liệu vào nào đó rồi chỉ in ra những dòng mà ngắn hơn 10 kí tự. Xem nhé:

main = do
    contents <- getContents
    putStr (shortLinesOnly contents)

shortLinesOnly :: String -> String
shortLinesOnly input = 
    let allLines = lines input
        shortLines = filter (\line -> length line < 10) allLines
        result = unlines shortLines
    in  result

Ta đã làm cho phần I/O của chương trình vừa viết ngắn gọn hết mức. Vì chương trình được viết có nhiệm vụ nhận dữ liệu vào nào đó rồi in kết quả đầu ra phụ thuộc vào dữ liệu được nhập, nên ta có thể viết theo cách: đọc nội dung dữ liệu vào, chạy một hàm trên dữ liệu đó rồi in những gì mà hàm trả lại.

Hàm shortLinesOnly hoạt động như sau: nó nhận vào một chuỗi, như "short\nlooooooooooooooong\nshort again". Chuỗi này gồm ba dòng, trong đó có hai dòng ngắn và dòng giữa dài. Chương trình chạy hàm lines trên chuỗi này, để chuyển chuỗi thành ["short", "looooooooooooooong", "short again"], từ đó ta sẽ gắn nó vào tên allLines. Sau đó danh sách các chuỗi này được lọc sao cho chỉ còn những dòng ngắn hơn 10 kí tự trong danh sách, tạo ra ["short", "short again"]. Và cuối cùng, unlines nối danh sách đó thành một chuỗi mới ngăn cách bởi dấu xuống dòng; kết quả là "short\nshort again". Ta hãy thử chạy chương trình.

i'm short
so am i
i am a loooooooooong line!!!
yeah i'm long so what hahahaha!!!!!!
short line
loooooooooooooooooooooooooooong
short
$ ghc --make shortlinesonly
[1 of 1] Compiling Main             ( shortlinesonly.hs, shortlinesonly.o )
Linking shortlinesonly ...
$ cat shortlines.txt | ./shortlinesonly
i'm short
so am i
short

Ta dẫn nội dung trong shortlines.txt đến kết quả đầu ra của shortlinesonly và kết quả ta thu được là chỉ còn những dòng ngắn.

Mẫu lập trình như thế này, trong đó ta lấy một chuỗi nào đó từ thiết bị đầu vào, chuyển đổi nó bằng một hàm rồi xuất kết quả ra, được dùng thường xuyên đến nỗi có hẳn một hàm tên là interact để giúp cho việc này được dễ dàng hơn. interact nhận vào một hàm có kiểu String -> String làm tham số rồi trả về một thao tác I/O để nhận dữ liệu đầu vào nào đó, chạy một hàm trên dữ liệu đó rồi in ra kết quả của hàm. Ta hãy sửa lại chương trình để dùng đến interact.

main = interact shortLinesOnly

shortLinesOnly :: String -> String
shortLinesOnly input = 
    let allLines = lines input
        shortLines = filter (\line -> length line < 10) allLines
        result = unlines shortLines
    in  result

Với mục đích chỉ để cho thấy rằng công đoạn này có thể được thực hiện với mã lệnh gọn nhẹ (mặc dù đồng thời sẽ khó đọc mã lệnh hơn) và để giới thiệu kĩ năng dùng hàm hợp, ta sẽ viết lại chương trình như sau.

main = interact $ unlines . filter ((<10) . length) . lines

Oa, ta thực sự đã rút gọn đoạn chương trình về còn một dòng lệnh, thật là hay!

interact có thể được dùng để lập các chương trình nhằm tiếp nhận một số nội dung được dẫn vào trong chúng và rồi đổ kết quả nào đó từ chương trình. Hoặc nó cũng có thể được dùng để tạo các chương trình với hình thức là nhận một dòng chữ do người dùng nhập vào, gửi trả lại kết quả nào đó dựa trên dòng chữ vừa nhập, rồi nhận một dòng khác và cứ như vậy. Thực ra không có khác biệt giữa hai cách này, và việc chọn lựa hoàn toàn phụ thuộc vào mục đích người dùng.

Ta hãy lập một chương trình để liên tục đọc vào một dòng chữ rồi báo cho ta biết rằng liệu dòng này có chữ cái sắp xếp đối xứng (palindrome) hay không. Ta cũng có thể chỉ cần dùng getLine để đọc vào dòng chữ, báo cho người dùng biết xem có chữ đối xứng không và rồi chạy lại main . Nhưng sẽ đơn giản hơn nếu ta dùng interact. Khi dùng interact, hãy hình dung xem bạn cần làm gì để chuyển đổi những số liệu đầu vào thành kết quả đầu ra mong muốn. Trong trường hợp này, ta phải thay thế mỗi dòng dữ liệu vào với "palindrome" hoặc là "not a palindrome". Vì vậy, ta phải viết một hàm để chuyển một thứ như "elephant\nABCBA\nwhatever" thành "not a palindrome\npalindrome\nnot a palindrome". Hãy làm việc này nhé!

respondPalindromes contents = unlines (map (\xs -> if isPalindrome xs then "palindrome" else "not a palindrome") (lines contents))
    where   isPalindrome xs = xs == reverse xs

Ta hãy viết mã lệnh này có dùng dấu chấm.

respondPalindromes = unlines . map (\xs -> if isPalindrome xs then "palindrome" else "not a palindrome") . lines
    where   isPalindrome xs = xs == reverse xs

Khá dễ hiểu. Trước hết nó chuyển những thứ như "elephant\nABCBA\nwhatever" thành ["elephant", "ABCBA", "whatever"] rồi sau đó ánh xạ hàm lambda này lên nó, cho ta ["not a palindrome", "palindrome", "not a palindrome"] và rồi unlines sẽ nối danh sách đó thành một chuỗi phân cách bởi dấu xuống dòng. Bây giờ ta có thể viết

main = interact respondPalindromes

Ta hãy kiểm tra lại chương trình này:

$ runhaskell palindromes.hs
hehe
not a palindrome
ABCBA
palindrome
cookie
not a palindrome

Mặc dù ta đã viết được chương trình để chuyển một chuỗi dữ liệu nhập vào rất dành thành một chuỗi khác, chương trình chạy như thể ta đã lập trình để nó chạy từng dòng một. Đó là vì Haskell có tính lười biếng và nó muốn in dòng đầu tiên của chuỗi kết quả nhưng lại không thể vì nó chưa có dòng đầu tiên được nhập vào. Bởi thế mà ngay khi ta cấp cho nó dòng chữ đầu tiên, nó sẽ in ra dòng chữ kết quả đầu tiên. Ta thoát khỏi chương trình bằng cách ấn kí tự kết thức tập tin.

Ta còn có thể dùng chương trình này bằng cách đơn giản là dẫn một tập tin vào nó. Giả sử nhử ta có tập tin này:

dogaroo
radar
rotor
madam

và ta lưu nó với tên words.txt. Sau đây là những gì ta thu được khi dẫn nó vào chương trình vừa viết:

$ cat words.txt | runhaskell palindromes.hs
not a palindrome
palindrome
palindrome
palindrome

Một lần nữa, ta thu được cùng kết quả như thể ta đã chạy chương trình và tự tay nhập các chữ vào trong thiết bị đầu vào chuẩn. Ta chỉ không nhìn thấy dữ liệu đầu vào cho palindromes.hs vì đầu vào này đến từ một tập tin chứ không phải từ những chữ mà ta gõ vào.

Như vậy bây giờ có thể bạn đã thấy cách hoạt động của thao tác I/O lười biếng và cách mà ta lợi dụng nó. bạn có thể chỉ cần hình dung theo những gì mà kết quả đầu ra cần phải có được ứng với một dữ liệu đầu vào nhất định, rồi viết một hàm để thực hiện việc chuyển đổi đó. Đối với thao tác I/O lười biếng, chưa có gì được tiêu thụ từ đầu vào cho đến khi công việc yêu cầu, vì những gì ta muốn in ra ngay bây giờ thì phụ thuộc vào dữ liệu vào đó.

Cho đến giờ, ta đã làm việc với I/O bằng cách in các thứ ra màn hình và đọc từ bàn phím. nhưng thế còn đọc và ghi ra tập tin thì sao? Ồ, ta phần nào đã làm việc đó rồi. Một cách nghĩ về việc đọc từ bàn phím là tưởng tượng như ta đang đọc từ một tập tin (đặc biệt một chút). Việc ghi ra màn hình cũng vậy, nó giống như ghi ra tập tin. Ta có thể gọi hai tập tin đặc biệt trên là stdoutstdin, nghĩa là standard output (đầu ra chuẩn) và standard input (đầu vào chuẩn). Ghi nhớ điều này, ta sẽ thấy rằng việc ghi ra và đọc từ các tập tin rất giống với ghi ra thiết bị đầu ra chuẩn và đọc vào từ thiết bị đầu vào chuẩn.

Ta sẽ khởi đầu bằng một chương trình thực sự đơn giản, để mở một tập tin tên là girlfriend.txt, trong đó gồm một đoạn thơ trích từ tác phẩm số 1 của Avril Lavigne, bài Girlfriend, và chỉ cần in nộ dung này ra màn hình. Sau đây là girlfriend.txt:

Hey! Hey! You! You! 
I don't like your girlfriend! 
No way! No way! 
I think you need a new one!

Và đây là chương trình ta viết:

import System.IO

main = do
    handle <- openFile "girlfriend.txt" ReadMode
    contents <- hGetContents handle
    putStr contents
    hClose handle

Khi chạy nó, ta thu được kết quả như trông đợi:

$ runhaskell girlfriend.hs
Hey! Hey! You! You!
I don't like your girlfriend!
No way! No way!
I think you need a new one!

Ta hãy duyệt qua chương trình này từng dòng một. Dòng thứ nhất chỉ là bốn từ biểu cảm, để thu hút sự chú ý của người đọc. Ở dòng thứ hai, Avril nói rằng cô không thích người yêu hiện tại của chúng ta. Dòng thứ ba để nhấn mạnh điều này, còn dòng thứ tư khuyên ta nên tìm một cô bạn gái mới.

Ta hãy duyệt qua từng dòng lệnh của chương trình nhé! Chương trình gồm có một số các thao tác I/O được gắn với nhau bằng khối lệnh do. Ở dòng đầu tiên của khối do, ta phát hiện thấy một hàm mới có tên openFile. Sau đây là dấu ấn kiểu của nó: openFile :: FilePath -> IOMode -> IO Handle. Nếu bạn đọc hẳn ra, thì sẽ là: openFile nhận vào một đường dẫn đến tập tin và một IOMode rồi trả lại một thao tác I/O để mở một tập tin và lấy chuôi ứng với tập tin đó vào trong kết quả.

FilePath chỉ là một kiểu tương đồng với String, được định nghĩa đơn giản là:

type FilePath = String

IOMode là một kiểu được định nghĩa như sau:

data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

A FILE IN A CAKE!!!

Cũng như kiểu ta đã lập để biểu diễn bảy giá trị có thể có của những ngày trong tuần, kiểu đang xét với hình thức liệt kê, thể hiện cho những chế độ ta cần xử lý với tập tin được mở. Rất đơn giản. Ta chỉ cần lưu ý rằng kiểu này là IOMode chứ không phải IO Mode. IO Mode là kiểu của một I/O có kết quả là một giá trị thuộc một kiểu Mode nào đó, còn IOMode chỉ là một kiểu liệt kê đơn giản.

Sau cùng, nó trả lại một thao tác I/O để mở tập tin được chỉ định theo chế độ được chỉ định. Nếu gắn thao tác đó vào một thứ gì đó, ta sẽ nhận được một Handle (tạm hiểu là “chuôi”). Một giá trị thuộc kiểu Handle biểu diễn vị trí của tập tin đang xét. Sử dụng chuôi này ta sẽ biết được cần phải đọc tập tin nào. Sẽ thật ngốc nghếch nếu ta đọc tập tin nhưng không gắn việc đọc nó vào một chuôi vì nếu như vậy ta sẽ không thể làm việc gì với tập tin đó cả. Vì vậy ở đây, ta sẽ gắn chuôi vào một tên gọi là handle.

Ở dòng kế tiếp, ta thấy một hàm tên là hGetContents. Nó nhận vào một Handle, vì vậy nó biết được tập tin nào ta cần lấy nội dung và rồi trả về một IO String — một thao tác I/O để nắm giữ kết quả là nội dung của tập tin đó. Hàm này rất giống với getContents. Điểm khác biệt duy nhất là ở chỗ getContents sẽ tự động đọc từ thiết bị đầu vào chuẩn (bàn phím), trong khi hGetContents nhận một chuôi tập tin để thông báo là cần đọc từ tập tin nào. Còn lại thì hai hàm này giống nhau. Và cũng như getContents, hGetContents sẽ không cố gắng đọc ngay tập tin để lưu vào trong bộ nhớ, mà sẽ chỉ đọc khi cần thiết. Đó là điều hay vì ta có thể coi contents như là toàn bộ nội dung của tập tin, nhưng thật ra nó không được tải vào trong bộ nhớ. Vì vậy nếu tập tin này rất lớn thì dùng hGetContents sẽ không gây ra nghẽn bộ nhớ, vì nó sẽ chỉ đọc từ tập tin những dữ liệu cần thiết vào lúc cần thiết.

Lưu ý điểm khác biệt giữa chuôi dùng để nhận diện một tập tin và nội dung của tập tin, được gắn vào handlecontents trong chương trình đang xét. Chuôi chỉ là thứ để cho ta biết tập tin hiện đang ở đâu. Nếu bạn tưởng tượng hệ thống tập tin là một cuốn sách khổng lồ và mỗi tập tin là một chương sách, thì chuôi là một thẻ đánh dấu để cho ta biết đang đọc hoặc ghi chép đến chương nào của cuốn sách, còn nội dung chính là bản thân chương sách đó.

Bằng mã lệnh putStr contents ta in nội dung ra thiết bị đầu ra chuẩn, sau đó thực hiện hClose để nhận chuôi và trả lại một thao tác I/O để đóng tập tin. Bạn phải tự tay đóng tập tin sau khi dùng openFile để mở nó!

Một cách khác để thực hiện những gì ta đã làm là dùng hàm withFile, vốn có dấu ấn kiểu là withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a. Nó nhận một đường dẫn đến tập tin, một IOMode và sau đó lấy một hàm, hàm này nhận một chuôi, và trả lại một thao tác I/O nào đó. Thứ mà nó trả lại là một thao tác I/O để mở tập tin đó, thực hiện công việc cần làm đối với tập tin, rồi đóng tập tin lại. Kết quả chứa đựng trong thao tác I/O cuối cùng được trả lại thì cũng giống như với kết quả của thao tác I/O được trả về từ hàm mà ta cung cấp. Điều này nghe có vẻ phức tạp, song nó thật đơn giản, đặc biệt là với lambda; sau đây là ví dụ trên được ta viết lại có dùng withFile:

import System.IO   

main = do   
    withFile "girlfriend.txt" ReadMode (\handle -> do
        contents <- hGetContents handle   
        putStr contents)

Bạn thấy đây, nó rất giống với đoạn mã lệnh trước. (\handle -> ... ) là hàm nhận vào một chuôi và trả về một thao tác I/O và nó thường được làm như thế này, có dùng lambda. Lý do mà nó phải nhận một hàm có trả về thao tác I/O chứ không phải chỉ lấy thao tác I/O để thực hiện rồi đóng tập tin lại là bởi vì thao tác I/O mà ta truyền vào cho nó sẽ không biết tập tin nào để thực hiện nữa. Bằng cách này, withFile mở một tập tin rồi truyền chuôi đến hàm mà ta cung cấp cho nó. withFile nhận lại thao tác I/O từ hàm đó rồi lập một thao tác I/O cũng giống như nó, chỉ khác là sau này sẽ đóng tập tin lại. Sau đây là cách ta lập riêng một hàm withFile:

withFile' :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
withFile' path mode f = do
    handle <- openFile path mode 
    result <- f handle
    hClose handle
    return result

butter toast

Ta biết rằng kết quả sẽ là một thao tác I/O vì vậy ta có thể chỉ cần khởi đầu bằng một lệnh do. Trước hết, ta mở tập tin và lấy một chuôi chỉ đến nó. Tiếp theo, ta áp dụng hàm cần dùng cho handle để nhận lại thao tác I/O có nhiệm vụ thực hiện toàn bộ công việc. Ta gắn thao tác này vào result, đóng chuôi lại rồi viết return result. Bằng cách trả lại (return) kết quả chứa trong thao tác I/O đã lấy được từ f, ta bố trí để thao tác I/O đang xét chứa đựng cùng kết quả với kết quả đã lấy được từ f handle. Bởi vậy nếu f handle trả lại một hành động mà sẽ đọc vào nhiều dòng từ thiết bị đầu vào chuẩn và ghi ra một tập tin rồi chứa đựng số dòng đã đọc vào trong kết quả, và nếu ta dùng với withFile', thì thao tác I/O nhận được cũng sẽ có kết quả là số dòng đọc được.

Cũng như ta đã có hGetContents hoạt động như getContents nhưng đối với một tập tin cụ thể, thì ta cũng có hGetLine, hPutStr, hPutStrLn, hGetChar, v.v. Chúng hoạt động như các dạng tương ứng có tên thiếu mất chữ h ở đầu; nhưng khác là chúng nhận tham số là một chuôi vào thao tác trên tập tin được chỉ định, thay vì hoạt động với đầu vào và đầu ra chuẩn. Lấy ví dụ: putStrLn là một hàm nhận vào một chuỗi rồi trả lại một thao tác I/O để in chuỗi đó lên màn hình kèm thêm dấu xuống dòng ở cuối. hPutStrLn nhận vào một chuôi và một chuỗi kí tự rồi trả lại một thao tác I/O để ghi chuỗi kí tự này ra tập tin tương ứng với chuôi và rồi kết thúc chuỗi kí tự bằng dấu xuống dòng. Theo tinh thần tương tự, hGetLine nhận một chuôi rồi trả về một thao tác I/O để đọc một dòng từ tập tin của nó.

Việc nạp các tập tin rồi coi nội dung của chúng như các chuỗi kí tự đều thông dụng đến nỗi ta có ba hàm nhỏ sau đây để giúp cho công việc dễ dàng hơn nữa:

readFile có dấu ấn kiểu là readFile :: FilePath -> IO String.
Hẳn bạn còn nhớ, FilePath chỉ là một tên gọi đẹp đẽ của String.
readFile nhận vào một đường dẫn đến tập tin và trả lại một thao tác I/O để đọc tập tin đó (dĩ nhiên là theo cách lười biếng) rồi gắn nội dung của nó, dưới dạng chuỗi kí tự, vào một thứ gì đó.
Thường bằng cách này sẽ tiện hơn là dùng openFile và gắn nó vào một chuôi rồi thực hiện hGetContents.
Sau đây là cách ta viết lại được ví dụ trên có dùng readFile:

import System.IO

main = do
    contents <- readFile "girlfriend.txt"
    putStr contents

Vì ta không lấy chuôi để nhận diện tập tin, ta sẽ không thể tự tay đóng tập tin này lại, vì vậy Haskell sẽ đảm nhiệm việc này mỗi khi ta dùng readFile.

writeFile có kiểu writeFile :: FilePath -> String -> IO ().
Nó nhận vào một đường dẫn đến tập tin cùng với một chuỗi kí tự để ghi vào tập tin đó, rồi trả lại một thao tác I/O để thực hiện việc ghi này. Nếu một tập tin như vậy đã tồn tại, thì nó sẽ bị co về chiều dài bằng 0 trước khi ghi đè chuỗi kí tự lên. Sau đây là cách biến nội dung girlfriend.txt thành CHỮ VIẾT IN rồi ghi nó vào tập tin girlfriendcaps.txt:

import System.IO   
import Data.Char

main = do   
    contents <- readFile "girlfriend.txt"   
    writeFile "girlfriendcaps.txt" (map toUpper contents)
$ runhaskell girlfriendtocaps.hs
$ cat girlfriendcaps.txt
HEY! HEY! YOU! YOU!
I DON'T LIKE YOUR GIRLFRIEND!
NO WAY! NO WAY!
I THINK YOU NEED A NEW ONE!

appendFile có dấu ấn kiểu cũng giống như writeFile, chỉ khác là appendFile không co tập tin về chiều dài bằng 0 nếu nó đã tồn tại, mà là thêm nội dung vào sau nó.

Giả sử ta có một tập tin todo.txt trong đó mỗi dòng ghi một công việc cần làm. Bây giờ hãy lập chương trình lấy một dòng từ thiết bị đầu vào chuẩn rồi thêm dòng này vào danh sách các việc cần làm.

import System.IO   

main = do   
    todoItem <- getLine
    appendFile "todo.txt" (todoItem ++ "\n")
$ runhaskell appendtodo.hs
Iron the dishes
$ runhaskell appendtodo.hs
Dust the dog
$ runhaskell appendtodo.hs
Take salad out of the oven
$ cat todo.txt
Iron the dishes
Dust the dog
Take salad out of the oven

Ta cần phải thêm dấu "\n" vào cuối mỗi dòng vì getLine không cho ta kí tự xuống dòng vào cuối.

Ôi, còn một thứ nữa. Ta đã nói rằng việc viết contents <- hGetContents handle không khiến cho tập tin được đọc ngay và lưu vào bộ nhớ. Nó là thao tác I/O có tính lười biếng, vì vậy khi viết:

main = do 
    withFile "something.txt" ReadMode (\handle -> do
        contents <- hGetContents handle
        putStr contents)

thực ra là giống như việc nối ống dẫn từ tập tin đến đầu ra. Cũng như bạn hình dung về danh sách như dòng dữ liệu, thì tập tin cũng có thể hình dung như dòng dữ liệu. Cách này sẽ đọc từng dòng một và in nó lên màn hình. Vì vậy bạn có thể hỏi, bề rộng của ống dẫn là bao nhiêu? Ổ đĩa sẽ được truy cập thường xuyên đến mức nào? À, đối với các tập tin chữ, thì chế độ đệm (buffering mode) mặc định là đệm theo từng dòng. Điều này nghĩa là phần nhỏ nhất của tập tin được đọc ngay là một dòng. Đó là lý do mà trong trường hợp này nó đã đọc một dòng, in dòng này ra màn hình, đọc dòng kế tiếp, in ra, v.v. Đối với những tập tin nhị phân, chế độ đệm mặc định thường là đệm theo khối. Điều này nghĩa là nó sẽ đọc tập tin theo từng bó một. Kích thước của bó này sẽ được hệ điều hành quyết định sao cho phù hợp.

Bạn có thể điều khiển chính xác chế độ đệm bằng cách dùng hàm hSetBuffering. Hàm này nhận một chuôi và một BufferMode rồi trả lại một thao tác I/O để thiết lập chế độ đệm. BufferMode là một kiểu dữ liệu liệt kê đơn giản và các giá trị nó có thể nắm giữ bao gồm: NoBuffering, LineBuffering hoặc BlockBuffering (Maybe Int). Trong đó Maybe Int được dùng để chỉ định kích thước bó tính theo byte. Nếu nó là Nothing, thì hệ điều hành sẽ tự quyết định kích thước bó. NoBuffering nghĩa là nó sẽ đọc từng kí tự một. NoBuffering thường là chế độ đệm rất dở vì nó phải truy cập đến ổ đĩa quá nhiều.

Sau đây là đoạn mã lệnh bên trên, nhưng thay vì đọc từng dòng, lần này sẽ là đọc tập tin theo từng bó 2048 byte.

main = do 
    withFile "something.txt" ReadMode (\handle -> do
        hSetBuffering handle $ BlockBuffering (Just 2048)
        contents <- hGetContents handle
        putStr contents)

Việc đọc tập tin theo những bó kích thước lớn hơn sẽ giúp ích nếu ta muốn giảm thiểu sự truy cập đến ổ đĩa, hoặc khi tập tin của ta là một tài nguyên trên mạng có đường truyền chậm.

Ta cũng có thể dùng hFlush, vốn là is một hàm nhận vào một chuôi rồi trả về một thao tác I/O để xả bộ đệm của tập tin tương ứng với chuôi đó. Khi làm việc với chế độ đệm theo dòng, bộ đệm sẽ được xả sau mỗi dòng một. Khi làm việc với chế độ đệm theo khối, việc xả tiến hành sau khi ta đã đọc một bó. Nó cũng được xả sau khi đóng chuôi lại. Điều này nghĩa là khi ta đạt đến kí tự xuống dòng thì chế độ đọc (hoặc ghi) sẽ báo lại tất cả những dữ liệu hiện có. Nhưng ta có thể dùng hFlush để ép xả tất cả dữ liệu hiện đã đọc được. Sau khi xả, dữ liệu sẽ đến tay các chương trình khác đang chạy.

Hãy hình dùng việc đọc tập tin theo chế độ đệm từng khối như sau: bồn cầu nhà bạn được thiết kế để tự xả sau khi nó nhận được đủ 1 gallon (chừng 3,8 lit) nước. Như vậy bạn bắt đầu vặn nước chảy vào và ngay khi mức nước dâng tới vạch chỉ 1 gallon thì nước sẽ được tự xả và dữ liệu có trong lượng nước bạn vặn vào đã được đọc hoàn toàn. Nhưng bạn cũng có thể tự xả nước bằng cách ấn nút trên bồn cầu. VIệc này làm cho bồn cầu xả nước và lượng nước (hay dữ liệu) bên trong bồn cầu cũng được đọc. Nếu như bạn vẫn chưa hiểu rõ, thì việc xả bồn cầu bằng cách tự ấn nút là một cách nói ẩn dụ cho hFlush. Theo tiêu chuẩn tương tự khi so sánh với lập trình thì ví dụ này vẫn chưa chuẩn nhưng tôi muốn minh họa bằng một vật thể trong đời sống hàng ngày để cho vui.

Ta đã lập một chương trình để thêm một hạng mục mới vào danh sách những việc cần làm trong todo.txt, bây giờ hãy lập chương trinh để xóa bớt một hạng mục. Tôi sẽ kèm đoạn mã lời giải vào đây và ta sẽ cùng duyệt chương trình này để thấy được là nó rất dễ. Ta sẽ dùng một số hàm mới từ System.Directory và một hàm mới từ System.IO, nhưng chúng đều sẽ được giải thích.

Sau đây là chương trình để xóa một hạng mục khỏi todo.txt:

import System.IO
import System.Directory
import Data.List

main = do      
    handle <- openFile "todo.txt" ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let todoTasks = lines contents   
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks   
    putStrLn "These are your TO-DO items:"
    putStr $ unlines numberedTasks
    putStrLn "Which one do you want to delete?"   
    numberString <- getLine   
    let number = read numberString   
        newTodoItems = delete (todoTasks !! number) todoTasks   
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile "todo.txt"
    renameFile tempName "todo.txt"

Trước hết, ta chỉ việc mở todo.txt theo chế độ đọc và gắn chuôi của nó với handle.

Tiếp theo, ta dùng một hàm mà trước đây chưa từng gặp — hàm openTempFile trong System.IO. Tên gọi của nó đã gợi cho ta biết công dụng. Nó nhận vào một đường đẫn đến một thư mục tạm thời và một tên bản mẫu cho tập tin rồi mở tập tin tạm thời đó. Ta đã dùng "." để chỉ thư mục tạm thời, vì . biểu diễn cho thư mục hiện hành trong gần như mọi hệ điều hành. Ta dùng "temp" làm tên bản mẫu cho tập tin tạm thời, nghĩa là tên của tập tin tạm thời sẽ là temp tiếp theo sẽ là một số kí tự ngẫu nhiên nào đó. Hàm này trả về một một thao tác I/O để tạo tập tin tạm thời và kết quả của thao tác I/O đó là một cặp giá trị: tên tập tin tạm thời cùng với một chuôi. Ta có thể chỉ mở một tập tin thường có tên là todo2.txt hoặc cái gì đó tương tự song một thói quen tốt hơn là dùng openTempFile để đảm bảo chắc chắn không gặp tai nạn ghi đè lên bất cứ tập tin sẵn có nào.

Lý do mà ta đã không dùng getCurrentDirectory để lấy thư mục hiện hành rồi truyền nó đến openTempFile mà lại chỉ truyền "." đến openTempFile là bởi vì . chỉ đến thư mục hiện hành trong các hệ điều hành tựa Unix cũng như Windows.

Tiếp theo, ta gắn nội dung của todo.txt vào contents. Sau đó, phân chia chuỗi đó thành một danh sách các chuỗi, mỗi chuỗi trên một dòng. Vì vậy todoTasks bây giờ sẽ giống như ["Iron the dishes", "Dust the dog", "Take salad out of the oven"]. Ta đan cài (zip) các số từ 0 trở lên với danh sách này bằng các dùng một hàm nhận vào một con số, như 3, và một chuỗi, như "hey" rồi trả lại "3 - hey", theo đó numberedTasks sẽ là ["0 - Iron the dishes", "1 - Dust the dog" ...]. Ta nối danh sách các chuỗi này thành một chuỗi thống nhất phân cách bởi các dấu xuống dòng, bằng unlines, rồi in chuỗi đó ra màn hình. Lưu ý rằng thay vì việc làm vậy, ta cũng còn có thể viết mapM putStrLn numberedTasks

Ta hỏi người dùng xem họ muốn xóa mục nào và đợi họ nhập vào một con số. Giả sử như họ muốn xóa số 1, ứng với Dust the dog, vì vậy họ gõ vào 1. numberString bây giờ sẽ là "1" và vì ta muốn có con số chứ không phải chuỗi, nên ta chạy read với chuỗi đó để nhận về 1 và gắn số này vào number.

Hãy nhớ lại các hàm delete!! trong Data.List. Hàm !! trả lại một phần tử từ một danh sách với chỉ số nào đó, và delete thì xóa phần tử đầu tiên với giá trị đã cho xuất hiện trong danh sách, rồi trả lại một danh sách mới khuyết mất phần tử đó. (todoTasks !! number) (number bây giờ là 1) sẽ trả lại "Dust the dog". Ta gắn todoTasks không còn phần tử "Dust the dog" đầu tiên vào newTodoItems rồi kết nối danh sách này, bằng unlines, thành một chuỗi mới trước khi ghi chuỗi này ra tập tin tạm thời mà ta đã mở. Tập tin cũ bây giờ vẫn không đổi và tập tin tạm thời chứa toàn bộ những dòng có trong tập tin cũ, chỉ trừ dòng mà ta đã xóa bỏ.

Sau đó ta đóng cả hai tập tin ban đầu và tạm thời, rồi xóa tập tin ban đầu bằng hàm removeFile; hàm này nhận một đường dẫn đến tập tin và xóa tập tin đó. Sau khi xóa todo.txt cũ, ta dùng renameFile để đổi tên tập tin tạm thời thành todo.txt. Cẩn thận đấy, removeFilerenameFile (cả hai đều trong System.Directory) nhận tham số là các đường dẫn đến tập tin, chứ không phải là chuôi.

Và như vậy là xong! Ta đã có thể viết chương trình ngắn gọn hơn, nhưng dù sao ta cũng đã rất cẩn thận không ghi đè lên tập tin đã có sẵn và hỏi hệ điều hành một cách lịch thiệp, xem ta có thể đặt tập tin tạm thời ở đâu. Hãy thử chạy chương trình nào!

$ runhaskell deletetodo.hs
These are your TO-DO items:
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
Which one do you want to delete?
1

$ cat todo.txt
Iron the dishes
Take salad out of the oven

$ runhaskell deletetodo.hs
These are your TO-DO items:
0 - Iron the dishes
1 - Take salad out of the oven
Which one do you want to delete?
0

$ cat todo.txt
Take salad out of the oven

Tham số dòng lệnh

COMMAND LINE ARGUMENTS!!! ARGH

Việc xử lý tham số dòng lệnh rất cần thiết nếu bạn muốn viết một mã lệnh hay ứng dụng chạy trên cửa sổ dòng lệnh. Thật may là thư viện chuẩn Haskell có một cách tuyệt vời để lấy các tham số dòng lệnh của một chương trình.

Ở mục trước, ta đã viết một chương trình để bổ sung một đề mục vào danh sách những việc cần làm. Có hai vấn đề nảy sinh từ cách làm mà ta đã chọn. thứ nhất, ta mới chỉ lập trình một cách cứng nhắc tên của tập tin chứa việc cần làm. Ta chỉ quyết định chọn tên tập tin là todo.txt và người dùng sẽ không bao giờ cần quản lý nhiều danh sách việc cần làm như vậy.

Có một cách giải quyết vấn đề này là luôn luôn hỏi người dùng xem tập tin nào họ muốn sử dụng làm danh sách công việc. Ta đã dùng phương pháp này khi muốn biết hạng mục nào người dùng cần xoá đi. Cách này cũng được, nhưng không hay lắm, vì nó đòi hỏi người dùng phải chạy chương trình, chờ cho chương trình hỏi điều gì đó rồi mới trả lời. Đây được gọi là một chương trình tương tác và điều khó khăn đối với chương trình tương tác dòng lệnh là ở đây — ta sẽ làm gì được khi cần phải tự động hoá việc thực thi chương trình, như một mã lệnh chạy theo lô (batch)? Thật khó tạo được một mã lệnh chạy lô mà tương tác được với chương trình, hơn là mã lệnh chạy lô mà chỉ gọi một hoặc nhiều chương trình.

Điều này lý giải tại sao đôi khi ta nên để người dùng báo cho chương trình biết họ cần gì khi họ chạy chương trình, thay vì để cho chương trình hỏi người dùng khi chương trình đã chạy rồi. Và để báo chương trình biết yêu cầu của người dùng khi họ chạy chương trình thì còn gì tốt hơn là dùng tham số dòng lệnh!

Module System.Environment có hai thao tác I/O hay. Một là getArgs, vốn có kiểu getArgs :: IO [String] và là một thao tác I/O để lấy các tham số mà chương trình chạy cùng với và có một danh sách các tham số làm kết quả chứa trong thao tác I/O này. getProgName có kiểu là getProgName :: IO String và là một thao tác I/O chứa tên của chương trình.

Sau đây là một chương trình ngắn minh hoạ cho cách hoạt động của hai thao tác I/O trên:

 import System.Environment 
 import Data.List

 main = do
    args <- getArgs
    progName <- getProgName
    putStrLn "The arguments are:"
    mapM putStrLn args
    putStrLn "The program name is:"
    putStrLn progName

Ta gắn getArgsprogName với argsprogName. Ta nói rằng The arguments are: và với mỗi đối số trong args, ta thực hiện putStrLn. Sau cùng, ta cũng in ra tên của chương trình. Hãy biên dịch chương trình này với tên gọi arg-test.

$ ./arg-test first second w00t "multi word arg"
The arguments are:
first
second
w00t
multi word arg
The program name is:
arg-test

Tuyệt. Với kiến thức này bạn đã có thể lập được một số ứng dụng dòng lệnh hay. Ta hãy tiếp tục và lập một chương trình như vậy. Ở mục trước, ta đã lập hai chương trình riêng biệt để thêm vào các hạng mục công việc và xóa các hạng mục này. Bây giờ ta hãy ghép chúng lại thành một chương trình, và tính năng của nó sẽ tùy theo đối số dòng lệnh. Chúng ta cũng làm cho chương trình chạy được với nhiều tập tin, chứ không riêng gì todo.txt.

Ta sẽ gọi chương trình này đơn giản là todo và nó có ba tính năng sau:

  • Xem các hạng mục công việc
  • Thêm các hạng mục công việc
  • Xóa các hạng mục công việc

Tạm thời bây giờ sẽ không băn khoăn xét đến trường hợp dữ liệu không hợp lệ được nhập vào.

Chương trình sẽ được lập nên sao cho nếu ta muốn thêm hạng mục Find the magic sword of power vào tập tin todo.txt, ta phải gõ vào cửa sổ lệnh như sau: todo add todo.txt "Find the magic sword of power". Để xem các hạng mục công việc ta chỉ cần gõ: todo view todo.txt và để xóa hạng mục công việc đứng ở vị trí thứ 2, ta gõ: todo remove todo.txt 2.

Ta sẽ bắt đầu lập trình bằng việc tạo ra một danh sách liên kết dispatch. Nó sẽ là một danh sách liên kết đơn giản có các khóa là những đối số dòng lệnh và giá trị là các hàm. Tất cả những hàm này đều có kiểu [String] -> IO (). Chúng sẽ nhận tham số là danh sách các đối số rồi trả lại một thao tác I/O thực hiện việc xem, thêm, và xóa, v.v.

import System.Environment 
import System.Directory
import System.IO
import Data.List

dispatch :: [(String, [String] -> IO ())]
dispatch =  [ ("add", add)
            , ("view", view)
            , ("remove", remove)
            ]

Ta chưa định nghĩa main, add, view hay remove, vì vậy hãy bắt đầu với main:

main = do
    (command:args) <- getArgs
    let (Just action) = lookup command dispatch
    action args

Trước hết, ta lấy các đối số và gắn chúng vào với (command:args). Nếu bạn còn nhớ cách khớp mẫu, thì viết như trên có nghĩa là đối số thứ nhất sẽ được gắn với command và các đối số còn lại sẽ gắn với args. Nếu ta gọi chương trình được viết bằng cách gõ vào todo add todo.txt "Spank the monkey", thì command sẽ là "add"args sẽ là ["todo.xt", "Spank the monkey"].

Ở dòng tiếp theo, ta tra tìm command trong danh sách dispatch. Vì "add" chỉ đến add, nên ta nhận được kết quả là Just add. Ta dùng khớp mẫu để kết xuất hàm tìm được ra khỏi Maybe. Điều gì sẽ xảy ra nếu command không có trong danh sách dispatch? À, khi đó việc tra tìm (lookup) sẽ trả lại Nothing, nhưng ta đã nói rằng không cần quan tâm đến trường hợp tìm kiếm thất bại nên khi khớp mẫu không thành thì chương trình sẽ nổi cáu.

Sau cùng, ta gọi hàm action với phần còn lại của danh sách đối số. Nó sẽ trả lại một thao tác I/O để thêm một hạng mục, hiển thị danh sách hạng mục hoặc là xóa một hạng mục; và vì thao tác đó thuộc về khối do trong main, nên nó sẽ được thực hiện. Nếu ta theo dõi ví dụ cụ thể này đến giờ và hàm actionadd, thì nó sẽ được gọi với args (tức là ["todo.txt", "Spank the monkey"]) rồi trả lại một thao tác I/O để thêm Spank the monkey vào todo.txt.

Hay quá! Tất cả những gì còn lại bây giờ là việc lập trình add, viewremove. Hãy bắt đầu với add:

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

Nếu ta gọi chương trình của ta như sau: todo add todo.txt "Spank the monkey", thì "add" sẽ được gắn với command trong lần khớp đầu tiên của khối main, còn ["todo.txt", "Spank the monkey"] sẽ được truyền đến hàm mà ta lấy từ danh sách dispatch. Như vậy, vì chúng ta chưa xử lý trường hợp dữ liệu đầu vào xấu, nên ta chỉ khớp mẫu ngay với danh sách có hai phần tử và trả lại một thao tác I/O để thêm dòng đó vào cuối tập tin, kèm theo một kí tự xuống dòng.

Tiếp theo, ta hãy lập tính năng xem danh sách. Nếu muốn xem các hạng mục trong tập tin, ta gõ vào todo view todo.txt. Vì vậy trong phép khớp mẫu thứ nhất, command sẽ là "view" còn args sẽ là ["todo.txt"].

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStr $ unlines numberedTasks

Ta đã viết gần giống như trong chương trình chỉ đảm nhiệm việc xoá hạng mục công việc, đó là khi ta hiển thị công việc để cho người dùng có thể chọn việc cần xoá, chỉ khác là ở đây ta hiển thị các công việc thôi.

Và cuối cùng, ta sẽ viết remove. Nó sẽ rất giống với chương trình đảm nhiệm riêng khâu xoá công việc, vì vậy nếu bạn chưa hiểu cách hoạt xoá hạng mục ở phần dưới đây, thì hãy xem lại phần giải thích từ chương trình trước đó. Điểm khác biệt chủ yếu là ta không viết cố định todo.txt mà lấy nó từ tham số. Ta cũng không nhắc người dùng nhập vào số thứ tự hạng mục cần xoá, mà lấy nó từ tham số.

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    handle <- openFile fileName ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let number = read numberString
        todoTasks = lines contents
        newTodoItems = delete (todoTasks !! number) todoTasks
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile fileName
    renameFile tempName fileName

Ta đã mở tập tin dựa trên fileName và mở một tập tin tạm thời, bỏ bớt dòng có chỉ số mà người dùng muốn xoá, ghi lại nội dung vào tập tin tạm thời, xoá tập tin ban đầu và đổi tên tập tin tạm thời trở lại thành fileName.

Sau đây là toàn bộ nội dung chương trình “hoành tráng” ta viết được!

import System.Environment 
import System.Directory
import System.IO
import Data.List

dispatch :: [(String, [String] -> IO ())]
dispatch =  [ ("add", add)
            , ("view", view)
            , ("remove", remove)
            ]

main = do
    (command:args) <- getArgs
    let (Just action) = lookup command dispatch
    action args

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line) [0..] todoTasks
    putStr $ unlines numberedTasks

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    handle <- openFile fileName ReadMode
    (tempName, tempHandle) <- openTempFile "." "temp"
    contents <- hGetContents handle
    let number = read numberString
        todoTasks = lines contents
        newTodoItems = delete (todoTasks !! number) todoTasks
    hPutStr tempHandle $ unlines newTodoItems
    hClose handle
    hClose tempHandle
    removeFile fileName
    renameFile tempName fileName

fresh baked salad

Tóm tắt lại đáp án này: ta đã lập một danh sách liên kết để ánh xạ từ câu lệnh đến các hàm, hàm này nhận vào tham số dòng lệnh nào đó rồi trả lại một thao tác I/O. Dựa vào nội dung của lệnh mà ta chọn được hàm tương ứng trong danh sách liên kết. Ta gọi hàm đó với phần còn lại đối số dòng lệnh để thu về một thao tác I/O, thao tác này để thực hiện việc thích hợp, rồi chỉ cần thực hiện thao tác đó!

Với các ngôn ngữ lập trình khác, ta có thể viết chương trình này bằn một lệnh chuyển (switch/case) rất lớn, hoặc là cách nào đó, nhưng việc dùng hàm cấp cao đã cho phép ta chỉ cần thông báo đến danh sách liên kết gửi cho một hàm phù hợp rồi bảo hàm đó cho ta một thao tác I/O đối với đối số dòng lệnh nhất định.

Hãy thử chạy chương trình vừa viết xem nào!

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven

$ ./todo add todo.txt "Pick up children from drycleaners"

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
3 - Pick up children from drycleaners

$ ./todo remove todo.txt 2

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Pick up children from drycleaners

Một điều hay khác ở đây là rất dễ thêm vào tính năng mới. Chỉ cần thêm vào một phần tử trong danh sách liên kết và lập trình phần hàm tương ứng với nó là ta đã có thể rung đùi khoái chí! Một bài tập cho bạn: hãy thử lập hàm bump nhận một tập tin và một số thứ tự hạng mục rồi trả lại một thao tác I/O để đẩy hạng mục đó lên đầu danh sách công việc cần làm.

Bạn có thể làm cho chương trình này thoát ra một cách êm đẹp trong trường hợp dữ liệu đầu vào không hợp lệ (chẳng hạn, nếu ai đó chạy todo UP YOURS HAHAHAHA) bằng cách lập một thao tác I/O chỉ để thông báo rằng có lỗi xảy ra (chẳng hạn, errorExit :: IO ()) và rồi kiểm tra xem có sai sót gì ở dữ liệu đầu vào không; và nếu có thì thực hiện thông báo lỗi thao tác I/O. Một cách khác là dùng đến biệt lệ, mà không lâu nữa ta sẽ gặp.

Ngẫu nhiên

this picture is the ultimate source of randomness and wackiness

Nhiều khi lập trình, bạn cần lấy số liệu ngẫu nhiên nào đó. Có thể bạn đang lập một trò chơi phải tung quân xúc sắc hoặc cần tạo ra dữ liệu để kiểm tra chương trình của bạn. Có rất nhiều trường hợp cần dùng đến số liệu ngẫu nhiên khi lập trình. Ồ, đúng hơn là giả ngẫu nhiên, vì ta biết rằng nguồn gốc duy nhất của ngẫu nhiên chỉ bắt nguồn từ con khỉ đi đạp xe một bánh, một tay cầm miếng pho mát và tay kia gãi mông. Ở mục này, ta sẽ xem cách làm cho Haskell phát sinh ra những số liệu dường như ngẫu nhiên.

Trong phần lớn những ngôn ngữ lập trình khác, bạn có trong tay các hàm để trả lại một số ngẫu nhiên nào đó. Mỗi lần gọi hàm này, (hi vọng là) bạn nhận được một số ngẫu nhiên khác trước. Thế còn Haskell thì sao? À, hãy nhớ rằng Haskell là ngôn ngữ lập trình hàm thuần túy. Điều đó nghĩa là nó minh bạch về cơ chế tham chiếu. Tức là một hàm, khi được hai lần cấp những tham số như nhau, thì hai lần đó phải cho ra kết quả giống nhau. Đó là đặc điểm hay vì nó cho phép ta suy luận khác đi về các chương trình và được phép trì hoãn việc lượng giá đến khi thật cần thiết. Nếu tôi gọi một hàm, tôi có thể chắc rằng nó không làm trò đùa cợt gì trước khi trả lại kết quả. Thứ đáng kể chính là kết quả. Tuy vậy, chính điều này lại khiến cho việc lấy số ngẫu nhiên phải cần mẹo một chút. Nếu tôi có một hàm thế này:

randomNumber :: (Num a) => a
randomNumber = 4

Đây không thể nói là hàm số ngẫu nhiên dùng được vì lúc nào nó cũng trả lại số 4, ngay cả khi tôi thuyết phục bạn rằng số 4 là hoàn toàn ngẫu nhiên, được sinh ra khi tôi gieo xúc sắc đi chăng nữa.

Các ngôn ngữ khác làm thế nào để tạo ra các số dường như ngẫu nhiên nhỉ? À, chúng đi thu thập những thông tin khác nhau trong máy tính của bạn, như giờ đồng hồ hiện tại, mức độ di chuyển chuột, tiếng ồn máy tạo ra thế nào, v.v. và dựa vào đó để đưa ra một con số trông có vẻ ngẫu nhiên. Tổ hợp cuả những yếu tố trên (sự ngẫu nhiên này) rất có thể sẽ khác nhau vào lúc này hay lúc khác; vì vậy bạn sẽ thu được những số ngẫu nhiên khác nhau.

À, vì vậy trong Haskell, ta có thể tạo một số ngẫu nhiên nếu ta lập một hàm nhận vào những yếu tố ngẫu nhiên đó làm tham số rồi trên cơ sở đó trả về một con số (hoặc kiểu dữ liệu khác).

Từ đó, module System.Random xuất hiện. Nó gồm tất cả các hàm thỏa mãn nhu cầu tạo ngẫu nhiên. Hãy thử đi sâu vào một hàm mà module này xuất khẩu xem sao, đó là hàm random. Sau đây là kiểu của nó: random :: (RandomGen g, Random a) => g -> (a, g). Oa! Có những lớp mới xuất hiện trong lời khai báo kiểu này! Lớp RandomGen được dành cho những kiểu có thể đóng vai trò nguồn phát sinh ngẫu nhiên. Lớp Random dành cho những thứ có thể nhận giá trị ngẫu nhiên. Một giá trị boole có thể nhận giá trị ngẫu nhiên, True hoặc False. Một con số cũng có thể nhận một loạt các giá trị ngẫu nhiên. Một hàm có thể nhận một giá trị ngẫu nhiên không? Tôi không nghĩ vậy đâu! Nếu ta thử dịch lời khai báo kiểu cho random sang tiếng Anh, thì ta có đại loại như: nó nhận vào một bộ tạo ngẫu nhiên (đó là nguồn phát sinh ngẫu nhiên) và trả lại một giá trị ngẫu nhiên và một bộ tạo ngẫu nhiên mới. Tại sao nó trả lại một bộ tạo ngẫu nhiên mới đi cùng giá trị ngẫu nhiên? À, điều này ta sẽ biết ngay sau đây.

Để dùng được hàm random, ta phải nhúng tay vào một bộ tạo số ngẫu nhiên. Module System.Random xuất khẩu một kiểu dữ liệu rất hay có tên là StdGen vốn là thực thể của lớp RandomGen. Ta có thể tự tạo ra StdGen hoặc có thể bảo hệ thống đưa ta kiểu dữ liệu này dựa trên một loạt những những thứ ngẫu nhiên.

Để tự tạo được một bộ phát sinh ngẫu nhiên, hãy dùng hàm mkStdGen. Hàm này có kiểu mkStdGen :: Int -> StdGen. Nó nhận một số nguyên rồi dựa vào đó, trả lại ta một bộ tạo ngẫu nhiên. Được rồi, hãy thử kết hợp dùng randommkStdGen để thu được một con số (khó có thể gọi là ngẫu nhiên).

ghci> random (mkStdGen 100)
<interactive>:1:0:
    Ambiguous type variable `a' in the constraint:
      `Random a' arising from a use of `random' at <interactive>:1:0-20
    Probable fix: add a type signature that fixes these type variable(s)

Cái gì vậy? A, phải rồi, hàm random có thể trả về một giá trị có kiểu bất kì thuộc về lớp Random, vì vậy ta phải báo cho Haskell biết kiểu mà ta cần là gì. Đồng thời đừng quên rằng nó trả lại một cặp gồm giá trị ngẫu nhiên và bộ tạo ngẫu nhiên.

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

Rồi cũng xong! Một số trông rất là ngẫu nhiên! Phần tử đầu tiên trong cặp là con số ta cần còn phần tử thứ hai là dạng biểu thị cho bộ tạo ngẫu nhiên ta mới nhận được. Điều gì sẽ xảy ra nếu ta lại gọi random với cùng bộ tạo ngẫu nhiên đó?

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

Dĩ nhiên rồi. Kết quả vẫn như vậy đối với tham số cũ. Vì vậy ta hãy thử cung cấp tham số là bộ tạo ngẫu nhiên mới.

ghci> random (mkStdGen 949494) :: (Int, StdGen)
(539963926,466647808 1655838864)

Được rồi, hay đấy, một số khác. Ta có thể dùng chú thích kiểu để lấy đợc những kiểu dữ liệu khác nhau từ hàm này.

ghci> random (mkStdGen 949488) :: (Float, StdGen)
(0.8938442,1597344447 1655838864)
ghci> random (mkStdGen 949488) :: (Bool, StdGen)
(False,1485632275 40692)
ghci> random (mkStdGen 949488) :: (Integer, StdGen)
(1691547873,1597344447 1655838864)

Hãy lập ra một hàm mô phỏng việc tung đồng xu 3 lần. Nếu random chẳng trả lại một bộ tạo ngẫu nhiên mới cùng với giá trị ngẫu nhiên, thì ta đã phải làm cho hàm này nhận tham số là ba bộ tạo ngẫu nhiên rồi trả lại ba lần tung đồng xu ứng với chúng. Nhưng điều này nghe có vẻ sai vì nếu một bộ tạo có thể làm ra giá trị ngẫu nhiên kiểu Int (vốn có thể nhận lấy nhiều giá trị khác nhau), thì nó cũng có thể tạo được kết quả ba lần tung xu (tức là có thể nhận lấy 8 tổ hợp khác nhau). Vì vậy, đây chính là lúc mà việc random trả lại bộ phát sinh mới kèm theo với giá trị lại trở nên tiện dụng.

Ta sẽ thể hiện một lượt tung đồng xu bằng giá trị Bool. True ứng với sấp, còn False là ngửa.

threeCoins :: StdGen -> (Bool, Bool, Bool)
threeCoins gen = 
    let (firstCoin, newGen) = random gen
        (secondCoin, newGen') = random newGen
        (thirdCoin, newGen'') = random newGen'
    in  (firstCoin, secondCoin, thirdCoin)

Ta gọi random cùng với tham số là bộ phát sinh hiện có, để thu được lần tung đồng xu và bộ phát sinh mới. Sau đó ta gọi lại nó, lần này với bộ phát ính mới, để được lượt tung đồng xu thứ hai. Làm tương tự, ta được lần tung xu thứ ba. Nếu đã gọi random cả ba lần với cùng một bộ phát sinh thì tất cả lần tung xu đều giống nhau và ta chỉ có thể nhận được kết quả là (False, False, False) hoặc (True, True, True).

ghci> threeCoins (mkStdGen 21)
(True,True,True)
ghci> threeCoins (mkStdGen 22)
(True,False,True)
ghci> threeCoins (mkStdGen 943)
(True,False,True)
ghci> threeCoins (mkStdGen 944)
(True,True,True)

Lưu ý rằng ta không nhất thiết phải viết random gen :: (Bool, StdGen). Đó là vì ta đã chỉ định rằng ta cần các giá trị boole ở phần khai báo kiểu hàm. Điều này lý giải tại sao trong trường hợp này, Haskell có thể suy luận được ràng ta muốn một giá trị boole.

Vậy sẽ ra sao nếu ta muốn tung bốn đồng xu? Hay năm? Ồ, đã có một hàm tên là randoms để nhận một bộ phát sinh và trả lại một dãy vô hạn các giá trị dựa trên bộ phát sinh này.

ghci> take 5 $ randoms (mkStdGen 11) :: [Int]
[-1807975507,545074951,-1015194702,-1622477312,-502893664]
ghci> take 5 $ randoms (mkStdGen 11) :: [Bool]
[True,True,True,True,False]
ghci> take 5 $ randoms (mkStdGen 11) :: [Float]
[7.904789e-2,0.62691015,0.26363158,0.12223756,0.38291094]

Tại sao randoms lại không trả lại một danh sách chứa những bộ sinh mới chứ? Ta có thể tự viết hàm randoms thật dễ dàng như sau:

randoms' :: (RandomGen g, Random a) => g -> [a]
randoms' gen = let (value, newGen) = random gen in value:randoms' newGen

Một lời định nghĩa theo hình thức đệ quy. Ta thu được một giá trị ngẫu nhiên và một bộ sinh mới từ bộ sinh hiện thời rồi lập nên một danh sách có giá trị này làm phần tử đầu và những số ngẫu nhiên dựa theo bộ sinh mới làm phần đuôi danh sách. Vì buộc phải có khả năng phát sinh được vô hạn những con số ngẫu nhiên khác nhau, nên ta không thể đưa trả lại bộ sinh ngẫu nhiên nữa.

Ta có thể lập nên một hàm phát sinh ra dãy hữu hạn các số ngẫu nhiên và một bộ sinh mới như sau:

finiteRandoms :: (RandomGen g, Random a, Num n) => n -> g -> ([a], g)
finiteRandoms 0 gen = ([], gen)
finiteRandoms n gen = 
    let (value, newGen) = random gen
        (restOfList, finalGen) = finiteRandoms (n-1) newGen
    in  (value:restOfList, finalGen)

Một lần nữa là lời định nghĩa đệ quy. Ta nói rằng nếu ta muốn 0 só ngẫu nhiên thì chỉ việc trả lại danh sách rỗng và bộ sinh vừa nhận được. Với bất kì số lượng số ngẫu nhiên nào khácc trước hết là ta lấy một số ngẫu nhiên và một bộ sinh mới. Đó sẽ là đầu danh sách. Tiếp theo ta nói rằng phần đuôi danh sách sẽ là n – 1 con số được phát sinh từ bộ sinh mới. Sau đó ta trả lại phần đầu sau khi đã nối với phần còn lại của danh sách, cùng với bộ sinh cuối cùng khi đã thu được n – 1 số ngẫu nhiên.

Đuều gì sẽ xảy ra nếu ta muốn một giá trị ngẫu nhiên trong một khoảng nào đó? Tất cả những số nguyên ngẫu nhiên cho đến giờ đều quá lớn hoặc nhỏ. Nếu ta muốn gieo xúc sắc thì sao? À, khi đó ta có thẻ dùng randomR. Nó có kiểu là randomR :: (RandomGen g, Random a) :: (a, a) -> g -> (a, g), nghĩa là nó giống với random, chỉ khác là nó nhận tham số thứ nhất là một cặp giá trị để đặt các giới hạn dưới và trên sao cho kết quả tìm được phải nằm trong những giới hạn đó.

ghci> randomR (1,6) (mkStdGen 359353)
(6,1494289578 40692)
ghci> randomR (1,6) (mkStdGen 35935335)
(3,1250031057 40692)

Cũng có hàm randomRs, để tạo ra một dãy các giá trị ngẫu nhiên trong khoảng mà ta định nghĩa. Hãy gõ thử lệnh sau:

ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
"ndkxbvmomg"

Hay đấy, trông như mật khẩu nào đó.

Bạn có thể sẽ tự hỏi rằng phần này thì liên quan gì đề I/O chứ? Đến giờ, ta vẫn chưa làm gì dính líu đến I/O. À, cho đến giờ ta mới chỉ tự tay lập ra bộ sinh số ngẫu nhiên từ một số nguyên nào đó. Vấn đề ở đây là, nếu ta viết như vậy trong chương trình thật, thì chúng sẽ luôn trả về cùng một số ngẫu nhiên thôi, điều này hẳn là không hay. Thế nên System.Random cho phép dùng thao tác I/O có tên getStdGen, vốn có kiểu là IO StdGen. Khi chương trình bắt đầu, nó yêu cầu hệ thống cung cấp một bộ sinh số ngẫu nhiên tốt rồi lưu nó vào một thứ gọi là bộ phát sinh tổng thể. getStdGen sẽ đi lấy giúp bạn bộ sinh ngẫu nhiên tổng thể đó khi bạn gắn nó [vào một tên bất kì].

Sau đây là một chương trình đơn giản để phát sinh ra một chuỗi ngẫu nhiên.

import System.Random

main = do
    gen <- getStdGen
    putStr $ take 20 (randomRs ('a','z') gen)
$ runhaskell random_string.hs
pybphhzzhuepknbykxhe
$ runhaskell random_string.hs
eiqgcxykivpudlsvvjpg
$ runhaskell random_string.hs
nzdceoconysdgcyqjruo
$ runhaskell random_string.hs
bakzhnnuzrkgvesqplrx

Song cũng phải cẩn thận, nếu chỉ thực hiện getStdGen hai lần thì cũng có nghĩa là yêu cầu hệ thống cung cấp cùng một bộ sinh tổng thể hai lần. Nếu bạn viết như sau:

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)
    gen2 <- getStdGen
    putStr $ take 20 (randomRs ('a','z') gen2)

thì cả hai lần đều sẽ in ra chuỗi giống nhau! Một cách làm để nhận được hai chuỗi có độ dài 20 kí tự khác nhau là lập nên một dòng vô hạn rồi lấy 20 kí tự đầu để in ra thành một dòng, tiếp theo là 20 kí tự sau đó in ra thành dòng thứ hai. Để làm điều này, ta có thể dùng hàm splitAt trong Data.List, có tác dụng là phân đôi danh sách ở một vị trí chỉ số nào đó rồi trả lại một bộ có phần đầu là thành phần thứ nhất và phần sau là thành phần thứ hai [của danh sách đưa vào].

import System.Random
import Data.List

main = do
    gen <- getStdGen
    let randomChars = randomRs ('a','z') gen
        (first20, rest) = splitAt 20 randomChars
        (second20, _) = splitAt 20 rest
    putStrLn first20
    putStr second20

Một cách khác là dùng thao tác newStdGen, vốn để phân đôi bộ phát sinh ngẫu nhiên hiện giờ thành hai bộ phát sinh khác. Thao tác này cập nhật bộ phát sinh ngẫu nhiên tổng thể thành một trong hai bộ mới rồi chứa đựng bộ còn lại dưới dạng kết quả.

import System.Random

main = do   
    gen <- getStdGen   
    putStrLn $ take 20 (randomRs ('a','z') gen)   
    gen' <- newStdGen
    putStr $ take 20 (randomRs ('a','z') gen')

Khi gắn newStdGen vào một thứ, ta không chỉ thu được một bộ phát sinh ngẫu nhiên mới mà cả bộ phát sinh tổng thể cũng được cập nhật, vì vậy nếu viết getStdGen rồi lại gắn nó vào một thứ khác, thì ta sẽ thu được một bộ phát sinh khác với gen.

Sau đây là một chương trình ngắn cho phép người dùng dự đoán con số mà máy tính đang nghĩ.

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    askForNumber gen

askForNumber :: StdGen -> IO ()
askForNumber gen = do
    let (randNumber, newGen) = randomR (1,10) gen :: (Int, StdGen)
    putStr "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString
        if randNumber == number 
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
        askForNumber newGen

jack of diamonds

Ta lập một hàm askForNumber; nó nhận một bộ phát sinh số ngẫu nhiên và trả lại một thao tác I/O để nhắc người dùng nhập vào một con số rồi thông báo lại cho họ biết là có trúng với số mà máy đang nghĩ không. Trong hàm này, trước hết ta phát sinh một số ngẫu nhiên và một bộ sinh mới dựa trên bộ sinh mà ta đã nhận được với vai trò tham số, rồi gọi chúng là randNumbernewGen. Giả sử rằng số được phát sinh ra là 7. Sau đó báo cho người dùng đoán số mà ta [máy] đang nghĩ. Ta thực hiện getLine rồi gắn kết quả thu được vào numberString. Khi người dùng nhập vào số 7, numberString sẽ trở thành "7". Tiếp theo, ta dùng when để kiểm tra xem liệu chuỗi mà người dùng nhập vào có rỗng không. Nếu có, một thao tác I/O rỗng là return () sẽ được thực hiện, và chương trình kết thúc. Nếu không, thao tác chứa khối do ngay ở đó được thực hiện. Ta dùng read đối với numberString để biến đổi nó thành một con số, vì vậy bây giờ number bằng 7.

Cho tôi xin lỗi! Nếu ở đây người dùng nhập vào dữ liệu mà read không thể đọc được (chẳng hạn "haha"), thì chương trình sẽ đổ vỡ cùng với một thông báo lỗi thô kệch. Nếu bạn không muốn chương trình mình viết phải đổ vỡ khi có dữ liệu sai bị nhập vào thì hãy dùng reads; hàm này trả về một danh sách rỗng nếu nó không đọc được một chuỗi. Còn nếu thành công, thì nó trả lại một danh sách một phần tử là một bộ có chứa giá trị ta cần và một chuỗi chứa tất cả những gì không đọc được.

Ta kiểm tra xem liệu số được nhập vào có bằng với số được phát sinh ngẫu nhiên không rồi trả lại người dùng một thông báo thích hợp. Sau đó ta gọi askForNumber một cách đệ quy, nhưng lần này thì với bộ phát sinh mới mà ta vừa nhận được, và kết quả trả lại là một thao tác I/O gần giống như cái mà ta đã thực hiện, chỉ khác ở bộ phát sinh; và rồi ta thực hiện thao tác I/O này.

main chỉ bao gồm việc lấy một bộ phát sinh ngẫu nhiên từ hệ thống rồi gọi askForNumber với bộ phát sinh đó để thu được thao tác ban đầu.

Sau đây là phần thể hiện của chương trình vừa viết.

$ runhaskell guess_the_number.hs
Which number in the range from 1 to 10 am I thinking of? 4
Sorry, it was 3
Which number in the range from 1 to 10 am I thinking of? 10
You are correct!
Which number in the range from 1 to 10 am I thinking of? 2
Sorry, it was 4
Which number in the range from 1 to 10 am I thinking of? 5
Sorry, it was 10
Which number in the range from 1 to 10 am I thinking of?

Một cách khác để viết chương trình này sẽ là:

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    let (randNumber, _) = randomR (1,10) gen :: (Int, StdGen)   
    putStr "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString
        if randNumber == number
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
        newStdGen
        main

Rất giống với bản trước, nhưng lần này thay vì tạo lập một hàm nhận vào một bộ phát sinh rồi gọi no một cách đệ quy với bộ phát sinh mới cập nhật, ta thực hiện tất cả công việc trong main. Sau khi báo cho người dùng biết rằng liệu họ có đoán đúng hay không, ta cập nhật bộ phát sinh tổng thể rồi sau đó gọi lại main. Cả hai cách đều đúng nhưng tôi thích cách làm thứ nhất hơn vì nó thực hiện ít việc trong main đồng thời cũng cho ta một hàm mà có thể dễ dàng dùng được.

Chuỗi byte

like normal string, only they byte ... what a pedestrian pun this is

Danh sách là cấu trúc dữ liệu có ích và hay. Cho đến giờ, hầu như chỗ nào ta cũng dùng đến nó. Có một loạt những hàm thao tác trên danh sách và tính lười biếng của Haskell cho phép ta thay các vòng lặp for và while ở các ngôn ngữ khác thành lọc và ánh xạ trên danh sách, vì việc định giá chỉ xảy ra khi thực sự cần thiết nên những khái niệm như danh sách vô hạn (và thậm chí cả danh sách vô hạn chứa những danh sách vô hạn!) đối với ta đã không thành vấn đề. Đó là lý do mà danh sách cũng có thể được dùng để biểu diễn các dòng, khi đọc dữ liệu từ thiết bị vào chuẩn hay đọc từ tập tin. Ta có thể đơn giản là mở tập tin rồi đọc nội dung của nó dưới dạng một chuỗi, mặc dù chuỗi này chỉ được truy cập đến khi cần.

Tuy nhiên, việc xử lý tập tin dưới dạng chuỗi có một nhược điểm: chậm chạp. Bạn biết đấy, String là kiểu tương đồng với [Char]. Các Char không có một kích thước cố định, vì để biểu diễn một kí tự trong bảng mã Unicode chẳng hạn sẽ tốn vài byte. hơn nữa, danh sách thực sự rất lường biếng. Nếu bạn có một danh sách như [1,2,3,4], nó sẽ chỉ được lượng giá khi hoàn toàn cần thiết. Vì vậy cả danh sách sẽ tựa như một lời hứa (hay cam kết) về danh sách. Hãy nhớ lại rằng [1,2,3,4] là cách viết tiện lợi cho 1:2:3:4:[]. Khi phần tử đầu của danh sách buộc phải được định giá (chẳng hạn khi in nó ra), thì phần còn lại của danh sách, 2:3:4:[] vẫn chỉ là một cam kết về danh sách, và cứ như vậy. Bởi thế, bạn có thể hình dung danh sách như những cam kết rằng phần tử kế tiếp sẽ được phân phát ngay khi cần và kèm theo nó là lời cam kết cho phần tử tiếp nữa. Chẳng cần phải nghĩ nhiều cũng thấy được rằng việc xử lý một danh sách số đơn giản dưới dạng một dãy những cam kết thế này chắc sẽ không hiệu quả.

Với phần nhiều trường hợp thì gánh nặng kể trên không ảnh hưởng đáng kể, nhưng sẽ là một trách nhiệm nặng nề khi ta phải đọc một tập tin lớn và xử lý dữ liệu trong đó. Vì vậy, Haskell đã có chuỗi byte. Chuỗi byte như thể các danh sách, chỉ khác là mỗi phần tử có kích thước đều là một byte (hoặc 8 bit). Cách mà chuỗi byte xử lý tính lười biếng cũng khác hẳn.

Chuỗi byte có hai loại: chặt chẽ và lười. Chuỗi byte nằm trong Data.ByteString và chúng hoàn toàn tránh khỏi tính lười biếng. Không có bất kì cam kết nào, một chuỗi byte chặt chẽ biểu thị một dãy các byte theo một mảng. Bạn không thể có những khái niệm như chuỗi byte chặt chẽ vô hạn. Nếu bạn định giá byte thứ nhất của một chuỗi byte chặt chẽ, bạn sẽ phải định giá toàn bộ chuỗi. Ưu điểm của điều này là có ít gánh nặng hơn vì không có các “thunk” (thuật ngữ chỉ cam kết). Nhược điểm là chúng có khả năng làm đầy tràn bộ nhớ một cách nhanh chóng hơn vì toàn bộ nội dung được đọc vào bộ nhớ cùng lúc.

Loại thứ hai của chuỗi byte nằm trong Data.ByteString.Lazy. Chúng lười biếng, nhưng không đến mức như danh sách. Như ta đã nói từ trước, trong danh sách có bao nhiêu phần tử thì cũng có bấy nhiêu cam kết. Điều này khiến cho danh sách trở nên chậm chạp đối với những mục đích nhất định. Chuỗi byte lười biếng có một phương pháp khác — chúng được lưu trong các bó (chunk) (chứu không phải “thunks”!), mỗi bó có kích thước là 64K. Vì vậy nếu bạn định giá một byte trong một chuỗi byte lười biếng (bằng cách in ra hoặc làm gì đó), thì 64K đầu tiên sẽ được định giá. Sau đó là một lời cam kết đến các bó còn lại. Chuỗi byte lười biếng như thể danh sách các chuỗi byte chặt chẽ với kích thước 64K. Khi bạn xử lý một tập tin bằng chuỗi byte lười biếng, nó sẽ đọc từng bó một. Điều này rất hay vì nó sẽ tránh không cho phần bộ nhớ được dùng nhảy vọt lên và lượng 64K có lẽ sẽ đủ chứa trong cache cấp 2 của CPU.

Nếu nhìn xuyên suốt tài liệu hướng dẫn về Data.ByteString.Lazy, bạn sẽ thấy rằng module này còn nhiều hàm nữa có cùng tên với những hàm trong Data.List, chỉ khác là dấu ấn kiểu có chứa ByteString thay vì [a]Word8 thay vì a. những hàm có cùng tên này gần như hoạt động giống những hàm hoạt động với danh sách. Vì chúng có cùng tên nên ở mã lệnh chương trình ta sẽ phải viết lệnh nhập chọn lọc và rồi nạp chương trình này vào trong GHCI thì mới nghịch với chuỗi byte được.

import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S

B có những hàm và kiểu là chuỗi byte lười biếng, còn S: với chuỗi byte chặt chẽ. ta sẽ sử dụng chủ yếu loại lười biếng.

Hàm pack có dấu ấn kiểu pack :: [Word8] -> ByteString. Điều này nghĩa là nó nhận một danh sách các byte có kiểu Word8 rồi trả lại một ByteString. Bạn có thể hình dung là nó lấy một danh sách, mà bản thân danh sách có tính lười biếng, rồi làm nó “bớt lười biếng đi”, theo nghĩa chỉ lười biếng ở phạm vi những khoảng 64K thôi.

Thế còn kiểu Word8 kia là gì vậy? À, kiểu dữ liệu này cũng giống Int, chỉ khác là có khoảng nhỏ nhỏ hơn nhiều, từ 0 đến 255. Nó biểu diễn một số 8-bit. Và cuxgn như Int, nó thuộc về lớp Num. Chẳng hạn, ta biết rằng giá trị 5 có tính đa hình (polymorphic) theo nghĩa là nó có thể đóng vai trò của bất kì kiểu số nào. À, vậy thì nó cũng có thể nhận kiểu Word8.

ghci> B.pack [99,97,110]
Chunk "can" Empty
ghci> B.pack [98..120]
Chunk "bcdefghijklmnopqrstuvwx" Empty

Bạn thấy đấy, thường bạn sẽ không phải lo lắng gì nhiều về Word8, vì hệ thống kiểu có thể giúp cho các số chọn được kiểu đó. Nếu bạn thử dùng một số lớn, như 336 với kiểu Word8, thì nó chỉ việc cuộn lại để 336 trở thành còn 80.

Ta đã gói một ít những giá trị vào trong một ByteString, để nó vừa vặn trong một bó. Cái Empty thì cũng giống như [] đối với danh sách.

unpack là hàm ngược của pack. Nó nhận một chuỗi byte rồi chuyển nó thành một danh sách các byte.

fromChunks nhận một danh sách các chuỗi byte chặt chẽ rồi chuyển đổi thành một danh sách các chuỗi byte lười biếng. toChunks nhận một chuỗi byte lười biếng rồi chuyển đổi thành một danh sách các chuỗi byte chặt chẽ.

ghci> B.fromChunks [S.pack [40,41,42], S.pack [43,44,45], S.pack [46,47,48]]
Chunk "()*" (Chunk "+,-" (Chunk "./0" Empty))

Điều này sẽ tốt nếu bạn có nhiều chuỗi byte chặt chẽ ngắn và muốn xử lý chúng một cách hiệu quả mà từ đầu không cần phải kết nối thành một chuỗi byte chặt chẽ dài đặt trong bộ nhớ.

Dạng chuỗi byte của hàm : được gọi là cons. Hàm này nhận một byte và một chuỗi byte rồi đặt byte đó lên đầu chuỗi. Tuy vậy nó có tính lười biếng, vì vậy nó sẽ tạo ra một bó mới ngay cả khi bó đầu tiên trong chuỗi byte chưa đầy. Đó là lý do mà ta nên dùng dạng chặt chẽ của conscons' nếu bạn cần phải chèn nhiều byte vào đầu một chuỗi byte.

ghci> B.cons 85 $ B.pack [80,81,82,84]
Chunk "U" (Chunk "PQRT" Empty)
ghci> B.cons' 85 $ B.pack [80,81,82,84]
Chunk "UPQRT" Empty
ghci> foldr B.cons B.empty [50..60]
Chunk "2" (Chunk "3" (Chunk "4" (Chunk "5" (Chunk "6" (Chunk "7" (Chunk "8" (Chunk "9" (Chunk ":" (Chunk ";" (Chunk "<"
Empty))))))))))
ghci> foldr B.cons' B.empty [50..60]
Chunk "23456789:;<" Empty

Bạn thấy đấy, empty tạo ra một chuỗi byte. Bạn thấy điểm khác biệt giữa conscons' chứ? Bằng foldr, ta bắt đầu với một chuỗi byte rỗng rồi sau đó duyệt theo danh sách số từ bên phải, thêm mỗi số vào đầu chuỗi byte. Khi dùng cons, cuối cùng ta thu được một bó ứng với từng byte, đúng là chẳng bõ công làm.

Ngược lại, các module bytestring có hàng tá các hàm tương tự như trong Data.List, bao gồm (và ngoài ra còn nữa) head, tail, init, null, length, map, reverse, foldl, foldr, concat, takeWhile, filter, v.v.

Module này cũng có các hàm có tên giống và biểu hiện giống với một số hàm có trong System.IO, chỉ khác là các chữ String được thay bằng ByteString. Chẳng hạn, hàm readFile trong System.IO có kiểu là readFile :: FilePath -> IO String, trong khi đó readFile trong module bytestring có kiểu readFile :: FilePath -> IO ByteString. Cẩn thận nhé out, nếu bạn đang dùng chuỗi byte chặt chẽ mà cố gắng đọc một tập tin, thì cả tập tin sẽ lập tức được đọc vào bộ nhớ ngay! Với chuỗi byte lười biếng, nó sẽ được đọc gọn gàng theo từng bó.

Ta hãy lập một chương trình đơn giản nhận hai tên tập tin làm đối số dòng lệnh rồi sao chép tập tin thứ nhất vào tập tin thứ hai. Lưu ý rằng System.Directory đã có sẵn một hàm gọi là copyFile, nhưng ta sẽ tự viết hàm sao chép và cả chương trình.

import System.Environment
import qualified Data.ByteString.Lazy as B

main = do
    (fileName1:fileName2:_) <- getArgs
    copyFile fileName1 fileName2

copyFile :: FilePath -> FilePath -> IO ()
copyFile source dest = do
    contents <- B.readFile source
    B.writeFile dest contents

Ta làm cho hàm được viết nhận vào hai FilePath (nhớ lại rằng FilePath chỉ là một kiểu tương đồng với String) rồi trả lại một thao tác I/O để sao chép một tập tin vào tập tin khác dùng có đến chuỗi byte. Trong hàm main, ta chỉ lấy các đối số rồi gọi hàm vừa viết với chúng để thu được thao tác I/O, mà sau đó sẽ được thực hiện.

$ runhaskell bytestringcopy.hs something.txt ../../something.txt

Lưu ý rằng một chương trình không dùng chuỗi byte sẽ trông giống như thế này; điểm khác biệt là ở đây ta đã dùng B.readFileB.writeFile thay vì readFilewriteFile. Nhiều khi, bạn có thể chuyển đổi một chương trình có dùng chuỗi byte chỉ bằng cách nhập các module cần thiết và rồi đặt những tên module chọn lọc vào trước một số hàm. Đôi khi, bạn phải chuyển đổi hàm được viết để hoạt động với chuỗi sao cho chúng hoạt động với chuỗi byte, nhưng việc này không khó khăn.

Mỗi khi cần làm chương trình chạy tốt hơn trong trường hợp đọc nhiều số liệu vào chuỗi, bạn hãy thử dùng chuỗi byte; nhiều khả năng là hiệu năng chương trình sẽ tăng vọt mà bạn lại ít tốn sức. Tôi thường viết chương trình dùng các chuỗi thường rồi sau đó chuyển sang dùng chuỗi byte nếu hiệu năng chương trình chưa đáp ứng đủ nhu cầu.

Biệt lệ

timberr!!!!

Mọi ngôn ngữ lập trình đều chứa những thủ tục, hàm, và đoạn mã lệnh có khả năng hỏng hóc theo cách nào đó. Đây là điều tất yếu. Những ngôn ngữ khác nhau có những cách xử lý hỏng hóc theo cách khác nhau. Trong C, ta thường dùng một giá trị trả về bất thường noà đó (như -1 hoặc một con trỏ rỗng [null pointer]) để chỉ định rằng giá trị mà một hàm trả về không nên được coi là giá trị bình thường. Còn Java và C#, thì thường hay dùng biệt lệ để xử lý những hỏng hóc này. Khi biệt lệ được tung ra, luồng thực hiện chương trình sẽ nhảy đến một đoạn mã lệnh nào đó mà ta đã chỉ định để đảm nhiệm việc dọn dẹp, và sau đó có thể là tung lại biệt lệ để những đoạn mã đảm nhiệm khác sẽ được thực thi.

Haskell có một hệ thống kiểu rất tốt. Các kiểu dữ liệu đại số cho phép có những kiểu như MaybeEither và ta có thể dùng những giá trị của các kiểu này để biểu thị cho kết quả hiện hữu hoặc không. Trong C, việc trả lại -1 khi có sự cố chẳng hạn, hoàn toàn là cách làm tuỳ tiện. Cách này chỉ có ý nghĩa với con người. Nếu không cẩn thận, ta sẽ có thể coi những giá trị bất thường đó là bình thường và sẽ gây ra hiểm hoạ trong chương trình. Về phương diện này, hệ thống kiểu của Haskell cung cấp sự an toàn cần thiết. Một hàm a -> Maybe b chỉ định rõ, nó có thể tạo ra giá trị b gói trong Just hoặc nó sẽ trả lại Nothing. Kiểu dữ liệu này khác hẳn so với a -> b đơn thuần và nếu ta thử dùng lẫn lộn hai kiểu dữ liệu trên thì trình biên dịch sẽ có ý kiến phàn nàn ngay.

Mặc dù đã có những kiểu dữ liệu biểu đạt tốt cho những trường hợp hỏng hóc, Haskell vẫn hỗ trợ các biệt lệ, vì chúng có ý nghĩa trong I/O. Nhiều việc có thể hỏng khi tiến hành ở môi trường ngoài vì môi trường này không tin cậy. Chẳng hạn, khi mở tập tin, một loạt những điều hỏng hóc có thể xảy ra. Tập tin có thể bị khoá, có thể tập tin không ở đó, hoặc ổ đĩa không có ở đó, hoặc một điều gì khác. Vì vậy tốt hơn là có khả năng cho luồng chương trình nhảy đến một phần xử lý lỗi trong mã lệnh chương trình khi có lỗi đại loại như vậy xảy ra.

Được rồi, vậy mã lệnh I/O (nghĩa là không thuần tuý) có thể tung ra các biệt lệ. Có lý đấy. Nhưng còn mã lệnh thuần tuý thì sao? À, nó cũng có thể tung biệt lệ. Hãy hình dung các hàm divhead. Chúng lần lượt có kiểu là (Integral a) => a -> a -> a[a] -> a. Không có Maybe hoặc Either trong phần kiểu dữ liệu được trả lại, mà hai hàm này thì có thể thất bại trong tính toán chứ! div sẽ nổ tung nếu bạn thử chia cho số không, còn head sẽ tức điên lên nếu bạn đưa nó một danh sách rỗng.

ghci> 4 `div` 0
*** Exception: divide by zero
ghci> head []
*** Exception: Prelude.head: empty list

Stop right there, criminal scum! Nobody breaks the law on my watch! Now pay your fine or it's off to jail.

Mã lệnh thuần tuý có thể tung biệt lệ, nhưng biệt lệ này chỉ bắt được trong phần I/O của mã lệnh được viết thôi (khi ta ở trong khối do thuộc về main). Đó là bởi vì bạn không biết khi nào (hoặc nếu như) một thứ bất kì được định giá trong mã lệnh thuần tuý, vì nó có tính lười biếng và không có thứ tự thực thi rõ ràng, nhưng còn mã lệnh I/O thì có.

Trước đây, ta đã nói về cách làm thế nào để dành càng ít thời gian cho phần I/O của mã lệnh chương trình viết ra thì càng tốt. Lô-gic của chương trình cần được đặt chủ yếu ở trong các hàm thuần tuý, vì kết quả của chúng chỉ phụ thuộc vào những tham số khi gọi hàm. Khi làm việc với hàm thuần tuý, bạn chỉ phải nghĩ về thứ mà hàm này trả về, vì nó không thể làm bất kì điều gì khác. Điều này làm cho mọi việc dễ dàng hơn. Mặc dù thực hiện một thao tác lô-gic trong I/O là cần thiết (như mở tập tin và những việc đại loại như vậy) song điều này nên được giữ ở mức tối thiểu. Các hàm thuần tuý đều mặc định là lười biếng, nghĩa là ta không biết khi nào chúng sẽ được định giá và điều đó cũng chẳng quan trọng gì. Tuy nhiên, một khi hàm thuần tuý bắt đầu tung biệt lệ, thì điều quan trọng là biết thời điểm chúng được lượng giá. Đó là nguyên nhân mà ta chỉ có thể bắt những biệt lệ vốn tung ra từ các hàm thuần tuý ở trong phần I/O của mã lệnh. Và điều đó không hay, vì ta muốn giữ cho phần I/O càng nhỏ càng tốt. Tuy nhiên, nếu ta không bắt chúng trong phần I/O của mã lệnh, thì chương trình sẽ đổ vỡ. Giải quyết sao đây? Đừng có trộn lẫn biệt lệ với mã lệnh thuần tuý. Hãy tận dụng hệ thống kiểu ưu việt của Haskell và dùng những kiểu như EitherMaybe để biểu diễn các kết quả có thể hỏng hóc.

Đó là lý do mà bây giờ ta sẽ chỉ xem cách dùng biệt lệ I/O. Biệt lệ I/O là những biệt lệ được gây ra khi có thứ gì đó bị hỏng hóc trong quá trình ta giao tiếp với môi trường bên ngoài trong một thao tác I/O thuộc về main. Chẳng hạn, ta có thể thử mở tập tin và hoá ra là tập tin đó đã bị xoá hoặc đại loại như vậy. Hãy xem chương trình sau, được viết để mở tập tin có tên được cho bởi đối số dòng lệnh và báo cho chúng ta biết tập tin này có bao nhiêu dòng chữ.

import System.Environment
import System.IO

main = do (fileName:_) <- getArgs
          contents <- readFile fileName
          putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

Một chương trình rất đơn giản. Ta thực hiện thao tác I/O getArgs và gắn chuỗi thứ nhất trong danh sách mà nó trả lại vào fileName. Tiếp theo, ta gọi nội dung của tập tin có tên nói trên là contents. Cuối cùng, ta áp dụng lines với những nội dung đó để thu về một danh sách các dòng rồi lấy chiều dài danh sách đó để đưa vào show nhằm thu được một chuỗi biểu diễn cho con số đó. Chương trình hoạt động như ta trông đợi, song điều gì sẽ xảy ra nếu ta cung cấp tên của tập tin vốn không tồn tại?

$ runhaskell linecount.hs i_dont_exist.txt
linecount.hs: i_dont_exist.txt: openFile: does not exist (No such file or directory)

A ha, ta nhận được lỗi từ GHC, báo cho ta biết rằng tập tin này không tồn tại. Chương trình bị đổ vỡ. Làm thế nào nếu ta muốn in ra một lời thông báo đẹp hơn trong trường hợp tập tin không tồn tại? Một cách làm việc này là trước khi thử mở tập tin, ta đi kiểm tra sự tồn tại của nó bằng hàm doesFileExist trong System.Directory.

import System.Environment
import System.IO
import System.Directory

main = do (fileName:_) <- getArgs
          fileExists <- doesFileExist fileName
          if fileExists
              then do contents <- readFile fileName
                      putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"
              else do putStrLn "The file doesn't exist!"

Ta đã viết fileExists <- doesFileExist fileName bởi vì doesFileExist có kiểu là doesFileExist :: FilePath -> IO Bool, nghĩa là nó trả lại một thao tác I/O có kết quả là một giá trị boole để báo cho ta biết rằng liệu tập tin có tồn tại không. Ta không thể chỉ dùng doesFileExist trực tiếp trong một biểu thức if.

Một giải pháp khác ở đây là dùng đến biệt lệ. Hoàn toàn chấp nhận được nếu ta dùng chúng trong tình huống này. Một tập tin không tồn tại là một biệt lệ nảy sinh từ I/O, vì vậy bắt biệt lệ này trong I/O là rất đẹp.

Để xử lý điều này bằng cách dùng biệt lệ, ta có thể lợi dụng hàm catch trong System.IO.Error. Kiểu của nó là catch :: IO a -> (IOError -> IO a) -> IO a. Hàm này nhận hai tham số. Tham số thứ nhất là một thao tác I/O; chẳng hạn, có thể là một thao tác I/O để thử mở một tập tin. Tham số thứ hai được gọi là chuôi (handler). Nếu như thao tác I/O thứ nhất được truyền đến catch mà tung ra biệt lệ I/O, thì biệt lệ này được truyền cho chuôi, theo đó sẽ quyết định cần phải làm gì tiếp. Như vậy kết quả cuối cùng là một thao tác I/O hoặc là sẽ đóng vai trò giống như của tham số thứ nhất, hoặc là sẽ làm việc mà chuôi yêu cầu trong trường hợp thao tác I/O thứ nhất tung ra biệt lệ.

non sequitor

Nếu bạn đã quen với khối try-catch trong các ngôn ngữ như Java hoặc Python, thì hàm catch cũng tương tự. tham số thứ nhất là thứ cần thử (try), kiểu như là thứ nằm trong khối try trong các ngôn ngữ mệnh lệnh khác. tham số thứ hai là chuôi và nhận một biệt lệ, cũng như hầu hết các khối catch nhận biệt lệ mà bạn có thể từ đó để kiểm tra xác định rằng điều gì đã xảy đến. Phần chuôi sẽ được kích hoạt khi biệt lệ được tung.

Chuôi nhận vào một giá trị có kiểu IOError, vốn là giá trị chỉ định rằng có biệt lệ I/O đã xảy ra. Chuôi cũng mang theo thông tin về kiểu của biệt lệ được tung. Còn cách thực hiện của kiểu biệt lệ này thế nào thì tuỳ thuộc vào bản thân ngôn ngữ, nghĩa là ta không thể khảo sát các gí trị thuộc về kiểu IOError bằng cách khớp mẫu với chúng, cũng như ta không thể khớp mẫu với những giá trị thuộc kiểu IO gì đó. Ta có thể dùng một loạt các vị từ có ích để tìm ra thông tin về các giá trị thuộc kiểu IOError như sẽ thấy ngay sau đây.

Bây giờ ta hãy sử dụng người bạn mới catch nhé!

import System.Environment
import System.IO
import System.IO.Error

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
           contents <- readFile fileName
           putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e = putStrLn "Whoops, had some trouble!"

Trước hết, bạn sẽ thấy rằng việc đặt cặp dấu nháy ngược quanh catch cho phép ta dùng nó như một hàm trung tố, vì hàm này nhận hai tham số. Việc dùng hàm này theo cách trung tố sẽ dễ nhìn hơn. Như vậy toTry `catch` handler thì cũng giống với catch toTry handler, vốn rất hợp với kiểu của nó. toTry là thao tác I/O mà ta thử thực hiện còn handler là hàm nhận IOError rồi trả lại một thao tác được thực hiện trong trường hợp xảy ra biệt lệ.

Hãy cho chương trình chạy nào:

$ runhaskell count_lines.hs i_exist.txt
The file has 3 lines!

$ runhaskell count_lines.hs i_dont_exist.txt
Whoops, had some trouble!

Trong phần chuôi, ta đã không kiểm tra xem vừa nhận được IOError loại gì. Ta chỉ nói "Whoops, had some trouble!" với bất kì lỗi loại nào. việc chỉ bắt tất cả những biệt lệ bằng một chuôi là thói quen không tốt khi lập trình Haskell, cũng như nhiều ngôn ngữ khác. Sẽ ra sao nếu có biệt lệ nào đó khác xảy ra nhưng ta không muốn bắt, vì nếu thế sẽ làm gián đoạn chương trình? Đó là lý do ta sẽ làm theo cách vẫn thường làm với các ngôn ngữ khác: ta sẽ kiểm tra xem đã nhận được biệt lệ loại gì. Nếu nó là loại biệt lệ mà ta đang đợi bắt, thì chỉ việc làm những điều dự tính. Còn nếu không, thì ta lại thả biệt lệ cho đi. Hãy sửa lại chương trình để chỉ bắt những biệt lệ gây ra bởi tập tin không tồn tại.

import System.Environment
import System.IO
import System.IO.Error

main = toTry `catch` handler

toTry :: IO ()
toTry = do (fileName:_) <- getArgs
           contents <- readFile fileName
           putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"

handler :: IOError -> IO ()
handler e
    | isDoesNotExistError e = putStrLn "The file doesn't exist!"
    | otherwise = ioError e

Mọi thứ vẫn giữ nguyên chỉ trừ phần chuôi, và ta đã chỉnh sửa để bắt một nhóm các biệt lệ I/O cụ thể. Ở đây ta đã dùng hai hàm mới trong System.IO.ErrorisDoesNotExistErrorioError. Hàm isDoesNotExistError là một vị từ với các IOError, theo nghĩa nó là một hàm nhận vào một IOError rồi trả về True hoặc False, tức là nó có kiểu isDoesNotExistError :: IOError -> Bool. Ta dùng hàm này với biệt lệ được truyền đến chuôi để xem liệu nó có phải là lỗi gây ra do tập tin không tồn tại hay không. Ở đây, ta đã dùng cú pháp chốt canh, nhưng ta cũng có thể dùng một cấu trúc if else. Nếu nó không phải gây ra bởi việc tập tin không tồn tại thì ta sẽ tung lại biệt lệ đã được chuôi truyền đến, bằng hàm ioError. Hàm này có kiểu ioError :: IOException -> IO a, vì vậy nó nhận IOError rồi tạo ra một thao tác I/O để tung nó đi. Thao tác I/O này có kiểu IO a, vì nó không bao giờ trả lại kết quả, vì vậy nó có thể đóng vai trò là IO bất kì.

Vậy biệt lệ được tung trong thao tác I/O toTry mà ta đã dính liền với khối do không phải được gây ra bởi sự tồn tại của tập tin, mà toTry `catch` handler sẽ bắt và tung lại nó. Cũng hay đấy nhỉ?

Có một vài vị từ hoạt động với IOError và nếu một chốt canh không định giá được thành True, thì việc định giá sẽ rớt tiếp xuống chốt canh tiếp theo. Các vị từ hoạt động với IOError bao gồm:

  • isAlreadyExistsError
  • isDoesNotExistError
  • isAlreadyInUseError
  • isFullError
  • isEOFError
  • isIllegalOperation
  • isPermissionError
  • isUserError

Phần lớn trong số chúng đều có tên gọi dễ hình dung được. isUserError lượng giá thành True khi ta dùng hàm userError để tạo ra biệt lệ; hàm này vốn được dùng để tạp các biệt lệ từ mã lệnh chương trình và cho chúng một chuỗi kèm theo. Chẳng hạn, bạn có thể viết ioError $ userError "remote computer unplugged!", mặc dù bạn nên dùng các kiểu như EitherMaybe để biểu thị các tình huống hỏng hóc có thể xảy ra thay vì tự tung các biệt lệ bằng userError.

Vì vậy bạn có thể viết đoạn chương trình như sau:

handler :: IOError -> IO ()
handler e
    | isDoesNotExistError e = putStrLn "The file doesn't exist!"
    | isFullError e = freeSomeSpace
    | isIllegalOperation e = notifyCops
    | otherwise = ioError e

trong đó notifyCopsfreeSomeSpace là các thao tác I/O mà bạn định nghĩa. Hãy đảm bảo chắc chắn là bạn sẽ tung lại biệt lệ nếu chúng không khớp với tiêu chí đặt ra, nếu không bạn sẽ làm cho chương trình đổ vỡ một cách thầm lặng trong những trường hợp mà đáng ra không được phép như vậy.

System.IO.Error cũng xuất khẩu các hàm cho phép ta lấy những thuộc tính từ các biệt lệ được dùng, như chuôi của tập tin đã gây ra lỗi là gì, hoặc tên tập tin là gì. Những hàm này đều bắt đầu bằng ioe và bạn có thể thấy một danh sách đầy đủ trong tài liệu tra cứu. Giả sử rằng ta muốn in tên tập tin đã gây ra lỗi. Ta không thể in fileName nhận được từ getArgs, vì chỉ có IOError được truyền vào chuôi và chuôi thì không thể biết về bất cứ điều gì khác. Một hàm chỉ phụ thuộc vào những tham số truyền vào khi được gọi. Điều này lý giải tại sao ta có thể dùng hàm ioeGetFileName, vốn có kiểu ioeGetFileName :: IOError -> Maybe FilePath. Hàm này nhận một IOError làm tham số và có thể trả lại FilePath (vốn chính là kiểu tương đồng với String, vì vậy cũng cùng là String). Về cơ bản, hàm này làm việc kết xuất đường dẫn đến tập tin từ IOError, nếu có thể. Ta hãy sửa đổi chương trình để in ra đường dẫn đến tập tin mà đã gây ra biệt lệ.

import System.Environment   
import System.IO   
import System.IO.Error   

main = toTry `catch` handler   

toTry :: IO ()   
toTry = do (fileName:_) <- getArgs   
           contents <- readFile fileName   
           putStrLn $ "The file has " ++ show (length (lines contents)) ++ " lines!"   

handler :: IOError -> IO ()   
handler e   
    | isDoesNotExistError e = 
        case ioeGetFileName e of Just path -> putStrLn $ "Whoops! File does not exist at: " ++ path
                                 Nothing -> putStrLn "Whoops! File does not exist at unknown location!"
    | otherwise = ioError e

Ở chốt canh mà isDoesNotExistErrorTrue, ta đã dùng một biểu thức case để gọi ioeGetFileName với e rồi khớp mẫu với giá trị Maybe mà nó trả lại. Các biểu thức case rất thông dụng khi bạn muốn khwps mẫu với thứ gì đó mà không muốn đưa vào một hàm mới.

Bạn không nhất thiết phải dùng một chuôi để bắt các biệt lệ trong toàn bộ phần I/O mà ta đã viết. Bạn chỉ cần bao quát được những phần nhất định của mã lệnh I/O bằng catch hoặc bao quát một vài trong số chúng bằng catch rồi dùng những chuôi khác nhau cho chúng, như sau:

main = do toTry `catch` handler1
          thenTryThis `catch` handler2
          launchRockets

Ở đây, toTry đã dùng handler1 làm chuôi, còn thenTryThis dùng đến handler2. launchRockets không phải là một tham số của catch, vì vậy bất kì biệt lệ nào được ném ra đều có thể làm chương trình này đổ vỡ, trừ khi launchRockets dùng catch bên trong để xử lý các biệt lệ riêng của nó. Dĩ nhiên, toTry, thenTryThislaunchRockets là các thao tác I/O đã được dính lại bằng cú pháp do và được định nghĩa một cách giả định ở nơi nào khác. Điều này cũng giống như khối try-catch trong các ngôn ngữ khác: bạn có thể bọc quanh toàn bộ chương trình bằng một khối try-catch hoặc bạn có thể dùng một cách tinh vi hơn và đặt những try-catch khác nhau vào các phần của mã lệnh để kiểm soát việc xử lý những lỗi nào ở đâu.

Bây giờ bạn đã biết cách xử lý các biệt lệ I/O rồi! Việc tung biệt lệ từ mã lệnh thuần tuý và xử lý chúng chua được đề cập đến ở đây, chủ yếu là vì, như ta đã nói, Haskell cung cấp nhiều cách làm tốt hươn để chỉ định lỗi thay vì nhờ cậy I/O để bắt chúng. Ngay cả khi dính liền những thao tác I/O có khả năng hỏng hóc, thì tôi vẫn thích chúng có kiểu giống như IO (Either a b), nghĩa rằng chúng là thao tác I/O thông thường nhưng kết quả trả lại khi thực hiện lại là kiểu Either a b, nghĩa là hoặc nó là Left a hay Right b.

Advertisements

%(count) bình luận

Filed under Haskell

One response to “Chương 9: Đầu vào và đầu ra

  1. Pingback: Tự học lấy Haskell | Blog của Chiến

Trả lời

Mời bạn điền thông tin vào ô dưới đây hoặc kích vào một biểu tượng để đăng nhập:

WordPress.com Logo

Bạn đang bình luận bằng tài khoản WordPress.com Đăng xuất / Thay đổi )

Twitter picture

Bạn đang bình luận bằng tài khoản Twitter Đăng xuất / Thay đổi )

Facebook photo

Bạn đang bình luận bằng tài khoản Facebook Đăng xuất / Thay đổi )

Google+ photo

Bạn đang bình luận bằng tài khoản Google+ Đăng xuất / Thay đổi )

Connecting to %s