เมื่อซอฟต์แวร์โตขึ้น ความยุ่งเหยิงมักจะตามมา (Entropy):
Design Patterns คือแนวทางแก้ปัญหาที่ผ่านการพิสูจน์แล้ว [3]:
คลาสนี้ทำทั้งคำนวณ, แสดงผล, และบันทึกข้อมูล [6]
class Report:
def calculate_data(self):
...
def format_html(self):
...
def save_to_db(self):
...
ถ้า HTML เปลี่ยน ก็ต้องแก้คลาสนี้ ถ้า DB เปลี่ยน ก็ต้องแก้คลาสนี้
แยกความรับผิดชอบออกเป็นคลาสเฉพาะทาง [7]
class ReportData: def calculate(self): ... class ReportFormatter: def to_html(self, data): ... class ReportRepository: def save(self, data): ...
แต่ละคลาสมีเหตุผลเดียวที่จะเปลี่ยนแปลง
"ซอฟต์แวร์ควร เปิดกว้างต่อการขยาย แต่ ปิดต่อการแก้ไข" [7]
ต้องแก้ฟังก์ชัน `get_area` ทุกครั้งที่มีรูปทรงใหม่ [7]
def get_area(shapes):
for s in shapes:
if type(s) == Rectangle:
...
elif type(s) == Circle:
...
# ต้องแก้ตรงนี้ถ้ามี Triangle!
ใช้ Abstract Base Class เพื่อขยาย ไม่ต้องแตะโค้ดเดิม [8]
class Shape(ABC): @abstractmethod def area(self): pass # เพิ่ม Triangle โดยไม่กระทบโค้ดเดิม class Triangle(Shape): def area(self): ...
"ถ้า S เป็นคลาสลูกของ T, ออบเจกต์ประเภท T จะต้องถูกแทนที่ด้วยออบเจกต์ประเภท S ได้ โดยไม่ทำให้โปรแกรมทำงานผิดพลาด" [8]
ทางคณิตศาสตร์ใช่ แต่ในเชิงพฤติกรรม (Behavior) ไม่ใช่! [11]
class Rectangle:
def set_w(self, w): ...
def set_h(self, h): ...
class Square(Rectangle):
def set_w(self, w):
self.width = w
self.height = w # Side effect!
def resize(rect): rect.set_w(10) rect.set_h(20) # คาดหวังพื้นที่ 200 # ถ้าส่ง Square มา พื้นที่จะเป็น 400! assert rect.area() == 200
บทเรียน: การสืบทอด (Inheritance) คือเรื่องของพฤติกรรม ไม่ใช่แค่โครงสร้าง ควรใช้ Composition หรือแยกเป็นคลาส Shape ที่อิสระต่อกัน [12]
"ไม่ควรบังคับให้ Client พึ่งพาเมธอดที่พวกเขาไม่ได้ใช้งาน" (อย่าทำ Interface ที่เทอะทะ) [9]
class Machine:
OldPrinter ต้อง Implement scan() ทั้งที่สแกนไม่ได้ [12]
class Printer:
def print(self)
class Scanner:
def scan(self)
class AllInOne(Printer, Scanner): [13]
โมดูลระดับสูงไม่ควรขึ้นกับโมดูลระดับต่ำ ทั้งคู่ควรขึ้นอยู่กับ Abstractions (นามธรรม) [10]
class App:
def __init__(self):
# พึ่งพา MySQL โดยตรง!
self.db = MySQLDatabase()
def get_data(self):
self.db.query()
class App:
# พึ่งพา Abstraction
def __init__(self, db: DataSource):
self.db = db
# ส่งสิ่งที่ต้องใช้เข้าไป (Injection)
app = App(MySQLDatabase())
app = App(PostgresDatabase())
โซลูชันสำหรับปัญหาที่พบบ่อยในการออกแบบซอฟต์แวร์ แบ่งออกเป็น 3 ประเภท: [3]
เกี่ยวกับการสร้างออบเจกต์
เกี่ยวกับการประกอบคลาสและออบเจกต์
เกี่ยวกับการสื่อสารระหว่างออบเจกต์
รับประกันว่าคลาสจะมี Instance เพียงอันเดียว และให้จุดเข้าถึงระดับ Global [3]
สร้างออบเจกต์โดยไม่ต้องระบุคลาสที่แน่นอน ให้คลาสลูกตัดสินใจว่าจะสร้างอะไร [3]
Adapter Pattern (ตัวแปลง): ทำหน้าที่เป็นสะพานเชื่อมระหว่าง 2 อินเทอร์เฟซที่ไม่เข้ากัน ให้สามารถทำงานร่วมกันได้
หัวปลั๊กไฟ (US Plug) เสียบกับเต้ารับไทยไม่ได้ เราจึงต้องใช้ Adapter ตัวแปลงเพื่อเชื่อมต่อ
class OldSystem:
def legacy_method(self):
return "DATA_XML"
class NewSystem:
def request(self):
return "DATA_JSON"
class Adapter(NewSystem):
def __init__(self, old_obj):
self.old_obj = old_obj
def request(self):
# แปลงข้อมูลเก่าให้เข้ากับระบบใหม่
data = self.old_obj.legacy_method()
return convert_xml_to_json(data)
ใช้สำหรับเพิ่มพฤติกรรม (Behavior) ให้กับวัตถุแบบไดนามิก โดยไม่ต้องแก้ไขโค้ดเดิมหรือสร้าง Subclass ใหม่ที่ซับซ้อน
@decorator ได้โดยตรง# Base Component
class Coffee:
def cost(self): return 50
# Decorator
class MilkDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 10
my_coffee = Coffee()
my_coffee = MilkDecorator(my_coffee)
print(my_coffee.cost()) # 60
เมื่อวัตถุหลัก (Subject) เปลี่ยนแปลงสถานะ มันจะแจ้งเตือนไปยังผู้สังเกตการณ์ (Observers) ทั้งหมดโดยอัตโนมัติ
ระบบ Signals & Slots คือการประยุกต์ใช้ Observer Pattern (ปุ่มถูกกด -> ฟังก์ชันทำงาน)
ช่วยให้คุณกำหนดกลุ่มของอัลกอริทึม (Algorithms) และทำให้มันสลับสับเปลี่ยนกันได้ (Interchangeable) ขณะโปรแกรมทำงาน
ลูกค้าเลือกจ่ายผ่าน CreditCard หรือ PayPal โค้ดส่วนประมวลผลไม่ต้องแก้ แค่เปลี่ยน Strategy Object
class PaymentContext:
def __init__(self, strategy):
self.strategy = strategy
def pay(self, amount):
self.strategy.execute(amount)
# Strategies
class CreditCardStrategy:
def execute(self, amount):
print(f"Paid {amount} via Card")
class PayPalStrategy:
def execute(self, amount):
print(f"Paid {amount} via PayPal")
# Usage
context = PaymentContext(CreditCardStrategy())
context.pay(100) # Paid 100 via Card
เทคนิคสำคัญในการทำตามหลักการ DIP (Dependency Inversion)
คลาสสร้าง Dependencies เองภายใน ผูกติดกันแน่น
class App:
def __init__(self):
# ผูกติดกับ MySQL
self.db = MySQLDatabase()
รับ Dependencies เข้ามาทาง Constructor
class App:
def __init__(self, db):
# ยืดหยุ่น รับอะไรก็ได้ที่เป็น DB
self.db = db
app = App(PostgresDatabase())
"ถ้ามันเดินเหมือนเป็ด และร้องเหมือนเป็ด ก็ถือว่ามันเป็นเป็ด"
Python ไม่เช็คชนิดตัวแปร (Type) แต่เช็คพฤติกรรม (Behavior/Method) ว่ามีให้เรียกใช้หรือไม่
วิธีกำหนด Interface แบบ Static Type Checking โดยไม่ต้องสืบทอดจริง (Structural Subtyping)
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def render(obj: Drawable):
obj.draw() # ไม่ต้องสืบทอดจาก Drawable แค่มี method draw ก็พอ
คลาสรู้ทุกอย่าง ทำทุกอย่าง (ละเมิด SRP) ไฟล์เดียวยาวพันบรรทัด ยากต่อการแก้ไข
การไหลของโปรแกรมยุ่งเหยิง ขาดโครงสร้าง (Structure) พึ่งพา if-else ซ้อนกันลึกๆ แทนการใช้ Polymorphism
คัดลอกโค้ดซ้ำๆ ไปหลายที่ (ละเมิด DRY - Don't Repeat Yourself) เมื่อต้องแก้บั๊ก ต้องตามแก้ทุกที่
SOLID Principles คือเข็มทิศ ช่วยให้โค้ดแข็งแรงและยืดหยุ่น
Design Patterns คือเครื่องมือ (Tools) แก้ปัญหาที่พบบ่อย
เลือกใช้ Composition มากกว่า Inheritance เพื่อลดความซับซ้อน
ใน Python, ใช้ Duck Typing และ Decorators เพื่อโค้ดที่กระชับแบบ Pythonic
การสร้าง "Log Viewer Application" ด้วย PySide6
ออกแบบแอปพลิเคชัน GUI ที่สามารถอ่านข้อมูล Log จากแหล่งต่างๆ (ไฟล์ Text, Mock API) ได้โดยใช้หลักการ SOLID
เราจะไม่เขียนทุกอย่างใน `main.py` แต่จะแยกส่วนประกอบตามหน้าที่ (SRP)
กำหนดสัญญาว่า "แหล่งข้อมูล" ต้องทำอะไรได้บ้าง
การทำงานจริง (อ่านไฟล์, สุ่มข้อมูล) ที่สับเปลี่ยนได้
แสดงผลอย่างเดียว ไม่ยุ่งกับ Business Logic
ตามหลัก DIP และ OCP เราเริ่มที่การกำหนด Interface ก่อน เพื่อให้ UI ไม่ผูกติดกับแหล่งข้อมูลจริง
interfaces/data_source.pyABC (Abstract Base Class)get_logs() ที่ทุกคลาสต้องมีfrom abc import ABC, abstractmethod from typing import List # Interface (Contract) class ILogSource(ABC): @abstractmethod def get_logs(self) -> List[str]: """ดึงข้อมูล Logs กลับมาเป็น List ของ String""" pass
สร้างคลาสที่ทำงานจริง 2 แบบ (ไฟล์ services/file_source.py และ services/mock_source.py)
class FileLogSource(ILogSource): def __init__(self, filepath): self.filepath = filepath def get_logs(self) -> List[str]: try: with open(self.filepath, 'r') as f: return f.readlines() except FileNotFoundError: return ["Error: File not found"]
class MockLogSource(ILogSource): def get_logs(self) -> List[str]: return [ "[INFO] System started", "[WARN] Memory usage high", "[ERROR] Connection lost" ]
ในไฟล์ ui/main_window.py เราจะไม่สร้าง FileLogSource โดยตรง (ห้ามใช้ new ภายในคลาส)
self.source = FileLogSource("log.txt")
รับ source เข้ามาผ่าน Constructor
from PySide6.QtWidgets import * class MainWindow(QMainWindow): # รับ Abstraction เข้ามา (DIP) def __init__(self, source: ILogSource): super().__init__() self.source = source # Composition self.init_ui() def load_data(self): # UI ไม่รู้ว่าข้อมูลมาจากไหน รู้แค่ get_logs() logs = self.source.get_logs() self.list_widget.addItems(logs)
สร้าง Simple Factory เพื่อเลือกแหล่งข้อมูลตาม Configuration
class SourceFactory: @staticmethod def create_source(source_type: str) -> ILogSource: if source_type == "file": return FileLogSource("app.log") elif source_type == "mock": return MockLogSource() else: raise ValueError("Unknown type")
Client code (main.py) ไม่จำเป็นต้องรู้ว่าต้อง import คลาสไหน หรือต้องกำหนดค่าเริ่มต้น (filepath) อย่างไร Factory จัดการให้หมด
เราต้องการฟีเจอร์ "กรองข้อมูล" (Filter) แทนที่จะเขียน if-else ใน UI ให้เราสร้าง Strategy
# Strategy Interface class IFilterStrategy(ABC): @abstractmethod def filter(self, logs: List[str]) -> List[str]: pass # Concrete Strategies class ErrorOnlyFilter(IFilterStrategy): def filter(self, logs): return [l for l in logs if "ERROR" in l] class NoFilter(IFilterStrategy): def filter(self, logs): return logs
เพิ่ม method set_filter_strategy ให้ UI
self.filter_strategy = NoFilter()
logs = self.source.get_logs()
filtered = self.filter_strategy.filter(logs)
ใน main.py เราจะเชื่อมต่อทุกส่วนเข้าด้วยกัน
import sys from PySide6.QtWidgets import QApplication from ui.main_window import MainWindow from services.factory import SourceFactory if __name__ == "__main__": app = QApplication(sys.argv) # 1. เลือก Source (เปลี่ยนแค่ตรงนี้ก็เปลี่ยนทั้งแอปได้) # ลองเปลี่ยนเป็น "file" หรือ "mock" source = SourceFactory.create_source("mock") # 2. Inject เข้าไปใน UI (Dependency Injection) window = MainWindow(source) window.show() sys.exit(app.exec())
จงเพิ่มความสามารถให้แอปอ่านไฟล์ .csv โดยที่:
main_window.py (UI)FileLogSource เดิมCsvLogSource ที่สืบทอดจาก ILogSourceUI ไม่ขึ้นกับคลาสอ่านไฟล์โดยตรง แต่ขึ้นกับ Interface ILogSource ทำให้สลับเปลี่ยนแหล่งข้อมูลได้ง่าย
แยกการอ่านข้อมูล (Service) ออกจากการแสดงผล (View) อย่างชัดเจน
เพิ่มฟีเจอร์ใหม่ (เช่น CSV Source หรือ Filter แบบใหม่) ได้โดยไม่ต้องแก้โค้ดเก่า