Pular para o conteúdo

Trilha de Kotlin · parte 11 de 59

Interfaces, delegação e classes open

Aula da fase 2 da trilha de Kotlin: Como compor comportamento com `by` em vez de herança, e por que classes são `final` por padrão?

intermediárioPublicada em · 14 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Quando você precisa reaproveitar comportamento, a primeira ideia que costuma aparecer é herança: uma classe nova “é um” tipo de outra e herda o que ela faz. Em Java isso é o padrão, e por isso muita gente leva o hábito consigo. Em Go não existe herança de classes; o reaproveitamento acontece por composição, com structs que embutem outras structs e interfaces satisfeitas de forma implícita. Kotlin fica entre os dois. A linguagem aceita herança, mas torna a decisão explícita: toda classe é final por padrão, e para deixar outra classe herdar de você é preciso escrever open. Esta aula explica por que essa escolha existe, como a delegação com by resolve a maior parte dos casos em que você teria herdado, e quais armadilhas aparecem quando um framework como o Spring precisa criar subclasses por conta própria.

Uma imagem que ajuda a lembrar a diferença: herança é contratar alguém com a promessa de que ele faz tudo o que o antecessor fazia, mais o que for novo. Delegação é contratar um assistente que cuida de uma parte do trabalho e repassa o resto a outra pessoa. Os dois podem produzir o mesmo resultado na prática, mas o assistente tem uma regra a mais, e é nessa regra que estão quase todos os bugs desta aula.

o mapa da aula

A aula segue esta ordem: primeiro o problema que a herança resolve e o que ela custa; depois por que o Kotlin torna as classes finais; em seguida, interfaces com implementação padrão e a delegação com by; depois a armadilha de delegação que quase todo mundo encontra uma vez; e por fim o encontro com o Spring, que cria subclasses para interceptar chamadas e por isso exige classes abertas. Os exemplos são de Kotlin puro, compilados com o kotlinc 2.1.21 (a versão que vem junto do IntelliJ instalado nesta máquina). Os trechos de erro também foram compilados, e as mensagens citadas são as reais.

1. herança e o custo de reaproveitar por ela

Herança parece a forma mais direta de reutilizar código. Você escreve uma classe Base com um método útil e uma classe Filha que reaproveita esse método. O problema aparece quando a classe Base muda. Qualquer mudança interna, como trocar a ordem em que um método chama outro, pode alterar o comportamento de todas as filhas, e a filha não tem como saber que isso aconteceu. Esse acoplamento é o que se chama de fragilidade da herança: a subclasse depende de detalhes que o autor da classe base nunca se comprometeu a manter.

Por isso a recomendação clássica do livro Effective Java, de Joshua Bloch, é projetar a herança com intenção, documentando os pontos de extensão, ou proibi-la. Kotlin seguiu essa recomendação no próprio padrão da linguagem. Em Java, um método é virtual por padrão, o que significa que qualquer subclasse pode sobrescrevê-lo. Em Kotlin, o padrão é o oposto.

2. por que classes são final por padrão

Em Kotlin, uma classe só pode ser herdada se for marcada com open, e um método só pode ser sobrescrito se a classe for aberta e o método também for open. Veja o que o compilador responde quando alguém tenta herdar de uma classe comum e sobrescrever um método que não foi marcado:

class Servico {
    fun executar(): String = "ok"
}

// Erro esperado: Servico é final por padrão.
class Servico2 : Servico()

open class Base {
    fun fixo(): String = "fixo"
}

// Erro esperado: fixo() não é open, não pode ser sobrescrito.
class Filho : Base() {
    override fun fixo(): String = "novo"
}

O compilador responde com this type is final, so it cannot be extended. para o primeiro caso e com 'fixo' in 'Base' is final and cannot be overridden. para o segundo.

A decisão tem um motivo claro: uma classe final é uma classe cujo comportamento você controla por inteiro. Quando alguém precisa estender, a intenção precisa estar escrita no código, e não ser um efeito colateral de esquecer de escrever final. Vale comparar com Go, que nem tem esse dilema, porque não tem herança de tipos concretos. Um struct em Go pode embutir outro struct, e os métodos do embutido ficam acessíveis no externo, mas isso é composição com promoção de métodos: o método do embutido não sabe que foi “herdado” e não é chamado de forma virtual quando o externo o sobrescreve. A discussão sobre herança frágil aparece em Go de outra forma, mas a conclusão é parecida: quem reaproveita código precisa pensar na fronteira.

Um detalhe importante é que o final do Kotlin não esconde a necessidade de polimorfismo. Ele só obriga você a dizer, no código, onde o polimorfismo existe. Se você precisa de duas implementações de um mesmo contrato, a forma idiomática é uma interface, e não uma hierarquia de classes abertas.

3. interfaces com implementação padrão

Uma interface em Kotlin pode declarar métodos abstratos e também métodos com corpo. Quem implementa a interface recebe o corpo padrão sem precisar escrevê-lo. Essa é a mesma ideia dos default methods que o Java introduziu na versão 8, mas com uma diferença de uso: em Kotlin, o corpo padrão convive bem com propriedades e com a regra de que a implementação concreta pode sobrescrever qualquer membro que a interface declare.

Considere uma interface de repositório que sabe salvar um item e, por padrão, salvar uma lista inteira chamando o método unitário:

interface Repositorio {
    fun salvar(id: String)

    fun salvarTudo(ids: List<String>) {
        ids.forEach { salvar(it) }
    }
}

Essa combinação é útil porque o código que sabe salvar um item de cada vez ganha o salvamento em lote de graça. O problema é que o corpo padrão depende de salvar, e essa dependência é justamente o que vai causar o bug da seção 5.

Pergunta de entrevistaPor que classes Kotlin são final por padrão?

Porque herança sem planejamento quebra o encapsulamento: uma subclasse pode sobrescrever um comportamento que o pai usava internamente, sem que ele saiba. Kotlin fecha a classe por padrão para forçar a intenção. Quando outra classe precisa estender, eu marco como open e documento o que pode ser sobrescrito. A mesma lógica vale para frameworks: proxies do Spring precisam de classes abertas, e por isso existe o plugin kotlin-spring.

4. delegação com by

Agora considere o caso em que você quer acrescentar um log a um repositório sem reescrever tudo. A saída tradicional em Java seria herdar da implementação e sobrescrever os métodos que interessam, ou escrever um wrapper manual com um método por método da interface. A primeira opção acopla o log a uma classe concreta. A segunda gera muito código repetido, que precisa ser atualizado a cada método novo.

Em Kotlin, a cláusula by resolve o caso. Ela diz ao compilador que todos os membros da interface devem ser encaminhados a um objeto interno, a menos que a classe os sobrescreva:

class RepositorioBase : Repositorio {
    override fun salvar(id: String) {
        println("base: salvando $id")
    }
}

class RepositorioComLog(private val inner: Repositorio) : Repositorio by inner {
    override fun salvar(id: String) {
        println("log: antes de salvar $id")
        inner.salvar(id)
    }
}

O compilador gera, em RepositorioComLog, um método salvarTudo que simplesmente chama inner.salvarTudo(ids). Você não escreve esse método; ele existe no bytecode. Por isso a delegação é o equivalente idiomático do padrão Decorator, e o custo de manutenção fica próximo de zero, porque uma nova função na interface é encaminhada automaticamente.

Vale notar a diferença para a embedding de Go. Em Go, um struct que embute outro ganha os métodos dele, e você pode sobrescrever um método declarando o seu próprio com o mesmo nome. Em Kotlin, a classe que delega precisa declarar explicitamente quais membros sobrescreve, e o compilador exige que a interface seja a fonte da verdade. Os dois mecanismos chegam a resultados parecidos, mas a delegação de Kotlin é formalizada pela interface, e não pela promoção de campos.

5. a armadilha: o objeto interno não chama o wrapper

Rode o código acima com um cliente que usa os dois caminhos:

fun main() {
    println("--- delegação sem correção")
    val w = RepositorioComLog(RepositorioBase())
    w.salvar("a")
    w.salvarTudo(listOf("b", "c"))
}

A saída é esta:

--- delegação sem correção
log: antes de salvar a
base: salvando a
base: salvando b
base: salvando c

O log aparece para salvar("a"), mas não aparece para salvarTudo. A explicação está na linha ids.forEach { salvar(it) } do corpo padrão da interface. Quando salvarTudo é encaminhado ao inner, o this dentro daquele corpo é o objeto RepositorioBase, e não o wrapper. Então o salvar chamado é o da base, e o override do wrapper nunca roda. O wrapper só intercepta chamadas que passam por ele; chamadas que acontecem dentro do objeto interno ficam invisíveis para o wrapper.

A figura abaixo mostra os dois caminhos. A seta tracejada é o encaminhamento gerado pela delegação. A seta vermelha é o this.salvar dentro do corpo padrão, que pula o log.

Delegação: o caminho que pula o override. Cliente direto → salvar (override) (salvar); Cliente em lote → salvarTudo (delegado) (salvarTudo)…
figura 1 · Delegação: o caminho que pula o override

A figura mostra que o caminho do lote sai do cliente, entra no wrapper e chega ao objeto interno sem passar pelo salvar sobrescrito. O quadro do salvar do wrapper fica sem uso nesse caminho, e a seta vermelha marca o ponto em que o log deixa de existir.

A correção é sobrescrever também o método que depende do comportamento que você quer interceptar:

class RepositorioComLogCorrigido(private val inner: Repositorio) : Repositorio by inner {
    override fun salvar(id: String) {
        println("log: antes de salvar $id")
        inner.salvar(id)
    }

    override fun salvarTudo(ids: List<String>) {
        ids.forEach { salvar(it) }
    }
}

Com essa versão, salvarTudo passa pelo salvar do wrapper, e o log aparece para cada item. O preço é que você voltou a escrever código que a delegação prometia poupar, e precisa lembrar de fazê-lo para cada método que dependa de outro. Esse é o motivo de a delegação ser recomendada para interfaces pequenas e estáveis: quanto mais membros interdependentes, maior a chance de um deles ficar de fora do wrapper sem aviso.

6. open, override e abstract

Quando uma classe é open, a regra padrão dos seus métodos continua final. Para liberar a sobrescrita, é preciso marcar cada método com open. Um método que já sobrescreve outro (com override) é aberto automaticamente, a menos que você escreva final override para encerrar a cadeia. Métodos de classes abstract e de interfaces são abertos por natureza, porque existem justamente para serem implementados.

open class Base {
    open fun oi(): String = "base"
    fun fixo(): String = "fixo"
}

// `override` sem `final` deixa o método aberto para novas subclasses.
open class Intermediaria : Base() {
    override fun oi(): String = "intermediaria"
}

class Folha : Intermediaria() {
    override fun oi(): String = "folha"
}

Com esse código, Folha().oi() devolve folha e Folha().fixo() devolve fixo. A regra prática é esta: abra só o que você projetou para ser estendido, documente o contrato dos métodos abertos e deixe o restante fechado. Abrir tudo por medo de precisar de uma subclasse no futuro é o caminho mais curto para herança frágil.

7. o encontro com o Spring: por que o framework precisa de classes abertas

Até aqui a discussão foi só sobre o compilador. O motivo prático para pensar em open num projeto Spring é outro. Quando você anota um serviço com @Transactional, o Spring precisa interceptar as chamadas aos métodos públicos para abrir e fechar a transação. Uma forma comum de fazer isso é criar, em tempo de execução, uma subclasse do serviço, chamada proxy, que intercepta os métodos e delega ao objeto real. Uma subclasse só existe se a classe original puder ser herdada, e um método só pode ser interceptado se puder ser sobrescrito.

A figura mostra o caminho. Se a classe for final, o proxy por herança não pode ser criado, e o resultado depende da configuração do Spring: a anotação pode simplesmente deixar de produzir efeito, ou o contexto pode falhar ao subir. Esse comportamento não foi reproduzido nesta aula, porque exige o Spring, e o detalhe deve ser confirmado na documentação da versão usada.

Por que o Spring exige classes abertas. @Transactional → Proxy gerado (precisa herdar); Proxy gerado → Serviço final (extends)…
figura 2 · Por que o Spring exige classes abertas

A figura deixa claro que o problema não está na anotação, e sim na classe. O caminho vermelho mostra o proxy tentando estender uma classe final. O caminho verde mostra o mesmo proxy estendendo uma classe aberta, que é o que o framework espera.

Para não escrever open em cada classe de serviço, o Kotlin oferece o plugin kotlin-spring, que abre automaticamente as classes e métodos anotados com as anotações mais comuns do Spring, como @Component, @Service e @Transactional. O plugin reduz o atrito, mas não elimina a necessidade de entender o motivo. Uma classe de domínio que não passa pelo Spring não precisa de open, e abri-la por padrão é o mesmo erro da seção 2, em outra roupa.

Um cuidado adicional fica para quem usa @Transactional em métodos de uma classe chamada de dentro de si mesma. A chamada interna não passa pelo proxy, e a transação não é aberta. Essa é uma questão de chamada e de proxy, não de open, e não será reproduzida aqui porque depende do contexto do Spring.

8. erros comuns e boas práticas

O primeiro erro é usar herança para reaproveitar código quando o que se quer é composição. Se a subclasse só usa dois métodos da base e não representa um subtipo real, a delegação com by expressa melhor a intenção, porque deixa claro o que vem de onde.

O segundo erro é abrir classes e métodos sem projetar o contrato. Um método open é uma promessa de que subclasses podem alterar seu comportamento, e essa promessa precisa estar documentada, senão a classe base fica presa a um comportamento que ninguém sabe manter.

O terceiro erro é o da seção 5: uma classe que delega uma interface e sobrescreve apenas parte dela, deixando métodos que dependem de outros sem interceptação. Para evitá-lo, sobrescreva os métodos que chamam os outros, ou mantenha a interface pequena.

O quarto erro é tratar o final como detalhe de estilo. Ele é uma decisão de design, e a consequência aparece em frameworks como o Spring, que dependem de subclasses para funcionar. Se uma anotação aparentemente não tem efeito, verifique se a classe ou o método está final antes de procurar bugs na lógica de transação.

9. exercícios

  1. Escreva uma interface Cache com ler(chave: String): String? e gravar(chave: String, valor: String), e uma função gravarTodos com corpo padrão que chama gravar. Crie uma classe que delega Cache a outro objeto e adiciona contagem de gravações. Verifique se gravarTodos conta corretamente. Se não contar, sobrescreva o método que faltou.
  2. Tente herdar de uma classe final e de um método não aberto. Leia a mensagem do compilador e explique, com suas palavras, por que o erro existe.
  3. Pegue uma hierarquia Java com herança de três níveis e reescreva-a em Kotlin usando interface e delegação. Compare o número de linhas e o número de lugares onde uma mudança na base teria efeito.

10. glossário

Herança: mecanismo em que uma classe filha reaproveita e estende o comportamento de uma classe pai.

Composição: reaproveitamento de comportamento pelo uso de outro objeto, e não pela herança.

Delegação: forma de composição em que um objeto encaminha a outro as chamadas de uma interface, com a cláusula by em Kotlin.

Classe final: classe que não pode ser estendida. Em Kotlin, é o padrão.

Proxy: objeto que se põe na frente de outro para interceptar chamadas, normalmente criado em tempo de execução por um framework.

Herança frágil: problema em que uma mudança interna na classe base altera o comportamento das subclasses de forma não prevista.

fontes

Os exemplos desta aula foram compilados e executados com o kotlinc 2.1.21, que vem junto do IntelliJ IDEA instalado nesta máquina, em 2026-10-20. A página oficial de Kotlin 2.0 sobre a nova geração de código das lambdas foi consultada em 2026-10-20: https://kotlinlang.org/docs/whatsnew20.html. O comportamento sobre o plugin kotlin-spring e sobre o uso de proxies pelo Spring não foi verificado com a documentação nesta sessão e está descrito de forma geral; confira a documentação oficial do Spring Framework da versão do projeto antes de depender desses detalhes.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?