data class e igualdad
Al terminar: Entender data class, copy y la diferencia entre == y ===, y explicarlo en una entrevista.
Al terminar tendrás Partido, un valor inmutable: un valor que no cambia nunca después de crearse; para tener uno distinto, se crea otro. Aquí, «registrar un gol» no modifica el Partido: devuelve otro con el marcador nuevo. Además, Jugador pasará a data class, y verás cómo dos jugadores con los mismos datos se consideran el mismo.
Importa porque casi todo lo que guarda datos en Kotlin es una data class, y porque la diferencia entre == y === es de las preguntas que más se hacen en entrevistas. Si no la tienes clara, tarde o temprano comparas dos cosas iguales y el programa dice que no lo son.
Temas: referencias (dos nombres para un mismo objeto), data class, copy, == frente a === y desestructuración.
Dos nombres, un mismo objeto
Antes de data class hace falta entender una idea: una variable no contiene el objeto, apunta a él. A eso se le llama una referencia. En Variables, tipos y operaciones la variable era una caja; con números y textos esa imagen basta, pero con objetos es más exacto decir que la variable guarda la dirección de dónde está el objeto. Analogía: dos personas con la dirección de la misma casa frente a dos casas idénticas en calles distintas. En el primer caso, si una pinta la fachada, la otra la ve pintada; en el segundo, cada casa es suya.
// Una clase normal: sus objetos se pueden cambiar (var)
class Taquilla(var vendidas: Int)
// Una data class: sus objetos se comparan por lo que contienen
data class Butaca(val fila: Int, val numero: Int)
fun main() {
val a = Taquilla(0)
val b = a // b no es otra taquilla: es otro nombre para la misma
b.vendidas = 5 // cambiar b es cambiar a
println(a.vendidas) // 5: a y b apuntan al mismo objeto
println(a === b) // true: === pregunta «¿es el mismo objeto?»
val c = Taquilla(5) // otra taquilla distinta, con los mismos datos
println(a == c) // false: en una clase normal, == solo es true si es el mismo objeto
println(Butaca(3, 7) == Butaca(3, 7)) // true: en una data class, == compara los datos
println(Butaca(3, 7) === Butaca(3, 7)) // false: siguen siendo dos objetos
}5
true
false
true
falseLa segunda mitad del ejemplo es lo que arregla data class, y es lo que sigue.
Lo que el compilador escribe por ti
Los datos que escribes entre los paréntesis de la clase, justo después de su nombre, son su constructor primario: es lo que mira una data class para generar todo lo que sigue.
| Miembro generado | Para qué sirve |
|---|---|
equals() | lo que usa == para comparar por contenido. También se genera hashCode(), una función auxiliar que nunca tendrás que escribir ni llamar tú |
toString() | Jugador(nombre=Ana, dorsal=9) en vez de Jugador@3a71f4dd |
copy(...) | clonar cambiando solo lo que indicas |
Del constructor primario (Fichaje(val jugador: String, …)) sale todo eso, y es lo único que cuenta. Además se puede desestructurar: sacar varias propiedades a variables de una vez, en orden, con val (a, b, c) = objeto. Si una propiedad no te interesa, escribes _ en su sitio: val (a, _, c) = objeto se salta la segunda.
// data delante de class: el compilador escribe por ti ==, toString y copy
data class Fichaje(val jugador: String, val club: String, val precio: Int)
fun main() {
val a = Fichaje("Ana", "Rayo", 100)
val b = a.copy(precio = 80) // copia de a, cambiando solo el precio
val c = a.copy() // copia idéntica
println(a == c) // true: mismo contenido
println(a === c) // false: dos objetos distintos
println(b) // toString generado: Fichaje(jugador=Ana, club=Rayo, precio=80)
val (jugador, club, precio) = b // desestructurar: saca las propiedades en orden, a tres variables
println("$jugador $club $precio")
}true
false
Fichaje(jugador=Ana, club=Rayo, precio=80)
Ana Rayo 80== frente a ===
| Operador | Pregunta | En una class normal | En una data class |
|---|---|---|---|
== | ¿tienen el mismo contenido? | true solo si es el mismo objeto (compara identidad) | true si coinciden las propiedades del constructor |
=== | ¿es el mismo objeto? | igual | igual |
Esto cambia código que ya escribiste. En Propiedades y visibilidad, Jugador era una clase normal, y nombrarCapitan(Jugador("Ana", 9)) habría fallado con «Ana no juega en Rayo FC», porque ese Jugador nuevo no es el que está en la plantilla. Con data class, sí lo encuentra: jugador in plantilla usa ==. Es la misma línea de Equipo; solo cambia lo que Jugador entiende por «igual».
Equipo no será data class, a propósito: dos equipos con el mismo nombre no son el mismo equipo. Por eso, el partido que crearás en el paso siguiente solo será igual a otro si comparten los mismos objetos Equipo.
Error típico: propiedades fuera del constructor
data class Entrada(val partido: String) {
var vendida: Boolean = false
}
fun main() {
val a = Entrada("Final")
a.vendida = true
val b = Entrada("Final")
println(a == b) // true: ¡aunque una está vendida y la otra no!
}data class Entrada(val partido: String, val vendida: Boolean = false)
fun main() {
val a = Entrada("Final", vendida = true)
val b = Entrada("Final")
println(a == b) // false
}Falla porque ==, toString y copy solo miran las propiedades del constructor primario. Lo que declares en el cuerpo queda fuera de todos ellos, y tampoco aparece en toString().
Relacionado: conviene que todo lo de una data class sea val. Un objeto que cambia después de crearse da sorpresas, porque quien lo guardó ya no sabe qué contiene. Por eso en Marcador todo será val y las modificaciones serán copias.
copy no clona en profundidad
copy crea un objeto nuevo, pero las propiedades que son objetos se comparten: la copia y la original apuntan a lo mismo. Con el Partido del paso siguiente, partido.copy(golesVisitante = 3) conserva el mismo Equipo local, no una copia de él. Con datos inmutables no importa. Con una lista mutable dentro, sí: Inmutabilidad y referencias lo desarrolla.
En el módulo de Swift verás su equivalente: el struct, que se copia al asignarlo en vez de compartirse.
Reto · Ejercicio: la versión en directo
En el Kotlin Playground: una canción tiene título, artista y duración en segundos, y dos canciones con los mismos datos son iguales. Crea «Volar», de Rosa, de 180 segundos; a partir de ella, una versión en directo que dura 30 segundos más, sin tocar la original; y una tercera canción con los mismos datos que la original. Imprime la versión en directo; después, si la original y la tercera son iguales por contenido y si son el mismo objeto; y por último, sacando el título y la duración de la versión en directo de una vez (sin el artista), la frase de la última línea. La consola debe mostrar:
Cancion(titulo=Volar, artista=Rosa, segundos=210)
true
false
Volar dura 210 sVer solución
data class Cancion(val titulo: String, val artista: String, val segundos: Int)
fun main() {
val original = Cancion("Volar", "Rosa", 180)
val enDirecto = original.copy(segundos = original.segundos + 30) // copia con un dato cambiado
val otra = Cancion("Volar", "Rosa", 180)
println(enDirecto) // toString generado
println(original == otra) // true: mismo contenido
println(original === otra) // false: dos objetos distintos
val (titulo, _, segundos) = enDirecto // desestructurar; _ salta el artista
println("$titulo dura $segundos s")
}copy crea otra canción y deja la original intacta; == compara contenido (lo genera la data class) y === compara si es el mismo objeto; en la desestructuración, _ salta el campo que no te interesa.
Paso · Partido inmutable
Objetivo: Jugador pasa a data class, nace Partido sin ninguna propiedad var, y main muestra que registrar un gol no toca el partido original. Al terminar salen Antes: 0-0, después: 2-1, ¿Misma ficha? true (==), false (===) y el jugador con otro dorsal.
1 · Crea Partido.kt. En la vista Android, despliega torneo → kotlin+java. Haz clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test) detrás; ahí están Main.kt y Equipo.kt) → New → Kotlin Class/File → File → escribe Partido y pulsa Intro. Se abre Partido.kt con la línea package ya escrita. Es el mismo gesto que usaste en Tu primer proyecto en Kotlin.
Piezas nuevas de este paso: una data class con valores por defecto, una función de la clase que devuelve copy(...), llamadas encadenadas, la coma tras el último parámetro y _ al desestructurar (para saltarse una propiedad). Todas en un ejemplo con otros datos, explicado línea a línea:
// data delante de class: el compilador genera ==, toString, copy y componentN
data class Pedido(
val mesa: Int,
val platos: Int = 0, // valor por defecto: si no lo pasas, vale 0
val bebidas: Int = 0, // la coma tras el último parámetro está permitida
) {
// Dentro de la clase, copy(...) crea otro Pedido igual salvo lo que indicas.
// No cambia este: devuelve uno nuevo.
fun conUnPlatoMas(): Pedido = copy(platos = platos + 1)
}
fun main() {
val vacio = Pedido(mesa = 4) // platos y bebidas toman su valor por defecto
val lleno = vacio.conUnPlatoMas().conUnPlatoMas() // cada llamada devuelve un Pedido nuevo: se encadenan
println(vacio) // el original no ha cambiado
println(lleno)
val (_, platos, bebidas) = lleno // desestructurar en orden; _ ignora el primero (mesa)
println("$platos platos, $bebidas bebidas")
}Al ejecutarlo, imprime:
Pedido(mesa=4, platos=0, bebidas=0)
Pedido(mesa=4, platos=2, bebidas=0)
2 platos, 0 bebidasIntenta escribirlo tú. Dos jugadores con el mismo nombre y el mismo dorsal pasan a ser iguales, y se imprimen con sus datos. Un Partido es una data class con cuatro propiedades, en este orden y con estos nombres exactos (los tests que copiarás después los usan): local y visitante (de tipo Equipo) y golesLocal y golesVisitante (de tipo Int, con valor por defecto 0). No cambia nunca: conGolLocal() y conGolVisitante() devuelven un partido nuevo con un gol más para ese lado. Al final de main, crea los Toros con Iván (dorsal 7), juega un partido Rayo FC-Toros que acaba 2-1 sin tocar el del principio, y muestra los dos marcadores; después compara a Ana con una Ana nueva de los mismos datos (por contenido y por identidad) e imprime una copia de Ana con el dorsal 10. Tras las líneas de la lección anterior, debe verse:
Antes: 0-0, después: 2-1
¿Misma ficha? true (==), false (===)
Con otro dorsal: Jugador(nombre=Ana, dorsal=10)Los bloques con ruta llevan la del fichero en tu proyecto.
Jugador.kt: sustituye el comentario y la líneaclass Jugador(val nombre: String, val dorsal: Int?) {(justo debajo delpackage) por el fragmento de abajo. El cambio real es añadirdatadelante declass; los comentarios son opcionales. El resto del fichero no cambia.Main.kt: pega el fragmento de abajo en lugar de la última}demain, la que está justo debajo deprintln("Toros: ${error.exceptionOrNull()?.message}"). El fragmento ya trae esa}al final y el resto del fichero no cambia.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// `Int?` es un Int que puede ser null: en el amateur hay jugadores sin dorsal.
// `data class`: el compilador genera equals, hashCode, toString, copy y
// componentN a partir de las propiedades del constructor primario.
data class Jugador(val nombre: String, val dorsal: Int?) {package com.sebastiannarvaez.marcador.torneo
// Un partido es un valor inmutable: ninguna propiedad es `var`.
// «Registrar un gol» no modifica el partido: devuelve OTRO con el marcador nuevo.
data class Partido(
val local: Equipo,
val visitante: Equipo,
val golesLocal: Int = 0,
val golesVisitante: Int = 0,
) {
// `copy` clona el partido cambiando solo lo que se indica.
fun conGolLocal(): Partido = copy(golesLocal = golesLocal + 1)
fun conGolVisitante(): Partido = copy(golesVisitante = golesVisitante + 1)
} // data class: copy, == frente a ===, desestructuración.
val toros = Equipo("Toros", listOf(Jugador("Iván", 7)))
val inicio = Partido(rayo, toros)
val alFinal = inicio.conGolLocal().conGolLocal().conGolVisitante()
val (_, _, golesLocal, golesVisitante) = alFinal
println("Antes: ${inicio.golesLocal}-${inicio.golesVisitante}, después: $golesLocal-$golesVisitante")
val ana = jugadores.first() // first(): el primer elemento de la lista (Ana)
println("¿Misma ficha? ${ana == Jugador("Ana", 9)} (==), ${ana === Jugador("Ana", 9)} (===)")
println("Con otro dorsal: ${ana.copy(dorsal = 10)}")
}Un aviso sobre el futuro: este Partido guarda el marcador. En El partido hecho de eventos el marcador pasará a deducirse de una lista de eventos, y borrarás conGolLocal, conGolVisitante y la desestructuración de cuatro campos.
2 · Ejecuta el programa. En la Terminal de Android Studio (View → Tool Windows → Terminal, que se abre en la carpeta del proyecto, donde está gradlew), escribe ./gradlew :torneo:run. Las últimas líneas deben ser las tres de abajo.
Antes: 0-0, después: 2-1
¿Misma ficha? true (==), false (===)
Con otro dorsal: Jugador(nombre=Ana, dorsal=10)3 · Añade los tests de Partido. PartidoTest.kt tiene seis tests que comprueban lo que acabas de escribir: que registrar un gol devuelve una copia, que copy cambia solo lo indicado, == frente a ===, la desestructuración y toString. No lo escribes tú: usa lo mismo que ya aprendiste con @Test y assertEquals, y lo único nuevo son dos comprobaciones, que se explican debajo. Es un fichero de relleno: lo copias con el botón.
- En
torneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo (test)(el que lleva(test)detrás, donde estáPuntosTest.kt) → New → Kotlin Class/File → File →PartidoTest→ Intro. - Pulsa Copiar fichero: copia el contenido al portapapeles.
- En el editor, selecciona todo (Cmd+A) y pega (Cmd+V). El fichero copiado trae su propia línea
package, así que sustituye a la que escribió Android Studio.
Hay dos comprobaciones nuevas: assertSame(a, b) y assertNotSame(a, b) comprueban identidad, es decir, ===: «¿es exactamente el mismo objeto?». assertTrue y assertFalse ya los conoces de Bucles y tu primer test (allí viste assertFalse solo de pasada: pasa si la condición es falsa). Este es el test que enseña == frente a ===:
@Test
fun dobleIgualIgualCompruebaContenido() {
val a = Jugador("Ana", 9)
val b = Jugador("Ana", 9)
assertTrue(a == b) // equals generado: mismo contenido
assertFalse(a === b) // identidad: son dos objetos distintos en memoria
}Ejecuta ./gradlew :torneo:test en la Terminal. Si copiaste el fichero antes de crear Partido, o sin terminar Jugador, Android Studio lo marca en rojo y Gradle no compila: termina el paso anterior y repite.
Punto de control
./gradlew :torneo:runtermina con las tres líneas de arriba../gradlew :torneo:testtermina enBUILD SUCCESSFUL. Abre el informe (open torneo/build/reports/tests/test/index.html, como en Bucles y tu primer test): arriba debe poner 24 tests, los 18 que ya tenías y los 6 dePartidoTest. Para ver que de verdad se ejecutan, enPartidoTest.kt, enregistrarUnGolDevuelveUnaCopia, cambia el1deassertEquals(1, despues.golesLocal)por un2, comprueba que apareceFAILEDy devuélvelo a1.Jugador("Ana", 9).toString()daJugador(nombre=Ana, dorsal=9): lo comprueba el último test.Partidono tiene ningúnvar, yiniciosigue en0-0después de registrar goles.- En la vista Android, el paquete
com.sebastiannarvaez.marcador.torneo (test)tiene ahora cuatro ficheros:PuntosTest.kt,JugadorTest.kt,EquipoTest.ktyPartidoTest.kt.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Cuáles de estos miembros genera el compilador para una
data class?2.
val a = Jugador("Ana", 9)yval b = Jugador("Ana", 9), conJugadorcomodata class. ¿Qué dana == bya === b?3.
data class Entrada(val partido: String) { var vendida = false }. Dos entradas del mismo partido, una vendida y otra no, ¿son iguales con==?4.
partido.copy(golesVisitante = 3)en unPartidoconlocal: Equipo. ¿Qué pasa conlocalen la copia?5.Con
class Taquilla(var vendidas: Int), hacesval a = Taquilla(0)yval b = a, y despuésb.vendidas = 5. ¿Cuánto valea.vendidas?