Object, companion object e singleton em Kotlin
1. Visão geral
Kotlin não possui a palavra-chave static. Em vez disso, a linguagem fornece a palavra-chave object com três formas de uso: object declaration, object expression e companion object.
Este artigo explica cada forma de uso, como Kotlin as compila para bytecode JVM, como chamá-las a partir de Java e quando não usá-las. Ao terminar, você saberá como criar singletons thread-safe, escrever factory methods elegantes e evitar erros comuns em testes.
2. Configuração
Os exemplos usam Kotlin 2.x em JVM 21. Para executar testes, adicione JUnit 5 e AssertJ ao seu 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>
Seu projeto precisa ter o kotlin-maven-plugin configurado para compilar código Kotlin.
3. Object declaration – singleton nativo da linguagem
Quando você declara um object no nível superior ou aninhado em uma classe, você obtém um singleton. Kotlin cria automaticamente exatamente uma instância dele. Você não precisa de construtor private, não precisa de getInstance() e não precisa de 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())
}
Você chama a função diretamente pelo nome do object: OrderIdGenerator.next(). Aqui não há a palavra-chave new e também não há chamada de constructor.
A variável counter usa AtomicLong porque múltiplas threads podem chamar next() simultaneamente. Um singleton garante apenas que há uma instância. Ele não torna automaticamente o estado interno thread-safe.
ORD-1
ORD-2
3.1. Inicialização lazy e thread-safe
O object é inicializado na primeira acesso, não quando o programa inicia:
object AppConfig {
init {
println("AppConfig foi inicializado")
}
val currency = "VND"
}
fun main() {
println("Começar")
println(AppConfig.currency)
println(AppConfig.currency)
}
Começar
AppConfig foi inicializado
VND
VND
O bloco init é executado apenas uma vez, imediatamente antes da primeira leitura de currency. Em termos de bytecode, Kotlin compila AppConfig em uma classe final com um campo public static final AppConfig INSTANCE. Este campo é atribuído em um static initializer.
A JVM garante que o static initializer de uma classe é executado apenas uma vez e de forma thread-safe quando múltiplas threads acessam. Portanto, o singleton do Kotlin é tanto lazy quanto thread-safe sem você escrever uma linha adicional.
3.2. Objects podem herdar e implementar interfaces
Um object é um tipo real. Ele pode implementar interfaces e ser passado como qualquer outro 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 não tem estado, então criar múltiplas instâncias seria um desperdício. Declará-lo como object expressa claramente a intenção: apenas uma instância é necessária.
4. Object expression – objetos anônimos
Quando você coloca um object na posição de uma expressão, você cria um objeto anônimo. Esse uso é semelhante a anonymous classes em 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]
Diferença importante: object expression não é um singleton. Cada vez que a expressão é executada, uma nova instância é criada. Além disso, object expression é inicializado imediatamente, não lazy como object declaration.
Você também pode criar um object sem supertipo para agrupar temporariamente alguns valores em escopo local:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("Média: ${stats.sum / stats.count}")
}
O tipo de stats é acessível apenas dentro da função ou em uma declaração private. Se você retorná-lo de uma função public, o tipo será truncado para Any e você perderá acesso às propriedades. Nesse caso, use data class.
Nota: Para interfaces com apenas um método abstrato, você deve usar lambda (SAM conversion) em vez de object expression, por exemplo
Comparator<Order> { a, b -> b.total.compareTo(a.total) }. Use object expression apenas quando precisar sobrescrever vários métodos ou manter estado.
5. Companion object – substituto para static
Às vezes, você precisa de uma função ligada à classe em vez de à instância, por exemplo, um factory method. Em Java, você usaria static. Em Kotlin, você usa 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
O código acima funciona assim:
- O construtor é
private, então de fora você é forçado a criarCustomerviaCustomer.of(...). - O companion object pode acessar o construtor private da classe que o contém.
EMAIL_REGEXé compilado apenas uma vez e compartilhado entre todas as chamadas.
Um companion object ainda é um object real: um singleton aninhado na classe, com nome padrão Companion. É inicializado quando a classe que o contém é carregada, equivalente a um static initializer em Java. Cada classe pode ter no máximo um companion object.
5.1. Nomeação e implementação de interfaces
Como é um object, um companion pode ter um nome próprio e implementar interfaces. Isso é algo que static em Java não pode fazer:
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)]
Note na chamada parseAll(Money, ...). Quando você usa o nome da classe Money como um valor, Kotlin entende que é seu companion object. Assim, a classe Money em si atua como um Parser<Money>.
5.2. Extension functions no companion
Você pode adicionar funções “tipo static” a uma classe sem precisar modificar seu código-fonte, desde que a classe tenha declarado um companion:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Se o companion não tiver nome, você escreve fun Money.Companion.zero(...).
6. Chamando objects e companions a partir de Java
Por padrão, Java vê objects e companions como instâncias normais:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
Essa forma de chamar é verbosa e difícil de ler. Kotlin fornece três ferramentas para tornar o código Java mais 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()
}
}
Do lado Java, você chamaria assim:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
O significado de cada ferramenta:
const valpode ser usado apenas com tipos primitivos eStringcom valor conhecido em tempo de compilação. A constante é inserida inline onde é usada e se torna um campopublic static final.@JvmFieldgera um campo static public, sem gerar um getter. Essa anotação é adequada para valores que não são constantes em tempo de compilação, comoList.@JvmStaticgera um método static adicional na classePaymentService. Funções sem essa anotação ainda precisam ser chamadas viaCompanion.
Se seu projeto tem apenas código Kotlin, você não precisa dessas anotações. A exceção é const val: ainda é útil porque o valor pode ser usado em anotações, por exemplo @Retry(maxAttempts = MAX_RETRY).
7. data object para estados sem dados
A partir de Kotlin 1.9, você pode declarar data object. Este uso é comum em hierarquias 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)
Se você omitir data, toString() imprimirá algo como PaymentResult$Pending@1b6d3586, muito difícil de ler em logs. data object também gera equals/hashCode consistentes. Isso é importante quando um object é desserializado como uma instância diferente.
8. Escrevendo testes
Testes para o factory method no 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 e nome`() {
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 lança exceção quando email é inválido`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion parse múltiplas linhas`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration sempre retorna a mesma instância`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
Esses testes são independentes porque Customer.of e Money.parse não mantêm estado. Com OrderIdGenerator é diferente: o contador vive durante toda a vida útil da JVM. Um teste que verifica next() == "ORD-1" será verdadeiro ou falso dependendo da ordem de execução dos testes. Este é exatamente o primeiro erro na seção seguinte.
9. Erros comuns e quando não usar
Object mantendo estado mutável. Estado global faz com que testes dependam uns dos outros e dificulta a execução paralela. Se um object precisa de estado, divida-o em uma class e passe a instância onde necessário (dependency injection).
Nota: Object declarations não têm construtores com parâmetros. Se um singleton depende de configuração ou de outro serviço, por exemplo
DataSourceou URL de um payment gateway, entãoobjectnão é a escolha apropriada.
Usando object em uma aplicação Spring. Beans em Spring são singletons por padrão no escopo de ApplicationContext. Escrever object PaymentService causará perda de constructor injection, dificuldade para fazer mock em testes e criará dois mecanismos de gerenciamento de ciclo de vida paralelos. Nesse caso, use @Service class PaymentService(...). Use object apenas para partes puras, sem dependências: constantes, funções utilitárias, policies sem estado.
Confundir companion object com static. Um companion é uma instância real. Chamar Customer.of(...) é na verdade chamar Customer.Companion.of(...). O custo disso é negligenciável, mas explica por que Java precisa de @JvmStatic.
Colocar toda função utilitária no companion. Para funções não diretamente relacionadas à classe, uma top-level function como fun formatVnd(amount: Long): String é mais concisa e fácil de encontrar.
Dependências cíclicas durante inicialização. Se o init do object A lê o object B, enquanto B lê A, você pode receber null para uma propriedade declarada como non-null. Mantenha o bloco de inicialização do object bem simples.
10. Conclusão
A palavra-chave object em Kotlin tem três funções:
- Object declaration cria um singleton lazy e thread-safe graças ao mecanismo de inicialização de classe da JVM.
- Object expression cria objetos anônimos. Cada execução da expressão é uma nova instância.
- Companion object substitui
static. Pode ter um nome, implementar interfaces e receber extension functions.
Ao trabalhar com Java, use const val, @JvmField e @JvmStatic para que a API pareça natural. A regra mais importante é manter o object sem estado mutável. Quando você precisa de dependências ou precisa fazer mock em testes, uma class comum, ou um bean Spring, é sempre uma escolha melhor.