Python permite reasignar nombres, sobrescribir métodos y heredar de casi cualquier clase. Esa flexibilidad es útil, pero algunas partes de una API deben permanecer estables: las constantes no deberían recibir otro valor, ciertos atributos de instancia deberían asignarse una sola vez, algunos métodos no deberían sobrescribirse y determinadas clases no fueron diseñadas para herencia. El módulo typing ofrece Final y el decorador @final para expresar estas intenciones a los analizadores estáticos.
Esta guía cubre constantes de módulo, atributos de clase e instancia, la diferencia entre Final e inmutabilidad real, dataclasses, configuración, métodos y clases finales, controles de runtime y errores de diseño frecuentes.
Constante de módulo
from typing import Final
MAX_INTENTOS: Final[int] = 3
URL_API: Final = "https://api.ejemplo.com"La primera forma declara tipo e intención final. La segunda permite inferir str. Una reasignación posterior debería marcarse:
MAX_INTENTOS = 5 # error de tipadoPython aún puede ejecutar esa asignación en runtime. Final es un contrato estático, no una restricción automática del intérprete.
Final no congela el objeto
RUTAS: Final[list[str]] = ["/inicio"]
RUTAS.append("/ayuda") # permitido
RUTAS = [] # debería rechazarseFinal impide cambiar la referencia del nombre, pero no vuelve inmutable la lista. Para una estructura inmutable, elige una representación apropiada:
RUTAS_FIJAS: Final[tuple[str, ...]] = ("/inicio", "/ayuda")Final protege el nombre estáticamente y la tupla aporta inmutabilidad estructural en runtime.
Convención de nombres y Final
Escribir constantes en mayúsculas es solo una convención. Final añade una regla verificable.
TIMEOUT_PREDETERMINADO: Final[float] = 5.0Usa ambas prácticas: las mayúsculas ayudan a lectores y Final ayuda a herramientas.
Atributos de clase
class Protocolo:
VERSION: Final[str] = "1.0"Una subclase no debería redefinir ese atributo:
class ProtocoloNuevo(Protocolo):
VERSION = "2.0" # error estático esperadoSi las versiones distintas forman parte del diseño, no marques el atributo como Final. La anotación debe reflejar una invariante real.
Atributos de instancia asignados una vez
class Sesion:
id: Final[str]
def __init__(self, id_: str) -> None:
self.id = id_Una asignación posterior debería rechazarse:
sesion = Sesion("abc")
sesion.id = "xyz" # error de tipadoEsto no bloquea la asignación en runtime. Usa propiedades de solo lectura, dataclasses congeladas o lógica de __setattr__ cuando necesites enforcement real.
Dónde inicializar un atributo Final
Debe inicializarse de forma clara y una sola vez, normalmente en el cuerpo de la clase o en __init__. Las rutas condicionales complejas dificultan el análisis.
class Solicitud:
token: Final[str]
def __init__(self, token: str | None) -> None:
valor = generar_token() if token is None else token
self.token = valorCalcular primero y asignar después suele ser el patrón más claro.
Final en dataclasses
from dataclasses import dataclass
@dataclass
class Evento:
id: Final[str]
payload: dict[str, object]El soporte puede variar porque la dataclass genera __init__. Para inmutabilidad de runtime, usa @dataclass(frozen=True):
@dataclass(frozen=True)
class EventoInmutable:
id: str
payload: dict[str, object]Una dataclass congelada tampoco congela objetos internos. El diccionario sigue siendo mutable salvo que uses una estructura inmutable.
Final y ClassVar
ClassVar indica que un atributo pertenece a la clase. Final indica que no debería redefinirse. La combinación puede tener limitaciones según la versión y el analizador. Una anotación Final en el cuerpo de la clase suele ser suficiente.
Usa ClassVar sin Final cuando las subclases deban configurar el valor. Usa Final cuando sea fijo en toda la jerarquía y verifica el comportamiento con el checker del proyecto.
Final y Literal
Final protege un nombre; Literal describe valores exactos.
from typing import Literal
MODO_PREDETERMINADO: Final[Literal["seguro"]] = "seguro"La combinación rara vez es necesaria para una constante simple. Literal es especialmente útil en parámetros y retornos, y Final en definiciones que no deben cambiar.
Final y NewType
from typing import NewType
UsuarioId = NewType("UsuarioId", int)
USUARIO_SISTEMA: Final[UsuarioId] = UsuarioId(1)NewType conserva el significado del valor y Final evita la reasignación estática del nombre.
El decorador @final en métodos
from typing import final
class Autenticador:
@final
def verificar_firma(self, token: str) -> bool:
return verificar(token)Una subclase no debería sobrescribir el método:
class AutenticadorCustom(Autenticador):
def verificar_firma(self, token: str) -> bool:
return True # error esperadoEl decorador indica que el algoritmo es parte fija del contrato. Úsalo con moderación porque limita la extensibilidad.
@final en clases
@final
class TokenInterno:
def __init__(self, valor: str) -> None:
self.valor = valorEl analizador debería rechazar herencia:
class TokenEspecial(TokenInterno): # error
passEs apropiado cuando una subclase podría romper invariantes, la implementación depende de optimizaciones internas o la composición es el modelo recomendado.
@final no bloquea herencia en runtime
El decorador de typing normalmente no impide al intérprete crear una subclase. Versiones modernas pueden establecer una marca como __final__, pero el enforcement depende de herramientas o lógica propia.
class ClaseCerrada:
def __init_subclass__(cls) -> None:
raise TypeError("herencia no permitida")Bloquea herencia en runtime solo cuando sea realmente necesario, porque cambia el comportamiento normal de Python.
Método template con pasos finales
class Importador:
def ejecutar(self, ruta: str) -> None:
datos = self.leer(ruta)
datos = self.transformar(datos)
self.guardar(datos)
def leer(self, ruta: str) -> bytes:
raise NotImplementedError
def transformar(self, datos: bytes) -> bytes:
return datos
@final
def guardar(self, datos: bytes) -> None:
escribir_con_auditoria(datos)Lectura y transformación son puntos de extensión, mientras guardar permanece fijo porque la auditoría es obligatoria.
Final en Protocol
Protocol describe comportamiento estructural, mientras Final y @final restringen sobre todo implementaciones nominales y herencia. Una clase compatible no tiene que heredar del Protocol, por lo que los marcadores finales allí suelen aportar poco.
Final y propiedades
Una propiedad sin setter ofrece protección práctica:
class Cuenta:
def __init__(self, numero: str) -> None:
self._numero = numero
@property
def numero(self) -> str:
return self._numeroEl almacenamiento interno también puede anotarse Final para reforzar la intención estática.
Configuración cargada en runtime
Un valor puede ser final después de inicializarse aunque provenga del entorno o de un archivo.
class Configuracion:
entorno: Final[str]
timeout: Final[float]
def __init__(self) -> None:
self.entorno = leer_entorno()
self.timeout = leer_timeout()Final no exige que el valor se conozca en tiempo de compilación; exige que no se reasigne después.
Importaciones y monkey patching
Importar un nombre Final no impide cambios dinámicos. Python también permite modificar atributos de módulos y clases. Final señala que esas acciones violan el contrato, pero no las desactiva. En pruebas, prefiere puntos explícitos de inyección.
Cuándo no usar Final
- Las subclases deben configurar el valor.
- La reasignación forma parte del ciclo de vida.
- La API está diseñada para extensión.
- Necesitas inmutabilidad del objeto, no estabilidad del nombre.
- El proyecto no ejecuta un analizador estático.
Errores comunes
- Confundir Final con const de runtime: Python aún permite asignar.
- Anotar una lista y esperar congelación: el contenido sigue mutable.
- Marcar todos los métodos final: la jerarquía se vuelve difícil de extender.
- Marcar valores configurables: el contrato contradice el diseño.
- Usar rutas de inicialización confusas: el analizador puede rechazarlas.
- Tratar Final como seguridad: permisos y datos necesitan validación real.
Ejemplo completo: cliente de API estable
from typing import Final, final
URL_PREDETERMINADA: Final[str] = "https://api.ejemplo.com"
class ClienteApi:
url: Final[str]
_cabecera_version: Final[str] = "X-Api-Version"
def __init__(self, url: str = URL_PREDETERMINADA) -> None:
self.url = url.rstrip("/")
def obtener(self, ruta: str) -> bytes:
return self._enviar("GET", ruta)
@final
def _enviar(self, metodo: str, ruta: str) -> bytes:
headers = {self._cabecera_version: "1"}
return transporte_http(
metodo,
f"{self.url}/{ruta.lstrip('/')}",
headers=headers,
)
class ClienteConCache(ClienteApi):
def obtener(self, ruta: str) -> bytes:
if datos := cache_obtener(ruta):
return datos
datos = super().obtener(ruta)
cache_guardar(ruta, datos)
return datosLa URL de instancia se asigna una vez, el nombre de cabecera es fijo y el envío no puede sobrescribirse. La operación de alto nivel sigue siendo extensible.
Probar el contrato
Ejecuta un checker en CI y conserva fixtures con reasignaciones y sobrescrituras inválidas. Las pruebas de runtime deben verificar inmutabilidad real solo cuando está implementada mediante tuplas, propiedades, dataclasses congeladas u otros mecanismos concretos.
Conclusión
typing.Final protege nombres y atributos contra reasignación en el contrato estático, mientras @final impide sobrescritura y herencia para analizadores. Ambas herramientas documentan invariantes y reducen extensiones accidentales.
La documentación oficial de Final y del decorador final en Python define la semántica. Combínalos con estructuras inmutables o controles de runtime cuando la regla deba aplicarse realmente.







