จาก Spaghetti Code สู่ Python ที่สะอาดตา: คู่มือปรับโครงสร้างโค้ดสำหรับมือใหม่

How And Why to Go From Spaghetti Code to Clean Python

บทนำ

Spaghetti Code เป็นปัญหาใหญ่ในการทำงานเพราะโลจิกของมันพันกันจนยุ่งเหยิง แม้ว่า function ใน Python จะสามารถจัดการขั้นตอนที่ซับซ้อนให้ดูอ่านง่ายได้ แต่ปัญหาจะเกิดขึ้นทันทีเมื่อหน้าที่ที่แตกต่างกันถูกมัดรวมกันไว้อย่างหนาแน่น จนทำให้ความสัมพันธ์ระหว่างส่วนต่างๆ ไม่ชัดเจน และการแก้ไขโลจิกเพียงจุดเดียวอาจส่งผลกระทบเป็นโดมิโนไปยังส่วนอื่นที่ไม่เกี่ยวข้อง

การแยกโค้ดออกเป็นฟังก์ชันย่อยที่เน้นเฉพาะจุดจะช่วยลดความซับซ้อนดังกล่าวได้ โดย good Python function ที่ดีควรมีวัตถุประสงค์ชัดเจน รับ Input ที่แน่นอน และให้ Output ที่เข้าใจง่าย ซึ่งจะช่วยให้การอ่าน การทดสอบ การ Debug และการแก้ไขโค้ดทำได้สะดวกยิ่งขึ้น เนื่องจากภาษา Python ให้อิสระในการจัดโครงสร้างโค้ดสูงมาก การฝึกนิสัยแยกความรับผิดชอบของโค้ดให้ชัดเจนจึงเป็นทักษะสำคัญที่ช่วยให้โปรแกรมบำรุงรักษาได้ง่ายในระยะยาว บทความนี้จะพาคุณไปทำความรู้จักกับ:

  • ลักษณะของโค้ดที่พันกันยุ่งเหยิงผ่านตัวอย่างที่รันได้จริง
  • วิธีแยกฟังก์ชันออกเป็นส่วนเล็กๆ ตามหน้าที่เฉพาะเจาะจง
  • การใช้ Data Class แทน Dictionary เพื่อจัดการข้อมูลคำสั่งซื้อ
  • การใช้การส่งข้อผิดพลาด (Raise Errors) แทนการพิมพ์คำเตือนเงียบๆ
  • แนวทางการทดสอบฟังก์ชันแยกส่วน และการนำไปประยุกต์ใช้กับงานจริง

เราจะเริ่มเปลี่ยนสคริปต์ที่จัดการยากให้กลายเป็นโค้ดที่สะอาดขึ้นทีละขั้นตอน

You can find the code on GitHub

การตรวจจับสัญญาณของโค้ดที่ยุ่งเหยิง

ตัวอย่างด้านล่างคือฟังก์ชันประมวลผลคำสั่งซื้อของร้านค้าออนไลน์ ซึ่งทำทุกอย่างตั้งแต่คำนวณส่วนลด อัปเดตสต็อก ไปจนถึงส่งอีเมลยืนยันรวมไว้ในฟังก์ชันเดียว

inventory = {"sku-1042": 18, "sku-2077": 4}
 
def process_order(order):
    total = 0
    for item in order["items"]:
        price = item["unit_price"] * item["quantity"]
        if order["customer_type"] == "vip":
            price = price * 0.85
        elif order["customer_type"] == "regular" and total > 100:
            price = price * 0.95
        total += price
        if item["sku"] in inventory:
            inventory[item["sku"]] -= item["quantity"]
        else:
            print(f"Warning: {item['sku']} not found in inventory")
 
if total > 500:
        shipping = 0
    else:
        shipping = 12.99
    total += shipping
 
print(f"Sending confirmation email to {order['customer_email']}")
    print(f"Order total: ${total:.2f}")
 
return total

ฟังก์ชัน process_order นี้รับภาระหนักเกินไป ทั้งคำนวณราคา ใช้ส่วนลด แก้ไขค่าใน inventory ที่เป็น Global ตัดสินใจเรื่องค่าจัดส่ง และจำลองการส่งอีเมลภายใน Loop เดียวกัน ที่สำคัญยังมี Bug ซ่อนอยู่ เพราะส่วนลดลูกค้าทั่วไปตรวจสอบจาก total > 100 ระหว่าง Loop ทำให้สิทธิ์การได้รับส่วนลดขึ้นอยู่กับลำดับสินค้าที่ใส่เข้ามา ไม่ได้ขึ้นอยู่กับยอดรวมจริงของคำสั่งซื้อ

สัญญาณอันตรายที่คุณควรระวังในโค้ดของตัวเองคือ ฟังก์ชันที่ชื่อไม่ครอบคลุมสิ่งที่มันทำจริง ตัวแปรที่เปลี่ยนความหมายไปมาระหว่างไล่โค้ด และการคำนวณที่ให้ผลลัพธ์ต่างกันตามลำดับการรันคำสั่ง

การแยกหนึ่งฟังก์ชันออกเป็นส่วนที่เน้นเฉพาะจุด

แนวทางแก้ไขคือการแบ่งหน้าที่ให้แต่ละฟังก์ชันมี Input และ Return Value ที่ชัดเจน ไม่มีการเปลี่ยนแปลงสถานะที่ใช้ร่วมกันภายใน Loop และไม่ขึ้นอยู่กับลำดับการรัน

def calculate_subtotal(items):
    return sum(item.unit_price * item.quantity for item in items)
 
def apply_discount(subtotal, customer_type):
    if customer_type == "vip":
        return subtotal * 0.85
    if customer_type == "regular" and subtotal > 100:
        return subtotal * 0.95
    return subtotal
 
def calculate_shipping(discounted_total):
    return 0.0 if discounted_total > 500 else 12.99

เมื่อแยกออกมาแล้ว ฟังก์ชันอย่าง apply_discount จะตรวจสอบยอดรวมที่สรุปผลแล้วแทนค่าสะสมใน Loop ช่วยกำจัด Bug เรื่องลำดับสินค้าทิ้งไปได้ทันที คุณสามารถหยิบฟังก์ชันใดไปใช้งานแยกกันได้โดยไม่ต้องรันสคริปต์ทั้งหมด

การแทนที่ Dictionary ด้วย Data Class

แม้การใช้ Dictionary จะสะดวก แต่ก็ไม่มีหลักฐานยืนยันว่า Key หรือประเภทข้อมูลข้างในถูกต้องหรือไม่ การใช้ Data classes จะช่วยกำหนดโครงสร้างที่แน่นอนให้กับคำสั่งซื้อและรายการสินค้า

from dataclasses import dataclass
 
@dataclass
class OrderItem:
    sku: str
    unit_price: float
    quantity: int
 
@dataclass
class Order:
    customer_email: str
    customer_type: str
    items: list[OrderItem]

เมื่อมีโครงสร้างที่ชัดเจน ฟังก์ชันหลักจะเปลี่ยนสถานะเป็นผู้ประสานงาน (Coordinator) ที่อ่านแล้วเข้าใจง่ายขึ้นทันที:

def process_order(order: Order, inventory: dict) -> float:
    subtotal = calculate_subtotal(order.items)
    discounted = apply_discount(subtotal, order.customer_type)
    total = discounted + calculate_shipping(discounted)
    update_inventory(order.items, inventory)
    return total

การอ่านจากบนลงล่างจะแสดงลำดับขั้นตอนที่ชัดเจน ทั้งการคำนวณ การคิดส่วนลด การคำนวณค่าจัดส่ง และการอัปเดตสต็อก สามารถศึกษาเพิ่มเติมได้ที่ Python Data Classes Beyond the Boilerplate

การส่งข้อผิดพลาดแทนการพิมพ์คำเตือน

ในโค้ดเดิม เมื่อไม่พบ SKU ระบบจะพิมพ์คำเตือนแล้วทำงานต่อ ซึ่งอาจทำให้เกิดความผิดพลาดในระบบบัญชีหรือสต็อกโดยไม่รู้ตัว

def update_inventory(items, inventory):
    for item in items:
        if item.sku not in inventory:
            raise ValueError(f"{item.sku} not found in inventory")
        inventory[item.sku] -= item.quantity

การใช้ raise exception จะหยุดการทำงานทันทีเมื่อเกิดปัญหา ช่วยป้องกันไม่ให้รายการที่ผิดพลาดหลุดไปถึงขั้นตอนสุดท้าย และยังช่วยให้การ Debug หรือการทดสอบทำได้ง่ายขึ้นเพราะระบุจุดผิดพลาดได้ชัดเจน

การทดสอบแต่ละส่วนด้วยตัวมันเอง

เมื่อแยกโลจิกเป็นฟังก์ชันเล็กๆ การทำ Unit Test ก็ไม่จำเป็นต้องรันทั้งระบบอีกต่อไป:

def test_apply_discount_vip():
    assert apply_discount(200, "vip") == 170.0
 
def test_apply_discount_regular_under_threshold():
    assert apply_discount(80, "regular") == 80

คุณสามารถใช้เครื่องมืออย่าง pytest เพื่อให้การทดสอบเป็นระบบมากขึ้น หากมีส่วนใดพัง รายงานผลจะชี้ไปยังฟังก์ชันนั้นโดยตรง ไม่ต้องเดาว่ายอดรวมที่ผิดพลาดมาจากขั้นตอนไหนกันแน่

นอกจากนี้ การเพิ่ม type hints ในฟังก์ชันยังช่วยให้ Linter ตรวจพบความผิดพลาดตั้งแต่ตอนเขียนโค้ด เช่น การเผลอส่ง Dictionary เข้าไปในจุดที่ต้องการ Order ได้อีกด้วย สามารถศึกษาต่อได้ที่ Beginner's Guide to Unit Testing Python Code with pytest

การนำไปประยุกต์ใช้กับโค้ดของคุณ

หากคุณพบว่าฟังก์ชันเริ่มขยายตัวเกินหน้าที่ ให้ลองทำตามขั้นตอนเหล่านี้:

  1. ลิสต์รายการสิ่งที่ฟังก์ชันทำแยกเป็นบรรทัดๆ
  2. แยกแต่ละรายการออกมาเป็นฟังก์ชันย่อยที่รับ Input และ Return ค่าปกติ
  3. ใช้ Data Class แทน Dictionary เพื่อความชัดเจนของข้อมูล
  4. ใช้ Exception จัดการข้อผิดพลาดแทนการสั่ง Print
  5. เขียน Test สำหรับแต่ละฟังก์ชันย่อยที่แยกออกมา

วิธีนี้จะช่วยให้การรีวิวโค้ดทำได้ง่ายขึ้น และมั่นใจได้ว่าสคริปต์ยังทำงานได้ถูกต้องในทุกขั้นตอนของการแก้ไข

สรุป

ตารางเปรียบเทียบการเปลี่ยนแปลงและประโยชน์ที่ได้รับ:

ปัญหาในโค้ดเดิมวิธีแก้ไขประโยชน์ที่ได้รับ
ฟังก์ชันเดียวทำหลายหน้าที่แยกเป็นฟังก์ชันย่อยอ่านง่าย แก้ไขและทดสอบได้เฉพาะจุด
คำนวณตามลำดับการรันที่ซับซ้อนคำนวณจากยอดสรุปหลังจบ Loopกำจัด Bug ที่เกิดจากลำดับการรัน
ใช้ Dictionary ที่ไม่แน่นอนใช้ Data Classโครงสร้างข้อมูลชัดเจน Linter ตรวจสอบได้
พิมพ์ Error แล้วทำงานต่อใช้ Raise Exceptionหยุดการทำงานทันทีเมื่อพบปัญหา
ทดสอบแยกส่วนไม่ได้เพิ่ม Unit Test รายฟังก์ชันระบุจุดที่เสียได้ทันทีเมื่อทดสอบไม่ผ่าน

ศึกษาเพิ่มเติม:

Source: KDnuggets
ดูแลงานแปลและเรียบเรียงโดย SirilukP

ความคิดเห็น (0)

เข้าสู่ระบบเพื่อร่วมแสดงความเห็น

สมัครสมาชิก

มาเป็นคนแรกที่แสดงความเห็นกันเลยโบร