۱۴ - ۵کد مورس
با انگیزهی تمرین بیشتر از تستینگ، یه پروژه میسازیم که نوشته رو به کُد مورس ترجمه میکنه (و برعکس). با یه پروژهی جدید شروع میکنیم. هر وقت از دستورِ 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.این تست ۱۰۰ تا نوشته ِ تصادفی رو تبدیلِ رفتوبرگشتی کرد، و مطمئن شد که تبدیل به/از مورس، نوشتهها رو تغییری نداد. این تضمینِ خیلی خوبیه که بدونین برنامهتون سالمِه و به ازای هر ورودیای همونطور که انتظار دارین عمل میکنه.