Salta el contingut

Principis SOLID: Millora el Disseny Orientat a Objectes en Python

Taula de continguts

Disseny Orientat a Objectes en Python: Els Principis SOLID

  • Principi de Responsabilitat Única (SRP)
  • Principi Obert-Tancat (OCP)
  • Principi de Substitució de Liskov (LSP)
  • Principi de Segregació d'Interfícies (ISP)
  • Principi d'Inversió de Dependències (DIP)
  • Conclusió

Quan construeixes un projecte Python fent servir la programació orientada a objectes (POO), planificar com les diferents classes i objectes interactuaran per resoldre els teus problemes específics és una part important de la feina. Aquesta planificació es coneix com a disseny orientat a objectes (DOO), i fer-ho bé pot ser un repte. Si et quedes encallat mentre dissenyes les teves classes Python, els principis SOLID et poden ajudar.

SOLID és un conjunt de cinc principis de disseny orientat a objectes que et poden ajudar a escriure codi més mantenible, flexible i escalable basat en classes ben dissenyades i amb una estructura neta. Aquests principis són una part fonamental de les bones pràctiques de disseny orientat a objectes.

En aquest tutorial:

  • Entendràs el significat i el propòsit de cada principi SOLID
  • Identificaràs codi Python que viola alguns dels principis SOLID
  • Aplicaràs els principis SOLID per refactoritzar el teu codi Python i millorar-ne el disseny
  • Al llarg del teu camí d'aprenentatge, codificaràs exemples pràctics per descobrir com els principis SOLID poden conduir a un codi ben organitzat, flexible, mantenible i escalable.

Per aprofitar al màxim aquest tutorial, has de tenir un bon coneixement dels conceptes de programació orientada a objectes de Python, com ara classes, interfícies i herència.

Disseny Orientat a Objectes en Python: Els Principis SOLID

Quan es tracta d'escriure classes i dissenyar les seves interaccions en Python, pots seguir una sèrie de principis que t'ajudaran a construir un codi orientat a objectes millor. Un dels conjunts d'estàndards per al disseny orientat a objectes (DOO) més populars i àmpliament acceptats es coneix com els principis SOLID.

Si véns de C++ o Java, potser ja coneixes aquests principis. Potser et preguntes si els principis SOLID també s'apliquen al codi Python. A aquesta pregunta, la resposta és un rotund sí. Si estàs escrivint codi orientat a objectes, hauries de considerar aplicar aquests principis al teu DOO.

Però, quins són aquests principis SOLID? SOLID és un acrònim que agrupa cinc principis fonamentals que s'apliquen al disseny orientat a objectes. Aquests principis són els següents:

* Principi de Responsabilitat Única (SRP)
* Principi Obert-Tancat (OCP)
* Principi de Substitució de Liskov (LSP)
* Principi de Segregació d'Interfícies (ISP)
* Principi d'Inversió de Dependències (DIP)

Exploraràs cadascun d'aquests principis en detall i codificaràs exemples del món real de com aplicar-los en Python. En el procés, obtindràs una comprensió sòlida de com escriure codi orientat a objectes més senzill, organitzat, escalable i reutilitzable aplicant els principis SOLID. Per començar, partiràs del primer principi de la llista.

Principi de Responsabilitat Única (SRP)

El principi de responsabilitat única (SRP) prové de Robert C. Martin, més conegut pel seu sobrenom Uncle Bob, que és una figura molt respectada en el món de l'enginyeria de programari i un dels signants originals del Manifest Àgil. De fet, ell va encunyar el terme SOLID.

El principi de responsabilitat única estableix que:

Una classe hauria de tenir només una raó per canviar.

Això significa que una classe hauria de tenir només una responsabilitat, expressada a través dels seus mètodes. Si una classe s'ocupa de més d'una tasca, hauries de separar aquestes tasques en classes separades.

Nota: Trobaràs els principis SOLID formulats de diverses maneres. En aquest tutorial, t'hi referiràs seguint la formulació que fa servir Uncle Bob al seu llibre Agile Software Development: Principles, Patterns, and Practices. Per tant, totes les cites directes provenen d'aquest llibre.

Si vols llegir formulacions alternatives en un resum ràpid d'aquests i altres principis relacionats, consulta The Principles of OOD d'Uncle Bob.

Aquest principi està estretament relacionat amb el concepte de separació de responsabilitats, que suggereix que hauries de dividir els teus programes en seccions diferents. Cada secció ha d'abordar una responsabilitat separada.

Per il·lustrar el principi de responsabilitat única i com et pot ajudar a millorar el teu disseny orientat a objectes, imagina que tens la classe FileManager següent:

# file_manager_srp.py

from pathlib import Path
from zipfile import ZipFile

class FileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def read(self, encoding="utf-8"):
        return self.path.read_text(encoding)

    def write(self, data, encoding="utf-8"):
        self.path.write_text(data, encoding)

    def compress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
            archive.write(self.path)

    def decompress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
            archive.extractall()

En aquest exemple, la classe FileManager té dues responsabilitats diferents. Fa servir els mètodes .read() i .write() per gestionar el fitxer. També s'ocupa dels arxius ZIP proporcionant els mètodes .compress() i .decompress().

Aquesta classe viola el principi de responsabilitat única perquè té dues raons per canviar la seva implementació interna. Per solucionar aquest problema i fer el teu disseny més robust, pots dividir la classe en dues classes més petites i centrades, cadascuna amb la seva pròpia responsabilitat específica:

# file_manager_srp.py

from pathlib import Path
from zipfile import ZipFile

class FileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def read(self, encoding="utf-8"):
        return self.path.read_text(encoding)

    def write(self, data, encoding="utf-8"):
        self.path.write_text(data, encoding)

class ZipFileManager:
    def __init__(self, filename):
        self.path = Path(filename)

    def compress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
            archive.write(self.path)

    def decompress(self):
        with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
            archive.extractall()

Ara tens dues classes més petites, cadascuna amb una única responsabilitat. FileManager s'ocupa de gestionar un fitxer, mentre que ZipFileManager gestiona la compressió i descompressió d'un fitxer en format ZIP. Aquestes dues classes són més petites, per tant més manejables. També són més fàcils de comprendre, provar i depurar.

El concepte de responsabilitat en aquest context pot ser bastant subjectiu. Tenir una única responsabilitat no significa necessàriament tenir un únic mètode. La responsabilitat no està directament lligada al nombre de mètodes sinó a la tasca principal de la qual és responsable la teva classe, depenent de la teva idea del que representa la classe en el teu codi. Tot i això, aquesta subjectivitat no hauria d'impedir-te d'esforçar-te per aplicar el SRP.

Principi Obert-Tancat (OCP)

El principi obert-tancat (OCP) per al disseny orientat a objectes va ser introduït originalment per Bertrand Meyer el 1988 i significa que:

Les entitats de programari (classes, mòduls, funcions, etc.) haurien d'estar obertes per a l'extensió, però tancades per a la modificació.

Per entendre de què tracta el principi obert-tancat, considera la classe Shape següent:

# shapes_ocp.py

from math import pi

class Shape:
    def __init__(self, shape_type, **kwargs):
        self.shape_type = shape_type
        if self.shape_type == "rectangle":
            self.width = kwargs["width"]
            self.height = kwargs["height"]
        elif self.shape_type == "circle":
            self.radius = kwargs["radius"]

    def calculate_area(self):
        if self.shape_type == "rectangle":
            return self.width * self.height
        elif self.shape_type == "circle":
            return pi * self.radius**2

L'inicialitzador de Shape accepta un argument shape_type que pot ser "rectangle" o "circle". També accepta un conjunt específic d'arguments de paraula clau usant la sintaxi **kwargs. Si estableixes el tipus de forma a "rectangle", també has de passar els arguments de paraula clau width i height per poder construir un rectangle correcte.

En canvi, si estableixes el tipus de forma a "circle", també has de passar un argument radius per construir un cercle.

Nota: Aquest exemple pot semblar una mica extrem. La seva intenció és exposar clarament la idea central darrere del principi obert-tancat.

Shape també té un mètode .calculate_area() que calcula l'àrea de la forma actual segons el seu .shape_type:

>>> from shapes_ocp import Shape

>>> rectangle = Shape("rectangle", width=10, height=5)
>>> rectangle.calculate_area()
50
>>> circle = Shape("circle", radius=5)
>>> circle.calculate_area()
78.53981633974483

La classe funciona. Pots crear cercles i rectangles, calcular la seva àrea, etc. No obstant això, la classe té un aspecte bastant dolent. Alguna cosa sembla incorrecta a primera vista.

Imagina que has d'afegir una nova forma, potser un quadrat. Com ho faries? Doncs bé, l'opció aquí és afegir una altra clàusula elif a .__init__() i a .calculate_area() per poder satisfer els requisits d'una forma quadrada.

Haver de fer aquests canvis per crear noves formes significa que la teva classe està oberta a la modificació. Això viola el principi obert-tancat. Com pots corregir la teva classe per fer-la oberta a l'extensió però tancada a la modificació? Aquí tens una possible solució:

# shapes_ocp.py

from abc import ABC, abstractmethod
from math import pi

class Shape(ABC):
    def __init__(self, shape_type):
        self.shape_type = shape_type

    @abstractmethod
    def calculate_area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        super().__init__("circle")
        self.radius = radius

    def calculate_area(self):
        return pi * self.radius**2

class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__("rectangle")
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        super().__init__("square")
        self.side = side

    def calculate_area(self):
        return self.side**2

En aquest codi, has refactoritzat completament la classe Shape, convertint-la en una classe base abstracta (ABC). Aquesta classe proporciona la interfície requerida (API) per a qualsevol forma que vulguis definir. Aquesta interfície consisteix en un atribut .shape_type i un mètode .calculate_area() que has de sobreescriure en totes les subclasses.

Nota: L'exemple anterior i alguns exemples de les seccions següents fan servir les ABCs de Python per proporcionar el que s'anomena herència d'interfície. En aquest tipus d'herència, les subclasses hereten interfícies en lloc de funcionalitat. En canvi, quan les classes hereten funcionalitat, es presenta l'herència d'implementació.

Aquesta actualització tanca la classe a les modificacions. Ara pots afegir noves formes al teu disseny de classes sense necessitat de modificar Shape. En cada cas, hauràs d'implementar la interfície requerida, cosa que també fa les teves classes polimòrfiques.

Principi de Substitució de Liskov (LSP)

El principi de substitució de Liskov (LSP) va ser introduït per Barbara Liskov en una conferència OOPSLA el 1987. Des de llavors, aquest principi ha estat una part fonamental de la programació orientada a objectes. El principi estableix que:

Els subtipus han de ser substituïbles pels seus tipus base.

Per exemple, si tens un fragment de codi que treballa amb una classe Shape, hauries de poder substituir aquesta classe per qualsevol de les seves subclasses, com ara Circle o Rectangle, sense trencar el codi.

Nota: Pots llegir les actes de la conferència de la ponència on Barbara Liskov va compartir per primera vegada aquest principi, o pots veure un breu fragment d'una entrevista amb ella per obtenir més context.

En la pràctica, aquest principi tracta de fer que les teves subclasses es comportin com les seves classes base sense trencar les expectatives de ningú quan criden els mateixos mètodes. Per continuar amb exemples relacionats amb formes, imagina que tens una classe Rectangle com la següent:

# shapes_lsp.py

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

A Rectangle, has proporcionat el mètode .calculate_area(), que opera amb els atributs d'instància .width i .height.

Com que un quadrat és un cas especial de rectangle amb costats iguals, penses a derivar una classe Square de Rectangle per reutilitzar el codi. Llavors, sobreescrius el mètode setter per als atributs .width i .height de manera que quan un costat canvia, l'altre costat també canvia:

# shapes_lsp.py

# ...

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def __setattr__(self, key, value):
        super().__setattr__(key, value)
        if key in ("width", "height"):
            self.__dict__["width"] = value
            self.__dict__["height"] = value

En aquest fragment de codi, has definit Square com a subclasse de Rectangle. Com podria esperar un usuari, el constructor de la classe accepta només el costat del quadrat com a argument. Internament, el mètode .__init__() inicialitza els atributs del pare, .width i .height, amb l'argument side.

També has definit un mètode especial, .__setattr__(), per enganxar-se al mecanisme d'assignació d'atributs de Python i interceptar l'assignació d'un nou valor a l'atribut .width o .height. Concretament, quan estableixes un d'aquests atributs, l'altre atribut també s'estableix al mateix valor:

>>> from shapes_lsp import Square

>>> square = Square(5)
>>> vars(square)
{'width': 5, 'height': 5}

>>> square.width = 7
>>> vars(square)
{'width': 7, 'height': 7}

>>> square.height = 9
>>> vars(square)
{'width': 9, 'height': 9}

Ara t'has assegurat que l'objecte Square sempre es mantingui com un quadrat vàlid, fent la teva vida més fàcil al petit cost d'una mica de memòria malgastada. Malauradament, això viola el principi de substitució de Liskov perquè no pots substituir instàncies de Rectangle pels seus equivalents Square.

Quan algú espera un objecte rectangle en el seu codi, pot suposar que es comportarà com tal exposant dos atributs independents .width i .height. Mentrestant, la teva classe Square trenca aquesta suposició canviant el comportament promès per la interfície de l'objecte. Això podria tenir conseqüències sorprenents i no desitjades, que probablement serien difícils de depurar.

Tot i que un quadrat és un tipus específic de rectangle en matemàtiques, les classes que representen aquestes formes no haurien d'estar en una relació pare-fill si vols que compleixin el principi de substitució de Liskov. Una manera de solucionar aquest problema és crear una classe base de la qual tant Rectangle com Square puguin heretar:

# shapes_lsp.py

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def calculate_area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def calculate_area(self):
        return self.side ** 2

Shape es converteix en el tipus que pots substituir polimòrficament per Rectangle o Square, que ara són germans en lloc d'un pare i un fill. Fixa't que els dos tipus de formes concretes tenen conjunts d'atributs diferents, mètodes d'inicialització diferents i podrien potencialment implementar fins i tot més comportaments separats. L'única cosa que tenen en comú és la capacitat de calcular la seva àrea.

Amb aquesta implementació, pots fer servir el tipus Shape indistintament amb els seus subtipus Square i Rectangle quan només et preocupa el seu comportament comú:

>>> from shapes_lsp import Rectangle, Square

>>> def get_total_area(shapes):
...     return sum(shape.calculate_area() for shape in shapes)

>>> get_total_area([Rectangle(10, 5), Square(5)])
75

Aquí, passes un parell format per un rectangle i un quadrat a una funció que calcula la seva àrea total. Com que la funció només es preocupa pel mètode .calculate_area(), no importa que les formes siguin diferents. Aquesta és l'essència del principi de substitució de Liskov.

Principi de Segregació d'Interfícies (ISP)

El principi de segregació d'interfícies (ISP) prové de la mateixa ment que el principi de responsabilitat única. Sí, és una altra fita en el barret d'Uncle Bob. La idea principal del principi és que:

Els clients no haurien de ser forçats a dependre de mètodes que no fan servir. Les interfícies pertanyen als clients, no a les jerarquies.

En aquest cas, els clients són classes i subclasses, i les interfícies consisteixen en mètodes i atributs. En altres paraules, si una classe no fa servir determinats mètodes o atributs, llavors aquests mètodes i atributs haurien de ser segregats en classes més específiques.

Considera el següent exemple de jerarquia de classes per modelar màquines d'impressió:

# printers_isp.py

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

    @abstractmethod
    def fax(self, document):
        pass

    @abstractmethod
    def scan(self, document):
        pass

class OldPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in black and white...")

    def fax(self, document):
        raise NotImplementedError("Fax functionality not supported")

    def scan(self, document):
        raise NotImplementedError("Scan functionality not supported")

class ModernPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in color...")

    def fax(self, document):
        print(f"Faxing {document}...")

    def scan(self, document):
        print(f"Scanning {document}...")

En aquest exemple, la classe base Printer proporciona la interfície que han d'implementar les seves subclasses. OldPrinter hereta de Printer i ha d'implementar la mateixa interfície. No obstant això, OldPrinter no fa servir els mètodes .fax() i .scan() perquè aquest tipus d'impressora no admet aquestes funcionalitats.

Aquesta implementació viola el ISP perquè força OldPrinter a exposar una interfície que la classe no implementa ni necessita. Per solucionar aquest problema, hauries de separar les interfícies en classes més petites i específiques. Llavors pots crear classes concretes heretant de múltiples classes d'interfície segons sigui necessari:

# printers_isp.py

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

class Fax(ABC):
    @abstractmethod
    def fax(self, document):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan(self, document):
        pass

class OldPrinter(Printer):
    def print(self, document):
        print(f"Printing {document} in black and white...")

class NewPrinter(Printer, Fax, Scanner):
    def print(self, document):
        print(f"Printing {document} in color...")

    def fax(self, document):
        print(f"Faxing {document}...")

    def scan(self, document):
        print(f"Scanning {document}...")

Ara Printer, Fax i Scanner són classes base que proporcionen interfícies específiques amb una única responsabilitat cadascuna. Per crear OldPrinter, només heretes la interfície Printer. D'aquesta manera, la classe no tindrà mètodes no utilitzats. Per crear la classe ModernPrinter, has d'heretar de totes les interfícies. En resum, has segregat la interfície Printer.

Aquest disseny de classes et permet crear diferents màquines amb diferents conjunts de funcionalitats, fent el teu disseny més flexible i extensible.

Principi d'Inversió de Dependències (DIP)

El principi d'inversió de dependències (DIP) és l'últim principi del conjunt SOLID. Aquest principi estableix que:

Les abstraccions no haurien de dependre dels detalls. Els detalls haurien de dependre de les abstraccions.

Això sona bastant complex. Aquí tens un exemple que t'ajudarà a aclarir-ho. Imagina que estàs construint una aplicació i tens una classe FrontEnd per mostrar dades als usuaris d'una manera amigable. L'aplicació actualment obté les seves dades d'una base de dades, de manera que acabes amb el codi següent:

# app_dip.py

class FrontEnd:
    def __init__(self, back_end):
        self.back_end = back_end

    def display_data(self):
        data = self.back_end.get_data_from_database()
        print("Display data:", data)

class BackEnd:
    def get_data_from_database(self):
        return "Data from the database"

En aquest exemple, la classe FrontEnd depèn de la classe BackEnd i la seva implementació concreta. Es pot dir que ambdues classes estan estretament acoblades. Aquest acoblament pot portar a problemes d'escalabilitat. Per exemple, imagina que la teva aplicació creix ràpidament i vols que l'aplicació pugui llegir dades d'una API REST. Com ho faries?

Potser pensaries en afegir un nou mètode a BackEnd per recuperar les dades de l'API REST. No obstant això, això també et requerirà modificar FrontEnd, que hauria d'estar tancada a la modificació, d'acord amb el principi obert-tancat.

Per solucionar el problema, pots aplicar el principi d'inversió de dependències i fer que les teves classes depenguin d'abstraccions en lloc d'implementacions concretes com BackEnd. En aquest exemple específic, pots introduir una classe DataSource que proporcioni la interfície per fer servir en les teves classes concretes:

# app_dip.py

from abc import ABC, abstractmethod

class FrontEnd:
    def __init__(self, data_source):
        self.data_source = data_source

    def display_data(self):
        data = self.data_source.get_data()
        print("Display data:", data)

class DataSource(ABC):
    @abstractmethod
    def get_data(self):
        pass

class Database(DataSource):
    def get_data(self):
        return "Data from the database"

class API(DataSource):
    def get_data(self):
        return "Data from the API"

En aquest redisseny de les teves classes, has afegit una classe DataSource com a abstracció que proporciona la interfície requerida, o el mètode .get_data(). Fixa't com FrontEnd ara depèn de la interfície proporcionada per DataSource, que és una abstracció.

Llavors defineixes la classe Database, que és una implementació concreta per als casos en què vols recuperar les dades de la teva base de dades. Aquesta classe depèn de l'abstracció DataSource mitjançant herència. Finalment, defineixes la classe API per donar suport a la recuperació de dades de l'API REST. Aquesta classe també depèn de l'abstracció DataSource.

Aquí tens com pots fer servir la classe FrontEnd en el teu codi:

>>> from app_dip import API, Database, FrontEnd

>>> db_front_end = FrontEnd(Database())
>>> db_front_end.display_data()
Display data: Data from the database

>>> api_front_end = FrontEnd(API())
>>> api_front_end.display_data()
Display data: Data from the API

Aquí, primer inicialitzes FrontEnd usant un objecte Database i després de nou usant un objecte API. Cada vegada que crides .display_data(), el resultat dependrà de la font de dades concreta que facis servir. Fixa't que també pots canviar la font de dades dinàmicament reassignant l'atribut .data_source a la teva instància de FrontEnd.

Conclusió

Has après molt sobre els cinc principis SOLID, incloent com identificar codi que els viola i com refactoritzar el codi per adherir-se a les millors pràctiques de disseny. Has vist exemples bons i dolents relacionats amb cada principi i has après que aplicar els principis SOLID pot ajudar-te a millorar el teu disseny orientat a objectes en Python.