۱۴ - ۵کد مورس

با انگیزه‌ی تمرین بیشتر از تستینگ، یه پروژه میسازیم که نوشته رو به کُد مورس ترجمه می‌کنه (و برعکس). با یه پروژه‌ی جدید شروع می‌کنیم. هر وقت از دستورِ ‏‎stack new project-name‎‏ برای شروع یه پروژه‌ی جدید استفاده می‌کنین (بجای ‏‎stack init‎‏ برای یه پروژه‌ای که از قبل داشتین)، خودش یه فایل به اسمِ ‏‎Setup.hs‎‏ ایجاد می‌کنه که توش چنین کُدی داره:

import Distribution.Simple
main = defaultMain

چیز مهمی نیست، معمولاً اصلاً نباید بهش دست بزنین. اما هرازگاهی لازم میشه ‏‎Setup.hs‎‏ رو تغییر بدین، پس خوبه که بدونین وجود داره.

بعد، طبق معمول، فایلِ ‏‎.cabal‎‏ رو تنظیم می‌کنیم. بخشی از محتویات این فایل با دستورِ ‏‎stack new project-name‎‏ براتون نوشته شده، اما باید یه چیزهایی بهش اضافه کنین (با دقت به چیزهایی مثل کوچیک/بزرگ بودنِ حروف و توگذاری و غیره):

name:                morse
version:             0.1.0.0
license-file:        LICENSE
author:              Chris Allen
maintainer:          cma@bitemyapp.com
category:            Text
build-type:          Simple
cabal-version:       >=1.10

library
  exposed-modules:     Morse
  ghc-options:         -Wall -fwarn-tabs
  build-depends:       base >=4.7 && <5
                     , containers
                     , QuickCheck
  hs-source-dirs:      src
  default-language:    Haskell2010

executable morse
  main-is              Main.hs
  ghc-options:         -Wall -fwarn-tabs
  hs-source-dirs:      src
  build-depends:       base >=4.7 && <5
                     , containers
                     , morse
                     , QuickCheck
  default-language:    Haskell2010

test-suite tests
  ghc-options:         -Wall -fwarn-tabs
  type:                exitcode-stdio-1.0
  main-is              test.hs
  hs-source-dirs:      tests
  build-depends:       base
                     , containers
                     , morse
                     , QuickCheck
  default-language:    Haskell2010

خب این آماده شد. حالا باید پوشه ِ ‏‎src‎‏ و فایلی به اسمِ ‏‎Morse.hs‎‏ رو به عنوانِ ماژولِ افشاشده درست کنیم:

-- src/Morse.hs

module Morse
       ( Morse
       , charToMorse
       , morseToChar
       , stringToMorse
       , letterToMorse
       , morseToLetter
       ) where

import qualified Data.Map as M

type Morse = String

چه خبره؟؟ اون همه چیزمیز بعد از اسمِ ماژول دیگه چیه؟ اون یه لیست از همه‌ی چیزهایی ِه که این ماژول صادر می‌کنه. یه کم تو فصل قبل توضیح دادیم ولی ازش استفاده‌ای نکردیم. در داربازی همه‌ی تابع‌هامون تو یه فایل بودن، پس لازم نداشتیم چیزی رو صادر کنیم.

نکته

مجبور نیستین صادرات رو اینطوری مشخص کنین. بطورِ پیش‌فرض، کلِ ماژول افشا و قابلِ وارد شدن توسطِ بقیه‌ی ماژول‌ها میشه. اما در پروژه‌های بزرگ، این طور نوشتنِ صادرات (که چه چیزی صادر بشه و متعاقباً چه چیزی صادر نشه) کمک به مدیریت پروژه می‌کنه، چون میشه گفت نقش راهنما یا مستندات رو هم بازی می‌کنه. در این مثال چیزهایی که صادر کردیم بیشتر از چیزهایی‌اند که در ‏‎Main‎‏ وارد کردیم (برفرض دیرتر متوجه شدیم که فقط دوتا از توابع‌ش رو لازم داریم). میشه برگردیم و از لیستِ بالا اونهایی که وارد نکردیم رو حذف کنیم. ولی ما اینجا این کار رو نمی‌کنیم چون می‌خوایم پروسه‌ای که در ساخت پروژه طی می‌کنیم رو بهتر نشون بدیم.

تبدیلِ لغات به کُد

ماژول ِ ‏‎Data.Map‎‏ رو هم ‏‎qualified‎‏ وارد کردیم (در فصل قبل به این روش از واردات اشاره کردیم). اینجا واردات رو مقیّد کردیم و اسم‌ش رو گذاشتیم ‏‎M‎‏ تا بتونیم با پیشوندِ ‏‎M‎‏ از توابعِ پکیج استفاده کنیم. فایده‌ی این کار اینه که بدونِ نیاز به نوشتنِ کلِ عبارتِ ‏‎Data.Map‎‏، هم بدونیم چه تابعی از کجا اومده، و هم از تداخل توابعِ هم‌نام با تابع‌های ‏‎Prelude‎‏ جلوگیری کنیم.

ساختار داده ِ ‏‎Map‎‏ رو جلوتر در کتاب توضیح میدیم. فعلاً میشه یه درختِ متعادل فرض‌ش کرد، که هر گره‌ش یه جفت از یک کلید و یک مقداره. اون کلید در واقع یه اندیس برای اون مقداره – یه نشانگری که باهاش مقدار رو از داخلِ درخت پیدا می‌کنیم. این کلید حتماً باید ترتیب‌دار باشه (یعنی یه نمونه از ‏‎Ord‎‏ داشته باشه). مشابهِ توابعی که برای درخت‌های دودویی نوشتیم (مثلِ ‏‎insert‎‏) و یه نمونه از ‏‎Ord‎‏ لازم داشتن. ‏‎Map‎‏ها ممکنه از لیست‌ها بازدهی ِ بیشتری داشته باشن، چون لازم نیست یه مشت داده رو بصورتِ خطی بگردن. به این دلیل که کلیدها ترتیب دارن و درخت متعادل ِه، هر بار که در جستجوی درخت میریم "چپ" یا "راست،" فضای جستجو نصف میشه. اندیس ِ گره‌ای که روش هستیم با کلید ِ مورد نظر مقایسه میشه؛ اگه کلید کوچیکتر باشه میریم چپ، بزرگتر باشه میریم راست، اگر هم مساوی باشن یعنی به گره ِ مقدارِ موردنظر رسیدیم.

این پایین دلیلِ اینکه چرا بجای یه لیستِ معمولی از ‏‎Map‎‏ استفاده کردیم معلوم شده. اینجا یه لیست از جفتهایی لازم داریم، که هم حرف ِ انگلیسی و هم معادلِ مورس ِشون رو شامل میشن. جدول تبدیل حروف رو به اینصورت تعریف می‌کنیم:

letterToMorse :: (M.Map Char Morse)
letterToMorse = M.fromList [
      ('a', ".-")
    , ('b', "-...")
    , ('c', "-.-.")
    , ('d', "-..")
    , ('e', ".")

    , ('f', "..-.")
    , ('g', "--.")
    , ('h', "....")
    , ('i', "..")
    , ('j', ".---")

    , ('k', "-.-")
    , ('l', ".-..")
    , ('m', "--")
    , ('n', "-.")
    , ('o', "---")

    , ('p', ".--.")
    , ('q', "--.-")
    , ('r', ".-.")
    , ('s', "...")
    , ('t', "-")

    , ('u', "..-")
    , ('v', "...-")
    , ('w', ".--")
    , ('x', "..-.")
    , ('y', "--.")
    , ('z', "....")

    , ('1', ".----")
    , ('2', "..---")
    , ('3', "...--")
    , ('4', "....-")
    , ('5', ".....")
    , ('6', "-....")
    , ('7', "--...")
    , ('8', "---..")
    , ('9', "----.")
    , ('0', "-----")
    ]

می‌بینید از تابعِ ‏‎M.fromList‎‏ استفاده کردیم – اون پیشوندِ ‏‎M‎‏ نشون میده تابع از ‏‎Data.Map‎‏ اومده. از ‏‎Map‎‏ برای ربط دادن بین حروف و معادلِ مورس ِشون استفاده کردیم. به این ‏‎Map‎‏ که توی برنامه برای پیدا کردنِ کد مورس ِ هر حرف ازش استفاده می‌کنیم، اسمِ ‏‎letterToMorse‎‏ رو انقیاد دادیم.

بعد چندتا تابع برای تبدیلِ حرف ِ مورس به حرف ِ انگلیسی (و برعکس)، و همینطور توابعی که همین کار رو با نوشته‌ها انجام میدن تعریف می‌کنیم:

morseToLetter :: M.Map Morse Char
morseToLetter =
  M.foldWithKey (flip M.insert) M.empty
                letterToMorse

charToMorse :: Char -> Maybe Morse
charToMorse c =
  M.lookup c letterToMorse

stringToMorse :: String -> Maybe [Morse]
stringToMorse s =
  sequence $ fmap charToMorse s

morseToChar :: Morse -> Maybe Char
morseToChar m =
  M.lookup m morseToLetter

به استفاده از ‏‎Maybe‎‏ در سه تا از اینها دقت کنین: هر ‏‎Char‎‏ ای که در یه ‏‎String‎‏ هست، معادلِ مورس نداره.

اصلِ نمایش

حالا یه ماژول ِ ‏‎Main‎‏ برپا می‌کنیم که کارِ تبدیلِ مورس رو به عهده بگیره. چندتا چیز رو داخل‌ش وارد می‌کنیم، بعضی‌هاشون رو در فصل قبل دیدیم، بعضی‌هاشون هم توضیح ندادیم. از اونجا که نمی‌خوایم وارد جزئیاتِ این کُد بشیم، اون واردات رو هم توضیح نمیدیم. فقط دقت کنین که یکی از اون واردات، ماژول ِ ‏‎Morse.hs‎‏ ِ خودمون‌ه که بالاتر تعریف کردیم.

-- src/Main.hs

module Main where

import Control.Monad (forever, when)
import Data.List (intercalate)
import Data.Traversable (traverse)
import Morse (stringToMorse, morseToChar)
import System.Environment (getArgs) 
import System.Exit (exitFailure,
                    exitSuccess) 
import System.IO (hGetLine, hIsEOF, stdin)

همونطور که گفتیم این بخش رو با جزئیات توضیح نمیدیم. ما تشویق‌تون می‌کنیم که تا جای ممکن اینها رو بخونین و تلاش‌تون رو برای درک‌شون بکنین، ولی یه کم سنگین‌ه، این فصل هم راجع به این کُدها نیست – راجع به تست‌هاست. این کاری که ازتون می‌خوایم انجام بدین شبیه برنامه‌نویسی بارباورانه‌*هست... خودمون هم خوشمون نمیاد، اما این کار رو می‌کنیم تا روی تستینگ تمرکز کنیم. همه‌ی اینها رو تو ماژول ِ ‏‎Main‎‏ ِتون بنویسین – اول تابعِ تبدیل به مورس:

convertToMorse :: IO ()
convertToMorse = forever $ do
  weAreDone <- hIsEOF stdin
  when weAreDone exitSuccess

  -- در غیر اینصورت، ادامه بده.
  line <- hGetLine stdin
  convertLine line

  where
    convertLine line = do
      let morse = stringToMorse line
      case morse of
       (Just str)
         -> putStrLn
            (intercalate " " str)
       Nothing
         -> do
           putStrLn $ "ERROR: " ++ line
           exitFailure


-- :م. این خط کُد
-- when weAreDone exitSuccess

-- :اینطور معنا میده
-- هروقت کار تموم شد، با موفقیت خارج شو
-- ^----------------^  ^----------^ ^----^
--    exitSuccess      we are done   when
*

م. یعنی کپی/پیست کردنِ کُد بدون فهمیدن‌ش.

حالا تابعِ تبدیل از مورس رو اضافه کنین:

convertFromMorse :: IO ()
convertFromMorse = forever $ do
  weAreDone <- hIsEOF stdin
  when weAreDone exitSuccess

  -- در غیر اینصورت، ادامه بده.
  line <- hGetLine stdin
  convertLine line

  where
    convertLine line = do
      let decoded :: Maybe String
          decoded =
            traverse morseToChar
                     (words line)
      case decoded of
       (Just s) -> putStrLn s
       Nothing -> do
         putStrLn $ "ERROR: " ++ line
         exitFailure

و حالا ‏‎main‎‏ ِ واجب:

main :: IO ()
main = do
  mode <- getArgs
  case mode of
    [arg] ->
      case arg of
       "from" -> convertFromMorse
       "to"   -> convertToMorse
       _      -> argError
    _ -> argError

   where argError = do
           putStrLn "Please specify the\
                     \ first argument\
                     \ as being 'from' or\
                     \ 'to' morse,\ 
                     \ such as: morse to"
           exitFailure

مطمئن شین همه چیز کار می‌کنه

از ترمینال یا command line، با استفاده از دستورِ ‏‎‎‏echo می‌تونیم از درست کار کردنِ همه چیز مطمئن بشیم. اگه با این روش آشنا و راحت هستین، امتحان‌ش کنین:

$ echo "hi" | stack exec morse to
.... ..
$ echo ".... .." | stack exec morse from
hi

تو Mac و Linux، با دستورِ ‏‎exec which morse‎‏ میشه آدرسی که Stack فایلِ قابل اجرا رو گذاشته پیدا کنین. با دستورِ ‏‎stack install‎‏ هم می‌تونین از Stack بخواین که اول برنامه رو بسازه (اگه لازم بود) بعد هم فایل‌های باینری‌ش رو از پروژه به یه آدرس مشترک کپی کنه. برای Mac و Linux این آدرس در ‏‎.local/bin‎‏ از پوشه‌ی خونه (م. عموماً با دستورِ ‏‎cd ~‎‏ میشه رفت اونجا) قرار داره. یکی از دلایلی که این آدرس انتخاب شده، احترام به اصولِ XDG ِه.

راه دیگه هم اینه که این ماژول رو در GHCi REPL بارگذاری کنین و از کامپایل شدنِ همه چیز و کار کردن‌شون (تا حدی) مطمئن بشین. بهتره هر خطای تایپ یا مشکلِ گرامری وجود داره، الان و قبل از شروع به اجرای تست‌ها درست کنین.

وقت تسته!

حالا باید تست‌هامون رو بنویسیم؛ فایل‌هاشون رو جداگانه در پوشه ِ خودشون گذاشتیم. اسمِ ماژول رو باز هم میذاریم ‏‎Main‎‏، اما به اسم فایل دقت کنین (اسم فایل به خودیِ‌خود مهم نیست، فقط باید با اسمی که برای فایل تست در فایل تنظیمات ِ Cabal تعریف کردین همخونی داشته باشه):

-- test/tests.hs

module Main where

import qualified Data.Map as M
import Morse
import Test.QuickCheck

وارداتِ کمتری برای این ماژول داریم، همه‌شون هم باید براتون آشنا باشن.

حالا ژنراتورهامون رو آماده می‌کنیم تا مطمئن باشیم مقادیری که ‏‎QuickCheck‎‏ برای تست برنامه‌مون استفاده می‌کنه مقادیر مناسبی‌اند:

allowedChars :: [Char]
allowedChars = M.keys letterToMorse

allowedMorse :: [Char]
allowedMorse = M.elems letterToMorse

charGen :: Gen Char
charGen = elements allowedChars

morseGen :: Gen Morse
morseGen = elements allowedMorse

بالاتر خیلی مختصر ‏‎elements‎‏ رو دیدیم. یه لیست از یه تایپی می‌گیره (اینجا لیست حروف و مورس‌های قابل قبول) و یه مقدارِ ‏‎Gen‎‏ از مقادیرِ داخل اون لیست انتخاب می‌کنه. به دلیل اینکه ‏‎Char‎‏ شامل هزاران حرفی که معادلِ مورس ندارن میشه، باید ژنراتور ِ اختصاصی ِ خودمون رو بنویسیم.

حالا مشخصه‌ای که می‌خوایم چک کنیم رو می‌نویسیم. می‌خوایم چک کنیم اگه چیزی رو به کد مورس تبدیل کنیم، و بعد نتیجه‌ش رو دوباره تبدیلِ معکوس کنیم، با اول‌ش برابر میشه یا نه:

prop_thereAndBackAgain :: Property
prop_thereAndBackAgain =
  forAll charGen
  (\c -> ((charToMorse c)
    >>= morseToChar) == Just c)

main :: IO ()
main = quickCheck prop_thereAndBackAgain

همه‌ی این کارها رو که انجام بدین، پوشه ِ پروژه‌تون باید چنین وضعی داشته باشه:

$ tree
.
├── LICENSE
├── Setup.hs
├── morse.cabal
├── src
|   ├── Main.hs
|   └── Morse.hs
├── stack.yaml
└── tests
    └── tests.hs

تست با مورس

حالا که همه چیز به نظر خوب میرسه، تست‌هامون رو اجرا می‌کنیم تا مطمئن بشیم. مشخصه‌ای که می‌خوایم تست کنیم اینه که اگه یه نوشته رو یه بار به مورس تبدیل کنیم و بعد دوباره تبدیلِ معکوس‌ش کنیم، نوشته ِ اصلی رو پس می‌گیریم یا نه. از آدرس ِ اصلی پروژه‌مون یه REPL باز می‌کنیم تا تست‌ها رو بارگذاری کنیم:

$ stack ghci morse:tests

{... شلوغی شلوغی شلوغی ...}

Ok, modules loaded: Main.
Prelude>

عالی. Stack همه چیز رو برامون بارگذاری کرد، هر وابستگی هم که لازم بود ساخت. ببینیم چی میشه:

Prelude> main
+++ OK, passed 100 tests.

این تست ۱۰۰ تا نوشته ِ تصادفی رو تبدیلِ رفت‌وبرگشتی کرد، و مطمئن شد که تبدیل به/از مورس، نوشته‌ها رو تغییری نداد. این تضمینِ خیلی خوبی‌ه که بدونین برنامه‌تون سالم‌ِه و به ازای هر ورودی‌ای همونطور که انتظار دارین عمل می‌کنه.