Saltar al contenido
🐍 Curso de Python
/

Módulo 3 · Lección 33 de 50

Las variables de control

Qué es de verdad una StringVar, el fallo que la hace desaparecer a media ejecución, el IntVar que revienta con el campo vacío, y las cuatro cosas de trace_add que nadie cuenta.

⏱ 26 min de lectura

Empieza el bloque D, y empieza pagando una deuda. Casi todas las lecciones del bloque C han usado StringVar, IntVar o BooleanVar con la misma nota al pie: «esto se explica en su lección del bloque D». Esta es esa lección, y llega con ventaja, porque ya has visto las variables de control resolviendo problemas de verdad en una docena de widgets distintos. Ahora toca entender por qué funcionan y qué cuatro cosas hacen que no son obvias.

Y hay un motivo para tratarlas con respeto: son el sitio donde vive un fallo que hace desaparecer datos a media ejecución sin que nadie diga nada.

Qué es una variable de control#

Las cuatro clases son envoltorios finos sobre la misma cosa:

consola
>>> tk.StringVar().get()      →  ''      (str)
>>> tk.IntVar().get()         →  0       (int)
>>> tk.DoubleVar().get()      →  0.0     (float)
>>> tk.BooleanVar().get()     →  False   (bool)
>>> tk.Variable().get()       →  ''

Las cuatro heredan de tk.Variable, y lo único que cambia es la conversión que aplica get() al leer. Pero lo importante no es eso, sino dónde está guardado el valor:

consola
>>> nombre = tk.StringVar()
>>> str(nombre)
'PY_VAR5'

Eso no es una representación bonita: es el nombre de una variable dentro del intérprete de Tcl. El objeto de Python no guarda el valor; guarda el nombre. Y se comprueba mirando por el otro lado:

consola
>>> apodo = tk.StringVar(name="mi_variable")
>>> apodo.set("hola")
>>> ventana.tk.globalgetvar("mi_variable")
'hola'
>>> ventana.tk.globalsetvar("mi_variable", "cambiado por Tcl")
>>> apodo.get()
'cambiado por Tcl'

Son la misma variable vista desde dos lenguajes. Y de ahí sale todo lo demás de la lección: por qué un textvariable funciona en los dos sentidos (los dos lados leen el mismo sitio), por qué dos widgets con la misma variable se mantienen sincronizados solos, y por qué existe el fallo de la sección siguiente.

El fallo que hace desaparecer la variable#

Los dos sentidos, gratis#

Un textvariable no es «volcar el valor al widget». Los dos lados leen y escriben el mismo sitio:

consola
>>> s = tk.StringVar(value="desde la variable")
>>> campo = ttk.Entry(ventana, textvariable=s)
>>> campo.get()
'desde la variable'
>>> # escribimos en el widget…
>>> s.get()
'escrito en el widget'
>>> s.set("cambiado en la variable")
>>> campo.get()
'cambiado en la variable'

Y de ahí un truco que sale gratis: la misma variable en dos widgets los mantiene sincronizados sin una línea de código.

consola
>>> comun = tk.StringVar(value="uno")
>>> primero = ttk.Entry(ventana, textvariable=comun)
>>> segundo = ttk.Entry(ventana, textvariable=comun)
>>> # se escribe en el primero…
>>> segundo.get()
'escrito en el primero'

Sirve para una vista previa, para un campo repetido en dos pestañas, o para el rótulo que acompaña a un Scale. Y se puede desatar:

consola
>>> campo.configure(textvariable="")
>>> campo.get()
'escrito en el primero'      # se queda con lo que hubiera
>>> comun.set("ya no le afecta")
>>> campo.get()
'escrito en el primero'

Elegir la clase, y dónde revienta cada una#

Aquí está el segundo problema serio, y explica una regla que conviene aplicar siempre.

consola
>>> tk.StringVar(value="007").get()
'007'

Una StringVar es fiel: devuelve exactamente lo que hay. Las otras convierten, y convertir puede fallar o puede perder datos:

consola
>>> n = tk.IntVar()
>>> n.set(3.7)
>>> n.get()
3

Trunca, no redondea, y sin avisar. Un 3,7 se convierte en 3 y nadie se enteró.

Pero el problema de verdad es este:

Y la BooleanVar acepta los booleanos de Tcl, que son más de los que uno espera:

consola
>>> para valor in (True, 1, "1", "true", "yes")   →  get() == True
>>> para valor in (False, 0, "0", "no")           →  get() == False
>>> tk.BooleanVar().set("")
_tkinter.TclError: expected boolean value but got ""

O sea que "yes" y "no" valen, pero la cadena vacía no. Es otra razón para no atar una BooleanVar a nada donde pueda entrar texto.

trace_add, con sus cuatro sorpresas#

Ya lo has usado en media docena de lecciones. Aquí va lo que hace que no es evidente.

Primero lo normal. Los modos son cuatro, y el error los enumera:

consola
>>> variable.trace_add("escribir", funcion)
_tkinter.TclError: bad operation "escribir": must be array, read, unset, or write

write es el que se usa el noventa y nueve por ciento de las veces. read salta al leer, y es una curiosidad. unset salta al destruirse. Y se pueden pasar varios a la vez con una tupla.

La función recibe tres cadenas:

consola
>>> variable.trace_add("write", lambda *a: print(a))
>>> variable.set("nuevo")
('PY_VAR0', '', 'write')

El nombre interno, el índice (para arrays de Tcl, siempre vacío aquí) y el modo. De ahí el lambda *_: que aparece en todos los ejemplos del curso: los tres argumentos no sirven casi nunca. Y ojo con el primero: es el nombre de Tcl, no el de tu variable de Python, así que no sirve para saber cuál de varias ha cambiado. Si una misma función atiende a varias variables, hay que decírselo con un partial.

Y se pueden quitar, para lo que trace_add devuelve un identificador:

consola
>>> identificador = variable.trace_add("write", funcion)
>>> identificador
'4387844736<lambda>'
>>> len(variable.trace_info())
1
>>> variable.trace_remove("write", identificador)
>>> len(variable.trace_info())
0

Ahora las cuatro sorpresas.

La regla, ahora con su justificación#

La lección 20 enunció el reparto y aquí se puede cerrar con lo que sabemos: en Tk los eventos son de la interacción y las variables son de los datos.

command y eventos <<...>>trace_add de la variable
Lo dispara una personaSíSí
Lo dispara tu código con set()NoSí
Lo dispara otro widget que comparte la variableNoSí
Sirve para saber qué pasóSíNo, solo que cambió

Un command es una respuesta a un gesto: sirve para «se ha pulsado este botón». Una traza es una respuesta a un dato: sirve para «este valor ahora es otro, venga de donde venga». El caso que lo demostró fue el de la lección 17, con una casilla maestra que cambiaba cinco variables de golpe: ningún command de las cinco casillas se enteró, y las cinco trazas sí.

El ejemplo#

Un formulario con los cuatro tipos, el espejo gratis, el conversor de dos trazas y la lectura segura de números.

variables.py
"""Las cuatro variables de control, con sus trampas resueltas."""

import tkinter as tk
from tkinter import ttk

MARGEN = 12
AIRE = 4

GRIS = "#8E8E93"
ROJO = "#C2185B"

MAX_NOMBRE = 20


def centrar(ventana: tk.Misc) -> None:
        """Coloca la ventana en el centro de la pantalla."""
    ventana.update_idletasks()
    ancho = ventana.winfo_width()
    alto = ventana.winfo_height()
    x = (ventana.winfo_screenwidth() - ancho) // 2
    y = (ventana.winfo_screenheight() - alto) // 2
    ventana.geometry(f"+{x}+{y}")


def entero(variable, por_omision=0):
        """Lee un número de una StringVar sin reventar.

    Por esto los campos de texto llevan StringVar y no IntVar: un IntVar
    atado a un Entry lanza TclError en cuanto el campo se queda vacío.
    """
    try:
                return int(variable.get())
    except ValueError:
                return por_omision


def crear_texto(contenedor):
        """StringVar: lo que escribe una persona, siempre StringVar."""
    marco = ttk.Labelframe(contenedor, text="StringVar", padding=MARGEN)
    marco.pack(fill="x")

    fila = ttk.Frame(marco)
    fila.pack(fill="x")
    ttk.Label(fila, text="Nombre:", width=12, anchor="w").pack(side="left")
    ttk.Entry(fila, textvariable=nombre).pack(side="left", fill="x",
                                              expand=True)

    # La misma variable en un segundo widget: espejo sin una línea de código.
    fila = ttk.Frame(marco)
    fila.pack(fill="x", pady=(AIRE, 0))
    ttk.Label(fila, text="Espejo:", width=12, anchor="w").pack(side="left")
    ttk.Entry(fila, textvariable=nombre).pack(side="left", fill="x",
                                              expand=True)

    ttk.Label(marco, textvariable=aviso_nombre, foreground=ROJO).pack(
        anchor="w", pady=(AIRE, 0))
    return marco


def crear_numeros(contenedor):
        """Un campo de texto con StringVar, y un Scale con DoubleVar."""
    marco = ttk.Labelframe(contenedor, text="Números", padding=MARGEN)
    marco.pack(fill="x", pady=(MARGEN, 0))

    fila = ttk.Frame(marco)
    fila.pack(fill="x")
    ttk.Label(fila, text="Unidades:", width=12, anchor="w").pack(side="left")
    # StringVar, aunque sea un número: lo escribe una persona.
    ttk.Entry(fila, textvariable=unidades, width=10).pack(side="left")
    ttk.Label(fila, textvariable=total, foreground=GRIS).pack(
        side="left", padx=(MARGEN, 0))

    fila = ttk.Frame(marco)
    fila.pack(fill="x", pady=(AIRE, 0))
    ttk.Label(fila, text="Descuento:", width=12, anchor="w").pack(side="left")
    # DoubleVar porque ttk.Scale siempre da decimales, lección 22.
    ttk.Scale(fila, from_=0, to=50, orient="horizontal",
              variable=descuento).pack(side="left", fill="x", expand=True)
    ttk.Label(fila, textvariable=descuento_texto, width=5,
              anchor="e").pack(side="left")
    return marco


def crear_conversor(contenedor):
        """Dos variables que se escriben la una a la otra.

    No hay bucle infinito porque Tcl desactiva cada traza mientras corre.
    """
    marco = ttk.Labelframe(contenedor, text="Dos trazas cruzadas",
                           padding=MARGEN)
    marco.pack(fill="x", pady=(MARGEN, 0))

    fila = ttk.Frame(marco)
    fila.pack(fill="x")
    for etiqueta, variable in (("Celsius", grados), ("Fahrenheit", fahrenheit)):
                ttk.Label(fila, text=f"{etiqueta}:").pack(side="left")
        ttk.Spinbox(fila, from_=-100, to=500, width=8, increment=5,
                    textvariable=variable).pack(side="left", padx=(AIRE,
                                                                  MARGEN))
    return marco


def crear_opciones(contenedor):
        """BooleanVar e IntVar: aquí el valor lo pone un widget, no un teclado."""
    marco = ttk.Labelframe(contenedor, text="BooleanVar e IntVar",
                           padding=MARGEN)
    marco.pack(fill="x", pady=(MARGEN, 0))

    ttk.Checkbutton(marco, text="Enviar una copia por correo",
                    variable=copia).pack(anchor="w")
    fila = ttk.Frame(marco)
    fila.pack(fill="x", pady=(AIRE, 0))
    for texto, valor in (("Normal", 1), ("Urgente", 2), ("Inmediato", 3)):
                ttk.Radiobutton(fila, text=texto, value=valor,
                        variable=prioridad).pack(side="left",
                                                 padx=(0, MARGEN))
    return marco


def limitar_nombre(*_):
        """Se corrige la variable desde su propia traza.

    Tcl desactiva la traza mientras se ejecuta, así que este set() no
    vuelve a llamar a esta función: no hace falta ninguna bandera.
    """
    valor = nombre.get()
    recortado = valor.strip()[:MAX_NOMBRE]
    if recortado != valor:
                nombre.set(recortado)
        aviso_nombre.set(f"Recortado a {MAX_NOMBRE} caracteres y sin espacios "
                         f"a los lados.")
    else:
                aviso_nombre.set("")


def calcular_total(*_):
        """entero() en vez de un IntVar: el campo puede estar vacío."""
    cuantas = entero(unidades)
    precio = cuantas * 9.90 * (1 - descuento.get() / 100)
    total.set(f"= {precio:,.2f} €")


def mostrar_descuento(*_):
        descuento_texto.set(f"{descuento.get():.0f}%")
    calcular_total()


def de_celsius(*_):
        """Sin esto no habría forma de escribir en los dos campos."""
    try:
                fahrenheit.set(round(grados.get() * 9 / 5 + 32, 1))
    except tk.TclError:      # el Spinbox se ha quedado vacío
                pass


def de_fahrenheit(*_):
        try:
                grados.set(round((fahrenheit.get() - 32) * 5 / 9, 1))
    except tk.TclError:
                pass


def describir(*_):
        urgencias = {1: "normal", 2: "urgente", 3: "inmediato"}
    resumen.set(f"{nombre.get() or '(sin nombre)'} — "
                f"{entero(unidades)} unidades, {descuento.get():.0f}% dto., "
                f"envío {urgencias[prioridad.get()]}, "
                f"copia {'sí' if copia.get() else 'no'}")


ventana = tk.Tk()
ventana.title("Variables de control")
ventana.minsize(560, 560)

# Todas las variables aquí, vivas mientras viva la ventana. Una variable
# creada dentro de una función y no guardada en ningún sitio la recoge el
# recolector, y el widget se queda apuntando a nada.
nombre = tk.StringVar(value="Erwin")
aviso_nombre = tk.StringVar()
unidades = tk.StringVar(value="3")       # StringVar: lo escribe una persona
total = tk.StringVar()
descuento = tk.DoubleVar(value=10)       # DoubleVar: lo pone un Scale
descuento_texto = tk.StringVar()
grados = tk.DoubleVar(value=20)
fahrenheit = tk.DoubleVar(value=68)
copia = tk.BooleanVar(value=True)
prioridad = tk.IntVar(value=1)
resumen = tk.StringVar()

cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)

crear_texto(cuerpo)
crear_numeros(cuerpo)
crear_conversor(cuerpo)
crear_opciones(cuerpo)

ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
ttk.Label(cuerpo, textvariable=resumen, foreground=GRIS, wraplength=500,
          justify="left").pack(anchor="w")

# Las trazas al final, cuando todos los widgets existen: una traza que se
# dispara con la interfaz a medio construir es un NameError de arranque.
nombre.trace_add("write", limitar_nombre)
unidades.trace_add("write", calcular_total)
descuento.trace_add("write", mostrar_descuento)
grados.trace_add("write", de_celsius)
fahrenheit.trace_add("write", de_fahrenheit)

for variable in (nombre, unidades, descuento, copia, prioridad):
        variable.trace_add("write", describir)

mostrar_descuento()
describir()

centrar(ventana)
ventana.mainloop()