Tabla de Contenidos
Programación Orientada a Objetos en Kotlin
Clases
La Programación Orientada a Objetos (POO) es un paradigma de programación basado en la utilización de objetos.
Un objeto agrupa principalmente:
- Datos, representados mediante propiedades.
- Comportamiento, representado mediante funciones.
Por ejemplo, si queremos representar un coche podríamos tener:
Coche Propiedades: marca modelo velocidad Funciones: acelerar() frenar() mostrarInformacion()
Para definir este tipo de elementos utilizaremos clases.
Clases y objetos
Una clase es una plantilla que define las características y el comportamiento que tendrán los objetos creados a partir de ella.
Por ejemplo:
class Coche { var marca = "" var modelo = "" var velocidad = 0 }
- Esta clase define cómo serán los objetos de tipo
Coche. - Sin embargo, declarar una clase no crea ningún coche.
- Para trabajar con ella debemos crear objetos o instancias de la clase.
Clase frente a objeto
Podemos entender una clase como un plano, plantilla o molde, y un objeto como ele ejemplar concreto realizado a partir de ese plano.
Una misma clase puede utilizarse para crear muchos objetos diferentes.
Declaración de una clase
Para declarar una clase utilizamos la palabra reservada `class` seguida del nombre de la clase:
class Persona { }
Por convenio, los nombres de las clases comienzan con mayúscula:
class Persona class Alumno class Producto class CuentaBancaria
Una clase puede estar formada por dos partes:
- Cabecera de la clase.
- Cuerpo de la clase.
Por ejemplo:
class Persona(val nombre: String) { . . . var edad = 0 fun saludar() { println("Hola, soy $nombre") } . . . }
La cabecera es:
class Persona(val nombre: String)
El cuerpo es todo lo que aparece entre `{ }`:
{ var edad = 0 . . . fun saludar() { println("Hola, soy $nombre") } . . . }
El cuerpo puede contener, entre otros elementos:
- Propiedades.
- Funciones.
- Bloques de inicialización.
- Constructores secundarios.
- Otras clases u objetos.
Si una clase no necesita cuerpo podemos omitir las llaves:
class Persona(val nombre: String, var edad: Int)
Fichero que contiene a una clase
Por cuestiones organizativas:
- vamos a usar una clase principal por fichero y fichero con el mismo nombre que la clase:
class Alumno → Alumno.kt
Aunque Kotlin no lo exige, mejora mucho la organización del proyecto y hace más sencillo localizar las clases. Para ficheros que agrupen funciones u otras declaraciones relacionadas, sí tiene sentido utilizar un nombre descriptivo diferente, por ejemplo Utilidades.kt.
Crear objetos
Para crear un objeto escribimos el nombre de la clase seguido de paréntesis:
val persona = Persona("Ana", 20)
A esta operación la denominamos instanciar una clase.
persona es ahora un objeto o instancia de Persona.
A diferencia de otros lenguajes orientados a objetos como Java, en Kotlin no se utiliza la palabra `new` para crear objetos.
Por ejemplo:
// Kotlin val persona = Persona("Ana", 20)
No escribimos:
// Incorrecto en Kotlin // val persona = new Persona("Ana", 20)
Podemos crear múltiples objetos de una misma clase:
val persona1 = Persona("Ana", 20) val persona2 = Persona("Carlos", 25) val persona3 = Persona("Marta", 19)
Todos son objetos diferentes, aunque hayan sido creados utilizando la misma clase.
Propiedades de una clase
Las variables que forman parte de una clase reciben normalmente el nombre de propiedades.
Por ejemplo:
class Coche { var marca = "" var modelo = "" var velocidad = 0 }
La clase `Coche` tiene tres propiedades:
marca modelo velocidad
Podemos acceder a ellas utilizando el operador . :
val coche = Coche() coche.marca = "Toyota" coche.modelo = "Corolla" coche.velocidad = 50 println(coche.marca) println(coche.modelo) println(coche.velocidad)
Propiedades val y var
Al igual que las variables normales, las propiedades pueden declararse utilizando val o var.
Por ejemplo:
class Persona( val nombre: String, var edad: Int )
En este caso:
nombrees una propiedad que no puede reasignarse.edades una propiedad que puede modificarse.
val persona = Persona("Ana", 20) persona.edad = 21
Esto es correcto.
Sin embargo:
// ERROR // persona.nombre = "Marta"
no está permitido porque `nombre` fue declarado mediante `val`.
Al igual que con las variables normales, es recomendable utilizar `val` cuando una propiedad no necesite ser reasignada.
Ejercicio 1
Crea una clase Libro con las propiedades titulo, autor y numeroPaginas. Crea dos objetos diferentes de la clase, da valor a sus propiedades y después muestra sus propiedades por pantalla.
Constructor primario
Cuando creamos un objeto normalmente queremos proporcionar valor a sus propiedades. Para ello utilizamos un constructor. La forma más habitual en Kotlin es utilizar un constructor primario, que se declara directamente en la cabecera de la clase:
class Persona(val nombre: String, var edad: Int) { }
Ahora debemos proporcionar esos valores al crear el objeto:
val persona = Persona("Ana", 20)
Un constructor primario permite que los parámetros que indiquemos, se conviertan automáticamente en propiedades de la clase, y podemos acceder a ellas:
println(persona.nombre) println(persona.edad)
Resultado:
Ana 20
La sintaxis completa del constructor podría escribirse utilizando la palabra `constructor`:
class Persona constructor(val nombre: String, var edad: Int) { }
Sin embargo, normalmente podemos omitirla:
class Persona(val nombre: String, var edad: Int) { }
Esta es la forma que utilizaremos habitualmente.
Ejercicio 2
Crea una clase Alumno cuyo constructor reciba:
nombre, como propiedadval.curso, como propiedadval.nota, como propiedadvar.
Crea un alumno, modifica posteriormente su nota y muestra todos sus datos.
Parámetros y propiedades del constructor
Existe una diferencia importante entre escribir:
class Persona(nombre: String)
y:
class Persona(val nombre: String)
En el primer caso nombre se pasa como parámetro al constructor. En el segundo caso `nombre` se convierte en una propiedad del objeto.
Por ello podemos hacer:
val persona = Persona("Ana") println(persona.nombre)
Sin `val` o `var`, el parámetro del constructor no se convierte automáticamente en una propiedad accesible del objeto.
Por tanto:
class Persona( val nombre: String, var edad: Int )
es una forma muy habitual de declarar simultáneamente:
- Los parámetros necesarios para crear el objeto.
- Las propiedades que almacenará.
Ejercicio 3
Observa las siguientes clases:
class Usuario1(nombre: String) class Usuario2(val nombre: String)
Crea un objeto de cada clase e intenta ejecutar:
println(usuario1.nombre) println(usuario2.nombre)
¿Cuál de las dos instrucciones produce un error? Explica brevemente por qué.
Parámetros con valores por defecto
Los parámetros del constructor pueden tener valores por defecto:
class Persona( val nombre: String = "Desconocido", var edad: Int = 0 )
Ahora podemos crear objetos de diferentes maneras:
val persona1 = Persona() val persona2 = Persona("Ana") val persona3 = Persona("Carlos", 25)
Los valores serán:
persona1 -> nombre = "Desconocido", edad = 0 persona2 -> nombre = "Ana", edad = 0 persona3 -> nombre = "Carlos", edad = 25
También podemos utilizar argumentos con nombre:
val persona = Persona( edad = 25, nombre = "Carlos" )
Propiedades adicionales
No todas las propiedades tienen que declararse en el constructor. Podemos declarar otras propiedades dentro del cuerpo de la clase:
class Persona( val nombre: String, val anioNacimiento: Int ) { ``` val mayoriaEdad = 18 var activo = true ``` }
También podemos calcular una propiedad utilizando los datos recibidos por el constructor:
class Rectangulo( val base: Double, val altura: Double ) { ``` val area = base * altura ``` }
Uso:
val rectangulo = Rectangulo(10.0, 5.0) println(rectangulo.area)
Resultado:
50.0
Ejercicio 4
Crea una clase Circulo que reciba mediante el constructor su radio. Añade en el cuerpo de la clase una propiedad area cuyo valor se calcule automáticamente:
PI * radio * radio
Crea un círculo y muestra su área.
Bloque init
En ocasiones necesitamos ejecutar instrucciones cuando se crea un objeto.
Para ello podemos utilizar un bloque `init`.
class Persona( val nombre: String, var edad: Int ) { ``` init { println("Creando una nueva persona") } ``` }
Cuando hacemos:
val persona = Persona("Ana", 20)
se ejecutará automáticamente el bloque `init`.
Podemos acceder desde él a los parámetros y propiedades:
class Persona( val nombre: String, var edad: Int ) { ``` init { println("Creando a $nombre") println("Edad inicial: $edad") } ``` }
Validación de datos en init{}
Uno de los usos del bloque init es comprobar que los datos utilizados para crear un objeto sean correctos.
Por ejemplo:
class Persona( val nombre: String, var edad: Int ) { ``` init { require(edad >= 0) { "La edad no puede ser negativa" } } ``` }
Esto sería válido:
val persona = Persona("Ana", 20)
Pero:
val persona = Persona("Ana", -5)
provocaría un error porque no se cumple la condición establecida mediante `require()`.
Otro ejemplo:
class Producto( val nombre: String, val precio: Double ) { . . . init{ require(nombre.isNotBlank()) { "El nombre no puede estar vacío" } require(precio >= 0) { "El precio no puede ser negativo" } } . . . }
require() permite indicar una condición que debe cumplirse.
Si la condición es falsa, Kotlin lanza una excepción indicando que el argumento proporcionado no es válido.
No es necesario profundizar todavía en las excepciones para poder utilizar esta validación básica.
Ejercicio 5
Crea una clase Articulo con las propiedades nombre y precio. Utiliza un bloque init y require() para impedir que se puedan crear artículos con el nombre vacío o con un precio negativo.
Comprueba su funcionamiento.
Constructores secundarios
Una clase puede tener además constructores secundarios.
Se declaran mediante constructor dentro del cuerpo de la clase.
Por ejemplo:
class Persona( val nombre: String, var edad: Int ) { ``` constructor(nombre: String) : this(nombre, 0) ``` }
Ahora podemos crear:
val persona1 = Persona("Ana", 20) val persona2 = Persona("Carlos")
En el segundo caso se utiliza el constructor secundario:
constructor(nombre: String) : this(nombre, 0)
que llama al constructor primario proporcionando:
nombre -> valor recibido edad -> 0
Si existe un constructor primario, los constructores secundarios deben terminar delegando en él mediante `this(…)`.
No obstante, Kotlin permite solucionar muchas situaciones mediante valores por defecto, por lo que no necesitaremos constructores secundarios con tanta frecuencia como en otros lenguajes.
Por ejemplo, en el caso anterior probablemente sea más sencillo escribir:
class Persona( val nombre: String, var edad: Int = 0 )
Funciones de una clase
Las clases también pueden contener funciones.
Estas funciones representan el comportamiento de los objetos.
Por ejemplo:
class Coche( val marca: String, val modelo: String, var velocidad: Int ) { ``` fun acelerar() { velocidad += 10 } fun frenar() { velocidad -= 10 } fun acelerar(cantidad : Int) { velocidad += cantidad } fun mostrarInformacion() { println("$marca $modelo - $velocidad km/h") } ``` }
Podemos utilizar estas funciones mediante el operador . :
fun main() { ``` val coche = Coche("Toyota", "Corolla", 0) coche.acelerar() coche.acelerar() coche.mostrarInformacion() ``` }
Resultado:
Toyota Corolla - 20 km/h
Las funciones pueden acceder directamente a las propiedades de su objeto:
fun acelerar() { velocidad += 10 }
No necesitamos pasar `velocidad` como parámetro porque pertenece al propio objeto.
Ejercicio 6
Crea una clase CuentaBancaria con las propiedades titular y saldo. Añade dos funciones:
ingresar(cantidad): aumenta el saldo.mostrarSaldo(): muestra el saldo actual.
Crea una cuenta y da valor a las dos propiedades, realiza dos ingresos y muestra el saldo resultante.
Data classes
Las data classes son clases destinadas principalmente a almacenar datos. Se declaran utilizando data class:
data class Usuario( val nombre: String, val edad: Int )
A partir de las propiedades del constructor primario, Kotlin genera automáticamente varias funciones útiles:
| Función | Finalidad |
|---|---|
toString() | Devuelve una representación del objeto y sus propiedades. |
equals() | Permite comparar dos objetos por sus datos. |
hashCode() | Genera un código asociado a los datos del objeto. |
copy() | Permite crear una copia del objeto. |
componentN() | Permite desestructurar el objeto. |
Por ejemplo:
val usuario1 = Usuario("Ana", 25) val usuario2 = Usuario("Ana", 25) println(usuario1) println(usuario1 == usuario2) val usuario3 = usuario1.copy(edad = 30) println(usuario3)
Salida:
Usuario(nombre=Ana, edad=25) true Usuario(nombre=Ana, edad=30)
Para declarar una data class, su constructor primario debe contener al menos una propiedad y sus parámetros deben declararse como val o var.
Ejercicio 7
Crea una data class denominada Pelicula con las propiedades:
titulodirectoranio
Crea dos películas con los mismos datos y comprueba mediante == si Kotlin las considera iguales. Crea después una tercera película utilizando copy(), modificando únicamente el año.
Propiedades de una data class
Una data class también puede tener propiedades declaradas dentro del cuerpo de la clase:
data class Persona(val nombre: String) { var edad: Int = 0 }
Sin embargo, las funciones generadas automáticamente por Kotlin solo tienen en cuenta las propiedades declaradas en el constructor primario.
En el ejemplo anterior, nombre se utiliza en equals(), toString(), copy(), etc., mientras que edad queda excluida.
Por ejemplo:
val persona1 = Persona("Ana") val persona2 = Persona("Ana") persona1.edad = 20 persona2.edad = 35 println(persona1 == persona2)
El resultado será:
true
Los dos objetos se consideran iguales porque equals() únicamente tiene en cuenta nombre, ya que es la única propiedad declarada en el constructor primario.
En una data class, debemos colocar en el constructor primario las propiedades que representan los datos principales del objeto y que queremos que participen en operaciones como la comparación, copia o representación del objeto.
Referencias y objetos
Una variable que contiene un objeto contiene una referencia a ese objeto. Por ejemplo:
val producto1 = Producto( "Teclado", 50.0, 10 ) val producto2 = producto1
En este caso no hemos creado dos productos independientes.
`producto1` y `producto2` hacen referencia al mismo objeto.
Por tanto:
producto2.stock = 5 println(producto1.stock)
mostrará:
5
Esto es diferente de lo que ocurre con tipos sencillos como `Int`:
var numero1 = 10 var numero2 = numero1 numero2 = 20 println(numero1)
El resultado continúa siendo:
10
Comprender que los objetos se manejan mediante referencias será importante posteriormente cuando trabajemos con objetos, colecciones y aplicaciones Android.
Ejercicio 8
Observa el siguiente código:
class Caja(var contenido: Int) val caja1 = Caja(10) val caja2 = caja1 caja2.contenido = 50 println(caja1.contenido)
¿Qué valor mostrará el programa?
Explica brevemente por qué modificar caja2 afecta también a caja1.
Ejemplo resuelto: clase Producto
Vamos a representar un producto de una tienda.
Cada producto tendrá:
- Nombre.
- Precio.
- Stock.
Además podrá:
- Mostrar su información.
- Vender unidades.
- Reponer unidades.
- Calcular el valor total del stock.
class Producto( val nombre: String, val precio: Double, var stock: Int = 0 ) { . . . init { require(nombre.isNotBlank()) { "El nombre no puede estar vacío" } require(precio >= 0) { "El precio no puede ser negativo" } require(stock >= 0) { "El stock no puede ser negativo" } } fun vender(cantidad: Int) { if (cantidad <= 0) { println("La cantidad debe ser positiva") } else if (cantidad > stock) { println("No existe stock suficiente") } else { stock -= cantidad println("Venta realizada") } } fun reponer(cantidad: Int) { if (cantidad > 0) { stock += cantidad } } fun calcularValorStock(): Double { return precio * stock } fun mostrarInformacion() { println("Producto: $nombre") println("Precio: $precio €") println("Stock: $stock") println("Valor del stock: ${calcularValorStock()} €") } . . . }
Podemos utilizarla:
fun main() { . . . val teclado = Producto( nombre = "Teclado mecánico", precio = 59.95, stock = 10 ) teclado.mostrarInformacion() teclado.vender(3) teclado.reponer(5) teclado.mostrarInformacion() }
En este ejemplo podemos identificar claramente los dos elementos principales de un objeto.
Estado del objeto:
nombre precio stock
Comportamiento del objeto:
vender() reponer() calcularValorStock() mostrarInformacion()
Práctica 1
Crea una clase denominada:
Vehiculo
Cada vehículo debe almacenar:
- Matrícula.
- Marca.
- Modelo.
- Kilómetros.
- Nivel de combustible.
- Capacidad máxima del depósito.
Por ejemplo:
val coche = Vehiculo( matricula = "1234 ABC", marca = "Toyota", modelo = "Corolla", kilometros = 50000, combustible = 30.0, capacidadDeposito = 50.0 )
La clase debe proporcionar las siguientes funciones:
mostrarInformacion() repostar(litros) realizarViaje(kilometros, consumo)
La función `repostar()` debe comprobar:
- Que la cantidad sea positiva.
- Que el depósito no supere su capacidad máxima.
Por ejemplo, si quedan 40 litros en un depósito de 50 y hacemos:
coche.repostar(20.0)
el depósito deberá quedar en:
50 litros
La función realizarViaje(kilometros, consumo) recibirá:
- Distancia que se quiere recorrer.
- Consumo del vehículo en litros cada 100 km.
Por ejemplo:
coche.realizarViaje( kilometros = 200, consumo = 6.5 )
El programa deberá calcular el combustible necesario:
combustibleNecesario = kilometros * consumo / 100
Si existe combustible suficiente:
- Restar el combustible consumido.
- Aumentar el kilometraje del vehículo.
- Mostrar un mensaje indicando que el viaje se ha realizado.
Si no existe combustible suficiente:
- No modificar el kilometraje.
- No modificar el combustible.
- Mostrar un mensaje indicando que no existe combustible suficiente.
Además, utiliza un bloque `init` para comprobar que:
- Los kilómetros iniciales no sean negativos.
- El combustible no sea negativo.
- La capacidad del depósito sea mayor que cero.
Finalmente, crea al menos tres vehículos diferentes y realiza distintas operaciones sobre ellos.
Práctica 2
Crea una clase Alumno que almacene:
nombre apellidos edad notas
Las notas pueden almacenarse en:
val notas = mutableListOf<Double>()
La clase deberá proporcionar las funciones:
agregarNota(nota) calcularMedia() estaAprobado() mostrarInformacion()
agregarNota()solamente deberá aceptar valores entre `0` y `10`.calcularMedia()devolverá la media de las notas del alumno.estaAprobado()devolverá un `Boolean` indicando si la media es igual o superior a `5`.mostrarInformacion()deberá producir una salida similar a:
Alumno: Ana García Edad: 20 Notas: 7.5, 8.0, 6.5, 9.0 Media: 7.75 Resultado: APROBADO
Crea posteriormente varios objetos `Alumno`, añade diferentes notas y muestra sus resultados.
Colecciones: List, Set y Map
Kotlin proporciona diferentes tipos de colecciones para almacenar y gestionar conjuntos de datos.
Las principales son:
| Colección | Características |
|---|---|
List | Colección ordenada de elementos. Permite elementos repetidos y acceso mediante índices. |
Set | Colección de elementos únicos. No permite elementos duplicados. |
Map | Almacena parejas formadas por una clave y un valor. Cada clave es única. |
Las colecciones pueden ser de solo lectura o mutables:
- Las colecciones de solo lectura permiten consultar y recorrer sus elementos, pero no añadirlos, modificarlos o eliminarlos.
- Las colecciones mutables permiten modificar su contenido.
Listas
Una List almacena una colección ordenada de elementos. Cada elemento ocupa una posición o índice, comenzando por 0.
Listas de solo lectura
Podemos crear una lista utilizando listOf():
val nombres = listOf("Ana", "Luis", "Marta", "Pedro")
Podemos acceder a sus elementos mediante su índice:
println(nombres[0]) println(nombres[2])
También podemos consultar algunas propiedades y funciones:
println(nombres.size) println(nombres.first()) println(nombres.last()) println(nombres.contains("Marta"))
Una lista creada con listOf() es de solo lectura, por lo que no podemos modificar su contenido:
// Error nombres.add("Laura")
| Método | Descripción | Ejemplo |
|---|---|---|
add(elemento) | Añade un elemento al final de la lista | lista.add(“Ana”) |
add(indice, elemento) | Inserta un elemento en una posición concreta | lista.add(1, “Luis”) |
addAll(coleccion) | Añade varios elementos | lista.addAll(otraLista) |
remove(elemento) | Elimina la primera aparición del elemento indicado | lista.remove(“Ana”) |
removeAt(indice) | Elimina el elemento situado en una posición | lista.removeAt(0) |
clear() | Elimina todos los elementos | lista.clear() |
contains(elemento) | Comprueba si un elemento está en la lista | lista.contains(“Ana”) |
indexOf(elemento) | Devuelve la posición de un elemento, o -1 si no existe | lista.indexOf(“Ana”) |
isEmpty() | Indica si la lista está vacía | lista.isEmpty() |
size | Devuelve el número de elementos | lista.size |
get(indice) | Devuelve el elemento de una posición | lista.get(0) |
set(indice, elemento) | Sustituye el elemento de una posición | lista.set(0, “Pedro”) |
Listas mutables
Cuando necesitamos añadir, modificar o eliminar elementos podemos utilizar mutableListOf():
val nombres = mutableListOf("Ana", "Luis", "Marta")
Podemos añadir elementos mediante add():
nombres.add("Pedro")
También podemos modificar un elemento utilizando su índice:
nombres[1] = "Carlos"
Para eliminar elementos podemos utilizar remove() o removeAt():
// Elimina el elemento indicado nombres.remove("Marta") // Elimina el elemento situado en la posición indicada nombres.removeAt(0)
También podemos eliminar todos los elementos:
nombres.clear()
A diferencia de los arrays, cuyo tamaño se establece al crearlos, una MutableList puede cambiar de tamaño durante la ejecución del programa al añadir o eliminar elementos.
Crear una lista mutable vacía
Es habitual crear inicialmente una lista vacía e ir añadiendo elementos durante la ejecución del programa.
Para crear una lista mutable vacía debemos indicar el tipo de elementos que almacenará:
val alumnos = mutableListOf<Alumno>()
Por ejemplo, utilizando una data class:
data class Alumno( val nombre: String, val edad: Int ) val alumnos = mutableListOf<Alumno>() alumnos.add(Alumno("Ana", 20)) alumnos.add(Alumno("Luis", 22)) alumnos.add(Alumno("Marta", 19)) for (alumno in alumnos) { println("${alumno.nombre} - ${alumno.edad} años") }
También podemos eliminar objetos de la lista:
alumnos.removeAt(1)
o buscar un objeto y eliminarlo:
val alumno = alumnos.find { it.nombre == "Ana" } // la función find() puede devolver null, así que compruebo if (alumno != null) { alumnos.remove(alumno) }
Recorrer una lista
La forma más sencilla de recorrer una lista es mediante un bucle for:
val nombres = mutableListOf("Ana", "Luis", "Marta") for (nombre in nombres) { println(nombre) }
Si necesitamos conocer también el índice podemos utilizar indices:
for (i in nombres.indices) { println("$i - ${nombres[i]}") }
Por ejemplo:
0 - Ana 1 - Luis 2 - Marta
Ejercicio 9
Crea la siguiente data class:
data class Videojuego( val titulo: String, val precio: Double )
Crea una lista mutable vacía de videojuegos, añade tres videojuegos y recorre posteriormente la lista mostrando el título y precio de cada uno.
Iteradores
Internamente, las colecciones pueden recorrerse mediante un iterador (Iterator), que permite acceder secuencialmente a sus elementos.
Cuando utilizamos un bucle for, Kotlin utiliza un iterador de forma implícita:
for (nombre in nombres) { println(nombre) }
También podemos crear el iterador explícitamente:
val iterador = nombres.iterator() while (iterador.hasNext()) { println(iterador.next()) }
En las colecciones mutables, un MutableIterator permite además eliminar elementos mientras recorremos la colección:
val numeros = mutableListOf(10, 20, 30, 40) val iterador = numeros.iterator() while (iterador.hasNext()) { val numero = iterador.next() if (numero == 20) { iterador.remove() } } println(numeros)
Resultado:
[10, 30, 40]
Ejemplo completo
val lenguajes = mutableListOf("Kotlin", "Java", "Python") // Añadir lenguajes.add("C#") // Modificar lenguajes[1] = "JavaScript" // Eliminar lenguajes.remove("Python") // Recorrer for (lenguaje in lenguajes) { println(lenguaje) }
Utilizaremos List cuando únicamente necesitemos consultar los datos y MutableList cuando necesitemos añadir, modificar o eliminar elementos.
Ejercicio 10
Partiendo de una lista mutable de nombres:
val nombres = mutableListOf("Ana", "Luis", "Marta", "Pedro")
Realiza las siguientes operaciones:
- Añade
Laura. - Elimina
Luis. - Modifica
MartaporMaría.
Recorre y muestra la lista resultante.
Sets
Un Set es una colección que no permite elementos duplicados.
Podemos crear uno mediante setOf():
val numeros = setOf(10, 20, 30, 20) println(numeros)
Resultado:
[10, 20, 30]
Para poder modificar su contenido utilizamos mutableSetOf():
val numeros = mutableSetOf(10, 20, 30) numeros.add(40) numeros.remove(20) for (numero in numeros) { println(numero) }
Maps
Un Map almacena datos mediante parejas clave-valor.
Por ejemplo, podemos relacionar el nombre de un alumno con su nota:
val notas = mapOf( "Ana" to 8, "Luis" to 6, "Marta" to 9 ) println(notas["Ana"])
Para modificar su contenido utilizamos mutableMapOf():
val notas = mutableMapOf( "Ana" to 8, "Luis" to 6 ) // Añadir notas["Marta"] = 9 // Modificar notas["Luis"] = 7 // Eliminar notas.remove("Ana")
Podemos recorrer sus claves y valores mediante:
for ((nombre, nota) in notas) { println("$nombre tiene un $nota") }
Resumen
| Necesidad | Colección recomendada |
|---|---|
| Elementos ordenados y accesibles mediante índice | List / MutableList |
| Elementos sin duplicados | Set / MutableSet |
| Asociar claves con valores | Map / MutableMap |
Práctica 3
Crear una aplicación que me permita almacenar elementos de tipo Alumno en una lista. Los Alumnos tendrán nombre y apellidos, dni, y calificación.
El programa me permitirá mediante un menú, almacenar nuevos alumnos pidiéndome sus datos, listar todos los alumnos, eliminar alumnos a partir del dni, listar alumnos aprobados, y modificar calificación de un alumno a partir de su dni.
Como en programas anteriores, el programa no debe terminar hasta que en el menú de texto seleccione la opción de salir del programa.
Excepciones
Una excepción es un problema que se produce durante la ejecución de un programa y que puede interrumpir su funcionamiento normal.
Por ejemplo, pueden producirse excepciones al:
Dividir un número entero entre cero.
Acceder a una posición inexistente de un array.
Convertir a número una cadena que no contiene un valor numérico.
Intentar utilizar incorrectamente un valor null.
Por ejemplo:
val numero = "Hola".toInt()
La conversión no puede realizarse y se producirá una excepción de tipo NumberFormatException.
En Kotlin, las excepciones derivan de la clase Exception, que a su vez deriva de Throwable.
Capturar excepciones: try-catch
Podemos controlar una excepción mediante los bloques try y catch.
En try escribimos el código que puede producir la excepción.
En catch indicamos qué hacer si se produce.
try { val numero = "Hola".toInt() println(numero) } catch (e: NumberFormatException) { println("No se ha podido convertir el texto a un número") }
Si la conversión produce una NumberFormatException, la ejecución pasa al bloque catch en lugar de finalizar bruscamente el programa.
Un ejemplo habitual al solicitar datos al usuario sería:
try { print("Introduce tu edad: ") val edad = readln().toInt() println("Tu edad es $edad") } catch (e: NumberFormatException) { println("Debes introducir un número entero") }
Capturar diferentes excepciones
Un mismo bloque try puede tener varios bloques catch:
try { val numeros = arrayOf(10, 20, 30) print("Introduce una posición: ") val posicion = readln().toInt() println(numeros[posicion]) } catch (e: NumberFormatException) { println("Debes introducir un número") } catch (e: IndexOutOfBoundsException) { println("La posición no existe") }
Cada catch se encarga de un tipo concreto de excepción.
El bloque finally
Opcionalmente podemos añadir un bloque finally.
El código contenido en finally se ejecutará siempre, tanto si se produce una excepción como si no.
try { val numero = readln().toInt() println("Número: $numero") } catch (e: NumberFormatException) { println("Valor incorrecto") } finally { println("Fin de la operación") }
La estructura general es:
try { // Código que puede producir una excepción } catch (e: TipoDeExcepcion) { // Tratamiento de la excepción } finally { // Código que se ejecuta siempre }
El bloque finally es especialmente útil cuando necesitamos liberar recursos independientemente de si una operación termina correctamente o produce una excepción.
Ejercicio 12
Solicita al usuario dos números enteros y realiza su división. Utiliza try-catch para controlar:
NumberFormatExceptionsi el usuario no introduce un número.ArithmeticExceptionsi intenta dividir entre cero.
Muestra un mensaje diferente para cada error.
Lanzar excepciones: throw
También podemos provocar nosotros mismos una excepción utilizando throw.
Por ejemplo:
fun establecerEdad(edad: Int) { if (edad < 0) { throw IllegalArgumentException("La edad no puede ser negativa") } println("Edad: $edad") }
Al ejecutar lo siguiente, se lanzará una IllegalArgumentException.
establecerEdad(-5)
Funciones condicionales
| Función de precondición | Utilidad | Excepción generada |
|---|---|---|
require() | Comprueba que los argumentos o datos de entrada sean válidos. | IllegalArgumentException |
check() | Comprueba que el estado de un objeto o variable sea válido. | IllegalStateException |
error() | Indica que el programa ha alcanzado un estado o condición no válida. | IllegalStateException |
Kotlin proporciona la función require() para comprobar que un argumento cumple una determinada condición.
Por ejemplo:
class Persona( val nombre: String, val edad: Int ) { init { require(edad >= 0) } }
Si intentamos crear:
val persona = Persona("Ana", -5)
require() lanzará automáticamente una IllegalArgumentException. Esta forma resulta especialmente útil para comprobar que los datos recibidos por una función o constructor son válidos.
La función require() y la función check()permite al compilador realizar una conversión inteligente de tipos (smart cast).
Después de que la comprobación se realice correctamente, la variable se convierte automáticamente a un tipo que no admite valores nulos (non-nullable).
Esta función se utiliza frecuentemente para realizar comprobaciones de nulabilidad y garantizar que una variable no contiene null antes de continuar con su utilización.
Por ejemplo:
fun imprimirCadenaNoNula(texto: String?) { // Comprobamos que texto no sea null require(texto != null) // Después de la comprobación, Kotlin sabe que 'texto' // no puede ser null y realiza automáticamente un smart cast // de String? a String println(texto.length) }
Excepciones habituales
Algunas excepciones que podemos encontrar frecuentemente son:
| Excepción | Situación habitual |
|---|---|
ArithmeticException | Operación aritmética no válida, como una división entera entre cero. |
NumberFormatException | Conversión a número de una cadena con formato incorrecto. |
IndexOutOfBoundsException | Acceso a una posición inexistente de un array o colección. |
NoSuchElementException | Intento de obtener un elemento que no existe. |
IllegalArgumentException | Se proporciona un argumento que no cumple las condiciones requeridas. |
IllegalStateException | El objeto o programa se encuentra en un estado no válido para realizar una operación. |
NullPointerException | Se intenta utilizar una referencia null de forma no segura. |
Evitar excepciones
Aunque podemos capturar excepciones mediante try-catch, siempre que sea posible es preferible evitar que se produzcan.
Kotlin proporciona diferentes funciones para realizar operaciones de forma segura.
Por ejemplo, en lugar de:
val numero = texto.toInt()
podemos utilizar:
val numero = texto.toIntOrNull()
Si la conversión no es posible, se obtiene null en lugar de producirse una NumberFormatException.
De forma similar, para acceder de forma segura a una colección podemos utilizar:
val elemento = lista.getOrNull(posicion)
Las excepciones deben utilizarse para gestionar situaciones excepcionales. No deberían sustituir a las comprobaciones normales que podemos realizar en nuestro programa.
Ejemplo completo
El siguiente programa solicita dos números enteros y realiza una división:
fun main() { try { print("Introduce el dividendo: ") val dividendo = readln().toInt() print("Introduce el divisor: ") val divisor = readln().toInt() val resultado = dividendo / divisor println("Resultado: $resultado") } catch (e: NumberFormatException) { println("Debes introducir números enteros") } catch (e: ArithmeticException) { println("No se puede dividir entre cero") } finally { println("Operación finalizada") } }
Valores nulos (Null safety)
El valor null representa la ausencia de un valor.
Uno de los problemas habituales en muchos lenguajes de programación es intentar acceder a una propiedad o función de una variable cuyo valor es null, provocando una excepción NullPointerException (NPE).
Kotlin incorpora un sistema denominado Null Safety que diferencia entre variables que pueden contener null y variables que no pueden contenerlo.
Tipos no nulables
Por defecto, las variables en Kotlin no pueden contener null.
Por ejemplo:
var nombre: String = "Ana" // Error de compilación nombre = null
La variable nombre es de tipo String, por lo que siempre debe contener un valor válido de ese tipo.
Esto permite utilizar sus propiedades y funciones directamente:
println(nombre.length)
Tipos nulables
Si queremos permitir que una variable pueda contener null, debemos añadir ? al tipo:
var nombre: String? = "Ana" nombre = null
Por tanto:
String → No puede contener null String? → Puede contener un String o null Int → No puede contener null Int? → Puede contener un Int o null
Cuando una variable es nulable, Kotlin no permite acceder directamente a sus propiedades:
val nombre: String? = null // Error // println(nombre.length)
Antes debemos gestionar la posibilidad de que contenga null.
Comprobar null mediante if
La forma más sencilla consiste en comprobar explícitamente el valor:
val nombre: String? = "Ana" if (nombre != null) { println(nombre.length) }
Después de comprobar que nombre no es null, Kotlin sabe que dentro del bloque puede tratarlo como un String normal.
Esto es un ejemplo de smart cast:
String? → String
Operador de llamada segura ?.
Kotlin proporciona el operador ?. para acceder de forma segura a una propiedad o función.
val nombre: String? = "Ana" println(nombre?.length)
Si nombre contiene un valor, se ejecutará:
nombre.length
Si nombre es null, la expresión devolverá null en lugar de producir una excepción.
Por ejemplo:
val nombre: String? = null println(nombre?.length)
Resultado:
null
También podemos encadenar varias llamadas seguras:
persona?.direccion?.ciudad?.nombre
Si cualquiera de los elementos de la cadena es null, el resultado completo será null.
Operador Elvis ?:
El operador Elvis ?: permite proporcionar un valor alternativo cuando una expresión devuelve null.
Por ejemplo:
val nombre: String? = null val nombreMostrar = nombre ?: "Desconocido" println(nombreMostrar)
Resultado:
Desconocido
También podemos combinarlo con ?.:
val nombre: String? = null val longitud = nombre?.length ?: 0
Esto significa:
- Si
nombreno esnull, obtiene su longitud. - Si
nombreesnull, utiliza0.
Por tanto, esta expresión:
val longitud = nombre?.length ?: 0
es una forma más concisa de escribir:
val longitud = if (nombre != null) { nombre.length } else { 0 }
Operador !!
El operador !! indica a Kotlin que estamos seguros de que una variable no contiene null.
val nombre: String? = "Ana" println(nombre!!.length)
Kotlin tratará nombre como un valor no anulable.
Sin embargo, si realmente contiene null:
val nombre: String? = null println(nombre!!.length)
se producirá una NullPointerException.
El operador !! elimina la protección que proporciona el sistema de Null Safety de Kotlin.
Debe utilizarse únicamente cuando podamos garantizar que el valor no será null. Siempre que sea posible, es preferible utilizar ?., ?: o realizar una comprobación explícita.
Resumen de operadores
| Sintaxis | Significado |
|---|---|
String | El valor no puede ser null. |
String? | El valor puede ser un String o null. |
variable != null | Comprueba explícitamente que el valor no sea null. |
?. | Accede de forma segura. Si el valor es null, devuelve null. |
?: | Proporciona un valor alternativo cuando el resultado es null. |
!! | Fuerza a tratar el valor como no nulo. Puede producir una NullPointerException. |
Control de entrada de datos
Una vez que conocemos las características de las variables nulables, podemos añadir un nuevo mecanismo a la hora de validar datos leídos desde teclado.
Con las siguientes funciones puedo asegurarme de que el dato introducido es el esperado, o null en caso contrario:
| Descripción | Tipo devuelto | Funcionamiento |
|---|---|---|
toByteOrNull() | Byte? | Convierte el texto a Byte. Devuelve null si no es válido o está fuera de rango. |
toShortOrNull() | Short? | Convierte el texto a Short. Devuelve null si no es válido o está fuera de rango. |
toIntOrNull() | Int? | Convierte el texto a Int. Devuelve null si no es válido o está fuera de rango. |
toLongOrNull() | Long? | Convierte el texto a Long. Devuelve null si no es válido o está fuera de rango. |
toFloatOrNull() | Float? | Convierte el texto a Float. Devuelve null si no tiene un formato numérico válido. |
toDoubleOrNull() | Double? | Convierte el texto a Double. Devuelve null si no tiene un formato numérico válido. |
toBooleanStrictOrNull() | Boolean? | Convierte “true” o “false” a Boolean. Devuelve null para cualquier otro valor. |
Un ejemplo muy útil para explicar su relación con Null Safety sería:
print("Introduce tu edad: ") val edad = readln().toIntOrNull() if (edad == null) { println("Debes introducir un número entero válido") } else { println("Tu edad es $edad") }
También puedes combinarlo con el operador Elvis ?:
print("Introduce tu edad: ") val edad = readln().toIntOrNull() ?: 0 println("Edad: $edad")
Para validar que el usuario ha introducido exactamente un carácter se puede utilizar, por ejemplo, singleOrNull():
val caracter: Char? = readln().singleOrNull()
Así, “A” devuelve el carácter 'A', mientras que una entrada como “Hola” devuelve null.
Ejemplo
fun mostrarUsuario(nombre: String?, edad: Int?) { ``` val nombreMostrar = nombre ?: "Desconocido" println("Nombre: $nombreMostrar") if (edad != null) { println("Edad: $edad") println("El año que viene tendrá ${edad + 1} años") } else { println("Edad no especificada") } ``` } fun main() { ``` mostrarUsuario("Ana", 20) mostrarUsuario(null, null) ``` }
Como regla general, debemos intentar utilizar tipos no anulables siempre que sea posible y declarar un tipo como nullable (?) únicamente cuando la ausencia de un valor sea una situación válida dentro de nuestro programa.
Ejercicio 13
Solicita al usuario su edad y utiliza:
val edad = readln().toIntOrNull()
Si edad contiene null, muestra:
Edad incorrecta
En caso contrario, indica si el usuario es mayor o menor de edad.
No utilices el operador !!.
Herencia
La herencia permite crear una clase a partir de otra existente, reutilizando y especializando su comportamiento.
La clase de la que se hereda se denomina clase padre o superclase, mientras que la nueva clase se denomina clase hija o subclase.
Por ejemplo, podemos tener una clase general Animal y crear clases más específicas como Perro y Gato.
Heredar de una clase
Por defecto, las clases en Kotlin son final, lo que significa que por defecto no se puede heredar de ellas. Para permitir la herencia debemos utilizar la palabra open:
open class Animal(val nombre: String) { fun comer() { println("$nombre está comiendo") } }
Para crear una clase que herede de Animal utilizamos el caracter ':'
class Perro(nombre: String) : Animal(nombre) { fun ladrar() { println("$nombre está ladrando") } }
Ahora Perro dispone tanto de sus propios miembros como de los heredados de Animal:
val perro = Perro("Toby") perro.comer() perro.ladrar()
En este ejemplo:
Animal → superclase Perro → subclase
Sobrescribir métodos
Una subclase puede proporcionar su propia implementación de un método heredado.
Para permitirlo, el método de la clase padre debe declararse como open:
open class Animal(val nombre: String) { open fun hacerSonido() { println("El animal hace un sonido") } }
La clase hija utiliza override para sobrescribirlo:
class Perro(nombre: String) : Animal(nombre) { override fun hacerSonido() { println("$nombre dice: Guau!") } }
Por ejemplo:
val perro = Perro("Toby") perro.hacerSonido()
Salida:
Toby dice: Guau!
En Kotlin, las clases y sus métodos son final por defecto.
Debemos utilizar:
openpara permitir que una clase pueda heredarse.openpara permitir que un método pueda sobrescribirse.overridepara sobrescribir un método heredado.
Sobrescribir propiedades
Las propiedades también pueden declararse como open y sobrescribirse en las clases hijas:
open class Animal { open val sonido = "Sonido desconocido" } class Perro : Animal() { override val sonido = "Guau" }
Palabra clave super
La palabra super permite acceder a miembros de la clase padre desde una clase hija.
open class Animal { open fun hacerSonido() { println("Sonido de animal") } } class Perro : Animal() { override fun hacerSonido() { super.hacerSonido() println("Guau") } }
En este caso, super.hacerSonido() ejecuta primero la implementación definida en Animal.
Jerarquías de clases
Una misma clase padre puede tener diferentes clases hijas:
open class Animal(val nombre: String) { open fun hacerSonido() { println("Sonido de animal") } } class Perro(nombre: String) : Animal(nombre) { override fun hacerSonido() { println("$nombre dice: Guau") } } class Gato(nombre: String) : Animal(nombre) { override fun hacerSonido() { println("$nombre dice: Miau") } }
Podemos tratar objetos de las clases hijas como objetos de la clase padre:
val animales = arrayOf( Perro("Toby"), Gato("Misi") ) for (animal in animales) { animal.hacerSonido() }
Salida:
Toby dice: Guau Misi dice: Miau
Aunque todos los elementos se tratan como Animal, cada objeto ejecuta su propia versión de hacerSonido(). Este comportamiento es una de las bases del polimorfismo.
La herencia establece una relación de tipo “es un”.
Un Perro es un Animal, por lo que la herencia tiene sentido.
Sin embargo, no deberíamos utilizar herencia únicamente para reutilizar código cuando no existe esta relación entre las clases.
Ejercicio 14
Crea una clase Vehiculo con una propiedad marca y una función mostrarInformacion().
Crea una clase Coche que herede de Vehiculo y sobrescriba mostrarInformacion() para mostrar:
Coche de la marca Toyota
Recuerda utilizar open y override donde corresponda.
Clases abstractas
Una clase abstracta es una clase diseñada para servir como base para otras clases.
Se declara utilizando la palabra abstract:
abstract class Animal { // ... }
Una clase abstracta no puede ser instanciada directamente:
abstract class Animal // Error val animal = Animal()
Su objetivo es definir características y comportamientos comunes que serán heredados por otras clases.
Métodos abstractos
Una clase abstracta puede contener métodos abstractos, es decir, métodos que se declaran pero no proporcionan una implementación.
abstract class Animal(val nombre: String) { abstract fun hacerSonido() }
Las clases que hereden de Animal deberán implementar el método utilizando override:
class Perro(nombre: String) : Animal(nombre) { override fun hacerSonido() { println("$nombre dice: Guau") } } class Gato(nombre: String) : Animal(nombre) { override fun hacerSonido() { println("$nombre dice: Miau") } }
Podemos utilizar estas clases normalmente:
val perro = Perro("Toby") val gato = Gato("Misi") perro.hacerSonido() gato.hacerSonido()
Miembros abstractos y no abstractos
Una clase abstracta puede combinar miembros abstractos, que deberán ser implementados por las clases hijas, con miembros que ya tienen una implementación.
abstract class Animal(val nombre: String) { abstract fun hacerSonido() fun dormir() { println("$nombre está durmiendo") } }
En este caso:
hacerSonido()es abstracto y cada clase hija debe implementarlo.dormir()ya tiene una implementación que pueden utilizar todas las clases hijas.
Las propiedades también pueden ser abstractas:
abstract class Animal { abstract val nombre: String abstract fun hacerSonido() } class Perro( override val nombre: String ) : Animal() { override fun hacerSonido() { println("$nombre dice: Guau") } }
Los miembros declarados como abstract no necesitan utilizar open, ya que están diseñados para ser sobrescritos obligatoriamente por las clases hijas.
¿Cuándo utilizar una clase abstracta?
Una clase abstracta resulta útil cuando varias clases comparten características comunes, pero queremos dejar parte de su comportamiento para que sea definido por cada clase hija.
Por ejemplo:
Animal
|
+-------+-------+
| |
Perro Gato
| |
hacerSonido() hacerSonido()
"Guau" "Miau"
Una clase abstracta permite definir una base común para varias clases, estableciendo qué propiedades o métodos deben implementar obligatoriamente sus subclases.
Ejercicio 14 - Herencia
Crea una clase Vehiculo con una propiedad marca y una función mostrarInformacion().
Crea una clase Coche que herede de Vehiculo y sobrescriba mostrarInformacion() para mostrar:
Coche de la marca Toyota
Recuerda utilizar open y override donde corresponda.
Ejercicio 15
Crea una clase abstracta Figura que contenga:
abstract fun calcularArea(): Double
Crea una clase Rectangulo que herede de Figura y reciba base y altura.
© 2026 Fernando Valdeón
