Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

بخش سوم: کنترل جریان برنامه

Authors
Affiliations
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد

بخش سوم : کنترل جریان برنامه

مقدمه

تا اینجای درس، همه‌ی برنامه‌های ما یک ویژگی مشترک داشتند: دستورها از اولین خط تا آخرین خط، دقیقاً یک‌بار و به همان ترتیبی که نوشته شده‌اند اجرا می‌شدند. به این الگو اجرای ترتیبی (Sequence) می‌گوییم. اما مسئله‌های واقعی این‌طور نیستند. وقتی یک سیستم بانکی می‌خواهد برداشت از حساب را انجام دهد، باید ابتدا تصمیم بگیرد: «اگر موجودی کافی است، برداشت را ثبت کن؛ وگرنه پیام خطا بده». وقتی می‌خواهیم میانگین نمرات یک کلاس را حساب کنیم، باید یک کار مشخص را برای هر دانشجو تکرار کنیم، بدون این‌که تعداد دانشجویان در زمان نوشتن برنامه معلوم باشد. یعنی دو نیاز تازه پیدا کرده‌ایم: تصمیم‌گیری و تکرار.

این بخش دقیقاً به همین دو نیاز پاسخ می‌دهد. در فصل ۸ با ساختارهای شرطی (if، else if، else، عمل‌گر ?: و switch) یاد می‌گیریم برنامه چگونه بین چند مسیر یکی را انتخاب کند. در فصل ۹ با حلقه‌ها (while، do-while، for) و ابزارهای کنترل آن‌ها (break، continue) یاد می‌گیریم برنامه چگونه یک کار را تکرار کند، و در پایان، دلیل این‌که چرا دستور goto را عملاً کنار می‌گذاریم بررسی می‌شود.

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

نقشه کلی بخش:
اجرای ترتیبی (Sequence)
          │
          ├── نیاز به «تصمیم‌گیری» ──► Selection  (فصل ۷)
          │         if / if-else / else-if / ?: / switch
          │
          └── نیاز به «تکرار» ──────► Iteration   (فصل ۸)
                    while / do-while / for
                              │
                              ├── حلقه‌های تودرتو
                              ├── break / continue
                              └── goto  (و دلیل پرهیز از آن)

الگوهای مهم حلقه:
Counter / Accumulator / Min-Max / Search / Sentinel / Convergence

فصل ۷ : ساختارهای شرطی (Conditional Structures)

7.1 چرا به شرط نیاز داریم؟

فرض کنید در برنامه‌ی ارزیابی نمرات، می‌خواهیم برای یک دانشجو وضعیت «قبول» یا «مردود» را چاپ کنیم. نمره‌ی قبولی در این درس ۱۰ از ۲۰ است. با دانش فعلی‌مان چنین می‌نویسیم:

Score: 8
Status: Pass
Status: Fail

خروجی بالا بی‌معنی است: برای نمره‌ی ۸ هر دو پیام «قبول» و «مردود» چاپ شده است. اگر مقدار score را عوض کنیم، باز هم همین دو خط چاپ می‌شود. دلیلش ساده است: در اجرای ترتیبی، برنامه هیچ راهی برای «نگاه کردن به داده و تصمیم گرفتن» ندارد؛ هر دستور فقط به این دلیل اجرا می‌شود که در فهرست قرار دارد. چند سؤال از خودتان بپرسید:

  • اگر بخواهیم فقط برای نمره‌های کمتر از ۱۰ پیام «مردود» چاپ شود، این تصمیم را در کدام خط بگیریم؟

  • اگر بخواهیم برای نمره‌های ۱۷ به بالا پیام «عالی»، برای ۱۴ تا ۱۶ پیام «خوب» و برای بقیه‌ی قبولی‌ها پیام «قبول» چاپ شود، فقط با printf چه کار می‌توانیم بکنیم؟

  • اگر نمره از کاربر گرفته شود (و در زمان نوشتن برنامه معلوم نباشد)، چطور برنامه باید رفتارش را با آن تطبیق دهد؟

مشکل اصلی این است: ما به سازه‌ای نیاز داریم که:

  1. یک شرط را بسنجد (نتیجه‌ی آن فقط «درست» یا «نادرست» باشد).

  2. بر اساس نتیجه‌ی شرط، فقط یکی از چند مسیر را اجرا کند.

  3. مسیرهای انتخاب‌نشده را کاملاً نادیده بگیرد.

این دقیقاً همان چیزی است که ساختار انتخاب (Selection) فراهم می‌کند. شکل کلی آن را به‌صورت Flowchart ببینید:

تمرین درون فصل ۸.۱

نوع تمرین: Quick Reasoning

برنامه‌ی sequential_naive را در نظر بگیرید. فرض کنید مقدار score را به 19 تغییر دهیم. آیا خروجی برنامه تغییر می‌کند؟ بدون اجرای کد توضیح دهید چرا نمی‌توان فقط با تغییر ورودی، این برنامه را «درست» کرد و چه چیزی در ساختار برنامه کم است.

8.2 شرط در زبان C: درست، نادرست و عمل‌گرهای منطقی

پیش از دیدن if، باید دقیقاً بدانیم «شرط» چیست. شرط در برنامه‌نویسی هر عبارتی است که نتیجه‌اش به یکی از دو حالت «درست» یا «نادرست» تعبیر می‌شود. زبان C برخلاف زبان‌هایی مثل Python یا Java، در استاندارد کلاسیکش نوع داده‌ی مستقل bool ندارد و از اعداد صحیح برای این کار استفاده می‌کند. قرارداد C این است:

  • مقدار صفر یعنی نادرست (False).

  • هر مقدار غیرصفر (۱، ۵، ‎-۳ و ...) یعنی درست (True).

  • عبارت‌های مقایسه‌ای و منطقی همیشه یکی از دو مقدار 1 (درست) یا 0 (نادرست) را تولید می‌کنند.

عمل‌گرهای مقایسه‌ای (Relational)
عمل‌گرمعنیمثالنتیجه
==برابر است با5 == 51
!=برابر نیست با5 != 50
< , <=کوچک‌تر، کوچک‌تر یا مساوی3 < 51
> , >=بزرگ‌تر، بزرگ‌تر یا مساوی3 >= 50

عمل‌گرهای منطقی (Logical)
عمل‌گرناممعنی
&&ANDوقتی درست است که هر دو طرف درست باشند
||ORوقتی درست است که حداقل یکی از دو طرف درست باشد
!NOTنتیجه را برعکس می‌کند

جدول درستی (Truth Table) دو عمل‌گر && و || را ببینید (A و B دو شرط دلخواه‌اند):

ABA && BA || B
0000
0101
1001
1111

Writing src/p04_c08_02_boolean_expr.c
a > b            -> 1
a == b           -> 0
a != b           -> 1
a > 5 && b > 5   -> 0
a > 5 || b > 5   -> 1
!(a > b)         -> 0
!!a (a is 7)     -> 1

خط آخر نکته‌ی مهمی را نشان می‌دهد: a برابر ۷ است (نه ۱)، اما در یک شرط «درست» محسوب می‌شود، چون غیرصفر است؛ و عمل‌گر ! دوبار روی آن اثر کرده و آن را به مقدار استاندارد 1 تبدیل کرده است.

ارزیابی کوتاه‌مدار (Short-Circuit Evaluation)

یک ویژگی بسیار مهم && و || این است که طرف راست فقط در صورت لزوم ارزیابی می‌شود:

  • در A && B، اگر A نادرست باشد، نتیجه قطعاً نادرست است و B اصلاً ارزیابی نمی‌شود.

  • در A || B، اگر A درست باشد، نتیجه قطعاً درست است و B اصلاً ارزیابی نمی‌شود.

این فقط یک بهینه‌سازی نیست؛ یک ابزار الگوریتمی است. با ترتیب دادن درست شرط‌ها می‌توانیم یک محاسبه‌ی خطرناک را «نگهبانی» کنیم (Guard):

Writing src/p04_c08_03_short_circuit.c
Skipped: x is zero, the division never ran
calls = 0 (the right side did not run)

چرا ترتیب شرط‌ها در x != 0 && 10 / x > 1 مهم است؟
اگر ترتیب را برعکس کنیم (10 / x > 1 && x != 0)، ابتدا تقسیم بر صفر ارزیابی می‌شود و برنامه دچار خطای زمان اجرا می‌شود. با قرار دادن «شرطِ نگهبان» در سمت چپ، تضمین می‌کنیم طرف راست فقط وقتی اجرا شود که امن است.

چرا در خط دوم برنامه مقدار calls هنوز صفر است؟
چون x == 0 درست است و در || وقتی طرف چپ درست باشد، طرف راست (++calls > 0) هرگز اجرا نمی‌شود؛ پس اثر جانبی آن (افزایش calls) هم رخ نمی‌دهد. به همین دلیل نوشتن عبارت‌هایی با اثر جانبی داخل شرط‌ها عادت خوبی نیست.

اولویت عمل‌گرها

وقتی چند عمل‌گر در یک عبارت هستند، ترتیب ارزیابی از بالا به پایین جدول زیر است:

اولویتعمل‌گر
۱ (بالاترین)!
۲< , <= , > , >=
۳== , !=
۴&&
۵ (پایین‌ترین)||

یعنی && قوی‌تر از || است. این دقیقاً جایی است که خواننده‌ی کد را (و گاهی نویسنده‌ی آن را) به اشتباه می‌اندازد:

Writing src/p04_c08_04_precedence.c
src/p04_c08_04_precedence.c: In function ‘main’:
src/p04_c08_04_precedence.c:5:41: warning: suggest parentheses around ‘&&’ within ‘||’ []8;;https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html#index-Wparentheses-Wparentheses]8;;]
    5 |     printf("1 || 0 && 0     -> %d\n", 1 || 0 && 0);
      |                                         ^~
1 || 0 && 0     -> 1
(1 || 0) && 0   -> 0

عبارت اول به‌صورت 1 || (0 && 0) ارزیابی می‌شود (چون && اول انجام می‌شود) و نتیجه‌اش 1 است؛ در حالی که با پرانتز، نتیجه 0 می‌شود. توجه کنید کامپایلر با -Wall هم دقیقاً همین ابهام را هشدار داده است. قاعده‌ی عملی: هر جا ترکیب && و || را کنار هم می‌نویسید، پرانتز بگذارید؛ حتی اگر لازم نباشد.

قوانین دمورگان (De Morgan's Laws)

برای ساختن «نقیض» یک شرط (مثلاً وقتی می‌خواهیم حالت‌های نامعتبر را از روی حالت‌های معتبر بسازیم) از دو قانون زیر استفاده می‌کنیم:

عبارت اصلیعبارت معادل
!(A && B)!A || !B
!(A || B)!A && !B

مثال: نمره‌ی معتبر یعنی score >= 0 && score <= 20. پس نمره‌ی نامعتبر یعنی !(score >= 0 && score <= 20) که طبق دمورگان برابر است با score < 0 || score > 20. به عمل‌گر دقت کنید: && تبدیل به || شد و هر مقایسه برعکس شد.

تمرین درون فصل ۸.۲

نوع تمرین: Predict the Output

int x = 5;
printf("%d %d\n", x > 3 && x < 10, x < 3 || x > 10);

بدون اجرای کد، خروجی را پیش‌بینی کنید. سپس با استفاده از قوانین دمورگان، عبارت !(x > 3 && x < 10) را بدون استفاده از ! بازنویسی کنید.

8.3 دستور if

ایده الگوریتم

ساده‌ترین شکل تصمیم‌گیری: «اگر شرط برقرار بود، یک کار اضافه انجام بده؛ در غیر این صورت، همین مرحله را رد کن و ادامه بده.» در این حالت دو مسیر داریم، اما یکی از آن‌ها «هیچ کاری نکردن» است.

Pseudocode
if condition:
    statements          // only when the condition is true
next statement          // always runs

Flowchart
Example A - Simple: پاداش برای نمره‌ی بالا
Writing src/p04_c08_05_if_simple.c
Score: 19
Excellent! Bonus point awarded.
Report finished.
Score: 12
Report finished.

برنامه دو بار با ورودی‌های 19 و 12 اجرا شده است. در اجرای دوم، پیام پاداش چاپ نشده، اما خط آخر (Report finished.) در هر دو اجرا چاپ شده. چرا؟

کدام خط‌ها «زیر کنترل» if هستند؟
فقط دستورهای داخل آکولاد ({ ... }) بعد از شرط. خط printf("Report finished.\n") بیرون از آکولاد است و به شرط وابسته نیست؛ یعنی بعد از پایان if ــ چه بدنه اجرا شده باشد، چه نه ــ برنامه از همین خط ادامه می‌یابد. این همان گره‌ی «دو مسیر که دوباره به هم می‌رسند» در Flowchart است.

چرا برای بدنه‌ی if همیشه آکولاد می‌نویسیم، حتی برای یک دستور؟
چون اگر آکولاد نباشد، فقط اولین دستور بعد از شرط به آن وابسته است و هر خط بعدی (حتی اگر با تورفتگی نوشته شده باشد) مستقل می‌شود. این یکی از رایج‌ترین منابع باگ است (در «خطاهای رایج» همین فصل می‌بینیم).

Example B - Real: اعتبارسنجی بازه‌ی نمره

در برنامه‌ی واقعی، اولین کاری که با ورودی کاربر باید کرد این است که مطمئن شویم معتبر است. نمره باید بین ۰ تا ۲۰ باشد:

Writing src/p04_c08_06_if_validate.c
15: valid score
20: valid score
0: valid score
21: invalid score (must be between 0 and 20)
-1: invalid score (must be between 0 and 20)

چرا شرط نامعتبر را با || نوشته‌ایم، نه با &&؟
نمره وقتی نامعتبر است که از یک طرف بازه بیرون باشد: یا خیلی کوچک (< 0) یا خیلی بزرگ (> 20). عبارت score < 0 && score > 20 هیچ‌وقت درست نمی‌شود، چون هیچ عددی هم‌زمان هم از ۰ کوچک‌تر است و هم از ۲۰ بزرگ‌تر. این همان قانون دمورگان است که در ۸.۲ دیدیم.

چرا بعد از چاپ پیام خطا return 1 نوشته‌ایم؟
return در main برنامه را فوراً تمام می‌کند. مقدار غیرصفر طبق قرارداد یعنی «برنامه با خطا تمام شد». این الگو (Guard Clause: «اگر ورودی بد است، همین‌جا کار را تمام کن») باعث می‌شود بقیه‌ی برنامه فقط با داده‌ی معتبر سروکار داشته باشد؛ در ۸.۶ بیشتر از آن استفاده می‌کنیم.

Example C - Tricky: دستور بعد از شرط، بدون آکولاد
if (score < 10)
    printf("Fail\n");
    printf("Please retake the course\n");

برای score = 15، آیا پیام «Please retake the course» چاپ می‌شود؟ بله. تورفتگی فقط برای انسان است و کامپایلر آن را نمی‌بیند. کامپایلر کد بالا را این‌طور می‌خواند:

if (score < 10) {
    printf("Fail\n");
}
printf("Please retake the course\n");   // همیشه اجرا می‌شود

برای همین است که هیچ‌وقت نباید به تورفتگی برای تشخیص حدود if اعتماد کرد؛ آکولاد تنها منبع حقیقت است.

تمرین درون فصل ۸.۳

نوع تمرین: Predict the Output

int x = 4;
if (x > 5)
    printf("A\n");
    printf("B\n");

خروجی چیست؟ دلیل خود را با استناد به قاعده‌ی «یک دستور بدون آکولاد» توضیح دهید.

تمرین‌های پایانی زیربخش: if

★ سطح پایه

  1. Small Implementation:
    برنامه‌ای بنویسید که یک عدد صحیح بخواند و فقط اگر عدد منفی بود، پیام Negative number را چاپ کند. در هر حالت، در انتها Done چاپ شود.

  2. Predict the Output:

int a = 10, b = 20;
if (a > b) {
    printf("X\n");
}
printf("Y\n");

خروجی را پیش‌بینی کنید و بگویید کدام خط‌ها به شرط وابسته‌اند.

★★ سطح متوسط

  1. Small Implementation:
    برنامه‌ای بنویسید که سه عدد صحیح بگیرد و فقط اگر هر سه عدد مثبت بودند، مجموع آن‌ها را چاپ کند.

  2. Find the Bug:

if (score >= 0 && score <= 20);
{
    printf("Valid\n");
}

این کد برای هر ورودی «Valid» چاپ می‌کند. علت را پیدا کنید و اصلاح کنید.

★★★ سطح چالشی

  1. Design:
    شرط «عدد x در بازه‌ی [۱۰, ۲۰] نیست» را یک‌بار با ! و یک‌بار بدون ! (با دمورگان) بنویسید و ثابت کنید (با جدول مقادیر آزمایشی) که معادل‌اند.

  2. Explain Why:
    توضیح دهید چرا نوشتن if (x = 0) در C خطای کامپایل نمی‌دهد، اما تقریباً همیشه اشتباه است. این شرط برای چه مقادیری از x بدنه را اجرا می‌کند؟

8.4 دستور if-else

ایده الگوریتم

در بسیاری از مسئله‌ها، هر دو حالت «شرط درست» و «شرط نادرست» کاری برای انجام دارند. if-else یک تقسیم دوشاخه است: دقیقاً یکی از دو مسیر اجرا می‌شود، نه هر دو و نه هیچ‌کدام. به همین دلیل مشکل برنامه‌ی sequential_naive (چاپ هم‌زمان «قبول» و «مردود») با آن حل می‌شود.

Pseudocode
if condition:
    statements A        // condition is true
else:
    statements B        // condition is false
next statement

Flowchart
Example A - Simple: زوج یا فرد
Writing src/p04_c08_07_even_odd.c
10 is even
7 is odd
0 is even

ایده‌ی الگوریتم: باقی‌مانده‌ی تقسیم بر ۲ فقط دو مقدار ممکن دارد؛ پس مسئله‌ی «زوج یا فرد» دقیقاً با یک تصمیم دوشاخه حل می‌شود. به این‌که عدد 0 زوج حساب شده است دقت کنید: 0 % 2 برابر 0 است، پس الگوریتم بدون هیچ استثنای جداگانه درست کار می‌کند.

Example B - Real: سال کبیسه (Leap Year)

این مسئله یک نمونه‌ی کلاسیک از قاعده‌ی چندلایه است. قاعده‌ی تقویم میلادی این است: سالی کبیسه است که بر ۴ بخش‌پذیر باشد، مگر این‌که بر ۱۰۰ بخش‌پذیر باشد، مگر این‌که بر ۴۰۰ هم بخش‌پذیر باشد. دو راه برای تبدیل این قاعده به شرط وجود دارد: یک عبارت منطقی واحد، یا یک زنجیره‌ی تصمیم از خاص‌ترین استثنا به عمومی‌ترین قاعده (۴۰۰ ← ۱۰۰ ← ۴).

Pseudocode
function IS_LEAP(year):
    if year divisible by 400:  return TRUE
    else if year divisible by 100: return FALSE
    else if year divisible by 4:   return TRUE
    else:                          return FALSE

Writing src/p04_c08_08_leap_year.c
1900: common year
2000: leap year
2024: leap year
2023: common year
2100: common year
1600: leap year

چرا این شش سال برای تست انتخاب شده‌اند؟
هر سال یک مسیر متفاوت از الگوریتم را آزمایش می‌کند: سال‌های 2000 و 1600 مسیر «بخش‌پذیر بر ۴۰۰» را؛ 1900 و 2100 مسیر «بخش‌پذیر بر ۱۰۰ ولی نه ۴۰۰» را؛ 2024 مسیر «بخش‌پذیر بر ۴ ولی نه ۱۰۰» را؛ و 2023 مسیر «هیچ‌کدام» را. این ایده را پوشش مسیرها (Path Coverage) می‌نامند: برای هر مسیر ممکن، حداقل یک ورودی آزمایشی. اگر فقط سال‌های «عادی» مثل ۲۰۲۴ و ۲۰۲۳ را تست می‌کردیم، یک پیاده‌سازی ساده‌ی year % 4 == 0 هم قبول می‌شد، در حالی که برای ۱۹۰۰ غلط است.

چرا در فرمول دوم ترتیب ۴۰۰ ← ۱۰۰ ← ۴ مهم است؟
چون اگر ابتدا بپرسیم «آیا بر ۴ بخش‌پذیر است؟»، سال ۱۹۰۰ همان‌جا کبیسه تلقی می‌شود و استثنای ۱۰۰ هرگز فرصت اصلاح این تصمیم را پیدا نمی‌کند. در یک زنجیره‌ی تصمیم، هر شرط فقط وقتی بررسی می‌شود که همه‌ی شرط‌های قبلی نادرست بوده‌اند؛ پس شرط‌ها باید از خاص به عام مرتب شوند (در ۸.۵ این اصل را کامل بررسی می‌کنیم).

چرا برنامه دو فرمول را با هم مقایسه می‌کند؟
وقتی برای یک مسئله دو راه حل مستقل داریم، مقایسه‌ی نتیجه‌ی آن‌ها یک تکنیک ارزان برای کشف باگ است. اگر هیچ‌وقت MISMATCH چاپ نشود، احتمال اشتباه هر دو هم‌زمان کم می‌شود.

Trace: دنبال کردن مسیر تصمیم برای سال ۱۹۰۰ و ۲۰۲۴
Loading...
Loading...
Example C - Tricky: باقی‌مانده‌ی اعداد منفی
Writing src/p04_c08_09_even_odd_negative.c
n % 2 = -1
version 1 (n % 2 == 1): even   <-- wrong!
version 2 (n % 2 != 0): odd

در C (از استاندارد C99 به بعد) تقسیم صحیح به سمت صفر گرد می‌شود و علامت باقی‌مانده با علامت مقسوم یکی است؛ پس -7 % 2 برابر -1 است، نه 1. نسخه‌ی اول فقط برای عددهای نامنفی درست بود. نسخه‌ی دوم به‌جای «آیا باقی‌مانده دقیقاً ۱ است؟» می‌پرسد «آیا باقی‌مانده صفر نیست؟» که برای همه‌ی اعداد درست است. درس الگوریتمی: شرط را طوری بنویس که روی کل دامنه‌ی ورودی درست باشد، نه فقط روی مثال‌هایی که امتحان کرده‌ای.

تمرین درون فصل ۸.۴

نوع تمرین: Trace

الگوریتم سال کبیسه را برای year = 2100 و سپس year = 2000 روی کاغذ دنبال کنید. برای هرکدام بنویسید کدام شرط‌ها به ترتیب بررسی می‌شوند و نتیجه‌ی هرکدام (درست/نادرست) چیست و در نهایت کدام مسیر انتخاب می‌شود.

تمرین‌های پایانی زیربخش: if-else

★ سطح پایه

  1. Small Implementation:
    برنامه‌ای بنویسید که یک عدد بخواند و بگوید مثبت است یا «غیر مثبت» (صفر یا منفی).

  2. Predict the Output:

int n = 15;
if (n % 5 == 0) {
    printf("A\n");
} else {
    printf("B\n");
}
printf("C\n");

★★ سطح متوسط

  1. Small Implementation:
    برنامه‌ای بنویسید که سه عدد بخواند و با فقط if-else و عمل‌گرهای منطقی بگوید آیا هر سه برابرند یا نه.

  2. Find the Bug:

if (year % 4 == 0)
    printf("Leap\n");
else
    printf("Common\n");

این کد برای کدام سال‌ها (حداقل دو مثال بزنید) جواب غلط می‌دهد؟ چه شرط‌هایی باید اضافه شود؟

  1. Trace:
    برای year = 1600 و year = 1700 الگوریتم دو فرمول (عبارت واحد و زنجیره) را دنبال کنید و نشان دهید هر دو به یک نتیجه می‌رسند.

★★★ سطح چالشی

  1. Edge Case Reasoning:
    اگر در برنامه‌ی زوج/فرد به‌جای n % 2 != 0 از n % 2 == 1 استفاده شود، برای کدام ورودی‌ها پاسخ غلط می‌دهد؟ یک ورودی آزمایشی بنویسید که این باگ را فاش کند.

  2. Design:
    مسئله‌ی «بخش‌پذیری بر ۳ و ۵» (همه‌ی اعدادی که هم بر ۳ و هم بر ۵ بخش‌پذیرند) را با یک شرط واحد و سپس با دو if تو در تو بنویسید. کدام خواناتر است؟ چرا؟

8.5 زنجیره‌ی else if (انتخاب چندمسیره)

ایده الگوریتم

گاهی بیش از دو مسیر داریم. مثلاً در برنامه‌ی ارزیابی نمرات، نمره باید به یکی از چند دسته تبدیل شود: «عالی» (۱۷ تا ۲۰)، «خوب» (۱۴ تا ۱۶)، «قبول» (۱۰ تا ۱۳) و «مردود» (زیر ۱۰). راه‌حل، یک زنجیره‌ی شرط‌ها است که از بالا به پایین یکی‌یکی بررسی می‌شوند و اولین شرطی که درست باشد برنده است؛ بقیه‌ی زنجیره اصلاً بررسی نمی‌شود.

این جمله‌ی کوتاه («اولین شرط درست برنده است») مهم‌ترین نکته‌ی این بخش است و تمام قاعده‌های بعدی از آن نتیجه می‌شوند.

Pseudocode
if condition 1:      statements 1
else if condition 2: statements 2
else if condition 3: statements 3
...
else:                statements N     // optional: if nothing above matched

Flowchart
Example A - Simple: درجه‌بندی نمره
Writing src/p04_c08_10_grade_ladder.c
20: Excellent
17: Excellent
16: Good
14: Good
13: Pass
10: Pass
9: Fail
0: Fail
21: invalid score
-5: invalid score

چرا در شرط سوم فقط score >= 14 نوشته‌ایم و score < 17 را ننوشته‌ایم؟
چون برای رسیدن به این شرط، شرط قبلی (score >= 17) باید نادرست شده باشد؛ یعنی در این نقطه از برنامه، ما از قبل «می‌دانیم» که score < 17 است. هر شرط در زنجیره به‌طور ضمنی نقیضِ همه‌ی شرط‌های بالایی را هم در خود دارد. این ویژگی شرط‌ها را کوتاه و خوانا می‌کند، اما همزمان یعنی ترتیب شرط‌ها بخشی از منطق برنامه است.

چرا شرط اعتبارسنجی در ابتدای زنجیره آمده است؟
چون اگر نمره‌ی 25 را بدون بررسی اعتبار وارد زنجیره می‌کردیم، به شرط score >= 17 می‌رسید و «Excellent» چاپ می‌شد. حالت‌های خاص و استثنایی (ورودی نامعتبر) باید پیش از حالت‌های عمومی بررسی شوند.

چرا شاخه‌ی نهایی else شرط ندارد؟
else یعنی «هر حالتی که هیچ‌کدام از شرط‌های بالا پوشش نداده‌اند». چون در این‌جا آخرین دسته (مردود) دقیقاً «بقیه‌ی حالت‌ها» است، نیازی به نوشتن score >= 0 && score < 10 نیست.

Trace: مسیر تصمیم برای نمره‌ی ۱۵
Loading...
تست مرزی (Boundary Value Testing)

«چه ورودی‌هایی را تست کنیم؟» سؤالی است که یک برنامه‌نویس حرفه‌ای هر بار از خودش می‌پرسد. برای زنجیره‌های شرطی، پاسخ استاندارد این است: روی مرز هر شرط، یک مقدار درست قبل از مرز، خود مرز، و یک مقدار درست بعد از آن را امتحان کنید؛ چون اکثر باگ‌های شرطی دقیقاً همان‌جا (اشتباه > به‌جای >=) پنهان می‌شوند.

مرزمقادیر آزمایشیخروجی مورد انتظار
مرز اعتبار پایین-1 , 0invalid , Fail
مرز Fail/Pass9 , 10Fail , Pass
مرز Pass/Good13 , 14Pass , Good
مرز Good/Excellent16 , 17Good , Excellent
مرز اعتبار بالا20 , 21Excellent , invalid

اگر دوباره به خروجی برنامه‌ی grade_ladder نگاه کنید، دقیقاً همین مقادیر آزمایش شده‌اند و همه با جدول بالا می‌خوانند. فرض کنید به‌جای score >= 14 اشتباهاً score > 14 نوشته بودیم؛ فقط ورودی 14 (که روی مرز است) این باگ را نشان می‌داد، در حالی که تست با 16 یا 12 هرگز آن را نمی‌دید.

Example B - Real: دسته‌بندی مثلث

مسئله: سه عدد صحیح به‌عنوان طول ضلع‌ها داده شده است. برنامه باید بگوید این سه عدد (۱) اصلاً ضلع معتبر هستند؟ (۲) تشکیل مثلث می‌دهند؟ (۳) مثلث متساوی‌الاضلاع، متساوی‌الساقین یا مختلف‌الاضلاع است؟ (۴) قائم‌الزاویه هست یا نه؟

ایده‌ی الگوریتم: سؤال‌ها را به ترتیب وابستگی مرتب می‌کنیم. تا وقتی ضلع‌ها مثبت نباشند، بحث نامساوی مثلث بی‌معناست؛ تا وقتی نامساوی مثلث برقرار نباشد، بحث نوع مثلث بی‌معناست. پس: اعتبار ← نامساوی مثلث ← نوع.

Pseudocode
read a, b, c
if a <= 0 or b <= 0 or c <= 0:       print "invalid"
else if a + b <= c or a + c <= b or b + c <= a:
                                      print "not a triangle"
else:
    if a = b = c:                     type ← "equilateral"
    else if two sides are equal:      type ← "isosceles"
    else:                             type ← "scalene"
    right ← (a² + b² = c²) or (a² + c² = b²) or (b² + c² = a²)
    print type, and "right-angled" if right

Writing src/p04_c08_11_triangle.c
3 4 5: scalene, right-angled
5 5 5: equilateral
5 5 8: isosceles
6 7 9: scalene
1 2 3: not a triangle
0 4 4: invalid (sides must be positive)
5 12 13: scalene, right-angled

چرا شرط نامساوی مثلث با <= نوشته شده، نه <؟
در یک مثلث واقعی مجموع هر دو ضلع اکیداً از ضلع سوم بزرگ‌تر است. حالت 1 2 3 (که 1 + 2 == 3) یک «مثلث تباهیده» است: سه نقطه روی یک خط. اگر < می‌نوشتیم، این ورودی به‌اشتباه مثلث معتبر شمرده می‌شد. همین ورودی یک تست مرزی عالی است.

چرا ساختار این برنامه ترکیبی از زنجیره‌ی else if و یک if تو در تو است؟
بخش اول (اعتبار ← مثلث بودن) یک انتخاب چندمسیره‌ی کلاسیک است. اما وقتی مطمئن شدیم مثلث معتبر است، دو سؤال مستقل از هم داریم: «نوع از نظر ضلع‌ها» و «قائم‌الزاویه بودن». این دو را نمی‌توان در یک زنجیره‌ی واحد گنجاند، چون در یک زنجیره فقط یک مسیر اجرا می‌شود، در حالی که ما باید هر دو پاسخ را بدهیم. ترکیب right (یک متغیر 0/1) با چاپ شرطی انتهای خط، راه ساده‌ی ما برای ترکیب پاسخ هر دو سؤال است.

Example C - Tricky: ترتیب اشتباه شرط‌ها

فرض کنید یک برنامه‌نویس همان زنجیره‌ی درجه‌بندی را از پایین به بالا نوشته باشد:

Writing src/p04_c08_12_ladder_wrong_order.c
18: Pass
15: Pass
12: Pass
5: Fail

کد بدون هیچ خطا یا هشداری کامپایل می‌شود و حتی برای نمره‌های پایین درست کار می‌کند؛ اما برای 18 و 15 جواب غلط می‌دهد. چرا؟ چون شرط score >= 10 برای هر نمره‌ی قبولی درست است و طبق قانون «اولین شرط درست برنده است»، دو شاخه‌ی بعدی هرگز اجرا نمی‌شوند (به آن‌ها کد غیرقابل‌دسترس یا Unreachable Code می‌گوییم).

قاعده‌ی طراحی: در یک زنجیره‌ی else if که روی بازه‌ها کار می‌کند، شرط‌ها را از محدودترین (سخت‌گیرانه‌ترین) به عمومی‌ترین بنویسید؛ یا برعکس، از عمومی‌ترین به محدودترین ولی با مقایسه‌ی < (مثلاً score < 10، سپس score < 14، ...). هر دو درست‌اند؛ مهم این است که مرتب باشند.

هزینه‌ی یک زنجیره‌ی else if

در بدترین حالت (مقداری که با هیچ شرطی تطابق ندارد یا فقط با آخرین شرط تطابق دارد)، یک زنجیره‌ی k شرطی باید همه‌ی k شرط را ارزیابی کند؛ یعنی هزینه‌ی آن متناسب با k است. این عدد معمولاً کوچک است و تأثیری ندارد؛ اما همین نکته توجیه می‌کند چرا برای زنجیره‌های بزرگ و پرتکرار، شرط‌هایی را که محتمل‌تر هستند بالاتر می‌گذارند (به شرطی که منطق برنامه را به هم نریزد).

تمرین درون فصل ۸.۵

نوع تمرین: Trace

برای score = 14 و سپس score = 13 زنجیره‌ی درجه‌بندی را دنبال کنید. در هر مورد بنویسید کدام شرط‌ها به ترتیب بررسی می‌شوند و چرا نتیجه‌ی این دو ورودی که فقط یک واحد اختلاف دارند متفاوت است.

تمرین درون فصل ۸.۶ : چرا ترتیب مهم است؟

نوع تمرین: Explain Why

در برنامه‌ی ladder_wrong_order (ترتیب اشتباه)، برای چه نمره‌هایی خروجی درست است و برای چه نمره‌هایی غلط؟ آیا می‌توانید بدون تغییر ترتیب شرط‌ها، فقط با تغییر خود شرط‌ها (بدون جابه‌جایی) آن را درست کنید؟ یک راه پیشنهاد دهید و مقایسه‌اش با راه‌حل «اصلاح ترتیب» را بنویسید.

تمرین‌های پایانی زیربخش: else if

★ سطح پایه

  1. Trace:
    برای ورودی 3 4 5 در برنامه‌ی دسته‌بندی مثلث، شرط‌هایی که به ترتیب بررسی می‌شوند و مقدار right را بنویسید.

  2. Predict the Output:

int x = 7;
if (x > 10) {
    printf("A\n");
} else if (x > 5) {
    printf("B\n");
} else if (x > 0) {
    printf("C\n");
}

★★ سطح متوسط

  1. Small Implementation:
    برنامه‌ای بنویسید که دمای هوا (عدد صحیح) را بگیرد و آن را به یکی از دسته‌های «یخبندان» (زیر ۰)، «سرد» (۰ تا ۱۴)، «معتدل» (۱۵ تا ۲۵) و «گرم» (بالای ۲۵) تبدیل کند.

  2. Find the Bug:

if (age >= 18) {
    printf("Adult\n");
} else if (age >= 13) {
    printf("Teen\n");
} else if (age >= 0 && age < 13) {
    printf("Child\n");
}

اگر age = -4 باشد چه اتفاقی می‌افتد؟ چه چیزی در طراحی این زنجیره از نظر مدیریت ورودی نامعتبر کم است؟

  1. Edge Case Implementation:
    برای برنامه‌ی دسته‌بندی مثلث، یک جدول از حداقل هشت ورودی آزمایشی بنویسید که همه‌ی مسیرهای برنامه (invalid، not a triangle، سه نوع ضلعی، قائم‌الزاویه و غیرقائم) را پوشش دهد.

★★★ سطح چالشی

  1. Design:
    مالیات‌ها معمولاً پله‌ای محاسبه می‌شوند: تا ۱۰۰۰ واحد، معاف؛ از ۱۰۰۰ تا ۵۰۰۰ واحد، ۱۰٪ مقدار اضافه‌بر ۱۰۰۰؛ بالای ۵۰۰۰ واحد، ۴۰۰ واحد (مالیات کامل پله‌ی دوم) به‌اضافه‌ی ۲۰٪ مقدار اضافه‌بر ۵۰۰۰. با یک زنجیره‌ی else if مالیات را حساب کنید و مرزهای ۱۰۰۰ و ۵۰۰۰ را تست کنید.

  2. Modify the Algorithm:
    برنامه‌ی دسته‌بندی مثلث را طوری تغییر دهید که علاوه بر نوع ضلع‌ها، نوع زاویه‌ها را هم (حاده، قائمه، منفرجه) تشخیص دهد. (راهنمایی: فقط بزرگ‌ترین ضلع مهم است؛ چگونه آن را بدون آرایه پیدا می‌کنید؟)

8.6 شرط‌های تو در تو (Nested Conditions)

ایده الگوریتم

یک if می‌تواند داخل بدنه‌ی if یا else دیگری قرار بگیرد. این کار زمانی طبیعی است که یک تصمیم فقط وقتی معنا دارد که تصمیم قبلی به نتیجه‌ی مشخصی رسیده باشد. مثال: برداشت از خودپرداز. پرسیدن «آیا مبلغ از موجودی کمتر است؟» وقتی مبلغ منفی باشد بی‌معنی است؛ پس باید ابتدا اعتبار مبلغ بررسی شود.

قواعد برداشت: مبلغ مثبت باشد؛ مضرب ۱۰ باشد؛ از موجودی (اینجا ۱۰۰۰) بیشتر نباشد؛ از سقف برداشت روزانه (اینجا ۵۰۰) بیشتر نباشد.

Example A - Simple: نسخه‌ی تو در تو (Nested)
Writing src/p04_c08_13_atm_nested.c
OK: withdrew 200, balance = 800
Denied: amount must be positive
Denied: amount must be a multiple of 10
Denied: above the daily limit
Denied: insufficient balance

برنامه درست کار می‌کند، اما دو مشکل دارد. اول: پیام خطای هر شرط از خود شرط دور افتاده است (پیام positive در انتهای برنامه است و شرطش در ابتدا). دوم: کد به‌شکل هرم (Pyramid) به سمت راست رشد می‌کند و هرچه شرط‌ها بیشتر شوند، خواندن آن سخت‌تر می‌شود. با هر سطح تو در تو، ذهن خواننده باید یک شرط دیگر را به‌خاطر بسپارد.

Example B - Real: نسخه‌ی تخت (Flat) با Guard Clause
Writing src/p04_c08_14_atm_flat.c
OK: withdrew 200, balance = 800
Denied: amount must be positive
Denied: amount must be a multiple of 10
Denied: above the daily limit
Denied: insufficient balance

خروجی هر دو نسخه برای ورودی‌های یکسان دقیقاً یکی است، اما ساختار دوم تخت و خواناتر است. ایده‌ی اصلی: به‌جای «اگر مبلغ معتبر بود، ادامه بده»، می‌نویسیم «اگر مبلغ نامعتبر است، همین‌جا رد کن». هر شرط، یک حالت خطا را فوراً کنار پیام خودش مدیریت می‌کند و حالت موفق در انتها (در else) می‌ماند. به این الگو Guard Clause یا «بررسی نگهبان» می‌گویند.

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

Example C - Tricky: Dangling Else

وقتی در یک if تو در تو آکولاد نمی‌گذاریم، پرسش «این else متعلق به کدام if است؟» پیش می‌آید. قاعده‌ی C: **هر else به نزدیک‌ترین if بدون else متصل می‌شود**، صرف‌نظر از تورفتگی.

Writing src/p04_c08_15_dangling_else.c
src/p04_c08_15_dangling_else.c: In function ‘main’:
src/p04_c08_15_dangling_else.c:8:8: warning: suggest explicit braces to avoid ambiguous ‘else’ []8;;https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html#index-Wdangling-else-Wdangling-else]8;;]
    8 |     if (a == 1)
      |        ^
else: runs when a == 1 and b != 1
done

خط اول خروجی نشان می‌دهد else به if (b == 1) چسبیده است، نه به if (a == 1)؛ در حالی که ظاهر کد (و نیت نویسنده) چیز دیگری بود. کامپایلر هم با یک هشدار دقیقاً همین ابهام را گزارش کرده است. با آکولاد، نیت برنامه‌نویس بدون ابهام بیان می‌شود و بلوک دوم هیچ چیزی چاپ نمی‌کند (چون a == 1 و b != 1 است و else بیرونی هم اجرا نمی‌شود).

تمرین درون فصل ۸.۷

نوع تمرین: Predict the Output

int a = 0, b = 5;
if (a == 0)
    if (b > 10)
        printf("X\n");
    else
        printf("Y\n");
printf("Z\n");

خروجی را پیش‌بینی کنید و بگویید else به کدام if تعلق دارد.

تمرین‌های پایانی زیربخش: شرط‌های تو در تو

★ سطح پایه

  1. Trace:
    در برنامه‌ی خودپرداز (نسخه‌ی تخت)، برای amount = 700 شرط‌های بررسی‌شده و پیام نهایی را بنویسید.

  2. Predict the Output:
    در همان برنامه، برای amount = 1000 خروجی چیست؟ (توجه: limit برابر ۵۰۰ است.)

★★ سطح متوسط

  1. Refactoring:
    کد زیر را بدون هیچ if تو در تو و فقط با && بازنویسی کنید:

if (age >= 18) {
    if (has_id) {
        printf("Entry allowed\n");
    }
}

  1. Find the Bug:
    نسخه‌ی تخت خودپرداز را طوری تغییر دهید که ترتیب دو شرط amount > balance و amount > limit عوض شود. آیا خروجی برای همه‌ی ورودی‌ها یکسان می‌ماند؟ برای کدام ورودی تفاوت پیدا می‌شود؟

★★★ سطح چالشی

  1. Design:
    یک برنامه‌ی ورود (Login) ساده طراحی کنید که نام‌کاربری‌ها و رمز را به‌صورت عددی (مثلاً user_id و pin) بگیرد، ابتدا اعتبار user_id را بسنجد (فقط ۱۰۰۱ و ۱۰۰۲ معتبرند)، سپس درستی pin را بررسی کند و در هر مرحله پیام مخصوص خود را بدهد. هم نسخه‌ی تو در تو و هم نسخه‌ی تخت را بنویسید.

  2. Compare Approaches:
    دو نسخه‌ی «تو در تو» و «تخت» از یک مسئله را از نظر تعداد خطوط، خوانایی، و سهولت اضافه‌کردن یک قانون جدید (مثلاً «حداقل برداشت ۵۰») مقایسه کنید.

8.7 عمل‌گر شرطی سه‌تایی ?:

ایده الگوریتم

گاهی تصمیم ما فقط برای انتخاب یک مقدار است، نه اجرای چند دستور. مثلاً «بزرگ‌ترین دو عدد» یا «قدر مطلق». در این حالت نوشتن یک if-else کامل فقط برای یک انتساب پرحرف است. C عمل‌گر ?: را برای همین منظور دارد:

شرط ? مقدار_اگر_درست : مقدار_اگر_نادرست

تفاوت بنیادین این عمل‌گر با if: **if یک دستور (Statement) است و مقدار تولید نمی‌کند؛ اما ?: یک عبارت (Expression) است و خودش یک مقدار دارد.** به همین دلیل می‌توان آن را در سمت راست یک انتساب، داخل printf و یا هر جای دیگری که مقدار لازم است قرار داد.

Pseudocode
result ← (condition) ? value_if_true : value_if_false

// equivalent
if condition:
    result ← value_if_true
else:
    result ← value_if_false

Example A - Simple: بیشینه، قدر مطلق و علامت
Writing src/p04_c08_16_ternary_basic.c
max = 20
|a - b| = 8
sign(a - b) = -1
a is even

چرا خط آخر ?: را داخل printf گذاشته‌ایم؟
چون ?: مقدار تولید می‌کند (اینجا یک رشته‌ی متنی، "even" یا "odd") و printf به همان مقدار نیاز دارد. با if باید دو printf جداگانه می‌نوشتیم یا یک متغیر کمکی می‌ساختیم.

چرا برای محاسبه‌ی sign از ?: تو در تو استفاده شد و آیا کار درستی است؟
از نظر عملکرد درست است، اما خواندنش سخت‌تر از معادل if-else if-else است. این نمونه نشان می‌دهد وقتی عمل‌گر ?: را تو در تو می‌کنیم، به مرز کاهش خوانایی رسیده‌ایم.

Example B - Real: جمع بستن درست کلمه‌ها (Plural)
Writing src/p04_c08_17_ternary_plural.c
0 files selected
1 file selected
2 files selected

(این برنامه یک حلقه‌ی for دارد که در فصل بعد کامل می‌بینیم؛ فعلاً فقط بدانید n را یکی‌یکی از ۰ تا ۲ می‌شمارد.) پیام «1 file» در مقابل «0 files» و «2 files»، یک نمونه‌ی ایده‌آل برای ?: است: فقط یک بخش کوچک از متن بر اساس یک شرط عوض می‌شود.

Example C - Tricky: اولویت پایین ?:

عمل‌گر ?: از تقریباً همه‌ی عمل‌گرهای دیگر اولویت پایین‌تر دارد (فقط از انتساب بالاتر است). برای همین عبارت‌های بدون پرانتز می‌توانند نتیجه‌ی غیرمنتظره بدهند:

int a = 5, b = 3;
int r = a > b ? 100 : 200 + 1;      // r = 100  (چون شرط درست است)

a = 1;
r = a > b ? 100 : 200 + 1;          // r = 201  (200 + 1 کامل در شاخه‌ی نادرست است)

در مثال دوم، + 1 جزو شاخه‌ی نادرست است، نه یک جمع بعد از کل ?:. قاعده‌ی عملی: **هر عبارت ?: را کامل داخل پرانتز بنویسید**، مخصوصاً وقتی با عمل‌گرهای دیگر ترکیب می‌شود.

همچنین اگر دو شاخه از نوع‌های مختلف باشند (مثلاً int و double)، نتیجه به نوع «بزرگ‌تر» (اینجا double) تبدیل می‌شود؛ پس باید printf را با مشخص‌کننده‌ی مناسب (%f) به کار برد.

کدام یک: if-else یا ?:؟
معیارif-else?:
نوعدستور (مقدار ندارد)عبارت (مقدار دارد)
اجرای چند دستوربلهخیر (فقط یک عبارت در هر شاخه)
مناسب برایتصمیم‌گیری درباره‌ی «چه کاری انجام شود»تصمیم‌گیری درباره‌ی «چه مقداری استفاده شود»
تو در تو کردنخوانا (با تورفتگی)به‌سرعت ناخوانا

تمرین درون فصل ۸.۸

نوع تمرین: Complete the Code

عبارت زیر را با ?: کامل کنید تا abs_val قدر مطلق x شود:

int x = -9;
int abs_val = /* ??? */;

سپس همین کار را با if-else بنویسید و بگویید در این مسئله کدام نسخه را ترجیح می‌دهید و چرا.

تمرین‌های پایانی زیربخش: عمل‌گر ?:

★ سطح پایه

  1. Predict the Output:

int n = 3;
printf("%s\n", (n > 2) ? "big" : "small");

  1. Small Implementation:
    با ?: کوچک‌ترین دو عدد a و b را به متغیر min بدهید.

★★ سطح متوسط

  1. Find the Bug:

int a = 2, b = 7;
int r = a > b ? a : b + 10;

مقدار r چیست؟ آیا نویسنده احتمالاً همین را می‌خواسته؟ با پرانتز، نیت «ده تا بیشتر از بزرگ‌ترین» را بنویسید.

  1. Refactoring:
    عبارت زیر را به یک if-else if-else خوانا تبدیل کنید و بگویید کدام را ترجیح می‌دهید:

char c = (x > 0) ? '+' : (x < 0) ? '-' : '0';

★★★ سطح چالشی

  1. Design:
    بزرگ‌ترین سه عدد را (الف) با ?: تو در تو، (ب) با دو if، و (ج) با ترکیب && پیدا کنید. کدام خواناتر است؟ هر سه را روی ورودی‌هایی که دو عدد برابرند (مثلاً 5 5 3) تست کنید.

  2. Explain Why:
    چرا نمی‌توان از ?: به‌جای if برای اجرای دو دستور مثل printf و انتساب هم‌زمان استفاده کرد؟ تفاوت «دستور» و «عبارت» را در یک مثال توضیح دهید.

8.8 switch-case

ایده الگوریتم

بعضی تصمیم‌ها یک الگوی خاص دارند: یک مقدار را با چند مقدار ثابت و مشخص مقایسه می‌کنیم. مثلاً شماره‌ی روز هفته، نوع عملگر ماشین‌حساب، یا انتخاب کاربر در یک منو. اگر این کار را با else if بنویسیم، مجبوریم عبارت day == ... را بارها تکرار کنیم:

if (day == 1) { ... } else if (day == 2) { ... } else if (day == 3) { ... }

دستور switch برای همین الگو ساخته شده است: مقدار عبارت را یک‌بار حساب می‌کند، سپس مستقیماً به برچسب case مطابق «می‌پرد» و از آن‌جا شروع به اجرا می‌کند. اگر هیچ caseی تطابق نداشت، به default می‌رود (اگر وجود داشته باشد).

Pseudocode
evaluate expression once
jump to the case whose constant equals the value
    execute from there downwards, until "break" or the end of the switch
if no case matches: jump to default (if any)

Flowchart
Example A - Simple: نام روز هفته
Writing src/p04_c08_18_switch_day.c
Saturday
Wednesday
Friday
Invalid day number
قوانین مهم switch
  • عبارت داخل switch (...) باید از نوع صحیح باشد (int، char، enum). مقدار اعشاری (double) یا رشته‌ی متنی مجاز نیست.

  • هر case باید یک مقدار ثابت صحیح باشد (عدد، کاراکتر مثل 'a'، یا ثابت #define)؛ متغیر و بازه (مثل case 1..5) مجاز نیست.

  • مقدار هر case باید منحصربه‌فرد باشد.

  • switch فقط تساوی را می‌سنجد؛ برای شرط‌های «بزرگ‌تر از» یا «بین دو مقدار» ساختار مناسب نیست.

  • default اختیاری است ولی برای دریافت ورودی‌های پیش‌بینی‌نشده بسیار مفید است.

نقش break و پدیده‌ی Fall-Through

مهم‌ترین نکته‌ی switch این است: **پرش به case مناسب فقط نقطه‌ی شروع اجراست، نه نقطه‌ی پایان.** پس از رسیدن به case، برنامه به اجرای دستورها ادامه می‌دهد و از case بعدی هم رد می‌شود، مگر این‌که به دستور break برسد. این رفتار را Fall-Through (سقوط به بلوک بعدی) می‌نامند. دستور break کنترل را از switch خارج می‌کند.

Writing src/p04_c08_19_switch_fallthrough.c
--- without break ---
B C D 
--- with break ---
B 

مقدار level برابر ۲ است. در نسخه‌ی اول، پرش به case 2 انجام می‌شود، اما چون break نداریم، B و بعد از آن C و حتی D (بلوک default) هم چاپ می‌شوند. در نسخه‌ی دوم، break بعد از B برنامه را از switch بیرون می‌برد. تفاوت را در انیمیشن‌های زیر خط‌به‌خط دنبال کنید:

Loading...
Loading...

Fall-Through همیشه باگ نیست؛ گاهی عمداً از آن استفاده می‌کنیم (مثلاً وقتی چند case باید یک کار مشترک کنند، در مثال C پایین). به همین دلیل زبان C آن را ممنوع نکرده است. اما چون فراموش کردن break یکی از رایج‌ترین خطاهاست، قرارداد خوبی است که اگر عمداً break نمی‌گذارید، با یک توضیح (/* fall through */) آن را مشخص کنید.

Example B - Real: ماشین‌حساب
Writing src/p04_c08_20_calculator.c
8 + 2 = 10
8 - 12 = -4
6 * 7 = 42
17 / 5 = 3 (remainder 2)
Error: division by zero
Error: unknown operator '^'

چرا شاخه‌ی / یک if داخلی دارد؟
switch فقط «کدام عملگر» را مشخص می‌کند؛ اما «آیا تقسیم امن است؟» یک تصمیم دیگر است و باید با ساختار شرطی دیگری گرفته شود. ساختارهای شرطی را می‌توان آزادانه داخل هم گذاشت.

چرا ورودی 8 ^ 2 به default می‌رود؟
چون '^' با هیچ‌یک از caseها برابر نیست. default همان جایی است که ورودی‌های پیش‌بینی‌نشده را با یک پیام روشن مدیریت می‌کنیم؛ بدون آن، برنامه در این حالت بی‌صدا هیچ کاری نمی‌کرد.

Example C - Tricky: ترکیب چند case (و محدودیت بازه)

فرض کنید می‌خواهیم بگوییم یک کاراکتر «حرف صدادار» است یا «بی‌صدا». پنج حرف صدادار داریم (در دو حالت کوچک و بزرگ) که همگی یک پاسخ مشترک دارند. با Fall-Through عمدی، چند case پشت‌سرهم را به یک بلوک وصل می‌کنیم. اما switch نمی‌تواند بپرسد «آیا اصلاً حرف الفبا است؟» (چون این یک بازه است)؛ آن را با if قبل از switch بررسی می‌کنیم:

Writing src/p04_c08_21_switch_vowel.c
'e': vowel
'K': consonant
'U': vowel
'7': not a letter

این مثال یک تقسیم کار روشن را نشان می‌دهد: **بازه‌ها را if مدیریت می‌کند، مقادیر گسسته را switch.** اگر همین مسئله را با else if می‌نوشتیم، شرطی با ده مقایسه‌ی || پشت‌سرهم لازم بود.

Example D - Real: تعداد روزهای ماه (ترکیب با الگوریتم کبیسه)

این مسئله ترکیب طبیعی دو الگوریتم قبلی است: ماه‌ها در سه گروه قرار می‌گیرند (۳۰ روزه، ۳۱ روزه، و فوریه). فوریه خودش یک تصمیم است (کبیسه بودن سال) که همان الگوریتم ۸.۴ را اجرا می‌کند.

Writing src/p04_c08_22_days_in_month.c
Month 2 of year 2024 has 29 days
Month 2 of year 1900 has 28 days
Month 4 of year 2023 has 30 days
Month 12 of year 2023 has 31 days
Invalid month: 13

به ترکیب سه ایده دقت کنید: (۱) گروه‌بندی caseها برای مقادیر با پاسخ مشترک، (۲) تصمیم تو در تو در داخل یک case، (۳) default برای ورودی نامعتبر که با return برنامه را فوراً تمام می‌کند. همچنین مقدار اولیه‌ی days = 0 تضمین می‌کند که متغیر در هیچ مسیری ناخواسته مقداردهی‌نشده باقی نماند.

Example E - Trick: تبدیل بازه به مقدار گسسته

گفتیم switch بازه را نمی‌فهمد. اما یک ترفند الگوریتمی وجود دارد: بازه را با یک محاسبه‌ی ساده به یک مقدار گسسته تبدیل کنیم. در سیستم نمره‌دهی ۱۰۰تایی، نمره‌ها را بر ۱۰ تقسیم صحیح می‌کنیم؛ همه‌ی نمره‌های ۹۰ تا ۹۹ می‌شوند ۹، همه‌ی ۸۰ تا ۸۹ می‌شوند ۸ و ...

Writing src/p04_c08_23_switch_score_div10.c
100 -> A
95 -> A
90 -> A
89 -> B
70 -> C
60 -> D
59 -> F
0 -> F
105: invalid score
-5: invalid score

چرا case 10 هم لازم است؟
چون 100 / 10 برابر 10 است و جزو بازه‌ی A محسوب می‌شود. این یک مورد مرزی است که فقط با تست 100 پیدا می‌شود.

چرا اعتبارسنجی بازه پیش از switch ضروری است؟
چون 105 / 10 برابر 10 است و بدون اعتبارسنجی «A» می‌گرفت؛ و -5 / 10 در C برابر 0 است (گرد‌کردن به سمت صفر) که به شاخه‌ی default می‌رفت و «F» می‌داد. ترفند «تقسیم بر ۱۰» فقط وقتی امن است که ورودی از قبل در بازه‌ی مورد انتظار باشد.

switch یا else if؟
معیارswitchزنجیره‌ی else if
نوع شرطفقط تساوی با ثابت‌های صحیحهر شرط دلخواه (بازه، ترکیب منطقی، اعشاری و ...)
تکرار عبارت مورد مقایسهیک‌بار نوشته می‌شوددر هر شرط تکرار می‌شود
ترتیب caseهامعمولاً بی‌اثر (چون ثابت‌ها منحصربه‌فردند)حیاتی (اولین شرط درست برنده است)
Fall-Throughوجود دارد (خطر و ابزار)وجود ندارد
مناسب برایمنو، دستور، کد وضعیت، روز و ماهبازه‌ها، شرط‌های مرکب، داده‌ی اعشاری

نکته‌ی مهم: انتخاب بین این دو، اول از همه یک تصمیم درباره‌ی خوانایی است. ممکن است کامپایلر switch را بهینه‌تر ترجمه کند، اما روی برنامه‌های درسی تفاوت سرعت قابل‌توجهی ایجاد نمی‌شود.

تمرین درون فصل ۸.۹

نوع تمرین: Predict the Output

int x = 3;
switch (x) {
    case 1: printf("one ");
    case 3: printf("three ");
    case 4: printf("four "); break;
    case 5: printf("five ");
}
printf("end\n");

خروجی را پیش‌بینی کنید. سپس بگویید اگر x = 5 باشد خروجی چه می‌شود.

تمرین درون فصل ۸.۱۰ : switch روی مقدار اعشاری

نوع تمرین: Explain Why

چرا کد زیر در C خطای کامپایل می‌دهد؟ چه ساختار جایگزینی را پیشنهاد می‌کنید؟

double price = 9.5;
switch (price) {
    case 9.5:
        printf("Cheap\n");
        break;
}

تمرین‌های پایانی زیربخش: switch

★ سطح پایه

  1. Small Implementation:
    برنامه‌ای بنویسید که شماره‌ی ماه (۱ تا ۱۲) را بگیرد و نام فصل (بهار، تابستان، پاییز، زمستان) را با Fall-Through عمدی چاپ کند.

  2. Predict the Output:

int n = 2;
switch (n) {
    case 1: printf("A"); break;
    case 2: printf("B");
    case 3: printf("C"); break;
    default: printf("D");
}

★★ سطح متوسط

  1. Find the Bug:

char op = '+';
switch (op) {
    case '+':
        printf("add\n");
    case '-':
        printf("subtract\n");
        break;
}

خروجی چیست و چرا این‌طور است؟ اصلاح کنید.

  1. Modify the Algorithm:
    برنامه‌ی ماشین‌حساب را طوری تغییر دهید که علاوه بر چهار عمل اصلی، عمل‌گر % (باقی‌مانده) را هم پشتیبانی کند و برای % هم از تقسیم بر صفر محافظت کند.

★★★ سطح چالشی

  1. Design:
    برنامه‌ای بنویسید که نمره‌ی ۰ تا ۲۰ را بگیرد و با ترفند «تقسیم صحیح»، درجه‌بندی ۴تایی (۰ تا ۹ مردود، ۱۰ تا ۱۳ قبول، ۱۴ تا ۱۶ خوب، ۱۷ تا ۲۰ عالی) را فقط با switch انجام دهد. چه محاسبه‌ای مرزهای نامنظم این بازه‌ها را به مقدارهای گسسته تبدیل می‌کند؟ (راهنمایی: ممکن است ترکیب Fall-Through با تعداد زیادی case ساده‌تر از یک فرمول باشد.)

  2. Compare Approaches:
    دو نسخه‌ی switch و else if از «نمایش نام روز هفته» را مقایسه کنید. در چه موقعیتی نسخه‌ی else if کاملاً غیرقابل جایگزینی با switch است؟

8.9 انتخاب ساختار مناسب

حالا که پنج ابزار شرطی در اختیار داریم، سؤال طبیعی این است: برای هر مسئله کدام را انتخاب کنیم؟ جدول زیر یک راهنمای تصمیم‌گیری است:

نوع مسئلهساختار پیشنهادی
یک کار اضافه که فقط در صورت برقراری شرط انجام می‌شودif
دو مسیر متقابل (دقیقاً یکی اجرا شود)if-else
چند بازه یا شرط مرکب که دسته‌های ناسازگار تولید می‌کنندزنجیره‌ی else if
فقط انتخاب یک مقدار بر اساس شرط?:
مقایسه‌ی یک عبارت صحیح با چند مقدار ثابت گسستهswitch
چند شرط مستقل که ممکن است هم‌زمان درست باشندچند if جداگانه (نه زنجیره!)

سطر آخر جدول مهم‌ترین و پنهان‌ترین تفاوت است. فرض کنید در یک فروشگاه، مشتری «عضو باشگاه» ۵٪ تخفیف و سفارش «بالای ۱۰۰۰» ۱۰٪ تخفیف می‌گیرد؛ و هر دو تخفیف قابل جمع هستند. اگر این دو شرط را با else if به هم بچسبانیم، فقط یکی اعمال می‌شود:

Writing src/p04_c08_24_independent_vs_chain.c
total=1200 member=1 | independent: 15% | chained: 5%
total=300 member=1 | independent: 5% | chained: 5%
total=1200 member=0 | independent: 10% | chained: 10%
total=300 member=0 | independent: 0% | chained: 0%

فقط در حالت اول (1200 1، یعنی مشتری عضو و سفارش بزرگ) دو نسخه تفاوت دارند: نسخه‌ی مستقل ۱۵٪ و نسخه‌ی زنجیره‌ای فقط ۵٪. سؤالی که باید همیشه بپرسید: «آیا این شرط‌ها متقابل (فقط یکی می‌تواند درست باشد) هستند، یا مستقل؟» پاسخ، انتخاب بین else if و چند if را تعیین می‌کند.

تمرین درون فصل ۸.۱۱

نوع تمرین: Choose the Structure

برای هر سناریوی زیر مشخص کنید کدام ساختار مناسب‌تر است (if ، if-else ، else if ، ?: ، switch ، یا چند if مستقل) و دلیل بیاورید:

  1. تبدیل شماره‌ی ماه (۱ تا ۱۲) به نام فصل.

  2. محاسبه‌ی هزینه‌ی ارسال: ۲۰ هزار تومان برای وزن زیر ۱ کیلو، ۳۵ هزار برای ۱ تا ۵ کیلو، ۶۰ هزار برای بالاتر.

  3. چاپ هشدار «باتری ضعیف» و هشدار «حافظه‌ی پر» که ممکن است هم‌زمان رخ دهند.

  4. انتخاب کوچک‌تر از دو عدد برای نمایش.

  5. اجرای دستور کاربر از منوی «۱ ثبت‌نام، ۲ ورود، ۳ خروج».

8.10 خطاهای رایج در ساختارهای شرطی

**❌ اشتباه ۱: استفاده از = به‌جای ==**

if (x = 5) {
    printf("x is five\n");
}

چرا اشتباه است؟
x = 5 یک انتساب است: ابتدا مقدار ۵ را در x می‌ریزد و بعد خود مقدار ۵ را به‌عنوان نتیجه‌ی شرط برمی‌گرداند؛ پس شرط همیشه درست است و x هم بی‌سروصدا خراب می‌شود. کامپایلر معمولاً هشدار می‌دهد، پس هشدارها را (با -Wall) جدی بگیرید.

✅ نسخه صحیح

if (x == 5) {
    printf("x is five\n");
}

❌ اشتباه ۲: نقطه‌ویرگول بعد از شرط

if (score >= 18);
{
    printf("Excellent\n");
}

چرا اشتباه است؟
; یک «دستور خالی» است و بدنه‌ی if همان می‌شود. بلوک بعدی یک بلوک معمولی و مستقل است که همیشه اجرا می‌شود.

✅ نسخه صحیح: ; را حذف کنید.

❌ اشتباه ۳: مقایسه‌ی زنجیره‌ای

if (0 <= score <= 20) { ... }       // درست به نظر می‌رسد، اما نیست

چرا اشتباه است؟
0 <= score نتیجه‌اش 0 یا 1 است و بعد آن عدد با 20 مقایسه می‌شود؛ پس عبارت همیشه درست است.

✅ نسخه صحیح

if (score >= 0 && score <= 20) { ... }

❌ اشتباه ۴: مقایسه‌ی دقیق اعداد اعشاری

if (0.1 + 0.2 == 0.3) { ... }       // نادرست است!

چرا اشتباه است؟
اعداد اعشاری در حافظه به‌صورت تقریبی ذخیره می‌شوند و 0.1 + 0.2 دقیقاً برابر 0.3 نیست.

✅ نسخه صحیح: فاصله‌ی دو عدد را با یک تلورانس (مثلاً 1e-9) مقایسه کنید: diff < 1e-9، که diff قدر مطلق تفاضل دو عدد است.

**❌ اشتباه ۵: x == 1 || 2**

if (x == 1 || 2) { ... }            // همیشه درست است

چرا اشتباه است؟
این عبارت (x == 1) || 2 خوانده می‌شود و 2 غیرصفر است و پس عبارت **برای هر x** درست است. مفهوم انسانی «x برابر ۱ یا ۲ باشد» باید کامل نوشته شود.

✅ نسخه صحیح

if (x == 1 || x == 2) { ... }

**❌ اشتباه ۶: فراموش کردن break در switch (در ۸.۸ دیدیم)، و ❌ اشتباه ۷: ترتیب غلط در زنجیره‌ی else if** (در ۸.۵ دیدیم).

برای دیدن چهار مورد اول در عمل، برنامه‌ی زیر را اجرا کنید و به هشدارهای کامپایلر (قبل از خروجی) هم دقت کنید:

Writing src/p04_c08_25_pitfalls.c
src/p04_c08_25_pitfalls.c: In function ‘main’:
src/p04_c08_25_pitfalls.c:8:9: warning: suggest parentheses around assignment used as truth value []8;;https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html#index-Wparentheses-Wparentheses]8;;]
    8 |     if (x = 5) {
      |         ^
src/p04_c08_25_pitfalls.c:13:15: warning: comparisons like ‘X<=Y<=Z’ do not have their mathematical meaning []8;;https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html#index-Wparentheses-Wparentheses]8;;]
   13 |     if (3 > 2 > 1) {
      |               ^
1) entered the if, and x is now 5
2) 3 > 2 > 1 is FALSE (surprise!)
3) 0.1 + 0.2 != 0.3 (sum = 0.30000000000000004)
4) y == 1 || 2 is true even for y = 7

(اشتباه ۲، یعنی نقطه‌ویرگول بعد از if، به‌خاطر ایجاد بلوک خالی در برنامه‌ی بالا لحاظ نشده است؛ آن را در تمرین‌ها خودتان امتحان کنید.)

8.11 کاربردهای واقعی ساختارهای شرطی

  • اعتبارسنجی ورودی کاربر (فرم‌ها، رمزها، سن، مبلغ) پیش از پردازش.

  • کنترل دسترسی و سطح مجوز در سیستم‌ها (مدیر / کاربر عادی / مهمان).

  • منوها و دستورهای واسط کاربری (switch روی کلید یا انتخاب).

  • کدهای خطا و وضعیت در سیستم‌عامل و پروتکل‌ها (تبدیل کد عددی به معنی).

  • محاسبه‌ی مالیات، تخفیف، تعرفه و هزینه‌ی پله‌ای.

  • بازی‌ها: تصمیم‌گیری شخصیت‌ها، تشخیص برخورد، قوانین امتیازدهی.

  • سیستم‌های کنترلی و حسگرها: روشن/خاموش شدن بر اساس آستانه‌ی دما یا فشار.

  • ...

تمرین‌های پایانی فصل ۸

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

  1. Predict the Output:

int a = 5, b = 10;
if (a > 3 && b < 5 || a == 5) {
    printf("X\n");
} else {
    printf("Y\n");
}

خروجی را پیش‌بینی کنید و جای پرانتزهای «ضمنی» را با رعایت اولویت عمل‌گرها نشان دهید.

  1. Trace:
    برای ورودی 15 در برنامه‌ی درجه‌بندی ۲۰تایی، و سپس ورودی 100 در برنامه‌ی درجه‌بندی ۱۰۰تایی (switch)، مسیر اجرا را دنبال کنید و نتیجه را بنویسید.

  2. Find the Bug:

if (n % 2 == 0)
    printf("even\n");
    printf("divisible by two\n");
else
    printf("odd\n");

این کد اصلاً کامپایل نمی‌شود. دلیل را توضیح دهید و راه اصلاح بدهید.

  1. Edge Case Reasoning:
    یک برنامه‌ی تشخیص «سال کبیسه» را با ورودی‌های 0، -4، 4 و 100 در ذهن اجرا کنید. آیا الگوریتم برای سال‌های ≤ ۰ معنای تقویمی دارد؟ این یک مسئله‌ی الگوریتمی است یا مسئله‌ی داده‌ی ورودی؟

  2. Design + Implementation:
    برنامه‌ای بنویسید که ساعت (۰ تا ۲۳) را بخواند و پیام مناسب را چاپ کند: «صبح بخیر» (۵ تا ۱۱)، «ظهر بخیر» (۱۲ تا ۱۳)، «عصر بخیر» (۱۴ تا ۱۸)، «شب بخیر» (بقیه‌ی ساعت‌های معتبر) و برای ورودی خارج از ۰ تا ۲۳ پیام خطا. مرزها را تست کنید.

  3. Refactoring:
    برنامه‌ی خودپرداز نسخه‌ی تو در تو را با ایده‌ی Guard Clause و return زودهنگام (بدون else if) بازنویسی کنید.

  4. Compare Approaches:
    مسئله‌ی «تشخیص سال کبیسه» را (الف) با یک عبارت منطقی، (ب) با زنجیره‌ی else if، (ج) با if تو در تو بنویسید و از نظر خوانایی و احتمال خطا مقایسه کنید.

  5. Algorithm Design:
    سه عدد صحیح a, b, c را به ترتیب صعودی مرتب چاپ کنید (بدون آرایه و فقط با if). حداقل چند مقایسه لازم است؟ آیا یک ساختار مرتب‌سازی هست که برای هر ورودی دقیقاً همین تعداد مقایسه را انجام دهد؟

  6. Refactoring:
    عبارت زیر را با استفاده از قوانین دمورگان ساده کنید و ثابت کنید با عبارت اصلی معادل است:

if (!(a > 0 && b > 0) && !(c < 0 || d < 0)) { ... }

Retrieval Practice فصل ۸

  • هنگام ارزیابی A && B، چه وقت B اصلاً ارزیابی نمی‌شود؟

  • قوانین دمورگان چه کاربردی در نوشتن شرط «نامعتبر» دارند؟

  • چرا در یک زنجیره‌ی else if، ترتیب شرط‌ها بخشی از منطق برنامه است؟

  • تست مرزی (Boundary Test) یعنی چه و برای شرط score >= 14 چه ورودی‌هایی را شامل می‌شود؟

  • Guard Clause چیست و چه مشکلی از کد تو در تو را حل می‌کند؟

  • تفاوت «دستور» و «عبارت» در if و ?: چیست؟

  • Fall-Through چیست؟ یک مورد که مفید و یک مورد که خطرناک است نام ببرید.

  • چرا switch نمی‌تواند شرط score >= 17 را مستقیماً بسنجد؟



8.12 جمع‌بندی فصل ۸

در این فصل یاد گرفتیم چرا اجرای ترتیبی برای مسئله‌های واقعی کافی نیست و شرط چگونه به برنامه امکان انتخاب بین چند مسیر را می‌دهد. دیدیم که در C مقدار صفر «نادرست» و هر مقدار غیرصفر «درست» است، که && و || به‌صورت کوتاه‌مدار ارزیابی می‌شوند و قوانین دمورگان ابزار ساختن نقیض شرط‌ها هستند. سپس if، if-else و زنجیره‌ی else if را بررسی کردیم و به این نتیجه رسیدیم که «اولین شرط درست برنده است» و ترتیب شرط‌ها بخشی از الگوریتم است. با تست مرزی و پوشش مسیرها یاد گرفتیم درستی شرط‌ها را به‌صورت سیستماتیک بیازماییم. دیدیم که عمل‌گر ?: برای انتخاب مقدار مناسب است و switch برای مقایسه‌ی یک عبارت با ثابت‌های گسسته، با نقش حیاتی break و پدیده‌ی Fall-Through. در نهایت یاد گرفتیم بین «شرط‌های متقابل» (زنجیره) و «شرط‌های مستقل» (چند if) تمایز بگذاریم.

حالا که می‌توانیم تصمیم بگیریم، سؤال طبیعی بعدی این است: چطور یک تصمیم یا یک محاسبه را چندین بار تکرار کنیم؟ مثلاً چطور همین درجه‌بندی را برای نمره‌ی ۵۰۰ دانشجو انجام دهیم، بدون این‌که ۵۰۰ بار کد را کپی کنیم؟ این موضوع فصل بعد است.



فصل ۹ : حلقه‌ها (Loops)

9.1 چرا به حلقه نیاز داریم؟

فرض کنید می‌خواهیم اعداد ۱ تا ۵ را چاپ کنیم و مجموعشان را حساب کنیم. با دانش فعلی‌مان می‌نویسیم:

Writing src/p04_c09_01_loops_naive.c
1
2
3
4
5
Sum: 15

برای ۵ عدد قابل تحمل است. اما چند سؤال از خودتان بپرسید:

  • اگر عدد ۱ تا ۱۰۰۰ بود، باید ۲۰۰۰ خط کد می‌نوشتیم؟

  • اگر تعداد اعداد را کاربر تعیین کند (یعنی در زمان نوشتن برنامه معلوم نباشد)، چند خط باید نوشت؟

  • کد بالا ده خط تقریباً یکسان دارد؛ اگر در یکی از آن‌ها اشتباه کنیم، چطور آن را پیدا کنیم؟

مشکل اصلی: کدی که تعداد دفعات اجرایش در زمان نوشتن ثابت (و در عمل محدود به صبر ما) است، نمی‌تواند با اندازه‌ی مسئله رشد کند. ما به سازه‌ای نیاز داریم که:

  1. یک دسته دستور را بارها اجرا کند، بدون این‌که آن را کپی کنیم.

  2. تعداد تکرار را بر اساس داده‌ی زمان اجرا (یک شرط) تعیین کند، نه بر اساس تعداد خطوط برنامه.

  3. در هر تکرار، وضعیت را کمی تغییر دهد تا بالاخره به پایان برسد.

این دقیقاً همان چیزی است که حلقه (Loop) فراهم می‌کند.

آناتومی یک حلقه

هر حلقه‌ی درست از چهار بخش ساخته می‌شود. تشخیص این چهار بخش، مهم‌ترین مهارت این فصل است:

بخشپرسشمثال (چاپ ۱ تا ۵)
مقداردهی اولیه (Initialization)حلقه از کجا شروع می‌شود؟i = 1
شرط ادامه (Condition)تا کی تکرار کنیم؟i <= 5
بدنه (Body)در هر تکرار چه کار کنیم؟printf, sum += i
به‌روزرسانی (Update)چطور به پایان نزدیک شویم؟i++

اگر یکی از این چهار بخش غلط یا فراموش شود، حلقه یا اصلاً اجرا نمی‌شود، یا کار اشتباه می‌کند، یا هرگز تمام نمی‌شود. همچنین به یک ایده‌ی ذهنی عمیق عادت کنید: نامتغیر حلقه (Loop Invariant)، یعنی چیزی که در ابتدای هر تکرار درست است. مثلاً در حلقه‌ی جمع ۱ تا n، قبل از هر تکرار می‌دانیم «sum مجموع همه‌ی اعداد کوچک‌تر از i است». درستی الگوریتم یعنی این جمله از اول تا آخر برقرار بماند.

تمرین درون فصل ۹.۱

نوع تمرین: Quick Reasoning

در برنامه‌ی loops_naive تعیین کنید «مقداردهی اولیه»، «شرط ادامه» و «به‌روزرسانی» چه نقشی دارند، اگر بخواهیم آن را به یک حلقه تبدیل کنیم. چه چیزی در هر تکرار ثابت می‌ماند و چه چیزی تغییر می‌کند؟

9.2 حلقه‌ی while

ایده الگوریتم

while ساده‌ترین حلقه است: «تا وقتی شرط برقرار است، بدنه را تکرار کن». شرط قبل از هر تکرار بررسی می‌شود؛ پس اگر از همان ابتدا نادرست باشد، بدنه حتی یک‌بار هم اجرا نمی‌شود (Pre-test Loop).

Pseudocode
initialise state
while condition:
    body
    update state        // must move the state toward making the condition false

Flowchart
Example A - Simple: شمارش معکوس و مجموع ۱ تا n
Writing src/p04_c09_02_while_countdown.c
5 4 3 2 1 Liftoff!
Writing src/p04_c09_03_while_sum.c
n = 5: loop sum = 15, formula n(n+1)/2 = 15
n = 100: loop sum = 5050, formula n(n+1)/2 = 5050
n = 1: loop sum = 1, formula n(n+1)/2 = 1
n = 0: loop sum = 0, formula n(n+1)/2 = 0

چرا ورودی 0 جواب 0 می‌دهد و برنامه خراب نمی‌شود؟
چون شرط i <= n از همان ابتدا نادرست است و بدنه اصلاً اجرا نمی‌شود (صفر تکرار). مجموع هیچ عددی، همان مقدار اولیه‌ی sum یعنی 0 است. این رفتار بدون نیاز به if اضافه، حالت‌های مرزی را درست پوشش می‌دهد.

چرا فرمول n(n+1)/2 هم کنار حلقه چاپ شده است؟
یک درس الگوریتمی مهم: گاهی یک فرمول بسته همان نتیجه را بدون حلقه می‌دهد. حلقه‌ی بالا n جمع انجام می‌دهد (زمان متناسب با n)، اما فرمول فقط چند عمل (زمان ثابت). پیش از نوشتن حلقه بپرسید: «آیا راه ریاضی مستقیمی هست؟» با این حال حلقه عمومی‌تر است و هرجا فرمول نداریم کار می‌کند. نکته‌ی فنی: long long به کار رفته تا جمع‌های بزرگ سرریز نکنند.

Trace: جدول مقادیر برای مجموع ۱ تا ۴
Loading...
Example B - Real: جمع ارقام یک عدد

مسئله: مجموع ارقام عدد n را حساب کنید (مثلاً 472 ← 4+7+2 = 13). ایده‌ی الگوریتم: رقم آخر هر عدد را می‌توان با n % 10 جدا کرد و با n / 10 آن رقم را حذف کرد. پس تا وقتی عدد صفر نشده، رقم آخر را بردار، به مجموع اضافه کن و عدد را کوتاه کن. توجه: تعداد تکرارها از قبل معلوم نیست (به تعداد ارقام وابسته است)؛ این یک حلقه‌ی کلاسیک while است.

Pseudocode
sum ← 0, count ← 0
while n > 0:
    sum ← sum + (n mod 10)
    n ← n div 10
    count ← count + 1

Writing src/p04_c09_04_digit_sum.c
472: digit sum = 13, digit count = 3
999: digit sum = 27, digit count = 3
7: digit sum = 7, digit count = 1
1000: digit sum = 1, digit count = 4
0: digit sum = 0, digit count = 0
Loading...

چرا ورودی 0 ارقام را صفر می‌شمارد؟
چون شرط n > 0 برای 0 از ابتدا نادرست است و بدنه اجرا نمی‌شود: sum = 0 درست است، اما count = 0 از نظر معنایی غلط است (عدد 0 یک رقم دارد). این یک باگ مرزی واقعی است. راه‌حل در بخش بعدی (do-while) می‌آید.

Example C - Tricky: الگوی Sentinel (حلقه با مقدار نگهبان)

گاهی تعداد داده از قبل معلوم نیست و کاربر «پایان داده‌ها» را با یک مقدار ویژه اعلام می‌کند؛ این مقدار را Sentinel می‌نامیم (اینجا -1 که نمره‌ی معتبر نیست). scanf عدد برگشتی دارد: تعداد مقادیر خوانده‌شده‌ی موفق؛ پس scanf(...) == 1 یعنی ورودی واقعاً خوانده شد (و در پایان ورودی، حلقه خودبه‌خود تمام می‌شود).

Writing src/p04_c09_05_while_sentinel_avg.c
count = 5, sum = 82, average = 16.40
No scores entered
count = 1, sum = 14, average = 14.00

چرا قبل از تقسیم، count == 0 را بررسی می‌کنیم؟
اگر کاربر بلافاصله -1 بزند، count صفر است و تقسیم sum / count یک تقسیم بر صفر می‌شد. هر الگوریتم «میانگین‌گیر» باید حالت «ورودی خالی» را پیش‌بینی کند؛ این یک Edge Case کلاسیک است.

ورودی سوم (14 بدون -1) چرا درست کار می‌کند؟
چون با تمام شدن ورودی، scanf مقدار 1 برنمی‌گرداند و شرط حلقه نادرست می‌شود. ترکیب scanf(...) == 1 && x != -1 هم پایان داده‌ها (EOF) و هم Sentinel را پوشش می‌دهد؛ و به لطف ارزیابی کوتاه‌مدار، x فقط وقتی با -1 مقایسه می‌شود که واقعاً مقدار گرفته باشد.

تمرین درون فصل ۹.۲

نوع تمرین: Trace

برای n = 305 الگوریتم جمع ارقام را دستی دنبال کنید و جدولی با ستون‌های n ، n % 10 ، sum بعد از هر تکرار بسازید. توجه کنید چرا رقم صفر وسط عدد مشکلی ایجاد نمی‌کند.

تمرین‌های پایانی زیربخش: while

★ سطح پایه

  1. Predict the Output:

int i = 3;
while (i > 0) {
    printf("%d ", i * i);
    i--;
}

  1. Small Implementation:
    برنامه‌ای بنویسید که با while همه‌ی مضرب‌های ۳ از ۳ تا ۳۰ را چاپ کند.

★★ سطح متوسط

  1. Find the Bug:

int i = 1;
while (i <= 5) {
    printf("%d ", i);
}

این حلقه چه رفتاری دارد و کدام بخش از چهار بخش آناتومی حلقه فراموش شده است؟

  1. Small Implementation:
    برنامه‌ای بنویسید که عدد صحیح مثبت n را بگیرد و معکوس آن را چاپ کند (مثلاً 1234 ← 4321). (راهنمایی: rev = rev * 10 + n % 10.)

  2. Trace:
    برای ورودی‌های 5 -2 8 -1 در الگوریتم میانگین با Sentinel، مقدار count و sum را بعد از هر تکرار بنویسید. آیا عدد منفی -2 هم جزو داده‌ها حساب می‌شود؟ این رفتار مطلوب است؟

★★★ سطح چالشی

  1. Design:
    الگوریتمی بنویسید که تعداد دفعاتی که عدد n را می‌توان بر ۲ تقسیم صحیح کرد تا به ۱ برسد (یعنی ⌊log₂ n⌋) را بشمارد. برای n = 1000 و n = 1 تست کنید.

  2. Explain Why:
    چرا در حلقه‌ی جمع ارقام، متغیر n تغییر می‌کند و ما مقدار اصلی را در original کپی کرده‌ایم؟ اگر کپی نمی‌کردیم چه مشکلی پیش می‌آمد؟

9.3 حلقه‌ی do-while

ایده الگوریتم

گاهی بدنه باید حداقل یک‌بار اجرا شود و فقط بعد از آن بپرسیم آیا ادامه بدهیم. مثال کلاسیک: گرفتن ورودی معتبر از کاربر. قبل از این‌که بتوانیم «معتبر بودن» را بسنجیم، باید یک مقدار گرفته باشیم. do-while شرط را بعد از هر تکرار بررسی می‌کند (Post-test Loop).

Pseudocode
do:
    body
while condition        // checked AFTER the body, so the body runs at least once

Flowchart
Example A - Simple: رفع باگ شمارش ارقام
Writing src/p04_c09_06_do_while_digits.c
0 has 1 digit(s)
7 has 1 digit(s)
472 has 3 digit(s)
1000 has 4 digit(s)

حالا 0 یک رقم دارد (درست). دلیل: بدنه حتماً یک‌بار اجرا می‌شود (count = 1)، و بعد n که صفر شده شرط را نادرست می‌کند. بقیه‌ی ورودی‌ها هم مثل قبل درست‌اند. این نمونه نشان می‌دهد انتخاب نوع حلقه فقط سلیقه نیست؛ گاهی درستی الگوریتم را تعیین می‌کند.

Example B - Real: اعتبارسنجی با تکرار تا ورودی معتبر
Writing src/p04_c09_07_do_while_validation.c
Enter score (0-20): Invalid: 25
Enter score (0-20): Invalid: -3
Enter score (0-20): Accepted: 17
Enter score (0-20): Accepted: 12
Enter score (0-20): Invalid: 99
Enter score (0-20): 
No more input

الگوی «بخوان ← بررسی کن ← اگر نامعتبر بود تکرار کن» دقیقاً شکل do-while است. با while باید قبل از حلقه یک بار ورودی را می‌خواندیم (و یک بار هم داخل آن)، یعنی کد خواندن دو جا تکرار می‌شد. نکته‌ی احتیاط: اگر ورودی به پایان برسد (scanf != 1)، حلقه به‌دلیل نبود داده تا ابد تکرار می‌شد؛ پس این حالت جدا مدیریت شده است (ورودی سوم، 99 فقط یک مقدار نامعتبر دارد و بعد ورودی تمام می‌شود).

مقایسه‌ی while و do-while
معیارwhiledo-while
زمان بررسی شرطقبل از بدنهبعد از بدنه
حداقل تعداد اجرای بدنهصفریک
مناسب برایداده‌ای که ممکن است خالی باشد«یک‌بار انجام بده، بعد بسنج» (ورودی، منو)
نقطه‌ویرگولنداردبعد از while(...) الزامی است

تمرین درون فصل ۹.۳

نوع تمرین: Predict the Output

int i = 10;
do {
    printf("%d ", i);
    i++;
} while (i < 5);

خروجی چیست؟ اگر همین را با while بنویسیم خروجی چه می‌شود؟

تمرین‌های پایانی زیربخش: do-while

★ سطح پایه

  1. Predict the Output: برنامه‌ی بالا (در تمرین درون‌فصل) را برای i = 1 دوباره پیش‌بینی کنید.

  2. Small Implementation:
    برنامه‌ای بنویسید که با do-while عددی را بخواند تا زمانی که مثبت باشد.

★★ سطح متوسط

  1. Find the Bug:

do {
    scanf("%d", &x);
} while (x < 0)
printf("ok\n");

این کد چرا کامپایل نمی‌شود؟

  1. Choose the Algorithm:
    برای هر مورد مشخص کنید while بهتر است یا do-while: (الف) خواندن اعداد تا رسیدن به پایان فایل، (ب) نمایش منو و گرفتن انتخاب، (ج) شمارش ارقام یک عدد.

★★★ سطح چالشی

  1. Design:
    یک بازی «حدس عدد» طراحی کنید: عدد مخفی ثابت (مثلاً ۴۲) است و برنامه تا وقتی حدس‌ها (از ورودی) غلط‌اند «Higher» یا «Lower» چاپ می‌کند و در پایان تعداد حدس‌ها را نشان می‌دهد.

9.4 حلقه‌ی for

ایده الگوریتم

در بسیاری از حلقه‌ها، چهار بخش آناتومی (مقداردهی، شرط، بدنه، به‌روزرسانی) به یک شمارنده مربوط‌اند. for این سه بخش (به‌جز بدنه) را در یک خط کنار هم می‌نویسد تا نگاه یک‌جا به «محدوده‌ی حلقه» ممکن شود:

for (مقداردهی ; شرط ; به‌روزرسانی) {
    بدنه
}

Pseudocode
for i from start to end step s:
    body

Flowchart

ترتیب اجرا بسیار مهم است: مقداردهی (۱ بار) ← شرط ← بدنه ← به‌روزرسانی ← شرط ← بدنه ← ... ← شرط (نادرست) ← خروج. به‌روزرسانی بعد از بدنه اجرا می‌شود، با این‌که در خط اول نوشته شده است:

Loading...
Example A - Simple: سه الگوی رایج شمارش
Writing src/p04_c09_08_for_basic.c
Up:    1 2 3 4 5 
Down:  5 4 3 2 1 
Step 2: 0 2 4 6 8 10 
Two variables: (0,6) (1,5) (2,4) 

چرا int i داخل پرانتز for تعریف شده است؟
از C99 به بعد می‌توان متغیر شمارنده را داخل خود for تعریف کرد. دامنه‌ی چنین متغیری فقط همان حلقه است؛ یعنی بیرون از حلقه وجود ندارد و نمی‌توان از آن اشتباهاً استفاده کرد. همین کار باعث می‌شود در سه حلقه‌ی پشت‌سرهم بتوانیم هر بار نام i را استفاده کنیم.

چه چیز در حلقه‌ی دوم متفاوت است؟
هر سه بخش عوض شده‌اند: شروع از ۵، شرط >= 1 و به‌روزرسانی i--. جهت شرط باید با جهت به‌روزرسانی هماهنگ باشد؛ اگر در حلقه‌ی شمارش معکوس شرط i <= 5 را نگه دارید، همیشه درست می‌ماند.

تعداد تکرارها: خطای یکی‌کم‌یکی‌زیاد (Off-by-One)

برای حلقه‌ای که i را از a تا b با گام ۱ می‌شمارد:

  • اگر شرط i <= b باشد (بازه‌ی بسته): تعداد تکرارها b - a + 1 است.

  • اگر شرط i < b باشد (بازه‌ی نیمه‌باز): تعداد تکرارها b - a است.

مثلاً for (i = 1; i <= 5; i++) پنج بار و for (i = 0; i < 5; i++) هم پنج بار اجرا می‌شود. بیشترین باگ‌های حلقه از اشتباه بین < و <= یا شروع از ۰ و ۱ می‌آید. عادت خوب: پیش از نوشتن حلقه، تعداد تکرار موردنیاز را بنویسید و با فرمول بالا چک کنید.

Example B - Real: فاکتوریل و محدودیت نوع داده
Writing src/p04_c09_09_for_factorial.c
11! = 39916800     | 32-bit unsigned: 39916800
12! = 479001600    | 32-bit unsigned: 479001600
13! = 6227020800   | 32-bit unsigned: 1932053504
14! = 87178291200  | 32-bit unsigned: 1278945280
15! = 1307674368000 | 32-bit unsigned: 2004310016

الگوریتم ضرب‌های متوالی از ابتدا تا انتها درست است، اما نوع داده ظرفیت محدودی دارد: از 13! به بعد مقدار ۳۲ بیتی با مقدار واقعی فاصله می‌گیرد (چون 13! = 6227020800 از ظرفیت ۳۲ بیت یعنی حدود ۴.۲۹ میلیارد بیشتر است و نتیجه «دور می‌زند»). نوع long long تا 20! درست کار می‌کند. درس مهم: درستی الگوریتم = درستی منطق + کافی بودن نوع داده. (برای ارقام علامت‌دار سرریز رفتار نامشخص دارد و از آن استفاده نکنید؛ در این مثال عمداً از unsigned استفاده شد که دور زدن آن تعریف‌شده است.)

Example C - Tricky: شمارنده‌ی اعشاری
Writing src/p04_c09_10_float_loop.c
after 12 additions: x = 1.19999999999999996, x == 1.0 ? 0
integer counter: reached 1.0 exactly 1 time(s)

با ده بار جمع 0.1 به مقداری می‌رسیم که کمی کمتر از 1.0 است؛ پس شرط x != 1.0 هرگز نادرست نمی‌شود و بدون سقف ایمنی، حلقه بی‌نهایت می‌شد. قاعده‌ی عملی: شمارنده‌ی حلقه را عدد صحیح بگیرید و مقدار اعشاری را از روی آن محاسبه کنید (y = j * 0.1)، یا برای حلقه‌های اعشاری از < و <= به‌جای == و != استفاده کنید.

کدام حلقه؟ راهنمای انتخاب
وضعیتحلقه‌ی مناسب
تعداد تکرار از قبل معلوم است (یا شمارنده داریم)for
تعداد تکرار نامعلوم و بستگی به شرطی دارد؛ ممکن است صفر بار هم اجرا نشودwhile
بدنه باید حداقل یک‌بار اجرا شود (ورودی، منو)do-while

هر سه حلقه از نظر قدرت محاسباتی هم‌ارزند؛ هر for را می‌توان با while نوشت و بالعکس. تفاوت‌شان در صراحت نیت است: وقتی for می‌بینید، می‌دانید با یک شمارنده‌ی مشخص سروکار دارید.

تمرین درون فصل ۹.۴

نوع تمرین: Complete the Code

حلقه‌ی for زیر را با while بازنویسی کنید (چهار بخش آناتومی را مشخص کنید):

for (int i = 10; i > 0; i -= 3) {
    printf("%d ", i);
}

خروجی چیست و بدنه چند بار اجرا می‌شود؟ تعداد را با فرمول مناسب بنویسید.

تمرین‌های پایانی زیربخش: for

★ سطح پایه

  1. Small Implementation:
    با for جدول مربع‌های ۱ تا ۱۰ را چاپ کنید.

  2. Predict the Output:

for (int i = 0; i < 5; i += 2) {
    printf("%d ", i);
}

★★ سطح متوسط

  1. Find the Bug:

int sum = 0;
for (int i = 1; i <= 10; i++);
{
    sum += i;
}

خطا (یا خطاها) را پیدا کنید. چرا این کد حتی ممکن است کامپایل نشود؟

  1. Trace:
    برای for (int i = 1; i < 20; i *= 2) مقدارهای i و تعداد تکرارها را بنویسید. این حلقه از چه مرتبه‌ای است (خطی یا لگاریتمی)؟

  2. Modify the Algorithm:
    برنامه‌ی فاکتوریل را طوری تغییر دهید که اگر n از ۲۰ بیشتر بود، به‌جای سرریز، پیام خطا چاپ کند.

★★★ سطح چالشی

  1. Design:
    مقدار e ≈ 1 + 1/1! + 1/2! + 1/3! + ... را تا ۱۰ جمله با یک حلقه‌ی for حساب کنید، بدون این‌که هر بار فاکتوریل را از ابتدا بشمارید. (راهنمایی: از جمله‌ی قبلی استفاده کنید.)

  2. Explain Why:
    چرا در for (unsigned i = 3; i >= 0; i--) حلقه بی‌نهایت است؟ (راهنمایی: کوچک‌ترین مقدار unsigned چیست؟)

9.5 الگوهای رایج حلقه (Loop Patterns)

اگر ده‌ها برنامه‌ی دارای حلقه را کنار هم بگذارید، متوجه می‌شوید اکثرشان از تعداد کمی الگوی تکرارشونده ساخته شده‌اند. شناختن این الگوها از حفظ کردن سینتکس ارزشمندتر است، چون هر مسئله‌ی جدید را به یکی از آن‌ها تبدیل می‌کند. در این بخش شش الگوی اصلی را می‌بینیم:

الگوپرسش اصلینمونه
Counterچند مورد شرط را دارد؟تعداد مضرب‌های ۳
Accumulatorمجموع (یا حاصل‌ضرب) چیست؟جمع نمرات
Min / Maxکوچک‌ترین یا بزرگ‌ترین کدام است؟بیشترین نمره
Search + Flagآیا وجود دارد؟ اولین مورد کدام است؟عدد اول بودن
Sentinelتا کی داده می‌آید؟ورودی تا -1
Convergenceتا کی تکرار کنیم تا جواب به اندازه‌ی کافی خوب شود؟ب.م.م، ریشه‌ی دوم

Pattern 1 & 2 - Counter و Accumulator

مسئله (مثال کلاسیک): مجموع و تعداد همه‌ی اعداد کوچک‌تر از ۱۰۰۰ که مضرب ۳ یا ۵ هستند. الگو: بگذرد از همه‌ی اعداد؛ برای هر عدد یک شرط بسنج؛ اگر برقرار بود، شمارنده را یکی بالا ببر و عدد را به جمع اضافه کن. دو متغیر کمکی (count و sum) را قبل از حلقه صفر می‌کنیم.

Writing src/p04_c09_11_pattern_counter_acc.c
count = 466, sum = 233168

چرا شرط i % 3 == 0 || i % 5 == 0 را با || نوشته‌ایم و دو if جدا نگذاشته‌ایم؟
اگر دو if مستقل می‌گذاشتیم، اعداد مضرب ۱۵ (مثل ۱۵ و ۳۰) دو بار شمرده می‌شدند. || هر عدد را فقط یک‌بار بررسی می‌کند. این همان تمایز «شرط‌های مستقل» در فصل قبل است، این بار در نقش یک اشتباه رایج.

چرا حلقه تا i < 1000 می‌رود، نه i <= 1000؟
چون صورت مسئله «کوچک‌تر از ۱۰۰۰» را می‌گوید. اگر <= می‌گذاشتیم، ۱۰۰۰ (که مضرب ۵ است) هم اضافه می‌شد. نوع مرز در صورت مسئله مستقیماً تعیین‌کننده‌ی علامت شرط است.

Pattern 3 - Min / Max روی دنباله‌ی ورودی

اگر همه‌ی داده‌ها را هم‌زمان در حافظه نداشته باشیم (چون هنوز آرایه نمی‌شناسیم)، می‌توانیم حین خواندن، «بهترین تا اینجا» را نگه داریم. نکته‌ی کلیدی: مقدار اولیه‌ی max و min را با اولین داده‌ی واقعی بسازیم، نه با یک عدد دلخواه مثل 0؛ در غیر این صورت، اگر همه‌ی داده‌ها منفی باشند، نتیجه غلط می‌شود.

Pseudocode
count ← 0
for each value x in the input:
    if count = 0:  min ← x, max ← x        // first value initialises both
    else:
        if x < min: min ← x
        if x > max: max ← x
    count ← count + 1

Writing src/p04_c09_12_pattern_minmax.c
count = 5, min = -3, max = 12
count = 1, min = 5, max = 5
count = 3, min = -15, max = -2
No data

چهار ورودی چهار مسیر متفاوت را آزمایش می‌کنند: حالت عادی، تک‌عنصری (کوچک‌ترین و بزرگ‌ترین یکی‌اند)، همه منفی (اگر max را با 0 شروع می‌کردیم، 0 به‌اشتباه بیشترین می‌شد) و ورودی خالی (باید «No data» چاپ شود، نه مقدار بی‌معنی).

Pattern 4 - Search + Flag: آیا عدد n اول است؟

عدد اول، عددی بزرگ‌تر از ۱ است که فقط بر ۱ و خودش بخش‌پذیر است. الگوریتم: برای d از ۲ به بعد، ببین آیا n بر d بخش‌پذیر است؛ اگر حتی یک مقسوم‌علیه پیدا شد، جواب «اول نیست» است و دیگر ادامه دادن بی‌معنی است. این همان الگوی جستجو با پرچم: یک متغیر is_prime با فرض اولیه‌ی «اول است» و در صورت یافتن شاهد مخالف، تغییر می‌کند و حلقه با break متوقف می‌شود.

بهبود الگوریتمی مهم: اگر n = a × b باشد، دست‌کم یکی از a و b از √n بیشتر نیست. پس کافی است d را فقط تا √n بررسی کنیم (شرط d * d <= n). این کار تعداد گام‌ها را از حدود n به حدود √n کاهش می‌دهد.

Pseudocode
if n < 2: return NOT PRIME
is_prime ← TRUE
for d from 2 while d × d ≤ n:
    if n mod d = 0:
        is_prime ← FALSE
        break
return is_prime

Writing src/p04_c09_13_prime_check.c
1: not prime (must be at least 2)
2: prime | steps: slow = 0, fast = 0
17: prime | steps: slow = 15, fast = 3
21: not prime | steps: slow = 2, fast = 2
97: prime | steps: slow = 95, fast = 8
1000003: prime | steps: slow = 1000001, fast = 999

چرا برای 21 هر دو نسخه خیلی سریع تمام می‌شوند؟
چون 21 = 3 × 7 و در گام دوم (d = 3) مقسوم‌علیه پیدا شده و break حلقه را قطع کرده است. حلقه فقط در بدترین حالت تا آخر می‌رود، و بدترین حالت برای این الگوریتم همان اعداد اول هستند (هیچ شاهدی پیدا نمی‌شود).

چرا برای 2 و 3 نسخه‌ی سریع هیچ گامی برنمی‌دارد؟
برای n = 2 شرط 2 × 2 <= 2 از همان ابتدا نادرست است؛ حلقه اجرا نمی‌شود و is_prime همان مقدار اولیه‌ی 1 می‌ماند، که درست است. حالت مرزی بدون کد اضافه حل شده است.

چرا (long long)d * d نوشته شده است؟
برای n های بزرگ (نزدیک به حد int)، d * d می‌تواند از ظرفیت int بگذرد. تبدیل به long long از سرریز جلوگیری می‌کند.

تفاوت دو نسخه را برای 1000003 ببینید: نسخه‌ی کند حدود یک میلیون گام برمی‌دارد و نسخه‌ی سریع حدود هزار. این تفاوت تفاوت مرتبه‌ی رشد است: O(n) در برابر O(√n).

Loading...
Pattern 6 - Convergence: الگوریتم اقلیدس (ب.م.م)

بزرگ‌ترین مقسوم‌علیه مشترک (gcd) دو عدد، قدیمی‌ترین الگوریتم مشهور تاریخ است (حدود ۳۰۰ سال پیش از میلاد). ایده: gcd(a, b) = gcd(b, a mod b)؛ یعنی مسئله را هر بار به یک مسئله‌ی کوچک‌تر با همان جواب تبدیل می‌کنیم تا به حالت b = 0 برسیم که در آن جواب همان a است. این یک حلقه‌ی همگرا است: شرط ادامه b != 0 است و هر تکرار b را اکیداً کوچک‌تر می‌کند (چون a mod b < b) ؛ پس تمام شدن حلقه تضمین‌شده است.

Pseudocode
while b ≠ 0:
    r ← a mod b
    a ← b
    b ← r
return a

Writing src/p04_c09_14_euclid_gcd.c
gcd(48, 18) = 6  (3 step(s))
gcd(17, 5) = 1  (3 step(s))
gcd(100, 0) = 100  (0 step(s))
gcd(0, 7) = 7  (1 step(s))
gcd(270, 192) = 6  (4 step(s))
gcd(13, 13) = 13  (1 step(s))
Loading...

چرا ورودی 100 0 جواب 100 می‌دهد؟
چون b = 0 از همان ابتدا شرط حلقه را نادرست می‌کند و a (یعنی ۱۰۰) به‌عنوان جواب بازمی‌گردد؛ و این از نظر ریاضی هم درست است (هر عدد، مقسوم‌علیه ۰ است). همچنین 0 7 در یک گام (a = 7, b = 0) به همان 7 می‌رسد: اگر a < b باشد، اولین تکرار فقط جای دو عدد را عوض می‌کند.

از کجا بدانیم حلقه حتماً تمام می‌شود؟
باید یک کمیت نزولی پیدا کنیم که در هر تکرار اکیداً کم شود و هرگز از صفر پایین‌تر نرود؛ این اثبات پایان‌پذیری حلقه است. اینجا b چنین کمیتی است. هر حلقه‌ای که چنین کمیتی نداشته باشد، «شاید» تمام نشود.

Pattern 6b - Convergence: ریشه‌ی دوم با روش نیوتن

گاهی جواب دقیق نداریم و فقط می‌توانیم «هر بار بهتر» شویم. برای √x تقریب اولیه g را می‌گیریم و آن را با g = (g + x / g) / 2 بهبود می‌دهیم. حلقه تا وقتی ادامه دارد که خطا از یک تلورانس کوچک بیشتر باشد. توجه کنید شرط توقف «برابر شدن» نیست (در اعداد اعشاری نمی‌توان به آن اعتماد کرد)، بلکه «به اندازه‌ی کافی نزدیک شدن» است.

Writing src/p04_c09_15_newton_sqrt.c
step 1: g = 1.5000000000, error = 2.500e-01
step 2: g = 1.4166666667, error = 6.944e-03
step 3: g = 1.4142156863, error = 6.007e-06
step 4: g = 1.4142135624, error = 4.511e-12
sqrt(2.0) ~ 1.4142135624 after 4 step(s)

خطا در هر گام تقریباً مجذور می‌شود (به همین دلیل از «۱.۴» به «۱.۴۱۴۲۱۳۵» بسیار سریع می‌رسیم): در کمتر از ۱۰ گام به دقت بالا می‌رسیم. سقف steps < 50 یک شبکه‌ی ایمنی است؛ هر حلقه‌ای که به «همگرایی» تکیه دارد، بهتر است سقف تکرار هم داشته باشد تا اگر مسئله شرط‌های روش را نقض کرد، برنامه برای همیشه نایستد.

Example - Tricky: حلقه‌ای که نمی‌دانیم حتماً تمام می‌شود (Collatz)

الگوریتم: عدد n را بگیر؛ تا وقتی n != 1 است، اگر زوج بود نصفش کن، وگرنه 3n + 1 کن. حدس معروف Collatz می‌گوید این حلقه برای هر عدد مثبتی تمام می‌شود، اما هنوز ثابت نشده است! این مثال نشان می‌دهد چرا «کمیت نزولی» در حلقه‌ها چنین اهمیتی دارد: در اینجا چنین کمیتی نمی‌شناسیم.

Writing src/p04_c09_16_collatz.c
start = 1: 0 step(s), peak value = 1
start = 6: 8 step(s), peak value = 16
start = 27: 111 step(s), peak value = 9232
start = 97: 118 step(s), peak value = 9232
Loading...
تمرین درون فصل ۹.۵

نوع تمرین: Trace

الگوریتم اقلیدس را برای a = 84 و b = 36 دستی دنبال کنید و جدولی با ستون‌های a ، b ، r بسازید. در چه گامی b صفر می‌شود و جواب چیست؟

تمرین درون فصل ۹.۶ : چرا max را با اولین داده شروع می‌کنیم؟

نوع تمرین: Explain Why

اگر در الگوریتم Min/Max مقدار اولیه‌ی max را صفر بگیریم و ورودی -8 -2 -15 باشد، نتیجه چه می‌شود؟ یک مقدار اولیه‌ی دیگر پیشنهاد دهید که بدون استفاده از اولین داده هم درست باشد و بگویید چرا آن معمولاً ایده‌ی بدی است (راهنمایی: INT_MIN در limits.h).

تمرین‌های پایانی زیربخش: الگوهای حلقه

★ سطح پایه

  1. Small Implementation (Counter):
    تعداد اعداد زوج بین ۱ تا ۱۰۰ را بشمارید.

  2. Small Implementation (Accumulator):
    حاصل‌ضرب اعداد فرد از ۱ تا ۹ را با یک حلقه حساب کنید.

★★ سطح متوسط

  1. Find the Bug:

int max = 0, x;
while (scanf("%d", &x) == 1) {
    if (x > max) max = x;
}

کد برای کدام ورودی‌ها جواب غلط می‌دهد؟

  1. Modify the Algorithm:
    الگوریتم عدد اول را طوری تغییر دهید که علاوه بر «اول است یا نه»، کوچک‌ترین مقسوم‌علیه را هم چاپ کند.

  2. Small Implementation:
    با الگوی Counter، تعداد ارقام زوج یک عدد را بشمارید (مثلاً 2846 ← ۴).

  3. Design:
    با استفاده از gcd، کوچک‌ترین مضرب مشترک (lcm) دو عدد را با فرمول lcm = a / gcd * b حساب کنید. چرا ابتدا تقسیم و بعد ضرب بهتر از a * b / gcd است؟

★★★ سطح چالشی

  1. Algorithm Design:
    تعداد جفت‌های (i, j) با 1 ≤ i < j ≤ 20 را پیدا کنید که gcd(i, j) = 1 باشد. (مجبور به استفاده از حلقه‌ی تودرتو هستید؛ بخش بعد را ببینید.)

  2. Complexity Reasoning:
    در الگوریتم عدد اول با √n، برای n = 10^12 چند تکرار لازم است؟ آیا این در یک ثانیه قابل اجراست؟ با نسخه‌ی کند مقایسه کنید.

9.6 حلقه‌های تودرتو (Nested Loops)

ایده الگوریتم

بسیاری از داده‌ها و مسئله‌ها ذاتاً «دو‌بعدی» هستند: جدول ضرب (سطر × ستون)، جفت‌های ممکن بین دو مجموعه، الگوهای تصویری. برای این‌ها یک حلقه کافی نیست. وقتی یک حلقه را داخل بدنه‌ی حلقه‌ی دیگر می‌گذاریم، حلقه‌ی داخلی برای هر تکرار حلقه‌ی بیرونی، یک‌بار کامل اجرا می‌شود. تشبیه خوب: عقربه‌ی ساعت‌شمار و دقیقه‌شمار؛ دقیقه‌شمار برای هر قدم ساعت‌شمار یک دور کامل می‌چرخد.

Pseudocode
for i from 1 to n:                    // outer: runs n times
    for j from 1 to m:                // inner: runs m times, FOR EACH i
        body
// body runs  n × m  times in total

Example A - Simple: الگوهای ستاره‌ای
Writing src/p04_c09_17_nested_patterns.c
Rectangle (3 x 5):
*****
*****
*****

Right triangle (n = 4):
*
**
***
****

Pyramid (n = 4):
   *
  ***
 *****
*******

الگوی ذهنی: **حلقه‌ی بیرونی «سطرها» و حلقه‌ی داخلی «ستون‌ها» را تعیین می‌کند؛ و printf("\n") بعد از پایان حلقه‌ی داخلی (و نه داخل آن) سطر را می‌بندد. در مستطیل، تعداد ستون ثابت است؛ در مثلث، شرط حلقه‌ی داخلی به متغیر بیرونی وابسته است** (j <= i)، و همین وابستگی شکل را تعیین می‌کند. در هرم، برای هر سطر سه کار انجام می‌دهیم: فاصله، ستاره، خط جدید، و تعداد ستاره از فرمول 2i - 1 می‌آید.

Loading...
Example B - Real: جدول ضرب
Writing src/p04_c09_18_mult_table.c
       1   2   3   4   5
   1   1   2   3   4   5
   2   2   4   6   8  10
   3   3   6   9  12  15
   4   4   8  12  16  20
   5   5  10  15  20  25
Example C - Real: همه‌ی اعداد اول تا ۵۰ (حلقه‌ی تودرتو + break)
Writing src/p04_c09_19_nested_primes.c
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 

حلقه‌ی بیرونی همه‌ی عددها را می‌پیماید و حلقه‌ی داخلی برای هر کدام همان آزمون اول بودن را اجرا می‌کند. دقت کنید prime = 1 داخل حلقه‌ی بیرونی مقداردهی می‌شود، چون هر عدد جدید باید از «فرض اول بودن» شروع کند. اگر آن را بیرون از حلقه‌ی بیرونی می‌گذاشتیم، بعد از اولین عدد غیراول، همه‌ی اعداد بعدی هم غیراول تلقی می‌شدند.

Complexity: هزینه‌ی حلقه‌ی تودرتو
Writing src/p04_c09_20_nested_count.c
n = 10
full square   : 100 iterations (n * n)
triangle j<=i : 55 iterations (n(n+1)/2 = 55)

اگر حلقه‌ی بیرونی n بار و حلقه‌ی داخلی (مستقل از آن) m بار اجرا شود، کل اجرای بدنه n × m است. اگر حلقه‌ی داخلی به متغیر بیرونی وابسته باشد (مثلث)، کل برابر مجموع 1 + 2 + ... + n = n(n+1)/2 می‌شود؛ که از نظر مرتبه‌ی رشد هنوز O(n²) است (ثابت 1/2 در مرتبه‌ی رشد بی‌اهمیت است). مهم‌ترین نتیجه: **دو حلقه‌ی تودرتو روی n عنصر، با دو برابر شدن n زمان را چهار برابر می‌کند.**

خطاهای رایج در حلقه‌های تودرتو

❌ اشتباه ۱: استفاده از یک شمارنده برای هر دو حلقه

for (int i = 0; i < 3; i++) {
    for (i = 0; i < 3; i++) {
        printf("*");
    }
}

چرا اشتباه است؟
حلقه‌ی داخلی همان i بیرونی را دستکاری می‌کند: بعد از پایان داخلی، i برابر 3 است و حلقه‌ی بیرونی هم تمام می‌شود؛ پس به‌جای ۹ ستاره فقط ۳ ستاره چاپ می‌شود (و در حالت‌های دیگر ممکن است حلقه بی‌نهایت شود).

✅ نسخه صحیح: از j برای حلقه‌ی داخلی استفاده کنید.

**❌ اشتباه ۲: break برای خروج از هر دو حلقه**

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

تمرین درون فصل ۹.۷

نوع تمرین: Predict the Output

for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        if (i == j) printf("%d ", i);
    }
}

خروجی چیست؟ بدنه‌ی printf در مجموع چند بار اجرا می‌شود و شرط i == j چند بار درست است؟

تمرین‌های پایانی زیربخش: حلقه‌های تودرتو

★ سطح پایه

  1. Small Implementation:
    مربع تو‌پُر n × n از ستاره را چاپ کنید (n را ثابت بگیرید).

  2. Trace:
    برای n = 3 در الگوی مثلث، ترتیب (i, j) ها را بنویسید و تعداد کل تکرار بدنه را بشمارید.

★★ سطح متوسط

  1. Small Implementation:
    مثلث «وارونه» (سطر اول n ستاره، سطر آخر ۱ ستاره) را چاپ کنید.

  2. Modify the Algorithm:
    جدول ضرب را طوری تغییر دهید که فقط نیمه‌ی بالایی مثلث (j >= i) چاپ شود.

  3. Find the Bug:

for (int i = 1; i <= 4; i++) {
    for (int j = 1; j <= i; j++) {
        printf("*");
        printf("\n");
    }
}

خروجی اشتباه چیست و printf("\n") را کجا باید گذاشت؟

★★★ سطح چالشی

  1. Design:
    الگوی «لوزی» (هرم و هرم وارونه زیر آن) را با حلقه‌های تودرتو چاپ کنید.

  2. Complexity Reasoning:
    اگر برای هر جفت (i, j) با 1 ≤ i < j ≤ n یک کار انجام شود، تعداد کل جفت‌ها چقدر است؟ (راهنمایی: n(n-1)/2.) برای n = 1000 چند میلیون جفت داریم؟

  3. Design:
    همه‌ی سه‌تایی‌های فیثاغورسی (a, b, c) با 1 ≤ a < b < c ≤ 50 را با سه حلقه‌ی تودرتو پیدا کنید. چگونه می‌توان با حذف حلقه‌ی سوم (محاسبه‌ی c از روی a و b) آن را سریع‌تر کرد؟

9.7 دستورهای break و continue

ایده الگوریتم

گاهی در میانه‌ی یک تکرار می‌فهمیم باید رفتار حلقه را تغییر دهیم:

  • **break**: «کار تمام شد؛ همین الان کل حلقه را ترک کن.» (مثلاً پاسخ را پیدا کردیم.)

  • **continue**: «این تکرار را رها کن و برو به تکرار بعدی.» (مثلاً این مورد مشمول پردازش نیست.)

تفاوت کلیدی: break کل حلقه را قطع می‌کند و continue فقط این دور را. هر دو فقط روی نزدیک‌ترین حلقه‌ی دربرگیرنده اثر می‌گذارند. توجه: وقتی continue در for اجرا شود، قسمت به‌روزرسانی هنوز اجرا می‌شود؛ اما در while و do-while کنترل مستقیماً به بررسی شرط می‌رود و هر به‌روزرسانیِ بعد از continue نادیده می‌ماند.

Flowchart
Example A - Simple: پیدا کردن اولین مورد (break)
Writing src/p04_c09_21_break_demo.c
first match = 154
pairs: (1,1) (2,1) (3,1)

مثال اول، همان الگوی «جستجو + break» است که در عدد اول دیدیم: بدون break حلقه تا ۱۰۰۰ ادامه می‌داد و found دوباره نوشته می‌شد (آخرین مضرب مشترک را به‌جای اولین می‌گرفتیم!). مثال دوم نشان می‌دهد break فقط حلقه‌ی داخلی را قطع کرده است: برای هر i، فقط j = 1 چاپ شده و بعد حلقه‌ی داخلی تمام شده، اما حلقه‌ی بیرونی ادامه داده است.

Example B - Real: پرش از روی موارد نامطلوب (continue)
Writing src/p04_c09_22_continue_demo.c
sum of 1..10 without multiples of 3 = 37
Loading...
Example C - Tricky: continue در while و خطر حلقه‌ی بی‌نهایت
Writing src/p04_c09_23_continue_trap.c
buggy version : after 5 iterations i = 0 (stuck)
fixed version : sum of odd numbers up to 10 = 25

در نسخه‌ی اول، continue قبل از i++ اجرا می‌شود و چون i هرگز تغییر نمی‌کند، شرط i % 2 == 0 همیشه درست می‌ماند؛ بدون سقف ایمنی، این حلقه بی‌نهایت می‌شد. در for این مشکل پیش نمی‌آید چون به‌روزرسانی در سرِ حلقه است. قاعده: در while اگر continue دارید، مطمئن شوید به‌روزرسانی قبل از آن انجام شده است.

مقایسه‌ی break، continue و return
دستوراثرمحدوده
breakکل حلقه (یا switch) را ترک می‌کندنزدیک‌ترین حلقه/switch
continueتکرار فعلی را رها می‌کندنزدیک‌ترین حلقه
returnکل تابع (اینجا main) را تمام می‌کندتمام توابع و حلقه‌ها

تمرین درون فصل ۹.۸

نوع تمرین: Predict the Output

for (int i = 1; i <= 6; i++) {
    if (i == 2) continue;
    if (i == 5) break;
    printf("%d ", i);
}

خروجی چیست و i پس از خروج از حلقه چه مقداری دارد؟ (اگر i بیرون از حلقه تعریف شده باشد.)

تمرین‌های پایانی زیربخش: break و continue

★ سطح پایه

  1. Small Implementation:
    با break، اولین مضرب ۱۳ بعد از ۱۰۰ را پیدا کنید.

  2. Small Implementation:
    با continue، فقط اعداد زوج بین ۱ تا ۱۰ را چاپ کنید.

★★ سطح متوسط

  1. Find the Bug:

int i = 0;
while (i < 10) {
    if (i == 5) continue;
    printf("%d ", i);
    i++;
}

چه مشکلی دارد؟ دو راه اصلاح بنویسید.

  1. Modify the Algorithm:
    در برنامه‌ی میانگین با Sentinel، نمره‌های نامعتبر (خارج از ۰ تا ۲۰) را با continue نادیده بگیرید و فقط نمره‌های معتبر را به میانگین اضافه کنید.

★★★ سطح چالشی

  1. Design:
    عدد n را بگیرید و بزرگ‌ترین مقسوم‌علیه آن که از خودش کوچک‌تر است را با حلقه‌ی معکوس و break پیدا کنید. چرا حلقه‌ی معکوس این‌جا از حلقه‌ی صعودی ساده‌تر است؟

  2. Compare Approaches:
    یک حلقه‌ی دارای continue را بدون continue (با if معکوس) بازنویسی کنید. در چه شرایطی نسخه‌ی با continue خواناتر است؟

9.8 حلقه‌های بی‌نهایت (عمدی و سهوی)

بی‌نهایت سهوی

حلقه‌ای که شرطش هرگز نادرست نمی‌شود. چهار علت رایج: (۱) فراموش کردن به‌روزرسانی، (۲) به‌روزرسانی در جهت اشتباه (i-- در حلقه‌ای که شرطش i < n است)، (۳) شرطی که با نوع داده جور نیست (شمارنده‌ی unsigned با i >= 0، یا مقایسه‌ی دقیق اعشاری)، (۴) continue پیش از به‌روزرسانی در while. راه کشف: برای حلقه‌ی مشکوک، مقادیر متغیرهای شرط را داخل حلقه چاپ کنید و ببینید آیا به سمت پایان حرکت می‌کنند.

بی‌نهایت عمدی

گاهی ساختار «تا ابد تکرار کن مگر وقتی که...» بهترین شکل مسئله است؛ مثل منو یا سروری که منتظر درخواست می‌ماند. در این حالت شرط را ثابت درست (while (1)) می‌گذاریم و از داخل بدنه با break (یا return) خارج می‌شویم:

Writing src/p04_c09_24_infinite_loop_break.c
running total = 5
running total = 15
running total = 13
zero received: stop
final total = 13
running total = 4
running total = 10
input ended
final total = 10

مزیت این شکل: دو شرط توقف مستقل (پایان ورودی و مقدار صفر) هرکدام جای مشخص و پیام مخصوص خود را دارند؛ فشرده کردن هر دو در شرط while باعث می‌شد خواندن کد سخت‌تر شود. هزینه: خواننده باید **همه‌ی breakهای بدنه** را بررسی کند تا بفهمد حلقه چگونه تمام می‌شود. پس این الگو را فقط وقتی استفاده کنید که شرط‌های توقف متعدد یا میانه‌ی بدنه باشند.

Writing src/p04_c09_25_loop_pitfalls.c
src/p04_c09_25_loop_pitfalls.c: In function ‘main’:
src/p04_c09_25_loop_pitfalls.c:22:5: warning: this ‘for’ clause does not guard... []8;;https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html#index-Wmisleading-indentation-Wmisleading-indentation]8;;]
   22 |     for (int i = 0; i < 5; i++);
      |     ^~~
src/p04_c09_25_loop_pitfalls.c:23:5: note: ...this statement, but the latter is misleadingly indented as if it were guarded by the ‘for’
   23 |     {
      |     ^
1) i < 10  : 9 iterations, sum = 45
   i <= 10 : 10 iterations, sum = 55
2) unsigned countdown from 2: 2 1 0 4294967295 4294967294
3) count = 1 (expected 5)

خروجی سه خطا را یکجا نشان می‌دهد. کامپایلر فقط برای مورد سوم هشدار داده، آن هم به‌دلیل تورفتگی گمراه‌کننده؛ دو مورد دیگر کاملاً بی‌صدا هستند. پس به نبودن هشدار اکتفا نکنید و به خروجی هم شک کنید: مقدار count برابر ۱ شد، نه ۵.

9.9 دستور goto و دلیل پرهیز از آن

goto چیست؟

زبان C دستوری به نام goto دارد که کنترل برنامه را بدون هیچ شرط ساختاری به یک برچسب (Label) دلخواه در همان تابع می‌برد:

goto label_name;
...
label_name:
    statement;

هر ساختاری که تا اینجا دیدیم (if، while، for، switch) را می‌توان با ترکیب goto و if ساخت؛ در واقع کامپایلر در پایین‌ترین سطح دقیقاً همین کار را می‌کند. برای مقایسه، یک حلقه‌ی جمع ۱ تا ۵ با goto و همان حلقه با while:

Writing src/p04_c09_26_goto_vs_while.c
goto  version: sum = 15
while version: sum = 15

خروجی دو نسخه یکی است؛ پس goto از نظر قدرت چیزی کم ندارد. مشکل در قابل فهم بودن است:

  • در نسخه‌ی while با یک نگاه می‌بینید شروع حلقه کجاست، شرط چیست و بدنه کجا تمام می‌شود. در نسخه‌ی goto باید برچسب‌ها را پیدا کنید و مسیر پرش‌ها را در ذهن دنبال کنید.

  • با goto، کنترل می‌تواند از هر جای تابع به هر جای دیگر بپرد؛ پس برای فهم یک خط باید همه‌ی راه‌های ممکن رسیدن به آن را بشناسید. نامتغیرهای حلقه (آن‌چه در ابتدای هر تکرار درست است) دیگر قابل استدلال نیستند.

  • هر چه تعداد gotoها بیشتر شود، کد به کد اسپاگتی (Spaghetti Code) نزدیک‌تر می‌شود: مسیر اجرا مثل یک کلاف درهم.

تفاوت ساختار را در دو Flowchart زیر ببینید؛ سمت اول یک برنامه‌ی ساخت‌یافته (هر بلوک یک ورودی و یک خروجی دارد) و سمت دوم یک برنامه‌ی پرشی:

داستان تاریخی

در سال ۱۹۶۸، دانشمند هلندی ادسخر دایکسترا (Edsger W. Dijkstra) نامه‌ای کوتاه در مجله‌ی Communications of the ACM منتشر کرد که عنوان معروف آن «Go To Statement Considered Harmful» (دستور goto زیان‌بار شمرده می‌شود) بود. استدلال او: فاصله‌ی بین متن برنامه و رفتار آن در زمان اجرا باید تا حد امکان کم باشد؛ goto این فاصله را بی‌نهایت زیاد می‌کند. دو سال پیش‌تر (۱۹۶۶)، بوهم و ژاکوپینی ثابت کرده بودند هر برنامه‌ی قابل محاسبه را می‌توان تنها با سه ساختار ترتیب، انتخاب و تکرار نوشت؛ یعنی goto از نظر نظری هیچ‌وقت لازم نیست. این دقیقاً همان سه ساختاری است که در این بخش از ابتدا دیدید.

جایگزین‌های goto

مورد رایجی که وسوسه می‌شویم goto بزنیم، خروج از حلقه‌های تودرتو است. سه راه ساخت‌یافته: ۱) یک پرچم، ۲) انتقال حلقه‌ها به تابع و return (بعد از توابع)، ۳) بازنگری شرط‌ها. برنامه‌ی زیر همین مسئله (پیدا کردن اولین جفت (i, j) با i * j == 24 و i + j == 10) را با goto و با پرچم حل می‌کند:

Writing src/p04_c09_27_nested_exit.c
goto version: i = 4, j = 6
flag version: i = 4, j = 6

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

استثنای مشهور: پاک‌سازی خطا

در برخی کدهای حرفه‌ای زبان C (مثلاً هسته‌ی سیستم‌عامل‌ها) از goto در یک الگوی بسیار محدود استفاده می‌شود: وقتی چند منبع را پشت‌سرهم باز می‌کنیم و در هر مرحله ممکن است خطا رخ دهد، همه‌ی خطاها به یک برچسب cleanup: در انتهای تابع می‌پرند تا منابع آزاد شوند. این استفاده‌ی به‌شدت قاعده‌مند (فقط پرش رو به جلو، فقط به انتهای تابع) با اسپاگتی فرق دارد. اما در این درس شما goto لازم ندارید: **قاعده‌ی عملی: برای هر وسوسه‌ی goto، ابتدا بازنویسی با حلقه، break، پرچم یا تابع را امتحان کنید.**

تمرین درون فصل ۹.۹

نوع تمرین: Explain Why

حلقه‌ی goto برنامه‌ی goto_vs_while را در نظر بگیرید. چرا فهمیدن «اینجا کدام مقادیر i ممکن است؟» در نسخه‌ی while ساده‌تر است؟ نامتغیر حلقه را برای هر دو نسخه بنویسید.

تمرین‌های پایانی زیربخش: goto

★ سطح پایه

  1. Refactoring:
    کد زیر را با while بازنویسی کنید:

int n = 5;
again:
if (n == 0) goto end;
printf("%d ", n);
n--;
goto again;
end:

★★ سطح متوسط

  1. Find the Bug:

int i = 0;
start:
printf("%d\n", i);
if (i < 3) goto start;

این کد چه خروجی دارد؟ و چرا ممکن است نویسنده انتظار چیز دیگری داشته باشد؟ (توجه: i هرگز تغییر نمی‌کند.)

  1. Compare Approaches:
    مسئله‌ی «پیدا کردن اولین عنصر مشترک بین دو بازه‌ی عددی با دو حلقه‌ی تودرتو» را با goto و با پرچم بنویسید و خوانایی را مقایسه کنید.

★★★ سطح چالشی

  1. Reverse Engineering:
    توضیح دهید چرا قضیه‌ی بوهم–ژاکوپینی نمی‌گوید «goto بد است»، بلکه چیز دیگری را ثابت می‌کند. این قضیه چه چیزی درباره‌ی «لازم بودن» goto می‌گوید؟

  2. Design:
    یک الگوریتم «باز کردن سه منبع پشت‌سرهم با خروج در هر مرحله‌ی خطا» را با if تودرتو، با پرچم، و با goto cleanup بنویسید (به‌صورت شبه‌کد). هر سه را از نظر خوانایی مقایسه کنید و بگویید در کدام استفاده از goto قابل دفاع است.

9.10 هزینه‌ی حلقه‌ها: شمردن تکرارها

برای مقایسه‌ی الگوریتم‌ها، نیاز به معیاری داریم که به سرعت کامپیوتر وابسته نباشد. ساده‌ترین معیار: بدنه‌ی حلقه چند بار اجرا می‌شود؟ این عدد را به‌عنوان تابعی از اندازه‌ی ورودی n بنویسید:

Writing src/p04_c09_28_iteration_counts.c
n = 1000
single loop      : 1000 iterations   -> O(n)
halving loop     : 9 iterations     -> O(log n)
two nested loops : 1000000 iterations -> O(n^2)
Loading...

مهم‌ترین عادت‌های هزینه‌سنجی حلقه:

  • یک حلقه‌ی ساده با گام ثابت روی n مورد: O(n).

  • حلقه‌ای که مقدار را در هر گام نصف (یا دو برابر) می‌کند: O(log n) (خیلی سریع).

  • **دو حلقه‌ی تودرتو روی n:** O(n²)؛ سه‌تایی: O(n³).

  • **حلقه تا √n** (مثل آزمون اول بودن): O(√n).

  • **حلقه‌ی با break:** بدترین حالت همان‌قدر است، اما حالت میانگین ممکن است خیلی بهتر باشد.

تمرین درون فصل ۹.۱۰

نوع تمرین: Complexity Reasoning

هر یک از حلقه‌های زیر چند بار بدنه را اجرا می‌کند (برحسب n) و از چه مرتبه‌ای است؟

for (int i = 1; i < n; i *= 2)         { ... }   // الف
for (int i = 0; i < n; i += 3)         { ... }   // ب
for (int i = 0; i < n; i++)
    for (int j = 0; j < 5; j++)        { ... }   // ج

9.11 خطاهای رایج در حلقه‌ها (جمع‌بندی)

  • Off-by-One: اشتباه بین < و <=، یا شروع از ۰ و ۱. درمان: پیش از نوشتن، تعداد تکرار موردنیاز را بنویسید و با فرمول بازه بسنجید.

  • فراموش کردن به‌روزرسانی / جهت اشتباه: حلقه هرگز تمام نمی‌شود. درمان: برای هر حلقه بپرسید «کدام کمیت هر بار به پایان نزدیک‌تر می‌شود؟»

  • **نقطه‌ویرگول بعد از for یا while:** بدنه به یک دستور خالی تبدیل می‌شود.

  • **شمارنده‌ی اعشاری یا unsigned:** x != 1.0 یا u >= 0 ممکن است هرگز نادرست نشود.

  • **تغییر شمارنده داخل بدنه‌ی for:** پیش‌بینی رفتار حلقه را سخت می‌کند.

  • مقداردهی اولیه‌ی اشتباه: max = 0 به‌جای اولین داده؛ prime = 1 بیرون از حلقه‌ی بیرونی.

  • **استفاده از continue پیش از به‌روزرسانی در while.**

  • سرریز در Accumulator: مجموع یا حاصل‌ضرب بزرگ در int.

  • عدم رسیدگی به ورودی خالی: تقسیم بر count وقتی count = 0.

9.12 کاربردهای واقعی حلقه‌ها

  • پردازش همه‌ی رکوردهای یک پایگاه‌داده یا خطوط یک فایل (خواندن تا پایان فایل).

  • حلقه‌ی اصلی بازی‌ها و برنامه‌های گرافیکی (دریافت ورودی ← به‌روزرسانی ← رسم، تا بسته شدن برنامه).

  • شبیه‌سازی‌های علمی: تکرار یک گام زمانی هزاران بار.

  • روش‌های عددی (نیوتن، نیمه‌کردن بازه) که تا رسیدن به دقت مطلوب تکرار می‌شوند.

  • سرورها و سیستم‌های ایمبدد که برای همیشه منتظر رویداد می‌مانند (while (1) عمدی).

  • ...

تمرین‌های پایانی فصل ۹

توجه: این تمرین‌ها با آن‌چه در تمرین‌های زیربخش دیدید تفاوت دارند و انواع حلقه، الگوهای حلقه و حالت‌های مرزی را با هم ترکیب می‌کنند.

  1. Predict the Output:

int x = 20, count = 0;
while (x > 1) {
    x /= 2;
    count++;
}
printf("%d %d\n", x, count);

خروجی را پیش‌بینی کنید و مرتبه‌ی رشد count را برحسب مقدار اولیه‌ی x بنویسید.

  1. Trace:
    برای for (int i = 1; i <= 4; i++) for (int j = i; j <= 4; j++) count++; تعداد نهایی count را با دنبال کردن جدول (i, j) به‌دست آورید و با فرمول مجموع مقایسه کنید.

  2. Find the Bug:

int n = 10, sum = 0;
for (int i = 1; i <= n; i++) {
    sum += i;
}
double avg = sum / n;
printf("%.2f\n", avg);

حلقه درست است، اما میانگین چاپ‌شده برای n = 10 دقیق نیست. خطا را پیدا کنید. (راهنمایی: به نوع تقسیم نگاه کنید.)

  1. Edge Case Reasoning:
    برای الگوریتم‌های «جمع ارقام»، «عدد اول» و «Min/Max» مشخص کنید ورودی‌های 0، 1، یک عدد منفی و یک عدد خیلی بزرگ چه رفتاری دارند و کدام یک مشکل‌دارند.

  2. Algorithm Design:
    برنامه‌ای بنویسید که تعداد ارقام 1 را در نمایش دودویی یک عدد بشمارد (راهنمایی: n % 2 و n / 2 در یک حلقه‌ی while).

  3. Design + Implementation:
    تعداد اعداد کامل (Perfect Number: عددی که برابر مجموع مقسوم‌علیه‌های کوچک‌تر از خود است، مثل ۶ و ۲۸) در بازه‌ی ۱ تا ۱۰۰۰ را بیابید. تعداد کل تکرارهای حلقه‌ی داخلی را برآورد کنید. چطور می‌توان آن را با √n سریع‌تر کرد؟

  4. Modify the Algorithm:
    الگوریتم عدد اول را طوری تغییر دهید که فقط 2 و سپس اعداد فرد را امتحان کند. تعداد گام‌ها تقریباً چند برابر کم می‌شود؟

  5. Compare Approaches:
    مسئله‌ی «چاپ اعداد ۱ تا ۱۰۰ که هم مربع کامل‌اند و هم زوج» را (الف) با for و if، (ب) با for که مستقیماً مربع‌ها را می‌سازد (i * i) بنویسید. کدام تعداد تکرار کمتری دارد؟

  6. Refactoring:
    حلقه‌ی while (1) با دو break در برنامه‌ی infinite_loop_break را به یک while با شرط مرکب تبدیل کنید. چه چیزی از خوانایی کم می‌شود؟

Retrieval Practice فصل ۹

  • چهار بخش آناتومی یک حلقه چیست؟ در for هرکدام کجا نوشته می‌شود؟

  • چه تفاوتی بین while و do-while از نظر حداقل تعداد اجرای بدنه وجود دارد؟

  • نامتغیر حلقه (Loop Invariant) یعنی چه؟ برای جمع ۱ تا n آن را بنویسید.

  • چرا الگوی Min/Max را با اولین داده شروع می‌کنیم، نه 0؟

  • چرا در آزمون اول بودن کافی است تا √n بررسی کنیم؟

  • چرا الگوریتم اقلیدس حتماً تمام می‌شود؟ کمیت نزولی آن چیست؟

  • حلقه‌ی داخلی در حلقه‌های تودرتو چند بار اجرا می‌شود و کل تکرارها چگونه حساب می‌شود؟

  • break و continue هرکدام روی کدام حلقه اثر می‌گذارند؟

  • چرا continue در while بدون به‌روزرسانی خطرناک است؟

  • دو دلیل برای پرهیز از goto و دو جایگزین ساخت‌یافته را نام ببرید.



9.13 جمع‌بندی فصل ۹

در این فصل یاد گرفتیم چرا تکرار دستی دستورها با رشد مسئله ناممکن می‌شود و حلقه چگونه با چهار بخش (مقداردهی، شرط، بدنه، به‌روزرسانی) آن را حل می‌کند. سه حلقه‌ی while (شرط اول، ممکن است هرگز اجرا نشود)، do-while (شرط آخر، حداقل یک‌بار) و for (برای شمارنده‌ی مشخص) را شناختیم و دیدیم انتخاب بین آن‌ها گاهی درستی الگوریتم را تعیین می‌کند. با شش الگوی حلقه (Counter، Accumulator، Min/Max، Search با Flag، Sentinel، Convergence) دیدیم بیشتر مسئله‌ها ترکیبی از چند الگوی آشنا هستند. حلقه‌های تودرتو را برای داده‌های دوبعدی و جفت‌ها به کار بردیم و فهمیدیم هزینه‌ی آن‌ها ضرب می‌شود. با break و continue رفتار حلقه را در میانه‌ی تکرار تغییر دادیم و خطرهای حلقه‌ی بی‌نهایت را بررسی کردیم. در پایان دلیل پرهیز از goto را از دید خوانایی و استدلال‌پذیری و با نگاهی تاریخی دیدیم. همچنین یاد گرفتیم تکرارها را بشماریم و الگوریتم‌ها را با O(log n)، O(√n)، O(n) و O(n²) مقایسه کنیم.



جمع‌بندی بخش چهارم

نقشه مفهومی

                         Sequence (اجرای ترتیبی)
                                    │
                  ┌─────────────────┴─────────────────┐
                  ▼                                   ▼
       Selection (تصمیم‌گیری)                 Iteration (تکرار)
                  │                                   │
   ┌──────┬───────┼───────┬────────┐        ┌─────────┼──────────┐
   ▼      ▼       ▼       ▼        ▼        ▼         ▼          ▼
  if  if-else  else-if    ?:     switch   while   do-while      for
                  │                          │
          ترتیب شرط‌ها مهم است          الگوهای حلقه
                                  Counter / Accumulator / Min-Max
                                  Search+Flag / Sentinel / Convergence
                                             │
                          ┌──────────────────┼──────────────────┐
                          ▼                  ▼                  ▼
                    Nested Loops      break / continue        goto
                    (هزینه ضرب‌شونده)   (نزدیک‌ترین حلقه)   (پرهیز؛ جایگزین: پرچم/تابع)

جدول مرور سریع

مفهومنکته کلیدیهزینه (در صورت وجود)
شرط در Cصفر = نادرست، غیرصفر = درست؛ && و || کوتاه‌مدارند-
قوانین دمورگان!(A && B) = !A || !B-
else ifاولین شرط درست برنده است؛ ترتیب بخشی از منطق استبدترین حالت: k مقایسه
تست مرزیروی مرز هر شرط: قبل، خود مرز، بعد-
Guard Clauseخطاها را زودتر رد کن؛ تو در تویی را کم کن-
?:یک عبارت است (مقدار دارد)؛ همیشه پرانتز بگذار-
switchفقط تساوی با ثابت صحیح؛ break را فراموش نکن-
whileشرط قبل از بدنه؛ ممکن است صفر بار اجرا شودبسته به مسئله
do-whileشرط بعد از بدنه؛ حداقل یک‌باربسته به مسئله
forشمارنده‌ی مشخص؛ مراقب off-by-one باشحلقه‌ی ساده: O(n)
Min/Maxبا اولین داده شروع کنO(n)
عدد اولفقط تا √n بررسی کنO(√n)
ب.م.م اقلیدسgcd(a,b) = gcd(b, a mod b)O(log n)
حلقه‌ی تودرتوتعداد تکرارها ضرب می‌شوددو سطح: O(n²)
break / continueفقط نزدیک‌ترین حلقه-
gotoاز نظر نظری لازم نیست؛ خوانایی را نابود می‌کند-

Retrieval Practice جامع بخش چهارم

  • چرا عبارت x == 1 || 2 همیشه درست است؟

  • چرا در یک زنجیره‌ی else if فقط یک شاخه اجرا می‌شود، و در چند if پشت‌سرهم ممکن است چند شاخه اجرا شود؟

  • چه تفاوتی بین «پوشش مسیرها» و «تست مرزی» است؟

  • چرا ترفند switch (score / 10) بدون اعتبارسنجی ورودی خطرناک است؟

  • چرا می‌گوییم break در switch و break در حلقه یک کار شبیه ولی نه یکسان انجام می‌دهند؟

  • کدام الگوی حلقه برای «پیدا کردن بزرگ‌ترین» و کدام برای «پیدا کردن اولین» مناسب است؟

  • چرا هر حلقه‌ی همگرا باید سقف تکرار هم داشته باشد؟



تمرین‌های نهایی بخش چهارم

توجه: این تمرین‌ها با مثال‌های داخل درس متفاوت‌اند و نیازمند ترکیب چند مفهوم هستند.

Level 1 : Fundamental
  1. برنامه‌ای بنویسید که یک عدد صحیح بخواند و بگوید مثبت، منفی یا صفر است.

  2. با یک حلقه‌ی for، مجموع همه‌ی اعداد فرد بین ۱ تا ۹۹ را چاپ کنید.

  3. برنامه‌ای بنویسید که عدد n را بخواند و n بار پیام «Hello» را چاپ کند (برای n ≤ 0 هیچ پیامی چاپ نشود).

Level 2 : Intermediate
  1. برنامه‌ای بنویسید که ساعت و دقیقه‌ی یک رویداد را بخواند، آن‌ها را اعتبارسنجی کند (ساعت ۰ تا ۲۳، دقیقه ۰ تا ۵۹) و بگوید «صبح»، «بعدازظهر» یا «شب» است.

  2. اعداد ورودی را تا رسیدن به صفر بخوانید و تعداد اعداد مثبت، منفی و میانگین کل را چاپ کنید (با مدیریت حالت ورودی خالی).

  3. فاکتوریل n را با long long حساب کنید و برای n که از ظرفیت نوع بیشتر است پیام خطا بدهید.

Level 3 : Advanced
  1. برنامه‌ای بنویسید که همه‌ی اعداد «آرمسترانگ» ۳ رقمی (عددی که مجموع مکعب ارقامش برابر خودش است، مثل ۱۵۳) را بین ۱۰۰ تا ۹۹۹ پیدا کند.

  2. برنامه‌ای بنویسید که n را بخواند و n جمله‌ی اول دنباله‌ی فیبوناچی را چاپ کند (بدون آرایه، با دو متغیر کمکی). برای چه nای از int سرریز می‌کند؟

Level 4 : Challenge
  1. بازی «حدس عدد با جستجوی دودویی»: برنامه عددی بین ۱ تا ۱۰۰۰ را (ثابت، مثلاً ۷۳۷) در نظر می‌گیرد و حدس‌ها را از ورودی می‌خواند. پس از هر حدس، «Higher» یا «Lower» چاپ می‌کند. سپس برنامه‌ی دومی بنویسید که خودش با روش نصف‌کردن بازه (low، high، mid) عدد را پیدا کند و تعداد حدس‌ها را بشمارد. بیشترین تعداد حدس لازم چند است؟ (راهنمایی: log₂ 1000.)

Final Challenge : مسئله جامع بخش چهارم

برنامه‌ی تحلیل نمرات کلاس

برنامه‌ای بنویسید که یک منوی تعاملی داشته باشد:

  1. افزودن نمره: یک نمره از ورودی بخواند. اگر خارج از بازه‌ی ۰ تا ۲۰ بود، تا گرفتن نمره‌ی معتبر دوباره بپرسد (حداکثر با ورودی تمام‌شدنی). نمره‌ی معتبر باید در آمار ثبت شود.

  2. نمایش آمار: تعداد نمره‌ها، میانگین، بیشترین و کمترین را چاپ کند. اگر هنوز نمره‌ای وارد نشده، پیام مناسب بدهد (و تقسیم بر صفر نکند).

  3. نمایش توزیع: تعداد نمره‌ها در هر دسته (مردود: زیر ۱۰، قبول: ۱۰ تا ۱۳، خوب: ۱۴ تا ۱۶، عالی: ۱۷ تا ۲۰) را به‌صورت هیستوگرام ستاره‌ای چاپ کند (یک سطر برای هر دسته، به‌تعداد نمره‌ها ستاره).

  4. خروج.

این مسئله عمداً چند مفهوم بخش را ترکیب می‌کند: منو با do-while (یا while (1)) و switch؛ اعتبارسنجی نمره با do-while؛ آمار با الگوهای Counter/Accumulator/Min-Max (بدون آرایه، با متغیرهای کمکی)؛ درجه‌بندی با زنجیره‌ی else if مرتب‌شده؛ و هیستوگرام با حلقه‌های تودرتو.

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

راهنمایی 1:

برای نگه‌داری آمار بدون آرایه، قبل از حلقه‌ی منو این متغیرها را تعریف کنید: count، sum، min، max، و چهار شمارنده‌ی fail، pass، good، excellent. فراموش نکنید min و max را با اولین نمره مقداردهی کنید، نه با یک عدد دلخواه (چطور می‌فهمید اولین نمره است؟ از count == 0).

راهنمایی 2:

چون منو باید حداقل یک‌بار نمایش داده شود، ساختار کلی do { ...نمایش منو، خواندن انتخاب، switch... } while (choice != 4); مناسب است. مراقب باشید scanf در پایان ورودی شکست می‌خورد؛ این حالت را با یک break یا return مدیریت کنید تا حلقه بی‌نهایت نشود.

راهنمایی 3:

برای هیستوگرام، برای هر دسته یک printf برای نام دسته و سپس یک for ساده برای چاپ ستاره‌ها بنویسید. اگر بخواهید همین کار را برای چهار دسته با کمتر تکرار کد انجام دهید، فعلاً کد تکراری قابل قبول است؛ در بخش توابع، راه تمیزتری یاد می‌گیرید.

راهنمایی 4:

حالت‌های مرزی را حتماً تست کنید: خروج بدون هیچ نمره‌ای، نمایش آمار قبل از ثبت نمره، نمره‌ی 0 و 20 (مرزهای بازه)، و نمره‌های 9، 10، 13، 14، 16، 17 که مرزهای دسته‌بندی‌اند.



با پایان این بخش، شما اکنون دو ابزار بنیادین برنامه‌نویسی را در اختیار دارید: تصمیم‌گیری (Selection) و تکرار (Iteration). همراه با اجرای ترتیبی، این سه ساختار برای نوشتن هر الگوریتمی کافی‌اند. اما شما فقط سینتکس را یاد نگرفتید؛ شما یاد گرفتید یک مسئله را به الگوی مناسب تبدیل کنید، درستی آن را با Trace و تست مرزی بسنجید و هزینه‌اش را بشمارید. این مهارت‌ها پایه‌ی بسیاری از موضوع‌های بعدی (آرایه‌ها، جستجو، مرتب‌سازی) هستند، که هر کدام با یک حلقه روی مجموعه‌ای از داده شروع می‌شوند.



نگاهی به یک دانشمند تأثیرگذار در این حوزه

خب، حالا که با ساختارهای تصمیم و تکرار آشنا شدیم و دیدیم چرا از goto پرهیز می‌کنیم، بد نیست کمی هم با دانشمندی آشنا شویم که این نگاه را در علوم کامپیوتر جا انداخت.

ادسخر ویبه دایکسترا (Edsger W. Dijkstra) دانشمند علوم کامپیوتر هلندی (۱۹۳۰ تا ۲۰۰۲) بود. او الگوریتم معروف کوتاه‌ترین مسیر در گراف‌ها را (که تقریباً هر نقشه‌یاب امروزی ریشه‌ای از آن دارد) در دهه‌ی ۱۹۵۰ ابداع کرد. اما شاید تأثیرگذارترین کار او در نگاه ما به «ساختار برنامه» باشد: در سال ۱۹۶۸ نامه‌ی کوتاه او درباره‌ی دستور goto منتشر شد و بحثی را آغاز کرد که به برنامه‌نویسی ساخت‌یافته (Structured Programming) انجامید. استدلال او فنی بود، نه سلیقه‌ای: اگر نتوانیم از روی متن برنامه رفتار آن در زمان اجرا را پیش‌بینی کنیم، نمی‌توانیم درستی‌اش را اثبات یا حتی با اطمینان بررسی کنیم. او در سال ۱۹۷۲ جایزه‌ی تورینگ را دریافت کرد.

جالب است که اگر به کاری که در این بخش کردیم نگاه کنیم، مسیر فکری دایکسترا چندان دور از آن نیست: یک برنامه‌ی پیچیده ← تشخیص این‌که فهمش سخت است ← پیدا کردن ساختارهایی که استدلال درباره‌ی آن‌ها آسان است (یک ورودی، یک خروجی) ← ساختن همه‌چیز از همان‌ها. نامتغیر حلقه، شرط توقف و Trace که در این بخش تمرین کردید، همه ابزارهای همین نگاه‌اند.

شاید یکی از نکات جالب این داستان این باشد که یکی از مؤثرترین ایده‌های برنامه‌نویسی، نه افزودن یک قابلیت جدید، بلکه «محدود کردن» قدرت برنامه‌نویس به ساختارهای قابل فهم بود.