Aprende a aplicar el principio de sustitución de Liskov (LSP) en Python usando protocolos, detectando y corrigiendo un bug real en un notificador por SMS. Verás cómo usar docstrings en formato NumPy, aprovechar un default factory y garantizar que las clases sean intercambiables sin romper el servicio de pagos.
¿Qué problema rompe el principio de sustitución de Liskov?
En el proyecto se reemplazaron las clases abstractas por protocolos (interfaces en Python) para Notifier y Payment Processor. Se añadieron docstrings que documentan comportamiento, parámetros y tipos, usando el formato NumPy en send_confirmation y describiendo también process_transaction.
Se introdujo a propósito un bug para ilustrar LSP: EmailNotifier respeta la firma del métodosend_confirmation(customer_data), tal como exige el protocolo, pero SMSNotifier exige un parámetro adicional sms_gateway. Al inyectar SMSNotifier en el servicio de pagos —que por defecto notifica por email mediante un default factory—, el debugger mostró: validación de cliente y pago correctas, cargo en Stripe correcto, pero una excepción al ejecutar SMSNotifier por incompatibilidad de firma. Esto impide sustituir email por SMS sin cambios en el servicio, violando LSP.
Protocolos: forma de declarar interfaces en Python con comportamiento similar a clases abstractas.
Docstrings: documentación del comportamiento y tipos; se usó formato NumPy.
Firma del método: el protocolo define send_confirmation(customer_data).
Bug: SMSNotifier pedía sms_gateway como parámetro y rompía la sustitución.
¿Cómo se veía la firma incompatible?
from typing import Protocol
classNotifier(Protocol):defsend_confirmation(self, customer_data):...classEmailNotifier:defsend_confirmation(self, customer_data):"""
Parameters
----------
customer_data : dict
Datos del cliente para confirmar la transacción.
"""passclassSMSNotifier:# Incumple el protocolo: agrega un parámetro extra.defsend_confirmation(self, customer_data, sms_gateway):# rompe LSPpass
¿Cómo se corrige con dataclass y atributos?
La solución fue convertir SMSNotifier en data class y mover sms_gateway a un atributo de instancia. Así, el método respeta la firma del protocolo y usa el atributo interno.
from dataclasses import dataclass
@dataclassclassSMSNotifier: sms_gateway:str# Atributo en lugar de parámetro del método.defsend_confirmation(self, customer_data):# Aquí se usaría self.sms_gateway para enviar el SMS.pass# Instanciación correcta del notificador SMS.sms_notifier = SMSNotifier(sms_gateway="this is a SMS mock get")
Con este ajuste, EmailNotifier y SMSNotifier comparten la misma firma y son sustituibles.
¿Cómo se prueba la sustitución en el servicio de pagos?
En la implementación del servicio se creó un notificador por SMS y otro por email. El servicio de pagos, por defecto, usa el notificador de email gracias al default factory, pero se puede pasar el notificador SMS por parámetro. Con un breakpoint en la sección clave y usando el debugger se verificó: validación de cliente y pago correctas, cargo en Stripe, y tras la corrección, no se generó ninguna excepción. Luego se probó cambiando al notificador por email y todo funcionó de forma intercambiable, sin modificar el servicio.
¿Qué habilidades y conceptos se refuerzan?
Principio de sustitución de Liskov: clases hijas o implementaciones deben ser intercambiables sin romper el cliente.
Protocolos en Python: alternativa ligera a clases abstractas para definir interfaces.
Docstrings en formato NumPy: claridad en comportamiento, parámetros y tipos.
Firma consistente: evitar parámetros extra que rompen la interfaz.
Data class: mover dependencias a atributos de instancia.
Instanciación correcta: pasar el sms_gateway al crear SMSNotifier.
Pruebas con debugger y breakpoint: validar flujo y detectar excepciones.
Configuración por defecto con default factory: email como estrategia de notificación base.
¿Qué errores detectarías y cómo mejorarlos?
Verifica que todas las implementaciones de Notifier respeten la misma firma.
Asegura que los servicios acepten cualquier notificador sin cambios internos.
Documenta con docstrings los tipos esperados y valores de retorno.
Repite la prueba con ambos notificadores para confirmar la intercambiabilidad.
¿De qué otra forma aplicarías Liskov aquí? ¿Qué otros detalles mejorarías en el código? Comparte tu enfoque en los comentarios.
Hola! En ese código te faltaría que las clases EmailNotifier y SMSNotifier hereden del protocolo Notifier, para que puedan ser pasadas como parámetros en el método notify(), ya que exige su primer parámetro como tipo Notifier.
Quedaría:
class EmailNotifier(Notifier):
class SMSNotifier(Notifier):
Buen punto ... no es necesario que EmailNotifier y SMSNotifier hereden de Notifier. Es decir que no requiere una relación explícita.
La función notify recibe como parámetro un Protocol, el cuál se puede substituir por todas las clases que cumplan con el contrato, es decir que tengan la implementación correcta de las funciones de la interfaz.
Al invocar el método process_transaction() de payment_service_sms_notifier lo hace con el parámetro customer_data_with_email y al invocar el de payment_service ingresa el parámetro customer_data_with_phone.
No debería ser al revés?
Debería fallar al tratar de buscar los atributos inexistente.
Ahh, no entendi el reto de ir a buscar el bug en el codigo porque en el codigo ni el before ni after tienen el parametro sms_gateway en el send_confirmation de SMSNotifier....
Los protocolos permiten declarar interfaces en Python funcionando de forma similar a clases abstractas, con la diferencia de no requerir herencia explícita, lo cual otorga flexibilidad para cumplir con interfaces sin la necesidad de estructuras de herencia estrictas.
En Python, las interfaces se denominan "Protocolos" porque el lenguaje utiliza esta terminología para definir un conjunto de métodos que las clases deben implementar, sin necesidad de heredar de una clase base específica. Esto se importa del módulo typing para permitir una programación más flexible y dinámica.
Los protocolos proporcionan una forma de definir interfaces de manera más informal y son una característica importante para implementar el principio de sustitución de Liskov. Esto permite que diferentes clases sean intercambiables siempre que cumplan con el mismo protocolo.
Para cumplir con el principio de sustitución de Liskov la clase SMSNotifier no requiere de un parámetro adicional al que pide EMailNotifier ya que heredan de la misma clase.
La clase debe quedar:
@dataclass
classSMSNotifier(Notifier):sms_gateway: str
def send_confirmation(self,customer_data:CustomerData): # Responsabilidad de la notificación
phone_number = customer_data.contact_info.phone # sms_gateway ="the custom SMS Gateway"print( f"send the sms using {self.sms_gateway}: SMS sent to {phone_number}: \
Thank you for your payment.")```@dataclassclass SMSNotifier(Notifier): sms\_gateway: str
  def send\_confirmation(self, customer\_data:CustomerData):*# Responsabilidad de la notificación* phone\_number = customer\_data.contact\_info.phone*# sms\_gateway ="the custom SMS Gateway"*print( f"send the sms using {*self*.sms\_gateway}: SMS sent to {phone\_number}: \ Thank you for your payment.")
El bug que encontré se debe a que se cambio la clase SMSNotifier, la deje así:
@dataclass
classSMSNotifier(Notifier):sms_gateway: str
def send_confirmation(self,customer_data:CustomerData): # Responsabilidad de la notificación
phone_number = customer_data.contact_info.phoneprint( f"send the sms using {sms_gateway}: SMS sent to {phone_number}: \
Thank you for your payment.")```Se quito la linea de código
\# sms\_gateway ="the custom SMS Gateway"Hay que cambiar la firma de la función a:def send\_confirmation(self, customer\_data:CustomerData, sms\_gateway):Así que al instanciar el sms\_notifier se debe debe incluir:sms\_notifier =SMSNotifier(sms\_gateway="the custom SMS Gateway")Con estas modificaciones me funciono el código de nuevo.
El siguiente es un ejemplo sencillo en Python que muestra la implementación de interfaces utilizando Protocol:
from typing import Protocol
classNotifier(Protocol):defsend_notification(self, message:str)->None:...classEmailNotifier:defsend_notification(self, message:str)->None:print(f"Enviando correo: {message}")classSMSNotifier:defsend_notification(self, message:str)->None:print(f"Enviando SMS: {message}")defnotify(notifier: Notifier, message:str)->None: notifier.send_notification(message)email_notifier = EmailNotifier()sms_notifier = SMSNotifier()notify(email_notifier,"Hola a todos!")notify(sms_notifier,"Hola a todos!")
En este ejemplo, Notifier es un protocolo que define un método send_notification. Tanto EmailNotifier como SMSNotifier implementan este método, permitiendo usar cualquiera de los dos como notificadores.
Una manera en la podria implentar el princio de liskov seria implemnetar el metodo de notificacion con llamada, con la data class, CallNotifier. Me gustaria recibir feedback. Asi esta mi codigo:
import os
from dataclasses import dataclass, field
from typing import Optional, Protocol
import stripe
from dotenv import load_dotenv
from pydantic import BaseModel
from stripe import Charge
from stripe.error import StripeError
_ = load_dotenv()classContactInfo(BaseModel): email: Optional[str]=None phone: Optional[str]=NoneclassCustomerData(BaseModel): name:str contact_info: ContactInfo
classPaymentData(BaseModel): amount:int source:str@dataclassclassCustomerValidator:defvalidate(self, customer_data: CustomerData):ifnot customer_data.name:print("Invalid customer data: missing name")raise ValueError("Invalid customer data: missing name")ifnot customer_data.contact_info:print("Invalid customer data: missing contact info")raise ValueError("Invalid customer data: missing contact info")ifnot(customer_data.contact_info.email or customer_data.contact_info.phone):print("Invalid customer data: missing email and phone")raise ValueError("Invalid customer data: missing email and phone")@dataclassclassPaymentDataValidator:defvalidate(self, payment_data: PaymentData):ifnot payment_data.source:print("Invalid payment data: missing source")raise ValueError("Invalid payment data: missing source")if payment_data.amount <=0:print("Invalid payment data: amount must be positive")raise ValueError("Invalid payment data: amount must be positive")classNotifier(Protocol):"""
Protocol for sending notifications.
This protocol defines the interface for notifiers. Implementations
should provide a method `send_confirmation` that sends a confirmation
to the customer.
"""defsend_confirmation(self, customer_data: CustomerData):"""Send a confirmation notification to the customer.
:param customer_data: Data about the customer to notify.
:type customer_data: CustomerData
"""...classEmailNotifier(Notifier):defsend_confirmation(self, customer_data: CustomerData):from email.mime.text import MIMEText
msg = MIMEText("Thank you for your payment.") msg["Subject"]="Payment Confirmation" msg["From"]="no-reply@example.com" msg["To"]= customer_data.contact_info.email or""print("Email sent to", customer_data.contact_info.email)@dataclassclassSMSNotifier(Notifier): sms_gateway:strdefsend_confirmation(self, customer_data: CustomerData): phone_number = customer_data.contact_info.phone
print(f"send the sms using {self.sms_gateway}: SMS sent to {phone_number}: Thank you for your payment.")@dataclassclassCallNotifier(Notifier): call_gateway:strdefsend_confirmation(self, customer_data: CustomerData): phone_number = customer_data.contact_info.phone
print(f"send the call using {self.call_gateway}: Call made to {phone_number}: Thank you for your payment.")@dataclassclassTransactionLogger:deflog( self, customer_data: CustomerData, payment_data: PaymentData, charge: Charge
):withopen("transactions.log","a")as log_file: log_file.write(f"{customer_data.name} paid {payment_data.amount}\n") log_file.write(f"Payment status: {charge['status']}\n")classPaymentProcessor(Protocol):"""
Protocol for processing payments.
This protocol defines the interface for payment processors. Implementations
should provide a method `process_transaction` that takes customer data and payment data,
and returns a Stripe Charge object.
"""defprocess_transaction( self, customer_data: CustomerData, payment_data: PaymentData
)-> Charge:"""Process a payment.
:param customer_data: Data about the customer making the payment.
:type customer_data: CustomerData
:param payment_data: Data about the payment to process.
:type payment_data: PaymentData
:return: A Stripe Charge object representing the processed payment.
:rtype: Charge
"""...@dataclassclassStripePaymentProcessor(PaymentProcessor):defprocess_transaction( self, customer_data: CustomerData, payment_data: PaymentData
)-> Charge: stripe.api_key = os.getenv("STRIPE_API_KEY")try: charge = stripe.Charge.create( amount=payment_data.amount, currency="usd", source=payment_data.source, description="Charge for "+ customer_data.name,)print("Payment successful")return charge
except StripeError as e:print("Payment failed:", e)raise e
@dataclassclassPaymentService: customer_validator = CustomerValidator() payment_validator = PaymentDataValidator() payment_processor: PaymentProcessor = field(default_factory=StripePaymentProcessor) notifier: Notifier = field(default_factory=CallNotifier) logger = TransactionLogger()defprocess_transaction(self, customer_data, payment_data)-> Charge: self.customer_validator.validate(customer_data) self.payment_validator.validate(payment_data)try: charge = self.payment_processor.process_transaction( customer_data, payment_data
)except StripeError as e:raise e
self.notifier.send_confirmation(customer_data) self.logger.log(customer_data, payment_data, charge)return charge
if __name__ =="__main__": sms_notifier = SMSNotifier(sms_gateway="This is a sms mock gateway") payment_service = PaymentService() payment_service_sms_notifier = PaymentService(notifier=sms_notifier) customer_data_with_email = CustomerData( name="John Doe", contact_info=ContactInfo(email="john@example.com")) customer_data_with_phone = CustomerData( name="John Doe", contact_info=ContactInfo(phone="1234567890")) payment_data = PaymentData(amount=100, source="tok_visa") payment_service_sms_notifier.process_transaction( customer_data_with_email, payment_data
) payment_service.process_transaction(customer_data_with_phone, payment_data)try: error_payment_data = PaymentData(amount=100, source="tok_radarBlock") payment_service.process_transaction( customer_data_with_email, error_payment_data
)except Exception as e:print(f"Payment failed and PaymentProcessor raised an exception: {e}")
Para aplicar el **Principio de Sustitución de Liskov** (LSP), las subclases deben comportarse de manera que puedan sustituir a su clase base sin alterar el funcionamiento del sistema. Esto implica que la subclase debe cumplir con el contrato de la clase base en cuanto a comportamiento, sin añadir restricciones o modificar expectativas fundamentales.
### Ejemplo: Aplicación Correcta del LSP en Python
Imaginemos que estamos desarrollando un sistema para un zoológico que maneja diferentes tipos de aves. Tenemos una clase Bird que representa un ave general con un método fly. Queremos agregar subclases Eagle (que puede volar) y Penguin (que no vuela).
#### Ejemplo Incorrecto: Violación del LSP
En este caso, al intentar hacer que Penguin herede de Bird pero no pueda volar, estamos violando el LSP, ya que Penguin no cumple con el comportamiento esperado (poder volar) de Bird.
Con este diseño, Penguin no se comporta como un Bird común, ya que lanza una excepción en lugar de volar, lo cual rompe el LSP. Si un programa intenta llamar al método fly en una instancia de Penguin y espera que funcione igual que en Bird, se generará un error inesperado.
### Solución: Aplicando el LSP
Para cumplir con el LSP, podemos rediseñar la estructura usando una clase base Bird con dos subclases específicas: FlyingBird y NonFlyingBird. Esto nos permite representar correctamente tanto a las aves que vuelan como a las que no, sin que una interfiera en la funcionalidad de la otra.
from abc import ABC, abstractmethod
\# Clase base abstracta
classBird(ABC):  @abstractmethod  def make\_sound(self):  pass
\# Subclase para aves que pueden volar
classFlyingBird(Bird):  def fly(self):  print("I can fly!")
\# Subclase para aves que no vuelan
classNonFlyingBird(Bird):  def fly(self):  raise NotImplementedError("This bird can't fly.")
\# Aves específicas
classEagle(FlyingBird):  def make\_sound(self):  print("Screech!")classPenguin(NonFlyingBird):  def make\_sound(self):  print("Honk!")
### Explicación
- **Clase Bird**: Es una clase base abstracta que define el método make\_sound que todas las aves deben implementar.
- **Clases FlyingBird y NonFlyingBird**: Son clases intermedias que especifican si un ave puede volar o no. FlyingBird implementa el método fly, mientras que NonFlyingBird levanta una excepción controlada con NotImplementedError para indicar que estas aves no vuelan.
- **Clases Eagle y Penguin**: Estas representan tipos específicos de aves que implementan el método make\_sound de la clase base Bird. Eagle puede volar gracias a que hereda de FlyingBird, y Penguin no vuela porque hereda de NonFlyingBird.
### Uso del Código
Podemos crear instancias de Eagle y Penguin y usarlas de manera segura sin que una viole el comportamiento esperado de la otra.
\# Crear instancias
eagle = Eagle()penguin = Penguin()
\# Ejecutar métodos
eagle.make\_sound()# Screech!eagle.fly()# I can fly!penguin.make\_sound()# Honk!\# penguin.fly()# Esto lanza NotImplementedError, lo cual es el comportamiento esperado
### Ventajas de Aplicar el LSP
1. **Consistencia en el Comportamiento**: Las subclases cumplen con el comportamiento esperado sin generar resultados inesperados o excepciones imprevistas.
2. **Reusabilidad**: Las clases FlyingBird y NonFlyingBird actúan como plantillas reutilizables para cualquier tipo de ave que vuele o no vuele, respectivamente.
3. **Mantenibilidad**: Se puede extender la jerarquía con nuevas clases de aves sin tener que modificar la lógica existente.
### Conclusión
El Principio de Sustitución de Liskov nos ayuda a crear una jerarquía de clases que se comporta de manera predecible, cumpliendo con las expectativas del usuario de la clase base. En este ejemplo, hemos creado un diseño que permite agregar nuevos tipos de aves sin romper la funcionalidad de las existentes, cumpliendo así con el LSP y mejorando la estabilidad y extensibilidad del sistema.