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

Functors são mapeamentos de módulos para módulos. Esse artigo explica como eles funcionam e por que eles são úteis.

O código dos exemplos estão disponíveis no GitHub, no repositório reasonml-demo-functors.

Nota: Functors são um tópico avançado. Você pode começar com ReasonML sem saber o que eles são. Irei explicar o básico de como usá-los sempre que for necessário.

1. O que são Functors?

Functor é um termo na category theory em programação funcional, que se refere a um mapeamento entre categorias. No ReasonML, existem duas maneiras de ver um functor:

  • Uma função onde os parâmetros são módulos e o resultado é um módulo
  • Um módulo (o resultado) é parametrizado (configurado) via módulos (os parâmetros)

Você só pode definir um functor como um sub-módulo (ele não pode ser o elemento principal um arquivo). Mas isso não é um problema, porque você geralmente quer entregar um functor dentro de um módulo, estando próximo das interfaces para seus parâmetros e (opcionalmente) seu resultado.

A sintaxe dos functors é a seguinte:

module «F» = («ParamM1»: «ParamI1», ···): «ResultI» => {
«corpo do functor»
};

O functor F tem como parâmetros um ou mais módulos, ParamM1, etc. Esses módulos devem ser disponibilizados pelas interfaces, ParamI1 etc. O tipo de resultado ResultI (outra interface) é opcional.

O corpo de um functor tem a mesma sintaxe de um módulo normal, mas pode se referir aos parâmetros e seus conteúdos.

2. Meu primeiro Functor

Como nosso primeiro exemplo, definimos um functor Repetition.Make que exporta uma função repeat() e repete o seu parâmetro, uma string. O parâmetro que o functor recebe, configura com que frequência a string é repetida.

Antes de definir nosso functor, precisamos definir uma interface Repetition.Count para receber como seu parâmetro:

/* Repetition.re */

module type Count = {
let count: int;
};

E o functor ficaria:

/* Repetition.re */

...

module Make = (RC: Count) => {
let rec repeat = (~times=RC.count, str: string) =>
if (times <= 0) {
"";
} else {
str ++ repeat(~times=times-1, str);
};
};

Make é um functor: Seu parâmetro RC é um módulo, seu resultado (em chaves) também é um módulo.

repeat() é uma função ReasonML relativamente simples. O único aspecto novo é o valor padrão para ~times, que vem do módulo como parâmetro chamado RC.

Repetition.Make atualmente não define o tipo de seu resultado. Isso significa que ReasonML infere esse tipo. Se quisermos mais controle, podemos definir uma interface S para o resultado:

/* Repetition.re */

module type S = {
let repeat: (~times: int=?, string) => string;
};

Precisando apenas adicionar S à definição de Make:

module Make = (RC: Count): S => ···

Em seguida, usamos Repetition.Make em um arquivo separado, chamado de RepetitionMain.re. Primeiro, definimos um módulo para o parâmetro do functor:

/* RepetitionMain.re */

module CountThree: Repetition.Count = {
let count=3;
};

O tipo Repetition.Count não é necessário, desde que CountThree tenha exatamente a mesma estrutura daquela interface. Mas isso deixa claro imediatamente qual é o propósito de CountThree.

Agora estamos prontos para usar nosso functor Repetition.Make para criar um módulo:

/* RepetitionMain.re */

module RepetitionThree = Repetition.Make(CountThree);

As chamadas de RepetitionThree.repeat a seguir, funcionam conforme o esperado:

/* RepetitionMain.re */

let () = print_string(RepetitionThree.repeat("abc\n"));

/* Output:
abc
abc
abc
*/

Ao invés de definir o módulo CountThree separadamente, poderíamos também ter incluído:

module RepetitionThree = Repetition.Make({
let count=3;
});

A maneira como temos o módulo Repetition estruturado, é um padrão comum para o uso de functors. Temos as seguintes partes:

  • Make: nosso functor
  • Uma ou mais interfaces para os parâmetros de Make (no nosso caso, Count)
  • S: uma interface para o resultado de Make

O módulo Repetition empacota o functor Make e tudo mais o que ele precisa.

3. Functors para estrutura de dados

Um caso de uso comum para functors é implementar estruturas de dados:

  • Os parâmetros do functor especificam os elementos gerenciados pela estrutura de dados. Devido aos parâmetros serem módulos, além de especificar os tipos de elementos, você também pode ter funções auxiliares que a estrutura de dados pode precisar para gerenciá-los
  • O resultado do functor é um módulo feito sob medida para os elementos especificados

Por exemplo, a estrutura de dados Set (que veremos em mais detalhes) deve ser capaz de comparar seus elementos. Portanto, o functor para seus conjuntos possui um parâmetro com a seguinte interface:

module type OrderedType = {
type t;
let compare: (t, t) => int;
};

OrderedType.t é o tipo dos elementos do conjunto, OrderedType.compare é usado para comparar esses elementos. OrderedType.t é semelhante à variável ‘a de tipo polimórfico list(‘a).

Vamos implementar uma estrutura de dados muito simples: pares com componentes arbitrários que podem ser impressos (convertidos em string). Para imprimir pares, devemos poder imprimir seus componentes. É por isso que os componentes devem ser especificados através da interface a seguir.

/* PrintablePair1.re */

module type PrintableType = {
type t;
let print: t => string;
};

Mais uma vez usamos o nome Make para o functor que produz os módulos com as estruturas de dados reais:

/* PrintablePair1.re */

module Make = (Fst: PrintableType, Snd: PrintableType) => {
type t = (Fst.t, Snd.t);
let make = (f: Fst.t, s: Snd.t) => (f, s);
let print = ((f, s): t) =>
"(" ++ Fst.print(f) ++ ", " ++ Snd.print(s) ++ ")";
};

Este functor possui dois parâmetros: Fst especifica o primeiro componente de um par imprimível, Snd especifica o segundo componente.

Os módulos retornados por PrintablePair1.Make possuem as seguintes partes:

  • t é o tipo da estrutura de dados suportada por este functor. Observe como se refere aos parâmetros do functor Fst e Snd.
  • make é a função para criar valores do tipo t.
  • print é uma função para trabalhar com pares imprimíveis. Converte um par imprimível em uma string.

Vamos usar o functor PrintablePair1.Make para criar um par imprimível cujo primeiro componente é uma string e cujo segundo componente é um int.

Primeiro, precisamos definir os argumentos para o functor:

/* PrintablePair1Main.re */

module PrintableString = {
type t=string;
let print = (s: t) => s;
};
module PrintableInt = {
type t=int;
let print = (i: t) => string_of_int(i);
};

Em seguida, usamos o functor para criar um módulo PrintableSI:

/* PrintablePair1Main.re */

module PrintableSI = PrintablePair1.Make(PrintableString, PrintableInt);

Por fim, criamos e imprimimos um par:

/* PrintablePair1Main.re */

let () = PrintableSI.({
let pair = make("Jane", 53);
let str = print(pair);
print_string(str);
});

A implementação atual tem uma falha, podemos criar elementos do tipo t sem usar PrintableSI.make():

let pair = ("Jane", 53);
let str = print(pair);

Para evitar isso, precisamos deixar o Make.t abstrato, por meio de uma interface:

/* PrintablePair1Main.re */

module type S = (Fst: PrintableType, Snd: PrintableType) => {
type t;
let make: (Fst.t, Snd.t) => t;
let print: (t) => string;
};

É definimos Make para ter o tipo S:

module Make: S = ···

Nota: O tipo S é o tipo de todo o functor.

É mais comum definir uma interface apenas para o resultado de um functor (não para todo o functor, como fizemos antes). Dessa forma, você pode reutilizar essa interface para outros fins. Por exemplo, essa interface seria a seguinte:

/* PrintablePair2.re */

module type S = {
type fst;
type snd;
type t;
let make: (fst, snd) => t;
let print: (t) => string;
};

Agora não podemos mais nos referir aos parâmetros Fst e Snd do functor. Portanto, precisamos introduzir dois novos tipos fst, snd e precisamos definir o tipo de make(). Esta função anteriormente tinha o seguinte tipo:

let make: (Fst.t, Snd.t) => t;

Como nos conectamos fst e snd com Fst.t e Snd.t? Fazemos isso por meio das chamadas restrições de compartilhamento (sharing constraints), que são equações que modificam interfaces. Eles são usados ​​assim:

/* PrintablePair2.re */

module Make = (Fst: PrintableType, Snd: PrintableType)
: (S with type fst = Fst.t and type snd = Snd.t) => {
type fst = Fst.t;
type snd = Snd.t;
type t = (fst, snd);
let make = (f: fst, s: snd) => (f, s);
let print = ((f, s): t) =>
"(" ++ Fst.print(f) ++ ", " ++ Snd.print(s) ++ ")";
};

As duas equações a seguir estão compartilhando restrições:

S with type fst = Fst.t and type snd = Snd.t

As S with indica quais restrições mudam a interface S. Agora, a interface do resultado de Make é:

{
type fst = Fst.t;
type snd = Snd.t;
type t;
let make: (fst, snd) => t;
let print: (t) => string;
}

Há mais uma coisa que podemos melhorar: fst e snd são redundantes. Seria melhor se a interface de resultados se referisse a Fst.t e Snd.t diretamente (como aconteceu quando tínhamos uma interface para todo o functor). Isso é feito através de substituições destrutivas (destructive substitutions).

Substituições destrutivas funcionam muito como restrições de compartilhamento. Contudo:

  • Uma restrição de compartilhamento S with type t = u fornece mais informações para S.t.
  • Uma substituição destrutiva with type t := u, substitui todas as ocorrências de t dentro S com u.

Transformando Make para usarmos substituições destrutivas:

module Make = (Fst: PrintableType, Snd: PrintableType)
: (S with type fst := Fst.t and type snd := Snd.t) => {
type t = (Fst.t, Snd.t);
let make = (fst: Fst.t, snd: Snd.t) => (fst, snd);
let print = ((fst, snd): t) =>
"(" ++ Fst.print(fst) ++ ", " ++ Snd.print(snd) ++ ")";
};

As substituições destrutivas removem fst e snd de S. Portanto, não precisamos mais defini-las no corpo de Make e sempre podemos nos referir diretamente a Fst.t e Snd.t. Devido às substituições destrutivas, a definição dentro de Make corresponde ao que a interface requer. A assinatura do resultado Make agora é:

{
type t;
let make: (Fst.t, Snd.t) => t;
let print: (t) => string;
}

O módulo padrão Set para conjuntos de valores segue as convenções que já expliquei:

  • Set.Make é o functor que produz o módulo real para conjuntos de manipulação.
  • Set.OrderedType é a interface para o parâmetro de Make:
module type OrderedType = {
type t;
let compare: (t, t) => int;
};
  • Set.S é a interface para o resultado de Make.

É assim que você cria e usa um módulo de conjuntos de strings:

module StringSet = Set.Make({
type t = string;
let compare = Pervasives.compare;
});

let set1 = StringSet.(empty |> add("a") |> add("b") |> add("c"));
let set2 = StringSet.of_list(["b", "c", "d"]);

/* StringSet.elements(s) converts set s to list */
StringSet.(elements(diff(set1, set2)));
/* list(string) = ["a"] */

A biblioteca padrão do ReasonML vem com um módulo String que funciona como um parâmetro Set.Make, possuindo ambos String.t e String.compare. Portanto, poderíamos ter escrito também:

module StringSet = Set.Make(String);

4. Functors para estender módulos

Functors também podem ser usados ​​para estender módulos existentes com outras funcionalidades. Usado dessa maneira, eles são semelhantes a herança múltipla e mixins (subclasses abstratas).

Como exemplo, vamos estender um módulo existente que só pode adicionar elementos únicos a sua estrutura de dados com uma função para adicionar todos os elementos de uma dada estrutura de dados. AddAll é um functor para fazer isso:

module AddAll = (A: AddSingle) => {
let addAll = (~from: A.t, into: A.t) => {
A.fold((x, result) => A.add(x, result), from, into);
};
};

A função addAll() usa fold() para iterar sobre os elementos ~from e adicioná-los a into, um de cada vez. result está sempre ligado ao que já foi calculado (primeiro into, depois o resultado da adição do primeiro x em into etc).

Nesse caso, deixamos que o ReasonML deduza o tipo do resultado AddAll e não fornecemos uma interface para ele. Se fôssemos fazer isso, ele teria o nome S e o tipo abstrato t (para os parâmetros e o resultado de addAll). Seria usado assim:

module AddAll = (A: AddSingle)
: (S with type t := A.t) => {
···
};

Podemos deduzir quais necessidades addAll teria e coletar isso na interface AddSingle:

module type AddSingle = {
type elt;
type t; /* type of data structure */

let empty: t;
let fold: ((elt, 'r) => 'r, t, 'r) => 'r;
let add: (elt, t) => t;
};

Usando StringSet, que já definimos anteriormente, e criamos StringSetPlus:

module StringSetPlus = {
include StringSet;
include AddAll(StringSet);
};

O novo módulo StringSetPlus contém o módulo StringSet e o resultado da aplicação do functor AddAll ao módulo StringSet. Estamos fazendo várias heranças entre os módulos.

E StringSetPlus em ação:

let s1 = StringSetPlus.of_list(["a", "b", "c"]);
let s2 = StringSetPlus.of_list(["b", "c", "d"]);
StringSetPlus.(elements(addAll(~from=s2, s1)));
/* list(string) = ["a", "b", "c", "d"] */

No momento, precisamos combinar o módulo base StringSet com a extensão AddAll(StringSet) para criar StringSetPlus:

module StringSetPlus = {
include StringSet; /* base */
include AddAll(StringSet); /* extension */
};

E se pudéssemos criá-lo da seguinte maneira?

module StringSetPlus = AddAll(StringSet);

Existem duas razões pelas quais não fazemos isso.

Primeiro, queremos manter o parâmetro AddAll e a base do StringSetPlus separados. Nós precisaremos dessa separação quando usarmos AddAll para listas.

Em segundo lugar, não há como implementar AddAll para que ele amplie a quantidade de seus parâmetros. Em teoria, isso ficaria assim:

module AddAll = (A: AddSingle) => {
include A;
···
};

Na prática, incluindo A, apenas inclui o que está dentro da interface AddSingle. Isso geralmente não é suficiente.

Existem dois tipos de estruturas de dados fornecidos pela biblioteca padrão do ReasonML:

  • list e outros são implementados como tipos de dados polimórficos, cujos tipos de elementos são especificados através de variáveis ​​de tipo.
  • Set e outros são implementados via functors. Os tipos de elementos são especificados por meio de módulos.

Infelizmente, AddAll funciona melhor com estruturas de dados implementadas via functors. Se quisermos usá-lo para listas, devemos vincular a variável type list(‘a) a um tipo concreto (neste caso, string). Isso leva ao seguinte parâmetro para AddAll:

module AddSingleStringList
: AddSingle with type t = list(string) = {
type elt = string;
type t = list(elt);
let empty = [];
let fold = List.fold_right;
let add = (x: elt, coll: t) => [x, ...coll];
};

[Esta é a solução mais simples que eu poderia ter — sugestões para melhorias são bem-vindas]

Depois disso, é assim que criamos e usamos um módulo que suporta todas as operações de lista, além de addAll():

module StringListPlus = {
include List;
include AddAll(AddSingleStringList);
};

StringListPlus.addAll(~from=["a", "b"], ["c", "d"]);
/* list(string) = ["a", "b", "c", "d"] */

5. Material

⭐️ 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