Kotlin ¿Sabías qué?
23-09-2024
Kotlin es un lenguaje de programación moderno, eficiente y diseñado para ser sencillo y seguro. Desde su introducción, ha ganado popularidad debido a su interoperabilidad con Java y su capacidad para escribir código conciso y expresivo. A continuación, exploraremos algunas de las características clave de Kotlin que lo hacen destacar, y cómo estas pueden mejorar la experiencia de desarrollo.
Variables: val
y var
En Kotlin, existen dos tipos principales de variables: val
y var
. La diferencia fundamental es que val
define variables inmutables, es decir, una vez asignado un valor no se puede cambiar, mientras que var
permite la reasignación de valores. Además, Kotlin introduce la palabra clave lateinit
, que indica que una variable var
se inicializará más tarde, lo que es útil cuando la inicialización inmediata no es posible.
Puedes verificar si una variable lateinit
ha sido inicializada utilizando el siguiente código, siempre que la variable no sea de un tipo primitivo:
lateinit var foo: String if(::foo.isInitialized) { }
Template strings
Los nombres de los métodos pueden utilizar template strings
, lo que aporta mucho valor a la hora de definir los tests:
@Test fun `should do something`() { // ... }
Raw Strings
Se pueden escribir Strings sin procesar (raw strings) utilizando comillas triples ("""
), facilitando trabajar con cadenas de texto que incluyan saltos de línea, sin utilizar caracteres de escape.
val text = """ Hello World using multiline """
Data classes
Existe un tipo de objeto denominado data-class
para almacenar datos, que implementa los métodos equals
, hashCode
y toString
, lo que facilita la manipulación y comparación de objetos sin tener que escribir mucho código adicional.:
data class BookingCustomData( val reference: String, val origin: String, val status: String, val killSlots: String, val noOfPackages: String, val cargoType: String, val weight: String, val contractRef: String ) val booking = BookingCustomData("ABC123", "Spain", "Pending", "General") println(booking) // Output: BookingCustomData(reference=ABC123, origin=Spain, status=Pending, cargoType=General)
Companion Objects en lugar de métodos estáticos
No existen los métodos estáticos, en su lugar existen los denominados companion objects
, que funcionan como objetos singleton asociados a la clase, limitados a contener dichos miembros estáticos:
class Irrelevant { companion object { const val USERNAME1 = "USERNAME1" fun addTenTo(val number: Int): Int { return number + 10 } } } /*...*/ val aUserName = Irrelevant.USERNAME1 // aUserName = "USERNAME1" val newNumber = Irrelevant.addTenTo(10) // newNumber = 20
Herencia
Por defecto, las clases están declaradas como final y no se pueden sobrescribir los métodos en clases derivadas. Es por ello, que para permitir la herencia, es a través de la palabra clave open
. Se puede limitar la sobrescritura de métodos por clases nietas, aplicando en las clases hijas la palabra final
:
open class Parent() { open fun foo (): String { return "Hello from Parent" } } class Child: Parent() { final override fun foo (): String { return "Hello from Child" } } class GrandChild: Child() { override fun foo (): String { // Error: 'foo' in 'Child' is final and cannot be overridden return "Hello from GrandChild" } }
Extensiones en interfaces y objetos
Tanto las interfaces como los companion objects pueden tener propiedades de extensión. Estas funciones extendidas pueden añadirse desde fuera de la clase original, sin necesidad de modificar su código:
// Interface val <T> List<T>.lastIndex: Int get() = size - 1 // Companion fun Irrelevant.Companion.print() { println("Irrelevant") }
Generics
En un objeto basado en templates
, Kotlin permite restringir cómo se pueden usar los tipos genéricos en clases o interfaces, utilizando las anotaciones in
y out
:
out
se utiliza para tipos covariantes. De esta forma indicamos que se pueden devolver pero no recibir como argumento de entrada.in
, en cambio, se utiliza para contravariantes. Esto significa que se pueden recibir como argumento, pero no retornarlo.
interface BaseUseCase<in I, out O> { fun execute(input: I): O }
Sobrecarga de Operadores
De la misma forma, puedes sobrecargar operadores utilizando la palabra reservada operator
. Así, se puede por ejemplo, sobrecargar el operador invoke
para así definir una llamada directa a la instancia y que sea el método por defecto, y por tanto, la instancia de la clase actúe como una función:
interface BaseUseCase<in I, out O> { operator fun invoke(input: I): O } class MyUseCase: BaseUseCase { override fun invoke(input: String): String { return "Say: $input" } } fun main() { val useCase = MyUseCase() println(useCase("Hello World")) }
Funciones de Alcance (Scope Functions)
Todos los métodos pueden usar el operador let
, also
y run
, los cuales forman parte de las denominadas scope functions, para encadenar operaciones (programación funcional) y evitar estructuras de control explícitas. Un ejemplo muy bueno es cuando existen propiedades opcionales.
En el siguiente caso, el valor devuelto por findByCode
solo se pasará a delete (como el parámetro it
), si este no es nulo:
customerRepository .findByCode(customers[0].code) ?.let { customerRepository.delete(it) }
Uso de rangos
El concepto de range
permite definir rangos de valores de manera simple, tanto numéricos como de caracteres:
for (i in 1..3) { } for (i in 'a'..'z') { }
Pattern Matching
En Kotlin existe el concepto de Pattern Matching
. Es un sustituto del clásico switch
, el cual es especialmente útil ya que:
- No requiere un
break
explícito para detener la ejecución, ya que sólo se ejecuta la rama correspondiente:
when (x) { 1 -> print("value is 1") 2 -> print("value is 2") else -> print("value is not 1 or 2") }
- Podemos aplicar los rangos de valores dentro de una estructura de pattern matching, para llegar a evaluaciones complejas como la siguiente:
val extractResult = when (id) { in 1..2 -> foo() 3, 4, 5, 6, 30 -> bar() in 23..24 -> otherStuff() else -> differentStuff() }
Sealed Classes
Las sealed classes o clases selladas, son una extensión del concepto de clases jerárquicas, que, a diferencia de las clases convencionales, permiten restringir las posibles subclases a un conjunto definido en tiempo de compilación. Esto es útil por ejemplo, en aquellas situaciones donde tienes un conjunto definido de funcionalidades que puedan ser representadas.
Combinando esto con el pattern matching explicado en el punto anterior, tenemos una herramienta poderosa que nos avisará en el momento en el que no hayamos implementado, por ejemplo, una lógica de negocio:
sealed class Subscription { data class Monthly(): Subscription() data class Yearly(): Subscription() data class Quarterly(): Subscription() } fun processSubscription(subscription: Subscription) { when (subscription) { is Subscription.Monthly -> processMonthlySubscription() is Subscription.Yearly -> processYearlySubscription() } // Error: Missing Quarterly case } ```kotlin ## TODO para marcar operaciones no implementadas Existe la palabra reservada `TODO`, que en sí, es una función que devuelve `Nothing` y lanza una excepción, permitiendo así marcar lugares en el código que aún no están listos, y facilitando compilar y ejecutar la aplicación sin problemas: ```kotlin fun TODO(): Nothing = throw NotImplementedError()
Contratos y Smart Cast
Tenemos el denominado contract
, una característica experimental y avanzada del lenguaje, los cuales aportan información adicional al compilador sobre el flujo del programa y el manejo de tipos. Con esto nos referimos a aquellas partes que la persona que desarrolla el código conoce, pero el compilador es incapaz de inferir.
Por ejemplo: cuando hacemos una comprobación de que algo no es nulo, por contrato el compilador sabe que puede hacer un smart-cast
al tipo Not-nullable
.
public inline fun CharSequence?.osNullOrEmpty(): Boolean { contract { returns(false) implies (this@isNullOrEmpty != null) } } fun example(text: CharSequence?) { if (!text.isNullOrEmpty()) { // Ahora el compilador sabe que `text` no es nulo println(text.length) // Sin error: smart cast a CharSequence no nullable } }
Type aliases
Se pueden definir type aliases typealias
para proporcionar nombres alternativos o simplificados, para tipos complejos, haciéndolos más legibles y/o adaptables en el código:
class Irrelevant { class Foo } typealias SomethingRandom = Irrelevant.Foo val fooInstance: SomethingRandom = Irrelevant.Foo()
Delegación de propiedades
Existe un sistema de delegación de propiedades, que permite delegar la lógica de acceso y modificación de las propiedades a otras clases o funciones. Tenemos los siguientes delegados:
map
, podemos utilizar un mapa en el constructor de una clase para definir sus propiedades. De esta forma, cada valor del map se asociará a una propiedad definida en la clase, basándose en el nombre de la clave y el nombre propiedad:
class User (val map: Map<String, Any?>) { val name: String by map val age: Int by map }
lazy
nos permite inicializar la propiedad sólo cuando ésta sea accedida por primera vez. Ideal para mejorar el rendimiento en aquellas situaciones donde la inicialización de un objeto es costosa :
fun foo() { val irrelevant: Irrelevant by lazy { Irrelevant() } }
observable
, nos permite ejecutar un código cada vez que el valor de la propiedad cambie. Útil para monitorear cambios en propiedades y ejecutar alguna lógica, como actualizaciones en UI o validaciones:
class User { var name: String by observable("<no name>") { prop, old, new -> println("$old -> $new") } } fun main() { val user = User() user.name = "first" user.name = "second" } // <no name> -> first // first -> second
Con lo que hemos visto hasta aquí, podemos decir que Kotlin ofrece múltiples herramientas para escribir código eficiente y expresivo.
¿Conoces alguna otra característica que deberíamos destacar?