به نظرم یکی از مغفولترین ویژگیهای زبانهای برنامهنویسی، سوییچ کیس ها هستند. این عبارت ها در زبانهای مختلف مثل سی و جاوا همواره با محدودیتهایشان وجود داشتند ولی بعدا تکامل پیدا کردند و قابلیتهای جدید کسب کردند. همچنین بعد از مدتی الهام بخش چیزهای جدیدتر مثل سینتکس guard و pattern matching شدند. در این مطلب میخواهیم بیشتر با انواع آنها و امکانات و محدودیتهایشان آشنا شویم. حتی از زبانهایی که این قابلیت را ندارند علت را جویا شویم و دید خوبی به دست بیاوریم!
در آخر هم با نسل جدید سوییچها (pattern matching) آشنا میشویم.
در زمانی که صحبت از شرط در زبانهای رویهای میشود، ابتدا if و احتمالا عملگر سهعملوندی (ternary operator) به ذهن میآیند، در وهله بعدی سوییچکیسها گفته می شوند با یک قید که «با if هم می توان آنها را پیادهسازی کرد».
بله، البته که همه انواع شرط را میشود با if پیادهسازی کرد اما هر ساختار زبانی را بهر کاربردی ساختند. مثلا ساختارهای حلقه را میتوان با recursion پیادهسازی کرد (به شرط tail call optimization از طرف کامپایلر) و برعکس. میتوان حلقه و تابع را با goto جایگزین کرد. اما همه اینها آمدهاند که به ما به عنوان برنامهنویس کمک کنند و کیفیت کد نوشتهشده یا کامپایل شده را بهبود دهند.
پس این سوییچکیسها که از آنها حرف میزنم هم کاربردی دارند. این کاربرد را از دو جهت میتوان بررسی کرد. در زمان نوشتن و ادیت کردن سورسکد و زمان بعد از کامپایل.
در زمان کدنویسی، قرار است «خوانایی کد را بالا ببرد» یعنی برای کسی که کد را میخواند تصریح میکند که همکار، این عبارتی که من نوشتم فقط و فقط قرار است بر اساس مقدارهای مختلف ورودی (که این مقدارها از پیش معلوم هستند) کارهای مختفلی انجام شود. قرار نیست اتفاق خاص و عجیبی بیفتد. از طرف دیگر وقتی سوییچکیس وجود دارد و در برنامه if و else میبینیم، از خود میپرسیم چرا از خود سوییچ استفاده نشده است؟ چه اتفاق ویژهای قرار است بیفتد که با خود سوییچ ممکن نبود؟
اما تنها کاربرد این نیست! کاربرد مهمتر این است که کدی که تولید میشود بهینهتر است. یعنی با فرض اینکه دو برنامه با منطق یکسان که یکی با سوییچکیس و یکی با if else های متوالی نوشته شود، برنامه اول سریعتر کار میکند. (البته شاید تفاوت به چشم نیاید ولی ندیدن به معنی نبودن نیست.)
دلیل این موضوع این است که کامپایلرها برای کامپایل کردن (و تولید کد ماشین) برای سوییچ کیس، تمهیدات خاصی در نظر گرفتهاند و کدی که تولید میشود معادل if else نیست. قرار نیست در این مطلب وارد جزئیات کامپایلر و یا زبان ماشین شویم ولی خوب است همینقدر بدانیم که در صورتی که روی اعداد ۱ تا ۱۰ سوییچ زدیم و عدد ۱۰ را ورودی داده ایم، برابری های ۱==۱۰ و ۲ == ۱۰ و .. چک نمیشوند و یکراست به حالت ۱۰ == ۱۰ میرسیم در حالی که اگر با if else مینوشتیم باید این چکها انجام میشد. این موضوع در زمانی که caseهای زیادی داریم به چشم میآید. کامپایلر با استفاده از lookup table کد سطح پایین تولید میکند و از قبل میداند که برای ورودی ۱۰ باید به کجا jump شود.
مطالعه بیشتر: مقایسه performance
همچنین برای درک شهودی از روال اجرا این پاسخ هم کمککننده است.
سوییچکیسی که در C داشتیم، محدودیتهای خودش را هم دارد،
مثلا اینکه فقط امکان سوییچ زدن روی ورودیهایی با تایپ عدد صحیح داریم (کاراکتر هم عدد صحیح است!)
و اینکه مقدار هر case باید در زمان کامپایل معلوم باشد وگرنه اصلا بهینهسازی ای ممکن نیست.
switch(a){ // type: integer or character case 1: // known in compile time // some code break; }
اما چرا برای اعداد اعشاری ممکن نیست؟ برای اینکه اعداد اعشاری به صورت ممیز شناور نگهداری میشوند و تساوی اصلا در در آنها دقت ندارد و موضوعیت ندارد. بنابراین اینکه ورودی یک double یا float بدهیم و انتظار داشته باشیم در صورت که برابر 1.3 شد کار خاصی انجام شود، حتی با if هم نیازمند دقت فراوان است، چه برسد به switch case با نحوه کامپایل شدن خاصش.
مطالعه بیشتر در مورد عدم دقت سیستم ممیز شناور در سیستمهای مالی
و همچنین این سایت در مورد محاسبات ممیز شناور
در نهایت خوب است به Fall-through نیز بپردازیم. کد زیر را در نظر بگیرید:
switch (n) { case 1: printf("this is 1\n"); case 2: printf("this is 2\n"); case 3: printf("this is 3\n"); default: printf("default\n"); }
با ورودی ۱ این برنامه چه چیزی چاپ میکند؟ آیا فقط this is 1 چاپ میشود؟ خیر! بلکه همه دستورات از case 1 به بعد چاپ میشوند. یعنی خروجی این برنامه (با ورودی ۱) چنین چیزی است:
this is 1 this is 2 this is 3 default
به این اتفاق Fall-through میگویند. در واقع دستورات داخل سوییچ کیس بر خلاف if، بلاکهای جدا از هم نیستند، بلکه همه دستورات یک بلاک هستند که فقط بسته به ورودی، از جاهای مختلف (case های مختلف) وارد بلاک میشویم. خروج از بلاک به صورت خودکار نیست و باید برنامهنویس با استفاده از دستور break تصریح کند. مثلا خروجی این برنامه به ورودی ۱، this is 1 است:
switch (n) { case 1: printf("this is 1\n"); break; case 2: printf("this is 2\n"); break; case 3: printf("this is 3\n"); break; default: printf("default\n"); }
توصیه میشود که همیشه در آخر دستورات هر case، خودمان break قرار دهیم چون در غیر اینصورت بررسی برنامه و trace سخت میشود و ممکن است به باگ بربخوریم. اما چرا اصلا این اماکان وجود دارد و خود C یه صورت پیشفرض break نمیکند؟
دلیل این کار، فراهم کردن امکان «اجرای یک تکه کد برای caseهای مختلف است»
به این مثال توجه کنید:
switch (n) { case 1: case 2: case 3: printf("this is 1 or 2 or 3\n"); break; case 4: printf("4\n"); break; }
این برنامه به ازای همهی حالت های ۱ و ۲ و ۳، خروجی this is 1 or 2 or 3 را چاپ میکند. به این ترتیب میتوانیم از زدن کد تکراری جلوگیری کنیم. اما آیا ارزشش را دارد؟ در بسیاری از زبانهای جدید مثل go این قابلیت حذف شده و به صورت خودکار break میشود. و یا در نسخههای جدید gcc وارنینگِ نگذاشتن break در آخر هر case اضافه شده که البته فقط برای caseهای غیرخالی نمایش داده میشود. برای جلوگیری از آن هم امکان تصریح fall-through در c++17 و تصریحی به شکل کامنت برای gcc وجود دارد. (بله! کامپایلر میتواند کامنتهای کد شما را هم بخواند)
مطالعه بیشتر در مورد Fall-through: ویکیپدیا
با دلایل بالا، اگر هم سوییچکیسی بخواهد وجود داشته باشد کاراییای بهتر از if و elif نخواهد داشت. پس اصلا زبان را ساده میکنیم و بیخیال میشویم!
البته با توجه به سینتکس تمیز دیکشنری در این زبان، میتوان علاوه بر if else از آنها هم استفاده کرد که گزینه جالبی به نظر میآید. مثلا در این برنامه:
def numbers_to_strings(argument): switcher = { 0: "zero", 1: "one", 2: "two", } return switcher.get(argument, "nothing")
هر key در دیکشنری در نقش یک case عمل میکند و پارامتر دوم در get به جای default عمل میکند. به این معنی که «اگر key در دیشکنری نبود، به جایش nothing برگردان»
همچنین عبارتهای پرکاربرد و دوستداشتنی pattern matching در راه اضافه شدن به پایتون هستند، آن ها را معرفی خواهیم کرد.
مطالعه بیشتر: انواع راههای پیادهسازی سوییچ در پایتون
سوییچکیسِ جاوا بسیار شبیه به سوییچکیس در سی است ولی مثل خیلی از امکانات دیگر، این امکان هم مقداری بهبودیافته.
String s = "salam" final String kh = "khubi?" switch (s) { case "salam": System.out.println("alaik e salam"); break; case kh: System.out.println("mersi"); break; default: System.out.println("?"); }
در این حالت، هم امکان نوشتن مستقیم literal و هم استفاده از متغیرهای final String مجاز است. خوب است بدانیم که به کمک hashcode، این سوییچکیسها هم کارایی بالایی دارند و به if و equals ترجمه نمیشوند.
مطالعه بیشتر: سورسکد کامپایلر جاوا!
تفاوت statement و expression:
به دستورهای زبان برنامهنویسی، statement گفته میشود. مثلا if و while و تعریف یا فراخوانی تابع statement هستند.
یک دسته از این statementها قابل تبدیل به مقدار اند، مثلا متغیر a که مقداری داخلش دارد، قابل تبدیل به مقدار است. یا 2+2 قابل تبدیل به مقدار است. همچنین فرخوانی تابعی که مقدار برمیگرداند (void نیست) قابل تبدیل به مقدار است. به این عبارتها expression میگویند.
در زمینه شرطها، if فقط یک statement است ولی ternary operator هم statement است و هم expression، چون خودش قابل تبدیل به مقدار (و مثلا چاپ یا assign) است.
// use if (statement) int a; if(1 == 2){ a = 3; } else { a = 4; } // use ternary operator (expression) int a = (1==2)? 3 : 4;
مطالعه بیشتر: expressionها
اما در جاوا ۱۳ شاهد این هستیم که خود عبارتهای سوییچ میتوانند مقدار برگردانند. مثلا به کد زیر نگاه کنید:
var result = switch(month) { case JANUARY, JUNE, JULY -> 3; case FEBRUARY, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER -> 1; case MARCH, MAY, APRIL, AUGUST -> 2; default -> 0; };
خود سوییچ تبدیل به مقدار شده. با استفاده از فلش تصریح کردیم که چه مقدار برگردد. برای مثال اگر month برابر JANUARY بود، کل سوییچکیس به مقدار 3 تبدیل میشود.
البته در حالت expression همچنان قابلیت نوشتن دستور را هم داریم. در این حالت با کلیدواژه yield میتوانیم مقدار برگشتی را مشخص کنیم.
var result = switch (month) { case JANUARY, JUNE, JULY -> 3; case FEBRUARY, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER -> 1; case MARCH, MAY, APRIL, AUGUST -> { int monthLength = month.toString().length(); yield monthLength * 4; } default -> 0; };
به کلیدواژه yield دقت کنید.
مطالعه بیشتر در باره switchها و بهبودهایشان در جاوا: baeldung
همچنین خوب است بدانیم که در حالتی که به عنوان expression استفاده شود، باید حتما caseهای ما، تمام حالتهای ممکن را پوشش دهند (یا عبارت default داشته باشیم) چرا که نمی توان گفت اگر فلان حالت به وجود آمد، هیچ چیز برنگردان! یک expression باید حتما در تمام حالات قابل تبدیل به مقدار باشد.
توجه:
تطبیق الگو یا pattern matching یکی از جذابیتهای زبانهای فانکشنال است. زبانهایی مثل haskell و elixir یا حتی rust بسیاری از کارها را با تطبیق الگو انجام میدهند. اما اصلا تطبیق الگو چیست و چه تفاوتی با سوییچکیسها دارد؟
با یک مثال توضیح را شروع میکنیم.
تابع زیر را در نظر بگیرید
f 0 = 1 f n = n * f (n-1)
بیاید اینطوری کد را بخونیم:
این یک تابع بازگشتیست که برای n های مثبت، !n را حساب میکند. دقت کنید به کدی که نوشتهایم. بسیار شبیه به تعریف ریاضی همین تابع است. این قدرت تطبیق الگو را مشخص میکند.
اما شباهت تطبیق الگو به اینجا محدود نمیشود، با استفاده از سینتکس match (یا در برخی زبانها خود case) می بینیم که بسیار شبیه سوییچکیس میشود:
// match in rust let x = 1; match x { 1 => println!("one"), 2 => println!("two"), 3 => println!("three"), 4 => println!("four"), 5 => println!("five"), // else clause, executed when none of value matches _ => println!("something else"), } // case in haskell case expression of pattern -> result pattern -> result pattern -> result ...
شاید سوال پیش بیاید که در این حالت که دقیقا مثل سوییچکیس است. پس چه تفاوتی دارد؟ چرا گفتم سوییچکیسِ تکاملیافته؟ پاسخ این است که سوییچکیس (و کاربردهای اولیهای که پترنمچینگ دیدیم) تنها کاری که انجام میدهند انتخاب بر روی مقدارهای مختلف یک تایپ است. یعنی مثلا تمام ورودیها عدد صحیح هستند و بین حالات مختلف انتخاب میکنیم. اما قابلیت ویژهی تطبیق الگو، قابلیت تعیین رویه برنامه بر اساس تایپ ورودی و همزمان شکستن آن تایپ و استخراج مقدار از آن است.
ممکن است با دیتاتایپهای جبری آشنایی نداشته باشید(مطالعه بیشتر: adt) برای همین فرض کنید که تایپی که وارد فرایند تطبیقالگو میشود یک struct یا union است. با استفاده از این قابلیت میتوانیم در حین سوییچ کردن، هم بر اساس تایپ تصمیم گیری کنیم، هم اجزای سازندهی سازنده ی آن تایپ را جدا کنیم و از آنها نیز استفاده کنیم.
در واقع میتوان گفت pattern matching ترکیب قدرتمندی از سوییچکیس و tuple unpack است.
فرض کنید یک استراکت (product type) داریم، در زبانهای فانکشنال میتوانیم به این صورت تطبیق الگو انجام دهیم:
-- pattern matching in Haskell with records syntax info p = case p of ("roozbeh", _) -> "author" -- any person with name roozbeh is ok ("hadi", "ali akbar") -> "mentor" ("mahdi", "seyedan") -> "mentor" ("mohammad", "hasani") -> "mentor ^_^" _ -> "unknown"
main = do let p0 = ("roozbeh", "sharifnasab") putStrLn $ info p0 let p1 = ("hadi", "ali akbar") putStrLn $ info p1 let p2 = ("mahdi", "seyedan") putStrLn $ info p2 let p3 = ("mohammad", "hasani") putStrLn $ info p3 let p4 = ("ali", "alavi") putStrLn $ info p4
در تابع info، از مقدار داخل استراکتها به صورت مستقیم برای تعیین روند برنامه استفاده کردیم. در صورت نبود این قابلیت باید if else میگذاشتیم و داخل شرط if هم از and استفاده میکردیم. مثلا:
if p[0] == "roozbeh" and p[1] == "sharifnasab": return author
مطالعه بیشتر در مورد تطبیق الگو در هسکل و راسط و توضیح کلیِ آن در ویکیپدیا
زبانهای شیگرا (به طور خاص #C و جاوا) بعد از مشاهدهی موفقیتهای تطبیقالگو و علاقه برنامهنویسان به این سینتکسها، سعی کردند با توجه به ساختارهای قبلی زبان، این امکان را نیز اضافه کنند.
سیشارپ مدتی پیش این امکان را اضافه کرد و امروزه استفاده از آن بین برنامهنویسان سیشارپ بسیار رایج است.
در این زبانها تمرکز تطبیق الگو، روی استخراج sub-type از روی super-type است. مثلا ورودی یک شی از جنس کلاس Shape است و براساس تایپهای مختلفی که از آن ارث بری میکنند، برنامه رویههای مختلفی را دنبال میکند. همچنین در عین حال که تایپها را جدا میکنیم، میتوانیم یک رفرنس از آن sub-type بگیریم. مثلا مرسوم است که بگوییم اگر شی ورودی Circle بود، یک Circle با نام c به من بده. (عملیات cast کردن به صورت امن)
سیشارپ پا را فراتر گذاشته و میتوانیم با استفاده از کلیدواژهی when، همزمان شرط هم بگذاریم.
public static double ComputeArea(object shape) { switch (shape) { case Square s when s.Side == 0: case Circle c when c.Radius == 0: case Triangle t when t.Base == 0 || t.Height == 0: case Rectangle r when r.Length == 0 || r.Height == 0: return 0; case Square s: return s.Side * s.Side; case Circle c: return c.Radius * c.Radius * Math.PI; case Triangle t: return t.Base * t.Height / 2; case Rectangle r: return r.Length * r.Height; case null: throw new ArgumentNullException(paramName: nameof(shape), message: "Shape ust not be null"); default: throw new ArgumentException( message: "shape is not a recognized shape", paramName: nameof(shape)); } }
این سوییچکیس را به این صورت میخوانیم:
مطالعه بیشتر در مورد pattern matching در سیشارپ
و اما جاوا! جاوا متاسفانه هنوز به مرحله عملیاتی نرسیده ولی صحبتهای جدی در مورد آن انجام شده. (امیدوارم این را بعدا بخوانید و به صورت رسمی اضافه شدهباشد!)
چیزی که قرار است در جاوا اضافه شود اصلا در قالب if است ولی کاربردی دقیقا مانند همتای سیشارپی خود خواهد داشت. در واقع به آن pattern matching for instanceof گفته میشود.
// current java version without pattern matching if (obj instanceof String) { String s = (String) obj; ... }
// java version with pattern matching if (obj instanceof String s) { // instance of and cast in single step // Let pattern matching do the work! ... }
یکی از کاربرد های خوب این سینتکس در متد equals است چرا که حتما ورودی Object میگیرد و ناچار به استفاده از instanceof و cast هستیم.
برنامهنویسان جاوا اگر علاقه به برنامهنویسی فانکشنال دارید میتوانید از زبان دوستداشتنی اسکالا استفاده کنید که با هدف ترکیب دو پارادایم آمده و به صورت پیشفرض دارای چنین امکاناتیست.
همچنین در پایان توصیه میکنم این مطلب را هم مطالعه کنید. به تفاوتهای تطبیق الگو و سوییچکیس در برخی دیگر از زبانها پرداختهاست.