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 criar Customer via Customer.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 val pode ser usado apenas com tipos primitivos e String com valor conhecido em tempo de compilação. A constante é inserida inline onde é usada e se torna um campo public static final.
  • @JvmField gera 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, como List.
  • @JvmStatic gera um método static adicional na classe PaymentService. Funções sem essa anotação ainda precisam ser chamadas via Companion.

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 DataSource ou URL de um payment gateway, então object nã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.

kotlinsingletoncompanion objectobjectjvm