2721 words
14 minutes
Type-Level Magic: A Recursive printf in Haskell
2025-06-06

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,此時的 tIO ()

擴展類型#

我們來攷慮如何在一箇參數時實現 printf!在 Haskell 中,Show 類型類提供了將值轉換為字符串的功能。所以我們攷慮不區分數字、字符串等類型,全部由 %s 來表示、使用 show 函數來轉換。

format :: (Show t) => String -> t -> String
format ('%':'s':xs) arg = show arg ++ xs
format (x:xs) arg = x : format xs arg
format "" _ = ""

這箇 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 .) . format
printf fmt = (putStrLn .) . format $ fmt
printf fmt = (putStrLn .) (format fmt)
printf fmt = putStrLn . (format fmt)
printf fmt arg = putStrLn . (format fmt) $ arg
printf 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 .) . format

Question

其實現在 printf 已經可以接受任意多的參數了,爲什麼?

攷慮 t = String -> String -> String -> IO ()

  1. 按新寫的規則,可以有 t = String -> (String -> (String -> IO ())) = String -> t1
  2. 同理,t1 = String -> (String -> IO ()) = String -> t2
  3. t2 = String -> IO (),這是已經實現了 Printable 的類型。
  4. 按新寫的規則可知 t1 也實現了 Printable
  5. 同理 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 -> String
format ('%':'s':xs) arg = show arg ++ xs
format (x:xs) arg = x : format xs arg
format "" _ = ""

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 -> String
formatNumber 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 = ""

我添加了 DoubleFloat 的實例,這樣我們可以使用 %f 來格式化浮點數,如果想再擴展,也衹用爲 Formattable 添加新的實例即可。最後的 OVERLAPPABLE 是用來處理一般情況下的 Show 類型類,爲了避免與其他 Formattable 實例衝突、同時提供了一个 fallback。

注意一开始使用了 UndecidableInstancesUndecidableInstances 聽起來有點嚇人,但它在現代 Haskell 中被廣泛且安全地使用。它僅僅是關閉了編譯器一個非常保守的檢查。這個檢查是爲了防止類型檢查過程陷入無限循環。 Compiler 會發現 Show 好像不比你這 Formattable 具體啊?要是它去找 Show 的實例,然後發現其引用了 Formattable,那就會陷入無窮遞歸。我們作為程序員可以向編譯器保證:我們不會反過來創建一個依賴 Formattable aShow 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,定義類型族及類型解析器。

Type-Level Magic: A Recursive printf in Haskell
https://blog.orbitoo.top/posts/haskell/printf-in-haskell/
Author
Orbitoo
Published at
2025-06-06
License
CC BY-NC-SA 4.0