Eustáquio Rangel

Desenvolvedor, pai, metalhead, ciclista

Parâmetros de contexto em Kotlin

Publicado em Developer



Faz alguns dias (23/06) foi liberada a versão 2.2.0 da linguagem Kotin, com algumas novidades, entre elas o suporte beta aos parâmetros de contexto. Vejam que ainda é beta, está em preview, pode mudar, mas já saiu da fase anterior (de onde inclusive já foram feitas algumas alterações), mas aparentemente vai continuar nesse rumo mesmo, mas por enquanto, para compilar o código que será visto abaixo, é necessário os parâmetros -Xcontext-parameters, ok?

Que diabos são os parâmetros de contexto? Antes de explicar o conceito envolvido, vamos dar uma olhada em injeção de dependências. Vamos imaginar que temos uma classe Person, onde para cada método executado, registra em um log. O log pode ser exibido na tela ou gravado em um arquivo. Para efeitos didáticos, vamos exibir ambos na tela, apenas com mensagens diferentes. Se tivéssemos esse código:


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Person(val name: String, val logger: Logger) {
    fun hello() {
        logger.log("Hello, $name!")
    }
    
    fun bye() {
        logger.log("Bye!")
    }
}

fun main() {
    val console = ConsoleLogger()
    val file    = FileLogger()
    val person  = Person("tag", console)
    
    person.hello()
    person.bye()
}

Ali definimos uma interface chamada Logger, e derivamos nossos Loggers dela. Temos o ConsoleLogger, que vai mostrar a mensagem no terminal, e o FileLogger, que iria gravar a mensagem em um arquivo, mas conforme já mencionado, para efeitos didáticos irá mostrar a mensagem de uma forma diferente no terminal. Quando iniciamos Person, enviamos o Logger que desejamos (essa é a injeção por construtor), de forma com que se quiséssemos enviar outro Logger, seria só trocar ali no construtor, já que ambos implementam a mesma interface. Isso nos permite, dentro dos métodos de Person, acionar o método log do Logger enviado:

Log: Hello, taq!
Log: Bye!

Isso funciona perfeitamente, mas vamos imaginar uma situação que desejamos que Person seja acionada primeiro com o ConsoleLogger e depois com o FileLogger. Se criamos cada Person com injeção direto no construtor, teríamos que criar outra Person, igual a anterior, com o outro Logger, o que seria bem redundante. Uma alternativa seria ajustar o Logger de acordo com o uso:


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Person(val name: String) {
    lateinit var logger: Logger
    
    fun hello() {
        logger.log("Hello, $name!")
    }
    
    fun bye() {
        logger.log("Bye!")
    }
}

fun main() {
    val person = Person("tag")
    
    person.logger = ConsoleLogger()
    person.hello()
    person.bye()
    
    person.logger = FileLogger()
    person.hello()
    person.bye()
}
Log: Hello, taq!
Log: Bye!
Log to file: Hello, taq!
Log to file: Bye! 

Esse é um dos contextos que os parâmetros de ... contexto, ajudam. Do jeito que está, agora a classe Person usa lateinit var logger: Logger em vez de receber o logger no construtor, e na função main() podemos ver como o logger é atribuído dinamicamente e testado com diferentes implementações (ConsoleLogger e FileLogger). A diferença principal é que agora o logger pode ser trocado em tempo de execução, demonstrando melhor a flexibilidade do padrão Strategy/Dependency Injection. Mas também podemos fazer algo do tipo:


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Person(val name: String) {
    context(logger: Logger)
    fun hello() {
        logger.log("Hello, $name!")
    }
    
    context(logger: Logger)
    fun bye() {
        logger.log("Bye!")
    }
}

fun main() {
    val person = Person("tag")
    
    with(ConsoleLogger()) {
        person.hello()
        person.bye()
    }
    
    with(FileLogger()) {
        person.hello()
        person.bye()
    }
}

Podemos ver que aqui não enviamos o Logger em nenhum dos momentos anteriores, seja no construtor, seja atribuindo ele mais tarde, mas sim através de with com o objeto correspondente dentro. Também em Person, temos que indicar nos métodos que eles serão executados dentro de um determinado contexto, utilizando context (dãr!), sendo necessário indicar uma referência e o tipo do objeto.

A diferença principal é que agora:

Essa abordagem é muito elegante e mostra como o Kotlin está evoluindo para facilitar a injeção de dependências de forma mais limpa.

Em versões anteriores da linguagem aparentemente não era necessário a referência, sendo inferido o objeto de forma automática, mas foi alterado, até pelo fato de quando eram enviados objetos que tinham o mesmo método, era necessário identificar cada um, o que acaba ficando melhor e mais claro com uma referência para cada um. Também pode ser utilizada na referência um parâmetro anônimo com "_", mas acaba também ficando mais verboso.

Feito isso, nos métodos de Person que esperam um contexto, podemos abrir o context instanciando o objeto desejado correspondente (seja ConsoleLogger ou FileLogger, etc) e executar os métodos de Person dentro do contexto enviado. Inclusive, o contexto faz propagação automática. Vejamos no caso de hello() chamando bye(), que resulta no mesmo output visto acima:


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Person(val name: String) {
    context(logger: Logger)
    fun hello() {
        logger.log("Hello, $name!")
        bye()
    }
    
    context(logger: Logger)
    fun bye() {
        logger.log("Bye!")
    }
}

fun main() {
    val person = Person("tag")
    
    with(ConsoleLogger()) {
        person.hello()
        person.bye()
    }
    
    with(FileLogger()) {
        person.hello()
        person.bye()
    }
}

Podemos ver uma característica interessante dos context receivers: na função hello(), ela chama bye() diretamente, sem precisar passar o logger explicitamente. O contexto é automaticamente propagado entre as funções que têm o mesmo context receiver. Isso demonstra como os context receivers facilitam a composição de funções que dependem do mesmo contexto, tornando o código mais limpo e natural.

E se precisarmos de vários contextos enviados? Não tem problema, podemos utilizar vários blocos de with:


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Database {
    context(logger: Logger)
    fun save(person: Person) {
        logger.log("Saving ${person.name}")
    }
    
    context(logger: Logger)
    fun commit() = logger.log("Committing!")
}

class Person(val name: String) {
    context(logger: Logger)
    fun hello() {
        logger.log("Hello, $name!")
    }
    
    context(logger: Logger)
    fun bye() {
        logger.log("Bye!")
    }
}

fun main() {
    val console = ConsoleLogger()
    val database = Database()
    val person = Person("tag")
    
    with(console) {
        with(database) {
            person.hello()
            database.save(person)
            
            person.bye()
            database.commit()
        }
    }
}

Aqui vemos um exemplo mais complexo com múltiplos context receivers aninhados. A classe Database também foi adicionada, demonstrando como diferentes classes podem compartilhar o mesmo contexto de Logger. Na função main(), vemos o uso de with aninhados onde o contexto do console (Logger) é compartilhado entre todas as operações, tanto da Person quanto da Database. Isso mostra a flexibilidade e elegância dos context receivers para gerenciar dependências de forma implícita.

E esse caso onde Person recebe os contextos de Logger e Database, executando save() dentro de hello() e commit() dentro de bye():


interface Logger {
    abstract fun log(msg: String)
}

class ConsoleLogger : Logger {
    override fun log(msg: String) {
        println("Log: $msg")
    }
}

class FileLogger : Logger {
    override fun log(msg: String) {
        println("Log to file: $msg")
    }
}

class Database {
    context(logger: Logger)
    fun save(person: Person) {
        logger.log("Saving ${person.name}")
    }
    
    context(logger: Logger)
    fun commit() = logger.log("Committing!")
}

class Person(val name: String) {
    context(logger: Logger, database: Database)
    fun hello() {
        logger.log("Hello $name!")
        database.save(this)
    }
    
    context(logger: Logger, database: Database)
    fun bye() {
        logger.log("Bye!")
        database.commit()
    }
}

fun main() {
    val console = ConsoleLogger()
    val database = Database()
    val person = Person("tag")
    
    with(console) {
        with(database) {
            person.hello()
            database.save(person)
            
            person.bye()
            database.commit()
        }
    }
}

Esta versão mostra um dos recursos mais poderosos dos context receivers: múltiplos contextos na mesma função. Na classe Person, as funções hello() e bye() agora têm context(logger: Logger, database: Database), permitindo acesso implícito a ambos os objetos. Isso elimina a necessidade de passar essas dependências como parâmetros ou armazená-las como propriedades da classe. A função hello() chama database.save(this) e a função bye() chama database.commit(), demonstrando como múltiplas dependências podem ser gerenciadas de forma elegante através dos context receivers.

Foi uma pinceladinha rápida sobre esse recurso novo, que ainda pode mudar, mas já dá para ir imaginando vários usos dele.

Happy Hacking! :-)




0 comentário - Comente esse artigo!

Artigos anteriores