اگر در یک کد هیچ شرط1، حلقه2 یا دیگر دستورات کنترل جریان وجود نداشته باشد، خواندش بسیار ساده خواهد بود. این نوع پرشها و شاخهها چیزهای سختی هستند که میتوانند کد را سریعا مبهم کنند. این فصل درباره ایجاد سادگی در خواندن کنترل جریان، در کدهای شما است.
کلید طلایی: تمام شرطها، حلقهها و سایر تغییرات برای کنترل جریان را تا حد امکان به شکل طبیعی1 انجام دهید. به شکلی بنویسید که خواننده مجبور نشود توقف کند و مجددا کد شما را بخواند.
کدام یک از این دو قطعه کد خوانایی بیشتری دارند، این کد:
if (length >= 10)
یا این:
if (10 <= length)
برای اکثر برنامهنویسان، کد اول خوانایی بیشتری دارد. حال قضاوت شما درباره دو کد زیر چیست؟ مثلا این:
while (bytes_received < bytes_expected)
یا این:
while (bytes_expected > bytes_received)
در اینجا هم، نسخه اول خوانایی بیشتری دارد. اما چرا؟ قانون کلی چیست؟ چگونه تصمیم میگیرید که بهتر است کد را به صورت a < b بنویسد و یا به صورت b > a ؟
یک راهنمایی مفید به نظر ما این است:
سمت راست | سمت چپ |
---|---|
عبارتی که مقدار آن معمولا ثابت است را در سمت راست بنویسید. | عبارتی که مقدارش در حال تغییر کردن است را در سمت چپ بنویسید. |
این راهنما با قواعد زبان انگلیسی مطابقت دارد. کاملا طبیعی است که بگوییم «اگر حداقل ۱۰۰هزار دلار در سال به دست آورید1» یا «اگر حداقل ۱۸ سال دارید2». و نه اینکه بگوییم «اگر ۱۸ سال کمتر یا مساوی با سن شما است3».
این مثال به خوبی خواناتر بودن while (bytes_received < bytes_expected) را توضیح میدهد، چرا که bytes_received مقداری است که بررسی روی آن انجام میشود و با اجرای حلقه، مقدار آن در حال افزایش بوده و bytes_expected مقدار پایدارتری4 است که در برابر آن مورد مقایسه قرار میگیرد.
در برخی از زبانها از جمله C و C++، و نه Java عمل انتصاب در یک شرط if قانونی است:
if (obj = NULL) ...
به احتمال زیاد این کد دارای اشکال است و منظور اصلی برنامهنویس این بوده است:
if (obj == NULL) ...
برای جلوگیری از چنین اشکالاتی، اکثر برنامهنویسان ترتیب آرگومانها را با هم جابجا میکنند:
if (NULL == obj) ...
در این روش if== به طور تصادفی به شکل = نوشته میشود، ولی مشکل این است که عبارت if(NULL = Obj) حتی کامپایل هم نمیشود.
متاسفانه، جابجایی ترتیب آرگومانها سبب میشود کد برای خواندن کمی غیرطبیعی جلوه کند. خوشبختانه کامپایلرهای مدرن نسبت به کدهایی مانند if(NULL = Obj) هشدار میدهند).
هنگام نوشتن یک دستور if/else، به طور معمول باید آزادی عمل در مورد جابجا کردن ترتیب بلوکهای دستور را داشته باشید. به عنوان نمونه شما میتوانید به هر دو شکل زیر شرط خود را بنویسید:
if (a == b) {
// Case One ...
} else {
// Case Two ...
}
و یا این:
if (a != b) {
// Case Two ...
} else {
// Case One ...
}
ممکن است تا کنون به این موضوع فکر نکرده باشید، اما در بعضی از موارد دلایل خوبی برای ترجیح دادن یک ترتیب نسبت به دیگری وجود دارد:
- ترجیح دادن بررسی مورد مثبت، به جای مورد منفی در ابتدای کار. به عنوان مثال ترجیح عبارت if(debug) نسبت به if(!debug).
- ترجیح دهید بررسی مورد سادهتر در ابتدای کار انجام شود تا زودتر از این شرط خارج شوید. همچنین این رویکرد ممکن است اجازه دهد if و else با هم و در یک زمان واحد، روی صفحه نمایشگر قابل دیدن باشند، که خوب است.
- ترجیح دادن بررسی مورد جالبتر یا انگشت نماتر1 در ابتدای کار.
گاهی این ترجیح دادنها با هم تداخل داشته و شما باید در مورد آنها تصمیم گیری کنید. اما در بسیاری از موارد، مشخص است که کدام گزینه برنده است.
به عنوان مثال فرض کنید یک وب سرور دارید که هر گاه URL شامل پارامتر کوئری expand_all بود، پاسخی ایجاد میکند:
if (!url.HasQueryParameter("expand_all")) {
response.Render(items);
...
} else {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
وقتی که خواننده به خط اول نگاه میکند، مغز او بلافاصله به expand_all فکر میکند. همچون زمانی که کسی میگوید: «به یک فیل صورتی فکر نکن». کاری از شما ساخته نیست، بی شک در مورد آن فکر خواهید کرد. کلمه غیر معمول فیل صورتی باعث میشود تا عبارت فکر نکن به چشم نیامده و شخص به فیل صورتی فکر کند.
در اینجا expand_all همان فیل صورتی است. از آنجا که موردِ جالبتری بوده و هم زمان نیز مثبت است، اجازه دهید آن را در ابتدا بیان کنیم:
if (url.HasQueryParameter("expand_all")) {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
} else {
response.Render(items);
...
از سوی دیگر، مثال زیر وضعیتی است که در آن مورد منفی سادهتر و جالبتر یا خطرناکتر بوده و به همین دلیل، ابتدا آن را مطرح میکنیم:
if not file:
# Log the error ...
else:
# ...
همواره این کار یک قضاوت یا تصمیم گیری است که به جزئیات هر مورد بستگی دارد. به طور خلاصه، توصیه ما این است که صرفا به این فاکتورها توجه کنید و مراقب مواردی که if/else شما در یک ترتیب ناخوشایند قرار گرفته اند، باشید.
در زبانهایی مانند C شما میتوانید یک عبارت شرطی را به صورت cond ? a : b بنویسید که روشی خاص و خلاصه برای نوشتن عبارت if (cond) { a } else { b } است.
البته تاثیر آن بر روی خوانایی قابل بحث است ولی طرفداران آن فکر میکنند این روشی خوب برای نوشتن چیزی در یک خط است که در غیر این صورت به چندین خط نیاز دارد. مخالفان آن نیز استدلال میکنند که این روش میتواند برای خواننده گیج کننده باشد و بررسی مرحلهای آن در دیباگر1 نیز سخت خواهد بود.
در اینجا موردی وجود دارد که عملگر سه گانه در عین مختصر بودن به راحتی قابل خواندن است:
time_str += (hour >= 12) ? "pm" : "am";
در صورت اجتناب از عملگر سه گانه، احتمالا چیزی شبیه کد زیر خواهید داشت که کمی طولانی شده و باعث افزونگی1 گشته است:
if (hour >= 12) {
time_str += "pm";
} else {
time_str += "am";
}
در مورد زیر، هر چند بیان عبارت شرطی، به نظر منطقیتر میباشد، ولی با این حال، این عبارت میتواند خواندن سریع کد در یک نگاه را سخت کند:
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);
در اینجا، عملگر سه گانه تنها انتخاب بین دو مقدار ساده نیست. انگیزه نوشتن کدی شبیه این کد، این است که همه چیز در یک خط فشرده شود.
کلید طلایی: به جای حداقل کردن تعداد خطوط کد، معیار بهتر، حداقل کردن مدت زمانی است که یک نفر بتواند آن را درک کند.
بیان منطق با یک دستور if/else باعث میشود، کد طبیعیتر به نظر برسد:
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}
کلید طلایی: به طور پیشفرض همواره از if/else استفاده کنید. از حالت سه گانه؟ : فقط در صورتی استفاده کنید که مورد سادهای باشد.
زبان Perl و بسیاری از زبانهای برنامهنویسی قابل احترام، دستور زیر را در خود دارند: do { expression } while (condition) این عبارت یا expression حداقل یک بار اجرا میشود. به عنوان مثال کد زیر را در نظر بگیرید:
// Search through the list, starting at 'node', for the given 'name'.
// Don't consider more than 'max_length' nodes.
public boolean ListHasNode(Node node, String name, int max_length) {
do {
if (node.name().equals(name))
return true;
node = node.next();
} while (node != null && --max_length > 0);
return false;
}
آنچه در مورد حلقه do/while عجیب است این است که، یک بلوک از کد ممکن است بر اساس یک شرط در پایین آن کد مجددا اجرا شود. به طور معمول شرطهای منطقی در بالای کد بوده و نقش محافظ را دارند. این همان روشی است که دستورات if، while و for با آن کار میکنند. از آنجا که معمولا کد را از بالا به پایین میخوانید این کار باعث میشود که حلقه do/while کمی غیرمعمول به نظر برسد. بسیاری از خوانندگان در نهایت مجبور میشوند کد را دوباره بخوانند.
این در حالی است که قاعدتا باید حلقهها راحتتر خوانده شوند، زیرا شما قبل از اینکه بخش داخلی بلوک کد را بخوانید، میدانید که شرط برای همه تکرارها چیست. ولی با این حال این احمقانه است که فقط برای حذف do/while کد دو برابر شود:
// Imitating a do/while — DON'T DO THIS!
body
while (condition) {
body (again)
}
خوشبختانه ما متوجه شدهایم که با تمرین بیشتر میتوان حلقههای do/while را به صورت حلقههای شروع شده با while نوشت:
public boolean ListHasNode(Node node, String name, int max_length) {
while (node != null && max_length-- > 0) {
if (node.name().equals(name)) return true;
node = node.next();
}
return false;
}
مزیت دیگری که این نسخه دارد، این است که هنوز هم اگر max_length برابر 0 و یا node برابر null باشد، هنوز هم کار میکند. دلیل دیگر برای اجتناب از do/while این است که دستور continue در داخل آن میتواند گیج کننده باشد. به عنوان مثال، به نظر شما این کد چه کاری انجام میدهد؟
do {
continue;
} while (false);
این حلقه دائمی است یا فقط یک بار اجرا میشود؟ اکثر برنامهنویسان مجبور هستند که صبر کرده و درباره آن فکر کنند.
آقای Bjarne Stroustrup، سازنده C++ بهترین جمله را در زبان برنامهنویسی C++ درباره حلقه do/while اینگونه بیان میکند:
«تجربه به من ثابت کرده است که دستور do منبع خطاها و گیج شدنها است... من ترجیح میدهم شرط را در ابتدا ببینم، به همین دلیل ترجیح میدهم که از دستورات do استفاده نکنم.»
برخی از کدنویسان معتقداند که توابع هیچگاه نباید چندین دستور return داشته باشند. این عبارت بی معنی است. return زودهنگام از یک تابع، یک عملکرد کاملا مناسب و در اغلب مواقع پسندیده است. به عنوان مثال این کد را ببینید:
public boolean Contains(String str, String substr) {
if (str == null || substr == null) return false;
if (substr.equals("")) return true;
...
}
پیاده سازی این تابع بدون این بندهای شرطی1 خیلی غیر معمول خواهد بود.
یکی از انگیزهها برای داشتن یک نقطه خروج از تابع، این است که تضمین میشود کلیه کدهای تمیز شده(cleanup)2 در بخش پایین تابع فراخوانی شده باشند. اما زبانهای مدرن روشهای پیچیدهتری برای دستیابی به این ضمانت را ارائه میدهند:
ساختار برنامهنویسی | چگونگی ایجاد ابهام در جریان برنامه سطح بالا |
---|---|
threading | واضح نیست که کد چه زمانی اجرا میشود. |
signal/interrupt handlers | کد خاصی ممکن است در هر زمانی اجرا شود. |
exceptions | توقف برنامه میتواند با فراخوانی همزمان چندین تابع انجام شود. |
function pointers & anonymous functions | به سختی میتوان گفت که دقیقا چه کدی در حال اجرا است! زیرا این کار در زمان کامپایل مشخص نیست. |
Virtual methods | object.virtualMethod() ممکن است کد یک زیرکلاس نامشخص را invoke کند. |
در زبان خالص C هنگام خروج یک تابع، مکانیزمی برای اعمال1 کد خاص وجود ندارد. بنابراین اگر یک تابعِ بزرگ با تعداد زیادی از کدهای cleanup وجود داشته باشد، return کردن سریع آنها به طور صحیح احتمالا سخت خواهد بود. در این موارد، گزینههای دیگری همچون بازسازی تابع یا حتی استفاده دقیق از goto cleanup; میتواند مورد استفاده قرار گیرد.
در زبانهایی غیر از C، نیاز کمی به goto به دلیل وجود راههای زیاد جایگزین برای انجام کار مد نظر، وجود دارد. همچنین این دستور به این دلیل که سریعا از دست در میرود و دنبال کردن کد را سخت میکند، بدنام شده است.
با این حال هنوز هم میتوان استفاده از دستور goto را در برخی از نسخههای پروژههای C مشاهده کرد که مهمترین آنها هسته لینوکس است. قبل از اینکه همه کاربردهای goto را افتضاح تلقی کنید، بهتر است تشریح کنیم که چرا برخی از استفادههای goto بهتر از دیگر روشها است.
سادهترین و بی ضررترین استفاده از goto، با یک exit تکی در پایین یک تابع نشان داده میشود:
if (p == NULL) goto exit;
...
exit:
fclose(file1);
fclose(file2);
...
return;
اگر در اینجا، تنها استفاده از goto مجاز باشد، این دستور مشکل زیادی نخواهد داشت.
احتمالا مشکلات زمانی رخ میدهد که چندین هدف برای goto وجود داشته باشد، مخصوصا اگر مسیر آنها مختلف باشد. به طور خاص هرچند goto میتواند باعث پیشرفت در کدهای اسپاگتی شود ولی در عین حال، میتواند با حلقههای ساختاری جایگزین شود. اما به یاد داشته باشید که در بیشتر اوقات باید از goto اجتناب کنید.
درک کردن کدهای توی در توی زیاد، دشوار است. هر سطح از مرحله تو در تو، شرطهایی اضافی را بر پشته ذهنی2 خواننده تحمیل میکند. هنگامی که خواننده یک براکت بسته «}» را میبیند، انجام عملیات pop روی پشته ذهنی شخص و اینکه شروط تحت آن را به یاد آورد، کار سختی خواهد بود.
در اینجا یک نمونه نسبتا ساده از این موضوع وجود دارد. دقت کنید که برای فهمیدن محتوای داخلی کدام شرط، آن را دو مرتبه بررسی میکنید:
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}
reply.WriteErrors("");
} else {
reply.WriteErrors(user_result);
}
reply.Done();
زمانی که شما اولین براکت بسته «}» را میبینید، مجبور خواهید بود با خودتان فکر کنید، وااای! permission_result != SUCCESS تازه تمام شد، پس حالا permission_result == SUCCESS است و این هنوز داخل بلوک کدی است که user_result == SUCCESS است.
همان گونه که مشاهده میکنید مجبور هستید که مقدار user_result و permission_result را همواره در ذهن خود نگه دارید و به محض اینکه هر بلوک if{} بسته میشود، باید مقدارهای داخل ذهن خود را تغییر دهید. این بخش خاص کد حتی بدتر است، زیرا شرایط بین SUCCESS و non-SUCCESS را به صورت متناوب نگهداری میکند.
قبل از تلاش برای اصلاح کد مثال قبلی بیایید درباره چگونگی پایان یافتن آن صحبت کنیم. در ابتدا کد ساده بود:
if (user_result == SUCCESS) {
reply.WriteErrors("");
} else {
reply.WriteErrors(user_result);
}
reply.Done();
این کد کاملا قابل فهم بوده و مشخص میکند که چه رشته خطایی باید نوشته شود و سپس با یک پاسخ تمام میشود.
اما بعد از این عملیات دوم به کد اضافه شده است:
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}
reply.WriteErrors("");
...
این تغییر با معنی است یعنی برنامهنویس یک تکه کد جدید برای درج کردن داشت و سادهترین مکان را برای درج آن، پیدا کرد. این کد جدید، در ذهن او تازه و به صورت «برجسته1» بود و «تفاوت» این تغییر خیلی واضح است. البته این کار یک تغییر ساده به نظر میرسد.
اما هنگامی که بعدها شخص دیگری کد را بررسی میکند، این پیش زمینه را ندارد. او نیز همچون شما که برای اولین بار کد را در ابتدای این بخش خواندید، مجبور است که با یک نگاه همه کد را متوجه شود.
کلید طلایی: هنگام ایجاد تغییر، به کد خود به دید یک چشم انداز1 تازه نگاه کنید. برگردید و به آن به صورت کلی نگاه کنید.
حال بیایید کد را بهبود دهیم. تودرتوهای شبیه این کد را میتوان با مدیریت حالتهای شکست1 در اولین فرصت ممکن حذف نموده و مقدار برگشتی تابع را سریعتر بازگرداند:
if (user_result != SUCCESS) {
reply.WriteErrors(user_result);
reply.Done();
return;
}
if (permission_result != SUCCESS) {
reply.WriteErrors(permission_result);
reply.Done();
return;
}
reply.WriteErrors("");
reply.Done();
به جای دو سطح، کد جدید تنها یک سطح از تودرتو بودن را دارد، و مهمتر از همه اینکه خواننده نیازی به عملیات pop در پشته مغز خود ندارد چرا که هر بلوک if با برگشت دادن یک مقدار به پایان میرسد.
تکنیک «برگشت دادن یا return کردن در اولین فرصت» همیشه قابل انجام نیست.به عنوان مثال در اینجا موردی از تودرتو بودن کد در حلقه را داریم:
for (int i = 0; i < results.size(); i++) {
if (results[i] != NULL) {
non_null_count++;
if (results[i]->name != "") {
cout << "Considering candidate..." << endl;
...
}
}
}
در داخل حلقه، روش مشابه برای برگشت دادن زود هنگام مقدار، استفاده از دستور continue است:
for (int i = 0; i < results.size(); i++) {
if (results[i] == NULL) continue;
non_null_count++;
if (results[i]->name == "") continue;
cout << "Considering candidate..." << endl;
...
}
به همان روشی که یک if (...) return; به عنوان حفاظ جزئی1 برای تابع عمل مینماید، دستور if (...) continue; نیز به عنوان یک حفاظ جزئی برای حلقه عمل میکند.
هر چند به طور کلی دستور continue میتواند مانند دستور goto در حلقه، گیج کننده باشد(زیرا این دستور ذهن خواننده را به اطراف منحرف میکند). اما در این مورد، به دلیل مستقل بودن هر تکرار از حلقه، خواننده میتواند به سادگی ببیند که continue فقط به معنی پرش2 از این آیتم است.
این فصل درباره کنترل جریان سطح-پایین1 بود یعنی نحوه ساختن حلقهها، شرطها و دیگر پرشها به گونهای که خواندن آنها، ساده باشد. اما این کافی نبوده و شما باید در مورد جریان برنامه خود در سطح بالا نیز فکر کنید. در حالت ایدهآل باید بتوانید به راحتی کل مسیر اجرای برنامه خود را دنبال کنید یعنی از main() شروع کرده و سپس از نظر ذهنی کد را دنبال نمایید و نیز به عنوان یک تابع، دیگر توابع را صدا بزنید تا اینکه در نهایت از برنامه خارج شوید.
با این حال، زبانهای برنامهنویسی و کتابخانهها در عمل دارای ساختارهایی2 هستند که به کد اجازه اجرا شدن در پشت صحنه را میدهند و این دنبال کردن آنها را سخت میکند. به این مثالها توجه کنید:
اصطلاحات ساختاری برای cleanup | زبان برنامه نویسی |
---|---|
destructors | C++ |
try finally | Java, Python |
with | Python |
using | C# |
برخی از این ساختارها بسیار مفید بوده و حتی میتوانند خوانایی کد شما را بیشتر نموده و افزونگی آن را کاهش دهند. اما به عنوان یک برنامهنویس، گاه با بی ملاحظهگی و بدون درک اینکه چقدر در آینده سبب دشواری فهمیدن کد برای خوانندهها میشوند، بیش از اندازه از آنها استفاده میکنیم. همچنین از سوی دیگر این ساختارها سبب سختتر شدن ردیابی اشکالات برنامه میشوند.
نکته اصلی این است که اجازه ندهید درصد زیادی از کد شما را این ساختارها تشکیل دهند. اگر از این ویژگیها درست استفاده نکنید، ممکن است ردیابی در سراسر کد شما را، به بازی Three-Card Monte تبدیل نمایند.
کارهای زیادی وجود دارد که با انجام آنها میتوانید خواندن کنترل جریان کدهایتان را سادهتر کنید.
هنگام نوشتن یک مقایسه(while (bytes_expected > bytes_received)) بهتر است مقدار متغیر را در سمت چپ و مقداری که کمتر تغییر میکند را در سمت راست بنویسید(while (bytes_received < bytes_expected)).
همچنین میتوانید ترتیب بلوکهای دستور if/else را تغییر دهید. به طور کلی، تلاش کنید که موردهای مثبت یا جالبتر را برای نوشتن اولویت دهید. البته گاهی اوقات این معیارها با هم تداخل دارند، اما در صورت عدم تداخل، این یک قانون خوب است که باید از آن پیروی کنید.
برخی از ساختارهای برنامهنویسی مانند عملگر سه تایی (:?)، حلقه do/while و goto اغلب سبب میشوند که کد غیرقابل خواندن شود. بهتر است از آنها را جایگزین کنید، زیرا همیشه گزینههای واضحتری وجود دارد.
برای دنبال کردن بلوکهای کد تو در تو نیازمند تمرکز بیشتری هستید. هر تودرتوی جدید مستلزم ایجاد پیش زمینه بیشتر برای خواننده است تا بتواند کد را در پشته حافظه خود قرار دهد. برای اجتناب از تودرتو نوشتن عمیق، از کد خطی یا linear استفاده کنید.
به طور کلی برگرداندن سریع میتواند به حذف تودرتو بودن کد و پاک سازی آن کمک کند. در خصوص این مورد دستورات محافظتی1 خیلی مفید هستند(البته موارد ساده را در قسمت بالای تابع مدیریت کنید).
[2]:
[3]:
[4]:
[5]:
[6]:
[7]:
[8]:
[9]:
[10]:
[11]:
[12]:
[13]:
[14]:
[15]:
[16]:
[17]:
[18]:
[19]:
[20]:
[21]:
[22]:
[23]:
[24]:
[25]:
[26]:
[27]:
[28]: