2 de julio de 2014

¿Dónde quedó el switch-case?

Hace tiempo escribí sobre la manera de darle la vuelta a la ausencia de los ciclos do-while en Python. Dicha entrada se convirtió rápidamente en una de las más visitadas del blog de EduPython. Debido a ello, en esta ocasión comentaré sobre la otra instrucción que mucha gente echa de menos en Python: la condicional switch-case de la familia de lenguajes basados en C (C++, C#, Java, JavaScript, etc.). Otros lenguajes como Pascal, Ada, Perl, Ruby y diversos dialectos de Lisp cuentan también con alguna instrucción parecida. Sin embargo Python carece de esta instrucción, que de forma más general se le puede llamar “ramificación condicional con múltiples caminos”.

El switch-case es una ramificación condicional
con múltiples caminos.

Una instrucción switch-case permite seleccionar, por medio de una expresión, el siguiente bloque de instrucciones a ejecutar de entre varios posibles. Veamos un ejemplo del switch-case en JavaScript:
// Código de JavaScript
function imprimeRangoCarta(n) {
    switch (n) {
        case 1:
            console.log('As');
            break;
        case 2: case 3: case 4: case 5:
        case 6: case 7: case 8: case 9:
        case 10:
            console.log(n);
            break;
        case 11:
            console.log('Jota');
            break;
        case 12:
            console.log('Reina');
            break;
        case 13:
            console.log('Rey');
            break;
        default:
            console.log('Inválido');
            break
    }
}
La intención de esta función es imprimir en la consola el rango de una carta de una baraja a partir de su valor numérico n, el cual se recibe como parámetro. Si n vale 1, 11, 12 o 13 la función imprime “As”, “Jota”, “Reina” o “Rey”, respectivamente; imprime el valor de n si dicha variable tiene un valor entre 2 y 10. Por último, imprime “Inválido” si n tiene algún valor diferente a los ya mencionados.

Las instrucciones break se requieren para concluir el switch. Si se llega a omitir un break, el programa continúa ejecutando las instrucciones asociadas al case (o default) que continúa inmediatamente; normalmente esto no es lo que se quiere.

La forma más directa de traducir nuestro código de JavaScript a Python es usando una secuencia de instrucciones if-elif-else:
# Código de Python 3
def imprime_rango_carta(n):
    if n == 1:
        print('As')
    elif 2 <= n <= 10:
        print(n)
    elif n == 11:
        print('Jota')
    elif n == 12:
        print('Reina')
    elif n == 13:
        print('Rey')
    else:
        print('Inválido')
La instrucción elif es una contracción de las instrucciones else e if, con la ventaja de que no es necesario añadir un nivel de indentación al momento de utilizarla. Si no existiera la instrucción elif, el código de arriba se tendría que escribir de una forma un tanto menos conveniente:
# Código de Python 3
def imprime_rango_carta(n):
    if n == 1:
        print('As')
    else:
        if 2 <= n <= 10:
            print(n)
        else:
            if n == 11:
                print('Jota')
            else:
                if n == 12:
                    print('Reina')
                else:
                    if n == 13:
                        print('Rey')
                    else:
                        print('Inválido')
Vale la pena también notar que para determinar si n está entre 2 y 10 usamos la expresión:
2 <= n <= 10
En otros lenguajes de programación tendríamos que escribir una expresión equivalente a la siguiente:
2 <= n and n <= 10
Para las situaciones más comunes, se considera que el switch-case tiene al menos dos ventajas sobre una serie equivalente de ifs anidados:
  1. Su sintaxis favorece la legibilidad y rapidez de escritura gracias a que el código generalmente es más breve y compacto.
  2. Corre más rápido debido a que usualmente, de manera interna, el ambiente de ejecución utiliza una tabla de acceso rápido para seleccionar el código a ejecutar, en lugar de ir haciendo una por una cada una de las comparaciones que requieren una serie de ifs anidados.
No hay nada que podamos hacer en Python respecto al punto 1, ya que no hay soporte sintáctico para la instrucción switch-case en el lenguaje. Sin embargo sí podemos aprovechar el punto 2, utilizando una tabla en Python para evitar múltiples comparaciones. La implementación de dicha tabla se puede hacer mediante una lista o un diccionario. En nuestro programa usaremos un diccionario. En la entrada titulada Código morse introduje el uso de diccionarios. Dado que los diccionarios en Python están implementados usando tablas hash, las búsquedas y accesos se ejecutan de manera muy veloz.

Ahora bien, para la discusión que sigue es importante distinguir entre “invocar una función” y “hacer referencia al objeto que representa una función”. Esto queda más claro con un ejemplo. Sea fun una función sin parámetros que devuelve siempre el mismo valor:
def fun():
    return 42
Podemos usar fun básicamente de dos maneras:
a = fun
b = fun()
Hay que notar la presencia o ausencia de los paréntesis () después del identificador fun. En el código anterior, a contiene una referencia al objeto que representa la función fun, mientras que b contiene el resultado de invocar a la función fun. Si imprimimos los valores de a y b con estas instrucciones:
print('a =', a)
print('b =', b)
veremos una salida como la siguiente:
a = <function fun at 0x4815162342>
b = 42
Esta salida extraña de a quiere decir que es un objeto función. De manera más precisa, a es una referencia al objeto función llamado fun. Dicho de otra forma, fun y a son alias, pues se refieren al mismo objeto. Dado que podemos decir que a es una función, entonces la podemos invocar usando los paréntesis correspondientes y hacer algo con el resultado:
c = a()
Como es de esperarse, c queda con 42.

Lo que hay que recordar es esto:
  • Si en una expresión viene el nombre de una función seguido de un par de paréntesis (), con o sin argumentos, significa que la función se está invocando. El resultado de esa parte de la expresión es lo que devuelva la función.
  • Si en una expresión viene el nombre de una función sin venir seguido de un par de paréntesis (), significa que se está obteniendo una referencia al objeto función siendo nombrado. El resultado de esa parte de la expresión es el objeto función correspondiente.
Integrando todo lo que hemos discutido, nos queda ahora el siguiente programa:
# Código de Python 3
def imprime_rango_carta(n):

    def caso_as():
        print('As')

    def caso_num():
        print(n)

    def caso_jota():
        print('Jota')

    def caso_reina():
        print('Reina')

    def caso_rey():
        print('Rey')

    def caso_invalido():
        print('Inválido')

    tabla = { 1: caso_as,   2: caso_num,   3: caso_num,
              4: caso_num,  5: caso_num,   6: caso_num,
              7: caso_num,  8: caso_num,   9: caso_num,
             10: caso_num, 11: caso_jota, 12: caso_reina,
             13: caso_rey }

    f = tabla.get(n, caso_invalido)
    f()
Hay que notar varias cosas en el código anterior:
  1. El código de cada “cláusula case” se incorpora dentro de una función local.
  2. La función local caso_num utiliza en su cuerpo el parámetro n de la función en la que está anidada. Esto es totalmente válido, y demuestra que Python soporta cerraduras léxicas (lexical closures). Esto último significa que al crear una función, todas las variables que son visibles en ese punto también lo son dentro del cuerpo de dicha función. 
  3. El objeto tabla se construye usando un diccionario. Las llaves son los valores de cada “cláusula case”, y éstas se asocian a sus correspondientes objetos función.
  4. El método get() de la penúltima línea permite buscar una llave (el primer argumento: n) en el diccionario receptor (tabla) y regresar el valor asociado a dicha llave en caso de encontrarlo; si la llave no existe, regresa un valor por omisión (el segundo argumento: el objeto función caso_invalido).
  5. A la variable f se le asigna el resultado del método get(), el cual en todos los casos es un objeto función.
  6. La última línea invoca la función obtenida en la instrucción anterior.
Esto código ya funciona como es debido, sin embargo si lo revisamos de manera más detenida podemos simplificarlo de manera significativa. Dado que el propósito de la función imprime_rango_carta es imprimir un valor en particular a partir de lo que contenga n, podemos hacer que el diccionario contenga solamente los valores que varían en cada uno de los distintos casos:
# Código de Python 3

__tabla = { 1: 'As', 2: 2, 3: 3, 4: 4, 5: 5, 6: 6,
            7: 7, 8: 8, 9: 9, 10: 10, 11: 'Jota',
            12: 'Reina', 13: 'Rey' }

def imprime_rango_carta(n):
    print(__tabla.get(n, 'Inválido'))
Definimos la variable __tabla afuera de la función para que sea inicializada una vez y no cada vez que ésta sea invocada. Los dos subguiones al inicio de un nombre son una convención para indicar que la variable es privada a este módulo. También hay que notar que solamente usamos una instrucción print() para abarcar todos los casos. Como se puede ver, el código queda más compacto y fácil de modificar que el switch-case original.

Moraleja: la instrucción switch-case es innecesaria en Python si sabemos usar diccionarios de manera correcta.