۲۷ - ۹تایپ‌های نوشتاری

عنوان این بخش یه کم اشتباهه، چون راجع به دوتا تایپِ نوشتاری و یک تایپ برای ارائه‌ی تسلسل ِ بایت‌ها صحبت می‌کنیم. اول ‏‎String‎‏، ‏‎Text‎‏، و ‏‎ByteString‎‏ رو اجمالاً توضیح میدیم:

‏‎String‎‏

‏‎String‎‏ رو می‌شناسین. تایپِ مستعار برای یه لیست از ‏‎Char‎‏ ِه، اما در پشتِ پرده به همون سادگیِ یه لیستِ ‏‎Char‎‏ نیست.

یکی از مزایای استفاده از ‏‎String‎‏ سادگی‌ه، توضیح دادن‌شون هم آسون‌ه. برای بیشترِ مثال‌ها و برنامه‌های بازیچه‌ای مناسب‌اند.

اما مثل خودِ لیست، ‏‎String‎‏‌ها می‌تونن بینهایت باشن. حتی برای ‏‎String‎‏‌های خیلی بزرگ هم ممکنه سریع مصرفِ حافظه از کنترل خارج بشه. دیگه اینکه اندیس‌گیری ِ حرف‌به‌حرف توی ‏‎String‎‏ اصلاً بهینه نیست. زمان لازم برای یک جستجو متناسب با طولِ لیست زیاد میشه.

‏‎Text‎‏

این تایپ از کتابخونه ِ ‏‎text‎‏ (روی Hackage) میاد. بهترین کاربردش برای وقت‌هایی‌ه که متن ساده دارین، و می‌خواین داده‌تون رو بهینه‌تر ذخیره کنین – بخصوص وقتی مصرفِ حافظه مهمه.

در فصل‌های قبل یه کم از این تایپ استفاده کردیم (اون موقع که با ‏‎OverloadedStrings‎‏ بازی می‌کردیم). مزایای این تایپ اینها هستن:

  • ارائه‌ی فشرده در حافظه؛ و

  • اندیس‌گیری ِ بهینه در نوشته.

    اما ‏‎Text‎‏ با UTF-16 کدبندی شده، و با توجه به محبوبیتِ UTF-8، چیزی نیست که اکثرِ مردم انتظارش رو داشته باشن. در دفاع از ‏‎Text‎‏، معمولاً UTF-16 به خاطر رابطه‌ی بهتری که با حافظه‌ی نهان داره (یا cache-friendly ِه) سرعتِ بیشتری داره.

    شکمی پیش نرین، اندازه بگیرین

    گفتیم ‏‎Text‎‏ ارائه‌ی فشرده‌تری توی حافظه داره، ولی فکر می‌کنین پروفایلِ حافظه ِ برنامه‌ی زیر چطور بشه؟ اول زیاد، بعد کم، یا اول کم بعد زیاد؟

    module Main where
    
    import Control.Monad.Primitive 
    import qualified Data.Text as T
    import qualified Data.Text.IO as TIO
    import qualified System.IO as SIO
    
    -- رو متناسب "/usr/share/dict/words" آدرس
    -- .با دستگاه خودتون تغییر بدین
    dictWords :: IO String
    dictWords =
      SIO.readFile "/usr/share/dict/words"
    
    dictWordsT :: IO T.Text
    dictWordsT = 
      TIO.readFile "/usr/share/dict/words" 
    
    main :: IO ()
    main = do
      replicateM_ 1000 (dictWords >>= print)
      replicateM_ 1000
        (dictWordsT >>= TIO.putStrLn)

    مشکل اینجاست که هربار اجراییه ِ ‏‎IO‎‏ اجبار میشه، ‏‎Text‎‏ کلِ فایل رو توی حافظه بارگذاری می‌کنه. اما عملیات ِ ‏‎readFile‎‏ برای ‏‎String‎‏ تنبل ِه و فقط همون مقداری از فایل رو که برای چاپ به ‏‎stdout‎‏ لازمه می‌بره توی حافظه. راهِ صحیح برای پردازشِ تدریجیِ داده‌ها، استفاده از جریان ِه،* البته چیزی نیست که با جزئیات بهش بپردازیم. ولی اینطوری میشه کدِمون رو تنبل‌تر کنیم:

    -- این رو به ماژولی که برای پروفایلینگ
    -- ‎‏درست کردین اضافه کنین.‏‎ Text و String
    
    import qualified Data.Text.Lazy as TL
    import qualified Data.Text.Lazy.IO as TLIO
    
    dictWordsTL :: IO TL.Text
    dictWordsTL =
      TLIO.readFile "/usr/share/dict/words"
    
    main :: IO ()
    main = do
      replicateM_ 1000 (dictWords >>= print)
      replicateM_ 1000
        (dictWordsT >>= TIO.putStrLn)
      replicateM_ 1000
        (dictWordsTL >>= TLIO.putStrLn)

    حالا مصرف حافظه بعد از یه رشد زیاد اون وسط، دوباره افت می‌کنه؛ دلیل‌ش اینه که فقط مقداری که برای چاپِ متن لازمه میره توی حافظه، و در طول مسیر هم حافظه رو آزاد می‌کنه. این چندتا (فقط چندتا، نه همه) از مزایای جریان‌دهی ِه، ما به شدت توصیه می‌کنیم بجای اتکا به یه API برای ‏‎IO‎‏ ِ تنبل، از جریان‌دهی استفاده کنین.

    ‏‎ByteString‎‏

    این یه نوشته یا متن نیست. حداقل، "الزاماً" این دوتا نیست. ‏‎ByteString‎‏ ها تسلسلی از بایت‌ها هستن که (بطور غیرمستقیم) با یه بردار از مقادیرِ ‏‎Word8‎‏ ارائه میشن. متن توی کامپیوتر متشکل از بایت‌ها‌ست، اما باید به نحوِ بخصوصی کدبندی شده باشه تا متن بشن. کدبندی ِ متن می‌تونه ‏‎ASCII‎‏، ‏‎UTF-8‎‏، ‏‎UTF-16‎‏، یا UTF-32 باشه (معمولاً UTF-8 یا UTF-16). تایپِ ‏‎Text‎‏ داده رو با UTF-16 کدبندی می‌کنه، یکی از دلایل‌ش عملکرد ِ بهتره. معمولاً اگه با هر بار خوندن از حافظه، تیکه‌های بزرگتری از داده خونده بشن، سرعت بالا میره، در نتیجه این ۱۶ بیت برای هر کاراکتر در کدبندی ِ UTF-16 در اکثرِ موارد بهتر عمل می‌کنه.

    مزیتِ اصلیِ ‏‎ByteString‎‏ اینه که خیلی راحت میشه ازش استفاده کرد (به واسطه‌ی ‏‎OverloadedStrings‎‏ ولی بجای اینکه فقط متن باشه، بایت ِه. همین باعث میشه فضای مسئله‌ی خیلی بزرگتری رو نسبت به متن ِ خالی پوشش بده.

    البته جنبه‌ی دیگه‌ش اینه که داده‌های بایتی‌ای که متن ِ درستی نیستن هم شامل میشه؛ که اگه نخواین بایت‌های غیرِمتنی ِ داخلِ داده‌تون بیان، این مورد جزء معایبِ ‏‎ByteString‎‏ محسوب میشه.

    مثال‌های ‏‎ByteString‎‏

    این مثال نشون میده که مقادیرِ ‏‎ByteString‎‏ همیشه متن نیستن:

    {-# LANGUAGE OverloadedStrings #-}
    
    module BS where
    
    import qualified Data.Text.IO as TIO
    import qualified Data.Text.Encoding as TE
    import qualified Data.ByteString.Lazy as BL
    -- https://hackage.haskell.org/package/zlib
    import qualified
      Code.Compression.GZip as GZip

    اینجا از فرمتِ فشرده‌سازی ِ ‏‎gzip‎‏ استفاده می‌کنیم تا داده‌ای درست کنیم که حاویِ بایت‌هایی با کدبندی ِ نامعتبر برای متن باشه.

    input :: BL.ByteString
    input = "123"
    
    compressed :: BL.ByteString
    compressed = BL.compress input

    ماژول ِ ‏‎GZip‎‏ انتظارِ ‏‎ByteString‎‏ ِ تنبل داره (احتمالاً به خاطر کارکردِ بهتر برای جریان‌دهی).

    main :: IO () 
    main = do 
      TIO.putStrLn $ TE.decodeUtf8 (s input)
      TIO.putStrLn $
        TE.decodeUtf8 (s compressed)
      where s = BL.toStrict

    ماژول ِ کدبندی از کتابخونه ِ ‏‎text‎‏ (م. ‏‎Data.Text.Encoding‎‏) انتظارِ ‏‎ByteString‎‏ ِ اکید داره، پس قبل از کدگشایی، اول باید اکید‌ِش کنیم. اینجا کدگشایی ِ دوم شکست می‌خوره، چون بایتی که بهش داده شده با کدبندی ِ متنی سازگاری نداره.

    تله‌های ‏‎ByteString‎‏

    ممکنه پیش خودتون فکر کنین: "می‌خوام یه ‏‎String‎‏ رو به ‏‎ByteString‎‏ تبدیل کنم!" کاملاً منطقی‌ه، اما خیلی از هسکل‌نویس‌ها به اشتباه از ماژول ِ ‏‎Char8‎‏ از کتابخونه ِ ‏‎bytestring‎‏ استفاده می‌کنن؛ در واقع چیزی که می‌خوان، اون نیست. ماژول ِ ‏‎Char8‎‏ فقط برای راحتی و کار با داده‌های بایتی و ‏‎ASCII‎‏ طراحی شده.* برای یونیکد کار نمی‌کنه، و اگه کوچکترین احتمالِ وجودِ داده‌ی یونیکد هست، نباید ازش استفاده بشه. برای مثال:

    module Char8ProllyNotWhatYouWant where
    
    import qualified Data.Text as T
    import qualified Data.Text.Encoding as TE
    import qualified Data.ByteString as B
    import qualified Data.ByteString.Char8 as B8
    
    -- utf8-string
    import qualified Data.ByteString.UTF8 as UTF8
    
    -- ‎‏کدبندی دستی یونیکد برای متن ژاپنی.‏‎
    -- در UTF8 اجازه‌ی GHC Haskell
    -- ‎‏فایل‌های منبع رو میده.‏‎
    s :: String
    s = "\12371\12435\12395\12385\12399\12289\
    \20803\27671\12391\12377\12363\65311"
    
    utf8ThenPrint :: B.ByteString -> IO ()
    utf8ThenPrint = 
      putStrLn . T.unpack . TE.decodeUtf8
    
    throwsException :: IO ()
    throwsException = 
      utf8ThenPrint (B8.pack s)
    
    bytesByWayOfText :: B.ByteString
    bytesByWayOfText = TE.encodeUtf8 (T.pack s)
    
    -- برامون انجام بده utf8-string بذاریم
    libraryDoesTheWork :: B.ByteString
    libraryDoesTheWork = UTF8.fromString s
    
    thisWorks :: IO ()
    thisWorks = utf8ThenPrint bytesByWayOfText
    
    alsoWorks :: IO ()
    alsoWorks = utf8ThenPrint libraryDoesTheWork
    *

    از اونجا که ‏‎ASCII‎‏ با ۷ بیت، و ‏‎Char8‎‏ با ۸ بیت کار می‌کنه، میشه از بیتِ هشتم برای کاراکترهای لاتین-۱ استفاده کرد. ولی به خاطر اینکه معمولاً داده‌ها ِ ‏‎Char8‎‏ به کدبندی‌هایی مثل ‏‎UTF-8‎‏ و ‏‎UTF-16‎‏ تبدیل میشن (که از هشتمین بیت به نحوِ متفاوتی استفاده می‌کنن)، پس خیلی عاقلانه نیست.

    اول کدی که شاملِ یونیکد ِه و سعی می‌کنه با استفاده از ماژول ِ ‏‎Char8‎‏ یه ‏‎ByteString‎‏ چاپ کنه رو اجرا می‌کنیم:

    Prelude> throwsException
    *** Exception: Cannot decode byte '\x93':
      Data.Text.Internal.Encoding.decodeUtf8:
        Invalid UTF-8 stream

    با استفاده از تابع ‏‎ord‎‏ از ‏‎Data.Char‎‏، میشه مقدار ‏‎Int‎‏ ِ بایتِ یه کاراکتر رو پیدا کنین:

    Prelude> import Data.Char (ord)
    Prelude> :t ord
    ord :: Char -> Int
    Prelude> ord 'A'
    65
    Prelude> ord '\12435'
    12435

    مثال دوم ‏‎('\12435')‎‏ به نظر واضح میاد، این تابع برای داده‌هایی که ارائه‌ی مخصوص دارن کاربردی‌تره. می‌تونین از سایت‌هایی که زبان‌شون انگلیسی نیست داده ِ نمونه برای تست پیدا کنین.

    م.

    اگه سیستم عامل‌تون تایپِ فارسی داره، می‌تونین ارائه‌ی عددیِ حروف فارسی هم پیدا کنین!

    Prelude> ord 'م'
    1605
    Prelude> ord '\1587'
    1587
    Prelude> ord 'س'
    1587

    می‌تونیم به کمک ترتیبِ کاراکترها، اولین کاراکتری که ‏‎Char8‎‏ رو شکست میده پیدا کنیم:

    Prelude> let xs = ['A'..'\12435']
    Prelude> let cs = map (:[]) xs
    Prelude> mapM_ (utf8ThenPrint . B8.pack) cs
    ... یه مشت خروجی ...

    پس باید بفهمیم چه چیزی بعد از تیلدا و کاراکترِ ‏‎\DEL‎‏ میاد:

    ... بعد از یه کم سعی و خطا ...
    Prelude> let f = take 3 . drop 60
    Prelude> mapM_ putStrLn (f cs)
    }
    ~
    
    

    خیلی خوب، ولی کجای جدولِ ‏‎ASCII‎‏ میشه؟ می‌تونیم از تابعِ متضادِ ‏‎ord‎‏ از ‏‎Data.Char‎‏ به اسمِ ‏‎chr‎‏ استفاده کنیم تا بفهمیم:

    Prelude> import Data.Char (chr)
    Prelude> :t chr
    chr :: Int -> Char
    Prelude> map chr [0..128]
    ... ‎‏۱۲۹ کاراکتر اول رو چاپ می‌کنه‏‎ ...

    چیزی که چاپ شد با جدولِ ‏‎ASCII‎‏ همخونی داره، و ‏‎UTF-8‎‏ هم اون کاراکترها رو همونطوری ارائه میده. حالا میشه با استفاده از این تابع ببینیم دقیقاً کجا کدِمون شکست می‌خوره:

    -- درست کار می‌کنه
    Prelude> utf8ThenPrint (B8.pack [chr 127])
    
    -- شکست می‌خوره
    Prelude> utf8ThenPrint (B8.pack [chr 128])
    *** Exception: Cannot decode byte '\x80':
      Data.Text.Internal.Encoding.decodeUtf8:
        Invalid UTF-8 stream

    با ماژول ِ ‏‎Char8‎‏ از کاراکترهای یونیکد استفاده نکنین! این مشکل فقط مختصِ هسکل نیست – همه‌ی زبان‌های برنامه‌نویسی باید به وجود کدبندی‌های مختلفِ متن واقف باشن.

    ‏‎Char8‎‏ بَده، قبوووووله

    ماژول ِ ‏‎Char8‎‏ برای یونیکد نیست؛ یا بطور کلی‌تر، برای متن نیست! تابعِ ‏‎pack‎‏ که داره فقط برای داده ِ ‏‎ASCII‎‏ ِه! چیزی که برنامه‌نویس‌ها رو گمراه می‌کنه اینه که کدبندی ِ ‏‎UTF-8‎‏ برای الفبای انگلیسی و چندتا کاراکترِ لاتین، عمداً با همون بایت‌هایی که ‏‎ASCII‎‏ برای کدبندی ِ همون کاراکترها استفاده می‌کنه، دقیقاً یکسان‌اند. پس این کد کار می‌کنه، اما از لحاظِ اصولی غلط‌ه:

    Prelude> utf8ThenPrint (B8.pack "blah")
    blah

    اگه به نتیجه‌ی ‏‎thisWorks‎‏ و ‏‎alsoWorks‎‏ نگاه کنین، می‌بینین که اگه با کتابخونه ِ ‏‎text‎‏ یا ‏‎utf8-string‎‏ یه ‏‎ByteString‎‏ ِ ‏‎UTF-8‎‏ بگیرین، به خوبی کار می‌کنه.

    کِی باید برای داده‌ی نوشتاری از ‏‎ByteString‎‏ بجای ‏‎Text‎‏ استفاده کرد؟

    معمولاً زمان‌هایی این کار لازمه که می‌خواین داده‌ای که با کدبندی ‏‎UTF-8‎‏ از راه رسیده رو ‏‎UTF-8‎‏ نگه دارین. معمولاً داده ِ ‏‎UTF-8‎‏ رو از یه فایل یا سوکتِ شبکه می‌خونین، و برای جلوگیری از سرباری، نمی‌خواین مدام بینِ ‏‎Text‎‏ و ‏‎UTF-8‎‏ نوسان کنین. اما اگه چنین کاری لازمه، شاید بهتر باشه از ‏‎newtype‎‏ استفاده کنین تا اشتباهاً با ‏‎ByteString‎‏‌های غیرِ ‏‎UTF-8‎‏ غاطی نشن.