در این فصل چه یاد میگیری#
وقتی کدت روی ۷۸۲ ردیف میترکد، کارِ رایج این است که به کد خیره شوی و print بکاری. این فصل بهجایش سه روش میدهد که هرکدام یک عددِ قابلِاندازهگیری دارند.
جستوجوی دوبخشی ردیفِ مقصر را در ۱۰ گام پیدا میکند، بهجای ۷۸۲ بار امتحان. بعد یک کوچککننده مینویسیم که ۷۸۲ ردیف را با ۲۲ اجرا به ۱ ردیف میرساند. و آخرش سراغِ باگی میرویم که جستوجوی دوبخشی روی آن دروغ میگوید — ردیفی را متهم میکند که بهتنهایی سالم است — و همان کوچککننده با ۳۷ اجرا جفتِ واقعیِ مقصر را پیدا میکند.

آخر این فصل میتوانی:
- با
pdbدر لحظهٔ خطا بایستی و مقدارِ متغیرها را ببینی - فضای جستوجوی یک باگ را نصفنصف کوچک کنی
- کوچکترین ورودیِ بازتولیدکننده را خودکار بسازی
assertرا بهعنوان تله بگذاری تا باگِ بعدی سرِ جای خودش گیر بیفتد
قبل از شروع#
از فصلِ ۴: قرارداد جلوی دادهٔ بدِ شناختهشده را میگیرد. این فصل دربارهٔ باگی است که هنوز نمیدانی چیست.
از سرنخ ترمِ ۱ فصل ۸: خواندنِ traceback از پایین به بالا. اینجا فرض میکنیم بلدی و یک قدم جلوتر میرویم.
| اجرا | زمانِ تقریبی |
|---|---|
| CPU (پیشفرضِ Colab) | حدودِ یک دقیقه |
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
۱. باگی که سرِ ۷۸۲ ردیف میترکد#
import json
from pathlib import Path
Path("features.py").write_text(r'''def clean(text):
return text.strip()
def words(text):
return clean(text).split()
def average_word_length(text):
parts = words(text)
return sum(len(w) for w in parts) / len(parts)
def summarise(rows):
return sum(average_word_length(r["text"]) for r in rows) / len(rows)
''', encoding="utf-8")
import features
rows = [t for t in MESSY if isinstance(t["text"], str)]
print("ردیفها:", len(rows))
try:
features.summarise(rows)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
ردیفها: 782
ZeroDivisionError: division by zero
«تقسیم بر صفر» — و همین. نه میگوید کدام ردیف، نه چه متنی، نه چندتا از اینها هست. این دقیقاً همان خطایی است که آدم را وسوسه میکند سیزده print در چهار تابع بکارد.
۲. pdb: بهجای حدس، بایست و نگاه کن#
pdb اشکالیابِ خودِ پایتون است. کارِ اصلیاش این است که در لحظهٔ خطا برنامه را نگه دارد تا بتوانی همانجا متغیرها را بپرسی.
اینجا با -c دستورها را از قبل به آن میدهیم تا نوتبوک منتظرِ تایپِ تو نماند: continue یعنی «برو تا وقتی چیزی بترکد»، بعد p text و p parts یعنی «این دو متغیر را چاپ کن».
import os
import subprocess
import sys
with open("messy.jsonl", "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
Path("crash.py").write_text(r'''import json
import features
with open("messy.jsonl", encoding="utf-8") as f:
rows = [json.loads(line) for line in f]
print(features.summarise(rows))
''', encoding="utf-8")
done = subprocess.run(
[sys.executable, "-m", "pdb", "-c", "continue", "-c", "p text", "-c", "p parts",
"-c", "quit", "crash.py"],
capture_output=True, text=True, encoding="utf-8")
here = str(Path.cwd()) + os.sep
print("--- آخرین قابِ traceback ---")
for line in done.stderr.strip().splitlines()[-3:]:
print(line.replace(here, ""))
print("--- پاسخِ pdb به p text و p parts ---")
for line in done.stdout.splitlines():
print(line.replace(here, ""))
--- آخرین قابِ traceback ---
return sum(len(w) for w in parts) / len(parts)
~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero
--- پاسخِ pdb به p text و p parts ---
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
''
[]
دو خطِ آخرِ خروجی، جوابِ p text و p parts هستند: متن '' بود و فهرستِ کلمهها خالی. در همان لحظه فهمیدیم که ورودی یک متنِ بیکلمه بوده، بدونِ اینکه حتی یک print در کد بگذاریم.
در Colab راهِ معمولیترش این است که %debug را در سلولِ بعدِ خطا بزنی و بعد همان دستورها را تایپ کنی. دستورهایی که بیشتر از همه لازم میشوند: p <نام> برای دیدنِ مقدار، l برای دیدنِ کدِ اطراف، u و d برای بالا و پایین رفتن بینِ قابها، و q برای خروج.
ولی pdb یک چیز را بهت نمیگوید: این خرابی چند تا از آن ۷۸۲ ردیف است و کدامها. برای آن باید جستوجو کرد.
۳. دوبخشیکردن: ۱۰ گام بهجای ۷۸۲#
ایده ساده است: نصفِ داده را امتحان کن. اگر شکست، مقصر آنجاست؛ اگر نشکست، در نصفِ دیگر است. هر گام فضای جستوجو را نصف میکند.
def breaks(subset):
try:
features.summarise(subset)
return False
except ZeroDivisionError:
return True
def bisect(sample, still_breaks):
lo, hi, steps = 0, len(sample), 0
while hi - lo > 1:
steps += 1
mid = (lo + hi) // 2
if still_breaks(sample[lo:mid]):
hi = mid
else:
lo = mid
return sample[lo], steps
culprit, steps = bisect(rows, breaks)
print("گامهای جستوجوی دوبخشی:", steps, "در برابرِ", len(rows), "بار امتحانِ یکییکی")
print("ردیفِ مقصر:", culprit["id"], "| متن:", repr(culprit["text"]))
print("همین یک ردیف بهتنهایی میشکند؟", breaks([culprit]))
گامهای جستوجوی دوبخشی: 10 در برابرِ 782 بار امتحانِ یکییکی
ردیفِ مقصر: 1066 | متن: ''
همین یک ردیف بهتنهایی میشکند؟ True
ده گام. و این عدد لگاریتمی است، نه خطی: برای ۷۸۲ ردیف ده گام، برای هشتصد هزار ردیف بیست گام.
و این روش فقط برای داده نیست. همان الگو روی تاریخچهٔ git مینشیند («کدام commit این را شکست؟»)، روی مراحلِ یک مسیرِ داده («کدام مرحله؟»)، و روی پیکربندی («کدام گزینه؟»). هر جا یک فضای مرتب و یک آزمونِ بله-خیر داری، دوبخشیکردن جواب میدهد.
✅ چک کن: آن سطرِ آخر — «همین یک ردیف بهتنهایی میشکند؟» — مهمترین خطِ این بخش است و اغلب فراموش میشود. جستوجوی دوبخشی همیشه یک جواب برمیگرداند، حتی وقتی جوابش بیمعنا باشد. تا وقتی مقصر را بهتنهایی امتحان نکردهای، فقط یک حدس داری. بخشِ ۵ نشان میدهد این چک چطور جانت را نجات میدهد.
۴. کوچکترین نمونهٔ بازتولیدکننده#
دوبخشیکردن یک ردیف داد. ولی هدفِ واقعیِ اشکالیابی چیزِ دیگری است: کوچکترین ورودیای که هنوز خراب میشود — چیزی که بشود در سه خط برای یک نفرِ دیگر فرستاد.
روشش این است: تکههای بزرگ را حذف کن، هر بار امتحان کن، اگر هنوز خراب بود آن حذف را نگه دار. بعد تکهها را نصف کن و باز از اول.
def shrink(sample, still_breaks):
current, chunk, tries = list(sample), len(sample) // 2, 0
while chunk >= 1:
index = 0
while index < len(current):
tries += 1
candidate = current[:index] + current[index + chunk:]
if candidate and still_breaks(candidate):
current = candidate
else:
index += chunk
chunk //= 2
return current, tries
smallest, tries = shrink(rows, breaks)
print(f"از {len(rows)} ردیف به {len(smallest)} ردیف، با {tries} بار اجرا")
print("نمونهٔ کمینه:", smallest)
از 782 ردیف به 1 ردیف، با 22 بار اجرا
نمونهٔ کمینه: [{'id': 1250, 'user_id': 'u083', 'created_at': '2025-04-01T14:00', 'text': '', 'label': 'فنی'}]
بیستودو اجرا، و حالا یک نمونهٔ یکردیفی داری که هم گزارشِ باگ است، هم تستِ فردا.
و اسمِ ردیف را نگاه کن: 1250، نه 1066. دوبخشی اولین مقصری را میدهد که سرِ راهش سبز شود؛ کوچککننده هر مقصری را که کافی باشد. هر دو درستاند چون این باگ چند ردیفِ مقصر دارد، نه یکی.
۵. وقتی دوبخشی دروغ میگوید#
حالا باگِ دوم، از جنسِ کاملاً متفاوت: باگی که به یک ردیف مربوط نیست، به یک جفت مربوط است. پیکرهٔ کثیف شناسههای تکراری دارد و ما جایی روی یکتاییِ شناسه حساب باز میکنیم:
def index_by_id(rows):
seen = {}
for r in rows:
assert r["id"] not in seen, f"شناسهٔ تکراری: {r['id']}"
seen[r["id"]] = r
return seen
def breaks_ids(subset):
try:
index_by_id(subset)
return False
except AssertionError:
return True
print("کلِ داده میشکند؟", breaks_ids(rows))
suspect, steps = bisect(rows, breaks_ids)
print("جستوجوی دوبخشی بعد از", steps, "گام میگوید مقصر:", suspect["id"])
print("ولی همین یک ردیف بهتنهایی میشکند؟", breaks_ids([suspect]))
pair, tries = shrink(rows, breaks_ids)
print(f"کوچکسازی: {len(rows)} → {len(pair)} ردیف، با {tries} بار اجرا")
print("شناسهها:", [r["id"] for r in pair])
کلِ داده میشکند؟ True
جستوجوی دوبخشی بعد از 10 گام میگوید مقصر: 1363
ولی همین یک ردیف بهتنهایی میشکند؟ False
کوچکسازی: 782 → 2 ردیف، با 37 بار اجرا
شناسهها: [1443, 1443]
جستوجوی دوبخشی با اطمینانِ کامل ردیفِ ۱۳۶۳ را متهم کرد، و آن ردیف بهتنهایی هیچ ایرادی ندارد.
دلیلش یک فرضِ نانوشته است: دوبخشیکردن فرض میکند یک عاملِ مقصر وجود دارد و در یکی از دو نیمه است. اینجا مقصر یک جفت است، و وقتی جفت بینِ دو نیمه پخش شود، هیچکدام از نیمهها نمیشکند و روش همانجا گم میشود — ولی چون همیشه یک ردیف برمیگرداند، گم شدنش شبیهِ جواب دادن است.
کوچککننده این فرض را ندارد. هر تکهای را حذف میکند که حذفش خرابی را از بین نبرد، و آخرش دقیقاً همان دو ردیفی میماند که با هم مسئله میسازند: دو تیکت با شناسهٔ 1443.
📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با امتحانِ یکییکی (۷۸۲ اجرا) و با جستوجوی دوبخشی (۱۰ اجرا و در حالتِ دوم، یک جوابِ غلط). روی کدام داده؟ همان ۷۸۲ ردیفِ پیکرهٔ کثیف. با چند
seed؟ هیچکدام، و اینجا باید توضیح داد: هر دو روش قطعیاند — با همان ورودی همیشه همان گامها را برمیدارند. عددی که تصادف نداردseedهم نمیخواهد.
۶. assert بهعنوان تله#
هر سه روشِ بالا بعد از خرابی بهکار میآیند. کارِ چهارم این است که دفعهٔ بعد خرابی سرِ جای درست گیر بیفتد:
import traceback
Path("features2.py").write_text(r'''def clean(text):
return text.strip()
def words(text):
return clean(text).split()
def average_word_length(text, ticket_id=None):
parts = words(text)
assert parts, f"متنِ بدونِ کلمه در تیکتِ {ticket_id}: {text!r}"
return sum(len(w) for w in parts) / len(parts)
def summarise(rows):
return sum(average_word_length(r["text"], r["id"]) for r in rows) / len(rows)
''', encoding="utf-8")
import features2
try:
features2.summarise(rows)
except AssertionError as exc:
frames = traceback.extract_tb(exc.__traceback__)
print(f"AssertionError: {exc}")
print("عمقِ traceback:", len(frames), "قاب | ترکید در:", frames[-1].name)
AssertionError: متنِ بدونِ کلمه در تیکتِ 1066: ''
عمقِ traceback: 4 قاب | ترکید در: average_word_length
همان خطا، این بار با شناسهٔ تیکت داخلِ پیام. حالا کسی که این را در یک گزارشِ شبانه میبیند، در ده ثانیه ردیف را پیدا میکند بهجای اینکه کلِ این فصل را دوباره اجرا کند.
قاعدهٔ گذاشتنِ تله: هر جا فرضی داری که کد بدونِ آن بیمعنا میشود، همان فرض را assert کن. «این فهرست خالی نیست»، «این ستون وجود دارد»، «این دو آرایه هماندازهاند». و هر assert باید مقداری را که مسئلهساز شد داخلِ پیامش بگذارد — همان قاعدهٔ فصلِ ۲، حالا در دلِ کدِ اصلی.
⚠️ مواظب باش:
assertبا پرچمِ-Oپایتون حذف میشود. پسassertتله است برای پیدا کردنِ باگِ برنامهنویس، نه جای اعتبارسنجیِ ورودیِ کاربر. اعتبارسنجیِ واقعی بایدraiseصریح باشد — همانTypeErrorوValueErrorی که در فصلِ ۴ ساختیم.
۷. و حالا تصمیم بگیر با ردیفِ بد چه کنی#
پیدا کردنِ باگ آخرِ کار نیست. باید تصمیم بگیری: ردیف را دور بریزی، یا مقدارِ جایگزین بگذاری، یا کلاً بایستی. و هر کدام را که انتخاب کردی، باید بشماریاش:
def summarise_v3(rows):
total, counted, skipped = 0.0, 0, 0
for r in rows:
parts = features.words(r["text"])
if not parts:
skipped += 1
continue
total += sum(len(w) for w in parts) / len(parts)
counted += 1
return total / counted, counted, skipped
mean, counted, skipped = summarise_v3(rows)
print(f"میانگینِ طولِ کلمه: {mean:.4f}")
print(f"ردیفِ شمردهشده : {counted}")
print(f"ردیفِ ردشده : {skipped}")
میانگینِ طولِ کلمه: 4.2233
ردیفِ شمردهشده : 771
ردیفِ ردشده : 11
آن skipped تزیین نیست. تابعی که ردیفها را بیصدا دور میریزد، دقیقاً همانقدر خطرناک است که تابعی که میترکد — فقط دیرتر معلوم میشود. اگر فردا این عدد از ۱۱ به ۴۰۰ برسد، تو باید ببینیاش، نه کاربر.
🔧 اگر کار نکرد: اگر
AssertionErrorبخشِ ۶ را نگرفتی و بهجایشTypeError: average_word_length() takes 1 positional argument but 2 were givenدیدی،features2را نساختهای و پایتون هنوز نسخهٔ قبلی را در حافظه دارد. این همان تلهٔ فصلِ ۱ است:importدوباره فایل را نمیخواند.importlib.reload(features2)یاRestart session and run all.
🤖 از دستیارت بپرس: «
git bisectچطور همین الگو را روی تاریخچهٔ commitها اجرا میکند؟» و بعد این را بپرس: «اگر آزمونم گاهی سبز و گاهی قرمز باشد، جستوجوی دوبخشی چه اتفاقی برایش میافتد؟» — جواب این است که کاملاً بیاعتبار میشود، و همین دلیلِ اهمیتِ تستِ غیرشکننده در فصلِ ۲ است.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
pdb |
پیدیبی | اشکالیابِ خودِ پایتون |
| post-mortem | پست-مورتم | ایستادن در لحظهٔ خطا و بررسیِ متغیرها |
| bisection | بایسکشن | نصفکردنِ پیاپیِ فضای جستوجو |
| minimal reproducer | مینیمال ریپروسر | کوچکترین ورودیای که هنوز خرابی را نشان میدهد |
| delta debugging | دلتا دیباگینگ | کوچککردنِ خودکارِ ورودیِ خراب |
git bisect |
گیت بایسکت | همان دوبخشیکردن، روی تاریخچهٔ commitها — فعلاً همینقدر بدان؛ ترمِ ۶ فصلِ ۱ با آن یک باگِ واقعی را پیدا میکند |
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
سه فصل روی درستی کار کردیم. حالا نوبتِ سرعت است — و اولین قاعدهاش این است که حدست دربارهٔ کندیِ کد تقریباً همیشه غلط است.
فصلِ بعد قبل از هر بهینهسازیای اندازه میگیرد: پروفایلِ زمان، پروفایلِ حافظه، و آزمایشی که در آن حدسِ ما دربارهٔ گرانترین بخشِ کد غلط از آب درمیآید.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.