Introdução à programação orientada a contexto Kotlin

Esta é uma tradução de Uma introdução à programação orientada a contexto no Kotlin

Neste artigo, tentarei descrever um novo fenômeno que surgiu como um subproduto do rápido desenvolvimento da linguagem Kotlin. Essa é uma nova abordagem para projetar a arquitetura de aplicativos e bibliotecas, que chamarei de programação orientada a contexto.

Algumas palavras sobre permissões de função


Como é sabido, existem três principais paradigmas de programação ( nota de Pedant : existem outros paradigmas):

  • Programação processual
  • Programação orientada a objetos
  • Programação funcional

Todas essas abordagens funcionam com funções de uma maneira ou de outra. Vejamos isso do ponto de vista da resolução de funções ou do agendamento de suas chamadas (ou seja, a escolha de uma função que deve ser usada neste local). A programação procedural é caracterizada pelo uso de funções globais e sua resolução estática com base no nome da função e nos tipos de argumento. Obviamente, os tipos só podem ser usados ​​no caso de linguagens estaticamente tipadas. Por exemplo, no Python, as funções são chamadas pelo nome e, se os argumentos estiverem incorretos, uma exceção será lançada no tempo de execução durante a execução do programa. A resolução de funções em linguagens com abordagem processual baseia-se apenas no nome do procedimento / função e seus parâmetros e, na maioria dos casos, é feita estaticamente.

Um estilo de programação orientado a objetos limita o escopo das funções. As funções não são globais, elas são parte de classes e só podem ser chamadas em uma instância da classe correspondente ( nota de Pedant : algumas linguagens procedurais clássicas têm um sistema modular e, portanto, escopo; linguagem procedural! = C).

Obviamente, sempre podemos substituir uma função membro de uma classe por uma função global por um argumento adicional do tipo do objeto chamado, mas, do ponto de vista sintático, a diferença é bastante significativa. Por exemplo, nesse caso, os métodos são agrupados na classe a que se referem e, portanto, é mais claramente visível que tipo de comportamento os objetos desse tipo fornecem.

Obviamente, o encapsulamento é mais importante aqui, devido ao fato de que alguns campos de uma classe ou seu comportamento podem ser privados e acessíveis apenas aos membros dessa classe (você não pode fornecer isso em uma abordagem puramente processual) e polimorfismo, graças ao qual o método realmente usado é determinado não apenas com base no nome método, mas também com base no tipo de objeto do qual é chamado. O envio de uma chamada de método em uma abordagem orientada a objetos depende do tipo de objeto definido no tempo de execução, do nome do método e do tipo de argumentos no estágio de compilação.

Uma abordagem funcional não traz nada de fundamentalmente novo em termos de resolução de funções. Normalmente, linguagens orientadas a função têm regras melhores para distinguir entre áreas de visibilidade ( nota do pedante : mais uma vez, C não são todas linguagens procedurais, existem aquelas nas quais as áreas de visibilidade são bem delimitadas) que permitem um controle mais rigoroso sobre a visibilidade das funções baseadas no sistema módulos, mas fora isso, a resolução é feita em tempo de compilação com base no tipo de argumentos.

O que é isso?


No caso da abordagem de objetos, ao invocar um método em um objeto, temos seus argumentos, mas além disso, temos um parâmetro explícito (no caso de Python) ou implícito que representa uma instância da classe chamada (a seguir todos os exemplos são escritos em Kotlin):

class A{ fun doSomething(){ println("    $this") } } 

Classes e fechamentos aninhados complicam um pouco as coisas:

 interface B{ fun doBSomething() } class A{ fun doASomething(){ val b = object: B{ override fun doBSomething(){ println("    $this  ${this@A}") } } b.doBSomething() } } 

Nesse caso, há duas implícitas para a função doBSomething - uma corresponde a uma instância da classe B e a outra surge do fechamento da instância A. O mesmo acontece no caso muito mais comum de fechamento de lambda. É importante observar que, nesse caso, funciona não apenas como um parâmetro implícito, mas também como um escopo ou contexto para todas as funções e objetos chamados no escopo lexical. Portanto, o método doBSomething realmente tem acesso a qualquer membro da classe A , público ou privado, bem como a membros do próprio B.

E aqui está Kotlin


Kotlin nos fornece um “brinquedo” completamente novo - funções de extensão . ( Observação de Pedant : na verdade, eles não são tão novos, eles também existem em C #). Você pode definir uma função como A.doASomething () em qualquer lugar do programa, não apenas dentro de A. Dentro desta função, temos um parâmetro implícito, chamado receptor, apontando para a instância A na qual o método é chamado:

 class A fun A.doASomthing(){ println(" -   $this") } fun main(){ val a = A() a.doASomthing() } 

As funções de extensão não têm acesso aos membros privados de seus destinatários, portanto o encapsulamento não é violado.

A próxima coisa importante que a Kotlin tem são os blocos de código com os receptores. Você pode executar um bloco de código arbitrário usando algo como destinatário:

 class A{ fun doInternalSomething(){} } fun A.doASomthing(){} fun main(){ val a = A() with(a){ doInternalSomething() doASomthing() } } 

Neste exemplo, ambas as funções podem ser chamadas sem um " a " extra . No início, porque a função with coloca todo o código do bloco subseqüente dentro do contexto de a. Isso significa que todas as funções nesse bloco são chamadas como se fossem chamadas no objeto (passado explicitamente) a .

A etapa final neste momento da programação orientada a contexto é a capacidade de declarar extensões como membros de uma classe. Nesse caso, a função de extensão é definida dentro de outra classe, assim:

 class B class A{ fun B.doBSomething(){} } fun main(){ val a = A() val b = B() with(a){ b.doBSomething() //   } b.doBSomething() //   } 

É importante que aqui B receba algum comportamento novo, mas somente quando ele estiver em um contexto lexical específico. Uma função de extensão é um membro regular da classe A. Isso significa que a resolução da função é feita estaticamente com base no contexto em que é chamada, mas a implementação real é determinada pela instância de A sendo passada como o contexto. A função pode até interagir com o estado do objeto a .

Envio orientado ao contexto


No início do artigo, discutimos diferentes abordagens para o envio de chamadas de função, e isso foi feito por um motivo. O fato é que as funções de extensão no Kotlin permitem trabalhar com o envio de uma nova maneira. Agora, a decisão sobre qual função específica deve ser usada baseia-se não apenas no tipo de seus parâmetros, mas também no contexto lexical de sua chamada. Ou seja, a mesma expressão em contextos diferentes pode ter significados diferentes. Obviamente, nada muda do ponto de vista da implementação, e ainda temos um objeto receptor explícito que define o envio para seus métodos e extensões descritos no corpo da própria classe (extensões de membro) - mas do ponto de vista da sintaxe, essa é uma abordagem diferente. .

Vejamos como a abordagem orientada a contexto difere da abordagem orientada a objeto clássica, usando o problema clássico de operações aritméticas em números em Java como exemplo. A classe Number em Java e Kotlin é o pai de todos os números, mas, diferentemente de números especializados como Double, ela não define suas operações matemáticas. Portanto, você não pode escrever, por exemplo, assim:

 val n: Number = 1.0 n + 1.0 //  `plus`     `Number` 

O motivo aqui é que não é possível definir consistentemente operações aritméticas para todos os tipos numéricos. Por exemplo, a divisão inteira é diferente da divisão de ponto flutuante. Em alguns casos especiais, o usuário sabe que tipo de operação é necessária, mas geralmente não faz sentido definir essas coisas globalmente. Uma solução orientada a objetos (e, de fato, funcional) seria definir um novo tipo de herdeiro da classe Number , as operações necessárias nela e usá-lo sempre que necessário (no Kotlin 1.3 você pode usar classes embutidas). Em vez disso, vamos definir um contexto com essas operações e aplicá-lo localmente:

 interface NumberOperations{ operator fun Number.plus(other: Number) : Number operator fun Number.minus(other: Number) : Number operator fun Number.times(other: Number) : Number operator fun Number.div(other: Number) : Number } object DoubleOperations: NumberOperations{ override fun Number.plus(other: Number) = this.toDouble() + other.toDouble() override fun Number.minus(other: Number) = this.toDouble() - other.toDouble() override fun Number.times(other: Number) = this.toDouble() * other.toDouble() override fun Number.div(other: Number) = this.toDouble() / other.toDouble() } fun main(){ val n1: Number = 1.0 val n2: Number = 2 val res = with(DoubleOperations){ (n1 + n2)/2 } println(res) } 

Neste exemplo, o cálculo de res é feito dentro de um contexto que define operações adicionais. Um contexto não precisa ser definido localmente; em vez disso, pode ser passado implicitamente como o receptor de uma função. Por exemplo, você pode fazer isso:

 fun NumberOperations.calculate(n1: Number, n2: Number) = (n1 + n2)/2 val res = DoubleOperations.calculate(n1, n2) 

Isso significa que a lógica das operações dentro do contexto é completamente separada da implementação desse contexto e pode ser escrita em outra parte do programa ou mesmo em outro módulo. Neste exemplo simples, um contexto é um singleton sem estado, mas contextos de estado também podem ser usados.

Também vale lembrar que os contextos podem ser aninhados:

 with(a){ with(b){ doSomething() } } 

Isso dá o efeito de combinar o comportamento de ambas as classes, no entanto, esse recurso é difícil de controlar hoje devido à falta de extensões com vários destinatários ( KT-10468 ).

O poder das corotinas explícitas


Um dos melhores exemplos de uma abordagem orientada ao contexto é usado na biblioteca Kotlinx-coroutines. Uma explicação da idéia pode ser encontrada em um artigo de Roman Elizarov. Aqui, apenas quero enfatizar que o CoroutineScope é um caso de design orientado a contexto com contexto com estado. O CoroutineScope desempenha dois papéis:

  • Ele contém o CoroutineContext , necessário para executar a coroutine e é herdado quando uma nova coroutine é iniciada.
  • Ele contém o estado da corotina pai, que permite cancelá-lo se a corotina gerada gerar um erro.

Além disso, a concorrência estruturada fornece um ótimo exemplo de uma arquitetura orientada a contexto:

 suspend fun CoroutineScope.doSomeWork(){} GlobalScope.launch{ launch{ delay(100) doSomeWork() } } 

Aqui, doSomeWork é uma função de contexto, mas definida fora de seu contexto. Os métodos de inicialização criam dois contextos aninhados que são equivalentes às áreas lexicais das funções correspondentes (nesse caso, os dois contextos são do mesmo tipo, portanto o contexto interno oculta o externo). Um bom ponto de partida para o aprendizado das corotinas Kotlin é o guia oficial.

DSL


Existe uma grande classe de tarefas para o Kotlin, que geralmente são chamadas de tarefas de criação de DSL (Domain Specific Language). Nesse caso, DSL é entendido como um código que fornece um construtor fácil de usar de algum tipo de estrutura complexa. De fato, o uso do termo DSL não é inteiramente correto aqui, pois nesses casos, a sintaxe básica do Kotlin é simplesmente usada sem truques especiais - mas ainda vamos usar esse termo comum.

Os construtores DSL são orientados ao contexto na maioria dos casos. Por exemplo, se você deseja criar um elemento HTML, primeiro precisa verificar se esse elemento específico pode ser adicionado a este local. A biblioteca kotlinx.html faz isso fornecendo extensões de classe baseadas em contexto que representam uma tag específica. De fato, toda a biblioteca consiste em extensões de contexto para elementos DOM existentes.

Outro exemplo é o construtor TornadoFX GUI . Todo o construtor do gráfico de cena é organizado como uma sequência de construtores de contexto aninhados, em que os blocos internos são responsáveis ​​por criar filhos para os blocos externos ou ajustar os parâmetros dos pais. Aqui está um exemplo da documentação oficial:

 override val root = gridPane{ tabpane { gridpaneConstraints { vhGrow = Priority.ALWAYS } tab("Report", HBox()) { label("Report goes here") } tab("Data", GridPane()) { tableview<Person> { items = persons column("ID", Person::idProperty) column("Name", Person::nameProperty) column("Birthday", Person::birthdayProperty) column("Age", Person::ageProperty).cellFormat { if (it < 18) { style = "-fx-background-color:#8b0000; -fx-text-fill:white" text = it.toString() } else { text = it.toString() } } } } } } 

Neste exemplo, a região lexical define seu contexto (que é lógico, pois representa a seção da GUI e sua estrutura interna) e tem acesso aos contextos pai.

O que vem a seguir: vários destinatários


A programação orientada a contexto fornece aos desenvolvedores do Kotlin muitas ferramentas e abre uma nova maneira de projetar a arquitetura de aplicativos. Precisamos de mais alguma coisa? Provavelmente sim.

No momento, o desenvolvimento de uma abordagem contextual é limitado pelo fato de você precisar definir extensões para obter algum tipo de comportamento de classe com contexto limitado. Isso é bom quando se trata de uma classe personalizada, mas e se queremos o mesmo para uma classe de uma biblioteca? Ou se quisermos criar uma extensão para um comportamento que já tenha escopo limitado (por exemplo, adicione algum tipo de extensão dentro do CoroutineScope)? Atualmente, o Kotlin não permite que as funções de extensão tenham mais de um destinatário. Mas vários destinatários podem ser adicionados ao idioma sem interromper a compatibilidade com versões anteriores. A possibilidade de usar vários destinatários está sendo discutida no momento ( KT-10468 ) e será emitida como uma solicitação KEEP (UPD: já emitida ). O problema (ou talvez um chip) dos contextos aninhados é que eles permitem que você cubra a maioria, se não todas, as opções para o uso de classes de tipos ( classes de tipos ), outro muito desejável dos recursos propostos. É bastante improvável que esses dois recursos sejam implementados no idioma ao mesmo tempo.

Adição


Queremos agradecer a Alexei Khudyakov, amante de Pedant e Haskell em tempo integral, por seus comentários sobre o texto do artigo e por emendas ao meu uso bastante livre dos termos. Agradeço também a Ilya Ryzhenkov pelos valiosos comentários e pela revisão da versão em inglês do artigo.

Autor do artigo original: Alexander Nozik , vice-chefe do Laboratório de Métodos Experimentais de Física Nuclear da JetBrains Research .

Traduzido por: Petr Klimay , Pesquisador do Laboratório de Física Experimental de Métodos da JetBrains Research

Source: https://habr.com/ru/post/pt477898/


All Articles