ReasonML: Tipos Variantes

O que são, como usar e exemplos de auto-recursão

Esse artigo faz parte da série “O que é ReasonML?”.

Tipos Variantes, Variant, é um tipo de dado encontrado em muitas linguagens de programação funcional. Eles são um ingrediente importante em ReasonML, que não está disponível em linguagens do estilo C (C, C++, Java, C#, etc). Nesse artigo, iremos ver como eles funcionam.

1. Variantes como conjuntos de símbolos (enums)

As variantes permitem definir conjuntos de símbolos. Quando usados dessa forma, eles são semelhantes aos enums em linguagens do estilo C. Por exemplo, o seguinte tipo de color define os símbolos para seis cores:

type color = Red | Orange | Yellow | Green | Blue | Purple;

Existem dois elementos na definição deste tipo:

  • O nome do tipo, color, que deve começar com uma letra minúscula.
  • Os nomes dos construtores (Red, Orange, etc), que devem começar com letras maiusculas. Por que construtores são chamados de construtores, isso ficará mais claro adiante, uma vez que usamos variantes como estruturas de dados.

Os nomes dos construtores devem ser únicos dentro do escopo atual. Isso permite que ReasonML facilmente deduza seus tipos:

# Purple;
- : color = Purple

As variantes podem ser processadas através do operador switch e pattern matching:

let invert = (c: color) =>
switch c {
| Red => Green
| Orange => Blue
| Yellow => Purple
| Green => Red
| Blue => Orange
| Purple => Yellow
};

Aqui, os construtores são usados tanto como padrões/casos (o lado esquerdo do =>) e valores (lados direito do =>). Isto é o invert() em ação:

# invert(Red);
- : color = Green
# invert(Yellow);
- : color = Purple

1.1 Dica: substituindo booleanos com variantes

Em ReasonML, as variantes são muitas vezes uma escolha melhor do que valores booleanos. Veja, por exemplo, a definição de função a seguir. (Lembre-se que, em ReasonML, o principal parâmetro vai no final, para permitir o currying).

let stringOfContact(includeDetails: bool, c: contact) => ···;

É assim que o stringOfContact é invocado:

let str = stringOfContact(true, myContact);

Não é claro o que o valor booleano no final está fazendo, você pode melhorar esta função através de um parâmetro rotulado.

let stringOfContact(~includeDetails: bool, c: contact) => ···;
let str = stringOfContact(~includeDetails=true, myContact);

Para ser mais auto-descritivo, vamos introduzir uma variante para o valor de ~includeDetails:

type includeDetails = ShowEverything | HideDetails;
let stringOfContact(~levelOfDetail: includeDetails, c: contact) => ···;
let str = stringOfContact(~levelOfDetail=ShowEverything, myContact);

Usando a variante includeDetails tem duas vantagens:

  • É evidente o que o parâmetro significa.
  • É fácil adicionar mais modos adiante.

1.2 Associando dados variantes á valores

Às vezes, você quer usar variantes como valores em chaves para procurar algum tipo de dado. Uma maneira de fazer isso é através de uma função que mapeia valores variantes para dados:

type color = Red | Orange | Yellow | Green | Blue | Purple;
let stringOfColor(c: color) =>
switch c {
| Red => "Red"
| Orange => "Orange"
| Yellow => "Yellow"
| Green => "Green"
| Blue => "Blue"
| Purple => "Purple"
};

Esta técnica tem uma desvantagem: ela cria redundância, especialmente se você deseja associar várias partes de dados com o mesmo valor de variante. Exploraremos alternativas em um artigo futuro.

2. Variantes como estruturas de dados

Cada construtor também pode manter um ou mais valores. Esses valores são identificados pela posição. Ou seja, construtores individuais são semelhantes às tuplas. O código a seguir demonstra esse recurso.

type point = Point(float, float);
type shape =
| Rectangle(point, point)
| Circle(point, float);

O tipo point é uma variante com um único Construtor. Ele contém dois números de ponto flutuante. O shape é outra variante. Ou é:

  • um Rectangle definido por dois pontos ou
  • um Circle definido por um centro e um raio.

Com vários parâmetros no Construtor, parâmetros posicionais (e não rotulados) tornam-se um problema — precisamos descrever em outro lugar quais suas funções. Registros, Records, são uma alternativa neste caso (iremos abordar em um artigo futuro).

Isto é como você usar os construtores:

# let bottomLeft = Point(-1.0, -2.0);
let bottomLeft: point = Point(-1., -2.);
# let topRight = Point(7.0, 6.0);
let topRight: point = Point(7., 6.);
# let circ = Circle(topRight, 5.0);
let circ: shape = Circle(Point(7., 6.), 5.);
# let rect = Rectangle(bottomLeft, topRight);
let rect: shape = Rectangle(Point(-1., -2.), Point(7., 6.));

Devido a cada nome de construtor ser único, ReasonML pode facilmente inferir os tipos.

Se construtores armazenam dados, pattern matching através do operador switch é ainda mais conveniente, porque ele também permite que você acesse os dados:

let pi = 4.0 *. atan(1.0);let computeArea = (s: shape) =>
switch s {
| Rectangle(Point(x1, y1), Point(x2, y2)) =>
let width = abs_float(x2 -. x1);
let height = abs_float(y2 -. y1);
width *. height;
| Circle(_, radius) => pi *. (radius ** 2.0)
};

Vamos usar o computeArea, continuando nossa sessão interativa rtop anterior:

# computeArea(circ);
- : float = 78.5398163397448315
# computeArea(rect);
- : float = 64.

3. Estruturas de dados auto-recursivo através de variantes

Você também pode definir estruturas de dados recursivas através de variantes. Por exemplo, árvores binárias cujos nós contêm números inteiros:

type intTree =
| Empty
| Node(int, intTree, intTree);

Os valores de intTree são construídos da seguinte maneira:

let myIntTree = Node(1,
Node(2, Empty, Empty),
Node(3,
Node(4, Empty, Empty),
Empty
)
);

myIntTree se parece com: 1 tem dois filho nós, 2 e 3. 2 tem dois filhos vazios de nós. Etc.

1
2
X
X
3
4
X
X
X

3.1 Processamento de estruturas de dados auto-recursivo através de recursão

Para demonstrar o processamento de estruturas de dados auto-recursivo, vamos implementar uma função computeSum, que calcula a soma dos inteiros armazenados em nós.

let rec computeSum = (t: intTree) =>
switch t {
| Empty => 0
| Node(i, leftTree, rightTree) =>
i + computeSum(leftTree) + computeSum(rightTree)
};
computeSum(myIntTree); /* 10 */

Este tipo de recursão é comum quando trabalhamos com tipos de variantes:

  1. Um conjunto limitado de construtores é usado para criar dados. Neste caso: Empty e Node().
  2. Os mesmos construtores são usados como padrões para processar os dados.

Isso garante a possibilidade de lidarmos com qualquer dado que seja passado para nós, enquanto ele mantenha o tipo intTree. ReasonML irá nos ajuda a verificar se estamos cobrindo a intTree exaustivamente no switch (cobrindo todos os casos). Isso nos protege da situação de “esquecer” algum caso. Para ilustrar, vamos supor que nós esquecemos de cobrir Empty e escrevemos computeSum dessa maneira:

let rec computeSum = (t: intTree) =>
switch t {
/* Faltando: Empty */
| Node(i, leftTree, rightTree) =>
i + computeSum(leftTree) + computeSum(rightTree)
};

Teremos o seguinte aviso:

Warning: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
Empty

Como mencionado na postagem sobre funções, apresentar casos padrão (catch-all), significa que você perde essa proteção. Uma recomendação é evitá-las sempre que possível.

4. Estruturas de dados mutuamente recursivas através de variantes

Você se lembra que, com let, tivemos que usar let rec para utilizar recursão:

  • Uma definição única autorecursiva foi feita via let rec.
  • Várias definições mutuamente recursivas foram feitas via let rec e conectadas via and.

type é implicitamente rec. Isso nos permitia criar definições auto-recursivas tais como intTree. Para definições mutuamente recursivas, também precisamos ligar essas definições através de and. O exemplo a seguir define um novo intTree, mas desta vez com um tipo separado para os nós.

type intTree =
| Empty
| IntTreeNode(intNode)
and intNode =
| IntNode(int, intTree, intTree);

intTree e intNode são mutuamente recursivos, é por isso que eles precisam ser definidos dentro da mesma declaração type , separada através de and.

5. Variantes parametrizadas

Vamos lembrar a definição original da intTree:

type intTree =
| Empty
| Node(int, intTree, intTree);

Como podemos transformar essa definição em uma definição genérica para árvores cujos nós podem conter qualquer tipo de valor?

Para isso, precisamos introduzir uma variável para o tipo de conteúdo do Node. Tipos de variáveis são prefixados com apóstrofos em ReasonML. Por exemplo: 'a. Portanto, uma árvore genérica se parece com:

type tree('a) =
| Empty
| Node('a, tree('a), tree('a));

Precisamos notar duas coisas aqui. Em primeiro lugar, o conteúdo de um nó, que anteriormente tinha o tipo int, agora tem o tipo 'a. Em segundo lugar, a variável do tipo 'a tornou-se um parâmetro do tipo tree. Node passa esse parâmetro para suas subárvores. Ou seja, podemos escolher um tipo de valor de nó diferente para cada árvore, mas dentro de uma árvore, todos os valores dos nós devem ter o mesmo tipo.

Agora, podemos definir um tipo para intTree através de um alias de tipo, fornecendo o parâmetro de tipo da tree:

type intTree = tree(int);

Vamos usar a tree para criar uma árvore de cadeias de caracteres:

let myStrTree = Node("a",
Node("b", Empty, Empty),
Node("c",
Node("d", Empty, Empty),
Empty
)
);

Devido a inferência de tipo, você não precisa fornecer um parâmetro de tipo. ReasonML automaticamente infere que myStrTree tem o tipo tree(string). A seguinte função genérica imprime qualquer tipo de árvore:

/**
* @param ~indent Valor de indentação da (sub)árove.
* @param ~stringOfValue Converte os valores de nó em string.
* @param t A árvore para converter em string.
*/
let rec stringOfTree = (~indent=0, ~stringOfValue: 'a => string, t: tree('a)) => {
let indentStr = String.make(indent*2, ' ');
switch t {
| Empty => indentStr ++ "X" ++ "\n"
| Node(x, leftTree, rightTree) =>
indentStr ++ stringOfValue(x) ++ "\n" ++
stringOfTree(~indent=indent+1, ~stringOfValue, leftTree) ++
stringOfTree(~indent=indent+1, ~stringOfValue, rightTree)
};
};

Essa função usa recursão para iterar sobre os nós do parâmetro t. Dado que a stringOfTree trabalha com tipos arbitrários 'a, precisamos de uma função de tipo específico para converter valores de tipo 'a para strings. É para isso que serve o parâmetro ~stringOfValue.

É assim que nós podemos imprimir nossa myStrTree:

# print_string(stringOfTree(~stringOfValue=x=>x, myStrTree));
a
b
X
X
c
d
X
X
X

6. Algumas variantes padrões que são úteis

Vou mostrar brevemente, duas variantes padrões amplamente usadas. Em um artigo futuro iremos falar delas.

6.1 Use option('a) para valores opcionais

Em muitas linguagens orientadas a objeto, uma variável de tipo string significa que a variável pode ser null ou um valor de sequência de caracteres. Tipos que incluem null são chamados de anulável. Tipos anuláveis são problemáticos, pois é fácil de trabalhar com seus valores, se esquecendo de lidar com null. Inesperadamente, um null aparece, e a infame exceção de ponteiros null é criada.

No ReasonML, tipos nunca são anuláveis. Em vez disso, valores potencialmente ausentes são manipulados através da variante parametrizada:

type option('a) =
| None
| Some('a);

option obriga a considerar sempre o caso None .

O suporte de option em ReasonML é mínimo. A definição desta variante é parte da língua, mas a biblioteca padrão ainda não tem funções utilitárias para trabalhar com valores opcionais. Até ela ser criada, você pode usar Js.Option do BuckleScript.

6.2 Use result('a) para o tratamento de erros

result é uma outra variante padrão para manipulação de erros em OCaml:

type result('good, 'bad) =
| Ok('good)
| Error('bad);

Novamente, a biblioteca padrão do ReasonML ainda não oferece suporte, mas você pode usar Js.Result do BuckleScript.

6.3 Exemplo: calculando expressões de inteiros

Trabalhar com árvores é um dos pontos fortes das linguagens do estilo ML. É por isso que elas são frequentemente utilizadas para programas envolvendo árvores de sintaxe (intérpretes, compiladores, etc.). Por exemplo, o verificador de sintaxe, Flow, criado pelo Facebook é escrito em OCaml.

Portanto, como um exemplo final, vamos implementar um avaliador para expressões simples de números inteiro.

A seguir está uma estrutura de dados para expressões de números inteiros.

type expression =
| Plus(expression, expression)
| Minus(expression, expression)
| Times(expression, expression)
| DividedBy(expression, expression)
| Literal(int);

Abaixo, uma expressão codificada com esta variante:

/* (3 - (16 / (6 + 2)) */
let expr =
Minus(
Literal(3),
DividedBy(
Literal(16),
Plus(
Literal(6),
Literal(2)
)
)
);

E finalmente, esta é a função que avalia as expressões de números inteiros.

let rec eval(e: expression) =
switch e {
| Plus(e1, e2) => eval(e1) + eval(e2)
| Minus(e1, e2) => eval(e1) - eval(e2)
| Times(e1, e2) => eval(e1) * eval(e2)
| DividedBy(e1, e2) => eval(e1) / eval(e2)
| Literal(i) => i
};
eval(expr); /* 1 */

Créditos

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store