ReasonML: Records

Quando e como utilizar essa estrutura de dados

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

Nesse artigo iremos examinar como Records funcionam em ReasonML.

1. O que são Records?

Records são similares a Tuple: Ele tem um tamanho fixo e cada uma de suas partes pode ter um tipo diferente e ser acessado diretamente. Porém, enquanto as partes da Tuple (seus componentes) são acessados por posição, as partes de Record (seus chaves) são acessados através de nomes. Por padrão, Records são imutáveis.

2. Uso básico

2.1. Definindo tipos de Record

Antes de criar um Record, você precisa definir um tipo para ele. Por exemplo:

type point = {
x: int,
y: int, /* vírgula final é opcional */
}

No exemplo acima, definimos o Record de tipo point, que contém dois chaves, x e y. Os nomes de chaves devem começar em letras minúculas.

Dentro do mesmo escopo, dois Records não podem ter o mesmo nome de chave. O motivo para essa restrição é que os nomes de chaves são usados para determinar o tipo do Record. Para alcançar essa tarefa, cada nome de chave é associado a exatamente um tipo de Record.

É possível usar o mesmo nome de chave em mais de um Record, porém, a usabilidade sofre com isso: O último tipo Record com o nome dessa chave “ganha” a possibilidade de inferência de tipos. Como consequência, usando os outros tipos Records, se torna complicado. Eu, particularmente, pretendo que não é possível reutilizar nome de chaves.

Nós iremos ver, mais há frente, como contornar esse problema.

Aninhando tipos de Records

É possível aninhar tipos de Records? Por exemplo, podemos fazer o seguinte?

type t = { a: int, b: { c: int }};

Não, não podemos! Nós iremos receber um erro de sintaxe. Devemos definir t da seguinte maneira:

type b = { c: int };
type t = { a: int, b: b };

Como temos b: b, o nome de chave tem o mesmo nome do seu valor, nós podemos abreviar essa declaração. Isso é chamado de punição (punning):

type t = { a: int, b };

2.2. Criando Records do zero

Você pode criá-los assim:

# let pt1 = { x: 12, y: -2 };
let pt1: point = {x: 12, y: -2};

Perceba que o nome de chaves foi utilizado para inferir que pt1 tem o tipo de point.

Punição funciona aqui também:

let x = 7;
let y = 8;
let pt2 = {x, y};
/* Same as: { x: x, y: y } */

2.3. Acessando os valores das chaves

Os valores das chaves podem ser acessados pelo operador . (ponto):

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};
# pt.x;
- : int = 1
# pt.y;
- : int = 2

2.4. Atualizações não destrutivas de Records

Records são imutáveis. Para mudar o valor das chaves de um Record r, você precisa criar um novo record s. No exemplo abaixo, a chave s.f terá um novo valor e todos as outras chaves de sterão o mesmo valor que r. Você pode usar a seguinte sintaxe:

let s = {...r, f: newValue}

Os três pontos ..., são chamados de spread operator. Eles, obrigatoriamente, devem ser declarados primeiro e podem ser usados no máximo uma única vez. Porém, você pode atualizar mais de uma chave (e não apenas uma única chave f).

Esse é um exemplo de como usar o spread operator:

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};
# let pt' = {...pt, y: 3};
let pt': point = {x: 1, y: 3};

2.5. Pattern matching

Os mecanismos padrões de pattern matching, também funcionam com Records:

let isOrig = (pt: point) =>
switch pt {
| {x: 0, y: 0} => true
| _ => false
};

E utilizando desestruturação com let:

# let pt = {x: 1, y: 2};
let pt: point = {x: 1, y: 2};
# let {x: xCoord} = pt;
let xCoord: int = 1;

Você pode usar punição (punning):

# let {x} = pt;
let x: int = 1;

Desestruturação de parâmetros também funciona:

# let getX = ({x}) => x;
let getX: (point) => int = <fun>;
# getX(pt);
- : int = 1

Aviso de chaves ausentes

Durante pattern matching, você pode omitir todos as chaves da qual você não está interessado, por exemplo:

type point = {
x: int,
y: int,
};
let getX = ({x}) => x; /* NÃO FAÇA ISSO! */

Para getX(), nós não estamos interessados na chave y e só mencionamos a chave x. Porém, é uma boa prática ser explícito sobre omitir chaves:

let getX = ({x, _}) => x;

O sublinhado depois de x diz ao ReasonML: “nós vamos ignorar todos as chaves restantes”.

Por que é melhor ser explícito? Porque agora você pode permitir que ReasonML te avise sobre nome de chaves ausentes, não se esqueça de adicionar o seguinte ao seu bsconfig.json:

"warnings": {
"number": "+R"
}

A versão inicial aciona o seguinte aviso:

Warning number 9
4 │ };
5 │
6 │ let getX = ({x}) => x;
the following labels are not bound in this record pattern:
y
Either bind these labels explicitly or add '; _' to the pattern.

Eu recomendo você ser ainda mais explícito e tornar chaves ausentes em erros (impedindo a compilação):

"warnings": {
"number": "+R",
"error": "+R"
}

Você pode consultar a documentação do BuckleScript para mais informações de como configurar alertas.

Verificar chaves ausentes é especialmente importante para trechos de códigos que usam todas as chaves:

let string_of_point = ({x, y}: point) =>
"(" ++ string_of_int(x) ++ ", "
++ string_of_int(y) ++ ")";
string_of_point({x:1, y:2});
/* "(1, 2)" */

Vamos supor que queremos adicionar uma nova chave, como z. Você irá querer que ReasonML te avise sobre _string_of_point_, para que você possa atualizar seu código.

2.6. Recursividade com tipos Record

Variantes foram os primeiros exemplos que vimos e utilizamos recursividade em definição de tipos. Você também pode utilizar Records com definições recursivas:

type intTree =
| Empty
| Node(intTreeNode)
and intTreeNode = {
value: int,
left: intTree,
right: intTree,
};

A variante recursiva intTree, recursivamente depende da definição do Record de tipo intTreeNode. Você pode criar elementos do tipo intTree da seguinte maneira:

let t = Node({
value: 1,
left: Node({
value: 2,
left: Empty,
right: Node({
value: 3,
left: Empty,
right: Empty,
}),
}),
right: Empty,
});

2.7. Tipos de Records parametrizados

Em ReasonML, tipos podem ser parametrizados por variáveis de tipo. Você pode usar essas variáveis de tipo ao definir tipos de Record. Por exemplo, se quisermos que nossa árvore de tipo contenha valores arbitrários, não apenas inteiros, nós podemos marcar o valor da chave valuecomo polimórfico (linha A):

type tree('a) =
| Empty
| Node(treeNode('a))
and treeNode('a) = {
value: 'a, /* A */
left: tree('a),
right: tree('a),
};

3. Records em outros módulos

Cada Record é definido em um escopo (ex: um módulo). Suas chaves vivem/são armazenadas no escopo mais alto daquele escopo. Enquanto isso ajuda na inferência de tipos, por outro lado, torna o uso daquele nome de chave mais complicado do que em outras linguagens. Vamos ver vários casos de como o mecanismo dos Records se comportam, colocando point em outro módulo, chamado de M:

module M = {
type point = {
x: int,
y: int,
};
};

3.1. Criando Records em outros módulos

Se tentarmos criar um tipo de Record chamado point, esperando que ele esteja no mesmo escopo, teremos o seguinte erro:

let pt = {x: 3, y: 2};
/* Error: Unbound record field x */

O motivo é que x e y não existem como nomes no escopo atual, eles existem apenas dentro do módulo M.

Uma maneira de concertar isso é qualificando, ao menos, uma das chaves:

let pt1 = {M.x: 3, M.y: 2}; /* OK */
let pt2 = {M.x: 3, y: 2}; /* OK */
let pt3 = {x: 3, M.y: 2}; /* OK */

Um outro modo de contornar isso, é qualificando todo o Record. É interessante como ReasonML reporta o tipo inferido — ambos o tipo e a primeira chave são qualificados:

# let pt4 = M.{x: 3, y: 2};
let pt4: M.point = {M.x: 3, y: 2};

Por último, você pode abrir o módulo M no escopo atual e importar x e y:

open M;
let pt = {x: 3, y: 2};

3.2. Acessando chaves de outros módulos

Se você não abrir o módulo M, você não pode usar chaves não-qualificadas para acessar seus valores:

let pt = M.{x: 3, y: 2};
print_int(pt.x);
/*
Warning 40: x was selected from type M.point.
It is not visible in the current scope, and will not
be selected if the type becomes unknown.
*/

O alerta irá embora se você qualificar a chave x:

print_int(pt.M.x); /* OK */

Abrir o módulo M localmente, também funciona:

M.(print_int(pt.x));
print_int(M.(pt.x));

3.3. Pattern Matching e Records de outros módulos

Com pattern matching, você irá encontrar os mesmos problemas de quando você tenta acessar as chaves normalmente — você não pode usar os nomes das chaves de point sem qualifica-los:

# let {x, _} = pt;
Error: Unbound record field x

Ao qualificar x:

# let {M.x, _} = pt;
let x: int = 3;

Porém, qualificar pattern matching não funciona:

# let M.{x, _} = pt;
Error: Syntax error

Nós podemos abrir localmente o módulo M para a ligação de let. Porém, isso não é uma expressão e não nos previne de envolvê-los em parênteses. Precisamos, mais uma vez, envolver todo o trecho em outro bloco de código utilizando chaves.

M.({
let {x, _} = pt;
···
});

3.4. Usando o mesmo nome de chave em múltiplos Records

No início desse artigo, eu comentei que era possível utilizar o mesmo nome de chave em múltiplos Records. O truque para isso, é colocar cada Record em um módulo diferente. Por exemplo, aqui temos dois tipos de Record, _ Person.t_ e Plant.t, ambos com a chave name. Porém, cada um morando em um módulo diferente, os nomes não irão colidir, ou seja, não é um problema:

module Person = {
type t = { name: string, age: int };
};
module Plant = {
type t = { name: string, edible: bool };
};

4. FAQ: Records

4.1. Existe alguma maneira de especificar o nome de chave dinamicamente?

Em JavaScript, existem duas possibilidades para acessar chaves (chamadas de propriedadesem JavaScript):

// Nome de propriedade estática 
// (previsível em tempo de compilação)
console.log(obj.prop);
function f(obj, fieldName) {
// Nome de propriedade dinâmica
// (previsível em tempo de execução)
console.log(obj[fieldName]);
}

Em ReasonML, nomes de chaves são sempre estáticas. Objetos JavaScript realizam dois papéis: Eles são ambos Records e Dicionários (Dictionary). Em ReasonML, você deve usar Records se precisar de um Record, ou Maps se precisar de um Dicionário.

⭐️ Créditos