Kotlin, parte 2: (not so) typesafe null y otras monerías

Una característica importante de Kotlin es que maneja seguridad en nulos. Esto es algo que varios lenguajes han estado implementando últimamente, porque ahorra muchos dolores de cabeza.

Normalmente, una variable de cualquier tipo que sea objeto, acepta null. En Kotlin no es así; para que una variable acepte null, se necesita especificar de esa forma. Esto no compila:

var x:String = "hola"
x = null

Porque x ha sido definida como de tipo String, y no acepta nulos. Para que acepte nulos, se tiene que definir así:

var x:String? = "hola"
x = null

Los tipos opcionales se pueden usar en parámetros de funciones, tipos de retorno y declaraciones locales.

Cuando se tiene un valor que puede ser null, no se puede usar de manera directa. Hay que verificar que el objeto exista; esto se puede lograr de varias formas:

var x:String? = "hola"
if (x != null) {
  //Aquí dentro, x ya se considera String
  println(x.length)
}
val largo = x?.length //largo será tipo `Int?`
//Se puede usar el operador "Elvis" con tipos opcionales
println(x ?: "no hay x")

Esto nos evita dolores de cabeza porque en vez de tener que lidiar con NullPointerException en tiempo de ejecución, estos errores se detectan en tiempo de compilación. Sin embargo, Kotlin tiene un gran problema con esto: integrado con la seguridad de nulos, viene un mecanismo para desactivarla por completo: Si se agrega una doble admiración al final de una variable opcional, el compilador deshabilita las verificaciones de nulos y se porta como lo haría el compilador de Java, permitiendo que sea nulo donde sea:

var x:String? = null
println(x!!.length) //Truena en runtime

//Por otro lado, si se tiene esto...
fun foo(s:String) = println("Hey, $s")

foo(x) //No compila
foo(x!!) //Compila, y truena en runtime

Es decepcionante que hayan incluido ese operador, por una simple razón: La gente que llega a Kotlin, no está acostumbrada a tener que verificar que algo no sea nulo y les va a fastidiar tener que estar validando todo al principio, porque no están acostumbrados; por lo tanto, van a usar la doble admiración por todos lados, anulando así el beneficio de dichas verificaciones.

Smart casting

Otra característica buena de Kotlin, es una que le llaman smart casting. Consiste en evitar hacer un cast innecesario, como en Java. Por ejemplo, esto es común en Java:

Object x = "hola";
if (x instanceof String) {
  //Hay que hacer una nueva variable con cast a String
  String s = (String)x;
  //Pero bien podría cometerse un error
  Integer i = (Integer)x; //truena en runtime
}

Esto lo he dicho varias veces: cuando hacemos un cast, le estamos diciendo al compilador "quítate, yo sé lo que hago". Y por supuesto, muchas veces no sabemos lo que estamos haciendo y cometemos errores en el código, que como el compilador ya no los valida, pues se van a tiempo de ejecución.

Kotlin alivia este tipo de problemas con los smart casts:

val x:Any = "hola"
if (x is String) {
  println(x.toUpperCase())
}

En este ejemplo, x es de tipo Any (el tipo raíz en Kotlin, que no es lo mismo que el Object de Java), sin embargo al tener la condición donde se verifica si es de tipo String, el bloque de código interno al if ya considera que x es de tipo String. De ese modo, no es necesario hacer un cast.

Esta es una característica que Ceylon también tiene, sin embargo, en Ceylon por tener tipos unión e intersección, esto es más poderoso y elimina por completo la necesidad de hacer casts, por lo que no se permiten (sólo se permiten upcasts, es decir, hacer casts a tipos más generales, pero no más específicos). Sin embargo, el sistema de tipos de Kotlin, al ser más limitado (prácticamente igual al de Java, con excepción de que todo es un objeto, no hay tipo nativos, lo cual es bastante bueno) necesita que haya casts para algunos casos especiales, sin embargo el lenguaje debe permitir hacer cast en donde sea. Por lo tanto se puede hacer algo así:

val x:Any = "hola"
if (x is String) {
  println(x as Int)
}

Ese código va a arrojar una ClassCastException al ejecutarse. El problema que se tiene en Java, fue migrado a Kotlin. Y los programadores que usen Kotlin por primera vez, si no investigan bien las características del lenguaje, van a hacer casts donde no se necesitan, y por lo tanto es posible que hagan un cast mal y tengan los mismos problemas que en Java.

Data classes

Kotlin tiene una cosa que tomaron prestada de Scala: las data classes. Son similares a los Java beans, con la diferencia de que por default son inmutables y por tanto tienen un constructor que recibe todos los valores para sus propiedades. Las data classes no necesitan un cuerpo; solamente se declara el nombre de la clase y en el constructor se declaran todas sus propiedades.

import java.util.Date

data class Persona(val nombre:String, val nacimiento:Date)

Las data classes son un poco más poderosas que los Java beans porque automágicamente se les agrega una implementación de toString que despliega los valores de todas sus propiedades, así como una implementación de equals que compara todas las propiedades de ambos objetos para considerarlos iguales. Pero no implementa hashCode...

Por cierto, para instanciar objetos en Kotlin no se requiere de new; simplemente se invoca el constructor como si fuera una función (al igual que en Ceylon).

Se pueden definir valores por defecto para parámetros, siempre y cuando se tengan al final todos los parámetros con valores por defecto:

data class Persona(val nombre:String,
                   val nacimiento:Date=Date())

//Se puede invocar con un valor
println(Persona("Juan", unaFecha))
//O bien omitir nacimiento para que se le ponga un valor por defecto
println(Persona("Juan"))

//Sobrecarga de operadores: == realmente invoca equals
println(Persona("A",fecha)==Persona("A",fecha))
//Lo anterior es lo mismo que poner esto
println(Persona("A",fecha).equals(Persona("A",fecha)))

//Invocaciones por nombre
println(Persona(nacimiento=fecha, nombre="Juan"))

Invocaciones por nombre

Las invocaciones por nombre son muy útiles cuando se tienen métodos, funciones o constructores con muchos parámetros, pues mejoran la legibilidad porque queda muy claro qué argumentos se pasan, sin tener que recurrir a la documentación, e incluso se puede reordenar los argumentos en caso que no tenga mucho sentido como están declarados los parámetros:

//Suponiendo que tengamos algo como esto
fun connectToServer(readTimeout:Int, connectTimeout:Int,
                    host:String, port:Int)
//Se puede llamar así
connectToServer(
    host = "localhost",
    port = 1234,
    connectTimeout = 1000,
    readTimeout = 5000
)

Destructuring

Creo que esto lo podríamos traducir como "desestructuración". Esto es algo muy simple pero muy conveniente y se entiende mejor con un simple ejemplo, usando una clase que ya definimos anteriormente:

val p = Persona("Juan")
val (n,f) = p
println("Nombre: $n")
println("FdN:    $f")

Otro ejemplo, con ciclos y mapas:

val mapa = mapOf(1 to "uno", 2 to "dos")
for ((k, v) in mapa) {
  println("$k: $v")
}

Lo anterior es bastante conveniente, mucho más breve que el equivalente en Java for (Map.Entry<Integer,String> e : mapa). Sin embargo, puede resultar algo desconcertante la irregularidad en la sintaxis: para declarar las entradas del mapa se usa el operator to, mientras que en el ciclo se usa la sintaxis (k, v); un programador novato seguramente intentaría primero escribir for (k to v in mapa), y los mensajes de error del compilador en este caso no ayudan en nada (pruébenlo ustedes mismos para darse cuenta de los errores que arroja al escribir esa alternativa y sus variantes con paréntesis).

En la tercera parte de esta serie, trataré un tema un poco más complejo: la sobrecarga de operadores, y los métodos de extensión.

Ir a la tercera parte de la serie

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.
Imagen de Nopalin

Supongo que cada programador

Supongo que cada programador tendrá su manera de ver el mundo, a algunos les gustará el sugar sintactic y a otros no. En lo personal me agrada cuando los nuevos lenguajes implementan nuevas maneras de escribir el código, pero no si es a costa de lo declarativo. Se vuelve muy engorroso revisar el código cuando el compilar convierte el sugar sintactic a las condiciones y declaración que debió escribir el programador. Pero como dije es cuestion de gustos, yo prefiero ver un método con 20 líneas de código, que al irlo revisando muestre exactamente o que hace, a verlo con 3 líneas de código y andar buscando en la documentación o preguntando por internet o navegando entre otros archivos fuente para descubrir su misterio.

Saludos