Variables y funciones estáticas
⏱ 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 setupStaticModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupStaticModule") {
group = "setup"
description = "Creates the necessary files for the lesson on static members"
moduleName = "static"
doLast {
createFiles(
packageName = "geometry",
main to "RectangleUtils.kt",
test to "RectangleUtilsTest.kt",
)
createFiles(
packageName = "tasks",
main to "Task.kt",
main to "TaskManager.kt",
test to "TaskManagerTest.kt",
)
createFiles(
packageName = "matchers",
test to "HaveName.kt",
test to "HaveAge.kt",
)
createFiles(
packageName = "users",
main to "User.kt",
test to "UserTest.kt",
)
}
}
Preocúpate de que el plugin static
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupStaticModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
En el desarrollo de bibliotecas de software, es común utilizar variables y funciones que no dependen de instancias específicas de una clase. Estas variables y funciones son conocidas como estáticas. En esta lección, exploraremos cómo manejar variables y funciones estáticas en Kotlin.
Funciones estáticas
Una función estática es una función que:
- Pertenece a la clase, no a una instancia de la clase.
- Puede ser invocada sin crear una instancia de la clase.
- No tiene acceso a los miembros de instancia (no puede acceder a
this
). - Comparte el mismo espacio de memoria para todas las instancias de la clase.
Este concepto es común en muchos lenguajes de programación y es especialmente útil en bibliotecas de software donde se necesitan funciones utilitarias o constantes que deben ser accesibles globalmente.
Funciones de Nivel Superior en Kotlin
En Kotlin, podemos declarar funciones y variables de nivel superior fuera de cualquier clase o interfaz. En el contexto de una biblioteca de software, esto permite definir utilidades que están disponibles sin necesidad de una clase contenedora.
Veamos un ejemplo de una función simple para calcular el área de un rectángulo.
Especificación BDD
Calcular el área de un rectángulo multiplicando su ancho por su altura es conceptualmente equivalente a contar las unidades (como cuadrados o píxeles) que caben dentro de ese rectángulo.
Imagina que el rectángulo está dividido en una cuadrícula, donde cada celda representa una unidad de área. Multiplicar el ancho por la altura te da el número total de estas unidades, porque:
- El ancho representa cuántas unidades caben en una fila.
- La altura representa cuántas filas de estas unidades hay.
Por lo tanto, multiplicar el ancho por la altura te da el número total de unidades que llenan el rectángulo, lo cual es el área.
Dado esto, podemos escribir una especificación BDD para la función calculateRectangleArea
:
"Given a rectangle" - {
"when calculating its area" - {
"should return the same result as counting the units" {}
}
}
Implementación de las pruebas
Ahora vamos a implementar las pruebas para nuestra especificación BDD. Utilizaremos Property-Based Testing para generar diferentes valores de width
y height
de forma aleatoria y asegurar que la función calculateRectangleArea
devuelve los resultados correctos al compararlos con una implementación alternativa.
- Código esencial
- Código completo
checkAll(
Arb.positiveInt(100),
Arb.positiveInt(100)
) { width, height ->
calculateRectangleArea(width, height) shouldBe
countAreaOnGrid(width, height)
}
- Implementación iterativa
- Implementación funcional
private fun countAreaOnGrid(width: Int, height: Int): String {
var count = 0
for (i in 1..width) {
for (j in 1..height) {
count++
}
}
return "$count cm²"
}
private fun countAreaOnGrid(width: Int, height: Int) = (1..width)
.sumOf {
(1..height).count()
}.let { "$it cm²" }
package com.github.username.staticfn.rectangle
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.positiveInt
import io.kotest.property.checkAll
class RectangleAreaTest : FreeSpec({
"Given a rectangle" - {
"when calculating its area" - {
"it should return the correct result based on counting units" {
checkAll(
Arb.positiveInt(100),
Arb.positiveInt(100)
) { width, height ->
calculateRectangleArea(width, height) shouldBe
countAreaOnGrid(width, height)
}
}
}
}
})
private fun countAreaOnGrid(width: Int, height: Int) = (1..width)
.sumOf { _ ->
(1..height).count()
}.let { "$it cm²" }
Implementación de la función
Finalmente, implementamos la función calculateRectangleArea
que calcula el área de un rectángulo multiplicando su width
por su height
y agregando la unidad de medida:
package com.github.username.staticfn.rectangle
const val DEFAULT_UNIT = "cm²"
fun calculateRectangleArea(width: Int, height: Int) =
"${width * height} $DEFAULT_UNIT"
Aunque en Kotlin las funciones de nivel superior no están asociadas a una clase en el código fuente, el compilador genera una clase contenedora estática para cada archivo que contiene funciones de este tipo. Esto significa que, a nivel de bytecode de la JVM, las funciones de nivel superior se convierten efectivamente en métodos estáticos. Esto les permite ser accesibles sin necesidad de instanciar una clase, de manera similar a los métodos estáticos en lenguajes como Java.
Invocar la función calculateRectangleArea
desde Java
calculateRectangleArea
desde JavaSi necesitas invocar la función calculateRectangleArea
desde Java, puedes hacerlo de la siguiente manera:
import com.github.username.staticfn.rectangle.RectangleUtils;
public class Main {
public static void main(String[] args) {
var width = 10;
var height = 5;
String area = RectangleUtilsKt.calculateRectangleArea(width, height);
System.out.println("Area: " + area);
}
}
Objects en Kotlin
Kotlin introduce el concepto de object
, que permite crear singletons de manera sencilla. Un object
es una declaración que combina la definición de una clase y su instancia en una sola expresión.
En el contexto de una biblioteca, un object
puede ser útil para agrupar funciones y variables relacionadas. Vamos a ver un ejemplo sencillo usando una lista de tareas (to-do list):
Especificación BDD
Vamos a definir una especificación BDD para un TaskManager
que gestiona una lista de tareas. La especificación describe cómo agregar tareas, obtener todas las tareas y limpiar la lista de tareas.
"A task manager" - {
"when adding tasks" - {
"should be able to get all tasks" {}
}
"when clearing tasks" - {
"should have an empty task list" {}
}
}
Implementación de las pruebas
Vamos a implementar las pruebas para nuestra especificación BDD. Utilizaremos Property-Based Testing para generar diferentes tareas de forma aleatoria y asegurar que el TaskManager
devuelve los resultados correctos al agregar tareas y limpiar la lista.
Es importante siempre tratar de utilizar casos realistas en tus pruebas, ya que esto te permitirá validar el comportamiento de tu código en situaciones reales. Por esto, utilizaremos como ejemplo una lista de compras, para esto podemos utilizar el generador products: Arb.() -> Arb<Product>
.
- Código esencial
- Código completo
checkAll(Arb.list(arbTask())) { tasks ->
tasks.forEach { TaskManager += it }
TaskManager.tasks shouldBe tasks
}
checkAll(Arb.list(arbTask())) { tasks ->
tasks.forEach { TaskManager += it }
TaskManager.clear()
TaskManager.tasks shouldBe emptyList()
}
private fun arbTask(): Arb<Task<Product>> = Arb.products()
.map { product -> Task(product) }
package com.github.username.staticfn.tasks
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.map
import io.kotest.property.arbs.products.Product
import io.kotest.property.arbs.products.products
import io.kotest.property.checkAll
class TaskManagerTest : FreeSpec({
"A task manager" - {
"when adding tasks" - {
"should be able to get all tasks" {
checkAll(Arb.list(arbTask())) { tasks ->
tasks.forEach { TaskManager += it }
TaskManager.tasks shouldBe tasks
}
}
}
"when clearing tasks" - {
"should have an empty task list" {
checkAll(Arb.list(arbTask())) { tasks ->
tasks.forEach { TaskManager += it }
TaskManager.clear()
TaskManager.tasks shouldBe emptyList()
}
}
}
}
})
private fun arbTask(): Arb<Task<Product>> = Arb.products()
.map { product -> Task(product) }
Implementación de TaskManager
Ahora vamos a implementar el TaskManager
que gestiona una lista de tareas. Aquí tienes una implementación básica de TaskManager
:
package com.github.username.staticfn.tasks
class Task<out T>(val value: T)
package com.github.username.staticfn.tasks
object TaskManager {
private val _tasks = mutableListOf<Task<*>>()
val tasks: List<Task<*>>
get() = _tasks.toList()
operator fun plusAssign(task: Task<*>) {
_tasks += task
}
fun clear() {
_tasks.clear()
}
}
- En esta implementación,
TaskManager
es unobject
que contiene una lista mutable de tareas_tasks
. - La función
plusAssign
(+=
) permite agregar tareas a la lista. - La función
clear
elimina todas las tareas de la lista. - La propiedad
tasks
devuelve una copia inmutable de la lista de tareas.
Companion Objects
En Kotlin, un companion object es un bloque dentro de una clase que permite definir miembros asociados a la clase en sí, en lugar de a instancias de la clase. Esto es útil cuando necesitas lógica adicional para crear objetos o tener funciones relacionadas con la clase pero que no dependan de una instancia específica.
Supongamos que estamos construyendo una clase User
y queremos controlar la creación de instancias mediante una función de fábrica que verifica si los datos son válidos.
Especificación BDD
Comencemos definiendo una especificación BDD para la clase User
. La especificación describe cómo crear un usuario con un nombre y una edad válidos.
"Given a user" - {
"when creating it with a valid age" - {
"should be created" {}
}
"when creating it with an invalid age" - {
"should throw an exception" {}
}
}
Implementación de las pruebas
Con la especificación BDD en mente, vamos a implementar las pruebas para la clase User
. Utilizaremos Property-Based Testing para generar diferentes nombres y edades de forma aleatoria y asegurar que la función de fábrica create
devuelve los resultados correctos al crear usuarios.
- Código esencial
- Código completo
checkAll(arbUser(Arb.int(18..200))) { user ->
user.shouldHaveName(user.name)
.shouldHaveAge(user.age)
}
checkAll(Arb.name(), Arb.int(0..17)) { name, age ->
shouldThrow<IllegalArgumentException> {
User.create(name.toString(), age)
}
}
private fun arbUser(ageArb: Arb<Int>): Arb<User> = Arb.name()
.flatMap { name ->
ageArb.map { age ->
User.create(name.toString(), age)
}
}
package com.github.username.matchers
import com.github.username.staticfn.users.User
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.should
fun haveAge(age: Int) = Matcher<User> { user ->
MatcherResult(
user.age == age,
{ "User should have age $age but has ${user.age}" },
{ "User should not have age $age" }
)
}
fun User.shouldHaveAge(age: Int): User {
should(haveAge(age))
return this
}
package com.github.username.matchers
import com.github.username.staticfn.users.User
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.should
fun haveName(name: String) = Matcher<User> { user ->
MatcherResult(
user.name == name,
{ "User should have name $name but has ${user.name}" },
{ "User should not have name $name" }
)
}
fun User.shouldHaveName(name: String): User {
should(haveName(name))
return this
}
package com.github.username.staticfn.users
import com.github.username.matchers.shouldHaveAge
import com.github.username.matchers.shouldHaveName
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FreeSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.flatMap
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.map
import io.kotest.property.arbs.name
import io.kotest.property.checkAll
class UserTest : FreeSpec({
"Given a user" - {
"when creating it with a valid age" - {
"should be created" {
checkAll(arbUser(Arb.int(18..200))) { user ->
user.shouldHaveName(user.name)
.shouldHaveAge(user.age)
}
}
}
"when creating it with an invalid age" - {
"should throw an exception" {
checkAll(Arb.name(), Arb.int(0..17)) { name, age ->
shouldThrow<IllegalArgumentException> {
User.create(name.toString(), age)
}
}
}
}
}
})
private fun arbUser(ageArb: Arb<Int>): Arb<User> = Arb.name()
.flatMap { name ->
ageArb.map { age ->
User.create(name.toString(), age)
}
}
- La función de fábrica
create
verifica si la edad es válida antes de crear una instancia deUser
. - Las funciones
shouldHaveName
yshouldHaveAge
son extensiones deUser
que permiten verificar si un usuario tiene un nombre o una edad específicos.
Implementación de User
Finalmente, implementamos la clase User
con una función de fábrica create
que verifica si la edad es válida antes de crear una instancia de User
.
package com.github.username.staticfn.users
class User private constructor(val name: String, val age: Int) {
companion object {
fun create(name: String, age: Int): User {
require(age >= 18) {
"Age must be greater than or equal to 18"
}
return User(name, age)
}
}
}
- La clase
User
tiene un constructor privado para evitar la creación directa de instancias. - La función
create
en elcompanion object
deUser
verifica si la edad es válida antes de crear una instancia deUser
.
Comparación entre Funciones de Nivel Superior, object
y companion object
Característica | Funciones de Nivel Superior | Object | Companion Object |
---|---|---|---|
Asociación | No asociadas a ninguna clase | Singleton global | Asociadas a una clase específica |
Acceso | Se acceden directamente mediante importaciones | Se acceden usando el nombre del object | Se acceden usando el nombre de la clase |
Uso Común | Funciones y variables utilitarias generales | Agrupar funciones y variables relacionadas globalmente | Funciones y variables estáticas relacionadas con la clase |
Necesidad de Instanciación | No requieren instanciación | No requieren instanciación | No requieren instanciación |
Visibilidad de Clase Interna | No pueden acceder a miembros privados de una clase | Pueden acceder a sus propios miembros privados | Pueden acceder a miembros privados de la clase |
Beneficios y limitaciones de las funciones y variables estáticas
Beneficios
- Fácil acceso global: Las funciones y variables estáticas o de nivel superior pueden ser accedidas sin necesidad de instanciar una clase. Esto facilita su uso como utilidades o constantes globales en bibliotecas de software.
- Ahorro de recursos: Al no requerir instanciación, se ahorran recursos, especialmente en casos donde el objeto o la función no necesita mantener estado.
- Simplificación en la organización del código: Permiten centralizar funciones que no necesitan estar asociadas a un objeto o instancia, simplificando la estructura del código.
Limitaciones
- Menor flexibilidad: Al no tener acceso a miembros de instancia, las funciones estáticas o de nivel superior no pueden utilizar o modificar el estado de un objeto, limitando su aplicabilidad en ciertos escenarios.
- Dificultad en la extensión: Si más adelante se requiere añadir comportamiento dinámico o específico de instancia, migrar de funciones estáticas a funciones de instancia puede ser tedioso y provocar cambios mayores en el código.
- Acoplamiento global: Las variables estáticas pueden generar un acoplamiento innecesario si se abusa de su uso, especialmente en sistemas grandes donde puede volverse complicado rastrear su impacto.
- Problemas de pruebas: Las funciones y variables estáticas, al ser accesibles globalmente, pueden ser difíciles de aislar en pruebas unitarias, especialmente cuando afectan el estado global o son compartidas entre múltiples clases.
¿Qué aprendimos?
En esta lección, exploramos cómo manejar funciones y variables estáticas en Kotlin, enfocándonos en tres conceptos clave: funciones de nivel superior, objects, y companion objects.
Puntos clave
- Funciones de Nivel Superior: Vimos cómo se pueden declarar funciones y variables fuera de clases o interfaces, lo que permite acceder a ellas sin instanciar clases. Estas funciones se convierten en métodos estáticos en el bytecode de la JVM, siendo eficaces para implementar utilidades globales.
- Objects: Aprendimos cómo Kotlin facilita la creación de singletones a través de los
object
, que permiten agrupar funciones y variables relacionadas en un único punto de acceso global. - Companion Objects: Exploramos cómo los companion objects proporcionan una manera eficiente de definir funciones y variables asociadas a una clase, pero sin la necesidad de instanciarla. Vimos cómo se pueden usar para implementar lógica adicional, como funciones de fábrica para crear objetos.
Finalmente, revisamos los beneficios y limitaciones del uso de funciones y variables estáticas. Si bien proporcionan un acceso global y facilitan el ahorro de recursos, pueden complicar la organización del código en escenarios que requieren mayor flexibilidad, especialmente en sistemas grandes.