Object, companion object y singleton en Kotlin
1. Descripción general
Kotlin no tiene la palabra clave static. En su lugar, el lenguaje proporciona la palabra clave object con tres formas de uso: object declaration, object expression y companion object.
Este artículo explica cada forma de uso, cómo Kotlin las compila en bytecode JVM, cómo llamarlas desde Java, y cuándo no deberías usarlas. Cuando termines de leerlo, sabrás cómo crear singleton seguros para threads, escribir factory methods elegantes y evitar errores comunes al hacer testing.
2. Configuración
Los ejemplos usan Kotlin 2.x en JVM 21. Para ejecutar los tests, añade JUnit 5 y AssertJ a tu pom.xml:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Tu proyecto debe tener configurado kotlin-maven-plugin para compilar código Kotlin.
3. Object declaration – singleton con soporte nativo del lenguaje
Cuando declaras object a nivel top-level o anidado dentro de una clase, obtienes un singleton. Kotlin crea automáticamente exactamente una instancia del mismo. No necesitas constructor privado, no necesitas getInstance() ni double-checked locking.
import java.util.concurrent.atomic.AtomicLong
object OrderIdGenerator {
private val counter = AtomicLong(0)
fun next(): String = "ORD-${counter.incrementAndGet()}"
}
fun main() {
println(OrderIdGenerator.next())
println(OrderIdGenerator.next())
}
Llamarás a la función directamente a través del nombre del object: OrderIdGenerator.next(). Aquí no hay palabra clave new ni tampoco invocación explícita del constructor.
La variable counter usa AtomicLong porque múltiples threads pueden llamar a next() simultáneamente. El singleton solo garantiza que hay una única instancia. No hace que el estado interno sea inherentemente thread-safe.
ORD-1
ORD-2
3.1. Inicialización lazy y thread-safe
El object se inicializa en el primer acceso, no cuando arranca la aplicación:
object AppConfig {
init {
println("AppConfig fue inicializado")
}
val currency = "VND"
}
fun main() {
println("Comenzando")
println(AppConfig.currency)
println(AppConfig.currency)
}
Comenzando
AppConfig fue inicializado
VND
VND
El bloque init se ejecuta una sola vez, justo antes de la primera lectura de currency. En términos de bytecode, Kotlin compila AppConfig como una clase final con un campo public static final AppConfig INSTANCE. Este campo se asigna en el inicializador estático.
La JVM garantiza que el inicializador estático de una clase se ejecuta una sola vez y es thread-safe cuando múltiples threads acceden simultáneamente. Por lo tanto, el singleton de Kotlin es tanto lazy como thread-safe sin que tengas que escribir línea alguna.
3.2. Un object puede heredar e implementar interfaces
Un object es un tipo real. Puede implementar interfaces y pasarse como cualquier otro valor:
interface DiscountPolicy {
fun apply(amount: Long): Long
}
object NoDiscount : DiscountPolicy {
override fun apply(amount: Long): Long = amount
}
class TenPercentOff : DiscountPolicy {
override fun apply(amount: Long): Long = amount * 90 / 100
}
fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)
fun main() {
println(checkout(200_000, NoDiscount))
println(checkout(200_000, TenPercentOff()))
}
200000
180000
NoDiscount no tiene estado, así que crear múltiples instancias sería un desperdicio. Declararlo como object comunica claramente la intención: se necesita exactamente una instancia.
4. Object expression – objetos anónimos
Cuando colocas object en la posición de una expresión, creas un objeto anónimo. Este uso es similar a las clases anónimas en Java:
data class Order(val id: String, val total: Long)
fun main() {
val byTotalDesc = object : Comparator<Order> {
override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
}
val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]
La diferencia crítica: una object expression no es un singleton. Cada vez que se ejecuta la expresión, se crea una nueva instancia. Además, una object expression se inicializa inmediatamente, no de forma lazy como una object declaration.
También puedes crear un object sin supertipo para agrupar temporalmente algunos valores en un ámbito local:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("Promedio: ${stats.sum / stats.count}")
}
El tipo de stats solo es accesible dentro de la función o en una declaración private. Si lo devuelves hacia fuera a través de una función pública, el tipo se verá forzado a Any y perderás acceso a las propiedades. En ese caso, usa data class en su lugar.
Nota: Para interfaces con un solo método abstracto, deberías usar una lambda (conversión SAM) en lugar de una object expression, por ejemplo
Comparator<Order> { a, b -> b.total.compareTo(a.total) }. Solo usa object expression cuando necesites sobrescribir múltiples métodos o mantener estado.
5. Companion object – el reemplazo de static
A veces necesitas una función asociada a una clase en lugar de una instancia, como un factory method. En Java, usarías static. En Kotlin, usas companion object:
class Customer private constructor(val email: String, val name: String) {
companion object {
private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
fun of(email: String, name: String): Customer {
require(EMAIL_REGEX.matches(email)) { "Email inválido: $email" }
return Customer(email.trim().lowercase(), name.trim())
}
}
}
fun main() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An
El código anterior funciona así:
- El constructor es
private, por lo que el exterior se ve obligado a crearCustomera través deCustomer.of(...). - El companion object puede acceder al constructor privado de la clase que lo contiene.
EMAIL_REGEXse compila una sola vez y se reutiliza para todas las invocaciones.
Un companion object sigue siendo un object real: un singleton anidado dentro de la clase, con un nombre por defecto Companion. Se inicializa cuando se carga la clase que lo contiene, equivalente a un inicializador estático en Java. Cada clase puede tener como máximo un companion object.
5.1. Dar nombre e implementar interfaces
Como es un object, un companion puede tener su propio nombre e implementar interfaces. Esto es algo que static en Java no puede hacer:
interface Parser<T> {
fun parse(raw: String): T
}
data class Money(val amount: Long, val currency: String) {
companion object Factory : Parser<Money> {
override fun parse(raw: String): Money {
val (amount, currency) = raw.trim().split(" ")
return Money(amount.toLong(), currency)
}
}
}
fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)
fun main() {
println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]
Observa la invocación parseAll(Money, ...). Cuando usas el nombre de la clase Money como un valor, Kotlin entiende que es el companion object de la misma. De esta manera, la propia clase Money actúa como un Parser<Money>.
5.2. Extension functions en companion
Puedes añadir funciones “de tipo static” a una clase sin modificar su código fuente, siempre que la clase ya tenga un companion declarado:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Si el companion no tiene nombre, escribirías fun Money.Companion.zero(...).
6. Llamar object y companion desde Java
Por defecto, Java ve objects y companions como instancias normales:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
Esta forma de llamar es verbosa y difícil de leer. Kotlin proporciona tres herramientas para que el código Java se vea más natural:
class PaymentService {
companion object {
const val MAX_RETRY = 3
@JvmField
val SUPPORTED_CURRENCIES = listOf("VND", "USD")
@JvmStatic
fun create(): PaymentService = PaymentService()
fun legacyCreate(): PaymentService = PaymentService()
}
}
Desde Java lo llamarías así:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
El significado de cada herramienta:
const valsolo funciona con tipos primitivos yStringcon valores conocidos en tiempo de compilación. La constante se inline en el lugar de uso y se convierte en un campopublic static final.@JvmFieldgenera un campo static público, sin generar getter. Esta anotación es adecuada para valores que no son constantes compile-time, comoList.@JvmStaticgenera un método static adicional en la clasePaymentService. Las funciones sin esta anotación deben llamarse a través deCompanion.
Si tu proyecto es solo código Kotlin, no necesitas estas anotaciones. La excepción es const val: sigue siendo útil porque el valor se puede usar en anotaciones, por ejemplo @Retry(maxAttempts = MAX_RETRY).
7. data object para estados sin datos
A partir de Kotlin 1.9, puedes declarar data object. Este uso es común en jerarquías sealed:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun main() {
println(PaymentResult.Pending)
println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)
Si omites la palabra data, toString() imprimirá algo como PaymentResult$Pending@1b6d3586, muy difícil de leer en logs. data object también genera equals/hashCode consistentes. Esto es importante cuando el object se deserializa como una instancia diferente.
8. Escribir tests
Test para el factory method en el companion object:
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `of normaliza email y nombre`() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
assertThat(customer.email).isEqualTo("an.nguyen@example.com")
assertThat(customer.name).isEqualTo("Nguyễn An")
}
@Test
fun `of lanza excepción con email inválido`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion parse procesa múltiples líneas`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration siempre devuelve la misma instancia`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
Estos tests se ejecutan de forma independiente porque Customer.of y Money.parse no mantienen estado. Con OrderIdGenerator es diferente: el contador vive durante toda la ejecución de la JVM. Un test que verifica next() == "ORD-1" será correcto o incorrecto dependiendo del orden de ejecución de los tests. Este es precisamente el primer error en la siguiente sección.
9. Errores comunes y cuándo no deberías usar
Object que mantiene estado mutable. El estado global hace que los tests dependan unos de otros y dificulta la ejecución paralela. Si el object necesita estado, sepáralo en una class e inyecta la instancia donde sea necesario (inyección de dependencias).
Nota: Una object declaration no puede tener un constructor con parámetros. Si el singleton depende de configuración u otros servicios, como
DataSourceo la URL de un gateway de pagos, entoncesobjectno es la opción más adecuada.
Usar object en una aplicación Spring. Los beans en Spring ya son singletons por defecto dentro del ámbito de ApplicationContext. Escribir object PaymentService hará que pierdas constructor injection, sea difícil hacer mock en tests y crees dos mecanismos de gestión de ciclo de vida que compiten. En este caso, usa @Service class PaymentService(...). Solo usa object para partes puras, sin dependencias: constantes, funciones de utilidad, policies sin estado.
Confundir companion object con static. Un companion es una instancia real. Llamar a Customer.of(...) es realmente llamar a Customer.Companion.of(...). El costo de esto es insignificante, pero explica por qué Java necesita @JvmStatic.
Meter todas las funciones de utilidad en el companion. Para funciones que no están directamente relacionadas con la clase, una top-level function como fun formatVnd(amount: Long): String es más concisa y más fácil de encontrar.
Dependencias circulares en la inicialización. Si el init del object A lee el object B, mientras que B lee A, podrías terminar con null para una propiedad declarada como non-null. Mantén el bloque de inicialización del object realmente simple.
10. Conclusión
La palabra clave object en Kotlin tiene tres roles:
- Object declaration crea un singleton lazy y thread-safe gracias al mecanismo de inicialización de clases de la JVM.
- Object expression crea un objeto anónimo. Cada ejecución de la expresión es una nueva instancia.
- Companion object reemplaza a
static. Puede tener nombre, implementar interfaces y recibir extension functions.
Cuando trabajes con Java, usa const val, @JvmField y @JvmStatic para que el API se vea natural. La regla más importante es mantener los objects sin estado mutable. Cuando necesites dependencias o necesites hacer mock en tests, una class común, o un bean de Spring, siempre será una mejor opción.