printf 是 C 語言中一箇經典的函數,用於格式化輸出。
Haskell 中沒有內建的 printf 函數,但 Text.Printf 模組提供了類似的功能。我們也可以利用 Haskell 的類型系統來實現一個簡單的版本。
函數類型是什麼?
攷慮以下情形?
main :: IO ()main = do printf "Hello, %s!" "World" printf "My name is %s, I am %d years old." "Alice" 30第一箇 printf 的類型是 String -> String -> IO (),第二箇是 String -> String -> Int -> IO ()。這就麻煩了,這表明了 printf 的類型是變化的,哪怕使用多態函數、我們也無法寫出一個通用的 printf 函數。
注意,String -> IO() 和 String -> Int -> IO() 也是類型,如果寫成 t,那 printf 就有了固定的類型 String -> t。由於根據 t 的不同,printf 的實現也會不同,所以我們要定義一箇 class 來表示這種行為。
class Printable t where printf :: String -> t
instance Printable (IO ()) where printf :: String -> IO () printf = putStrLn這樣,我們有了最基礎的 printf,當無參時,它的行爲就是 putStrLn,此時的 t 是 IO ()。
擴展類型
我們來攷慮如何在一箇參數時實現 printf!在 Haskell 中,Show 類型類提供了將值轉換為字符串的功能。所以我們攷慮不區分數字、字符串等類型,全部由 %s 來表示、使用 show 函數來轉換。
format :: (Show t) => String -> t -> Stringformat ('%':'s':xs) arg = show arg ++ xsformat (x:xs) arg = x : format xs argformat "" _ = ""這箇 format 函數接受一箇格式字符串和一箇參數,竝在遇到 %s 時吃掉參數、用 show 函數將其轉換為字符串插入。
instance (Show t) => Printable (t -> IO ()) where printf :: String -> t -> IO () printf fmt arg = putStrLn (format fmt arg)這樣,我們就有了可以接受一箇參數的 printf 函數。不過,想不想把 printf 寫成 point-free 的呢?
instance (Show t) => Printable (t -> IO ()) where printf :: String -> t -> IO () printf = (putStrLn .) . format啊哈,這箇 point-free 形式一眼看不懂?我們來推導一下。
printf :: (Show t) => String -> t -> IO ()printf = (putStrLn .) . formatprintf fmt = (putStrLn .) . format $ fmtprintf fmt = (putStrLn .) (format fmt)printf fmt = putStrLn . (format fmt)printf fmt arg = putStrLn . (format fmt) $ argprintf fmt arg = putStrLn (format fmt arg)接下來,我們得想想兩箇參數的情況。
instance (Show t, Show u) => Printable (u -> t -> IO ()) where printf :: String -> u -> t -> IO () printf fmt arg1 arg2 = putStrLn (format (format fmt arg1) arg2)這箇實現是自然的,回憶剛剛我們的 point-free 版本,是不是可以改寫?
instance (Show u, Show t) => Printable (u -> t -> IO ()) where printf :: String -> u -> t -> IO () printf fmt arg1 = (putStrLn .) . format $ (format fmt arg1)魔法開始
有沒有發現、(putStrLn .) . format 這箇東西好像寫過一次?誒,它就是單參數時的 printf 嘛!再想想,u -> t -> IO () 這箇類型,我們知道 -> 是右結合的,所以它也就是 u -> (t -> IO ())。
是的,t -> IO () 正是一箇已經實現了 Printable 的類型。所以我們可以寫成:
-- replace `t -> IO ()` with `Printable t`instance (Show u, Printable t) => Printable (u -> t) where printf :: String -> u -> t -- replace `(putStrLn .) . format` with `printf` printf fmt arg1 = printf (format fmt arg1)進一步地,再改寫成 point-free 形式:
instance (Show u, Printable t) => Printable (u -> t) where printf :: String -> u -> t printf = (printf .) . formatQuestion其實現在
printf已經可以接受任意多的參數了,爲什麼?
攷慮 t = String -> String -> String -> IO ()。
- 按新寫的規則,可以有
t = String -> (String -> (String -> IO ())) = String -> t1。 - 同理,
t1 = String -> (String -> IO ()) = String -> t2。 - 而
t2 = String -> IO (),這是已經實現了Printable的類型。 - 按新寫的規則可知
t1也實現了Printable - 同理
t也實現了Printable。
我們新編寫的規則能自動地適應任意多的參數!
再回看 t2 = String -> IO (),其實我們新寫的規則也能處理它,有 t3 = IO (),
t3 命中了最初的 Printable 實例,所以單參數的 printf 不需要特別處理。
完整的實現如下:
class Printable t where printf :: String -> t
instance Printable (IO ()) where printf :: String -> IO () printf = putStrLn
instance (Show u, Printable t) => Printable (u -> t) where printf :: String -> u -> t printf = (printf .) . format
format :: (Show t) => String -> t -> Stringformat ('%':'s':xs) arg = show arg ++ xsformat (x:xs) arg = x : format xs argformat "" _ = ""Question如果我們想實現一箇
sprintf(返回格式化字符串而不打印),要怎麼做呢?
sprintf 的類型是 String -> ... -> String,中間的 ... 可以交給 Printable 來處理,我們只需要實現一箇 Printable String 的實例即可。
instance Printable String where printf :: String -> String printf = id我們這就讓 printf 同時支持了打印和返回格式化字符串的功能。
main :: IO ()main = do let formattedStr = printf "Hello, %s!" "World" -- return type is String, so this is sprintf putStrLn formattedStr printf "Hello, %s, %s!" "Alice" "Bob" -- return type is IO (), so this is printf解決 IO a
現在的 printf 還有一箇問題,它在 IO 時衹能返回 IO ()。
main :: IO ()main = do printf "Hello, %s!" "Alice" -- Compile error printf "Hello, %s!" "Bob"在第一次使用 printf 時,Compiler 衹能知道 printf 最後要返回 IO a,但不知道 a 是什麼。而我們的定義衹包含了 IO (),所以 Compiler 不太高興。
最简單的解法是指出返回的是 IO ():
main :: IO ()main = do () <- printf "Hello, %s!" "Alice" -- Now it works printf "Hello, %s!" "Bob"這樣,Compiler 就知道 printf 返回的是 IO (),不會報錯了。不過,我們不想每次都寫 () <- printf ...,這樣太麻煩了。
Question爲什麼第二箇
printf沒有報錯?
一箇更好的解決方案是讓 Printable 類型類支持返回 IO a,這樣就不需要 Compiler 來推斷了,反正想要什麼 a 都可以。
import Data.Functor (($>))instance Printable (IO a) where printf :: String -> IO a printf fmt = putStrLn (format fmt) $> undefined魔法在於,我們用 $> 丢棄了 putStrLn 的結果 IO (),把 IO 裏的東西換成了 undefined,而 undefined 可以是任何類型,當然也包括 a,這下返回類型就是 IO a 了。
其實上面的方案還有一箇問題,就是 undefined 會在運行時拋出異常。正常情況下,printf 返回的 IO a 不會被求值,因爲副作用已經被執行了,但如果某人寫出了這樣的代碼:
main :: IO ()main = do intValue <- printf "Hello, %s!" "Alice" let y = intValue + 1 print y這樣的代碼會在運行時拋出異常,因爲 intValue 會被求值,而 undefined + 1 會導致異常,但編譯器竝不會報錯,它會高高興興地把 printf 的返回類型推斷爲 IO Int,從而在它看來,一切都是完美的!
所以,我們需要一箇更安全的解決方案。
instance (a ~ ()) => Printable (IO a) where printf :: String -> IO () printf = putStrLn這次我們寫了箇 a ~ (),這告訴了編譯器:如果你推斷 printf 的返回類型是 IO a,那麼 a 必須是 ()。這下,編譯器不會抱怨不知道 IO a 是什麼類型了,而且上面那種不合法的操作也能被發現了。
推廣 format
上面的 format 調用了 show 函數來將參數轉換為字符串,如果 arg 本身就是 String 呢?那麼我們就不需要、也不應該調用 show 函數了,如果調用了,會導致多餘的引號。
也就是說,format 的行爲與 arg 的類型有關,所以我們也把它寫成類型類。
{-# LANGUAGE UndecidableInstances #-}
class Formattable t where format :: String -> t -> String
instance Formattable String where format ('%' : 's' : xs) arg = arg ++ xs format (x : xs) arg = x : format xs arg format "" arg = ""
formatNumber :: (Floating a, RealFrac a) => String -> a -> StringformatNumber digitsStr arg = intPart ++ "." ++ fracPart where digits = read digitsStr :: Int intPart = show $ floor arg fracPartNoPadding = show $ round (arg * 10 ^ digits) `mod` (10 ^ digits) fracPaddingLength = digits - length fracPartNoPadding fracPart = replicate fracPaddingLength '0' ++ fracPartNoPadding
instance Formattable Double where format ('%' : 'f' : xs) arg = formatNumber "5" arg ++ xs format ('%' : '.' : n : 'f' : xs) arg = formatNumber [n] arg ++ xs format ('%' : '.' : n1 : n2 : 'f' : xs) arg = formatNumber [n1, n2] arg ++ xs format (x : xs) arg = x : format xs arg format "" arg = ""
instance Formattable Float where format ('%' : 'f' : xs) arg = formatNumber "5" arg ++ xs format ('%' : '.' : n : 'f' : xs) arg = formatNumber [n] arg ++ xs format ('%' : '.' : n1 : n2 : 'f' : xs) arg = formatNumber [n1, n2] arg ++ xs format (x : xs) arg = x : format xs arg format "" arg = ""
instance {-# OVERLAPPABLE #-} (Show a) => Formattable a where format ('%' : 's' : xs) arg = show arg ++ xs format (x : xs) arg = x : format xs arg format "" arg = ""我添加了 Double 和 Float 的實例,這樣我們可以使用 %f 來格式化浮點數,如果想再擴展,也衹用爲 Formattable 添加新的實例即可。最後的 OVERLAPPABLE 是用來處理一般情況下的 Show 類型類,爲了避免與其他 Formattable 實例衝突、同時提供了一个 fallback。
注意一开始使用了 UndecidableInstances,
UndecidableInstances 聽起來有點嚇人,但它在現代 Haskell 中被廣泛且安全地使用。它僅僅是關閉了編譯器一個非常保守的檢查。這個檢查是爲了防止類型檢查過程陷入無限循環。
Compiler 會發現 Show 好像不比你這 Formattable 具體啊?要是它去找 Show 的實例,然後發現其引用了 Formattable,那就會陷入無窮遞歸。我們作為程序員可以向編譯器保證:我們不會反過來創建一個依賴 Formattable a 的 Show a 實例,從而打破了潛在的循環。因此,開啟這個擴展是安全的,同時也讓編譯器對我們放心。
這樣,我們就有了更通用的 format 函數,可以處理不同類型的參數,這樣就能更靈活地使用 printf。
Further Steps
目前的 printf 實用性上衹有性能問題了,設計已非常優雅,但它將一個重要的安全問題遺留到了運行時。這也是 C 語言 printf 的經典缺陷。
比如:
main :: IO()main = do printf "You have %s apples! It's %.2f dollars." 4 8上面的代碼會打印出
> You have 4 apples! It's %.2f dollars.爲什麼?
因爲 %.2f 是由浮點數解析器在 format 函數中解析的,而 4 是整數,調用到的 format 是 fallback 版本的,衹能解析 %s。
這其實是一箇類型錯誤導致的非預期行爲。而目前的 printf 無法從 fmt 的形式上推導出 args 的期望类型。
想要實現這點,需要把 fmt 從簡單的 String 改到一箇更高級的 ADT,定義類型族及類型解析器。