۳۰ – رفع اشکال (Troubleshooting)

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


خطاهای نحوی (Syntactic Errors)

یک دستهٔ کلی از خطاها، خطاهای نحوی هستند. خطاهای نحوی شامل اشتباه تایپی یا اشتباه در عناصر دستوری شل هستند. در بیشتر موارد، این نوع خطاها باعث می‌شوند شل از اجرای اسکریپت امتناع کند.

در بحث‌های زیر، از این اسکریپت برای نشان دادن انواع رایج خطا استفاده می‌کنیم:

#!/bin/bash
# trouble: script to demonstrate common errors

number=1
if [ $number = 1 ]; then
    echo "Number is equal to 1."
else
    echo "Number is not equal to 1."
fi

این اسکریپت همان‌طور که نوشته شده، بدون مشکل اجرا می‌شود:

Number is equal to 1.

گم‌شدن کوتیشن‌ها (Missing Quotes)

اگر اسکریپت را ویرایش کنیم و کوتیشن پایانی آرگومان echo اول را حذف کنیم:

#!/bin/bash
# trouble: script to demonstrate common errors

number=1
if [ $number = 1 ]; then
    echo "Number is equal to 1.
else
fi
echo "Number is not equal to 1."

خروجی این خواهد بود:

unexpected EOF while looking for matching `"'  
syntax error: unexpected end of file

دو خطا ایجاد می‌شود. خط‌ها هم جایی را نشان می‌دهند که اشتباه در آن نیست، بلکه خیلی جلوتر. دلیلش مشخص است: بعد از حذف کوتیشن، bash دنبال کوتیشن بسته می‌گردد تا وقتی یکی پیدا کند. و آن را بعد از echo دوم پیدا می‌کند. همین باعث سردرگمی کامل bash می‌شود و در نهایت ساختار دستور if می‌شکند، چون دستور fi داخل یک رشتهٔ باز قرار گرفته است.

در اسکریپت‌های طولانی، پیدا کردن این نوع خطا واقعاً سخت می‌شود. استفاده از ویرایشگری که هایلایت نحوی دارد، کمک بزرگی است. اگر نسخهٔ کامل vim نصب باشد، هایلایت نحوی با دستور زیر فعال می‌شود:

:syntax on

توکن‌های گم‌شده یا غیرمنتظره (Missing or Unexpected Tokens)

اشتباه رایج دیگر این است که یک دستور ترکیبی—for example, if یا while—ناقص رها شود.

مثلاً ببینیم اگر سمی‌کالن بعد از test را حذف کنیم چه می‌شود:

#!/bin/bash
# trouble: script to demonstrate common errors

number=1
if [ $number = 1 ] then
    echo "Number is equal to 1."
else
fi
echo "Number is not equal to 1."

نتیجه:

syntax error near unexpected token `else'
`else'

دوباره، پیام خطا به جایی اشاره می‌کند که مشکل واقعی نیست، بلکه جلوتر است.

دلیلش جالب است: دستور if یک لیست از دستورات را می‌پذیرد و وضعیت خروج آخرین دستور را بررسی می‌کند. ما انتظار داریم این لیست شامل یک دستور باشد: [ که همان test است.

دستور [ تمام کلماتی را که بعدش می‌آید به عنوان آرگومان می‌گیرد. در اینجا آرگومان‌ها می‌شوند:
$number, 1, =, ]

اما وقتی سمی‌کالن را حذف کنیم، کلمهٔ then هم به این فهرست آرگومان‌ها اضافه می‌شود—که از نظر نحوی مشکلی ندارد. حتی دستور echo بعدش هم معتبر است؛ چون شل آن را به صورت دستور دیگری در لیست دستورات if تفسیر می‌کند.

اما وقتی به else می‌رسد، مشکل شروع می‌شود. چون else یک reserved word است و شل نمی‌تواند آن را به‌عنوان یک دستور معمولی در لیست دستورات if تفسیر کند. در نتیجه خطای "unexpected token" می‌دهد.


گسترش‌های غیرمنتظره (Unanticipated Expansions)

گاهی ممکن است خطاهایی در اسکریپت وجود داشته باشند که فقط بعضی وقت‌ها خودشان را نشان بدهند. یک زمان اسکریپت بدون مشکل اجرا می‌شود و زمان دیگر شکست می‌خورد—به خاطر نتیجهٔ یک expansion.

اگر سمی‌کالن حذف‌شده را دوباره برگردانیم و مقدار متغیر number را خالی بگذاریم، می‌توانیم این مشکل را نشان بدهیم:

#!/bin/bash
# trouble: script to demonstrate common errors

number=
if [ $number = 1 ]; then
    echo "Number is equal to 1."
else
    echo "Number is not equal to 1."
fi

خروجی:

[: =: unary operator expected
Number is not equal to 1.

یک پیام خطای نامأنوس دریافت می‌کنیم و سپس خروجی echo دوم.

مشکل از expansion متغیر number داخل دستور test است.

وقتی این دستور:

[ $number = 1 ]

expansion شود و number خالی باشد، نتیجه به این شکل درمی‌آید:

[  = 1 ]

این از نظر نحوی نامعتبر است و خطا تولید می‌شود.
عملگر = یک عملگر دوتایی است—یعنی باید دو طرف آن مقدار وجود داشته باشد—اما اینجا مقدار سمت چپ وجود ندارد. به همین دلیل test انتظار یک عملگر یک‌تایی دارد (مثل -z)، و چون آن را نمی‌یابد، خطا رخ می‌دهد.

چون test به‌خاطر خطا با وضعیت غیرصفر خارج می‌شود، دستور if هم آن را شکست تلقی کرده و بخش else اجرا می‌شود.

راه‌حل این مشکل ساده است: باید آرگومان اول را درون کوتیشن قرار دهیم:

[ "$number" = 1 ]

در این صورت، پس از expansion نتیجه این است:

[ "" = 1 ]

که از نظر نحوی معتبر است (سه آرگومان دارد).

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


خطاهای منطقی (Logical Errors)

برخلاف خطاهای نحوی، خطاهای منطقی باعث نمی‌شوند اسکریپت اجرا نشود؛ بلکه اسکریپت اجرا می‌شود اما نتیجه‌ٔ اشتباه تولید می‌کند—چون منطق برنامه ایراد دارد.

تعداد خطاهای منطقی بی‌شمار است، اما رایج‌ترین‌ها در اسکریپت‌ها این‌ها هستند:


۱. عبارت‌های شرطی نادرست

خیلی راحت می‌شود if/then/else را اشتباه نوشت و منطق اشتباهی اجرا شود.
گاهی منطق برعکس می‌شود، گاهی ناقص است.


۲. خطاهای “یک واحد اختلاف” (Off by one)

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

نتیجهٔ این خطاها:


۳. موقعیت‌های پیش‌بینی‌نشده

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

این شامل مواردی مانند:


برنامه‌نویسی تدافعی (Defensive Programming)

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

یک مثال بزنیم—بر اساس یک اتفاق واقعی. یک مدیر سیستم بدشانس اسکریپتی نوشت برای انجام یک وظیفهٔ نگهداری روی یک سرور مهم. اسکریپت شامل دو خط زیر بود:

cd $dir_name
rm *

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

بیایید ببینیم چطور می‌شود طراحی را بهتر کرد.

اولین قدم این است که اجرای rm وابسته به موفقیت cd باشد:

cd $dir_name && rm *

به این ترتیب، اگر cd شکست بخورد، rm اجرا نمی‌شود.
اما هنوز یک مشکل باقی است: ممکن است مقدار dir_name خالی یا unset باشد. در این صورت اسکریپت وارد دایرکتوری فعلی کاربر می‌شود و تمام فایل‌های آن را حذف می‌کند. این هم باید جلوگیری شود.

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

[[ -d $dir_name ]] && cd $dir_name && rm *

اما بهترین راه این است که اسکریپت در این شرایط همان اول خراب شود و متوقف شود:

# Delete files in directory $dir_name

if [[ ! -d "$dir_name" ]]; then
    echo "No such directory: '$dir_name'" >&2
    exit 1
fi

if ! cd $dir_name; then
    echo "Cannot cd to '$dir_name'" >&2
    exit 1
fi

if ! rm *; then
    echo "File deletion failed. Check results" >&2
    exit 1
fi

در اینجا، هم نام دایرکتوری را چک می‌کنیم و هم نتیجهٔ دستور cd را. اگر هرکدام شکست بخورد، پیام خطا به standard error می‌رود و اسکریپت با وضعیت خروج ۱ متوقف می‌شود.


بررسی ورودی (Verifying Input)

یک قاعدهٔ کلی در برنامه‌نویسی این است: هر برنامه‌ای که ورودی دریافت می‌کند، باید بتواند هر چیزی که دریافت می‌کند را مدیریت کند.

این یعنی ورودی باید با دقت بررسی شود تا فقط ورودی معتبر اجازهٔ پردازش داشته باشد.

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

[[ $REPLY =~ ^[0-3]$ ]]

این تست بسیار دقیق است. فقط وقتی مقدار REPLY یک عدد بین ۰ تا ۳ باشد، نتیجهٔ صفر برمی‌گرداند. هر مقدار دیگری رد می‌شود. نوشتن چنین تست‌هایی سخت است، اما برای تولید اسکریپت با کیفیت لازم است.


طراحی تابعِ زمان است (Design Is a Function of Time)

نویسنده یک خاطره می‌گوید:

وقتی دانشجوی طراحی صنعتی بودم، استادم می‌گفت:

درجهٔ کیفیت طراحی یک پروژه کاملاً بستگی به زمانی دارد که برای آن در نظر گرفته شده است.
اگر پنج دقیقه وقت داشته باشید وسیله‌ای بسازید که مگس بکشد، یک مگس‌کُش طراحی می‌کنید.
اگر پنج ماه وقت داشته باشید، شاید یک «سیستم ضدمگس لیزری هدایت‌شونده» بسازید.

همین اصل درباره برنامه‌نویسی هم صدق می‌کند.


تست کردن (Testing)

تست در هر نوع توسعهٔ نرم‌افزار ضروری است—حتی اسکریپت‌ها.

یک ضرب‌المثل معروف در دنیای متن‌باز می‌گوید:

«زود منتشر کن، زیاد منتشر کن.»

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

در بحث قبلی دیدیم که چطور می‌توان از stub‌ها برای بررسی مسیر اجرای برنامه استفاده کرد. از همان مراحل اولیهٔ توسعهٔ اسکریپت‌ها، این تکنیک‌ها ارزشمند هستند.

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

if [[ -d $dir_name ]]; then
    if cd $dir_name; then
        echo rm *    # TESTING
    else
        echo "cannot cd to '$dir_name'" >&2
        exit 1
    fi
else
    echo "no such directory: '$dir_name'" >&2
    exit 1
fi

exit   # TESTING

نکات مهم:


Test Cases — موارد آزمون

برای انجام یک تست مفید، لازم است مجموعهٔ مناسبی از تست‌ها طراحی و اجرا شود. این کار با انتخاب دقیق داده‌های ورودی یا شرایط اجرایی انجام می‌شود، به‌طوری که حالات مرزی (edge cases) و حالات گوشه‌ای (corner cases) را پوشش دهد.

در قطعه‌کد مثال‌مان (که بسیار ساده است)، می‌خواهیم بدانیم کد تحت سه وضعیت مشخص چطور عمل می‌کند:

  1. dir_name نام یک دایرکتوری موجود را دارد
  2. dir_name نام یک دایرکتوری غیرموجود را دارد
  3. dir_name خالی است

با اجرای تست در هر سه حالت، ما پوشش تست خوبی به‌دست می‌آوریم.

همچون طراحی، تست هم تابع زمان است. همیشه نیازی نیست تمام ویژگی‌های اسکریپت به‌طور گسترده تست شوند؛ مهم این است که مشخص کنیم کدام بخش‌ها حیاتی‌ترند. چون کد نمونهٔ ما در صورت خطا می‌تواند ویرانگر باشد، باید در طراحی و تست آن دقت بسیار بیشتری صرف کرد.


Debugging — اشکال‌زدایی

اگر تست نشان دهد که اسکریپت مشکل دارد، مرحلهٔ بعدی دیباگ کردن است.
«مشکل» یعنی اسکریپت برخلاف انتظار برنامه‌نویس رفتار می‌کند.

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

اسکریپت خوب نوشته‌شده تلاش می‌کند کمک کند: برنامه‌نویسی تدافعی، شناسایی شرایط غیرعادی، و ارائهٔ پیام‌های مفید؛ اما بعضی مشکلات عجیب‌ترند و به تکنیک‌های پیشرفته‌تر نیاز دارند.


Finding the Problem Area — پیدا کردن ناحیهٔ مشکل

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

یکی از روش‌های ایزوله‌سازی، کامنت‌کردن بخش‌هایی از اسکریپت است.

مثال:

if [[ -d $dir_name ]]; then
    if cd $dir_name; then
        rm *
    else
        echo "cannot cd to '$dir_name'" >&2
        exit 1
    fi
# else
#
#
fi

exit 1
echo "no such directory: '$dir_name'" >&2

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


Tracing — ردگیری اجرای برنامه

خیلی از باگ‌ها نتیجهٔ جریان منطقی غیرمنتظره هستند:
بخش‌هایی از اسکریپت اجرا نمی‌شوند، یا در زمان یا ترتیب اشتباه اجرا می‌شوند.

برای مشاهدهٔ جریان واقعی اجرای برنامه، از ردگیری (tracing) استفاده می‌کنیم.

ردگیری دستی با پیام‌های اطلاع‌رسان

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

echo "preparing to delete files" >&2

if [[ -d $dir_name ]]; then
    if cd $dir_name; then
        echo "deleting files" >&2
        rm *
    else
        echo "cannot cd to '$dir_name'" >&2
        exit 1
    fi
else
    echo "no such directory: '$dir_name'" >&2
    exit 1
fi

echo "file deletion complete" >&2

پیام‌ها را به stderr می‌فرستیم تا با خروجی عادی قاطی نشوند.
عمداً این خط‌ها را بدون تورفتگی می‌گذاریم تا بعداً راحت پیدایشان کنیم.

خروجی نمونه:

preparing to delete files
deleting files
file deletion complete

Tracing با ویژگی داخلی Bash — گزینه‌ی -x

Bash روش دیگری هم دارد:
گزینه‌ی -x که باعث می‌شود هر دستور با مقادیر گسترش‌یافته‌اش چاپ شود.

مثال:

#!/bin/bash -x
# trouble: script to demonstrate common errors

number=1

if [ $number = 1 ]; then
    echo "Number is equal to 1."
else
    echo "Number is not equal to 1."
fi

خروجی:

+ number=1
+ '[' 1 = 1 ']'
+ echo 'Number is equal to 1.'
Number is equal to 1.

علامت + نشان می‌دهد این خطوط متعلق به trace هستند.

این علامت از متغیر PS4 می‌آید.
می‌توانیم PS4 را طوری تغییر دهیم که شماره خط هم نمایش داده شود:

export PS4='$LINENO + '

و خروجی:

5 + number=1
7 + '[' 1 = 1 ']'
8 + echo 'Number is equal to 1.'
Number is equal to 1.

Tracing بخشی از اسکریپت

برای ردگیری فقط یک بخش، نه کل اسکریپت، می‌توانیم از set -x استفاده کنیم:

#!/bin/bash
# trouble: script to demonstrate common errors

number=1

set -x   # Turn on tracing

if [ $number = 1 ]; then
    echo "Number is equal to 1."
else
    :
fi

echo "Number is not equal to 1."

set +x   # Turn off tracing

با set -x tracing فعال می‌شود، با set +x غیرفعال.
می‌توانیم چند بخش مختلف از اسکریپت را بررسی کنیم بدون اینکه کل اسکریپت را شلوغ کنیم.


Examining Values During Execution — بررسی مقادیر هنگام اجرا

گاهی لازم است همراه با tracing، مقدار متغیرها را هم نمایش بدهیم تا ببینیم اسکریپت از داخل دقیقاً چه می‌کند. اضافه کردن چند دستور echo معمولاً کافی است:

#!/bin/bash
# trouble: script to demonstrate common errors

number=1
echo "number=$number"   # DEBUG

set -x   # Turn on tracing
if [ $number = 1 ]; then
    echo "Number is equal to 1."
else
    echo "Number is not equal to 1."
fi
set +x   # Turn off tracing

در این مثال ساده، فقط مقدار متغیر number را نمایش می‌دهیم و خط اضافه‌شده را با کامنت مشخص کرده‌ایم تا بعداً راحت حذف شود. این روش مخصوصاً هنگام بررسی رفتار حلقه‌ها و محاسبات عددی داخل اسکریپت‌ها بسیار مفید است.


Summing Up — جمع‌بندی

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

دیباگ‌کردن یک مهارت هنری است که از طریق تجربه رشد می‌کند— هم در پیشگیری از باگ (تست مداوم در طول توسعه)، و هم در پیدا کردن باگ (استفادهٔ مؤثر از tracing).


Further Reading — برای مطالعهٔ بیشتر

● مقالات کوتاه ویکی‌پدیا دربارهٔ خطاهای نحوی و منطقی:
http://en.wikipedia.org/wiki/Syntax_error
http://en.wikipedia.org/wiki/Logic_error

● منابع آنلاین مفید برای جنبه‌های تکنیکی برنامه‌نویسی bash:
http://mywiki.wooledge.org/BashPitfalls
http://tldp.org/LDP/abs/html/gotchas.html
http://www.gnu.org/software/bash/manual/html_node/Reserved-Word-Index.html

● کتاب The Art of Unix Programming نوشتهٔ اریک ریموند—منبعی عالی برای یادگیری مفاهیم پایهٔ برنامه‌نویسی یونیکس که بسیاری از آن‌ها در اسکریپت‌نویسی شل هم کاربرد دارند:
http://www.faqs.org/docs/artu/
http://www.faqs.org/docs/artu/ch01s06.html

● برای دیباگ سنگین و حرفه‌ای bash، Bash Debugger قابل استفاده است:
http://bashdb.sourceforge.net/