Como você codifica os tipos de dados algébricos em uma linguagem C # ou Java?

52

Existem alguns problemas que são facilmente resolvidos pelos Tipos de Dados Algébricos, por exemplo, um tipo de Lista pode ser expresso de forma muito sucinta como:

data ConsList a = Empty | ConsCell a (ConsList a)

consmap f Empty          = Empty
consmap f (ConsCell a b) = ConsCell (f a) (consmap f b)

l = ConsCell 1 (ConsCell 2 (ConsCell 3 Empty))
consmap (+1) l

Este exemplo específico está em Haskell, mas seria semelhante em outras linguagens com suporte nativo para Tipos de dados algébricos.

Acontece que há um mapeamento óbvio para a subtipagem no estilo OO: o tipo de dados se torna uma classe base abstrata e todo construtor de dados se torna uma subclasse concreta. Aqui está um exemplo no Scala:

sealed abstract class ConsList[+T] {
  def map[U](f: T => U): ConsList[U]
}

object Empty extends ConsList[Nothing] {
  override def map[U](f: Nothing => U) = this
}

final class ConsCell[T](first: T, rest: ConsList[T]) extends ConsList[T] {
  override def map[U](f: T => U) = new ConsCell(f(first), rest.map(f))
}

val l = (new ConsCell(1, new ConsCell(2, new ConsCell(3, Empty)))
l.map(1+)

A única coisa necessária além da subclasse ingênua é uma forma de selar as classes, ou seja, uma maneira de tornar impossível adicionar subclasses a uma hierarquia.

Como você abordaria esse problema em uma linguagem como C # ou Java? Os dois obstáculos que encontrei ao tentar usar tipos de dados algébricos em C # foram:

  • Não consegui descobrir em que o tipo de fundo é chamado em C # (ou seja, não consegui descobrir o que colocar em class Empty : ConsList< ??? > )
  • Eu não consegui descobrir uma maneira de selar ConsList para que nenhuma subclasse possa ser adicionada à hierarquia

Qual seria a maneira mais idiomática de implementar tipos de dados algébricos em C # e / ou Java? Ou, se não for possível, qual seria o substituto idiomático?

    
por Jörg W Mittag 07.08.2012 / 08:38
fonte

7 respostas

38

Existe uma maneira fácil, mas clichê, para selar classes em Java. Você coloca um construtor privado na classe base e faz subclasses de classes internas dele.

public abstract class List<A> {

   // private constructor is uncallable by any sublclasses except inner classes
   private List() {
   }

   public static final class Nil<A> extends List<A> {
   }

   public static final class Cons<A> extends List<A> {
      public final A head;
      public final List<A> tail;

      public Cons(A head, List<A> tail) {
         this.head = head;
         this.tail = tail;
      }
   }
}

Aproxime-se de um padrão de visitantes para envio.

Meu projeto jADT: O DataTypes Algébrico Java gera todo esse clichê para você link

    
por 07.09.2012 / 00:34
fonte
19

Você pode conseguir isso usando o padrão de visitante , que complementará a correspondência de padrões. Por exemplo

data List a = Nil | Cons { value :: a, sublist :: List a }

pode ser escrito em Java como

interface List<T> {
    public <R> R accept(Visitor<T,R> visitor);

    public static interface Visitor<T,R> {
        public R visitNil();
        public R visitCons(T value, List<T> sublist);
    }
}

final class Nil<T> implements List<T> {
    public Nil() { }

    public <R> R accept(Visitor<T,R> visitor) {
        return visitor.visitNil();
    }
}
final class Cons<T> implements List<T> {
    public final T value;
    public final List<T> sublist;

    public Cons(T value, List<T> sublist) {
        this.value = value;
        this.sublist = sublist;
    }

    public <R> R accept(Visitor<T,R> visitor) {
        return visitor.visitCons(value, sublist);
    }
}

A selagem é obtida pela classe Visitor . Cada um de seus métodos declara como desconstruir uma das subclasses. Você poderia adicionar mais subclasses, mas teria que implementar accept e chamando um dos métodos visit... , por isso teria que se comportar como Cons ou como Nil .

    
por 07.08.2012 / 15:08
fonte
13

Se você abusar de parâmetros nomeados C # (introduzidos no C # 4.0), você pode fazer tipos de dados algébricos que são fáceis de combinar:

Either<string, string> e = MonthName(2);

// Match with no return value.
e.Match
(
    Left: err => { Console.WriteLine("Could not convert month: {0}", err); },
    Right: name => { Console.WriteLine("The month is {0}", name); }
);

// Match with a return value.
string monthName =
    e.Match
    (
        Left: err => null,
        Right: name => name
    );
Console.WriteLine("monthName: {0}", monthName);

Aqui está a implementação da classe Either :

public abstract class Either<L, R>
{
    // Subclass implementation calls the appropriate continuation.
    public abstract T Match<T>(Func<L, T> Left, Func<R, T> Right);

    // Convenience wrapper for when the caller doesn't want to return a value
    // from the match expression.
    public void Match(Action<L> Left, Action<R> Right)
    {
        this.Match<int>(
            Left: x => { Left(x); return 0; },
            Right: x => { Right(x); return 0; }
        );
    }
}

public class Left<L, R> : Either<L, R>
{
    L Value {get; set;}

    public Left(L Value)
    {
        this.Value = Value;
    }

    public override T Match<T>(Func<L, T> Left, Func<R, T> Right)
    {
        return Left(Value);
    }
}

public class Right<L, R> : Either<L, R>
{
    R Value { get; set; }

    public Right(R Value)
    {
        this.Value = Value;
    }

    public override T Match<T>(Func<L, T> Left, Func<R, T> Right)
    {
        return Right(Value);
    }
}
    
por 07.02.2014 / 16:57
fonte
5

Em C #, você não pode ter esse tipo Empty , porque, devido à reificação, os tipos base são diferentes para diferentes tipos de membros. Você só pode ter Empty<T> ; não é tão útil.

Em Java, você pode ter Empty : ConsList devido ao tipo de apagamento, mas não tenho certeza se o verificador de tipos não gritaria em algum lugar.

No entanto, como os dois idiomas têm null , você pode pensar em todos seus tipos de referência como "Whatever | Null". Então você só usaria o null como o "Vazio" para evitar ter que especificar o que deriva.

    
por 07.08.2012 / 09:17
fonte
3

The only thing needed beyond naive subclassing is a way to seal classes, i.e. a way to make it impossible to add subclasses to a hierarchy.

Em Java você não pode. Mas você pode declarar a classe base como pacote private, o que significa que todas as subclasses diretas devem pertencer ao mesmo pacote da classe base. Se você declarar as subclasses como finais, elas não poderão mais ser subclassificadas.

Eu não sei se isso resolveria seu verdadeiro problema ...

    
por 07.08.2012 / 09:31
fonte
3

O tipo de dados ConsList<A> pode ser representado como uma interface. A interface expõe um único método deconstruct que permite "desconstruir" um valor desse tipo - isto é, manipular cada um dos possíveis construtores. As chamadas para um método deconstruct são análogas a uma forma case of em Haskell ou ML.

interface ConsList<A> {
  <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  );
}

O método deconstruct recebe uma função de "retorno de chamada" para cada construtor no ADT. No nosso caso, é necessária uma função para o caso de lista vazia e outra função para o caso "cons cell".

Cada função de retorno de chamada aceita como argumentos os valores que são aceitos pelo construtor. Portanto, o caso "lista vazia" não recebe argumentos, mas o caso "cons cell" recebe dois argumentos: o cabeçalho e o final da lista.

Podemos codificar esses "vários argumentos" usando Tuple classes ou usando currying. Neste exemplo, escolhi usar uma simples classe Pair .

A interface é implementada uma vez para cada construtor. Primeiro, temos a implementação da "lista vazia". A implementação deconstruct simplesmente chama a função de retorno de chamada emptyCase .

class ConsListEmpty<A> implements ConsList<A> {
  public ConsListEmpty() {}

  public <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  ) {
    return emptyCase.apply(new Unit());
  }
}

Em seguida, implementamos o caso "cons cell" de maneira semelhante. Desta vez, a classe tem propriedades: a cabeça e cauda da lista não vazia. Na implementação deconstruct , essas propriedades são passadas para a função de retorno de chamada consCase .

class ConsListConsCell<A> implements ConsList<A> {
  private A head;
  private ConsList<A> tail;

  public ConsListCons(A head, ConsList<A> tail) {
    this.head = head;
    this.tail = tail;
  }

  public <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  ) {
    return consCase.apply(new Pair<A,ConsList<A>>(this.head, this.tail));
  }
}

Aqui está um exemplo de como usar essa codificação de ADTs: podemos escrever uma função reduce que é a lista normal de dobras.

<T> T reduce(Function<Pair<T,A>,T> reducer, T initial, ConsList<T> l) {
  return l.deconstruct(
    ((unit) -> initial),
    ((t) -> reduce(reducer, reducer.apply(initial, t.v1), t.v2))
  );
}

Isso é análogo a essa implementação em Haskell:

reduce reducer initial l = case l of
  Empty -> initial
  Cons t_v1 t_v2  -> reduce reducer (reducer initial t_v1) t_v2
    
por 18.10.2015 / 17:01
fonte
2

The only thing needed beyond naive subclassing is a way to seal classes, i.e. a way to make it impossible to add subclasses to a hierarchy.

How would you approach this problem in a language like C# or Java?

Não há uma boa maneira de fazer isso, mas se você está disposto a viver com um hack horrível, então você pode adicionar um tipo de verificação explícita ao construtor da classe base abstrata. Em Java, isso seria algo como

protected ConsList() {
    Class<?> clazz = getClass();
    if (clazz != Empty.class && clazz != ConsCell.class) throw new Exception();
}

Em C # é mais complicado por causa dos genéricos reificados - a abordagem mais simples pode ser converter o tipo em uma string e mangle isso.

Observe que, em Java, até mesmo esse mecanismo pode, teoricamente, ser ignorado por alguém que realmente deseja através do modelo de serialização ou sun.misc.Unsafe .

    
por 07.08.2012 / 15:02
fonte