En muchos proyectos de Python, una función pequeña termina acumulando bloques if para tratar enteros, cadenas, listas, diccionarios y objetos propios. Al principio, unas pocas comprobaciones con isinstance() parecen suficientes. Sin embargo, cuando aparecen nuevos tipos, la función mezcla demasiadas reglas y resulta más difícil de mantener y probar. singledispatch en Python ofrece una alternativa ordenada: una única función pública puede tener varias implementaciones, y Python selecciona la adecuada según el tipo del primer argumento.
En esta guía aprenderás a usar functools.singledispatch, definir una implementación predeterminada, registrar tipos concretos y abstractos, trabajar con clases propias y uniones, inspeccionar el registro, probar el despacho y reconocer cuándo conviene elegir otro patrón. El tema se relaciona directamente con las funciones en Python, los decoradores, la programación orientada a objetos y las dataclasses en Python.
¿Qué es singledispatch?
singledispatch es un decorador de la biblioteca estándar que convierte una función normal en una función genérica. Cuando se llama, la función genérica revisa el tipo real de su primer argumento y ejecuta la implementación registrada más específica. La expresión “single dispatch” indica que un solo argumento participa en la selección.
El diseño fue estandarizado mediante la PEP 443. La documentación oficial de functools.singledispatch explica el registro, la resolución por herencia, las uniones de tipos, las clases base abstractas y las herramientas de inspección.
Primer ejemplo con singledispatch
Supongamos que una aplicación necesita describir distintos valores. En lugar de concentrar todas las reglas en una cadena de condiciones, podemos separar cada implementación:
from functools import singledispatch
@singledispatch
def describir(valor):
return f"Valor de tipo {type(valor).__name__}: {valor!r}"
@describir.register
def _(valor: int):
return f"Número entero: {valor}"
@describir.register
def _(valor: str):
return f"Texto con {len(valor)} caracteres"
@describir.register
def _(valor: list):
return f"Lista con {len(valor)} elementos"
print(describir(10))
print(describir("Python"))
print(describir([1, 2, 3]))
print(describir({"activo": True}))La primera función decorada es la implementación predeterminada. Se ejecuta cuando no existe un registro más específico. Cada bloque con @describir.register añade una especialización y mantiene el mismo nombre público para quien llama la función.
¿Por qué las funciones registradas suelen llamarse _?
Muchos ejemplos usan el nombre _ porque normalmente no se llama directamente a la implementación especializada. El acceso habitual se realiza mediante la función genérica. No obstante, puedes utilizar un nombre descriptivo:
@describir.register
def describir_float(valor: float):
return f"Número decimal: {valor:.2f}"El decorador de registro devuelve la función especializada sin reemplazarla, por lo que un nombre claro puede facilitar pruebas unitarias y depuración.
Registrar un tipo de forma explícita
Las anotaciones son cómodas, pero también puedes indicar el tipo directamente:
@describir.register(tuple)
def _(valor):
return f"Tupla con {len(valor)} posiciones"Esta forma resulta útil en código antiguo, cuando no se desean anotaciones o al registrar una clase importada desde otra biblioteca.
Cómo decide Python qué implementación usar
Python busca primero una coincidencia exacta para el tipo del primer argumento. Si no la encuentra, recorre la jerarquía de herencia y elige la clase registrada más cercana. Gracias a este comportamiento, las clases base abstractas son muy útiles.
from collections.abc import Mapping
@describir.register
def _(valor: Mapping):
claves = list(valor)[:3]
return f"Mapeo con claves: {claves}"
print(describir({"nombre": "Ana", "edad": 30}))Un diccionario cumple la interfaz Mapping, por lo que esta implementación puede atenderlo. Registrar abstracciones como Mapping, Sequence o Set suele ser más flexible que limitar el código a una única clase concreta.
Usar clases propias
Las clases del dominio funcionan igual que los tipos incorporados:
from dataclasses import dataclass
@dataclass
class Producto:
nombre: str
precio: float
@describir.register
def _(valor: Producto):
return f"Producto {valor.nombre}: ${valor.precio:.2f}"
producto = Producto("Teclado", 79.90)
print(describir(producto))Este patrón puede servir para serializadores, presentadores, generadores de comandos, eventos, exportadores y adaptadores. Cuando aparece un nuevo tipo del dominio, se añade una implementación sin modificar una gran función central.
Registrar uniones de tipos
En versiones modernas de Python, una anotación puede contener una unión:
@describir.register
def _(valor: int | float):
return f"Número: {valor}"La misma implementación atenderá enteros y números de punto flotante. Conviene mantener las uniones pequeñas. Si los tipos necesitan reglas diferentes, dos registros separados serán más claros.
Solo el primer argumento controla el despacho
Esta es la limitación principal. En una función como convertir(valor, formato), solamente valor selecciona la implementación. El segundo parámetro puede modificar la lógica interna, pero no participa en el despacho.
@singledispatch
def convertir(valor, formato="texto"):
raise TypeError(
f"Tipo no compatible: {type(valor).__name__}"
)
@convertir.register
def _(valor: int, formato="texto"):
if formato == "hex":
return hex(valor)
return str(valor)Si el comportamiento depende de la combinación de dos tipos, considera clases, un registro de estrategias, condiciones explícitas o una biblioteca de despacho múltiple.
Diseñar una implementación predeterminada segura
La función base debe tener un propósito claro. Para una representación visual, un texto genérico puede ser suficiente. En serialización o validación, aceptar silenciosamente un tipo desconocido puede ser peligroso. En esos casos, es mejor producir un error preciso:
@singledispatch
def crear_payload(valor):
raise TypeError(
f"No existe conversor para {type(valor).__name__}"
)Un fallo temprano y descriptivo evita que datos inválidos lleguen a otra capa del sistema.
Inspeccionar el registro
La función genérica incluye herramientas útiles. El método dispatch devuelve la implementación que se usaría para un tipo:
implementacion = describir.dispatch(int)
print(implementacion(25))El atributo de solo lectura registry muestra las asociaciones entre tipos y funciones:
for tipo, funcion in describir.registry.items():
print(tipo, funcion)Estas herramientas ayudan a depurar, documentar y probar sistemas donde varias clases o extensiones registran comportamientos.
Cómo probar funciones con singledispatch
Las pruebas deberían cubrir la implementación predeterminada, las especializaciones principales y los casos de herencia:
def test_describir_entero():
assert describir(5) == "Número: 5"
def test_describir_producto():
producto = Producto("Ratón", 40.0)
assert "Ratón" in describir(producto)
def test_describir_fallback():
resultado = describir(object())
assert "object" in resultadoCuando la prioridad entre clases es importante, prueba directamente describir.dispatch(MiTipo). Así detectarás si un registro nuevo altera la resolución esperada.
singledispatchmethod en clases
functools.singledispatchmethod aplica la misma idea a métodos de instancia o de clase:
from functools import singledispatchmethod
class Exportador:
@singledispatchmethod
def exportar(self, valor):
raise TypeError("Tipo no compatible")
@exportar.register
def _(self, valor: str):
return {"texto": valor}
@exportar.register
def _(self, valor: int):
return {"numero": valor}
exportador = Exportador()
print(exportador.exportar("Academify"))El mecanismo examina el primer argumento que no sea self o cls. Es una opción práctica para servicios que convierten, validan, renderizan o exportan distintos tipos.
Extender una función desde otro módulo
Un módulo puede importar la función genérica y registrar un nuevo tipo:
from app.formatos import describir
from app.modelos import Factura
@describir.register
def _(valor: Factura):
return f"Factura {valor.numero}"Esta capacidad permite crear extensiones y plugins. Aun así, los registros ejecutados como efecto secundario de una importación pueden resultar difíciles de rastrear. Carga los módulos de extensión en un lugar explícito y documenta qué tipos añaden.
Cuándo singledispatch es una buena elección
- Una operación cambia claramente según el tipo del dato.
- Quieres mantener una sola función pública.
- Nuevos tipos deben añadirse sin editar una cadena central de condiciones.
- La herencia o las interfaces abstractas ya expresan compatibilidad.
- Cada implementación puede ser pequeña y probarse por separado.
Cuándo conviene evitarlo
No uses singledispatch únicamente para eliminar cualquier if. Una condición corta puede ser más fácil de leer. Evítalo cuando el comportamiento depende de valores y no de tipos, cuando varios argumentos determinan el resultado, cuando existen solamente dos casos simples y estables o cuando los métodos polimórficos de las propias clases expresan mejor la responsabilidad.
Tampoco es recomendable dispersar registros por módulos sin una política clara. Un registro global oculto puede hacer que el comportamiento cambie según el orden de importación.
Errores frecuentes
- Anotar el parámetro equivocado: el primer argumento de despacho debe contener el tipo correcto.
- Esperar despacho por el segundo argumento: solo uno participa en la selección.
- Registrar clases demasiado concretas: una clase base abstracta puede aceptar más entradas válidas.
- Usar un fallback inseguro: los tipos desconocidos deben generar un valor válido o un error claro.
- Confundir anotaciones con validación: el registro usa el tipo, pero no valida automáticamente los atributos internos.
Conclusión
singledispatch en Python organiza comportamientos específicos por tipo detrás de una sola función genérica. Puede sustituir cadenas extensas de isinstance, conservar una API estable y aprovechar la jerarquía de clases para encontrar la implementación más específica.
Utilízalo cuando el dominio realmente varía según el tipo. Mantén un fallback explícito, registros fáciles de localizar, abstracciones adecuadas y pruebas que comprueben tanto el resultado como la selección. Con estas prácticas, singledispatch aporta extensibilidad sin convertir el comportamiento de la función en una complejidad oculta.






