Orientação a Objetos em Kotlin
Um dos paradigmas de programação mais relevantes no desenvolvimento de sistemas modernos é a orientação a objetos. Aqui, ensinaremos como utilizá-la de maneira eficiente para modelar softwares na linguagem Kotlin.
Por que escolher Kotlin?
[editar | editar código]Java é uma das linguagens mais difundidas e reconhecidas no mundo da POO. Porém, nos últimos anos, esta tem sido alvo de múltiplas críticas por conta de sua verbosidade e falta de segurança. Kotlin, como um de seus sucessores, se aproveita da grande difusão e fama de sua linguagem-mãe, porém com maior nível de segurança e possuindo uma sintaxe mais simples. Dessa maneira, resolvendo os maiores problemas de Java, Kotlin se tornou uma das alternativas mais viáveis e populares para estruturar projetos em POO.
Agora que entendemos as vantagens desta linguagem,
Instalação
[editar | editar código]Linux (Snap)
[editar | editar código]Execute o seguinte comando em seu terminal:
sudo snap install --classic kotlin
Linux e Mac (SDKMAN)
[editar | editar código]Execute o seguinte comando em seu terminal (obter o pacote e editar o PATH):
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
Agora, basta instalar o Kotlin:
sdk install kotlin
Finalmente, cheque se a instalação deu certo:
kotlin -version
Windows
[editar | editar código]Execute o seguinte comando no seu terminal:
winget install JetBrains.Kotlin
Cheque se a instalação deu certo:
kotlin -version
IDE
[editar | editar código]Utilize a IDE de sua preferência, mas recomendamos o uso do IntelliJ. Alunos da USP possuem a licensa estudantil gratuita e pronta para uso. Agora, estamos prontos para começar a programar!
O básico
[editar | editar código]Antes de mergulharmos no paradigma em questão, vamos nos familiarizar rapidamente com a linguagem.
Compilação e Execução
[editar | editar código]Com o Kotlin instalado, para compilar e executar o seu código, basta utilizar os comandos
kotlinc nome_do_arquivo.kt
kotlin nome_do_arquivoKt
Variáveis
[editar | editar código]Existem 2 tipos de variáveis em Kotlin:
- val - variáveis imutáveis (remete a valor). Semelhante à palavra reservada final em Java;
- var - variáveis mutáveis (remete a variável). As variáveis mais comuns, existentes em qualquer linguagem.
Tipos
[editar | editar código]Principais tipos numéricos:
- Int - números inteiros de 32 bytes;
- Long - números inteiros de 64 bytes;
- Float - números com ponto flutuante de 32 bytes;
- Double - números com ponto flutuante de 64 bytes.
Tipo booleano:
- Boolean - assume valor true ou false (diferentes de 1 ou 0).
Tipos de texto:
- Char - armazena um único caractere;
- String - armazena uma sequência de caracteres (chars). São imutáveis.
Coleções:
- Arrays - coleção mutável elementos de mesmo tipo;
- Listas Imutáveis - coleção imutável que pode ou não ser tipada;
- Listas Mutáveis - coleção mutável que pode ou não ser tipada.
Tipos especiais:
- Any - tipo genérico (superclasse de todos os tipos);
- Unit - equivalente ao void em Java (funções sem valor de retorno);
- Nothing - funções que nunca retornam (como exceptions).
Principais métodos em números:
- n.toTipo() - converte tipo de n para Tipo (n.toInt(), n.toDouble()...);
- n.inc() e n.dec() - soma ou subtrai 1 do valor de n (apenas para tipos inteiros);
- n.roundToInt() - arredonda matematicamente o valor de n para um inteiro (apenas para double).
Principais métodos em textos:
- str.length - devolve o tamanho da String str;
- str.uppercase() e str.lowercase() - devolve uma String igual a str com Chars maiúsculos ou minúsculos;
- str.reversed() - devolve uma String na ordem contrária a str;
- str.contains(pattern) - checa se str contém a substring pattern. Retorna true ou false.
Declaração de variáveis
[editar | editar código]Em geral, no Kotlin, utilizamos a declaração explícita:
val nome: String = "Dijkstra"
var idade: Int = 72
Porém, também é possível declarar variáveis implicitamente:
val nome = "Turing"
var idade = 41
Para declarar coleções, utilizamos funções:
val fib = arrayOf(0, 1, 1, 2, 3, 5)
val lista = listOf(0, 1, "bcc", 'x')
val listaM = mutableListOf(0, 1, "bcc", 'x')
Note que podemos alterar os valores da lista2 mesmo sendo val. Isso se dá pois a imutabilidade de val se refere ao ponteiro da lista, e não aos seus elementos:
val lista1 = mutableListOf(1, 0, "ime", 'y')
val lista2 = mutableListOf(0, 1, "bcc", 'x')
var lista3 = mutableListOf('a', 'b', 3)
var lista4 = mutableListOf('c', 4)
lista2[0] = "ime" // lista2 == ["ime", 1, "bcc", 'x']
lista2 = lista1 // gera um erro
lista3 = lista4 // lista3 == ['c', 4] (lista 3 aponta para endereço de lista 4)
Getters e Setters
[editar | editar código]Getters e Setters são um diferencial do Kotlin. Por trás da criação de uma variável, temos o seguinte:
// Criação da variável nome
var nome : String = "João"
// Por trás das câmeras...
get() = field
set(valor) { field = valor }
Claro, não precisamos ser explícitos em declarar getters e setters toda vez que criamos uma variável. Mas o Kotlin nos dá a liberadade de editarmos essas funções de alguma variável, caso convenha.
// Criação da variável nome com um getter customizado
var nome : String = "João"
get() = field.uppercase()
// Criação da variável vida com setter customizado
var vida : Int = 0
set(valor) {
field = if(valor > 0) valor else 0
}
println(nome)
vida = -1
println(vida)
JOÃO
0
Note que utilizamos a palavra reservada field. Em Kotlin, field nada mais é do que o valor contido em uma variável. Portanto, para nome, field.uppercase() retorna o valor de nome em caixa alta, enquanto a atribuição de valor que fazemos a field na variável vida atribui, portanto, valor à variável vida.
Funções
[editar | editar código]Em Kotlin, declaramos funções com a palavra reservada fun, seguindo o seguinte formato:
fun nome_da_funcao(argumento_1: Tipo, argumento_2: Tipo): Tipo_de_retorno {
Corpo...
}
Segue o exemplo de duas funcões que calculam o fatorial de n — uma iterativa e uma recursiva.
fun fatorialIter(n: Int): Long {
var fat : Long = 1
for(i in 1 .. n) fat *= i
return fat
}
fun fatorialRec(n: Int): Long {
if(n == 0 || n == 1) return 1
return n * fatorialRec(n-1)
}
Orientação a Objetos
[editar | editar código]Finalmente, podemos ir ao que interessa. Como qualquer curso sobre POO, iniciaremos por classes.
Classes
[editar | editar código]Criação
[editar | editar código]Em Kotlin, para criarmos uma classe, assim como em Java, utilizamos a palavra reservada class, com a seguinte sintaxe:
class nome_da_classe(argumentos) {
Corpo da classe...
}
Simples e conciso. Podemos utilizar um simples exemplo que nos acompanhará para o resto desse tutorial. Como a POO é completamente dominante na área de desenvolvimento de jogos, criaremos a classe "personagem".
Visibilidade
[editar | editar código]Em Kotlin, temos 4 modificadores de visibilidade de atributos e métodos: public, internal, protected e private.
- public - Acessíveis a todos, sem restrições;
- internal - Acessíveis àqueles que estão dentro de um mesmo módulo (projeto);
- protected - Acessíveis apenas à classe original e a suas subclasses;
- private - Acessíveis apenas dentro da classe original.
Métodos
[editar | editar código]Dentro de nosso personagem, podemos implementar algumas funções que executam certas ações que personagens genéricos de um jogo executariam. Chamamos as funções de uma classe específica de seus métodos.
class personagem(private val nome: String, private var vida : Int, private var posX: Double, private var posY: Double) {
// "pos" é a posição do personagem na tela
// Funções get (sintaxe especial: simples e concisa)
fun getNome() = nome
fun getVida() = vida
fun getPos() = Pair(posX, posY)
// Constantes de movimento
val passo : Double = 0.5
// Funções de movimento
fun andarFrente() : Unit {
posY += passo
}
fun andarTrás() : Unit {
posY -= passo
}
fun andarEsquerda() : Unit {
posX -= passo
}
fun andarDireita() : Unit {
posX += passo
}
// Função vida (usaremos no futuro)
fun setVida(newVida : Int) {
vida = newVida
}
}
Construtores e Instância
[editar | editar código]Note que em nenhum momento explicitamente declaramos um construtor. Isso se dá pois, em Kotlin, os argumentos da classe já desempenham o papel de instânciar objetos com valores específicos em cada um de seus atributos, simplificando o nosso trabalho. Portanto, para instanciarmos um personagem, teríamos o seguinte:
val personagem1 : Personagem = Personagem("Steve", 20, 0.0, 0.0)
Porém, para casos mais específicos, existem também os construtores secundários, declarados da seguinte maneira:
class Personagem(val nome : String) {
var vida: Int = 0
constructor(val nome : String, var vida : Int) : this(nome) {
this.vida = vida
}
...
}
Aqui, o construtor primário de "Personagem" aceita apenas o nome como argumento. Caso queira instanciar um personagem já com o atributo vida, podemos usar o construtor secundário. this(nome) simplesmente chama o construtor primário, utilizando o argumento nome passado para o construtor secundário como argumento para o construtor primário. Por simplicidade, usaremos apenas construtores primários nesse tutorial. Em alguns casos, talvez seja interessante executar algum código além do construtor assim que instanciarmos um objeto de uma certa classe. Para isso, temos a palavra reservada init. O bloco init é executado logo após o construtor da classe.
class Personagem(...) {
init{
println("Você instânciou o personagem $nome".)
// Podemos acessar variáveis em Strings pelo operador $
// num = 1
// "$num" == "1"
}
}
Com um objeto da classe Personagem criado, invocarmos os métodos dos objetos é bem simples.
println(personagem.getNome())
println(personagem.getVida())
println(personagem.getPos())
personagem.andarFrente()
personagem.andarFrente()
personagem.andarEsquerda()
println(personagem.getPos())
Output:
Steve
20
(0.0, 0.0)
(1.0, -0.5)
Claro, a classe personagem é extremamente simples e não possui muitas informações que gostaríamos de utilizar em um jogo. Felizmente, ao invés de colocarmos todas as características de todos os personagens dentro dessa mesma classe, a POO nos fornece um conceito crucial para o desenvolvimento de sistemas no geral: a herança. Assim, podemos ter outras classes para especificarmos o que cada tipo de personagem faz separadamente.
Nested e Inner Classes
[editar | editar código]Antes de partirmos para o próximo tópico, vale ressaltar a palavra reservada inner. Assim como em Java, podemos criar classes dentro de classes da seguinte maneira:
class Personagem() {
val nome : String
class Exemplo() {
println(nome)
}
}
Esse código NÃO FUNCIONA. Apesar de não ser tão intuitivo à primeira vista, classes internas não têm acesso aos atributos das classes externas. Para que isso seja possível, devemos declará-las explicitamente com a palavra inner:
class Personagem() {
val nome : String
inner class Exemplo() {
println(nome)
}
}
Agora sim, esse código funciona.
Herança
[editar | editar código]Definição
[editar | editar código]A herança, em POO, simplesmente define uma relação hierárquica entre as classes de um projeto. Como na biologia, classes filhas (ou subclasses) herdam atributos e métodos de classes pais (ou superclasses). Assim, podemos ter um grande poder de modularização no nosso projeto, sem precisarmos repetir linhas e linhas de código em classes que desempenham um papel similar. Podemos, portanto, implementar a classe "protagonista", que herda da classe "personagem".
Declaração
[editar | editar código]Antes de tudo, devemos fazer uma modificação à classe "personagem". Por padrão, todas as classes em Kotlin são declaradas como final (ou seja, não podem ter filhos). Isso é diferente de Java, por exemplo. Logo, para que nossa classe possa ter filhos, devemos declará-la com a palavra reservada open.
open class Personagem(...) { ... }
A sintaxe que usaremos é a seguinte:
class Protagonista(...) : Personagem(...) { ... }
Inicialização da subclasse
[editar | editar código]Para criarmos a subclasse, devemos passar não apenas os argumentos que queremos em "Personagem", mas também os novos atributos do "Protagonista". Podemos também já fixar valores diretamente em sua criação.
// Aqui, recebemos as posições como argumentos, dinheiro como um novo atributo
// da classe Protagonista (inicialmente 0) e fixamos os valores "Steve" e 20
// em Personagem
class Protagonista(posX : Double, posY : Double, var dinheiro : Int = 0) : Personagem("Steve", 20, posX, posY) {
...
}
Métodos
[editar | editar código]Quando uma classe herda a outra, herda também seus métodos. Porém, isso não nos impede de adicionar métodos novos à classe filha.
class Protagonista(posX : Double, posY : Double, var dinheiro : Int = 0) : Personagem("Steve", 20, posX, posY) {
fun curar(cura : Int) {
setVida(getVida() + cura)
}
}
Note que podemos chamar o método getVida(), que é da classe "Personagem", como se fosse um método da classe "Protagonista".
Além de podemos adicionar métodos novos, uma grande vantagem da POO é que podemos sobrescrever métodos e atributos das classes herdadas em seus filhos.
Sobrescrita de métodos
[editar | editar código]A sobrescrita de métodos (method overriding) possibilita que sejamos ainda mais específicos quando tratando de hierarquia de classes. Com essa técnica, não ficamos presos ao que criamos anteriormente nas superclasses, podendo ter uma liberdade maior ao criar filhos que, apesar de implementarem uma mesma função, talvez queiram executá-la de maneira um pouco diferente, inclusive gerando resultados diferentes dos que obteríamos utilizando os métodos dos pais.
Em Kotlin, para evitar a sobrescrita acidental, precisamos declarar explicitamente quais métodos podem ser sobrescritos, e quais métodos são sobrescrições, utilizando as palavras reservadas open e override.
Ou seja, vamos alterar novamente as classes que implementamos para demonstrar a sobrescrita.
// Aqui, as reticências (...) são apenas repetições dos códigos anteriores
class Personagem (...) {
...
fun setPosY(y : Double) : Unit {
posY = x
}
open fun andarFrente() {
posY += passo
}
...
}
class Protagonista(...) : Personagem(...) {
...
override fun andarFrente() {
setposY(getPosY() + 2*passo)
}
...
}
Aqui, atualizamos o nosso protagonista. Podemos, também, sobrescrever atributos de maneira similar, obtendo um resultado parecido.
// Aqui, as reticências (...) são apenas repetições dos códigos anteriores
class Personagem (...) {
...
open val passo : Double = 0.5
...
}
class Protagonista(...) : Personagem(...) {
...
override val passo : Double = 1.0
...
}
Em ambos os casos demonstrados acima, temos o mesmo resultado: o protagonista anda para frente 2 vezes mais rápido que os demais personagens. Vale observar que podemos utilizar a palavra reservada super para fazer referências à classe pai. Por exemplo:
class Personagem (...) {
...
open fun andarFrente() {
posY += passo
}
...
}
class Protagonista(...) : Personagem(...) {
...
override fun andarFrente() {
super.andarFrente()
super.andarFrente()
}
...
}
Aqui, obtemos o mesmo efeito que antes, mas de maneira mais simples (não precisamos criar a função auxiliar na classe pai). Também podemos utilizar super para referenciar atributos, construtores e até diferenciar entre superclasses pelo operador <>.
interface Personagem () {
val nome: String
}
interface Jogador () {
val nome: String
}
class Protagonista(...) : Personagem(), jogador() {
super<Personagem>.nome // nome do Personagem
super<Jogador>.nome // nome do Jogador
}
Interfaces
[editar | editar código]Definição
[editar | editar código]Em POO, interfaces são, basicamente, contratos em que são especificados métodos, seus argumentos e valores de retorno sem que seja explicitada uma implementação. Assim, podemos garantir que várias classes diferentes que implementem uma mesma interface, apesar de poderem seguir caminhos completamente diferentes por trás dos panos, terão resultados e uma interação com o usuário dentro de um mesmo padrão.
Criação
[editar | editar código]Para criar interfaces em Kotlin, basta seguir a seguinte sintaxe:
interface nome_da_interface {
val atributo_1 : tipo_1
var atributo_2 : tipo_2
fun função_1(arg_1 : tipo_arg_1, ...)
fun função_2(arg_2 : tipo_arg_2, ...)
...
}
Exemplo
[editar | editar código]Aqui, podemos criar uma interface que será implementada por todas as classes que conceitualmente são móveis em nosso projeto.
interface móveis {
var posX : Double
var posY : Double
fun mover(x : Double, y : Double) : Unit
}
class Personagem(...) : móveis {
...
fun mover(x : Double, y : Double) : Unit {
posX = x
posY = y
}
// Podemos, então, implementar as outras funções pela interface
fun andarFrente() : Unit {
mover(posX, posY + passo)
}
...
}
Assim, temos uma garantia de consistência de dados muito maior, continuando com a liberdade total quando se trata da maneira que implementaremos cada função.
Classes Abstratas
[editar | editar código]Retornando aos modificadores de classes, podemos declarar classes como abstract. Classes abstratas desempenham um papel similar às interfaces. Ambas possuem a propriedade de não poderem ser instanciadas em objetos por si só. Porém, diferentemente das interfaces, podemos ter implementações em seus métodos.
Podemos enxergar classes abstratas como um meio termo entre classes e interfaces. Mas vale notar que enquanto classes implementam interfaces, elas herdam de classes abstratas. Conceitos parecidos, mas tecnicamente diferentes.
Outras Estruturas
[editar | editar código]Data Class
[editar | editar código]Como uma pequena facilidade para nós, o Kotlin possui um tipo especial de classe chamadas de data classes. Essas classes são especiais pois, desde sua criação, já possuem alguns métodos implementados pelo próprio Kotlin. São eles:
- toString();
- equals();
- hashcode();
- copy();
- componentN.
Todos métodos muito relevantes e úteis no desenvolvimento de sistemas. Por padrão, essas classes obrigatoriamente são declaradas com pelo menos um atributo em seu construtor primário (como estivemos fazendo).
Porém, é importante notar que essas classes não podem se tornar superclasses. Apesar de poderem herdar de outras classes, não podemos declará-las com a palavra reservada open.
Podemos, por exemplo, implementar a posição como uma data class, assim tendo uma maneira de a imprimir sem precisarmos implementá-la.
data class Posição(var x : Double, var y : Double)
val posição : Posição = Posição(0.2, 0.6)
println(posição)
println(posição.hashcode()) // Não é exatamente muito interessante nesse contexto...
Output:
posição(x=0.2, y=0.6)
813695069
Companion Object
[editar | editar código]Observamos anteriormente como afinarmos nossa especificidade com a técnica do method overriding. Porém, muitas vezes podemos querer seguir a direção contrária e generalizar um atributo ao máximo. Para isso, o Kotlin fornece estruturas chamadas companion objects.
Basicamente, estes são atributos estáticos de classe. Ou seja, ao invés de criar uma mesma variável que sempre terá o mesmo valor para cada instância de um objeto, um único companion object existe para todos os objetos de sua classe.
Aqui, podemos simplesmente utilizar a gravidade como um exemplo. Sendo uma constante imutável da natureza, queremos implementá-la de tal maneira em nosso projeto. Por simplicidade, criaremos a gravidade como um atributo da classe "Personagem"
class Personagem(...) {
...
companion object {
val gravidade : Double = 10.0
}
...
}
Objects
[editar | editar código]Sim. Em Kotlin, temos a palavra reservada object. Um object nada mais é do que um singleton, ou seja, se comporta como uma classe que suporta apenas uma instância. Portanto, não precisam de construtores e são instanciados imediatamente. Por exemplo:
object Espada {
val dano: Int = 10
var durabilidade: Int = 100
fun atacou() : Unit {
durabilidade = durabilidade - 2
}
}
Aqui, o objeto espada já existe no escopo de nosso código. Podemos acessá-lo como se fosse uma instância de uma classe "Espada" (que não existe):
println(Espada.dano)
Espada.atacou()
Espada.atacou()
Espada.atacou()
println(Espada.durabilidade)
10
94
Esses objects podem herdar classes e implementar interfaces. Mas claro, não podem ter filhos.
Is e When
[editar | editar código]Uma possibilidade muito elegante que Kotlin nos fornece é a transformação (casting) automática de variáveis em tipos compatíveis. Será mais fácil de entender com um exemplo:
fun imprimir(objeto : Any) : Unit {
if(objeto is String) {
println(objeto)
println(objeto.length)
}
}
Aqui, Kotlin verifica se objeto é uma String. Se sim, o bloco é executado e, automaticamente, objeto é transformado em uma String denro do bloco if. Generalizando esse conceito, podemos utilizar a palavra when:
fun imprimir(objeto : Any) : Unit {
when(objeto) {
is String -> println("$objeto tem tamanho $objeto.length!")
is Int -> println("$objeto é um inteiro!")
is Personagem -> println("$objeto.nome tem vida $objeto.vida")
}
}
Aqui, a depender do tipo da variável objeto, nossa função se comporta de maneiras diferentes, mais adequadas ao nosso caso. É importante notar que is apenas funciona para variáveis imutáveis (val).
Conclusão
[editar | editar código]Kotlin é uma linguagem que fornece diversas facilidades ao programador sem cair na armadilha dos açúcares sintáticos de linguagens como o Python. É super consistente e segura, superando sua linguagem mãe Java em incontáveis aspectos (principalmente na verborragia). Esse foi um tutorial inicial sobre a programação Orientada a Objetos em Kotlin, criando uma relação entre os conceitos muito conhecidos desse paradigma e a sintaxe e especialidades da linguagem tão aclamada. Agora, você deve ter uma sólida base para se aventurar no mundo da POO em Kotlin de forma eficaz e consistente, tendo conhecimento dos principais conceitos tão importantes para a construção de qualquer projeto de software. Portanto, aproveite e vá programar!