เมื่อคุณเริ่มทำงานกับโปรเจ็กต์ Python ขนาดใหญ่ สิ่งแรกๆ ที่คุณจะสังเกตเห็นก็คือโค้ดจะเข้าใจยาก ทดสอบยาก และต่อยอดได้ยากหากคุณไม่ปฏิบัติตามกฎการออกแบบพื้นฐานบางประการ นั่นคือจุดที่หลักการ SOLID อันโด่งดังเข้ามามีบทบาท: ชุดแนวทางปฏิบัติที่ดีที่สุดที่ออกแบบมาเพื่อให้การทำงานของทีมง่ายขึ้นมาก
หลักการเหล่านี้มีต้นกำเนิดมาจากขอบเขตของการเขียนโปรแกรมเชิงวัตถุแบบคลาสสิก (Java, C++, C#, เป็นต้น)แต่ก็เข้ากันได้ดีกับ Python ตราบใดที่คุณใช้คลาสและวัตถุในลักษณะที่เข้มงวดไม่มากก็น้อย เราจะมาดูรายละเอียดว่าหลักการเหล่านี้คืออะไร มาจากไหน ทำไมจึงสำคัญ และเหนือสิ่งอื่นใด วิธีการนำ SOLID ไปประยุกต์ใช้พร้อมตัวอย่างที่ชัดเจนใน Pythonเพื่อทำให้โค้ดของคุณดูแลรักษาง่ายขึ้น ขยายขนาดได้ง่ายขึ้น และใช้งานได้สนุกยิ่งขึ้น
SOLID คืออะไร และมันมาจากไหน?
คำว่าSOLID เป็นคำย่อที่ไมเคิล เฟเธอร์ส ทำให้เป็นที่นิยม โดยใช้เรียกกลุ่มหลักการออกแบบ 5 ข้อที่เสนอโดยโรเบิร์ต ซี. มาร์ติน หรือที่รู้จักกันดีในชื่อลุงบ็อบ วิศวกรซอฟต์แวร์ชาวอเมริกันผู้นี้เป็นหนึ่งในผู้ลงนามใน Agile Manifesto เขาได้ตีพิมพ์บทความเรื่อง "The Principles of OOD" ในช่วงกลางทศวรรษ 1990 และต่อมาในบทความ "Design Principles and Design Patterns" ซึ่งวางรากฐานสำคัญของการออกแบบเชิงวัตถุสมัยใหม่
เมื่อเวลาผ่านไป ผู้เขียนคนอื่นๆ เช่นBarbara Liskov และ Bertrand Meyerก็ได้ร่วมเสนอแนวคิดที่ถูกนำมาผนวกเข้ากับหลักการชุดนี้ ส่วน Michael Feathers นั้นมีไอเดีย (ที่เฉียบแหลมมาก) คือการเรียงลำดับตัวอักษรย่อใหม่ให้กลายเป็นคำว่า SOLID ซึ่งช่วยให้แนวคิดนี้แพร่กระจายไปอย่างรวดเร็วในวงการพัฒนาซอฟต์แวร์
ตัวอักษรทั้งห้าของ SOLID สอดคล้องกับหลักการออกแบบเชิงวัตถุเหล่านี้ ซึ่งสามารถนำไปใช้กับ Python ได้เช่นกัน:
- S – หลักการความรับผิดชอบเดียว (หลักการความรับผิดชอบเดียว)
- O – หลักการเปิด/ปิด (หลักการเปิด/ปิด)
- หลักการทดแทนของลิสคอฟ (L – Liskov Substitution Principle) (หลักการทดแทนของลิสคอฟ)
- I – หลักการแยกส่วนอินเทอร์เฟซ (หลักการแยกส่วนอินเทอร์เฟซ)
- D – หลักการผกผันการพึ่งพา (หลักการกลับด้านการพึ่งพา)
โดยทั่วไปแล้ว หลักการทั้งห้าข้อนี้ เมื่อใช้ร่วมกันจะช่วยให้คุณเขียนซอฟต์แวร์ที่ยืดหยุ่น ทดสอบได้ และบำรุงรักษาได้ง่ายซึ่งหมายถึงการปรับใช้ที่รวดเร็วขึ้น ข้อผิดพลาดที่ไม่ทราบสาเหตุลดลง การนำโค้ดกลับมาใช้ใหม่ได้ดีขึ้น และปัญหาปวดหัวน้อยลงเมื่อโครงการใช้งานจริงมาได้หลายปีแล้ว
หลักการ SOLID ถูกนำมาใช้ในภาษา Python เพื่ออะไรบ้าง?
การนำหลักการ SOLID มาใช้ใน Python ไม่ใช่แค่เรื่องทางวิชาการเท่านั้น แต่ส่งผลโดยตรงต่อการทำงานประจำวันของทีม เมื่อคุณยึดมั่นในหลักการเหล่านี้คุณจะลดโค้ดที่ยุ่งเหยิง ลดกลิ่นเหม็นของโค้ด และป้องกันไม่ให้โค้ดของคุณ "มีกลิ่นเหม็นเน่า " ดังคำเปรียบเทียบที่รู้จักกันดีว่า "ถ้ามันมีกลิ่นเหม็น แสดงว่ามันออกแบบมาไม่ดี" บน Windows นักพัฒนาหลายคนเลือกที่จะติดตั้งและกำหนดค่า WSL2เพื่อให้ได้สภาพแวดล้อม Linux ที่ใกล้เคียงกับสภาพแวดล้อมการใช้งานจริงมากขึ้น
ในสภาพแวดล้อมการทำงานร่วมกัน (เช่น ทีมพัฒนาแบ็กเอนด์ วิศวกรรมข้อมูล ผลิตภัณฑ์ที่มีวงจรชีวิตยาว ฯลฯ) หลักการเหล่านี้มีความสำคัญอย่างยิ่ง หลายคนสามารถทำงานบนโค้ดเบสเดียวกันได้โดยไม่ก้าวล้ำขอบเขตหรือทำให้ทุกอย่างพังทลายแม้เพียงการแตะต้องเล็กน้อยนอกจากนี้ Python แม้จะมีความยืดหยุ่นและไดนามิก แต่ก็ยังช่วยให้สามารถประยุกต์ใช้แนวคิดเชิงนามธรรมของการเขียนโปรแกรมเชิงวัตถุ (OOP) ได้อย่างราบรื่น เช่น คลาสแบบนามธรรม ลำดับชั้นการสืบทอด การประกอบ และอินเทอร์เฟซ ผ่านทาง abcฯลฯ
โดยสรุปแล้ว SOLID ช่วยให้คุณบรรลุเป้าหมายดังต่อไปนี้:
- โค้ดสะอาดตาและอ่านง่ายขึ้นแม้จะผ่านมาหลายปีนับตั้งแต่เขียนบทความนั้นแล้วก็ตาม
- ความสามารถในการทดสอบที่ดีขึ้นเนื่องจากมีการแบ่งแยกความรับผิดชอบไว้อย่างชัดเจน
- สามารถนำกลับมาใช้ซ้ำได้สูงและปรับขนาดได้สะดวก เนื่องจากมีการพึ่งพาซึ่งกันและกันระหว่างโมดูลน้อยลง
- ข้อผิดพลาดด้านหลักทรัพย์ค้ำประกันน้อยลงเมื่อคุณเปลี่ยนแปลงบางสิ่งในโมดูลหนึ่ง คุณจะไม่เผลอไปทำให้สิ่งอื่นอีกห้าอย่างเสียหาย
S – หลักการความรับผิดชอบเดียว
หลักการข้อแรกกล่าวว่าคลาสควรมีเหตุผลในการเปลี่ยนแปลงเพียงหนึ่งเดียวนั่นคือ ควรมีหน้าที่รับผิดชอบที่ชัดเจนและแน่นอนเพียงอย่างเดียว นี่ไม่ได้หมายความว่าควรมีเพียงเมธอดเดียว แต่หมายความว่าตรรกะทั้งหมดของคลาสควรชี้ไปยังจุดประสงค์ที่สอดคล้องกันเพียงหนึ่งเดียว
ลองนึกภาพคลาส Python ที่เป็นตัวแทนของผู้ใช้ และนอกเหนือจากการจัดเก็บข้อมูลของผู้ใช้แล้ว ยังทำหน้าที่เข้าถึงฐานข้อมูลและสร้างรายงานอีกด้วย:
class User:
def __init__(self, name: str):
self.name = name
def get_user_from_database(self, user_id: int) -> dict:
# Recupera datos desde la base de datos
# ...
pass
def save_user_to_database(self) -> None:
# Persiste el usuario en la base de datos
# ...
pass
def generate_user_report(self) -> str:
# Genera un informe del usuario
# ...
pass
ในที่นี้ คลาสนี้รวมเอาหน้าที่แตกต่างกันสามอย่างไว้ด้วยกัน ได้แก่ การเป็นตัวแทนผู้ใช้ การจัดการการคงอยู่ของข้อมูล และการสร้างรายงาน การเปลี่ยนแปลงฐานข้อมูล รูปแบบรายงาน หรือคุณลักษณะของผู้ใช้ จำเป็นต้องแก้ไขคลาสเดียวกัน ซึ่งเพิ่มความเสี่ยงในการเกิดข้อผิดพลาดข้ามแพลตฟอร์ม
หากเราแยกประเด็นเหล่านี้ออกจากกัน การออกแบบจะดีขึ้นอย่างมาก:
class User:
def __init__(self, name: str):
self.name = name
class UserDB:
@staticmethod
def get_user(user_id: int) -> User:
# Lógica para obtener usuarios de la base de datos
# ...
return User("John Doe")
@staticmethod
def save_user(user: User) -> None:
# Lógica para guardar el usuario
# ...
pass
class UserReportGenerator:
@staticmethod
def generate_report(user: User) -> str:
# Lógica para generar informes de usuario
# ...
return f"Report for user: {user.name}"
ตอนนี้ถึงเวลาเรียนแล้ว ผู้ใช้เป็นเพียงตัวแทนของผู้ใช้ในฐานะเอนทิตีเท่านั้นหากวิธีการสร้างรายงานเปลี่ยนแปลงไป เพียงแค่แตะ UserReportGeneratorหากคุณต้องการเปลี่ยนแปลงฐานข้อมูล คุณเพียงแค่แตะหน้าจอ UserDBแต่ละคลาสมีเหตุผลในการเปลี่ยนแปลงเพียงอย่างเดียว ซึ่งช่วยให้การแก้ไขข้อผิดพลาดและการพัฒนาระบบง่ายขึ้น
การนำ SRP ไปใช้กับตัวอย่างที่สมจริงยิ่งขึ้น: เป็ดและการสื่อสาร
ลองพิจารณาสถานการณ์คลาสสิกที่ปรับเปลี่ยนแล้ว: คลาสDuckซึ่งในตอนแรกจะค่อยๆ เพิ่มความรับผิดชอบเข้าไปจนกลายเป็นคลาสขนาดใหญ่ที่ยากต่อการบำรุงรักษา ลองนึกภาพการใช้งานแบบง่ายๆ ดู:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
def greet(self, other_duck: "Duck") -> None:
print(f"{self.name}: {self.do_sound()}, hello {other_duck.name}")
ควรนิยามคลาสนี้ อย่างง่ายๆ ว่า "เป็ด " แต่คลาสนี้ยังทำหน้าที่จัดการวิธีการสื่อสารระหว่างเป็ดด้วยกัน หากคุณเปลี่ยนแปลงตรรกะการสนทนาในวันพรุ่งนี้ (เช่น เพิ่มวลี ภาษาอื่นๆ ช่องทางการสื่อสารที่แตกต่างกัน) คุณจะต้องแก้ไขคลาสเป็ด ซึ่งปัจจุบันก็ทำงานได้ดีอยู่แล้วในฐานะเอนทิตี
วิธีแก้ปัญหาที่เคารพหลักการ SRP คือการแยกความรับผิดชอบข้อที่สองนั้นไปไว้ในคลาสอื่นที่เชี่ยวชาญด้านการสื่อสาร:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
class Communicator:
def __init__(self, channel: str):
self.channel = channel
def communicate(self, duck1: Duck, duck2: Duck) -> None:
sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
conversation =
print(*conversation, f"(via {self.channel})", sep="\n")
เนื่องจากการแยกทางครั้งนี้ คุณสามารถพัฒนาตรรกะการสื่อสารได้โดยไม่ต้องเปลี่ยนแปลงนิยามของคำว่า "เป็ด"นอกจากนี้ โค้ดยังทดสอบได้ง่ายกว่า: คุณทดสอบพฤติกรรมของ Duck และในอีกด้านหนึ่ง ของ Communicatorโดยไม่ทำให้ความรับผิดชอบปะปนกัน
O – หลักการเปิด/ปิด
หลักการเปิดเผยโค้ด (Open Code Principle หรือ OCP) ระบุว่าส่วนประกอบของซอฟต์แวร์ควรเปิดกว้างสำหรับการขยายพฤติกรรม แต่ควรปิดกั้นการแก้ไขโดยตรงกล่าวอีกนัยหนึ่ง เมื่อคุณต้องการเพิ่มฟังก์ชันการทำงานใหม่ คุณไม่ควรต้องเขียนคลาสที่ใช้งานได้อยู่แล้วและถูกใช้งานโดยโมดูลอื่น ๆ ใหม่ทั้งหมด
ตัวอย่างคลาสสิกคือการคำนวณพื้นที่ของรูปทรงเรขาคณิต มาดูเวอร์ชันที่ไม่เคารพหลักการ OCP กันก่อน :
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Circle:
def __init__(self, radius: float):
self.radius = radius
class AreaCalculator:
def calculate_area(self, shape) -> float:
if isinstance(shape, Rectangle):
return shape.width * shape.height
elif isinstance(shape, Circle):
return 3.14159 * shape.radius * shape.radius
else:
raise ValueError("Forma no soportada")
ถ้าคุณต้องการเพิ่มรูปสามเหลี่ยมในวันพรุ่งนี้ คุณจะต้องถูกบังคับให้... แก้ไขรหัสของ AreaCalculatorเพิ่มอีก elifการกระทำนี้ขัดกับหลักการ OCP เพราะคลาสจะไม่ "ปิด" ต่อการเปลี่ยนแปลงอีกต่อไป
เวอร์ชันที่ถูกต้องเกี่ยวข้องกับการนำเสนอแนวคิดเชิงนามธรรม Shape ด้วยวิธีการ area() ซึ่งแต่ละคนนำไปปฏิบัติในแบบของตนเอง:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius * self.radius
class AreaCalculator:
def calculate_area(self, shape: Shape) -> float:
return shape.area()
ด้วยการออกแบบนี้ ทำให้ เพิ่มรูปสามเหลี่ยมที่คุณไม่ได้สัมผัส AreaCalculatorคุณเพียงแค่สร้างคลาสย่อยใหม่:
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
หลักการเปิด/ปิดนั้นสอดคล้องกับแนวคิดเรื่อง "เปิด/ปิด" ได้เป็นอย่างดี" กำหนดจุดขยายที่ชัดเจนผ่านนามธรรม: อินเทอร์เฟซ, คลาสแบบนามธรรม, ฮุก ฯลฯ ใน Python โมดูลนี้ abc วิธีนี้ช่วยให้คุณสามารถแสดงสิ่งนี้ได้อย่างชัดเจน แม้ว่าภาษาจะเป็นแบบไดนามิกก็ตาม
OCP ประยุกต์ใช้กับตัวอย่างผู้สื่อสาร
กลับมาที่ ตัวอย่าง Communicatorอีกครั้ง เราสามารถก้าวไปอีกขั้นและเตรียมการออกแบบเพื่อรองรับการสนทนาประเภทต่างๆ โดยไม่ต้องเขียน Communicator ใหม่ทุกครั้ง เพื่อทำเช่นนั้น เรากำหนดนามธรรมของการสนทนาและให้ Communicator ใช้งานนามธรรมนั้นโดยตรง:
from typing import final
from abc import ABC, abstractmethod
class AbstractConversation(ABC):
@abstractmethod
def do_conversation(self) -> list:
pass
class SimpleConversation(AbstractConversation):
def __init__(self, duck1: Duck, duck2: Duck):
self.duck1 = duck1
self.duck2 = duck2
def do_conversation(self) -> list:
sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
return
class Communicator:
def __init__(self, channel: str):
self.channel = channel
@final
def communicate(self, conversation: AbstractConversation) -> None:
print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")
ในเวอร์ชันนี้ หากคุณต้องการเพิ่มวิธีการพูดแบบใหม่ (ตัวอย่างเช่น การสนทนาที่ก้าวร้าว การสนทนาแบบผลัดกันพูด ฯลฯ) คุณก็แค่สร้างคลาสย่อยอีกคลาสหนึ่งขึ้นมา AbstractConversation. วิธีการ communicate() de Communicator มันไม่มีการเปลี่ยนแปลง ยังคงปฏิบัติตาม OCP อย่างเคร่งครัด
หลักการทดแทนของลิสคอฟ (L – Liskov Substitution Principle)
หลักการทดแทนของลิสคอฟ (Liskov Substitution Principle) ซึ่งคิดค้นโดยบาร์บารา ลิสคอฟ ระบุว่าคลาสย่อยควรสามารถใช้แทนคลาสพื้นฐานได้โดยไม่เปลี่ยนแปลงพฤติกรรมที่คาดหวังของโปรแกรมในทางปฏิบัติ หมายความว่า หากโค้ดทำงานได้กับอินสแตนซ์หนึ่งของคลาสพื้นฐาน ก็ควรทำงานได้ดีเช่นเดียวกันกับอินสแตนซ์ใดๆ ของคลาสย่อย
ตัวอย่างทั่วไปของการละเมิด LSP คือการสร้างแบบจำลองนกทุกตัวด้วยวิธีเดียว fly()รวมถึงนกกระจอกเทศด้วย:
class Bird:
def fly(self) -> None:
pass
class Duck(Bird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
def fly(self) -> None:
# Las avestruces no vuelan
raise NotImplementedError("Las avestruces no pueden volar")
รหัสใดๆ ที่สมมติว่า นกทุกตัวที่บินได้จะบินไม่ได้เมื่อได้รับนกกระจอกเทศ. ฉันหมายถึง Ostrich มันไม่ใช่สิ่งทดแทนที่ถูกต้องสำหรับ Birdซึ่งถือเป็นการละเมิด LSP (Local Service Provider)
วิธีแก้ปัญหาคือการปรับลำดับชั้นให้สะท้อนความเป็นจริงได้ดียิ่งขึ้น: ไม่ใช่ว่านกทุกตัวจะบินได้ ดังนั้น ควรใช้วิธีนี้กับนกเพียงบางส่วนเท่านั้น fly():
class Bird:
pass
class FlyingBird(Bird):
def fly(self) -> None:
pass
class Duck(FlyingBird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
# No vuela, así que no implementa fly()
pass
ด้วยการออกแบบนี้ ฟังก์ชันใดก็ตามที่ต้องการนกบิน จะระบุว่าฟังก์ชันนั้นต้องการนกบินหนึ่งตัว FlyingBirdและจะไม่มีวันได้รับนกกระจอกเทศ ด้วยวิธีนี้ LSP จึงได้รับการเคารพ และหลีกเลี่ยงข้อผิดพลาดขณะรันไทม์ที่ไม่คาดคิดได้
บทสนทนาระหว่าง LSP กับนก
กลับมาที่ตัวอย่างการสนทนาอีกครั้ง เป็นเรื่องปกติที่จะเริ่มเขียนโค้ดโดยคิดถึงแต่เป็ดก่อน แล้วค่อยอยากเพิ่มนกกาหรือนกชนิดอื่นเข้าไป หากคลาสการสนทนาขึ้นอยู่กับ... Duck, คุณจะไม่สามารถนำไปใช้กับนกชนิดอื่นได้ โดยไม่ต้องแก้ไขโค้ด:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation มันถูกออกแบบมาสำหรับเป็ดเท่านั้น คุณจะไม่สามารถใช้กับอีกาได้โดยไม่ต้องแก้ไขมัน วิธีที่ถูกต้องคือการสร้างนามธรรมทั่วไป Bird และทำให้การสนทนาขึ้นอยู่กับแนวคิดเชิงนามธรรมนั้น:
from abc import ABC, abstractmethod
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def do_sound(self) -> str:
pass
class Crow(Bird):
def do_sound(self) -> str:
return "Caw"
class Duck(Bird):
def do_sound(self) -> str:
return "Quack"
class SimpleConversation(AbstractConversation):
def __init__(self, bird1: Bird, bird2: Bird):
self.bird1 = bird1
self.bird2 = bird2
def do_conversation(self) -> list:
sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
return
ด้วยวิธีนี้ คลาสย่อยใด ๆ ของ Bird ที่เคารพสัญญา (do_sound()(ชื่อ ฯลฯ) คือ สารทดแทนที่ถูกต้อง และจะไม่ทำให้พฤติกรรมที่คาดหวังไว้เปลี่ยนแปลงไป SimpleConversation.
I – หลักการแยกส่วนอินเทอร์เฟซ
หลักการ ISP ระบุว่าไม่ควรบังคับให้ไคลเอ็นต์ใด ๆ ต้องพึ่งพาเมธอดที่ตนเองไม่ได้ใช้เมื่อแปลเป็นคลาสหรืออินเทอร์เฟซแบบนามธรรมแล้ว หมายความว่าการมีอินเทอร์เฟซขนาดเล็กที่เฉพาะเจาะจงหลาย ๆ อัน จะดีกว่าการมีอินเทอร์เฟซขนาดใหญ่แบบทั่วไปเพียงอันเดียว
สังเกตการออกแบบนี้ซึ่งมีอินเทอร์เฟซอยู่ Worker สิ่งนี้กำหนดให้ผู้ที่นำไปใช้ทุกคนต้องมีวิธีการทำงานและการรับประทานอาหารที่เฉพาะเจาะจง:
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self) -> None:
pass
@abstractmethod
def eat(self) -> None:
pass
class Human(Worker):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Worker):
def work(self) -> None:
print("El robot está trabajando")
def eat(self) -> None:
# El robot no come, pero está obligado a declarar este método
pass
ระดับ หุ่นยนต์อาศัยวิธีการหนึ่ง eat() ที่ไม่ต้องการการเปลี่ยนแปลงใดๆ ที่เกี่ยวข้องกับอาหารจะส่งผลต่อหุ่นยนต์ แม้ว่ามันจะไม่มีส่วนเกี่ยวข้องกับพฤติกรรมนั้นเลยก็ตาม
โดยการใช้ ISP เราได้แบ่งอินเทอร์เฟซออกเป็นสองส่วนที่เล็กกว่าและเฉพาะเจาะจงมากขึ้น:
class Workable(ABC):
@abstractmethod
def work(self) -> None:
pass
class Eatable(ABC):
@abstractmethod
def eat(self) -> None:
pass
class Human(Workable, Eatable):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Workable):
def work(self) -> None:
print("El robot está trabajando")
ตอนนี้แต่ละคลาสจะใช้งานเฉพาะเมธอดที่จำเป็นจริงๆ เท่านั้นซึ่งจะช่วยลดการเชื่อมโยงระหว่างคลาสต่างๆ ช่วยให้การพัฒนาการออกแบบง่ายขึ้น และทำให้โค้ดมีความชัดเจนมากขึ้น กล่าวคือ เห็นได้ชัดเจนว่าใครสามารถทำอะไรได้บ้าง
ISP ในการจำลองแบบนก: การบินและการว่ายน้ำ
สิ่งที่คล้ายกันนี้เกิดขึ้นเมื่อสร้างแบบจำลองนกที่บินและว่ายน้ำ หากใช้แนวคิดพื้นฐานที่เรียบง่าย Bird จำเป็นต้องนำทั้งสองอย่างมาใช้ fly() ในขณะที่ swim()สุดท้ายคุณจะได้เรียนวิชาต่างๆ เช่นนี้ Crow คนที่ต้องแสร้งทำเป็นว่าว่ายน้ำเป็น:
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def fly(self) -> None:
pass
@abstractmethod
def swim(self) -> None:
pass
@abstractmethod
def do_sound(self) -> str:
pass
ตามที่ผู้ให้บริการอินเทอร์เน็ต (ISP) ระบุ วิธีแก้ปัญหาคือการแบ่งอินเทอร์เฟซออกเป็นฟังก์ชันเฉพาะเจาะจงมากขึ้น :
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def do_sound(self) -> str:
pass
class FlyingBird(Bird):
@abstractmethod
def fly(self) -> None:
pass
class SwimmingBird(Bird):
@abstractmethod
def swim(self) -> None:
pass
class Crow(FlyingBird):
def fly(self) -> None:
print(f"{self.name} is flying high and fast!")
def do_sound(self) -> str:
return "Caw"
class Duck(SwimmingBird, FlyingBird):
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
ถ้าคุณคิดจะปั้นโมเดลนกเพนกวินเมื่อไหร่ ก็ง่ายๆ เลย คุณทำให้เขาได้รับมรดกจาก SwimmingBird แต่ไม่ใช่จาก FlyingBirdและคุณไม่จำเป็นต้องสร้างเมธอดที่ว่างเปล่าหรือโยนข้อยกเว้นเทียมขึ้นมา
D – หลักการผกผันการพึ่งพา
หลักการสุดท้าย DIP สามารถสรุปได้เป็นสองแนวคิดหลัก คือโมดูลระดับสูงไม่ควรขึ้นอยู่กับโมดูลระดับต่ำ ทั้งสองควรขึ้นอยู่กับนามธรรมและนามธรรมไม่ควรขึ้นอยู่กับรายละเอียด แต่รายละเอียดต่างหากที่ควรขึ้นอยู่กับนามธรรม
ในทางปฏิบัติ หมายความว่าตรรกะทางธุรกิจของคุณไม่ควรผูกติดกับรายละเอียดเฉพาะเจาะจง เช่น "ฉันใช้ MySQL" "ฉันเขียนลงไฟล์ในเครื่อง" หรือ "ฉันส่งข้อความ SMS ด้วยผู้ให้บริการรายนี้" แต่คุณควรนิยามตรรกะทางธุรกิจให้กว้างขึ้น อินเทอร์เฟซนามธรรม (ตัวอย่างเช่น Database, Channel, NotificationService) และคุณทำให้โค้ดระดับสูงของคุณสื่อสารกับพวกเขาเท่านั้น
การออกแบบที่ขัดต่อหลักการ DIPคือที่เก็บข้อมูลผู้ใช้ที่สร้างอินสแตนซ์ฐานข้อมูล MySQL โดยตรง:
class MySQLDatabase:
def connect(self) -> None:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
# Ejecutar consulta
return []
class UserRepository:
def __init__(self) -> None:
self.database = MySQLDatabase() # Dependencia directa
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
หากคุณตัดสินใจใช้ PostgreSQL ในวันพรุ่งนี้ คุณจะต้อง... แก้ไขคลาสระดับสูง UserRepositoryคุณต้องยึดติดกับรายละเอียดการใช้งานเฉพาะอย่างหนึ่ง
โดยการใช้ DIP เราจะกำหนดนามธรรมของฐานข้อมูลก่อน จากนั้นจึงให้การใช้งานจริงสืบทอดจากนามธรรมนั้น:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self) -> None:
pass
@abstractmethod
def query(self, sql: str) -> list:
pass
class MySQLDatabase(Database):
def connect(self) -> None:
# Conexión a MySQL
pass
def query(self, sql: str) -> list:
# Consulta en MySQL
return []
class PostgreSQLDatabase(Database):
def connect(self) -> None:
# Conexión a PostgreSQL
pass
def query(self, sql: str) -> list:
# Consulta en PostgreSQL
return []
class UserRepository:
def __init__(self, database: Database) -> None:
self.database = database # Depende de una abstracción
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
ดังนั้น คุณสามารถแทรกการใช้งานใดๆ ก็ได้ของ Database เมื่อสร้าง repository โดยไม่แก้ไขโค้ดภายใน:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
รูปแบบนี้เรียกว่าDependency Injectionและเป็นวิธีที่พบมากที่สุดในการประยุกต์ใช้ DIP: คลาสจะไม่สร้าง Dependency ของตัวเอง แต่จะรับ Dependency จากภายนอก (ผ่านทาง Constructor หรือเมธอดเฉพาะ) โดยใช้ Abstraction เป็นประเภทเสมอ
DIP ประยุกต์ใช้กับช่องทางการสื่อสารและอุปกรณ์สื่อสาร
ในตัวอย่างการสนทนาของนก เราสามารถปรับปรุงการจัดการช่องสัญญาณได้โดยการใช้ DIP สมมติว่าคุณกำหนดนามธรรมหนึ่งสำหรับช่องสัญญาณและอีกนามธรรมหนึ่งสำหรับผู้สื่อสาร:
class AbstractChannel(ABC):
@abstractmethod
def get_channel_message(self) -> str:
pass
class AbstractCommunicator(ABC):
@abstractmethod
def get_channel(self) -> AbstractChannel:
pass
@final
def communicate(self, conversation: AbstractConversation) -> None:
print(*conversation.do_conversation(),
self.get_channel().get_channel_message(),
sep="\n")
ตัวอย่างการนำไปใช้แบบง่ายๆ ในเบื้องต้นอาจเป็นดังนี้:
class SMSChannel(AbstractChannel):
def get_channel_message(self) -> str:
return "(via SMS)"
class SMSCommunicator(AbstractCommunicator):
def __init__(self) -> None:
self._channel = SMSChannel() # Depende de detalle concreto
def get_channel(self) -> AbstractChannel:
return self._channel
ถึงแม้จะดูเหมือนถูกต้องก็ตาม อุปกรณ์สื่อสารนี้ยังคงเชื่อมต่อโดยตรงกับ SMSChannelเราปรับปรุงการออกแบบโดยให้ตัวสื่อสารรับช่องทางจากภายนอก (การฉีดการพึ่งพา) และด้วยเหตุนี้จึงขึ้นอยู่กับนามธรรมเท่านั้น:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
ด้วยแนวทางนี้ ช่องทางใหม่ใดๆ (อีเมล การแจ้งเตือนแบบพุช ฯลฯ) จะสามารถนำไปใช้งานได้ AbstractChannel y สามารถใช้งานได้โดยไม่ต้องแก้ไขโค้ดการสื่อสารอีกครั้ง คลาสระดับสูงนั้นขึ้นอยู่กับนามธรรม ไม่ใช่รายละเอียด
จะเกิดอะไรขึ้นเมื่อคุณเพิกเฉยต่อหลักการ SOLID?
หากละเลยหลักการเหล่านี้ โค้ดมักจะประสบปัญหาต่างๆ เช่นโค้ดเหม็น โค้ดเสื่อมสภาพ และการเชื่อมโยงที่ยุ่งยากซับซ้อนซึ่งหมายถึงคลาสขนาดใหญ่ที่มีหน้าที่รับผิดชอบมากมาย คลาสย่อยที่ละเมิดข้อตกลง การพึ่งพาแบบวนซ้ำ และเมธอดที่เปลี่ยนแปลงอยู่ตลอดเวลาเพราะทำหน้าที่มากเกินไป
ผลที่ตามมานั้นชัดเจนและค่อนข้างเจ็บปวดสำหรับทุกทีม: ช่องโหว่มากขึ้น บั๊กมากขึ้น การปรับปรุงโค้ดอย่างต่อเนื่อง และในกรณีที่เลวร้ายที่สุด โค้ดที่ใช้งานแทบไม่ได้เลย นี่คือสิ่งที่เรียกกันทั่วไปว่า "โค้ดสปาเก็ตตี้" — ยากต่อการทำความเข้าใจ เต็มไปด้วยการแก้ไขแบบชั่วคราว และแทบเป็นไปไม่ได้ที่จะขยายเพิ่มเติมโดยไม่ทำให้ส่วนสำคัญเสียหาย
หลักการ SOLID ไม่ได้ตายตัว และการนำไปใช้อย่างเคร่งครัดทุกประการก็ไม่ใช่เรื่องคุ้มค่าเสมอไป โดยเฉพาะอย่างยิ่งในการสร้างต้นแบบอย่างรวดเร็วหรือโครงการขนาดเล็กมาก ถึงกระนั้นการจดจำหลักการเหล่านี้และนำไปใช้กับการออกแบบเชิงวัตถุส่วนใหญ่ใน Pythonจะสร้างความแตกต่างอย่างมากระหว่างโครงการที่สามารถขยายขนาดได้ในระยะยาวกับโครงการที่ล้มเหลวทันทีที่มันเติบโตขึ้นเล็กน้อย