از کد اسپاگتی تا پایتون تمیز: راهنمای مبتدیان برای بازنویسی کد

چرا کد اسپاگتی برای برنامه‌نویس‌ها دردسر می‌سازد؟

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

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

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

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

نشانه‌های کد به‌هم‌ریخته را بشناسید

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

stock = {"latte": 10, "espresso": 5}

def process_order(order):
    total = 0
    for item in order["items"]:
        price = item["price"] * item["count"]
        if order["customer"] == "member":
            price = price * 0.9
        elif order["customer"] == "guest" and total > 200:
            price = price * 0.95
        total += price
        if item["name"] in stock:
            stock[item["name"]] -= item["count"]
        else:
            print(f"Warning: {item['name']} not in stock list")

    if total > 500:
        delivery = 0
    else:
        delivery = 30
    total += delivery

    print(f"Sending receipt to {order['email']}")
    print(f"Total: {total}")
    return total

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

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

چطور کد اسپاگتی را در پروژه‌ی خودتان تشخیص دهید؟

سه نشانه‌ی ساده وجود دارد که می‌توانید همین امروز در کدهایتان دنبالشان بگردید. اولین نشانه، تابعی است که اسمش با کارهایی که انجام می‌دهد نمی‌خواند؛ مثلا تابعی به نام process_order که در واقع ایمیل هم می‌فرستد و انبار را هم تغییر می‌دهد. دومین نشانه، متغیری است که هرچه در تابع جلوتر می‌روید معنایش عوض می‌شود، مثل total که اول جمع جزئی است، بعد جمع با تخفیف و آخر سر جمع با هزینه‌ی ارسال. سومین نشانه، هر محاسبه‌ای است که نتیجه‌اش به ترتیب اجرای دستورها وابسته است. اگر جابه‌جا کردن دو خط، خروجی را عوض می‌کند، احتمالا با یک گره‌ی پنهان روبه‌رو هستید.

تقسیم یک تابع بزرگ به تابع‌های کوچک و متمرکز

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

def calculate_subtotal(items):
    return sum(item.price * item.count for item in items)


def apply_discount(subtotal, customer_type):
    if customer_type == "member":
        return subtotal * 0.9
    if customer_type == "guest" and subtotal > 200:
        return subtotal * 0.95
    return subtotal


def calculate_delivery(total):
    return 0 if total > 500 else 30

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

مزیت دیگر این است که می‌توانید هر کدام از این تابع‌ها را تنها صدا بزنید و دقیقا بدانید چه کاری می‌کند، بدون اینکه بقیه‌ی برنامه را اجرا کنید. مثلا برای اینکه بفهمید تخفیف مشتری مهمان درست حساب می‌شود، فقط کافی است apply_discount(300, "guest") را در ترمینال امتحان کنید.

یک تابع خوب چه ویژگی‌هایی دارد؟

تابع خوب در پایتون هدف مشخصی دارد، ورودی‌های تعریف‌شده می‌گیرد و نتیجه‌ای قابل‌فهم تحویل می‌دهد. اگر برای توضیح کار یک تابع مجبور شدید از کلمه‌ی «و» استفاده کنید، مثلا «قیمت را حساب می‌کند و موجودی را کم می‌کند»، این نشانه‌ی خوبی است که تابع باید به دو تابع تقسیم شود. البته نباید هم زیاده‌روی کرد. تابعی که یک خط بیشتر ندارد و فقط یک‌جا استفاده می‌شود، همیشه لازم نیست جدا شود. معیار، وضوح مسئولیت است و نه تعداد خطوط.

جایگزین کردن دیکشنری با دیتاکلاس

در کد اولیه، سفارش و آیتم‌هایش به‌شکل دیکشنری‌هایی با کلیدهای رشته‌ای رد و بدل می‌شدند. این روش کار می‌کند، اما هیچ تضمینی نمی‌دهد که کلیدهای لازم وجود داشته باشند یا نوع مقدارها درست باشد. اگر یک بار به‌جای "count" بنویسید "quantity"، برنامه تا لحظه‌ی اجرا هیچ اعتراضی نمی‌کند و آن‌وقت با یک KeyError سر و کله‌ی خطا پیدا می‌شود.

دیتاکلاس‌ها (Data Classes) راهی ساده برای دادن یک ساختار مشخص به داده‌هاست:

from dataclasses import dataclass


@dataclass
class OrderItem:
    name: str
    price: float
    count: int


@dataclass
class Order:
    email: str
    customer_type: str
    items: list[OrderItem]

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

order = Order(
    email="ali@example.com",
    customer_type="guest",
    items=[
        OrderItem(name="latte", price=100, count=3),
        OrderItem(name="espresso", price=80, count=1),
    ],
)

حالا ویرایشگر کد شما (مثل VS Code یا PyCharm) می‌داند هر سفارش چه فیلدهایی دارد و هنگام تایپ، پیشنهاد خودکار می‌دهد. اگر اسم فیلدی را اشتباه بنویسید، همان لحظه خطا می‌بینید و منتظر اجرای برنامه نمی‌مانید. همچنین شکل داده در همان چند خط اول فایل روشن است و کسی که کد را می‌خواند لازم نیست حدس بزند دیکشنری سفارش چه کلیدهایی دارد.

نکته‌ی فنی: نوشتن list[OrderItem] به‌این‌شکل از پایتون ۳.۹ به بعد پشتیبانی می‌شود. اگر از نسخه‌ی قدیمی‌تری استفاده می‌کنید، باید List را از ماژول typing وارد کنید.

تابع اصلی به‌عنوان هماهنگ‌کننده

حالا که تکه‌های کوچک را داریم و داده هم ساختار دارد، می‌توانیم تابع اصلی را دوباره بنویسیم:

def process_order(order: Order, stock: dict) -> float:
    subtotal = calculate_subtotal(order.items)
    discounted = apply_discount(subtotal, order.customer_type)
    total = discounted + calculate_delivery(discounted)

    update_stock(order.items, stock)
    send_receipt(order.email, total)
    return total

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

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

به‌جای چاپ هشدار، خطا ایجاد کنید

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

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

def update_stock(items, stock):
    # اول همه‌ی آیتم‌ها را بررسی می‌کنیم
    for item in items:
        if item.name not in stock:
            raise ValueError(f"{item.name} در انبار وجود ندارد")
        if stock[item.name] < item.count:
            raise ValueError(f"موجودی {item.name} کافی نیست")

    # فقط اگر همه‌چیز درست بود، موجودی را کم می‌کنیم
    for item in items:
        stock[item.name] -= item.count

و تابع ارسال رسید هم که فعلا فقط چاپ می‌کند:

def send_receipt(email, total):
    print(f"Sending receipt to {email}")
    print(f"Total: {total}")

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

توجه کنید که در update_stock عمدا دو حلقه‌ی جدا نوشتیم. اگر بررسی و کم کردن موجودی در یک حلقه بودند و سفارش سه آیتم داشت، ممکن بود موجودی دو آیتم اول کم شده باشد و آیتم سوم خطا بدهد. آن‌وقت انبار در وضعیتی نیمه‌کاره می‌ماند و سفارش هم ثبت نشده بود. با بررسی کامل قبل از تغییر، یا کل سفارش اعمال می‌شود یا هیچ چیزی تغییر نمی‌کند.

خطاها را چطور مدیریت کنیم؟

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

try:
    process_order(order, stock)
except ValueError as error:
    print(f"ثبت سفارش ناموفق بود: {error}")

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

تست کردن هر تکه به‌صورت مستقل

بزرگ‌ترین پاداش تقسیم کد به تابع‌های کوچک، تست‌پذیری است. در کد اولیه برای تست کردن قانون تخفیف مجبور بودید کل فرایند را اجرا کنید؛ یعنی هم انبار تغییر می‌کرد و هم رسید چاپ می‌شد. حالا هر تابع را می‌توان جداگانه تست کرد. برای این کار از pytest استفاده می‌کنیم که با دستور pip install pytest نصب می‌شود:

import pytest


def test_apply_discount_member():
    assert apply_discount(200, "member") == pytest.approx(180)


def test_apply_discount_guest_above_threshold():
    assert apply_discount(300, "guest") == pytest.approx(285)


def test_apply_discount_guest_below_threshold():
    assert apply_discount(100, "guest") == 100


def test_free_delivery_for_big_orders():
    assert calculate_delivery(600) == 0


def test_update_stock_missing_item():
    items = [OrderItem(name="tea", price=50, count=1)]
    with pytest.raises(ValueError):
        update_stock(items, {"latte": 10})


def test_update_stock_is_all_or_nothing():
    stock = {"latte": 10, "espresso": 1}
    items = [
        OrderItem(name="latte", price=100, count=2),
        OrderItem(name="espresso", price=80, count=5),
    ]
    with pytest.raises(ValueError):
        update_stock(items, stock)
    assert stock == {"latte": 10, "espresso": 1}

برای اجرای تست‌ها کافی است در پوشه‌ی پروژه دستور pytest را بزنید. اگر مثلا قانون تخفیف را اشتباه تغییر بدهید، تست شکست‌خورده مستقیم به شما نشان می‌دهد مشکل از تابع apply_discount است. این را مقایسه کنید با تابع اولیه که گزارش باگ فقط می‌گفت «مبلغ کل عجیب است» و هیچ اشاره‌ای نمی‌کرد کدام یک از پنج مسئولیت تابع خراب شده.

دقت کنید که در تست‌ها از pytest.approx استفاده کردیم. اعداد اعشاری در کامپیوتر همیشه دقیق ذخیره نمی‌شوند و مقایسه‌ی مستقیم آن‌ها با == گاهی به نتیجه‌ی غیرمنتظره می‌رسد. این ابزار مقایسه را با یک تلورانس بسیار کوچک انجام می‌دهد و از این دردسر جلوگیری می‌کند.

افزودن Type Hint

در تابع process_order بالا از Type Hint استفاده کردیم؛ یعنی نوشتیم order: Order و -> float. پایتون این‌ها را در زمان اجرا اجباری نمی‌کند، اما ابزارهایی مثل mypy یا خود ویرایشگر شما می‌توانند پیش از اجرای کد هشدار بدهند که مثلا به‌جای یک Order، یک دیکشنری به تابع داده‌اید. همین بررسی ساده، بخش بزرگی از خطاهای رایج را قبل از رسیدن به تست یا محیط واقعی از بین می‌برد.

کد کامل و تمیز

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

from dataclasses import dataclass


@dataclass
class OrderItem:
    name: str
    price: float
    count: int


@dataclass
class Order:
    email: str
    customer_type: str
    items: list[OrderItem]


def calculate_subtotal(items):
    return sum(item.price * item.count for item in items)


def apply_discount(subtotal, customer_type):
    if customer_type == "member":
        return subtotal * 0.9
    if customer_type == "guest" and subtotal > 200:
        return subtotal * 0.95
    return subtotal


def calculate_delivery(total):
    return 0 if total > 500 else 30


def update_stock(items, stock):
    for item in items:
        if item.name not in stock:
            raise ValueError(f"{item.name} در انبار وجود ندارد")
        if stock[item.name] < item.count:
            raise ValueError(f"موجودی {item.name} کافی نیست")

    for item in items:
        stock[item.name] -= item.count


def send_receipt(email, total):
    print(f"Sending receipt to {email}")
    print(f"Total: {total}")


def process_order(order: Order, stock: dict) -> float:
    subtotal = calculate_subtotal(order.items)
    discounted = apply_discount(subtotal, order.customer_type)
    total = discounted + calculate_delivery(discounted)

    update_stock(order.items, stock)
    send_receipt(order.email, total)
    return total


if __name__ == "__main__":
    stock = {"latte": 10, "espresso": 5}
    order = Order(
        email="ali@example.com",
        customer_type="guest",
        items=[OrderItem(name="latte", price=100, count=3)],
    )
    process_order(order, stock)
    print(stock)

اگر این کد را اجرا کنید، مبلغ نهایی ۳۱۵ خواهد بود: جمع جزئی ۳۰۰، بعد از تخفیف ۲۸۵ و با هزینه‌ی ارسال ۳۰. موجودی لاته هم از ۱۰ به ۷ کاهش پیدا می‌کند.

چطور این الگو را روی کدهای خودتان پیاده کنیم؟

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

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

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

اشتباه‌های رایج هنگام بازنویسی

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

سخن پایانی

تبدیل کد اسپاگتی به کد تمیز جادو نیست؛ مجموعه‌ای از عادت‌های ساده است که با تکرار در ذهن جا می‌افتد. در این مقاله دیدیم که جدا کردن مسئولیت‌ها باگ پنهانِ وابسته به ترتیب را از بین می‌برد، دیتاکلاس‌ها ساختار داده را روشن و قابل بررسی می‌کنند، ایجاد Exception جلوی ادامه‌ی یک فرایند خراب را می‌گیرد و تابع‌های کوچک تست کردن را ساده می‌کنند و هر خطایی را مستقیم به منبعش ردیابی می‌کنند.

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