Herramientas de usuario

Herramientas del sitio


anexos:kotlin2

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:

  • nombre es una propiedad que no puede reasignarse.
  • edad es 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 propiedad val.
  • curso, como propiedad val.
  • nota, como propiedad var.

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:

  • titulo
  • director
  • anio

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() 
  1. agregarNota() solamente deberá aceptar valores entre `0` y `10`.
  2. calcularMedia() devolverá la media de las notas del alumno.
  3. estaAprobado() devolverá un `Boolean` indicando si la media es igual o superior a `5`.
  4. 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 Marta por Marí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:

  • NumberFormatException si el usuario no introduce un número.
  • ArithmeticException si 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 nombre no es null, obtiene su longitud.
  • Si nombre es null, utiliza 0.

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:

  • open para permitir que una clase pueda heredarse.
  • open para permitir que un método pueda sobrescribirse.
  • override para 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

anexos/kotlin2.txt · Última modificación: por fernando