پرش به محتویات

نوع داده در مجموعه

تا اینجا، انواع داده مقدماتی مانند Bool و String را دیده‌ایم. نوع داده سفارشی را به این شکل ساخته‌ایم:

type Color = Red | Yellow | Green

یکی از مهم‌ترین تکنیک‌ها در برنامه‌نویسی Elm این است که مقدارهای ممکن در کد دقیقا با مقدارهای معتبر در دنیای واقعی مطابقت داشته باشند. این کار هیچ فضایی برای داده‌های نامعتبر باقی نمی‌گذارد و به همین دلیل همیشه به توسعه‌دهندگان توصیه می‌کنم که بر روی نوع داده سفارشی و ساختار داده تمرکز کنند.

با توجه به این موضوع، متوجه شدم که درک رابطه بین نوع داده و مجموعه مفید است. درک این ارتباط ممکن است کمی در ابتدا دشوار باشد، اما واقعا به توسعه ذهنیت شما کمک می‌کند!

مجموعه

می‌توانید نوع داده را به عنوان یک مجموعه از مقادیر در نظر بگیرید:

  • Bool مجموعه { True, False } است
  • Color مجموعه { Red, Yellow, Green } است
  • Int مجموعه { ... -2, -1, 0, 1, 2 ... } است
  • Float مجموعه { ... 0.9, 0.99, 0.999 ... 1.0 ... } است
  • String مجموعه { "", "a", "aa", "aaa" ... "hello" ... } است

بنابراین، وقتی می‌گویید x : Bool مانند این است که بگویید x در مجموعه { True, False } قرار دارد.

کاردینالیته

وقتی شروع به فهمیدن تعداد مقادیر در این مجموعه‌ها می‌کنید، چیزهای جالبی اتفاق می‌افتد. برای نمونه، مجموعه Bool { True, False } شامل دو مقدار است. بنابراین، ریاضی‌دان‌ها می‌گویند که Bool دارای کاردینالیته دو است. به همین ترتیب:

cardinality(Bool) = 2
cardinality(Color) = 3
cardinality(Int) = ∞
cardinality(Float) = ∞
cardinality(String) = ∞

این موضوع زمانی جالب‌تر می‌شود که شروع به فکر کردن درباره نوع داده‌ای مانند (Bool, Bool) می‌کنیم که مجموعه‌ها را با هم ترکیب می‌کند.

یادداشت

کاردینالیته برای Int و Float در واقع کوچک‌تر از بی‌نهایت است. کامپیوترها باید اعداد را در یک مقدار ثابت از بیت‌ها جا دهند. بنابراین، بیشتر شبیه این است که cardinality(Int32) = 2^32 و cardinality(Float32) = 2^32 باشد. نکته این است که این مقدار، بسیار زیاد است. در بخش نوع داده به عنوان بیت، به این موضوع می‌پردازیم.

عملیات ضرب (تاپِل و رکورد)

وقتی نوع داده را با تاپِل ترکیب کنید، کاردینالیته آن‌ها در همدیگر ضرب می‌شود:

cardinality((Bool, Bool)) = cardinality(Bool) × cardinality(Bool) = 2 × 2 = 4
cardinality((Bool, Color)) = cardinality(Bool) × cardinality(Color) = 2 × 3 = 6

برای اطمینان از درست بودن این موضوع، سعی کنید تمام مقادیر ممکن (Bool, Bool) و (Bool, Color) را فهرست کنید. آیا با اعداد پیش‌بینی شده مطابقت دارند؟ برای (Color, Color) چطور؟

اما چه اتفاقی می‌افتد وقتی از مجموعه‌های بی‌نهایت مانند Int و String استفاده کنیم؟

cardinality((Bool, String)) = 2 × ∞
cardinality((Int, Int)) = ∞ × ∞

من واقعا ایده داشتن دو بی‌نهایت را دوست دارم. یکی کافی نبود؟ سپس دیدن بی‌نهایتِ بی‌نهایت‌ها. آیا در یک نقطه تمام نمی‌شویم؟!

یادداشت

تا کنون از تاپِل استفاده کردیم، اما رکورد دقیقا به همین شکل کار می‌کند:

cardinality((Bool, Bool)) = cardinality({ x : Bool, y : Bool })
cardinality((Bool, Color)) = cardinality({ active : Bool, color : Color })

اگر type Point = Point Float Float را تعریف کنید، آنگاه cardinality(Point) معادل cardinality((Float, Float)) است. همه چیز درباره ضرب کردن است!

عملیات جمع (نوع داده سفارشی)

وقتی کاردینالیته یک نوع داده سفارشی را محاسبه می‌کنید، کاردینالیته هر حالتش را با هم جمع می‌کنید. بیایید با بررسی نوع داده Maybe و Result شروع کنیم:

cardinality(Result Bool Color) = cardinality(Bool) + cardinality(Color) = 2 + 3 = 5
cardinality(Maybe Bool) = 1 + cardinality(Bool) = 1 + 2 = 3
cardinality(Maybe Int) = 1 + cardinality(Int) = 1 + ∞

برای اطمینان از درست بودن این موضوع، سعی کنید تمام مقادیر ممکن در مجموعه‌های Maybe Bool و Result Bool Color را فهرست کنید. آیا با اعداد به دست آمده مطابقت دارند؟

در ادامه، چند نمونه دیگر وجود دارد:

type Height
  = Inches Int
  | Meters Float

-- cardinality(Height)
-- = cardinality(Int) + cardinality(Float)
-- = ∞ + ∞


type Location
  = Nowhere
  | Somewhere Float Float

-- cardinality(Location)
-- = 1 + cardinality((Float, Float))
-- = 1 + cardinality(Float) × cardinality(Float)
-- = 1 + ∞ × ∞

نگاه کردن به نوع داده سفارشی به این شکل به ما کمک می‌کند تا ببینیم چه زمانی دو نوع داده معادل هستند. برای نمونه، Location معادل Maybe (Float, Float) است. وقتی این را بدانید، کدام یک را باید استفاده کنید؟ من Location را به دلایل زیر ترجیح می‌دهم:

  • کد، خود به خود مستندسازی می‌شود. نیازی نیست بپرسید آیا Just (1.6, 1.8) یک مکان است یا یک جفت ارتفاع.

  • ماژول Maybe ممکن است توابعی را ارایه دهد که برای داده‌های خاص منطقی نیستند. برای نمونه، ترکیب دو مکان احتمالا نباید مانند Maybe.map2 کار کند. آیا یک Nowhere به این معنی است که همه چیز Nowhere است؟ به نظر عجیب می‌رسد!

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

چه کسی اهمیت می‌دهد؟

تفکر "نوع داده به عنوان مجموعه" به توضیح یک کلاس مهم از باگ‌ها کمک می‌کند: داده‌های نامعتبر. فرض کنید می‌خواهیم رنگ چراغ راهنما را نشان دهیم. مجموعه مقادیر معتبر { red, yellow, green } است، اما چگونه می‌توانیم آن را در کد مدل‌سازی کنیم؟ در اینجا سه رویکرد مختلف وجود دارد:

  • type alias Color = String — می‌توانیم تصمیم بگیریم که "red"، "yellow"، "green" سه رشته متنی هستند که استفاده خواهیم کرد و تمام رشته‌های متنی دیگر داده‌های نامعتبر هستند. اما اگر داده نامعتبر تولید شود چه؟ شاید کسی یک اشتباه تایپی مانند "rad" انجام دهد. شاید کسی بجای آن "RED" تایپ کند. آیا همه توابع باید بررسی‌هایی برای آرگومان‌های رنگ ورودی داشته باشند؟ آیا همه توابع باید تست‌هایی داشته باشند تا اطمینان یابند نتایج رنگ معتبر هستند؟ مشکل اصلی این است که ∞ = cardinality(Color) است، به این معنی که (3 - ∞) مقدار نامعتبر وجود دارد. باید بررسی‌های زیادی انجام دهیم تا اطمینان یابیم هیچ کدام از آن‌ها هرگز اتفاق نمی‌افتند!

  • type alias Color = { red : Bool, yellow : Bool, green : Bool } — ایده این است که مفهوم "red" با Color True False False نشان داده می‌شود. اما Color True True True چه معنایی دارد؟ چه معنایی دارد که همه رنگ‌ها بطور همزمان وجود داشته باشند؟ این داده نامعتبر است. درست مانند حالت String، در نهایت باید بررسی‌هایی در کد و تست‌های خود بنویسیم تا اطمینان یابیم هیچ اشتباهی وجود ندارد. در این حالت، cardinality(Color) = 2 × 2 × 2 = 8 است و تنها ۵ مقدار نامعتبر وجود دارد. قطعا راه‌های کمتری برای اشتباه کردن وجود دارد، اما هنوز هم باید برخی از بررسی‌ها و تست‌ها را انجام دهیم.

  • type Color = Red | Yellow | Green — در این حالت، داده نامعتبر غیرممکن است. cardinality(Color) = 1 + 1 + 1 = 3 است، که دقیقا با مجموعه سه مقدار در دنیای واقعی مطابقت دارد. بنابراین، هیچ دلیلی برای بررسی داده نامعتبر در کد یا تست وجود ندارد. این داده نامعتبر، نمی‌تواند وجود داشته باشد!

هدف کلی این است که حذف داده نامعتبر کد را کوتاه، ساده و قابل اعتماد می‌کند. با اطمینان از اینکه مجموعه مقدارهای ممکن در کد دقیقا با مجموعه مقدارهای معتبر در دنیای واقعی مطابقت دارند، بسیاری از مشکلات به سادگی از بین می‌روند.

با ایجاد تغییر در برنامه، مجموعه مقادیر ممکن در کد ممکن است شروع به انحراف از مجموعه مقادیر معتبر در دنیای واقعی کنند. به شدت توصیه می‌کنم که بطور دوره‌ای، انواع داده خود را بازبینی کنید تا دوباره با هم مطابقت داشته باشند. مانند این است که متوجه شوید چاقوی شما کند شده و آن را با سنگ تیز کنید. این نوع نگهداری، بخشی اساسی از برنامه‌نویسی در Elm است.

وقتی به این شکل فکر می‌کنید، در نهایت به تست‌های کمتری نیاز دارید، در حالی که کد شما قابل اعتمادتر می‌شود. شروع به استفاده از وابستگی‌های کمتری می‌کنید، در عین حال کارها را سریع‌تر انجام می‌دهید. به همین ترتیب، کسی که در کار با چاقو ماهر است احتمالا یک SlapChop نمی‌خرد. قطعا جایی برای مخلوط‌کن‌ها و پردازنده‌های غذا وجود دارد، اما جایگزین چاقو نمی‌شوند. هیچ کس تبلیغ نمی‌کند چگونه می‌توانید مستقل و خودکفا باشید بدون اینکه عواقب جدی داشته باشد. در این مورد پولی وجود ندارد!

درباره طراحی زبان برنامه‌نویسی

تفکر "نوع داده به عنوان مجموعه" می‌تواند در توضیح اینکه چرا یک زبان برای برخی افراد "آسان"، "محدودکننده" یا "مستعد خطا"به نظر می‌رسد، موثر باشد. برای نمونه:

  • جاوا — مقادیر ابتدایی مانند Bool و String وجود دارند. از آنجا، می‌توانید کلاس‌هایی با مجموعه‌ای ثابت از فیلدهای مختلف ایجاد کنید. این فرآیند، بسیار شبیه رکوردها در Elm است و به شما اجازه می‌دهد کاردینالیته‌ها را ضرب کنید. اما انجام عملیات جمع بسیار دشوار است. می‌توانید این کار را با subtyping انجام دهید، اما این یک فرآیند نسبتا پیچیده است. بنابراین در حالی که Result Bool Color در Elm آسان است، در جاوا نسبتا دشوار است. فکر می‌کنم برخی افراد جاوا را "محدودکننده" می‌دانند زیرا طراحی یک نوع داده با کاردینالیته ۵ بسیار دشوار است و اغلب به نظر می‌رسد که ارزشش را ندارد.

  • جاوااسکریپت — دوباره، مقادیر ابتدایی مانند Bool و String وجود دارند. از آنجا، می‌توانید اشیایی با مجموعه‌ای پویا از فیلدها ایجاد کنید که به شما اجازه می‌دهد کاردینالیته‌ها را ضرب کنید. این کار بسیار سبک‌تر از ایجاد کلاس‌ها است. اما مانند جاوا، انجام عملیات جمع بطور خاص آسان نیست. برای نمونه، می‌توانید Maybe Int را با اشیایی مانند { tag: "just", value: 42 } و { tag: "nothing" } شبیه‌سازی کنید، اما این واقعا هنوز هم ضرب کاردینالیته است. این کار باعث می‌شود که مطابقت دقیق با مجموعه مقادیر معتبر در دنیای واقعی دشوار باشد. بنابراین، فکر می‌کنم برخی افراد جاوااسکریپت را "آسان" می‌دانند زیرا طراحی یک نوع داده با کاردینالیته (∞ × ∞ × ∞) بسیار آسان است و می‌تواند تقریبا هر چیزی را پوشش دهد، اما دیگران آن را "مستعد خطا" می‌دانند زیرا طراحی یک نوع داده با کاردینالیته ۵ واقعا ممکن نیست و فضای زیادی برای داده‌های نامعتبر باقی می‌گذارد.

جالب است که برخی از زبان‌های دستوری دارای نوع داده سفارشی هستند! Rust یک نمونه عالی است. در این زبان، به آن‌ها enum می‌گویند تا بر اساس درک افرادی بنا شود که ممکن است از C و Java آمده باشند. بنابراین، جمع کاردینالیته‌ها در Rust به همان اندازه Elm آسان است و تمام مزایای مشابه را به همراه دارد!

فکر می‌کنم نکته این است که "عملیات جمع" انواع داده به شدت دست کم گرفته می‌شود و تفکر "نوع داده به عنوان مجموعه" به روشن شدن این موضوع کمک می‌کند که چرا طراحی‌های خاص یک زبان برنامه‌نویسی می‌تواند منجر به ناامیدی‌های خاصی شود.