《learn you a Haskell》這書的結構與常見的語言入門教材完全不一樣。事實上,即使學到第八章,你還是寫不出正常的程序…因為到現在為止還沒告訴你入口點模塊怎么寫,IO部分也留在了最后幾章才介紹。最重要的是,沒有系統的總結數據類型、操作符、語句,這些知識被零散的介紹在1-8章的例子中,換句話來說,這書其實不算是很合格的教材(代碼大全那種結構才更適合),不過它重點強調了FP與其他語言的思想差異,對于已經有其他語言基礎的人來說,讀這本書能夠很快領悟到FP的妙處,對于那些并不用它來做項目(估計也很難有公司用這個做項目,太難了)的人來說,這本書很適合作為拓展閱讀的小冊子。另外一本《Real World Haskell》也相當有名,我還沒有看,這本看完后再著手閱讀吧。
第八章 自定義類型和類型類
關鍵字data用來構建類型,格式為
data classname = definition
左端是類型的名字,右端是該類型的取值,又稱為構造子。例如:
data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)
這里表示類型Shape可以取值為Circle或Rectangle,這兩個類型的構造子分別是3個、4個Float。構造子本質是一種函數,取幾個參數返回一種類型。比如這里的Circle :: Float->Float->Float->Shape。關鍵字deriving表示派生于某類型類,即具有該類型類的性質。這里面有一些類似于面向對象中的封裝與繼承的概念,比如可以認為Shape是基類,Circle和Rectangle是派生類。如果某個函數對Shape通用,那么在模式匹配中就將兩種派生類的實現都寫出來,否則只匹配部分類即可。注意Circle和Rectangle并不是自定義類型,并不能直接使用,只有在需要Shape時,用構造子進行匹配來使用。如果構造子只有一個,可以讓data類型與構造子同名,這樣更加清晰。
如果說第七章的module相當于C++中的namespace,那么本章的data就相當于class,不同的是module封裝的是function,而Haskell中class就是function,所以module、data和function就構成了Haskell中的用戶層次。
導出格式:
module Shape
(Shape(…)
,fun1
,fun2
)
其中Shape(…)表示導出全部構造子。
Record Syntax
我們知道C++中提倡軟件工程中的封裝概念,盡量不讓內置數據類型暴露給使用者,一般要寫一大堆setxxx,const getxxx函數,雖然這些小函數往往只有一句話,但是寫多了也很煩。Record Syntax是Haskell中一種用來簡化此類函數書寫的語法結構。在聲明data時,直接標明其參數名稱和對應的數據類型:
data Shape=Circle { x::Float,y::Float,radix::Float} | Rectangle { x1::Float,y1::Float,x2::Float,y2::Float} deriving (Show)
這里的語法有點像C中的位域,但是它實際的意思其實仍然是前面學過的類型解釋符。Haskell會自動生成已經注明名稱的參數的函數。另外在調用構造子時,也要遵從這種結構,不過x::Float換成x=1.0(即Circle {x=1.0,y=2.2,radix=3.1})。
類型參數
前面學習了值構造子,這里介紹類型構造子。值構造子顯然需要明確值的類型,而類型構造子則寬松的多,比如向量,我們只需要向量的參數類型一致即可,不必明確具體的類型。換句話說,類型參數是一種"泛型"語法支持(類似C++的模板)。
比如 data Vector a = Vector a a a deriving (Show),這就是一個三維向量。類型參數a對后面的值構造子產生約束。也就是C++中的:
template <class a>
class Vector
{
????Vector(a first,a second, a last);
}
顯然我們在寫函數的時候需要對類型參數進行實例化,換句話說,需要對函數進行類型約束。記住,不要在data聲明中添加類型約束,而是在函數中添加,因為具體執行操作的是函數,而數據類型需要比函數更加抽象。這里按著C++中寫模板的思路來就很容易理解什么時候用類型構造子。
派生實例
本節介紹了從類型類從派生類型的方法。前面已經介紹過類型類本質上是一種接口要求,它描述了類型的行為一致性。在我們創建類型時,可以在值構造子后面加上deriving (Ord,Show,Read,…),來給創建的類添加接口。一旦加上了指定的類型類接口,就給予所創建的類對應的行為特性。如果加了Ord,就可以直接比較兩個類(根據值構造子和參數),如果加了Show,那么就可以顯示該類的參數(如果使用了syntax record,就顯示出"名稱=值"的格式。)
派生實例是很有用的特性,在我們設計類的時候需要明確該類支持的行為特性。這看起來有些像C++中的重載操作符,但是不需要我們自己去實現。Haskell會自動推斷應該怎樣實現聲明的行為。
注意Haskell中True/False與C中意義完全不同【C語言中沒有bool,只是單純認為0為false,非0為true】,可以認為 data Bool = False | True deriving (Ord),所以True > False是成立的。同理Nothing總是小于Just a。
類型別名
類似typedef的語法,在Haskell中格式為
type Newtype=OldType,與typedef相似的是一般用來簡化書寫或者明晰概念。與typedef不同的是,type也支持Haskell的不全調用。
這里舉了Either a b作為例子,Either a b是用來代替Maybe a的,當函數的返回類型可能有多種,或者函數需要根據情況返回不同的信息時,經常使用Either a b作為返回類型。
data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)
Either a b有兩個值構造子,如果用了Left構造子,其返回類型就是a,否則是b,換句話說,Haskell可以返回多種類型的結果,而不像C++那樣只能用結構來封裝。
遞歸數據結構
【我本以為第8章到這就完了呢…結果后面又發現是翻譯的大哥翻到一半就終止了,第八章后面還有不少,這部分是后來添補的】
考慮list,如果我們需要自己定義list類型,應該如何聲明?應該這樣:
data List a = Empty | Cons a (List a) deriving (Show,Read,Eq,Ord)
這里Cons相當于運算符 " : ",顯然這里的List定義是遞歸的——等號的左右兩端都存在List。這樣,我們就可以像使用:一樣使用Cons來構造List,如 3 `Cons` 4 `Cons` Empty。
也可以自定義操作符,使用infixr來確定操作符的優先級和左、右結合性,注意這里可以定義任意操作符,這一點和C++中的重載操作符有本質的不同。書中以操作符 :-: 為例介紹了使用方法。
下面介紹了一個二叉搜索樹的生成作為例子:
data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show, Read, Eq)
一個二叉樹是一個空樹,或者一個由根節點和其左子樹與右子樹構成的樹。下面是一些常用操作函數的實現,這里從略。
TypeClass102
本節介紹如何自定義類型類,類型類與常見的過程式或者面向對象模型的編程語言并無相似之處,不過可以和C++中的重載操作符對比參考。
我們使用關鍵字class來定義類型類,使用關鍵字instance來定義類型類的實例。
class Eq a where
(==) :: a->a->Bool
(/=) :: a->a->Bool
x == y = not x /= y
x /= y = not x==y
instance Eq TrafficLight where
…
instance下面就是詳細解釋該接口的實現。在不想使用默認的從類型類派生得到的行為時,必須使用instance來自定義數據對于該接口的行為方式。對于Maybe類型的實例,格式是
instance (Eq m) => Eq (Maybe m) where
注意這里需要對m添加類型約束。
可以在GHCI中使用:info 得到類型類、類型和類型構造子的詳細信息。
A yes-no typeclass
本節講了一個類型類的實例,它用來完成各種類型向Bool的缺省轉換(如C的非零為True,0為False)。
class YesNo a where
????yesno :: a -> Bool
實現:
instance YesNo Int where
????yesno 0 = False
????yesno _ = True
instance YesNo [a] where
????yesno [] = False
????yesno _ = True
instance YesNo Bool where
????yesno = id
…其他略
注意id是標準庫函數。
函數子類型類
函數子類型類(Functor class)指的是可以被被映射的函數滿足的接口。
class Functor f where
????fmap :: (a->b) -> f a -> f b
注意定義中的f并非類型,而是類型構造子。換言之,函數子類型類的實例參數必須是類型類構造子而不能是具體的類型,那么可見這個類型類是容器的接口(如Maybe或[]等擁有1個以上值構造子的類型)。如果f有兩個以上的參數,那么只能用函數的不完全調用格式,僅保留一個參數進行處理(如Either a)
種類和一些類型相關的東西
本節對類型(type)的知識做了擴展,這里將類型本身分為具體類型和不完全類型。
這種類型的類型被稱為種類(kinds),可以在GHCI中使用:k來對類型進行種類的分析。
GHCI使用 " * "表示具體的類型,如果不是具體類型,就是可以通過一個或多個參數得到具體類型的不完全類型。
如果理解了類型構造子本身也是函數這一點,本節的內容還是比較容易理解的。
第九章 輸入與輸出
第九章開始就沒有中文的翻譯了,只能看英文資料,英文9-14章戳我
到了第九章,我們終于可以寫Hello World了!一本教材講到大半才講輸入輸出的,也算是比較罕見了,呵呵。
在Haskell中,我們并不能像在命令式編程中一樣隨意改變非const變量的值。Haskell保持著這樣一個特性:對于一個函數,只要它的調用參數不變,那么它返回的值總是不變。Haskell不會試圖改變已經確定的變量,而是試圖返回新的變量。那么這里出現一個問題:如果Haskell并不改變任何變量,那么它就無法輸出——因為輸出會改變屏幕。為此,Haskell設計了一種機制將非純函數式編程(即與輸入輸出打交道的部分)與函數式編程(即前面八章介紹的內容)隔離開來,這里將這種機制稱為side-effects,直譯為邊界效應。
Hello World!
Hello World總是每種語言必須提到的東西。對于Haskell,輸出Helloword只需要一行代碼,嗯,不愧是優雅與簡潔的典范。
main = putStrLn "Hello world!"
main表示主函數,所有涉及IO的函數都在main中執行。所以main函數經常被寫作main:: IO (),當然()也可以換成其他的返回類型。
putStrLn函數的解釋是
putStrLn :: String->IO ()
也就是輸入一個字符串,執行一個IO action,返回一個空的tuple。
因為IO行為是非純函數行為,所以Haskell設計了do塊來將所有的非純函數進行封裝,最后通過<-操作符將IO取得的值綁定到一個變量上。do塊中,除了最后一個IO action 其他的均可綁定到一個變量上,不過如putStrLn這種函數的返回值肯定是(),所以綁定沒什么意義。最后一個IO action會將返回值綁定給main本身。do塊這種行為方式類似于verilog中的begin…end語句塊。
如果不涉及IO,仍然使用前面學過的let … in…來直接綁定變量,不過這里in可以省略,缺省成為整個do塊中有效。
return語句:在haskell中return語句只能在IO塊中使用,它表示一個IO行為,輸出一個可以通過變量綁定的值。return語句并不能從該段程序中返回。我們只有在需要執行一次什么都不做的IO或者不希望返回最后一個IO action取得的值時使用return語句。
main:main本身即是一個IO函數,所以可以通過在main函數結尾調用它來遞歸該函數。
其他IO函數:
函數原型 | 解釋 |
putStr | 類似putStrLn,但尾部不輸出換行符 |
putChar/getChar | 輸出/入字符 |
print:: Show a => a -> IO () | 輸出一切屬于show類型類的數據 |
sequence:: Monad m => [m a] -> m [a] | 執行參數1中的I/O動作,返回動作的結果 |
mapM :: Monad m => (a -> m b) -> [a] -> m [b] | map的I/O版,相當于sequence . map |
mapM_ :: Monad m => (a -> m b) -> [a] -> m () | 同上,只是不再返回I/O動作的執行結果 |
注意map一個I/O動作到一個list中,并不會真正執行這個list中的動作。想要真正執行,必須使用sequence函數,當然,方便起見,可以使用mapM或mapM_。
這一塊介紹了Control.Monad內的幾個函數,when函數取一個布爾值和一個I/O動作作為參數,如果bool值為真,執行該動作,否則返回一個什么都不執行的I/O動作;forever永久執行參數中的I/O動作;forM類似于mapM,只是參數的順序顛倒。
文件與流
其實I/O這一塊Haskell與命令式語言并無不同,要注意的只是do塊的位置和I/O函數結果的綁定。對于文件I/O,haskell與C基本一致,常用函數如下:
函數原型 | 解釋 |
getContents :: IO String | 從標準流讀入數據直到EOF(Ctrl+D) |
interact :: (String -> String) -> IO () | 對輸入執行參數1的函數,輸出結果 |
openFile :: FilePath -> IOMode -> IO Handle | System.IO,打開文件,選擇方式,返回句柄 |
hGetContents :: Handle -> IO String | 根據句柄返回文件內容 |
hClose :: Handle -> IO () | 根據句柄關閉文件 |
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r | 整合的一個函數,打開文件并使用參數3處理文件,完成后關閉文件 |
hGetLine/hPutStr/hputStrLn/hGetChar | 大體同標準流,只是參數中多了文件句柄 |
readFile :: FilePath -> IO String | 讀取文件 |
writeFile :: FilePath -> String -> IO () | 寫入文件,模式為截斷 |
appendFile :: FilePath -> String -> IO () | 寫入文件,模式為追加 |
getTemporaryDirectory :: IO FilePath | System.Directory,讀取臨時文件夾路徑 |
openTempFile :: FilePath -> String -> IO (FilePath, Handle) | System.IO,打開臨時文件,返回一個tuple |
removeFile/renameFile | System.Directory,刪除、重命名文件,參數是路徑不是句柄 |
注意這里的讀取文件相關函數都是惰性的。以上青色字體來自System.IO庫,沒有注明的來自Precluded庫。使用句柄做參數的函數均以h開頭。
緩沖控制:如果需要修改編譯器默認的緩沖機制,可以使用函數hSetBuffering來修改,使用hFlush來強制刷新緩沖區
這里介紹了Unix下管道操作符 | 的使用。簡單來說,可以通過管道操作符將上一個動作的輸出作為下一個動作的輸入。
命令行參數
同C語言程序一樣,Haskell也是可以接受命令行參數的。C語言將命令行參數作為main函數的參數傳遞,而Haskell主要使用兩個函數來取得用戶輸入的參數,import System.Environment
getArgs :: IO [String] | 取出所有參數 |
getProgName :: IO String | 取得程序名稱 |
另外后面給了錯誤退出的函數(類似<stdlib>中的exit函數):errorExit。
隨機數發生器
隨機數發生器在任何語言中都是標準庫自帶的函數/類。雖然Haskell要求純函數的輸入一定時,輸出固定,但是實際上幾乎所有的語言中隨機數發生器生成的都是偽隨機數,所以Haskell這個特性并不意味著其實現比一般的語言困難。相關函數如下(import System.Random):
random :: (RandomGen g, Random a) => g -> (a, g) | 參數給出一個隨機數種子,返回一個隨機數和一個新的種子 |
mkStdGen :: Int -> StdGen | 以一個整數為參數,生成一個標準的種子 |
randoms :: (RandomGen g, Random a) => g -> [a] | 根據種子生成一個無限長的隨機序列 |
randomR :: (RandomGen g, Random a) => (a, a) -> g -> (a, g) | 參數1的pair限制了最后取得隨機數的范圍 |
randomRs :: (RandomGen g, Random a) => (a, a) -> g -> [a] | 根據參數1生成規定范圍的無限長list |
getStdGen :: IO StdGen | 取得一個全局的隨機數發生器種子 |
newStdGen :: IO StdGen | 刷新全局隨機數發生器 |
? | ? |
注意random函數的返回類型可以是任何類型,所以在使用的時候必須在后面加上類型約束::(type1,type2)作為隨機數和種子的類型,如果使用StdGen的種子,則一般返回類型為(Int,StdGen)。
如果不執行newStdGen,那么getStdGen總是返回同樣的種子。
另外這里還介紹了read加入了錯誤處理的版本reads,后者再不能讀取參數時將返回空list。
二進制字符串
不清楚這個翻譯是否合適,總之Bytestrings主要介紹二進制讀寫文件的方法。不同于C語言,這里有兩個版本的二進制讀取,一個是嚴格的非惰性版本,另外一個是惰性版本,分別來自Data.ByteString和Data.ByteString.Lazy. 對于lazy版本,這里和前面介紹的文件IO函數的實現也有所不同——它每次最少讀取64K字節的東西。大體來講ByteString的相關函數與Data.List中的函數接口一致,不過在類型約束中用ByteString代替了[a],用Word8代替了a。函數pack將Word8打包成ByteString,unpack用于解包;fromChunks將嚴格版(非惰性)轉換為惰性版,toChunks則相反;cons和cons'用來取代list的 :操作符,注意后者適用于非惰性版;還有其他一些函數,對應于list中的某些函數,這里就不列舉了。