۱۵ - ۱۰استفاده از جبر با درخواست جبر

اشاره کردیم که ‏‎Monoid‎‏های بیشتری برای ‏‎Maybe‎‏ به غیر از ‏‎First‎‏ و ‏‎Last‎‏ وجود دارن. حالا اون یکی نمونه ِ ‏‎Monoid‎‏ رو می‌نویسیم. اینجا دنبالِ انتخابِ یک مقدار بین یه مجموعه از مقادیر نیستیم، بلکه می‌خوایم مقادیرِ ‏‎a‎‏ که مشمولِ تایپِ ‏‎Maybe a‎‏ هستن رو ترکیب کنیم.

اول سعی کنین ببینین متوجه یه الگو میشین:

instance Monoid b => Monoid (a -> b)

instance (Monoid a, Monoid b)
      => Monoid (a, b) 
instance (Monoid a, Monoid b, Monoid c)
      => Monoid (a, b, c)

همه‌ی این ‏‎Monoid‎‏ها برای یه تایپ بزرگتر، با استفاده از نمونه ِ ‏‎Monoid‎‏های تایپ‌هایی که اون تایپ بزرگتر رو تشکیل میدن، ‏‎Monoid‎‏ ِشون رو درست می‌کنن.

حتی اگه فقط یکی از مقادیرِ اون تایپ بزرگتر شامل مقداری از تایپِ بسته‌بندی‌شده (مثلِ ‏‎a‎‏ در ‏‎Maybe a‎‏) باشه و بقیه‌ی مقادیرش از اون تایپ نداشته باشن، باز هم برای چنین نمونه ِ ‏‎Monoid‎‏ ای برای اون تایپ بزرگتر، لازمه که همه‌ی آرگومان‌های تایپی‌ش یه نمونه از ‏‎Monoid‎‏ داشته باشن. برای مثال، ‏‎Nothing‎‏ هیچ مقدارِ ‏‎a‎‏ ای (که ازش انتظارِ یه ‏‎Monoid‎‏ داریم) نداره، اما ‏‎Just a‎‏ داره. پس همه‌ی مقادیرِ ممکن از تایپ ‏‎Maybe‎‏، آرگومان تایپی ‏‎a‎‏ رو ندارن. برای یه ‏‎Maybe Monoid‎‏ که یک عملیات ِ ‏‎mappend‎‏ برای مقادیر ‏‎a‎‏ داره، برای اون ‏‎a‎‏ حتماً یه ‏‎Monoid‎‏ لازم داریم. ‏‎Monoid‎‏هایی مثل ‏‎First‎‏ و ‏‎Last‎‏، تایپ ‏‎Maybe a‎‏ رو دربرمی‌گیرن، اما چون مقادیر ‏‎a‎‏ رو ‏‎mappend‎‏ نمی‌کنن یا براشون هیچ ‏‎mempty‎‏ ای ارائه نمیدن، از اون ‏‎a‎‏ انتظار ‏‎Monoid‎‏ هم ندارن.

با این حال اگه یه نوع‌داده‌ای دارین که آرگومان تایپی‌ای داره که در هیچ کدوم از جملات‌ش ظاهر نمیشه (یه تایپ تخیلی داره)، تایپچکر هم از شما تقاضای نمونه ِ ‏‎Monoid‎‏ نمی‌کنه. برای مثال:

data Booly a =
    False'
  | True'
  deriving (Eq, Show)

-- (conjunction) عطف
instance Monoid (Booly a) where
  mappend False' _ = False'
  mappend _ False' = False'
  mappend True' True' = True'

هیچ محدودیت ِ ‏‎Monoid‎‏ برای ‏‎a‎‏ لازم نداشتیم چون هیچ وقت mappend ِش نمی‌کنیم (نمی‌تونیم؛ وجود نداره)، هیچ ‏‎mempty‎‏ ای هم از تایپِ ‏‎a‎‏ نخواستیم. دلیل اصلی‌ای که محدودیت لازم نیست همینه، البته بسته به تعریفِ نمونه، با وجود اینکه تایپ بسته‌بندی‌شده در مقادیر هم ظاهر بشه، ممکن هست این محدودیت لازم نباشه.

تمرین: ‏‎Optional Monoid‎‏

نمونه ِ ‏‎Monoid‎‏ رو برای تایپِ ‏‎Optional‎‏ که در واقع همون ‏‎Maybe‎‏ هست بنویسین.

ata Optional a =
   Nada
 | Only a
 deriving (Eq, Show)

nstance Monoid a
     => Monoid (Optional a) where
 mempty = undefined
 mappend = undefined

خروجی‌های مورد نظر:

relude> Only (Sum 1) `mappend` Only (Sum 1)
nly (Sum {getSum = 2})

relude> Only (Product 4) `mappend` Only (Product 2)
nly (Product {getProduct = 8})

relude> Only (Sum 1) `mappend` Nada
nly (Sum {getSum = 1})

relude> Only [1] `mappend` Nada
nly [1]

relude> Nada `mappend` Only (Sum 1)
nly (Sum {getSum = 1})

شرکت‌پذیری

این بخش بیشترش دوره‌ست، اما می‌خوایم شرکت‌پذیری رو با دقتِ بیشتری بگیم. شرکت‌پذیری میگه ممکنه آرگومان‌های عملیات‌تون رو به انواع مختلف شرکت بدین (یا گروه‌بندی کنین)، و جواب همون می‌مونه.

چندتا از مثال‌هایی که میشه دوباره شرکت داده بشن رو دوره کنیم:

Prelude> (1 + 9001) + 9001
18003
Prelude> 1 + (9001 + 9001)
18003
Prelude> (7 * 8) * 3
168
Prelude> 7 * (8 * 3)
168

چندتا مثالی که بدون تغییر جواب‌شون نمیشه پرانتزهاشون رو جابجا کرد هم ببینیم:

Prelude> (1 – 10) – 100
-169
Prelude> 1 – (10 – 100)
91

این مشخصه به قدرتمندیِ جابجایی‌پذیری نیست. جابجایی‌پذیری یعنی بدون تغییر در نتیجه‌ی نهایی، میشه ترتیب آرگومان‌ها رو عوض کرد. جمع و ضرب جابجایی‌پذیر اند، اما ‏‎(++)‎‏ برای لیست‌ها فقط شرکت‌پذیر ِه.

این رو با یه نسخه‌ی شیطون از جمع که ترتیبِ آرگومان‌هاش رو عوض می‌کنه نشون میدیم:

Prelude> let evilPlus = flip (+)
Prelude> 76 + 67
143
Prelude> 76 `evilPlus` 67
143

شواهدی داریم که ‏‎(+)‎‏ جابجایی‌پذیری داره، اما اثبات نداریم.

با این حال همون کار رو با ‏‎(++)‎‏ هم میشه انجام بدیم:

Prelude> let evilPlusPlus = flip (++)
Prelude> let oneList = [1..3]
Prelude> let otherList = [4..6]

Prelude> oneList ++ otherList
[1,2,3,4,5,6]

Prelude> oneList `evilPlusPlus` otherList
[4,5,6,1,2,3]

در مورد لیست‌ها، با مثال نقض ثابت کردیم که ‏‎(++)‎‏ جابجایی‌پذیری نداره. مهم نیست اگه حتی برای همه‌ی ورودی‌های دیگه جابجایی‌پذیری داشته باشه؛ همین که برای یکی از اونها اینظور نیست، پس قانون جابجایی‌پذیری براش صادق نیست.

جابجایی‌پذیری مشخصه ِ مفیدی‌ه، و برای مواقعی که به منظور ارتقاء عملکرد و بازدهی ِ بیشتر، نیاز به تغییر در ترتیبِ محاسبات باشه به درد می‌خوره، بدون نگرانی برای تغییرِ جواب. از مانویدهای جابجایی‌پذیر در طراحی سیستم‌های گسترده و محدودیت‌هاشون (که همین مانویدهایی‌اند که جابجایی‌پذیری ِ عملیاتشون رو تضمین می‌کنن) هم استفاده میشه.

با این حال برای ما ‏‎Monoid‎‏ از قانون شرکت‌پذیری تبعیت می‌کنه اما قانون جابجایی‌پذیری براش صادق نیست. البته بعضی عملیات‌های مانویدی (جمع و ضرب) جابجایی‌پذیر هستن.

همانی

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

Prelude> 1 + 0
1
Prelude> 521 + 0
521
Prelude> 1 * 1
1
Prelude> 521 * 1
521

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

Prelude> let myList = [1..424242]

-- صفر مقدار همانی برای جمع‌ه
Prelude> map (+0) myList == myList
True

-- اما برای ضرب نیست
Prelude> map (*0) myList == myList
False

-- مقدار همانی برای ضرب‌ه 1
Prelude> map (*1) myList == myList
True

-- اما برای جمع نیست
Prelude> map (+1) myList == myList
False

این اون یکی قانون برای ‏‎Monoid‎‏ ِه: عملیات ِ دوتایی باید شرکت‌پذیر باشه و باید یک مقدارِ همانی ِ معقول داشته باشه.

مشکلِ نمونه‌های یتیم

هم در این فصل و هم در فصل تایپکلاس‌ها گفتیم که جفت تایپکلاس و نمونه‌ش برای یک تایپ، یکتاست.

با این حال وقتی نمونه‌های یتیم نوشته میشن، امکان داره برای یک تایپ، چند نمونه داشته باشیم. اما به هیچ عنوان نباید نمونه‌‌های یتیم نوشت. اگه GHC هشدار ِ نمونه‌های یتیم بهتون داد، سریع درست‌ش کنین!

حتی اگه چندتا نمونه برای یه تایپ لازم دارین، باز هم از ‏‎newtype‎‏ استفاده کنین، نه نمونه‌های یتیم. با ‏‎Sum‎‏ و ‏‎Product‎‏ هم دیدیم که بدون توسل به نمونه‌های یتیم یا هتکِ یکتا بودن نمونه‌‌های تایپکلاسی، تونستیم دوتا ‏‎Monoid‎‏ برای اعداد تعریف کنیم.

یه مثال از یک نمونه‌ی یتیم و نحوه‌ی درست کردن‌ش ببینیم. اول یه پوشه برای پروژه درست کنین و واردِ اون پوشه بشین:

$ mkdir orphan-instance && cd orphan-instance

بعد دوتا فایل می‌سازیم و تو هر کدوم یه ماژول میذاریم:

module Listy where

newtype Listy a =
  Listy [a]
  deriving (Eq, Show)

module ListyInstances where

import Data.Monoid
import Listy

instance Monoid (Listy a) where
  mempty = Listy []
  mappend = (Listy l) (Listy l') =
    Listy $ mappend l l'

پس آدرس ِ پروژه‌مون این شکلی میشه:

$ tree
.
├── Listy.hs
└── ListyInstances.hs

حالا برای اینکه ‏‎ListyInstances‎‏ رو طوری بسازیم که ‏‎Listy‎‏ رو ببینه، باید از پرچم ِ ‏‎-I‎‏ برای شامل کردن پوشه‌ی فعلی، و متعاقباً مرئی کردنِ ماژولهای توش، استفاده کنیم. اون نقطه جلوی ‏‎I‎‏ در سیستم‌های یونیکس یعنی "این پوشه." اگه موفق بشین، باید چنین چیزی ببینین:

$ ghc -I. --make ListyInstances.hs
[2 of 2] Compiling ListyInstances

دقت کنین که تنها خروجی این کار یه شیئِ فایلی میشه؛ یه ماژولی که میشه ازش به عنوان یه کتابخونه استفاده بشه، دلیل‌ش هم اینه که هیچ ‏‎main‎‏ ای برای ساختن فایل قابل اجرا تعریف نکردیم. به این خاطر از این روش ساختیم تا از گرفتاری‌های شروع یه پروژه (با دستوری مثل ‏‎stack new‎‏) دوری کنیم. برای هر پروژه‌ای که یه کم پیچیده‌تره یا قراره مدت زمان بیشتری کار کنه، از یه ابزار مدیریت ِ ساخت و وابستگی مثل Cabal استفاده کنین (اگه دارین از Stack استفاده می‌کنین، از Cabal هم استفاده می‌کنین).

حالا یه مثال از دلیل مشکل‌زا بودن نمونه‌های یتیم. اگه نمونه ِ ‏‎Monoid‎‏ از ‏‎ListyInstances‎‏ رو تو ‏‎Listy‎‏ کپی کنیم و بعد ‏‎ListyInstances‎‏ رو دوباره بسازیم، خطای زیر رو می‌گیریم:

$ ghc -I. --make ListyInsatnces.hs
[1 of 2] Compiling Listy
[2 of 2] Compiling ListyInstances

Listy.hs:7:10:
Duplicate instance declarations:
-- :م. تعریفِ نمونه‌های تکراری
instance Monoid (Listy a)
-- Defined at Listy.hs:7:10
instance Monoid (Listy a)
-- Defined at ListyInstances.hs:5:10

چنین تداخلِ تعریف نمونه‌‌ها برای هرکسی که از نسخه‌ی قبلیِ کُدمون استفاده کنه ممکنه پیش بیاد. و این یه مشکل‌ه.

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

چندتا راه حل برای نمونه‌های یتیم وجود دارن:

۱.

تایپ رو تعریف کردین ولی تایپکلاس رو تعریف نکردین؟ نمونه رو تو همون ماژولی که تایپ رو تعریف کردین بنویسین تا نشه تایپ رو بدونِ نمونه‌هاش وارد کرد.

۲.

تایپکلاس رو تعریف کردین اما تایپ رو نه؟ نمونه رو در همون ماژولی بذارین که تایپکلاس تعریف شده تا نشه تایپکلاس رو بدون نمونه‌هاش وارد کرد.

۳.

نه تایپ و نه تایپکلاس مالِ خودتونه؟ با یه ‏‎newtype‎‏ تایپ اصلی رو بپوشونین تا یه تایپی که متعلق به خودتونه داشته باشین، و بتونین براش نمونه‌های تایپکلاسی داشته باشین. بعداً راه‌هایی رو توضیح میدیم که این پروسه رو ساده‌تر می‌کنن.

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