Ejercicio: Diccionario (Map) con valor por defecto

Ejercicio: Diccionario (Map) con valor por defecto

En esta entrada vamos a ver cómo Kotlin maneja un valor predeterminado en un diccionario (Map), y vamos a practicar con soluciones alternativas que llegan al mismo resultado, intentando escribir un código cada vez más simple y conciso. Para ello supongamos que queremos escribir una función genérica que toma una colección como argumento y devuelve un diccionario que contiene los elementos de la colección (claves) asociados a su número de ocurrencias (valores):
fun <T> contarFrecuencia(valores: Collection<T>): Map<T, Int>

Vamos a ver distintas maneras de hacerlo. En la primera, usamos un MutableMap y al iterar sobre cada elemento de la lista comprobamos si ese valor aparece en el diccionario: si no es así (null), le asignamos un conteo inicial de 1; de lo contrario incrementamos el contador:
fun main() {
    val letras = listOf("a", "b", "a", "d", "c")
    println(contarElementos(letras))
    // {a=2, b=1, d=1, c=1}

    val numeros = listOf(1, 2, 3, 4, 4, 3, 2, 3, 2, 2)
    println(contarElementos(numeros))
    // {1=1, 2=4, 3=3, 4=2}
}

fun <T> contarElementos(lista: Collection<T>): Map<T, Int> {
    val frecuenciasMap = mutableMapOf<T, Int>()
    for (item in lista) {
        val frecuencia = frecuenciasMap[item]
        frecuenciasMap[item] = if (frecuencia == null) 1 else frecuencia + 1
    }
    return frecuenciasMap
}

Como alternativa mejorada a la solución anterior, podemos utilizar putIfAbsent para asegurarnos que el elemento se inicializa con valor 0 e incrementar el contador de forma segura en la siguiente línea. Lo que hacemos es proporcionar un valor predeterminado explícito como alternativa al operador de índice, lo que asegura que se devuelve un tipo no anulable:
fun <T> contarElementos(lista: Collection<T>): Map<T, Int> {
    val frecuenciasMap = mutableMapOf<T, Int>()
    for (item in lista) {
        frecuenciasMap.putIfAbsent(item, 0)
        frecuenciasMap[item] = frecuenciasMap.getValue(item) + 1
    }
    return frecuenciasMap
}

Otra herramienta similar para proporcionar valores predeterminados explícitos es getOrDefault:
fun <T> contarElementos(lista: Collection<T>): Map<T, Int> {
    val frecuenciasMap = mutableMapOf<T, Int>()
    for (item in lista) {
        frecuenciasMap[item] = frecuenciasMap.getOrDefault(item, 0) + 1
    }
    return frecuenciasMap
}

Estos valores predeterminados conducen a soluciones sencillas y comprensibles, pero podemos mejorar el código utilizando la extensión MutableMap::withDefault que se ve así:
fun <K, V> MutableMap<K, V>.withDefault(defaultValue: (key: K) -> V): MutableMap<K, V>

Esta extensión permite proporcionar un inicializador para las claves que no tienen un valor asociado en el diccionario:
fun <T> contarElementos(lista: Collection<T>): Map<T, Int> {
    val frecuenciasMap = mutableMapOf<T, Int>().withDefault { 0 }
    for (item in lista) {
        frecuenciasMap[item] = frecuenciasMap.getValue(item) + 1
    }
    return frecuenciasMap
}

Vemos que esto simplifica el código aún más, ya que ya no necesitamos lidiar con valores desconocidos en la iteración: Ya que estamos usando un diccionario por defecto, podemos obtener valores de forma segura e incrementar el contador a medida que avanzamos.

No obstante, al usar la extensión withDefault es importante tener en cuenta que el valor por defecto se usa cuando el diccionario no contiene un valor para la clave especificada y dicho valor solo se obtiene usando Map::getValue, por lo que no es accesible con operadores de índice. Esto es así porque se devuelve el valor correspondiente a la clave o null si esa clave no existe en el diccionario:
fun main(){
    val map = mutableMapOf<String, Int>().withDefault { 0 }
    println(map["key"])          // null
    println(map.getValue("key")) // 0

    map["key2"] = 2
    println(map["key2"])          // 2
    println(map.getValue("key2")) // 2
}

Podemos simplificar el código con el uso de apply que permite inicializar el diccionario en una sola declaración (en próximas entradas veremos las funciones estándar o scope functions como let, run, with, apply y also):
fun <T> contarElementos(lista: Collection<T>): Map<T, Int> =
    mutableMapOf<T, Int>().withDefault { 0 }.apply {
        for (item in lista) {
            put(item, getValue(item) + 1)
        }
    }

Pero todavía podemos escribir un código más simplificado utilizando una función de agrupamiento (ver Operaciones con colecciones IV: Operaciones de agrupamiento) junto a una función de conteo:
fun <T> contarElementos(lista: Collection<T>): Map<T, Int> =
    lista.groupingBy { it }.eachCount()

En definitiva, podemos inicializar un diccionario (Map) con un valor por defecto con la extensión withDefault y con getValue, aunque podemos llegar al mismo resultado utilizando las útiles herramientas que ofrece la biblioteca estándar, y es que en Kotlin podemos resolver algoritmos simples de maneras muy diferentes.

Esta entrada es una adaptación libre y traducida de Default Map in Kotlin escrita por Simon Wirtz en Kotlin Expertise Blog.

Comentarios

Entradas populares

I/O: entrada y salida de datos en consola

Recursos gratis para aprender Kotlin

Lectura y escritura de archivos