برنامه‌نویسی پایتون فهرست مطالب
فصل ۱۹

خطاها و استثناها

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

در این فصل می‌خوانید
  1. سه نوع مشکل
  2. اولین try و except
  3. گرفتن پیام خطا
  4. استثناهای پرتکرار
  5. چند except برای یک try
  6. else و finally
  7. چرا except خالی بد است
  8. دستور raise
  9. تابع خواندن امن عدد
  10. دستور assert
  11. ماژول logging

۱. سه نوع مشکل

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

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

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

۲. اولین try و except

# Without a try, this line stops the whole program:
#     x = 10 / 0
#     ZeroDivisionError: division by zero

# 'try' contains the risky code, 'except' says what to do when it fails.
try:
    x = 10 / 0
    print(x)
except ZeroDivisionError:
    print('Division by zero error')

print('The program continues normally.')

اولین try: نمونه 01_zero_division.py

Division by zero error
The program continues normally.

شرح مثال

۳. گرفتن پیام خطا

# int() raises ValueError when the text is not a number.
text = 'Kadoos'

try:
    age = int(text)
    print(age)
except ValueError as e:
    # 'as e' gives us the exception object, which contains the message.
    print("Can't convert this string to an integer!")
    print('The message of Python was:', e)

گرفتن پیام: نمونه 02_value_error.py

Can't convert this string to an integer!
The message of Python was: invalid literal for int() with base 10: 'Kadoos'

با as e خود شیء استثنا را می‌گیرید. چاپ کردنش پیام اصلی پایتون را می‌دهد و type(e).__name__ نام نوع خطا را. برای گزارش‌گیری بسیار مفید است.

۴. استثناهای پرتکرار

استثناکی رخ می‌دهد
ValueErrorنوع درست است ولی مقدار بی‌معناست، مثل int('abc')
TypeErrorنوع غلط است، مثل 'a' + 1
IndexErrorاندیس بیرون از لیست
KeyErrorکلید ناموجود در دیکشنری
FileNotFoundErrorفایل وجود ندارد
PermissionErrorاجازه دسترسی به فایل نیست
ZeroDivisionErrorتقسیم بر صفر
AttributeErrorشیء چنین صفت یا متدی ندارد
ImportErrorماژول پیدا نشد
items = [10, 20, 30]

try:
    print(items[5])
except IndexError:
    print('This index does not exist!')

# The same protection without an exception:
index = 5
if 0 <= index < len(items):
    print(items[index])
else:
    print('index out of range (checked with an if)')

خطای اندیس: نمونه 03_index_error.py

# Opening a file that does not exist raises FileNotFoundError.
try:
    with open('myfile.txt', encoding='utf-8') as f:
        print(f.read())
except FileNotFoundError:
    print('File not found.')
except PermissionError:
    print('You are not allowed to read this file.')

فایل ناموجود: نمونه 05_file_not_found.py

با if جلوگیری کنیم یا با try بگیریم

هر دو درست‌اند. وقتی می‌شود راحت جلوگیری کرد، مثل بررسی اندیس یا dict.get، همان بهتر است. ولی برای فایل، بررسی کردن کافی نیست: ممکن است بین لحظه بررسی و لحظه باز کردن، فایل پاک شود. قاعده عملی: برای منطق برنامه if، برای چیزهایی که بیرون از کنترل شما هستند مثل فایل و شبکه، try.

۵. چند except برای یک try

# One 'try' can have several 'except' blocks: Python runs the first one
# that matches the exception that happened.
text = 'AA'          # try also with '10' and with '1'
numbers = [1, 2, 3]

try:
    index = int(text)          # may raise ValueError
    print(numbers[index])      # may raise IndexError
except ValueError:
    print('Conversion error!')
except IndexError:
    print('Index error!')

# Several exceptions can share the same block.
try:
    index = int(text)
    print(numbers[index])
except (ValueError, IndexError) as e:
    print('Something went wrong:', type(e).__name__, '-', e)

چند except: نمونه 06_several_excepts.py

پایتون از بالا به پایین اولین except مناسب را اجرا می‌کند، درست مثل elif. اگر چند خطا واکنش یکسانی دارند، می‌توانید همه‌شان را در یک تاپل بگذارید.

۶. else و finally

# The complete shape:
#   try     -> the risky code
#   except  -> what to do when it fails
#   else    -> runs only when NOTHING failed
#   finally -> runs ALWAYS (success or failure), used to clean up
user_input = input('Enter a number: ')

try:
    number = int(user_input)
    x = 10 / number
    print(x)
except ZeroDivisionError:
    print('Division by zero error')
except ValueError:
    print('That was not a number')
else:
    print('The division is done!')
finally:
    print('Error or no error, this always runs.')

ساختار کامل: نمونه 07_else_finally.py

بخشکی اجرا می‌شود
tryهمیشه، تا وقتی خطا رخ دهد
exceptفقط وقتی آن نوع خطا رخ داده باشد
elseفقط وقتی هیچ خطایی رخ نداده باشد
finallyهمیشه، چه خطا باشد چه نباشد

finally برای پاک‌سازی است: بستن فایل، قطع اتصال پایگاه داده، آزاد کردن منابع. حتی اگر داخل try دستور return باشد، باز هم finally اجرا می‌شود. البته با وجود with، بیشتر کاربردهای قدیمی‌اش دیگر ضرورتی ندارد.

۷. چرا except خالی بد است

# A bare 'except:' catches EVERYTHING, even the mistakes in your own code
# and Ctrl-C. The bug then becomes invisible - never do this.
try:
    x = 10 / 0
except:
    print('Something is wrong!')      # but WHAT exactly? we have no idea

# Catch the exception you expect ...
try:
    x = 10 / 0
except ZeroDivisionError as e:
    print('Clear message:', e)

# ... or, if you really must catch everything, at least keep the object.
try:
    x = 10 / 0
except Exception as e:
    print(f'{type(e).__name__}: {e}')

except خالی: نمونه 08_bare_except.py

بدترین عادت برنامه‌نویسی

except: خالی همه چیز را می‌گیرد، از جمله غلط تایپی خودتان و حتی Ctrl + C کاربر. نتیجه این است که باگ نامرئی می‌شود: برنامه پیام مبهمی می‌دهد و شما هفته‌ها نمی‌فهمید مشکل کجاست. همیشه نوع خطا را بنویسید. اگر واقعاً مجبورید همه چیز را بگیرید، دست‌کم except Exception as e بنویسید و پیام را چاپ کنید.

۸. دستور raise

# 'raise' creates an exception ourselves. It is how a function says
# "the value you gave me makes no sense".
def set_age(age: int) -> int:
    if age < 0:
        raise ValueError("Age can't be negative!")
    if age > 130:
        raise ValueError('Age is unrealistic!')
    return age


try:
    set_age(-5)
except ValueError as e:
    print('Refused:', e)

# Re-raising after logging is a common pattern: we note the problem,
# but we let the caller decide what to do.
def read_config(path: str) -> str:
    try:
        with open(path, encoding='utf-8') as f:
            return f.read()
    except FileNotFoundError:
        print(f'[warning] {path} is missing')
        raise            # send the same exception further up


try:
    read_config('nothing.cfg')
except FileNotFoundError:
    print('The caller handled it.')

دستور raise: نمونه 09_raise.py

شرح مثال

۹. تابع خواندن امن عدد

مفیدترین چیز این فصل. این دو تابع را در هر برنامه‌ای که ورودی می‌گیرد می‌توانید کپی کنید.

'''The most useful pattern of this session: reading a valid number.'''


def read_number(message: str) -> float:
    'Ask until the user really types a number.'
    while True:
        user_input = input(message)
        try:
            return float(user_input)
        except ValueError:
            print('That is not a number, try again.')


def read_int(message: str, minimum: int, maximum: int) -> int:
    'Ask until the user types a whole number inside the range.'
    while True:
        try:
            value = int(input(message))
        except ValueError:
            print('Please type digits only.')
            continue

        if value < minimum or value > maximum:
            print(f'The value must be between {minimum} and {maximum}.')
            continue

        return value


def main() -> None:
    a = read_number('Enter a number: ')
    b = read_number('Enter another number: ')
    print('Sum:', a + b)

    age = read_int('Enter your age (1-120): ', 1, 120)
    print('Your age is', age)


if __name__ == '__main__':
    main()

خواندن امن: نمونه 10_safe_input.py

ترکیب سه چیز است: حلقه بی‌پایان فصل هشتم، try این فصل، و return که هم مقدار را می‌دهد و هم حلقه را تمام می‌کند. مقایسه کنید با نسخه فصل هشتم که با isdigit() کار می‌کرد: این نسخه اعشاری و منفی را هم درست مدیریت می‌کند.

۱۰. دستور assert

# 'assert condition, message' stops the program with an AssertionError when the
# condition is False. It is a tool for the DEVELOPER, not for the user:
# it documents an assumption and catches a bug early.
def average(numbers: list[float]) -> float:
    assert len(numbers) > 0, 'average() needs at least one number'
    return sum(numbers) / len(numbers)


print(average([10, 20, 30]))

try:
    average([])
except AssertionError as e:
    print('AssertionError:', e)

# Careful: assertions disappear when Python is run with the -O option,
# so never use assert to validate what a USER typed - use if/raise for that.

دستور assert: نمونه 11_assert.py

assertraise
برای چه کسیبرنامه‌نویسکاربر و صداکننده تابع
چه می‌گوید«این نباید هرگز رخ دهد»«ورودی نامعتبر است»
در حالت بهینهحذف می‌شودباقی می‌ماند
اعتبارسنجی ورودی کاربرهرگزبله

نکته حیاتی: اگر پایتون با گزینه -O اجرا شود، همه assert ها حذف می‌شوند. پس هرگز برای بررسی ورودی کاربر یا برای امنیت به آن تکیه نکنید.

۱۱. ماژول logging

# print() is fine while learning, but a real program uses 'logging':
# every message has a level, a time stamp, and can be sent to a file.
import logging

logging.basicConfig(
    level=logging.DEBUG,                     # show everything from DEBUG upwards
    format='%(asctime)s [%(levelname)s] %(message)s',
    datefmt='%H:%M:%S',
    # filename='app.log',                    # uncomment to write into a file
)

logger = logging.getLogger(__name__)


def divide(a: float, b: float) -> float | None:
    logger.debug('divide(%s, %s) was called', a, b)
    try:
        result = a / b
    except ZeroDivisionError:
        # exception() also prints the traceback - perfect inside an except block
        logger.exception('division by zero')
        return None
    logger.info('the result is %s', result)
    return result


def main() -> None:
    logger.info('program started')
    divide(10, 2)
    divide(10, 0)
    logger.warning('this is a warning')
    logger.error('this is an error')
    logger.info('program finished')


if __name__ == '__main__':
    main()

ماژول logging: نمونه 12_logging.py

14:32:10 [INFO] program started
14:32:10 [DEBUG] divide(10, 2) was called
14:32:10 [INFO] the result is 5.0
14:32:10 [DEBUG] divide(10, 0) was called
14:32:10 [ERROR] division by zero
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero
14:32:10 [WARNING] this is a warning
سطحکی به کار می‌رود
DEBUGجزئیات فنی، فقط هنگام توسعه
INFOگزارش کارهای عادی
WARNINGچیزی عجیب است ولی برنامه کار می‌کند
ERRORکاری انجام نشد
CRITICALبرنامه نمی‌تواند ادامه دهد

چرا logging بهتر از print است

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

پوشه S19-errors-and-exceptions شامل نمونه‌های 01_zero_division.py تا 12_logging.py.

خلاصه فصل

ابزاروظیفه آن
try / exceptگرفتن خطا به جای خراب شدن برنامه
except X as eدسترسی به پیام و نوع خطا
except (A, B)یک واکنش برای چند خطا
elseوقتی هیچ خطایی نبود
finallyهمیشه. برای پاک‌سازی
except: خالیهرگز. باگ را نامرئی می‌کند
raise ValueError(msg)اعلام خطا از داخل تابع
raise خالیفرستادن همان خطا به لایه بالاتر
assertبررسی فرض برنامه‌نویس. برای ورودی کاربر نه
loggingجایگزین حرفه‌ای print، با سطح و زمان و فایل

تمرین‌های فصل نوزده

  1. ماشین‌حسابی بنویسید که هر چه کاربر تایپ کند، هرگز خراب نشود.
  2. تابع safe_divide(a, b) را بنویسید که به جای خطا، None برگرداند.
  3. نام فایلی را از کاربر بگیرید و اگر وجود نداشت، پیام روشنی بدهید.
  4. تابع read_age() را بنویسید که تا وقتی جواب عدد صحیح بین ۱ تا ۱۲۰ نشده بپرسد.
  5. تابعی بنویسید که اگر لیست خالی بگیرد، ValueError بیندازد.
  6. هنگام خواندن دیکشنری KeyError را بگیرید و آن راه حل را با dict.get() مقایسه کنید.
  7. به یکی از برنامه‌های قبلی‌تان logging اضافه کنید و گزارش را در فایل بنویسید.
  8. فرق assert و raise را با یک مثال از هر کدام توضیح دهید.
فصل قبل: فایل‌ها و سیستم‌عامل فصل بعد: پروژه‌های ترم اول