Métodos de extensión
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/object-oriented-programming-kt
Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupExtensionsModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupExtensionsModule") {
description = "Creates the base module and files for the Data-Driven Testing project"
moduleName.set("extensions")
doLast {
createFiles(
packageName = "utils",
main to "StringExtensions.kt",
test to "StringExtensionsTest.kt",
main to "ListExtensions.kt",
test to "ListExtensionsTest.kt",
)
createFiles(
packageName = "connection",
main to "Host.kt",
test to "HostTest.kt",
)
createFiles(
packageName = "greet",
main to "Greeter.kt",
test to "GreeterTest.kt",
)
}
}
Preocúpate de que el plugin extensions
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupExtensionsModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
Los métodos de extensión (o funciones de extensión) son una característica poderosa que te permite añadir nuevas funciones a clases existentes sin modificar su código fuente o utilizar herencia. Esto es especialmente útil cuando quieres extender clases de librerías o APIs que no puedes cambiar directamente.
Método de extensión
Un método de extensión es una función que añade comportamiento adicional a una clase ya existente. Aunque parece que estás añadiendo un nuevo método a la clase, en realidad estás definiendo una función que opera sobre una instancia de esa clase. La sintaxis te permite llamar a esta función como si fuera un método miembro de la clase.
Sintaxis de métodos de extensión en Kotlin
La sintaxis básica para definir una función de extensión es la siguiente:
fun ClassName.methodName(parameters): ReturnType {
// Implementación de la función
}
Donde:
ClassName
es la clase que estás extendiendo.methodName
es el nombre de la nueva función.parameters
son los parámetros que la función acepta.ReturnType
es el tipo de dato que la función devuelve.
Caso de estudio: Creando un matcher como función de extensión
En la lección sobre matchers personalizados, aprendimos a crear nuestros propios matchers para hacer las pruebas más legibles, permitiendo expresiones como 2 should beEven()
. Pero, ¿cómo podemos encadenar varios matchers personalizados, al igual que los predefinidos de Kotest?
Vamos a definir cómo queremos que se vea la sintaxis de estos matchers personalizados dentro de un test:
- Código esencial
- Código completo
checkAll(
Arb.int(0..1000).map { it * 2 }
) { evenNumber ->
evenNumber
.shouldBeEven()
.shouldNotBeNegative()
}
checkAll(
Arb.negativeInt().filter { it % 2 != 0 }
) { oddOrNegativeNumber ->
shouldThrow<AssertionError> {
oddOrNegativeNumber
.shouldBeEven()
.shouldNotBeNegative()
}
}
package cl.ravenhill.matchers
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.filter
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.negativeInt
import io.kotest.property.checkAll
class IntMatcherChainingTest : FreeSpec({
"A number" - {
"when testing if it is even and greater or equal to 0" - {
"should pass if the number is multiple of 2 and non-negative" {
checkAll(
Arb.int(0..1000).map { it * 2 }
) { evenNumber ->
evenNumber
.shouldBeEven()
.shouldNotBeNegative()
}
}
"should fail if the number is odd or negative" {
checkAll(
Arb.negativeInt().filter { it % 2 != 0 }
) { oddOrNegativeNumber ->
shouldThrow<AssertionError> {
oddOrNegativeNumber
.shouldBeEven()
.shouldNotBeNegative()
}
}
}
}
}
})
Para hacer esto posible, necesitamos que nuestras funciones de matcher devuelvan el mismo valor de tipo Int
y permitan encadenar llamadas. Esto implica definir las funciones con el tipo Int.() -> Int
.
Definición de los matchers personalizados
A continuación se muestra cómo podemos crear nuestras funciones de extensión para permitir el encadenamiento de matchers:
- Código esencial
- Código completo
fun Int.shouldBeEven(): Int {
this should beEven()
return this
}
fun Int.shouldNotBeNegative(): Int {
this shouldNot beNegative()
return this
}
package cl.ravenhill.matchers
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.should
import io.kotest.matchers.shouldNot
fun beEven() = Matcher<Int> { actual ->
MatcherResult(
actual % 2 == 0,
{ "Expected $actual to be even" },
{ "Expected $actual to be odd" }
)
}
fun beNegative() = Matcher<Int> { actual ->
MatcherResult(
actual < 0,
{ "Expected $actual to be negative" },
{ "Expected $actual to be non-negative" }
)
}
fun Int.shouldBeEven(): Int {
this should beEven()
return this
}
fun Int.shouldNotBeNegative(): Int {
this shouldNot beNegative()
return this
}
Hemos definido dos funciones de extensión para Int
que permiten encadenar matchers personalizados:
shouldBeEven()
: Verifica si un número es par.shouldNotBeNegative()
: Verifica si un número no es negativo.
Ambas funciones devuelven el valor original para permitir el encadenamiento de matchers.
Estas funciones permiten escribir pruebas más limpias y legibles al encadenar múltiples condiciones. Al devolver el valor original, podemos aplicar múltiples validaciones sin necesidad de romper la fluidez del código de prueba.
Ejercicio
Implementa las funciones de extensión shouldBeNegative: Int.() -> Int
y shouldNotBeNegative: Int.() -> Int
para verificar si un número es negativo o no.
Solución
- Código esencial
- Código completo
fun Int.shouldBeNegative(): Int {
this should beNegative()
return this
}
fun Int.shouldNotBeNegative(): Int {
this shouldNot beNegative()
return this
}
fun beNegative() = Matcher<Int> { actual ->
MatcherResult(
actual < 0,
{ "Expected $actual to be negative" },
{ "Expected $actual to be non-negative" }
)
}
fun Int.shouldBeNegative(): Int {
this should beNegative()
return this
}
fun Int.shouldNotBeNegative(): Int {
this shouldNot beNegative()
return this
}
Caso de estudio: Añadiendo una función a List<T>
Los métodos de extensión pueden ser genéricos, lo que te permite trabajar con clases genéricas.
Supongamos que queremos añadir una función second()
a las listas, que devuelve el segundo elemento o lanza una excepción si la lista tiene menos de dos elementos.
Especificación BDD
"A list" - {
"when getting the second element" - {
"should return the last element if the list has 2 elements" {}
("should return the element at index 1 if the list has more than " +
"2 elements") {}
"should throw an exception if the list has less than 2 elements" {}
}
}
- Código esencial
- Código completo
fun <T> List<T>.second(): T {
if (size < 2) throw NoSuchElementException("La lista tiene menos de dos elementos")
return this[1]
}
listOf(10, 20, 30).second() shouldBe 20
shouldThrow<NoSuchElementException> {
emptyList<Int>().second()
}
fun <T> List<T>.second(): T {
if (size < 2) throw NoSuchElementException("La lista tiene menos de dos elementos")
return this[1]
}
class ListExtensionTest : FreeSpec({
"Getting the second element of a list" - {
"should return 20 for [10, 20, 30]" {
val numbers = listOf(10, 20, 30)
numbers.second() shouldBe 20
}
"should throw an exception for an empty list" {
val emptyList = emptyList<Int>()
shouldThrow<NoSuchElementException> {
emptyList.second()
}
}
}
})
- [1] Definimos una función de extensión
second()
paraList<T>
. - [2] Verificamos si la lista tiene al menos dos elementos.
- [3] Devolvemos el segundo elemento usando
this[1]
.
Uso de run
y Extensiones de Receptores
En Kotlin, la función run
te permite ejecutar un bloque de código en el contexto de un objeto. Esto es especialmente útil cuando deseas ejecutar métodos de extensión definidos dentro de una clase de manera concisa y legible.
Ejemplo completo usando run
con funciones de extensión internas
A continuación veremos cómo definir una función de extensión dentro de una clase y luego utilizar run
desde fuera para ejecutarla.
- Código esencial
- Código completo
class Greeter(val greeting: String) {
// Función de extensión interna
fun String.withGreeting(): String = "$greeting, $this!"
}
Para utilizar esta función desde fuera de la clase con run
:
val greeter = Greeter("Hello")
val message = greeter.run { "Alice".withGreeting() }
println(message) // Imprime: Hello, Alice!
package greet
class Greeter(val greeting: String) {
// Función de extensión interna para String
fun String.withGreeting(): String = "$greeting, $this!"
}
Test asociado:
package greet
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
class GreeterTest : FreeSpec({
"Greeter" - {
"should format greeting message correctly" {
val greeter = Greeter("Hello")
val message = greeter.run {
"Alice".withGreeting()
}
message shouldBe "Hello, Alice!"
}
}
})
- La función de extensión
withGreeting()
se define dentro de la claseGreeter
y tiene acceso a sus propiedades, incluso aunque esté extendiendo la claseString
. - Al usar
greeter.run { ... }
, establecemosgreeter
como el receptor implícito, permitiendo invocar métodos y extensiones definidas enGreeter
de forma directa y legible. - Esto hace que la función
run
sea particularmente útil para ejecutar métodos de extensión que dependen del contexto interno de una clase.
Propiedades de Extensión
Al igual que los métodos de extensión, las propiedades de extensión te permiten añadir nuevas propiedades a una clase existente sin modificar su código fuente o utilizar herencia. Aunque parece que estás añadiendo una nueva propiedad a la clase, en realidad estás creando una función que se comporta como si fuera una propiedad.
Definición de Propiedad de Extensión
Una propiedad de extensión sigue la misma sintaxis que una propiedad normal, pero debe definirse como una función getter, ya que no es posible almacenar datos adicionales en la instancia de la clase que estás extendiendo.
Sintaxis de una Propiedad de Extensión
val ClassName.propertyName: PropertyType
get() = // Implementación que devuelve el valor de la propiedad
set(value) {
// Implementación opcional para propiedades de escritura
}
Donde:
ClassName
es la clase que estás extendiendo.propertyName
es el nombre de la nueva propiedad.PropertyType
es el tipo de la propiedad.get()
es la función que devuelve el valor de la propiedad.set(value)
es la función opcional que establece el valor de la propiedad.
Ejemplo: Propiedad de Extensión para String
Supongamos que queremos añadir una propiedad de extensión a la clase String
para obtener la primera palabra de una cadena.
val String.firstWord: String
get() = this.split(" ").first()
val sentence = "Kotlin is fun"
println(sentence.firstWord) // Output: Kotlin
Beneficios y limitaciones de los métodos de extensión
Beneficios
- Mayor legibilidad: Permiten invocar métodos como si fueran parte de la clase original, haciendo el código más expresivo y fácil de entender.
- Extensibilidad: Facilitan la extensión de clases provenientes de librerías externas o APIs, incluso si no tienes acceso a su código fuente.
- Flexibilidad: Evitan la necesidad de utilizar herencia excesiva para añadir funcionalidad, manteniendo jerarquías más simples y claras.
- Separación de responsabilidades: Ayudan a separar claramente nuevas funcionalidades de la implementación original, promoviendo un diseño modular.
- Facilidad para pruebas unitarias: Facilitan la creación de métodos específicos para pruebas, incrementando la legibilidad y la fluidez de los tests.
Limitaciones
- Acceso limitado: Las funciones de extensión no pueden acceder a miembros privados o protegidos de la clase que extienden, salvo si están definidas dentro de la misma clase.
- Riesgo de sobreuso: Pueden incentivar la creación excesiva de métodos, generando dificultad para identificar claramente dónde está implementada una funcionalidad específica.
- Conflictos potenciales: Dos extensiones con la misma firma pueden causar conflictos, haciendo el código difícil de mantener si no se gestionan adecuadamente los espacios de nombres (imports).
- No modifican realmente la clase original: Aunque parecen métodos propios de la clase, son funciones externas que podrían causar confusión si no están claramente documentadas.
Aquí tienes la última sección completa con su ejemplo y conclusiones claras para cerrar la lección sobre métodos de extensión:
Conclusiones
Los métodos y propiedades de extensión son herramientas esenciales en Kotlin para añadir funcionalidad adicional a clases existentes de forma clara, concisa y segura. Permiten mantener un diseño modular, limpio y fácil de mantener, evitando el uso innecesario de herencia y promoviendo la separación de responsabilidades.
Sin embargo, como toda herramienta poderosa, requieren un uso cuidadoso y equilibrado para evitar problemas como conflictos en los nombres o confusiones sobre el origen real del método o propiedad.
Puntos clave
- Extensiones simples y efectivas: Permiten mejorar la usabilidad y expresividad del código sin modificar la implementación original de la clase.
- Modularidad y legibilidad: Fomentan un diseño modular y un código fácil de mantener y leer.
- Facilitan la integración con bibliotecas externas: Puedes extender fácilmente clases externas y adaptarlas a tus necesidades sin utilizar herencia.
- Precaución con conflictos y uso excesivo: Es importante utilizar los métodos y propiedades de extensión con moderación, asegurando claridad y evitando conflictos en los espacios de nombres.
En definitiva, dominar los métodos y propiedades de extensión te permite aprovechar al máximo la flexibilidad y potencia del lenguaje Kotlin, facilitando la creación de código limpio, expresivo y fácil de mantener.
Bibliografías Recomendadas
- 🌐 "Extensions | Kotlin." Accedido: 14 de marzo de 2025. [En línea]. Disponible en: https://kotlinlang.org/docs/extensions.html