返回文章列表

文章

Get Programming with Haskell-25

在阅读完第 25 课后,你将能够: • 使用 ByteString 类型高效地处理二进制数据。 • 通过使用 ByteString.Char8 将 ByteString 视为常规 ASCII 字符串。 • 使用 Haskell 对 JPEG 图像进行故障处理(glitch)。 • 处理二进制 Unicode 数据。

目录
  1. 处理二进制数据
  2. 25.1 使用 ByteString 处理二进制数据
  3. 25.2 故障化 JPEG 图像
  4. 25.2.1 插入随机字节
  5. 25.2.2 排序随机字节
  6. 25.2.3 使用 foldM 链接 IO actions
  7. 25.3 ByteStrings、Char8 和 Unicode
  8. 总结
  9. 📎 参考文章

处理二进制数据#

在本课中,你将学习如何使用 Haskell 的 ByteString 类型处理二进制文件数据。ByteString 允许你将原始二进制数据视为常规字符串。为了演示 ByteString 的用法,你将专注于一个有趣的项目,该项目需要你操作二进制文件数据。你将创建一个简单的命令行工具,该工具允许你创建故障艺术,如图 25.1 所示。 图 25.1 A scene from<br>Michael Betancourt’s glitch art video “Kodak Moment” (2013) 故障艺术是一种通过故意损坏二进制数据,从而在图像或视频中创建视觉伪影的实践。你将处理相对简单的“故障”JPEG 图像的任务。你还将了解一些与处理二进制 Unicode 数据相关的问题。

25.1 使用 ByteString 处理二进制数据#

到目前为止,在本单元中,你只了解了如何在文件中处理文本。你首先学习了基本的 String 类型,然后了解到 Text 是处理文本数据的更好类型。另一个类似于 StringText 的重要类型称为 ByteString。关于 ByteString 有趣的是,它并不像名称 String 可能暗示的那样专门用于文本。ByteString 是一种处理任何二进制数据流的有效方式。与 Data.Text 类似,你几乎总是使用单个字母进行限定导入来导入 Data.ByteStringHaskell

import qualified Data.ByteString as B

即使 ByteString 是字节数组而不是文本类型,你也可以始终使用 ASCII 来表示字节字符串。有 256 个,或 28(8 位)ASCII 字符,因此每个可能的字节都可以表示为 ASCII 字符。只要你使用 OverloadedStrings 扩展,你就可以使用字面 ASCII 字符串来表示字节向量。

sampleBytes :: B.ByteString
sampleBytes = "Hello!"

但是如果你尝试使用 B.unpack 将你的 ByteString 转换为普通的 String,你很快就会遇到问题。以下代码将抛出一个错误。

sampleString :: String
sampleString = B.unpack sampleBytes

正如你从类型签名中看到的,B.unpack 尝试将 ByteString 转换为字节列表(类型为 Word8):

B.unpack :: B.ByteString -> [GHC.Word.Word8]

默认情况下,Data.ByteString 不允许你像处理 Char 那样处理字节,因此你需要使用 Data.ByteString.Char8Char8 代表 8 位字符(ASCII 字符)。你需要单独导入 Char8,并且通常使用限定符 BC

import qualified Data.ByteString.Char8 as BC

你可以通过查看它们的 unpack 函数的类型来了解普通 ByteStringByteString.Char8 之间的区别:

B.unpack :: BC.ByteString -> [GHC.Word.Word8]
BC.unpack :: BC.ByteString -> [Char]

你可以看到 ByteString.Char8unpack 工作方式与 Data.Textunpack 完全相同。ByteString.Char8 允许你使用与 Data.Text 相同的核心函数来处理文本。细心的读者还会注意到 B.unpack 的类型签名已经改变了!B.unpack 现在使用来自 ByteString.Char8ByteString 表示。这意味着从现在开始,你可以自由地将你的 ByteString 视为普通的 ASCII 文本。 与 Text 类似,ByteStringString 共享一个通用的 API。正如你将在下一节中看到的,你可以将处理 TextString 时使用的所有相同函数用于二进制数据。这使得像处理普通列表一样轻松地推理高效存储的二进制数据。

25.2 故障化 JPEG 图像#

既然你已经了解了 ByteString 的基本用法,让我们深入了解如何创建故障艺术。你的程序的所有代码都将放在一个名为 glitcher.hs 的文件中。你将处理一个可以从维基百科下载的图像(https://www.google.com/search?q=https://en.wikipedia.org/wiki/H._P._Lovecraft%23/media/File:H._P._Lovecraft,_June_1934.jpg)。在本练习中,你将把这个文件命名为 lovecraft.jpg,如图 25.2 所示。 图25.2 The target of your glitching is the lovecraft.jpg image. 为了开始你的程序,让我们看看如何创建你想要用于读取和写入图像的基本功能。以下是程序的基本结构:

  1. 从用户那里获取文件名参数。
  2. 读取图像文件的二进制数据。
  3. 随机更改图像数据中的字节。
  4. 写入包含故障图像的新文件。 你将在程序中使用 Data.ByteStringData.ByteString.Char8 来处理图像的二进制数据。因为你正在处理二进制数据,所以你希望使用 BC.readFile 读取你的文件。以下是你的程序的基本轮廓,没有任何故障代码。 Haskell
import System.Environment
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC

main :: IO ()
main = do
  args <- getArgs
  let fileName = head args
  imageFile <- BC.readFile fileName
  glitched <- return imageFile
  let glitchedFileName = mconcat ["glitched_",fileName]
  BC.writeFile glitchedFileName glitched
  print "all done"

有了这段代码,你可以运行你的程序,传入一个文件,并得到一个新文件,它应该是你的故障 JPEG。唯一缺少的是故障化你的图像的代码!

25.2.1 插入随机字节#

故障艺术的美学部分在于尝试不同的数据损坏方法,并观察哪些方法有效。你将从替换文件中一个随机字节为另一个你随机选择的字节开始。创建随机数需要一个 IO action。但是,最好尽可能多地将代码从 IO action 中分离出来,因为你的非 I/O 代码是纯粹且可预测的。你还可以通过将代码加载到 GHCi 中并在各种数据样本上进行尝试来轻松测试你的代码。 在你创建 IO action 之前,你将从一个将 Int 转换为 Char 的函数开始。因为 CharEnum 的成员,所以你可以使用 toEnum。你可以单独使用 toEnum,但是如果你这样做,你无法强制你的 Char 在 0 到 255 之间的约束。为了强制执行此操作,你将对传递给 toEnumInt 取模 255。你将把所有这些放在一个 intToChar 函数中。

intToChar :: Int -> Char
intToChar int = toEnum safeInt
  where safeInt = int `mod` 255

接下来,你需要一个将此 Char 转换为 ByteString 的函数。你可以通过使用 BC.pack 获取你的 Char 并将其转换为 BC.ByteString 来完成此操作。因为 BC.pack 需要一个字符串,所以你需要将你的 Char 放在一个列表中。

intToBC :: Int -> BC.ByteString
intToBC int = BC.pack [intToChar int]

现在你有一种将 Int 转换为表示为 BC.ByteString 的单字节的方法,你可以编写代码来用这个值替换一个字节。你仍然不需要使用 IO action。 你的 replaceByte 函数是你随机目标的确定性版本。此函数将获取要替换的字节的位置、要放入的新 Char/ByteInt 值以及图像文件的字节。你将使用 BC.splitAt 在目标字节周围分割你的字节。BC.splitAt 将返回一对值,表示数据的第一部分和其余部分(就像同时调用 takedrop 一样)。然后你将从剩余的字节中删除一个字节,为你的新字节腾出空间。最后,你将新字节连接到这两个部分的中间。

replaceByte :: Int -> Int -> BC.ByteString -> BC.ByteString
replaceByte loc charVal bytes = mconcat [before,newChar,after]
  where (before,rest) = BC.splitAt loc bytes
        after = BC.drop 1 rest
        newChar = intToBC charVal

现在你准备好你的 IO action 了。你将使用 System.Random 中的 randomRIOrandomRIO 将接受一个元组中的一对值,并在该范围内随机给你一个数字。你的 IO action 将被命名为 randomReplaceByterandomReplaceByte 所需要做的就是选择两个随机数:一个用于 Char,一个用于位置。

randomReplaceByte :: BC.ByteString -> IO BC.ByteString
randomReplaceByte bytes = do
  let bytesLength = BC.length bytes
  location <- randomRIO (1,bytesLength)
  charVal <- randomRIO (0,255)
  return (replaceByte location charVal bytes)

现在你可以在你的 main 函数中使用这个 IO action 来修改你的图像文件:

main :: IO ()
main = do
  args <- getArgs
  let fileName = head args
  imageFile <- BC.readFile fileName
  glitched <- randomReplaceByte imageFile
  let glitchedFileName = mconcat ["glitched_",fileName]
  BC.writeFile glitchedFileName glitched
  print "all done"

你可以在命令行编译并运行你的程序:

$ ghc glitcher.hs
$ ./glitcher lovecraft.jpg

如图 25.3 所示,这些结果还可以,但不如你希望的那样引人注目。让我们尝试一些更复杂的方法,看看是否能获得更好的结果。

25.2.2 排序随机字节#

另一种常见的图像故障技术是获取字节的一个子部分并对其进行排序。你可以通过使用 BC.splitAt 在某个点分割你的 ByteString 来实现这一点,然后将后半部分分割成固定大小的块;你对该块进行排序,然后使用 mconcat 将所有内容重新组合在一起。这是你的 sortSection 函数,它接受该部分的起始点、该部分的大小和字节流。

sortSection :: Int -> Int -> BC.ByteString -> BC.ByteString
sortSection start size bytes = mconcat [before,changed,after]
  where (before,rest) = BC.splitAt start bytes
        (target,after) = BC.splitAt size rest
        changed = BC.reverse (BC.sort target)

你只需要在你的 main 函数中使用它来创建一个选择随机起始点的 IO action。

randomSortSection :: BC.ByteString -> IO BC.ByteString
randomSortSection bytes = do
  let sectionSize = 25
  let bytesLength = BC.length bytes
  start <- randomRIO (0,bytesLength - sectionSize)
  return (sortSection start sectionSize bytes)

你可以用 randomSortSection 替换 randomReplaceByte 并尝试一种修改后的方法。 Haskell

main :: IO ()
main = do
  args <- getArgs
  let fileName = head args
  imageFile <- BC.readFile fileName
  glitched <- randomSortSection imageFile
  let glitchedFileName = mconcat ["glitched_",fileName]
  BC.writeFile glitchedFileName glitched
  print "all done"

有了这个技巧,你将获得更有趣的结果,如图 25.4 所示。但是,如果你能将这些方法结合起来,你可能会做得更好! 图 25.4 A much more interesting result, achieved with randomSortSection

25.2.3 使用 foldM 链接 IO actions#

假设你想在你的数据上使用 randomSortSection 两次,并使用 randomReplaceByte 三次。你可以像这样重写你的 main 函数。

main :: IO ()
main = do
  args <- getArgs
  let fileName = head args
  imageFile <- BC.readFile fileName
  glitched1 <- randomReplaceByte imageFile
  glitched2 <- randomSortSection glitched1
  glitched3 <- randomReplaceByte glitched2
  glitched4 <- randomSortSection glitched3
  glitched5 <- randomReplaceByte glitched4
  let glitchedFileName = mconcat ["glitched_",fileName]
  BC.writeFile glitchedFileName glitched5
  print "all done"

这可以工作,但是以这种方式编写代码显然很麻烦,并且很容易因为你必须跟踪的所有名称而出现简单的拼写错误。相反,你可以使用 Control.Monad 中的 foldM。正如 mapMmap 推广到 monad(此时,只是使用 do-notation 的代码),foldM 对 folding 执行相同的操作。使用 foldM,你可以将你的原始 imageFile 作为初始值,然后是一个将转换你的文件的 IO action 列表。 唯一缺少的是一个将应用这些函数的函数。在这种情况下,你可以使用一个简单的 lambda 表达式。这是使用 foldM 重写的你的 main 函数。 Haskell

main :: IO ()
main = do
  args <- getArgs
  let fileName = head args
  imageFile <- BC.readFile fileName
  glitched <- foldM (\bytes func -> func bytes) imageFile [randomReplaceByte ,randomSortSection ,randomReplaceByte ,randomSortSection ,randomReplaceByte]
  let glitchedFileName = mconcat ["glitched_",fileName]
  BC.writeFile glitchedFileName glitched
  print "all done"

现在你可以最后一次编译你的程序,看看你能制作出什么样的故障!图 25.5 显示了一个示例。 图25.5 Now your<br>beloved author looks more like a resident of Innsmouth! 你可能仍然可以做更多的事情来使这个图像更有趣,但是现在你已经建立了一个可以轻松地将你能想到的任何奇怪的变异链接在一起的设置。

25.3 ByteStrings、Char8 和 Unicode#

正如你在我们的故障艺术示例中看到的那样,ByteString.Char8 是将二进制数据视为文本的有用工具。但是,在使用 ByteStringByteString.Char8 和 Unicode 数据时,务必小心。这是一个将 BC.ByteString 设置为 Unicode 字符串的示例(对于此 Unicode,你使用的是著名哲学家龙树的梵文天城文)。 Haskell

nagarjunaBC :: BC.ByteString
nagarjunaBC = "नागर्जुनॅ"

如果你将其加载到 GHCi 中,你会看到 Unicode 没有被保留:

GHCi> nagarjunaBC
"(>\ETB0M\FSA("

这并不太令人惊讶,因为 Char8 ByteString 仅适用于 ASCII。但是出于各种原因,你可能希望将文本转换为字节,主要原因是将 Unicode 作为 ByteString 写入文件。假设你的 Unicode 安全地表示为 Text 类型。

nagarjunaText :: T.Text
nagarjunaText = "नागर्जुनॅ"

要将 nagarjunaText 转换为字节向量,你不能简单地使用 BC.pack,因为 BC.pack 的类型是 String -> ByteString,所以你首先需要使用 T.unpack,然后再使用 BC.packHaskell

nagarjunaB :: B.ByteString
nagarjunaB = (BC.pack . T.unpack) nagarjunaText

如果你查看类型签名,你应该已经将你的 Unicode 安全地表示为字节。但是如果你转换回来,你会发现情况并非如此。请注意,你需要限定导入 Data.Text.IO 以确保正确打印文本:

GHCi> TIO.putStrLn ((T.pack . BC.unpack) nagarjunaB)
"(>\ETB0M\FSA("

你陷入了同样的问题!如果你将 nagarjunaB 写入文件,你最终会丢失你的 Unicode。你需要一种直接将 Text 转换为 B.ByteString 而不是在此过程中转换为 BC.ByteString 的方法。为此,你需要使用 Data.Text.Encoding,并且你需要进行另一次限定导入:

import qualified Data.Text.Encoding as E

此模块包含两个允许你执行此直接转换的基本函数:

E.encodeUtf8 :: T.Text -> B.ByteString
E.decodeUtf8 :: B.ByteString -> T.Text

现在你可以安全地将 Unicode 文本转换为原始字节并再次转换回来。

nagarjunaSafe :: B.ByteString
nagarjunaSafe = E.encodeUtf8 nagarjunaText
GHCi> TIO.putStrLn (E.decodeUtf8 nagarjunaSafe)
नागर्जुन

为了安全起见,如果你的数据可能包含 Unicode,请永远不要使用 Data.ByteString.Char8 的便利性。如果你正在处理纯粹的二进制数据,就像本课的示例一样,常规 ByteStringChar8 的组合效果很好。对于任何其他情况,请坚持使用 ByteStringTextText.Encoding。在本单元的总结中,你将看到后一种情况的扩展示例。

总结#

在本课中,我们的目标是教你如何在 Haskell 中编写二进制数据。ByteString 类型允许你以类似于处理普通字符串的方式处理原始二进制数据。这可以极大地简化你编写编辑二进制数据的程序的方式。但是,务必记住不要将二进制数据的单字节表示 (Char8) 与 Unicode 文本混合使用。让我们看看你是否理解了。 Q25.1 编写一个程序,该程序读取一个文本文件,并输出文件中字符数和字节数之间的差值。 Q25.2 添加另一种故障技术 randomReverseBytes,该技术可以随机反转数据中的字节段。

📎 参考文章#