Typeclasses: Muito mais do que uma interface
Vinicius Santos
Posted on March 25, 2024
Se você já tem uma certa experiência em programação, há grandes chances de que você já programou em uma linguagem orientada a objetos ou já aplicou conceitos de orientação a objetos em alguma linguagem que permita isso. Programação orientada a objetos é um padrão muito difundido em nossa área e muitas vezes usamos essa língua franca para introduzir novos conceitos aos estudantes, e um desses conceitos é typeclasses. Se você estudou superficialmente uma linguagem funcional, provavelmente já viu algum livro ou tutorial comparando uma typeclass com uma interface, ou seja, ambas são ferramentas para declarar comportamentos/métodos/funções que algo deve implementar. Ao nível didático, esta comparação funciona, mas ignora algumas características que demonstram as principais diferenças entre essas ferramentas, suas peculiaridades e suas vantagens. Meu objetivo neste tutorial é mostrar que, mesmo que ambas as ferramentas tenham intuitos similares, existe um mundo de diferença que denota cada um dos paradigmas. Falaremos muito mais sobre typeclasses do que interfaces, mas usualmente compararemos algo com interfaces.
Entendendo como funciona
Expandindo sobre o que foi dito anteriormente, uma typeclass é um mecanismo que define funções que serão compartilhadas pelos tipos que pertencem a essa typeclass. Importante ressaltar é que, quando criamos as funções de uma typeclass para um tipo, dizemos que criamos uma instância de uma typeclass para aquele tipo e, nesse caso, o conceito de instância aqui é completamente diferente do conceito na POO.
Para efeito de demonstração, implementaremos um novo tipo:
data List a = Nil | Cons a (List a) deriving (Show)
Em breve explicarei o que exatamente deriving (Show) está fazendo.
Esse tipo define uma lista recursivamente, onde temos uma constante (Cons) concatenada com outra lista. O Haskell possui algumas typeclasses na sua biblioteca padrão que já padronizam algumas funções, então implementaremos uma instância para a typeclass Eq, que define as seguintes funções:
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
Perceba que Eq define as operações de igualdade e, portanto, caso queira comparar se duas listas são iguais, precisamos definir uma instância dessa typeclass para nosso tipo. Vamos criar duas listas x e y:
x = Cons 1 (Cons 2 (Cons 3 Nil))
y = Cons 1 (Cons 1 (Cons 3 Nil))
Se rodarmos x == y, olha o que é mostrado na tela:
• No instance for ‘Eq (List Integer)’ arising from a use of ‘==’
There are instances for similar types:
instance Eq a => Eq [a] -- Defined in ‘GHC.Classes’
• In the expression: x == y
In an equation for ‘it’: it = x == y
Esse erro está dizendo que não existe uma instância da typeclass Eq para o nosso tipo List, portanto, bora implementar e resolver esse erro:
instance (Eq a) => Eq (List a) where
Nil == Nil = True
Cons x xs == Cons y ys = x == y && xs == ys
_ == _ = False
agora se rodarmos novamente nossa expressão temos que:
Se você ler o erro novamente, perceberá que ele diz o seguinte ‘There are instances for similar types’, ou seja, o compilador foi inteligente o suficiente para detectar que o tipo que criamos é similar ao outro tipo, neste caso o [a], e a instância poderia ser modificada e reutilizada. Daí nasce o deriving, onde você pode pedir para o compilador implementar uma instância daquela typeclass para você. No nosso caso, eu pedi para o compilador implementar uma instância para a typeclass Show, que permite que um tipo seja mostrado no terminal. É interessante lembrar que o mecanismo para fazer isso é muito mais complexo e necessariamente não funciona de modo idêntico à minha explicação. O uso de uma licença poética aqui é necessário quando provavelmente só os grandes magos anciãos responsáveis pelo compilador sabem o que realmente está acontecendo debaixo dos panos.
Leitores mais atentos notaram que a criação de uma instância da typeclass para um tipo não é obrigatória no momento de criação do tipo, diferente das interfaces, onde, ao nível de código, a criação da sua classe acontece no mesmo momento da implementação dos métodos da sua interface. Por exemplo, em java, fazemos Class List implements Eq
, não podendo primeiro criar a classe e depois implementar os métodos da interface. As principais vantagens disso são a extensibilidade e condicionalidade, já que podemos criar uma instância de uma typeclass para um tipo que eu ou outra pessoa implementou em qualquer parte do meu código. Podemos criar uma nova typeclass e estender um tipo da biblioteca padrão, por exemplo. Veja que, a partir desse ponto, a diferença entre typeclass e interface está se tornando clara. A seguir, demonstraremos como cada ferramenta difere no quesito Polimorfismo.
Obs: É interessante notar que a definição de uma instância de uma classe permite restrições de tipo. Veja que definimos a instância desse modo (Eq a) => Eq (List a), onde estou dizendo que minha instância de Eq para List a só funciona se a já tiver implementado a classe Eq.
Polimorfismo
Polimorfismo é um conceito onde uma entidade pode assumir mais de uma forma. Na programação, uma entidade no código, como um objeto, pode assumir diferentes tipos. Um exemplo clássico é a interface List
da std do Java. Quando criamos um argumento num método que recebe um valor do tipo List
, este valor pode ser um arrayList
, um Stack
ou um Vector
, ou seja, esse argumento é polimórfico. Na programação, você encontrará normalmente 3 tipos de polimorfismo: polimorfismo de subtipo (ou subtype), polimorfismo ad hoc e polimorfismo paramétrico.
Talvez a maior diferença entre typeclass e interface acontece em como cada ferramenta implementa polimorfismo. Nas interfaces de POO, nós temos o polimorfismo de subtipo. Voltamos ao exemplo da interface List
, quando criamos classes que implementam essa interface, estamos criando uma árvore de tipos, onde o List
é a raiz e todas as outras classes são nós filhos, ou seja, subtipos do tipo List
, como podemos ver na figura a seguir:
Fonte: https://www.programiz.com/java-programming/list
Assim, algo que tem como tipo List
aceita List
e qualquer um dos seus subtipos:
List<Integer> listVector = new Vector<>();
List<Integer> listLinkedList = new LinkedList<>();
List<Integer> listArrayList = new ArrayList<>();
List<Integer> listStack = new Stack<>();
Como em programação funcional não existe o conceito de herança como vemos em POO, não existe o conceito de subtipos do mesmo jeito que em POO. Portanto, uma typeclass usa outro tipo de polimorfismo, o polimorfismo ad hoc (unido ao polimorfismo paramétrico, o famoso Generics do Java e Typescript). No polimorfismo ad hoc, o comportamento do seu programa varia conforme o tipo da entrada. Por exemplo, no JavaScript, o operador + funciona diferente conforme o tipo dos argumentos passados, se eu passar dois inteiros, ele irá somar os valores, se eu passar duas strings, ele irá concatenar as strings, isso é um tipo de polimorfismo ad hoc chamado operator overloading.
Assim, temos total liberdade de criar comportamentos específicos para cada instância de uma typeclass, desde que a nossa implementação siga a assinatura de função definida. Aliado com a extensibilidade de um typeclass, isso permite adicionar comportamentos novos ou diferentes a praticamente qualquer tipo. Voltando ao exemplo do JavaScript, se tentarmos somar duas strings em Haskell, ou seja, somar “aaa” com “bbb”, recebemos o seguinte erro:
• No instance for ‘Num String’ arising from a use of ‘+’
• In the expression: "aaa" + "bbb"
In an equation for ‘it’: it = "aaa" + "bbb"
O que mostra que não definimos o comportamento do operador + para o tipo String, ou seja, não criamos uma instância da typeclass Num para o tipo String, que define os operadores de soma, subtração e multiplicação. Então implementaremos isso:
instance Num String where
(+) = (++)
(*) = undefined
(-) = undefined
abs = undefined
signum = undefined
fromInteger = undefined
Com essa instância, implementamos um comportamento para o operador + para strings, onde somar duas strings equivale a concatená-las. Como as outras funções não são tão importantes para o nosso exemplo, eu decidi não implementá-las.
Perceba o quão poderoso é a extensibilidade que as typeclasses trazem para o programador, podemos escolher quando e como definir um certo comportamento.
Superclasses
Algo que não existe em typeclasses é o conceito de OOP de hierarquia, o que poderá gerar a seguinte dúvida: “eu gostaria de criar um typeclass, mas para que eu consiga implementar as funções nessa typeclass eu preciso que as funções de outras typeclasses estejam implementadas, e se não existe hierarquia como em OOP, como eu posso garantir que essas funções vão ser implementadas?”. Perceba que eu nunca disse que Haskell não tem hierarquia, mas sim que Haskell não implementa hierarquia como em OOP. Para definir uma hierarquia entre tipos, Haskell implementa o conceito de superclasse, onde uma classe x é dita superclasse de uma classe y se é necessário implementar uma instância da classe x antes de implementar uma instância da classe y. Por exemplo, a classe Eq é uma superclasse da classe Ord, que define as seguintes funções:
class Eq a => Ord a where
compare :: a -> a -> Ordering
(<) :: a -> a -> Bool
(<=) :: a -> a -> Bool
(>) :: a -> a -> Bool
(>=) :: a -> a -> Bool
max :: a -> a -> a
min :: a -> a -> a
A definição da superclasse é dada nesse trecho Eq a => Ord a. Veja que a sintaxe para definir uma superclasse é bem similar a restrição de tipos para parâmetros de função, já que estamos fazendo uma restrição em que tipos podem implementar a classe Ord.
A Herança em typeclasses é um pouco diferente do que estamos acostumados em POO, onde temos o “é-um(a)”, por exemplo, funcionário é uma pessoa. Aqui, está mais próximo de dizer que a deve implementar o comportamento descrito em Eq antes de implementar o comportamento descrito em Ord.
Typeclasses com vários parâmetros
Pense no seguinte problema, você quer criar uma typeclass chamada Container que permite você fazer algumas operações em estruturas do tipo Container, como arrays, Sets, Maps, etc. Nossa typeclass ficaria assim:
class Container a where
insert :: a -> e -> a
member :: a -> e -> Bool
Ambas as funções recebem dois argumentos de tipos distintos e retornam alguma coisa.
Agora instanciaremos a classe container para arrays:
instance Container [a] where
insert l e = e : l
member (x : xs) e = (x == e) || member xs e
Infelizmente, esse código não compila, pois o compilador não tem nenhum tipo de informação sobre o tipo e
. Precisamos definir o tipo e
de algum modo. Felizmente, podemos fazer isso usando typeclasses com vários parâmetros. Basta ativar essa extensão e modificar nosso código:
{-# LANGUAGE MultiParamTypeClasses #-}
class (Eq e) => Container c e where
insert :: c -> e -> c
member :: c -> e -> Bool
instance (Eq e) => Container [e] e where
insert l e = e : l
member (x : xs) e = (x == e) || member xs e
Utilizamos a extensão MultiParamTypeClasses para ativar essa funcionalidade e finalmente conseguirmos implementar nossa typeclass. Perceba que agora precisamos definir os dois tipos no momento da instanciação, assim conseguimos dar alguma informação sobre os dois tipos para o compilador e garantir um certo nível de type safety, algo único das typeclasses.
Dispatch
Quando estamos trabalhando com polimorfismo em interfaces e typeclasses, em algum momento o compilador ou programa precisa decidir qual função/método ele deve chamar, e essa decisão é chamada de dispatch. Existem dois tipos de dispatch, o dinâmico e o estático. No dispatch dinâmico, o processo de escolha de qual método executar acontece em tempo de execução e com a informação disponível durante o tempo de execução, enquanto no dispatch estático esse processo de escolha acontece no tempo de compilação.
Considerando tudo o que foi dito até agora e qualquer tipo de conhecimento que você tenha sobre programação funcional, faz sentido presumir que o Haskell apenas utiliza dispatch estático, mas não funciona bem assim. Sim, interfaces utilizam dispatch dinâmico, mas Haskell utilizam ambos em determinados casos. Veja o seguinte exemplo:
class (Eq e) => Container c e where
insert :: c -> e -> c
member :: c -> e -> Bool
instance (Eq e) => Container [e] e where
insert l e = e : l
member (x : xs) e = (x == e) || member xs e
foo :: (Container c e) => c -> e -> Bool
foo c e = member (insert c e) e
Temos uma typeclass container que define duas funções e uma instância dessa typeclass para um tipo paramétrico que implementa as funções da classe Eq. Na função foo, temos uma restrição de tipos e recebemos dos argumentos para nossa função. Mas internamente, quando fazemos a restrição de tipos, o GHC transforma a restrição em um argumento para a função, onde esse argumento é um dicionário que contém todos os métodos da classe container para os tipos ‘c’ e ‘e’. Tudo é feito de um modo altamente otimizado utilizando esse conceito.
Agora imagine que temos o seguinte tipo:
data Box where
MkBox :: Show a => a -> Box
Utilizando GADTs aqui para restringir os tipos aceitos pelo construtor para apenas tipos que são instância da classe Show.
E definimos uma instância da classe show para nosso tipo
instance Show Box where
show (MkBox x) = show x
Agora imagine a seguinte situação: temos um programa que recebe um input de um usuário, insere-o dentro do nosso Box e fica passando esse valor por todo canto desse programa. Em algum momento do programa, uma função faz um show nesse box. O compilador não tem informações suficientes em tempo de compilação para saber qual show utilizar naquele momento, então é necessário um mecanismo como dispatch dinâmico para conseguir descobrir qual show usar. Veja que, mesmo que garantias em tempo de compilação sejam incríveis e possam eliminar muitas dores de cabeça, ainda assim, são necessárias algumas funcionalidades de nível de runtime.
Associated Data Types
Talvez a coisa mais interessante de typeclasses seja os associated data types. Em uma typeclass, além de declarar as funções que devem ser implementadas na instância de um tipo, também podemos declarar tipos. Com essa funcionalidade, cada instância de uma typeclass pode declarar tipos específicos para sua instância, fornecendo uma maneira de encapsular tipos específicos da instância na própria definição da typeclass. Veremos como isso funciona.
Imagine que você, assim como eu, é um amante fervoroso do JavaScript/TypeScript e quer fazer com que o Haskell seja um pouco mais parecido com a sua amada linguagem. Seu primeiro objetivo é implementar algo similar ao typecasting do JavaScript e decidiu fazer isso usando typeclasses. A primeira etapa é criar uma typeclass que define nosso comportamento:
class Transformable a where
transform :: a -> b
Agora iremos definir transformable para Bool:
instance Transformable Bool where
transform True = 1
transform False = 0
Infelizmente nossa instância não compila, pois nosso tipo b é muito genérico. Aqui podemos utilizar associated data types para associar um tipo a nossa typeclass
{-# LANGUAGE TypeFamilies #-}
class Transformable a where
type Converted a
transform :: a -> Converted a
Agora, quando criamos uma instância da nossa classe Transformable, além de implementar o método transform, também precisamos criar o tipo converted a. Assim, conseguimos finalmente implementar nossa instância Transformable para Bool
instance Transformable Bool where
type Converted Bool = Int
transform True = 1
transform False = 0
Conclusão
Podemos ver que inicialmente, a comparação de typeclasses com interfaces funciona, mas quando nos olhamos mais detalhadamente, cada um esconde um mundo de complexidade e conceitos poderosos e que definem seus respectivos paradigmas.
Posted on March 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024