Ir al contenido
wannacode

Factory Method y Simple Factory

En resumen: Una fábrica guarda en un solo lugar la decisión de qué clase crear: Simple Factory pone la elección en un punto, y Factory Method deja que una subclase decida qué objeto fabricar.

Problema

Tres sitios conocen cada tipo de envío

El pedido dice cómo llega al cliente: "address" (a domicilio), "office" (en una oficina de mensajería) o "locker" (en un casillero). Cada tipo tiene una clase con su propio precio y su propia etiqueta:

class AddressDelivery:
price = 5.90
def label(self, recipient):
return f"{recipient}, a domicilio"
class OfficeDelivery:
price = 3.90
def label(self, recipient):
return f"{recipient}, en una oficina de mensajería"
class LockerDelivery:
price = 2.90
def label(self, recipient):
return f"{recipient}, en un casillero"

Tres partes de la tienda necesitan el envío: la caja, la vista previa del precio y la impresora de etiquetas. Cada una decide por su cuenta qué clase crear:

def checkout(kind, items):
if kind == "address":
delivery = AddressDelivery()
elif kind == "office":
delivery = OfficeDelivery()
elif kind == "locker":
delivery = LockerDelivery()
else:
raise ValueError(kind)
return items + delivery.price
def preview_price(kind):
if kind == "address":
delivery = AddressDelivery()
elif kind == "office":
delivery = OfficeDelivery()
elif kind == "locker":
delivery = LockerDelivery()
else:
raise ValueError(kind)
return delivery.price
def print_label(kind, recipient):
if kind == "office":
delivery = OfficeDelivery()
else:
delivery = AddressDelivery()
return delivery.label(recipient)
print(f"Pedido: {checkout("locker", 40):.2f} €")
print(f"Vista previa: envío {preview_price("locker"):.2f} €")
print("Etiqueta: " + print_label("locker", "María"))
Salida
Pedido: 42.90 €
Vista previa: envío 2.90 €
Etiqueta: María, a domicilio

El mes pasado llegaron los casilleros. Alguien añadió "locker" en la caja y en la vista previa. La impresora de etiquetas se escribió antes y tiene un else para la dirección. María pagó el envío a un casillero, y el mensajero fue a su casa.

Un tipo nuevo son tres cambios, y uno se olvida. La elección «qué clase para qué tipo» está copiada, y las copias no saben unas de otras.

La idea

Simple Factory: new vive en un solo lugar

La elección «qué clase para qué tipo» es una sola decisión, así que vive en un solo lugar: una fábrica (factory). Recibe el tipo y devuelve un objeto nuevo. Quien llama no llama a la clase y no conoce la clase: recibe un objeto que tiene price y label.

Los roles:

  • el producto: la forma común, price y label(recipient), que tiene cada clase;
  • los productos concretos: AddressDelivery, OfficeDelivery, LockerDelivery;
  • la fábrica: create_delivery(kind), el único lugar que sabe qué clase corresponde a qué tipo;
  • los clientes: la caja, la vista previa, la etiqueta. Solo preguntan.

Esto se llama Simple Factory. No es del libro de la Banda de los Cuatro (Gang of Four), pero es la forma más común. Factory Method, el del libro, es algo un poco distinto y viene más adelante en la lección.

Una regla: un tipo desconocido es un error, no un valor por defecto silencioso. Una fábrica que no conoce el tipo lanza una excepción con un mensaje claro. Si no, el error no desaparece, se va con el mensajero a la dirección equivocada.

En Python la fábrica es una tabla tipo → clase. Una clase crea objetos por sí sola: AddressDelivery() es un objeto nuevo, así que la tabla guarda las propias clases, y DELIVERIES[kind]() llama a la correcta.

La tabla «nombre → estrategia» de la lección de Strategy es lo mismo: una fábrica que te da un objeto a partir de un texto.

Míralo

Una fábrica para los tres sitios

DELIVERIES = {
"address": AddressDelivery,
"office": OfficeDelivery,
"locker": LockerDelivery,
}
def create_delivery(kind):
if kind not in DELIVERIES:
raise ValueError(f"Tipo de envío desconocido: {kind}")
return DELIVERIES[kind]()

La caja, la vista previa y la etiqueta ya no eligen, preguntan:

def checkout(kind, items):
return items + create_delivery(kind).price
def preview_price(kind):
return create_delivery(kind).price
def print_label(kind, recipient):
return create_delivery(kind).label(recipient)
print(f"Pedido: {checkout("locker", 40):.2f} €")
print(f"Vista previa: envío {preview_price("locker"):.2f} €")
print("Etiqueta: " + print_label("locker", "María"))
Salida
Pedido: 42.90 €
Vista previa: envío 2.90 €
Etiqueta: María, en un casillero

La etiqueta es para un casillero. La elección está en un solo lugar, así que no puede estar bien en la caja y mal en la etiqueta.

Más precisamente, para curiosos

¿Por qué no DELIVERIES.get(kind)()? Si falta el tipo, get devuelve None, y None() da TypeError: 'NoneType' object is not callable. El error no dice qué tipo es desconocido. La comprobación if kind not in DELIVERIES con un mensaje claro cuesta dos líneas.

Míralo

Un tipo nuevo: una clase y una línea

La tienda añade la recogida en tienda: tipo "pickup", gratis. Lo nuevo es una clase:

class PickupDelivery:
price = 0.00
def label(self, recipient):
return f"{recipient}, en la tienda"

y una línea en la fábrica, resaltada aquí:

DELIVERIES = {
"address": AddressDelivery,
"office": OfficeDelivery,
"locker": LockerDelivery,
"pickup": PickupDelivery,
}
def create_delivery(kind):
if kind not in DELIVERIES:
raise ValueError(f"Tipo de envío desconocido: {kind}")
return DELIVERIES[kind]()
home = create_delivery("address")
print(f"{home.label('María')}: {home.price:.2f} €")
locker = create_delivery("locker")
print(f"{locker.label('María')}: {locker.price:.2f} €")
pickup = create_delivery("pickup")
print(f"{pickup.label('María')}: {pickup.price:.2f} €")
Salida
María, a domicilio: 5.90 €
María, en un casillero: 2.90 €
María, en la tienda: 0.00 €

home = create_delivery("address")
print(f"{home.label('María')}: {home.price:.2f} €")
locker = create_delivery("locker")
print(f"{locker.label('María')}: {locker.price:.2f} €")
pickup = create_delivery("pickup")
print(f"{pickup.label('María')}: {pickup.price:.2f} €")
Paso a paso, en palabras
  1. create_delivery("address") recibe solo el texto del pedido. La fábrica busca el molde que le corresponde.
  2. Del molde sale un AddressDelivery nuevo. Quien llama no pregunta qué clase es: usa solo price y label().
  3. La misma fábrica, otro texto: "locker" pasa de largo los otros moldes y se detiene en LockerDelivery.
  4. La etiqueta ya dice casillero: la clase se elige en un solo lugar, y la caja, la vista previa y la etiqueta preguntan a ese lugar.
  5. Un tipo nuevo: la clase PickupDelivery y una línea más en la fábrica. Las líneas que llaman a create_delivery no cambian.
  6. "pickup" pasa como cualquier otro tipo. El tipo nuevo se añadió en un solo lugar.

La caja, la vista previa y la etiqueta no se tocan. Se enteran del tipo nuevo cuando un pedido lo pide.

Míralo

Tipo desconocido

El tipo viene de fuera: de un formulario, de la web de un socio, de un archivo viejo. Tarde o temprano aparece un tipo que no existe:

try:
delivery = create_delivery("drone")
print(delivery.label("María"))
except ValueError as e:
print(e)
Salida
Tipo de envío desconocido: drone

El error dice qué tipo es desconocido. Se captura donde el pedido entra en la tienda, y el cliente ve un mensaje en lugar de un paquete en el lugar equivocado.

¿Y sin la comprobación? DELIVERIES[kind] da un KeyError con el tipo, pero el mensaje es para el programador, no para el pedido:
def create_delivery(kind):
return DELIVERIES[kind]()
Error
KeyError: 'drone'

Míralo

Factory Method: la subclase decide

Simple Factory elige por texto. A veces la elección no es por texto, sino por quién pregunta.

El informe de ventas se envía como CSV (para contabilidad) y como tabla HTML (en un correo). Los dos hacen lo mismo: cada producto es una fila, y al final hay una fila «Total». Solo es distinto el archivo en el que se escriben las filas:

class CsvFile:
def __init__(self):
self.lines = ["producto;total"]
def add(self, product, total):
self.lines.append(f"{product};{total:.2f}")
def text(self):
return "\n".join(self.lines)
class HtmlFile:
def __init__(self):
self.rows = []
def add(self, product, total):
self.rows.append(f"<tr><td>{product}</td><td>{total:.2f}</td></tr>")
def text(self):
return "<table>\n" + "\n".join(self.rows) + "\n</table>"
El algoritmo común está en la clase base. Qué archivo crear lo dice el atributo de clase file_class: una clase es un valor, y una subclase pone otra distinta. Frameworks como Django lo hacen así también.
class ReportExporter:
file_class = CsvFile
def export(self, sales):
file = self.file_class()
overall = 0
for product, total in sales:
file.add(product, total)
overall += total
file.add("Total", overall)
return file.text()
class HtmlExporter(ReportExporter):
file_class = HtmlFile
sales = [("Café", 12.50), ("Té", 8.00)]
print(ReportExporter().export(sales))
print(HtmlExporter().export(sales))
Salida
producto;total
Café;12.50
Té;8.00
Total;20.50
<table>
<tr><td>Café</td><td>12.50</td></tr>
<tr><td>Té</td><td>8.00</td></tr>
<tr><td>Total</td><td>20.50</td></tr>
</table>

Esto es Factory Method: la clase base llama a un método que crea el objeto (self.file_class()), y la subclase decide qué clase exactamente.

¿Por qué no pasar un archivo ya hecho? El archivo acumula filas. Cada export tiene que empezar con un archivo vacío, así que el informe no guarda un archivo, sabe cómo crear uno nuevo.

¿Cuándo basta Simple Factory? Factory Method compensa cuando alrededor de la creación hay un algoritmo común que las subclases no deben repetir (aquí: las filas y el total). Si solo quieres un objeto por tipo, sin algoritmo alrededor, Simple Factory es más claro. Y una subclase que existe solo para devolver un objeto nuevo es ceremonia. Entonces pasa la propia clase: export(sales, HtmlFile).

Pruébalo

¿Qué saldrá?

Los tipos vienen de un formulario de la web. ¿Qué imprime el programa?

for kind in ["office", "Locker", "locker"]:
try:
print(create_delivery(kind).label("María"))
except ValueError as e:
print(e)
Respuesta

Pruébalo

Encuentra el error

Otro programador escribió la fábrica así:

DELIVERIES = {
"office": OfficeDelivery,
"locker": LockerDelivery,
}
def create_delivery(kind):
return DELIVERIES.get(kind, AddressDelivery)()

El pedido viene de la web de un socio, que envía el tipo con mayúscula inicial:

delivery = create_delivery("Locker")
print("Etiqueta: " + delivery.label("María"))
print(f"Envío: {delivery.price:.2f} €")
Salida
Etiqueta: María, a domicilio
Envío: 5.90 €

María eligió un casillero. Pagó 5,90 € de envío en lugar de 2,90 €, y el paquete fue a su dirección. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Cuándo no la necesitas

Una fábrica es un salto más al leer: para ver qué clase se crea, vas a otro sitio. No compensa cuando:

  • ese lugar siempre necesita la misma clase. OfficeDelivery() donde el envío siempre va a una oficina es más claro que una fábrica;
  • el tipo se conoce mientras escribes el código y no llega como dato (de un pedido, un archivo, un formulario);
  • escribes una clase DeliveryFactory con solo un método estático. Bastan una tabla y una función;
  • un Factory Method tiene una subclase «para el futuro».

Compensa cuando el tipo llega como dato y la elección de la clase, si no, se copiaría en varios sitios. O cuando hay un algoritmo común alrededor de la creación y solo cambia el paso «qué objeto».

Ejercicio

Envío exprés

Resuelve esto en tu editor.

Envío exprés. La tienda añade el envío exprés a domicilio: tipo "express", 9,90 €, etiqueta «…, exprés a domicilio». Añade una clase ExpressDelivery y hazla disponible a través de la fábrica. La caja, la vista previa y la etiqueta no se tocan. Crea un envío exprés para María e imprímelo como en la tarjeta «Un tipo nuevo»:

Salida
María, exprés a domicilio: 9.90 €

Más tareas

  • Mayúsculas y espacios. El formulario envía "Locker" y " locker ". Haz que la fábrica los acepte cambiando solo la fábrica. Los tipos desconocidos tienen que seguir dando error.
  • Un informe JSON. Añade un formato JSON al informe de la tarjeta «Factory Method»: [{"product": "Café", "total": 12.50}, …]. ¿Qué clases escribes y cuáles no tocas?
  • Precio por peso. Los casilleros admiten paquetes de hasta 20 kg, y por encima de 5 kg el envío a domicilio cuesta más. ¿Dónde va el peso: en la fábrica o en el método del precio? Piensa por qué.

Test

Ponte a prueba

1. La tienda añade el envío por dron, tipo "drone". ¿Qué escribes con Simple Factory?
2. La fábrica recibe un tipo que no conoce. ¿Qué debe hacer?
3. ¿Cuándo es mejor Factory Method que Simple Factory?
4. Para ahorrar memoria, la tabla guarda un objeto ya hecho por cada tipo, y la fábrica lo devuelve. El objeto guarda el destinatario en un campo. ¿Qué pasa con dos pedidos seguidos a casillero?
5. ¿Cuándo no necesitas una fábrica?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·