Skip to main content

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

Be lazy...

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.

checkAll(
Arb.positiveInt(100),
Arb.positiveInt(100)
) { width, height ->
calculateRectangleArea(width, height) shouldBe
countAreaOnGrid(width, height)
}
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²"
}

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:

src/main/kotlin/com/github/username/staticfn/rectangle/RectangleUtils.kt
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

Si 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>.

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) }

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:

static/src/main/kotlin/com/github/username/staticfn/tasks/Task.kt
package com.github.username.staticfn.tasks

class Task<out T>(val value: T)
static/src/main/kotlin/com/github/username/staticfn/tasks/TaskManager.kt
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()
}
}
¿Qué acabamos de hacer?
  • En esta implementación, TaskManager es un object 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.

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)
}
}
¿Qué acabamos de hacer?
  • La función de fábrica create verifica si la edad es válida antes de crear una instancia de User.
  • Las funciones shouldHaveName y shouldHaveAge son extensiones de User 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)
}
}
}
¿Qué acabamos de hacer?
  • La clase User tiene un constructor privado para evitar la creación directa de instancias.
  • La función create en el companion object de User verifica si la edad es válida antes de crear una instancia de User.

Comparación entre Funciones de Nivel Superior, object y companion object

CaracterísticaFunciones de Nivel SuperiorObjectCompanion Object
AsociaciónNo asociadas a ninguna claseSingleton globalAsociadas a una clase específica
AccesoSe acceden directamente mediante importacionesSe acceden usando el nombre del objectSe acceden usando el nombre de la clase
Uso ComúnFunciones y variables utilitarias generalesAgrupar funciones y variables relacionadas globalmenteFunciones y variables estáticas relacionadas con la clase
Necesidad de InstanciaciónNo requieren instanciaciónNo requieren instanciaciónNo requieren instanciación
Visibilidad de Clase InternaNo pueden acceder a miembros privados de una clasePueden acceder a sus propios miembros privadosPueden 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.