اگر If/Else را Abstract کنیم چی؟

November 20th, 2020 · 5 min read

می‌خوام شما را با اصطلاحی آشنا کنم تحت عنوان ontogeny recapitulates phylogeny! که به قانون تشابه روند تکامل جنین تمام جانور‌ها اشاره میکنه.

خب دانشمندان هم به کمی narcissism نیاز دارند! مثل حالا که از کلمه‌ی narcissism استفاده کردم که البته میتونستم از کلمه‌ی خودشیفتگی هم استفاده کنم. (البته من دانشمند نیستم!)

طی این پست تلاش میکنم به شما ثابت کنم که سه کلمه‌ی Functor, Applicative, Monad هم بر خلاف نام و رعشه‌ای که بر هیبت برنامه‌نویس‌ها وارد میکنند صرفا اصطلاحاتی دهان پرکن برای توصیف راه‌حل‌های روزمره‌ای هستند که هر برنامه‌نویسی به قطع یقین یکبار کشفشان کرده.


اندر حکایت branching در برنامه نویسی

It's just 'IFs'. Hundreds and hundreds of IFs...

هرچند برنامه‌نویس‌ها از شنیدن این حقیقت بیزارند ولی غالب برنامه‌هایی که می نویسیم صرفا یک تعداد بسیار زیادی از If/Else هستند تا برنامه ما نسبت به ورودی‌های مشخص رفتار‌های مشخصی از خودش بروز بده.

1function f(x) {
2 return 0
3}
4
5function g(x) {
6 if (x === 0) {
7 return 0
8 } else {
9 return 1
10 }
11}

فانشکن f فارغ از ورودی همواره از خودش یک رفتار نشان میده و از این رو یک شاخه بیشتر نداره. اما فانشکن g با توجه به ورودی تصمیم میگیره که باید مقدار 0 یا 1 را برگردونه و این باعث میشه که این فانشکن دوتا شاخه داشته باشه.

عبارت‌هایی شبیه If/Else تنها کاری که میکنند اضافه کردن یکسری branch به برنامه شماست تا برنامه یک جرعه هوشمندانه‌تر رفتار کنه.

حالا فرض کنید میخواهید برای فانشکن‌های f و g یکسری test بنویسید. چه موقعی میتونید ادعا کنید که تمام رفتار‌های برنامه‌تان را تست کردید؟ زمانی که برای تک تک branch ها test نوشته باشید.

Algebraic Structure

قصد ندارم یک اصطلاح عجیب و غریب دیگه به داستانمان اضافه کنم! ولی باید بدانیم که واحد abstraction در ریاضی و البته FP چیزی هست به نام algebraic structure. هر algebraic structure مجموعه‌ای از operation(s) و axiom(s) هست.

اجازه بدید به زبانِ زبان‌های برنامه‌نویسی رایج توضیح بدم. به مثال زیر که تعریف abstraction تساوی ریاضی به واسطه algebraic structure ای به نام Eq است نگاه کنید.

1// Axioms:
2// Reflexivity: x.equals(x) = true
3// Symmetry: x.equals(y) = y.equals(x)
4// Negation: x.notEquals(y) = !x.equals(y)
5// ...
6
7interface Eq<T> {
8 equals(x: T): boolean
9 notEquals(x: T): boolean
10}

همانطور که میبینید یک interface تعریف کردیم که دوتا operation با نام‌های equals و notEquals را مشخص کرده. علاوه بر operation ها یک شرطی را به شکل comment مشخص کردیم که انتظار داریم تمام class هایی که سعی در پیاده‌سازی این interface آنها را رعایت کنند.

خواهش میکنم مِن بعد به یاد داشته باشید که Functor, Applicative و Monad یک نوع algebraic structure هستند و این یعنی یکسری operation و یکسری axiom دارند.

خب که چی؟

همانطور که گفتم algebraic structure واحد abstraction ما در FP است و این یعنی با واسطه Functor, Applicative و Monad ما قصد داریم یک چیزی را abstract کنیم. پس first things first. چه چیزی را می‌خواهیم abstract کنیم؟

این سه یار غار (Functor, Applicative, Monad) زمانی به درد ما خواهند خورد که با datatype ای سر و کار داشته باشیم که دو یا چند مسیر (sum type) داشته باشه و یکسری از مسیر‌ها برای ما معنای خاصی داشته باشند. مثال بزنم؟ بیاید به Maybe نگاه کنیم.

برای کسانی که با Haskell حال میکنند:

1data Maybe a = Just a | Nil

برای کسانی که به زودی با Haskell حال خواهند کرد:

1type Maybe<a> = Maybe.Just<a> | Maybe.Nothing
2
3namespace Maybe {
4 const enum Tag {
5 Just,
6 Nothing,
7 }
8
9 export const Just = Tag.Just
10 export interface Just<a> {
11 tag: typeof Just
12 value: a
13 }
14 export function just<a>(value: a): Just<a> {
15 return { tag: Just, value }
16 }
17
18 export const Nothing = Tag.Nothing
19 export interface Nothing {
20 tag: typeof Nothing
21 }
22 export const nothing = { tag: Nothing }
23}

همانطور که میبینید Maybe دو مسیر داره که شامل Just و Nothing میشه. مسیر Just برای ما معنادار هست و ما معمولا با مقدار داخلش یکسری کار‌هایی خواهیم داشت ولی مسیر Nothing اینطور نیست.

هر سه این algebraic structure ها operation اشان قرار که چک کنه اگر با مسیرهای معنادار روبرو شده یکسری کار انجام بده و اگر مسیر بی‌معنا بود ازش گذر کنه.

حالا مضمون عنوان پست نباید ثقیل جلوه کنه. هدف هر سه این algebraic structure ها abstract کردن branching هایی هست که دائم‌العمر در حال تکرار شدن هستند.

Functor

فرض کنید یک برنامه میخوایم بنویسیم که مقدار Maybe Int را میگیره و قرار آن مقدار Int داخل را تبدیل کنه به String و در نهایت مقدار Maybe String را خروجی بده.

1f :: Maybe Int -> Maybe String
2f Nothing = Nothing
3f (Just x) = Just $ show x
1function f(x: Maybe<Int>): Maybe<String> {
2 switch (x.tag) {
3 case Maybe.Nothing: return Maybe.nothing
4 case Maybe.Just: return Maybe.just(x.value.toString())
5 }
6}

شد، لیکن به خون جگر شد. زیاد اتفاق خواهد افتاد که بخوایم از تایپ Maybe a بریم به Maybe b و همه دارند دقیقا از الگوی یکسانی پیروی میکنند (همان مسیر معنادار و مسیر بی‌معنا) پس بهتره که abstract اش کنیم. از operation داخل Functor با نام fmap یاد میکنیم.

1fmapMaybe :: Maybe a -> Maybe b
2fmapMaybe Nothing = Nothing
3fmapMaybe (Just x) = Just $ ?
1function fmapMaybe<a, b>(x: Maybe<a>): Maybe<b> {
2 switch (x.tag) {
3 case Maybe.Nothing: return Maybe.nothing
4 case Maybe.Just: return Maybe.just(?)
5 }
6}

یک سطح abstract شدیم ولی دستمان زیر سنگ مانده! نمیدونیم چطور باید از تایپ a به b برسیم. پس نیاز داریم که دستور‌العمل این انتقال را بگیریم.

1fmapMaybe :: (a -> b) -> (Maybe a -> Maybe b)
2fmapMaybe _ Nothing = Nothing
3fmapMaybe f (Just x) = Just $ f x
1function fmapMaybe<a, b>(f: (a: a) => b, x: Maybe<a>): Maybe<b> {
2 switch (x.tag) {
3 case Maybe.Nothing: return Maybe.nothing
4 case Maybe.Just: return Maybe.just(f(x.value))
5 }
6}

عالی شد. همین حین در واقع Maybe functor را کشف کردیم! اما ازتان اجازه می‌خوام که بهم فرصت بدید که یک وهله دیگه در abstraction پیش برم و از ad-hoc polymorphism استفاده کنم چون datatype های امثال Maybe که توانایی تشکیل Functor را دارند کم نیستند!

1fmap :: (a -> b) -> (f a -> f b)

درباره axiom هاش صحبتی نمیکنم چون نیازی نداریم بهشون فعلا! اما قبل از اینکه به سمت Applicative روانه بشیم یک نکته را به خاطر داشته باشید! بین این سه algebraic structure هرگز اجازه ندارید از داخل functor خارج بشید. همواره باید در نظر بگیرید که اجازه رفتن از f a به a را ندارید.

1-- There is no such thing when you are thinking in these three algebraic structures
2extract :: f a -> a

Applicative

سناریویی را در نظر بگیرید که فرمی دارید با دو ورودی username و password که هر کدام ممکن مقداری داشته باشند یا خیر و در نتیجه تصمیم میگیرید که با Maybe پرزنتشان کنید.

1username :: Maybe Username
2password :: Maybe Password
1declare const username: Maybe<Username>
2declare const password: Maybe<Password>

و هچنین فانکشنی دارید که برای ساخت User ازش استفاده می کنید.

1mkUser :: Username -> Password -> User
1declare const mkUser: (u: Username) => (p: Password) => User

مسلما هر کدام از مقدار‌های username یا password اگر Nothing باشند نمی‌تونیم با mkUser هیچ user ای بسازیم.

ابتدا به روش impretive پیاده سازی کنیم ببینیم در چه حال هستیم!

1function main() {
2 if (isNothing(username) || isNothing(password)) {
3 return
4 }
5
6 const user = mkUser(username.value)(password.value)
7}

کلاه خودتان را قاضی کنید؛ یک جو انصاف؛ زیبا نیست؟ همان قضیه مسیر معنادار و مسیر بی‌معنی پابرجاست. :) پس چرا abstract اش نکنیم؟ به کد پایین نگاه کنید:

1mkUser :: Username -> (Password -> User)
2fmap :: (a -> b ) -> (f a -> f b )
3fmap mkUser :: f Username -> f (Password -> User)
4fmap mkUser username :: Maybe (Password -> User)

خب تا به اینجای کار فانشکن mkUser را به fmap اپلای کردیم و فانشکن ما منتظر password هست تا بتونه user را بسازه ولی یک مشکل بزرگ داریم! فانشکنمان داخل Maybe گیر کرده! :(

خانم‌ها و‌آقایان شما را با apply آشنا میکنم.

1apply :: f a -> f b

شبیه fmap نیست؟ باز دستمان زیر سنگ مانده و بازم نمیدونیم چطور باید از a برسیم به b.

1apply :: f (a -> b) -> (f a -> f b)
2fmap :: (a -> b) -> (f a -> f b)

مقایسه کنید! تنها تفاوتشان در فانشکنی هست که قرار ما را از a ببره به b! توی apply در اصل فانشکنمان داخل functor (همان f) گیر کرده! اگر برای Maybe تایپ را concrete کنیم میشه:

1apply :: Maybe (a -> b) -> (Maybe a -> Maybe b)
2fmap :: (a -> b) -> (Maybe a -> Maybe b)

برگردیم سراغ سناریوای که مطرح کردیم.

1fmap mkUser username :: Maybe (Password -> User)
2
3apply :: f (a -> b) -> (f a -> f b )
4apply (fmap mkUser username) :: (Maybe Password -> Maybe User)
5apply (fmap mkUser username) password :: Maybe User

حل شد ولی یحتمل توی دلتان دارید لعن نفرین میکنید که این چه وضع خوانایی هست! من هم زیبایی را دوست دارم پس بیاید زیبا کنیم این کد را.

1(<$>) :: (a -> b) -> (f a -> f b)
2(<*>) :: f (a -> b) -> (f a -> f b)
3
4mkUser <$> username
5 <*> password

باید خودتان دیگه متوجه شده باشید که <$> همان fmap و <*> همان apply هستند. میدونم دارید عاشق Haskell میشید! (یا شایدم شده‌اید) :D

Monad

برای درک Monad باید اول دقت کنید که هر Monad ای یک Functor که دست و پا در آورده! یعنی یک operation اضافه‌تر علاوه بر fmap داره به نام join.

1join :: f (f a) -> f a
1// This type signature isn't impossible in TypeScript BTW :D
2declare const join: <f, a>(x: f<f<a>>) => f<a>

حال سناریویی را در نظر بگیرید که شما یک فرم دارید با ورودی username که ممکن مقداری داشته باشه و یا نداشته باشه. و همچنین فانشکنی دارید با نام findUserByUsername که در دیتابیس ول می‌گرده تا یک user را با username ای که بهش داده شده پیدا کنه که صد البته ممکن که چیزی پیدا نکنه. (من را به یاد HDL-C می‌ندازه! آخر مگر معصوم‌تر از این فانشکن هم داریم؟)

1username :: Maybe Username
2
3findUserByUsername :: Username -> Maybe User

بیاید فانکشن findUserByUsername را اپلای کنیم به fmap و ببینیم چه خواهد شد؟!

1findUserByUsername :: (Username -> Maybe User)
2fmap :: (a -> b ) -> (f a -> f b )
3fmap findUserByUsername :: (f Username -> f (Maybe User))
4fmap findUserByUsername username :: Maybe (Maybe User)

نتیجه نهایی دوتا Maybe تو در تو هست که اصلا فایده‌ای نداره! مسخره‌است! خب join به دادمان خواهد رسید.

1fmap findUserByUsername username :: Maybe (Maybe User)
2
3join (fmap findUserByUsername username) :: Maybe User

اما همچنان زیاد راضی کننده نیست این روش composition برای همین فانشکنی کمکی داخل Monad داریم به نام bind که حاصل لقاح fmap و join هست.

1bind :: (a -> f b) -> (f a -> f b)
2bind f fa = join (fmap f fa)
3
4bind findUserByUsername username :: Maybe User

به زبان bind operator اگر بنویسم.

1(>>=) :: f a -> (a -> f b) -> f b
2
3username >>= findUserByUsername

ختم کلام

حدس میزنم که دیگه حدس زده باشید که چرا در هر محفلی بحث از کاربردی بودن این سه algebraic structure هستش! کجا دیده بودید که branching را هم abstract کنند؟ :)

1fmap :: (a -> b ) -> (f a -> f b)
2apply :: f (a -> b ) -> (f a -> f b)
3bind :: (a -> f b) -> (f a -> f b)

این میزان از هماهنگی عاشقانه نیست؟ به هارمونی عاشقانه FP خوش آمدید. امیدوارم لذت برده باشید.

خبرنامه

با عضویت در خبرنامه، می‌تونی از آخرین مطالب من از طریق ایمیل مطلع بشی و همواره امکان لغو عضویت را خواهی داشت.

پیشنهاد مطالعه

ساید‌افکت را درست درک کنیم!

امان از دست کلمات و اصطلاحات قلبمه و سملبه‌ی functional programming…

تایپ‌های ناشایسته

این پست اولین بخش از سری پست‌های «طریقت یک FP…