Argumentos posicional-only y keyword-only: / y * en firmas

Cuando defines una función en Python, los parámetros que describes son, por defecto, completamente flexibles: el caller puede pasarlos por posición o por nombre indistintamente. Esa flexibilidad tiene un coste que no siempre es obvio: el nombre del parámetro se convierte en parte de tu API pública. Si algún día lo renombras, rompes a cualquier caller que lo usaba como keyword argument. Los separadores / y * existen precisamente para que puedas controlar eso con precisión quirúrgica.

La firma def f(a, b, /, c, *, d) divide los parámetros en tres zonas:

  • Posicional-only (a, b): solo se pueden pasar por posición. Usar f(a=1) es un TypeError.
  • Normal (c): se puede pasar de ambas formas, como siempre.
  • Keyword-only (d): solo se puede pasar por nombre. Usar f(1, 2, 3, 4) para d es un TypeError.

El separador / marca el final de la zona posicional-only y está disponible desde Python 3.8. El separador * (sin nombre) marca el inicio de la zona keyword-only y existe desde Python 3.0. Ambos pueden coexistir en la misma firma, y el orden importa: / siempre antes que *.

¿Por qué el diseño funciona así? Porque el contrato entre caller y función tiene dos dimensiones independientes: el orden y el nombre. Python mezclaba ambas por defecto; / y * te permiten desacoplarlas explícitamente. Las funciones built-in como len(), abs() o sorted() usaban esta restricción internamente desde siempre (implementadas en C, donde el nombre del parámetro es irrelevante para el caller), pero no había sintaxis Python para expresar lo mismo hasta 3.8.

El momento en el que necesitas / es cuando tienes una función cuyo nombre de parámetro es un detalle de implementación, no un contrato: funciones de bajo nivel, wrappers sobre C extensions, o cualquier API donde quieras libertad de refactoring. El momento en el que necesitas * es cuando la legibilidad en el call-site importa y quieres forzar que el caller sea explícito, especialmente con booleanos o flags (verbose=True en lugar de un True misterioso en posición 4).

Si no usas estos separadores donde deberías, renombrar obj a source en un parámetro de una biblioteca pública es un breaking change silencioso que solo descubres cuando alguien abre un issue.

from typing import Sequence


def clip(
    values: Sequence[float],
    /,
    *,
    low: float,
    high: float,
) -> list[float]:
    """Recorta cada valor de `values` al rango [low, high].

    `values` es posicional-only: podemos renombrarlo internamente sin
    afectar a ningún caller. `low` y `high` son keyword-only para que
    el call-site sea autoexplicativo y no dependa del orden.
    """
    if low > high:
        raise ValueError(f"low ({low}) no puede ser mayor que high ({high})")
    return [max(low, min(high, v)) for v in values]


# ── Uso correcto ──────────────────────────────────────────────────────
data = [1.0, 5.5, -3.0, 8.2, 4.0]
result = clip(data, low=0.0, high=5.0)
print(result)  # [1.0, 5.0, 0.0, 5.0, 4.0]

# Pasar `values` por keyword → TypeError (posicional-only)
# clip(values=data, low=0.0, high=5.0)

# Pasar `low` por posición → TypeError (keyword-only)
# clip(data, 0.0, 5.0)

# ── Por qué el orden de keywords no importa ───────────────────────────
result2 = clip(data, high=5.0, low=0.0)  # idéntico a result
assert result == result2


# ── Zona "normal" (entre / y *) ───────────────────────────────────────
def scale(
    values: Sequence[float],
    /,
    factor: float,   # puede pasarse por posición O por nombre
    *,
    inplace: bool = False,
) -> list[float] | None:
    scaled = [v * factor for v in values]
    if inplace:
        # En un objeto mutable real, modificarías en sitio.
        # Aquí solo lo ilustramos con un print.
        print("(simulando modificación in-place)")
        return None
    return scaled


print(scale(data, 2.0))            # factor por posición: OK
print(scale(data, factor=2.0))     # factor por nombre: también OK
scale(data, 2.0, inplace=True)     # inplace debe ser keyword: OK
# scale(data, 2.0, True)           # TypeError: inplace es keyword-only

Cómo leer estas firmas en la práctica

La función clip no tiene ningún parámetro en la zona “normal” — solo posicional-only y keyword-only — y eso es intencional. values es una secuencia de entrada, un detalle interno; si mañana decides que el parámetro se llama seq o data_points porque el nombre encaja mejor con la nueva documentación, ningún caller se rompe. Ese es el contrato que / establece.

low y high son keyword-only por razones opuestas: su nombre sí importa en el call-site. Leer clip(data, 0.0, 5.0) no te dice nada; leer clip(data, low=0.0, high=5.0) es autodocumentado. * sin nombre es exactamente para esto: no introduce un *args que capture posicionales extra, solo actúa como separador.

La función scale ilustra la zona intermedia. factor es un parámetro lo suficientemente común como para que ambas formas de llamada sean razonables, pero inplace es un flag booleano donde la legibilidad forzada vale la pena.

Fíjate en cómo Python evalúa las zonas en orden: primero asigna posicionales de izquierda a derecha hasta agotar los parámetros posicional-only, luego sigue con los normales, y finalmente busca keyword arguments para los keyword-only. Si hay **kwargs, solo captura los nombres que no pertenezcan a la zona posicional-only.

Errores que debes conocer

Error: pasar un argumento posicional-only por nombre, algo que es fácil hacer si vienes de documentación generada automáticamente que muestra el nombre del parámetro sin indicar que es posicional-only.

# ❌ Wrong
clip(values=data, low=0.0, high=5.0)
# TypeError: clip() got some positional-only arguments passed as keyword arguments: 'values'

# ✅ Right
clip(data, low=0.0, high=5.0)

El nombre values existe solo para la implementación interna y la documentación; desde el exterior no forma parte de la firma.


Error: colocar / después de *, lo que Python rechaza con SyntaxError porque rompería la ordenación de zonas.

# ❌ Wrong
def f(a, *, b, /, c):  # SyntaxError: / must be ahead of *
    ...

# ✅ Right
def f(a, /, b, *, c):
    ...

La regla es simple: posicional-only primero (/), keyword-only al final (*), con la zona normal en medio.


Error: asumir que *args en la firma tiene el mismo efecto que * como separador — no es lo mismo.

# ❌ Wrong: *args captura posicionales extra; d puede pasarse por posición
def f(a, *args, d):
    print(a, args, d)

f(1, 2, 3, d=10)  # a=1, args=(2,3), d=10 — d sigue siendo keyword-only, OK
f(1, d=10)        # funciona, pero args está vacío — puede ser lo que quieres o no

# ✅ Right: si no necesitas capturar posicionales extra, usa * como separador
def f(a, *, d):
    print(a, d)

# f(1, 2, d=10)  # TypeError: f() takes 1 positional argument but 2 were given

*args tiene dos efectos: separa y captura. El * desnudo solo separa, y eso es lo que quieres cuando no esperas argumentos posicionales adicionales.

57

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio