เลิกเขียน If-Else ยาวเหยียด: เปลี่ยนมาใช้ Registry Pattern ใน Python เพื่อโค้ดที่สะอาดกว่า

บทนำ
Codebase ของ Python เกือบทุกโปรเจกต์มักเจอปัญหาเดียวกัน คือฟังก์ชันที่เริ่มจากจุดเล็กๆ มีแค่ 2-3 เงื่อนไข แต่เมื่อเวลาผ่านไปและมีคนเพิ่มเคสใหม่ๆ เข้ามาเรื่อยๆ คุณอาจพบว่าตัวเองกำลังเผชิญกับ if/elif/else ยาวเหยียดกว่า 200 บรรทัดที่ไม่มีใครกล้าแตะต้อง ดังตัวอย่างนี้:
def get_model(name):
if name == "logreg":
return LogisticRegression()
elif name == "random_forest":
return RandomForestClassifier()
elif name == "svm":
return SVC()
elif name == "xgboost":
return XGBClassifier()
# ... อีก 15 สาขา
else:
raise ValueError(f"Unknown model: {name}")แม้โค้ดจะใช้งานได้จริง แต่มันขัดต่อหลักการ Open/Closed Principle ที่ระบุว่าซอฟต์แวร์ควรเปิดสำหรับการขยาย (Open for extension) แต่ปิดสำหรับการแก้ไข (Closed for modification) วิธีแก้ปัญหาที่ดีกว่าคือการใช้ Registry Pattern บทความนี้จะพาไปดูว่ามันคืออะไร ตั้งแต่การเริ่มสร้างจาก Dictionary ง่ายๆ ไปจนถึงระดับ Production และเมื่อไหร่ที่ควรนำไปใช้
ปัญหากับการใช้ If-Else ต่อกันยาวๆ
การใช้เงื่อนไขซ้อนกันมากเกินไปส่งผลเสียในหลายด้าน:
- ขัดต่อหลัก Open/Closed Principle: เมื่อต้องการเพิ่มเคสใหม่ คุณต้องเข้าไปแก้ไขฟังก์ชันเดิมที่ใช้งานได้อยู่แล้ว ซึ่งเสี่ยงต่อการเกิดบั๊ก การเปลี่ยนแปลงควรทำได้โดยการ "เพิ่มไฟล์ใหม่" ไม่ใช่ "แก้ไขตัวจัดการส่วนกลาง"
- สะสมโลจิกที่ไม่เกี่ยวข้องกัน: หากตัวจ่ายงานต้องจัดการทั้งบัตรเครดิต, PayPal และคริปโต โดเมนที่ต่างกันเหล่านี้จะถูกบังคับให้มารวมอยู่ในฟังก์ชันเดียวกัน
- ขยายตัวได้ยาก (Scalability): ทุกหัวข้อที่เพิ่มเข้ามาจะเพิ่มภาระในการอ่านและรักษาโค้ด หากมี 20 สาขา คุณต้องเลื่อนผ่านโค้ดจำนวนมากเพียงเพื่อดีบั๊กจุดเดียว
- ขยายจากภายนอกไม่ได้: หากคุณสร้างไลบรารีที่มีการ Hardcoded เงื่อนไขไว้ ผู้ใช้จะไม่สามารถเพิ่มโมเดลของตัวเองได้เลย นอกจากต้องแก้โค้ดต้นฉบับ
Registry Pattern แก้ไขปัญหาเหล่านี้โดยการเปลี่ยนความสัมพันธ์ แทนที่จะให้ตัวจ่ายงานต้องรู้จักทุกตัวเลือก แต่จะให้แต่ละตัวเลือกมาลงทะเบียนตัวเองแทน
Registry pattern คืออะไร? โดยพื้นฐานมันคือตารางค้นหาส่วนกลาง (Central lookup table) ที่จับคู่ Key เข้ากับวัตถุ (ฟังก์ชัน, คลาส, อินสแตนซ์) โดยที่แต่ละวัตถุจะลงทะเบียนตัวเอง ใน Python มักใช้ Dictionary เป็นตารางค้นหา และใช้ Decorator ในการลงทะเบียน
จาก If-Else ไปสู่ Dictionary
วิธีที่ง่ายที่สุดคือการเปลี่ยนจากสายโซ่เงื่อนไขมาเป็นการค้นหาใน Dictionary ซึ่งจะช่วยให้การทำงานรวดเร็วขึ้น:
MODEL_REGISTRY = {
"logreg": LogisticRegression,
"random_forest": RandomForestClassifier,
"svm": SVC,
"xgboost": XGBClassifier,
}
def get_model(name):
try:
return MODEL_REGISTRY[name]
except KeyError:
raise ValueError(
f"Unknown model: {name!r}. "
f"Available: {list(MODEL_REGISTRY)}"
) from Noneนี่คือรูปแบบเริ่มต้นของ Registry แม้จะทำงานแบบ O(1) และตรวจสอบรายการได้ง่าย แต่ยังมีข้อเสียคือต้องคอยอัปเดต Dict ด้วยมือเมื่อมีโมเดลใหม่ เรามาทำให้ดีขึ้นด้วยการให้คอมโพเนนต์ลงทะเบียนตัวเองกันดีกว่า
การสร้าง Decorator-Based Registry
นี่คือเวอร์ชันที่ใช้งานจริงได้ดีที่สุด โดยใช้ Decorator เพื่อให้ฟังก์ชันหรือคลาสประกาศตัวตน ณ จุดที่มันถูกเขียนขึ้น:
PAYMENT_HANDLERS = {}
def register(payment_type):
def decorator(func):
PAYMENT_HANDLERS[payment_type] = func
return func
return decorator
@register("credit_card")
def charge_credit_card(amount):
return f"Charged ${amount} to credit card"
@register("paypal")
def charge_paypal(amount):
return f"Charged ${amount} via PayPal"
@register("crypto")
def charge_crypto(amount):
return f"Charged ${amount} in crypto"
def process_payment(payment_type, amount):
handler = PAYMENT_HANDLERS.get(payment_type)
if handler is None:
raise ValueError(f"Unknown payment type: {payment_type!r}")
return handler(amount)สังเกตว่า process_payment เหลือเพียง 4 บรรทัดและไม่ต้องแก้ไขอีกเลย หากต้องการเพิ่ม Apple Pay ก็แค่เขียนฟังก์ชันใหม่พร้อมใส่ @register("apple_pay") ในไฟล์ไหนก็ได้ ไม่ต้องกลัว Merge Conflict และโค้ดจะอยู่รวมกับส่วนที่เกี่ยวข้องอย่างชัดเจน
การสร้าง Reusable Registry Class เพื่อลดการเขียนโค้ดซ้ำซ้อน เราสามารถสร้างคลาสมาจัดการ Registry ให้เป็นระบบมากขึ้น ซึ่งจะช่วยเรื่องการตรวจจับชื่อซ้ำและความสะอาดของ API:
class Registry:
"""A reusable name-to-object registry."""
def __init__(self, name):
self.name = name
self._registry = {}
def register(self, key):
def decorator(obj):
if key in self._registry:
raise KeyError(
f"{key!r} already registered in {self.name!r}"
)
self._registry[key] = obj
return obj
return decorator
def get(self, key):
if key not in self._registry:
raise KeyError(
f"{key!r} not found in {self.name!r}. "
f"Available: {list(self._registry)}"
)
return self._registry[key]
def __contains__(self, key):
return key in self._registry
def keys(self):
return self._registry.keys()ตัวอย่างการนำไปใช้สร้าง Pipeline ประมวลผลข้อความ:
transforms = Registry("transforms")
@transforms.register("lowercase")
def to_lower(text):
return text.lower()
@transforms.register("strip")
def strip_whitespace(text):
return text.strip()
@transforms.register("remove_digits")
def remove_digits(text):
return "".join(c for c in text if not c.isdigit())
# Pipeline สามารถขับเคลื่อนด้วยข้อมูลจากคอนฟิก
pipeline = ["strip", "lowercase", "remove_digits"]
text = " Order #4521 CONFIRMED "
for step in pipeline:
text = transforms.get(step)(text)
print(repr(text))
# ผลลัพธ์: 'order # confirmed'พฤติกรรมของโปรแกรมถูกกำหนดด้วยข้อมูล ทำให้การปรับลำดับหรือเพิ่มขั้นตอนใหม่ทำได้ง่ายเพียงแค่แก้ไฟล์คอนฟิก
การลงทะเบียนคลาสอัตโนมัติด้วย __init_subclass__
หาก Registry เก็บเป็นคลาส เราสามารถใช้ฮุก __init_subclass__ (ใน Python 3.6+) เพื่อให้ Subclass ลงทะเบียนตัวเองโดยอัตโนมัติ:
class DataLoader:
_registry = {}
def __init_subclass__(cls, fmt=None, **kwargs):
super().__init_subclass__(**kwargs)
if fmt:
DataLoader._registry[fmt] = cls
@classmethod
def get_loader(cls, fmt):
if fmt not in cls._registry:
raise ValueError(
f"No loader for {fmt!r}. "
f"Available: {list(cls._registry)}"
)
return cls._registry[fmt]
class CSVLoader(DataLoader, fmt="csv"):
def load(self, path):
return f"Loading CSV from {path}"
class JSONLoader(DataLoader, fmt="json"):
def load(self, path):
return f"Loading JSON from {path}"
class ParquetLoader(DataLoader, fmt="parquet"):
def load(self, path):
return f"Loading Parquet from {path}"
loader = DataLoader.get_loader("parquet")
print(loader.load("sales.parquet"))วิธีนี้ช่วยให้การสร้างระบบ Plugin สะดวกขึ้นมาก เพราะเพียงแค่สืบทอดคลาสก็เป็นการลงทะเบียนในตัวทันที
จุดที่ Registry Pattern มีประโยชน์ในการใช้งานจริง
นี่คือเทคนิคที่เป็นแกนหลักของซอฟต์แวร์ระดับโลกหลายตัว:
- Machine Learning Frameworks: เช่น Hugging Face Transformers, Detectron2 และ MMDetection ที่ใช้เลือกโมเดลหรือตัว Optimizer ผ่านชื่อ string
- Data Parsers: ใช้จับคู่นามสกุลไฟล์ (.csv, .json) กับคลาสที่ทำหน้าที่โหลดข้อมูล
- Web Frameworks: เช่น
@app.route()ของ Flask และ@cli.command()ของ Click ที่จับคู่ URL หรือคำสั่งเข้ากับฟังก์ชันจัดการ - Plugin Systems: เช่น pytest fixtures หรือ Airflow operators ที่รวบรวมส่วนประกอบต่างๆ ตอน Import
- Event Handlers: เปลี่ยนตารางสถานะ (State table) ที่ซับซ้อนให้กลายเป็น Dictionary ที่อ่านง่าย
ข้อควรพิจารณาในทางปฏิบัติ
- การ Import สำคัญมาก: Decorator จะทำงานต่อเมื่อมีการ Import ไฟล์นั้นๆ หากไฟล์ Plugin ไม่ถูกโหลดเข้ามาระบบจะไม่เห็น @register
- ป้องกันการตั้งชื่อซ้ำ: ควรมีการแจ้งข้อผิดพลาดหากมี Key ซ้ำเพื่อป้องกันพฤติกรรมที่ไม่คาดคิด
- ทำข้อความข้อผิดพลาดให้ชัดเจน: ควรแสดงรายการ Key ทั้งหมดที่มีให้ผู้ใช้งานทราบเสมอ
- เลือกใช้ให้ถูกงาน: หากเงื่อนไขมีน้อยและเสถียรอยู่แล้ว การใช้ if/else ปกติอาจจะอ่านง่ายกว่า Registry จะเหมาะเมื่อมีการขยายตัวบ่อยๆ เท่านั้น
บทสรุป
Registry pattern ช่วยเปลี่ยนการเขียนเงื่อนไขที่ขยายยากให้กลายเป็นตารางค้นหาที่เป็นระเบียบ ทำให้ฟีเจอร์ใหม่ปรากฏในรูปแบบของไฟล์ใหม่แทนการแก้ไขไฟล์เดิม ซึ่งจะส่งผลดีต่อการรักษาโค้ดในระยะยาว
หากครั้งต่อไปคุณต้องพิมพ์ elif เป็นครั้งที่สาม ลองหยุดคิดดูว่า Dictionary หรือ Registry จะช่วยให้โค้ดสะอาดขึ้นได้หรือไม่
# ก่อนหน้า
if kind == "a": ...
elif kind == "b": ...
elif kind == "c": ...
# หลังจากนั้น
@registry.register("a")
def handle_a(): ...การตัดสินใจเลือกใช้ Registry Pattern ในวันนี้ จะช่วยให้คุณ (และทีม) ขอบคุณตัวเองในอนาคตที่ไม่ต้องเลื่อนผ่านโค้ด 200 บรรทัดเพื่อแก้ไขจุดเดียว
ความคิดเห็น (0)
เข้าสู่ระบบเพื่อร่วมแสดงความเห็น
สมัครสมาชิกมาเป็นคนแรกที่แสดงความเห็นกันเลยโบร
