วิธีนำหลักการ SOLID มาใช้ใน Python ทีละขั้นตอน

  • หลักการ SOLID เป็นรากฐานที่ชัดเจนสำหรับการออกแบบโค้ด Python แบบเชิงวัตถุที่อ่านง่าย บำรุงรักษาง่าย และปรับขนาดได้ดียิ่งขึ้น
  • หลักการแต่ละข้อ (SRP, OCP, LSP, ISP และ DIP) จะกล่าวถึงปัญหาการออกแบบเฉพาะประเภท ตั้งแต่การแบ่งแยกความรับผิดชอบที่ไม่ชัดเจนไปจนถึงการพึ่งพาที่ตายตัว
  • การนำหลักการ SOLID มาใช้ร่วมกับคลาส นามธรรม และการฉีดการพึ่งพาใน Python ช่วยลดการเชื่อมโยง ปรับปรุงความสามารถในการทดสอบ และอำนวยความสะดวกในการพัฒนาระบบ

แข็งแกร่งในภาษา Python

เมื่อคุณเริ่มทำงานกับโปรเจ็กต์ 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จะสร้างความแตกต่างอย่างมากระหว่างโครงการที่สามารถขยายขนาดได้ในระยะยาวกับโครงการที่ล้มเหลวทันทีที่มันเติบโตขึ้นเล็กน้อย

IDE ที่ดีที่สุดสำหรับการเขียนโปรแกรม Windows 11
บทความที่เกี่ยวข้อง:
IDE ที่ดีที่สุดสำหรับการเขียนโปรแกรมบน Windows 11

เพิ่มเป็นแหล่งข้อมูลที่ต้องการใน Google