۱۵ - ۱۲زندگی بهتر با QuickCheck

اثبات قوانین معمولاً خیلی زحمت می‌بره، مخصوصاً اگه وَسطای کدنویسی باشیم و دائماً کُدمون تغییر کنه. پس اگه یه راه ارزون و کم‌زحمت داشته باشیم که تا حدی معلوم کنه در یه نمونه، قوانین به احتمال زیاد رعایت میشن خیلی مفیده. ‏‎QuickCheck‎‏ یه گزینه‌ی خیلی خوب برای چنین چیزی‌ه.

تأیید ِ شرکت‌پذیری با ‏‎QuickCheck‎‏

شرکت‌پذیری ِ بیانیه‌های ساده‌ی حساب رو میشه با استفاده از تابعِ تساوی، روی دو نوع پرانتزگذاری تو REPL چک کرد:

-- اینها رو برابر می‌دونیم
-- شرکت‌پذیراند (*) ‎‏و‏‎ (+) ‎‏چون‏‎

1 + (2 + 3) == (1 + 2) + 3

4 * (5 * 6) == (4 * 5) * 6

البته این ثابت نمی‌کنه که شرکت‌پذیری برای همه‌ی ورودی‌های ‏‎(+)‎‏ و ‏‎(*)‎‏ حاکمه، ما هم قصد داریم این رو ثابت کنیم. رفیق قدیمی‌مون از جبر لانداتجرید! – برای این اثبات کافیه:

\ a b c -> a + (b + c) == (a + b) + c

\ a b c -> a * (b * c) == (a * b) * c

اما آرگومان‌ها تنها چیزهایی نیستن که بشه تجرید کرد. چطور میشه مشخصه ِ شرکت‌پذیری رو به صورتِ انتزاعی برای یه تابع نشون داد؟

\ f a b c ->
  f a (f b c) == f (f a b) c
-- (infix) یا میانوندی

\ (<>) a b c ->
  a <> (b <> c) == (a <> b) <> c

سورپرایز! آرگومان‌های تابعی رو می‌تونین به اسم‌های میانوندی هم انقیاد بدین.

asc :: Eq a
    => (a -> a -> a)
    -> a -> a -> a
    -> Bool 
asc (<>) a b c =
  a <> (b <> c) == (a <> b) <> c

حالا چه جوری این تابع رو به یه مشخصه که بشه با ‏‎QuickCheck‎‏ تست کرد تبدیل کنیم؟ سریع‌ترین و آسون‌ترین‌ش شبیهِ این میشه:

import Data.Monoid
import Test.QuickCheck

monoidAssoc :: (Eq m, Monoid m)
            => m -> m -> m -> Bool 
monoidAssoc a b c =
  (a <> (b <> c)) == ((a <> b) <> c)

برای اجرای تست باید تایپ‌های تابع رو تعیین کنیم تا ‏‎QuickCheck‎‏ بدونه چه تایپ‌هایی از داده ایجاد کنه.

حالا با این میشه شرکت‌پذیری ِ تابع‌ها رو چک کرد:

-- برای خلاصه‌نویسی
λ> type S = String
λ> type B = Bool
λ> quickCheck (monoidAssoc :: S -> S -> S -> B)
+++ OK, passed 100 tests.

تابعِ ‏‎quickCheck‎‏ با استفاده از تایپکلاسِ ‏‎Arbitrary‎‏، مقادیرِ تصادفی رو به عنوانِ ورودی به تابع میده تا تست‌شون کنه. با اینکه چنین کاری رایج‌ه، شاید نخوایم به وجود داشتنِ یه نمونه ِ ‏‎Arbitrary‎‏ برای تایپِ ورودیهامون اتکا کنیم. یکی از چندتا دلیل‌ش می‌تونه این باشه که تایپ مالِ خودمون نیست و ترجیح میدیم نمونه‌ی یتیم براش ننویسیم. یا می‌تونه تایپی باشه که خودش یه نمونه از ‏‎Arbitrary‎‏ داره، ولی ما گستردگی ِ متفاوتی از مقادیرِ تصادفی‌ش می‌خوایم؛ یا می‌خوایم کاری کنیم که علاوه بر مقادیرِ تصادفی، حتماً چندتا حالت مرزی ِ خاص هم تست بشن.

حتماً باید تایپ‌های مورد نظر رو اعلام کنیم تا ‏‎QuickCheck‎‏ بدونه از کدوم یکی از نمونه‌های ‏‎Arbitrary‎‏ مقادیرِ تصادفی برای تستینگ رو بگیره. اگه با تابعِ ‏‎verboseCheck‎‏ تست کنین، مقادیری که تست شدن هم می‌بینین.* اگه بدون اعلام ِ تایپ برای آرگومان‌ها، با این تابع تست رو اجرا کنین:

Prelude> verboseCheck monoidAssoc
Passed:
()
()
()

(۱۰۰ بار تکرار)
*

م. لغتِ verbose یکی از لغاتِ پرکاربرد در علمِ کامپیوتره، که میشه گفت روده‌دراز معنی میده! معمولاً برای نوشته شدنِ تک‌تکِ مراحلی که یه دستور طی می‌کنه به کار میره.

این GHCi ِه که با بکارگیری تایپ‌های پیش‌فرض گازِتون گرفت (تو فصل تستینگ هم دیدیم). این پیش‌فرضی‌سازیِ تایپ در GHCi خیلی تحمیلی‌تره، که البته برای جلسه‌های تعاملی که می‌خواین یه کُدی رو سریع اجرا کنین و REPL خودش یه برنده برای تایپکلاس‌هایی که نمیدونه چطور خبر کنه انتخاب کنه، مناسبه. اگه همین مثال رو از یه فایل منبع کامپایل می‌کردین، GHC به مبهم بودنِ تایپ گله می‌کرد.

تستِ همانی ِ چپ و راست

از طریقِ همون کاری که برای شرکت‌پذیری کردیم، همانی ِ چپ و راست هم میشه با ‏‎QuickCheck‎‏ بررسی کرد:

monoidLeftIdentity :: (Eq m, Monoid m)
                   => m
                   -> Bool
monoidLeftIdentity a = (mempty <> a) == a

monoidRightIdentity :: (Eq m, Monoid m)
                    => m
                    -> Bool
monoidRightIdentity a = (a <> mempty) == a

اجرای اینها برای یه ‏‎Monoid‎‏ این نتایج رو داره:

λ> quickCheck (monoidLeftIdentity :: String -> Bool)
+++ OK, passed 100 tests.

λ> quickCheck (monoidRightIdentity :: String -> Bool)
+++ OK, passed 100 tests.

تستِ تاب تحملِ ‏‎QuickCheck‎‏

یه مثال با یه ‏‎Monoid‎‏ ِ نامعتبر بزنیم ببینیم ‏‎QuickCheck‎‏ چی کار می‌کنه. تو این مثال می‌خوایم نشون بدیم که چرا ‏‎False‎‏ نمی‌تونه مقدار همانی ِ یه ‏‎Bool Monoid‎‏ باشه، همیشه مقدارِ ‏‎False‎‏ برگردونه و یه ‏‎Monoid‎‏ ِ معتبر هم باشه:

-- مشخصه‌های شرکت‌پذیری، همانی چپ، و
-- .همانی راست رو اینجا نیاوردیم
-- .اونها رو به نسخه‌ی خودتون اضافه کنین

import Control.Monad
import Data.Monoid
import Test.QuickCheck

data Bull =
    Fools
  | Twoo
  deriving (Eq, Show)

instance Arbitrary Bull where
  arbitrary =
    frequency [ (1, return Fools)
              , (1, return Twoo) ]

instance Monoid Bull where
  mempty = Fools
  mappend _ _ = Fools

type BullMappend =
  Bull -> Bull -> Bull -> Bool

main :: IO ()
main = do
  let ma = monoidAssoc
      mli = monoidLeftIdentity
      mri = monoidRightIdentity
  quickCheck (ma :: BullMappend)
  quickCheck (mli :: Bull -> Bool)
  quickCheck (mri :: Bull -> Bool)

وقتی تو REPL بارگذاری کنین و ‏‎main‎‏ رو اجرا کنین، خروجیِ زیر رو می‌گیرین:

Prelude> main
+++ OK, passed 100 tests.
*** Failed! Falsifiable (after 1 test)
Twoo
*** Failed! Falsifiable (after 1 test) 
Twoo

خب این به ظاهر ‏‎Monoid‎‏ برای ‏‎Bool‎‏، تستِ شرکت‌پذیری رو گذروند، اما سَرِ تست‌های همانی شکست خورد. برای اینکه دلیل‌ش رو ببینیم، قوانین، و ‏‎mempty‎‏ و ‏‎mappend‎‏ رو کنار هم میذاریم:

-- نمونه اینطور تعریف شده
mempty = Fools
mappend _ _ = Fools

-- قوانین همانی
mappend mempty x = x
mappend x mempty = x

-- آیا از قوانین تبعیت می‌کنه؟

-- mappend به خاطر نحوه‌ی تعریفِ
mappend mempty x = Fools
mappend x mempty = Fools

-- نیست، پس در x همون Fools
-- قانون همانی شکست می‌خوره.

موردی نداره ‏‎Fools‎‏ مقدارِ همانی باشه، اما اگه ‏‎mappend‎‏ همیشه مقدارِ همانی رو برگردونه، دیگه همانی نیست. رفتارش شبیه صفر نیست؛ اصلاً قبل از برگردوندن ِ ‏‎Fools‎‏، نگاه نمی‌کنه ببینه کدوم آرگومان‌هاش ‏‎Fools‎‏ اند. یه سیاهچاله‌ست که همه‌ش یه مقدار رو برمی‌گردونه، خب مفهومی هم نداره. منظورمون از اون صفری که گفتیم رو با ضرب، که هم یک همانی داره، و هم یک صفر نشون میدیم:

-- عدد صفره Sum برای mempty دلیل اینکه
0 + x == x
x + 0 == x

-- عدد یک‌ه Product برای mempty دلیل اینکه
1 * x == x
x * 1 == x

-- Product برای mempty دلیل اینکه
-- *عدد صفر *نیست
0 * x == 0
x * 0 == 0

استفاده از ‏‎QuickCheck‎‏ راه خیلی خوب و ارزونی برای اعتبارسنجیِ نمونه‌ها نسبت به قوانین‌شون‌ه. اینها رو باز هم می‌بینیم.

تمرین: شاید یه مانوید ِ دیگه

یه نمونه ِ ‏‎Monoid‎‏ برای تایپ ‏‎Maybe‎‏ بنویسین که نیازی به ‏‎Monoid‎‏ بودنِ محتویات‌ش نداشته باشه. با همون مشخصه‌های ‏‎QuickCheck‎‏ که برای قوانینِ ‏‎Monoid‎‏ نوشتیم، نمونه‌تون رو چک کنین.

-- فراموش نکنین که یه نمونه‌ی
-- .بنویسین First' برای Arbitrary
-- .اینطور چیزها رو همیشه نمیگیم

newtype First' a =
  First' { getFirst' :: Optional a }
  deriving (Eq, Show)

instance Monoid (First' a) where
  mempty = undefined
  mappend = undefined 

firstMappend :: First' a
             -> First' a
             -> First' a
firstMappend = undefined

type FirstMappend =
     First' String
  -> First' String
  -> First' String
  -> Bool

type FstId =
  First' String -> Bool

main :: IO ()
main = do
  quickCheck (monoidAssoc :: FirstMappend)
  quickCheck (monoidLeftIdentity :: FstId)
  quickCheck (monoidRightIdentity :: FstId)

خروجی مورد نظر برای این ‏‎Monoid‎‏ که برای ‏‎Optional‎‏/‏‎Maybe‎‏ نوشتیم، در صورت وجود، اولین مورد موفق رو برمی‌گردونه. یه جورایی میشه این رو فصل ِ منطقی ("یا") ِ نمونه ِ ‏‎Monoid‎‏ دونست.

Prelude> First' (Only 1) `mappend` First' Nada
First' {getFirst' = Only 1}
Prelude> First' Nada `mappend` First' Nada
First' {getFirst' = Nada}
Prelude> First' Nada `mappend` First' (Only 2)
First' {getFirst' = Only 2}
Prelude> First' (Only 1) `mappend` First' (Only 2)
First' {getFirst' = Only 1}